diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 09b064dfed..206d5ccb17 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -228,11 +228,31 @@ In addition to a project settings file, a project's `.llxprt` directory can cont #### `sessionRetention` -- **`sessionRetention`** (object): - - **Description:** Settings for automatic session cleanup. +- **`sessionRetention.enabled`** (boolean): + - **Description:** Enable automatic session cleanup. Set to false to disable all janitorial mutations. + - **Default:** `true` + - **Requires restart:** No + +- **`sessionRetention.maxTotalSizeMB`** (number): + - **Description:** Machine-wide aggregate size limit for all session recordings and cold archives, in MiB. Defaults to 4096 (4 GiB). + - **Default:** `4096` + - **Requires restart:** No + +- **`sessionRetention.maxAge`** (string): + - **Description:** Maximum age of sessions to keep (e.g. "30d", "7d", "24h"). No default age limit. - **Default:** `undefined` - **Requires restart:** No +- **`sessionRetention.maxCount`** (number): + - **Description:** Maximum number of sessions to keep (most recent). No default count limit. + - **Default:** `undefined` + - **Requires restart:** No + +- **`sessionRetention.minRetention`** (string): + - **Description:** Minimum retention period (safety floor, defaults to "1d"). + - **Default:** `"1d"` + - **Requires restart:** No + #### `output` - **`output.format`** (enum): diff --git a/packages/cli/src/config/settings-schema/schema-core.ts b/packages/cli/src/config/settings-schema/schema-core.ts index ef1b328bfe..59a777ab04 100644 --- a/packages/cli/src/config/settings-schema/schema-core.ts +++ b/packages/cli/src/config/settings-schema/schema-core.ts @@ -322,7 +322,60 @@ export const CORE_SETTINGS_SCHEMA = { category: 'General', requiresRestart: false, default: undefined as SessionRetentionSettings | undefined, - description: 'Settings for automatic session cleanup.', + description: + 'Settings for automatic session cleanup. Cleanup is enabled by default with a machine-wide 4 GiB aggregate size budget.', + properties: { + enabled: { + type: 'boolean', + label: 'Enabled', + category: 'General', + requiresRestart: false, + default: true, + description: + 'Enable automatic session cleanup. Set to false to disable all janitorial mutations.', + showInDialog: true, + }, + maxTotalSizeMB: { + type: 'number', + label: 'Max Total Size (MiB)', + category: 'General', + requiresRestart: false, + default: 4096, + description: + 'Machine-wide aggregate size limit for all session recordings and cold archives, in MiB (see the default property).', + showInDialog: true, + }, + maxAge: { + type: 'string', + label: 'Max Age', + category: 'General', + requiresRestart: false, + default: undefined, + description: + 'Maximum age of sessions to keep (e.g. "30d", "7d", "24h"). No default age limit.', + showInDialog: true, + }, + maxCount: { + type: 'number', + label: 'Max Count', + category: 'General', + requiresRestart: false, + default: undefined, + description: + 'Maximum number of sessions to keep (most recent). No default count limit.', + showInDialog: true, + }, + minRetention: { + type: 'string', + label: 'Min Retention', + category: 'General', + requiresRestart: false, + default: '1d', + description: + 'Minimum retention period (safety floor, defaults to "1d").', + showInDialog: true, + }, + }, }, output: { type: 'object', diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index ad014f27fd..37e59032dc 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -112,7 +112,7 @@ export interface AccessibilitySettings { } export interface SessionRetentionSettings { - /** Enable automatic session cleanup */ + /** Enable automatic session cleanup (default-on when unset). */ enabled?: boolean; /** Maximum age of sessions to keep (e.g., "30d", "7d", "24h", "1w") */ @@ -123,6 +123,9 @@ export interface SessionRetentionSettings { /** Minimum retention period (safety limit, defaults to "1d") */ minRetention?: string; + + /** Machine-wide aggregate size limit in MiB (defaults to 4096 = 4 GiB). */ + maxTotalSizeMB?: number; } export interface SettingsError { diff --git a/packages/cli/src/ui/components/AuthDialog.test.tsx b/packages/cli/src/ui/components/AuthDialog.test.tsx index 8b6a079216..a7a5791a82 100644 --- a/packages/cli/src/ui/components/AuthDialog.test.tsx +++ b/packages/cli/src/ui/components/AuthDialog.test.tsx @@ -218,10 +218,10 @@ describe('AuthDialog', () => { // whose outcome depends on machine load. await waitFor(() => { expect(mockToggleOAuthEnabled).toHaveBeenCalledWith('claudecode'); + expect(lastFrame()).toContain('[ON]'); }); expect(mockAuthenticate).not.toHaveBeenCalled(); expect(onSelect).not.toHaveBeenCalled(); - expect(lastFrame()).toContain('[ON]'); unmount(); }); diff --git a/packages/cli/src/utils/sessionCleanup-test-helpers.ts b/packages/cli/src/utils/sessionCleanup-test-helpers.ts deleted file mode 100644 index 3bf37ae0d1..0000000000 --- a/packages/cli/src/utils/sessionCleanup-test-helpers.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { automock } from '@vybestack/llxprt-code-test-utils'; -import { vi, type Mock } from 'bun:test'; -import * as fs from 'node:fs/promises'; -import { type Config } from '@vybestack/llxprt-code-core'; -import { SESSION_FILE_PREFIX } from '@vybestack/llxprt-code-storage'; -import { type SessionInfo, getAllSessionFiles } from './sessionUtils.js'; - -const realPromisesModule = { ...(await import('fs/promises')) }; - -void vi.mock('fs/promises', () => automock(realPromisesModule)); -void vi.mock('./sessionUtils.js', () => ({ - getAllSessionFiles: vi.fn(), -})); - -/** - * Bun ships no deep-mock type, so the members each suite actually drives are - * named explicitly and given Bun's Mock signature. - */ -type MockedMembers = { - [P in K]: T[P] extends (...args: never[]) => unknown ? Mock : T[P]; -}; - -export const mockFs = fs as unknown as MockedMembers< - typeof fs, - 'access' | 'readFile' | 'unlink' ->; -export const mockGetAllSessionFiles = getAllSessionFiles as Mock< - typeof getAllSessionFiles ->; - -export type { Config, SessionInfo }; - -export function createMockConfig(overrides: Partial = {}): Config { - return { - storage: { - getProjectTempDir: vi.fn().mockReturnValue('/tmp/test-project'), - }, - getSessionId: vi.fn().mockReturnValue('current123'), - getDebugMode: vi.fn().mockReturnValue(false), - initialize: vi.fn().mockResolvedValue(undefined), - ...overrides, - } as unknown as Config; -} - -export function createTestSessions(): SessionInfo[] { - const now = new Date(); - const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); - const twoWeeksAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); - const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); - - return [ - { - id: 'current123', - fileName: `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12.json`, - lastUpdated: now.toISOString(), - isCurrentSession: true, - }, - { - id: 'recent456', - fileName: `${SESSION_FILE_PREFIX}2025-01-18T15-45-00-recent45.json`, - lastUpdated: oneWeekAgo.toISOString(), - isCurrentSession: false, - }, - { - id: 'old789abc', - fileName: `${SESSION_FILE_PREFIX}2025-01-10T09-15-00-old789ab.json`, - lastUpdated: twoWeeksAgo.toISOString(), - isCurrentSession: false, - }, - { - id: 'ancient12', - fileName: `${SESSION_FILE_PREFIX}2024-12-25T12-00-00-ancient1.json`, - lastUpdated: oneMonthAgo.toISOString(), - isCurrentSession: false, - }, - ]; -} diff --git a/packages/cli/src/utils/sessionCleanup.boundary.test.ts b/packages/cli/src/utils/sessionCleanup.boundary.test.ts new file mode 100644 index 0000000000..d167c6dda7 --- /dev/null +++ b/packages/cli/src/utils/sessionCleanup.boundary.test.ts @@ -0,0 +1,247 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * CLI-boundary behavioral test for session cleanup (Item 9). + * + * Proves that the CLI entry point (`cleanupExpiredSessions`) correctly passes + * the machine-global temp root and resolves partial/default `sessionRetention` + * settings through the full CLI→core pipeline, using real temporary + * filesystems — no filesystem mock theater. + * + * The `globalTempDirOverride` parameter (added as a narrow testability + * boundary to `cleanupExpiredSessions`) allows injecting a real temp + * directory so the test does not affect the machine's real global temp root. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { Config } from '@vybestack/llxprt-code-core'; +import { DEFAULT_MAX_TOTAL_SIZE_MB } from '@vybestack/llxprt-code-core/recording/janitor/index.js'; +import type { Settings } from '../config/settings.js'; +import { cleanupExpiredSessions } from './sessionCleanup.js'; + +async function makeTempDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'cli-cleanup-boundary-')); +} + +function validHash64(): string { + return crypto.randomUUID().replace(/-/g, '').repeat(2).slice(0, 64); +} + +/** Minimal Config stub providing only the methods cleanupExpiredSessions uses. */ +function createMinimalConfig( + sessionId: string, + debugMode = false, +): Pick { + return { + getSessionId: () => sessionId, + getDebugMode: () => debugMode, + }; +} + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +/** + * Create a real old session file under a project-hash chats directory. + * Returns the file path, session ID, and project hash for assertions. + */ +async function createOldSession( + tempDir: string, + ageDays = 5, +): Promise<{ filePath: string; sessionId: string; hash: string }> { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const sessionId = 'session-' + crypto.randomUUID(); + const startTime = new Date( + Date.now() - ageDays * 24 * 60 * 60 * 1000, + ).toISOString(); + const payload = JSON.stringify({ + v: 1, + seq: 0, + ts: startTime, + type: 'session_start', + payload: { sessionId, startTime, projectHash: hash }, + }); + const filePath = path.join( + chatsDir, + `session-2026-01-01T00-00-00-${sessionId.slice(0, 12)}.jsonl`, + ); + await fs.writeFile(filePath, payload + '\n'); + const oldTime = new Date(Date.now() - ageDays * 24 * 60 * 60 * 1000); + await fs.utimes(filePath, oldTime, oldTime); + + return { filePath, sessionId, hash }; +} + +describe('cleanupExpiredSessions — CLI boundary (Item 9)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('passes the global temp root and discovers sessions through the full pipeline', async () => { + await createOldSession(tempDir); + + const config = createMinimalConfig('current-cli-session'); + const settings: Settings = {} as Settings; + + const result = await cleanupExpiredSessions( + config as unknown as Config, + settings, + tempDir, + ); + + expect(result.disabled).toBe(false); + expect(result.janitorWonLease).toBe(true); + expect(result.scanned).toBe(1); + }); + + it('retains the 4 GiB default for a partial settings object through the CLI boundary', async () => { + await createOldSession(tempDir); + + const config = createMinimalConfig('current-cli-session'); + // Partial settings: only maxAge is set. + // The 4 GiB default size budget must be retained so the small session is NOT deleted. + const settings = { + sessionRetention: { maxAge: '30d' }, + } as unknown as Settings; + + const result = await cleanupExpiredSessions( + config as unknown as Config, + settings, + tempDir, + ); + + // Default budget retained — small session survives. + expect(result.configuredByteLimit).toBe( + DEFAULT_MAX_TOTAL_SIZE_MB * 1024 * 1024, + ); + expect(result.archived).toBe(0); + expect(result.rawDeleted).toBe(0); + }); + + it('passes undefined sessionRetention (defaults) through the CLI boundary', async () => { + const { filePath } = await createOldSession(tempDir); + + const config = createMinimalConfig('current-cli-session'); + // No sessionRetention at all — pure defaults. + const settings = {} as Settings; + + const result = await cleanupExpiredSessions( + config as unknown as Config, + settings, + tempDir, + ); + + expect(result.disabled).toBe(false); + // Default-on, default budget, no maxAge. + expect(result.configuredByteLimit).toBe( + DEFAULT_MAX_TOTAL_SIZE_MB * 1024 * 1024, + ); + // Old session under budget survives (no default maxAge). + expect(result.rawDeleted).toBe(0); + expect(await fileExists(filePath)).toBe(true); + }); + + it('honors enabled:false through the CLI boundary', async () => { + const config = createMinimalConfig('current-cli-session'); + const settings = { + sessionRetention: { enabled: false }, + } as unknown as Settings; + + const result = await cleanupExpiredSessions( + config as unknown as Config, + settings, + tempDir, + ); + + expect(result.disabled).toBe(true); + expect(result.janitorWonLease).toBe(false); + }); +}); + +describe('cleanupExpiredSessions — config resolution vs external fs (finding D)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('surfaces invalid retention settings clearly instead of returning configuredByteLimit 0', async () => { + const config = createMinimalConfig('current-cli-session'); + const settings = { + sessionRetention: { maxCount: 2.5 }, + } as unknown as Settings; + + // Invalid settings are a configuration error, not a best-effort external + // filesystem failure. They must throw rather than be swallowed into a + // configuredByteLimit-0 result. + await expect( + cleanupExpiredSessions(config as unknown as Config, settings, tempDir), + ).rejects.toThrow(/Invalid sessionRetention/); + }); + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'preserves the resolved configured limit when the temp root is externally inaccessible', + async () => { + // An unreadable global temp root is an external filesystem condition that + // is best-effort: cleanup cannot proceed, but the resolved configured + // limit must still be reported (never zeroed out) so diagnostics stay + // coherent (finding D). + const restricted = path.join(tempDir, 'restricted'); + await fs.mkdir(restricted, { recursive: true }); + await fs.chmod(restricted, 0o000); + try { + const config = createMinimalConfig('current-cli-session'); + const settings = { + sessionRetention: { maxTotalSizeMB: 16 }, + } as unknown as Settings; + + const result = await cleanupExpiredSessions( + config as unknown as Config, + settings, + restricted, + ); + + expect(result.configuredByteLimit).toBe(16 * 1024 * 1024); + expect(result.janitorWonLease).toBe(false); + } finally { + await fs.chmod(restricted, 0o700).catch(() => {}); + } + }, + ); +}); diff --git a/packages/cli/src/utils/sessionCleanup.config.test.ts b/packages/cli/src/utils/sessionCleanup.config.test.ts deleted file mode 100644 index d99d38c15a..0000000000 --- a/packages/cli/src/utils/sessionCleanup.config.test.ts +++ /dev/null @@ -1,845 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { automock } from '@vybestack/llxprt-code-test-utils'; -import { - describe, - it, - expect, - vi, - beforeEach, - afterEach, - type Mock, -} from 'bun:test'; -import { cleanupExpiredSessions } from './sessionCleanup.js'; -import { DebugLogger } from '@vybestack/llxprt-code-core'; -import { SESSION_FILE_PREFIX } from '@vybestack/llxprt-code-storage'; -import type { Settings } from '../config/settings.js'; -import * as fs from 'node:fs/promises'; -import { getAllSessionFiles } from './sessionUtils.js'; - -const realPromisesModule = { ...(await import('fs/promises')) }; - -void vi.mock('fs/promises', () => automock(realPromisesModule)); -void vi.mock('./sessionUtils.js', () => ({ - getAllSessionFiles: vi.fn(), -})); - -import { - createMockConfig, - createTestSessions, -} from './sessionCleanup-test-helpers.js'; - -/** - * Bun ships no deep-mock type, so the members each suite actually drives are - * named explicitly and given Bun's Mock signature. - */ -type MockedMembers = { - [P in K]: T[P] extends (...args: never[]) => unknown ? Mock : T[P]; -}; - -const mockFs = fs as unknown as MockedMembers; -const mockGetAllSessionFiles = getAllSessionFiles as Mock< - typeof getAllSessionFiles ->; - -describe('Session Cleanup', () => { - beforeEach(() => { - vi.clearAllMocks(); - const sessions = createTestSessions(); - mockGetAllSessionFiles.mockResolvedValue( - sessions.map((session) => ({ - fileName: session.fileName, - sessionInfo: session, - })), - ); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('parseRetentionPeriod format validation', () => { - // Test all supported formats - it.each([ - ['1h', 60 * 60 * 1000], - ['24h', 24 * 60 * 60 * 1000], - ['168h', 168 * 60 * 60 * 1000], - ['1d', 24 * 60 * 60 * 1000], - ['7d', 7 * 24 * 60 * 60 * 1000], - ['30d', 30 * 24 * 60 * 60 * 1000], - ['365d', 365 * 24 * 60 * 60 * 1000], - ['1w', 7 * 24 * 60 * 60 * 1000], - ['2w', 14 * 24 * 60 * 60 * 1000], - ['4w', 28 * 24 * 60 * 60 * 1000], - ['52w', 364 * 24 * 60 * 60 * 1000], - ['1m', 30 * 24 * 60 * 60 * 1000], - ['3m', 90 * 24 * 60 * 60 * 1000], - ['6m', 180 * 24 * 60 * 60 * 1000], - ['12m', 360 * 24 * 60 * 60 * 1000], - ])('should correctly parse valid format %s', async (input) => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: input, - // Set minRetention to 1h to allow testing of hour-based maxAge values - minRetention: '1h', - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - // If it parses correctly, cleanup should proceed without error - const result = await cleanupExpiredSessions(config, settings); - expect(result.disabled).toBe(false); - expect(result.failed).toBe(0); - }); - - // Test invalid formats - it.each([ - '30', // Missing unit - '30x', // Invalid unit - 'd', // No number - '1.5d', // Decimal not supported - '-5d', // Negative number - '1 d', // Space in format - '1dd', // Double unit - 'abc', // Non-numeric - '30s', // Unsupported unit (seconds) - '30y', // Unsupported unit (years) - '0d', // Zero value (technically valid regex but semantically invalid) - ])('should reject invalid format %s', async (input) => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: input, - }, - }; - - const errorSpy = vi - .spyOn(DebugLogger.prototype, 'error') - .mockImplementation(() => {}); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining( - input === '0d' - ? 'Invalid retention period: 0d. Value must be greater than 0' - : `Invalid retention period format: ${input}`, - ), - ); - - errorSpy.mockRestore(); - }); - - // Test special case - empty string - it('should reject empty string', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '', - }, - }; - - const errorSpy = vi - .spyOn(DebugLogger.prototype, 'error') - .mockImplementation(() => {}); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - // Empty string means no valid retention method specified - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining('Either maxAge or maxCount must be specified'), - ); - - errorSpy.mockRestore(); - }); - - // Test edge cases - it('should handle very large numbers', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '9999d', // Very large number - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - const result = await cleanupExpiredSessions(config, settings); - expect(result.disabled).toBe(false); - expect(result.failed).toBe(0); - }); - - it('should validate minRetention format', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '5d', - minRetention: 'invalid-format', // Invalid minRetention - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - // Should fall back to default minRetention and proceed - const result = await cleanupExpiredSessions(config, settings); - - // Since maxAge (5d) > default minRetention (1d), this should succeed - expect(result.disabled).toBe(false); - expect(result.failed).toBe(0); - }); - }); - - describe('Configuration validation', () => { - it('should require either maxAge or maxCount', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - sessionRetention: { - enabled: true, - // Neither maxAge nor maxCount specified - }, - }; - - const errorSpy = vi - .spyOn(DebugLogger.prototype, 'error') - .mockImplementation(() => {}); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining('Either maxAge or maxCount must be specified'), - ); - - errorSpy.mockRestore(); - }); - - it('should validate maxCount range', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxCount: 0, // Invalid count - }, - }; - - const errorSpy = vi - .spyOn(DebugLogger.prototype, 'error') - .mockImplementation(() => {}); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining('maxCount must be at least 1'), - ); - - errorSpy.mockRestore(); - }); - - describe('maxAge format validation', () => { - it('should reject invalid maxAge format - no unit', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '30', // Missing unit - }, - }; - - const errorSpy = vi - .spyOn(DebugLogger.prototype, 'error') - .mockImplementation(() => {}); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining('Invalid retention period format: 30'), - ); - - errorSpy.mockRestore(); - }); - - it('should reject invalid maxAge format - invalid unit', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '30x', // Invalid unit 'x' - }, - }; - - const errorSpy = vi - .spyOn(DebugLogger.prototype, 'error') - .mockImplementation(() => {}); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining('Invalid retention period format: 30x'), - ); - - errorSpy.mockRestore(); - }); - - it('should reject invalid maxAge format - no number', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: 'd', // No number - }, - }; - - const errorSpy = vi - .spyOn(DebugLogger.prototype, 'error') - .mockImplementation(() => {}); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining('Invalid retention period format: d'), - ); - - errorSpy.mockRestore(); - }); - - it('should reject invalid maxAge format - decimal number', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '1.5d', // Decimal not supported - }, - }; - - const errorSpy = vi - .spyOn(DebugLogger.prototype, 'error') - .mockImplementation(() => {}); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining('Invalid retention period format: 1.5d'), - ); - - errorSpy.mockRestore(); - }); - - it('should reject invalid maxAge format - negative number', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '-5d', // Negative not allowed - }, - }; - - const errorSpy = vi - .spyOn(DebugLogger.prototype, 'error') - .mockImplementation(() => {}); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining('Invalid retention period format: -5d'), - ); - - errorSpy.mockRestore(); - }); - - it('should accept valid maxAge format - hours', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '48h', // Valid: 48 hours - maxCount: 10, // Need at least one valid retention method - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - const result = await cleanupExpiredSessions(config, settings); - - // Should not reject the configuration - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(0); - expect(result.failed).toBe(0); - }); - - it('should accept valid maxAge format - days', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '7d', // Valid: 7 days - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - const result = await cleanupExpiredSessions(config, settings); - - // Should not reject the configuration - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(0); - expect(result.failed).toBe(0); - }); - - it('should accept valid maxAge format - weeks', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '2w', // Valid: 2 weeks - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - const result = await cleanupExpiredSessions(config, settings); - - // Should not reject the configuration - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(0); - expect(result.failed).toBe(0); - }); - - it('should accept valid maxAge format - months', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '3m', // Valid: 3 months - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - const result = await cleanupExpiredSessions(config, settings); - - // Should not reject the configuration - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(0); - expect(result.failed).toBe(0); - }); - }); - - describe('minRetention validation', () => { - it('should reject maxAge less than default minRetention (1d)', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '12h', // Less than default 1d minRetention - }, - }; - - const errorSpy = vi - .spyOn(DebugLogger.prototype, 'error') - .mockImplementation(() => {}); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining( - 'maxAge cannot be less than minRetention (1d)', - ), - ); - - errorSpy.mockRestore(); - }); - - it('should reject maxAge less than custom minRetention', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '2d', - minRetention: '3d', // maxAge < minRetention - }, - }; - - const errorSpy = vi - .spyOn(DebugLogger.prototype, 'error') - .mockImplementation(() => {}); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining( - 'maxAge cannot be less than minRetention (3d)', - ), - ); - - errorSpy.mockRestore(); - }); - - it('should accept maxAge equal to minRetention', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '2d', - minRetention: '2d', // maxAge == minRetention (edge case) - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - const result = await cleanupExpiredSessions(config, settings); - - // Should not reject the configuration - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(0); - expect(result.failed).toBe(0); - }); - - it('should accept maxAge greater than minRetention', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '7d', - minRetention: '2d', // maxAge > minRetention - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - const result = await cleanupExpiredSessions(config, settings); - - // Should not reject the configuration - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(0); - expect(result.failed).toBe(0); - }); - - it('should handle invalid minRetention format gracefully', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '5d', - minRetention: 'invalid', // Invalid format - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - // When minRetention is invalid, it should default to 1d - // Since maxAge (5d) > default minRetention (1d), this should be valid - const result = await cleanupExpiredSessions(config, settings); - - // Should not reject due to minRetention (falls back to default) - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(0); - expect(result.failed).toBe(0); - }); - }); - - describe('maxCount boundary validation', () => { - it('should accept maxCount = 1 (minimum valid)', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxCount: 1, // Minimum valid value - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - const result = await cleanupExpiredSessions(config, settings); - - // Should accept the configuration - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(0); - expect(result.failed).toBe(0); - }); - - it('should accept maxCount = 1000 (maximum valid)', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxCount: 1000, // Maximum valid value - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - const result = await cleanupExpiredSessions(config, settings); - - // Should accept the configuration - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(0); - expect(result.failed).toBe(0); - }); - - it('should reject negative maxCount', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxCount: -1, // Negative value - }, - }; - - const errorSpy = vi - .spyOn(DebugLogger.prototype, 'error') - .mockImplementation(() => {}); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining('maxCount must be at least 1'), - ); - - errorSpy.mockRestore(); - }); - - it('should accept valid maxCount in normal range', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxCount: 50, // Normal valid value - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - const result = await cleanupExpiredSessions(config, settings); - - // Should accept the configuration - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(0); - expect(result.failed).toBe(0); - }); - }); - - describe('combined configuration validation', () => { - it('should accept valid maxAge and maxCount together', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '30d', - maxCount: 10, - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - const result = await cleanupExpiredSessions(config, settings); - - // Should accept the configuration - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(0); - expect(result.failed).toBe(0); - }); - - it('should reject if both maxAge and maxCount are invalid', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: 'invalid', - maxCount: 0, - }, - }; - - const errorSpy = vi - .spyOn(DebugLogger.prototype, 'error') - .mockImplementation(() => {}); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - // Should fail on first validation error (maxAge format) - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining('Invalid retention period format'), - ); - - errorSpy.mockRestore(); - }); - - it('should reject if maxAge is invalid even when maxCount is valid', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: 'invalid', // Invalid format - maxCount: 5, // Valid count - }, - }; - - // The validation logic rejects invalid maxAge format even if maxCount is valid - const errorSpy = vi - .spyOn(DebugLogger.prototype, 'error') - .mockImplementation(() => {}); - - const result = await cleanupExpiredSessions(config, settings); - - // Should reject due to invalid maxAge format - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining('Invalid retention period format'), - ); - - errorSpy.mockRestore(); - }); - }); - - it('should never throw an exception, always returning a result', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '7d', - }, - }; - - // Mock getSessionFiles to throw an error - mockGetAllSessionFiles.mockRejectedValue( - new Error('Failed to read directory'), - ); - - // Should not throw, should return a result with errors - const result = await cleanupExpiredSessions(config, settings); - - expect(result).toBeDefined(); - expect(result.disabled).toBe(false); - expect(result.failed).toBe(1); - }); - - it('should delete corrupted session files', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '30d', - }, - }; - - // Mock getAllSessionFiles to return both valid and corrupted files - const validSession = createTestSessions()[0]; - mockGetAllSessionFiles.mockResolvedValue([ - { fileName: validSession.fileName, sessionInfo: validSession }, - { - fileName: `${SESSION_FILE_PREFIX}2025-01-02T10-00-00-corrupt1.json`, - sessionInfo: null, - }, - { - fileName: `${SESSION_FILE_PREFIX}2025-01-03T10-00-00-corrupt2.json`, - sessionInfo: null, - }, - ]); - - mockFs.unlink.mockResolvedValue(undefined); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(3); // 1 valid + 2 corrupted - expect(result.deleted).toBe(2); // Should delete the 2 corrupted files - expect(result.skipped).toBe(1); // The valid session is kept - - // Verify corrupted files were deleted - expect(mockFs.unlink).toHaveBeenCalledWith( - expect.stringContaining('corrupt1.json'), - ); - expect(mockFs.unlink).toHaveBeenCalledWith( - expect.stringContaining('corrupt2.json'), - ); - }); - - it('should handle unexpected errors without throwing', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '7d', - }, - }; - - // Mock getSessionFiles to throw a non-Error object - mockGetAllSessionFiles.mockRejectedValue('String error'); - - // Should not throw, should return a result with errors - const result = await cleanupExpiredSessions(config, settings); - - expect(result).toBeDefined(); - expect(result.disabled).toBe(false); - expect(result.failed).toBe(1); - }); - }); -}); diff --git a/packages/cli/src/utils/sessionCleanup.integration.test.ts b/packages/cli/src/utils/sessionCleanup.integration.test.ts deleted file mode 100644 index 74e60ba516..0000000000 --- a/packages/cli/src/utils/sessionCleanup.integration.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi } from 'bun:test'; -import { debugLogger } from '@vybestack/llxprt-code-telemetry'; -import { cleanupExpiredSessions } from './sessionCleanup.js'; -import type { Settings } from '../config/settings.js'; -import { SESSION_FILE_PREFIX } from '@vybestack/llxprt-code-storage'; -import type { Config } from '@vybestack/llxprt-code-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 = { - 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 = { - 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 () => { - // Cleanup reports validation failures through the debug logger, not - // console.error. - const errorSpy = vi - .spyOn(debugLogger, 'error') - .mockImplementation(() => {}); - const config = createTestConfig(); - - const settings: Settings = { - 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 = { - 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 }); - } - }); -}); diff --git a/packages/cli/src/utils/sessionCleanup.test.ts b/packages/cli/src/utils/sessionCleanup.test.ts deleted file mode 100644 index 146a67dae9..0000000000 --- a/packages/cli/src/utils/sessionCleanup.test.ts +++ /dev/null @@ -1,756 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { automock } from '@vybestack/llxprt-code-test-utils'; -import { - describe, - it, - expect, - vi, - beforeEach, - afterEach, - type Mock, -} from 'bun:test'; -import * as path from 'node:path'; -import { cleanupExpiredSessions } from './sessionCleanup.js'; -import { DebugLogger } from '@vybestack/llxprt-code-core'; -import { SESSION_FILE_PREFIX } from '@vybestack/llxprt-code-storage'; -import type { Settings } from '../config/settings.js'; -import * as fs from 'node:fs/promises'; -import { type SessionInfo, getAllSessionFiles } from './sessionUtils.js'; - -const realPromisesModule = { ...(await import('fs/promises')) }; - -void vi.mock('fs/promises', () => automock(realPromisesModule)); -void vi.mock('./sessionUtils.js', () => ({ - getAllSessionFiles: vi.fn(), -})); - -import { - createMockConfig, - createTestSessions, -} from './sessionCleanup-test-helpers.js'; - -/** - * Bun ships no deep-mock type, so the members each suite actually drives are - * named explicitly and given Bun's Mock signature. - */ -type MockedMembers = { - [P in K]: T[P] extends (...args: never[]) => unknown ? Mock : T[P]; -}; - -const mockFs = fs as unknown as MockedMembers< - typeof fs, - 'access' | 'readFile' | 'unlink' ->; -const mockGetAllSessionFiles = getAllSessionFiles as Mock< - typeof getAllSessionFiles ->; - -describe('Session Cleanup', () => { - beforeEach(() => { - vi.clearAllMocks(); - const sessions = createTestSessions(); - mockGetAllSessionFiles.mockResolvedValue( - sessions.map((session) => ({ - fileName: session.fileName, - sessionInfo: session, - })), - ); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('cleanupExpiredSessions', () => { - it('should return early when cleanup is disabled', async () => { - const config = createMockConfig(); - const settings: Settings = { - 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 return early when sessionRetention is not configured', async () => { - const config = createMockConfig(); - const settings: Settings = {}; - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(result.deleted).toBe(0); - }); - - it('should handle invalid maxAge configuration', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: 'invalid-format', - }, - }; - - const errorSpy = vi - .spyOn(DebugLogger.prototype, 'error') - .mockImplementation(() => {}); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(result.deleted).toBe(0); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining( - 'Session cleanup disabled: Error: Invalid retention period format', - ), - ); - - errorSpy.mockRestore(); - }); - - it('should delete sessions older than maxAge', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '10d', // 10 days - }, - }; - - // Mock successful file operations - mockFs.access.mockResolvedValue(undefined); - mockFs.readFile.mockResolvedValue( - JSON.stringify({ - sessionId: 'test', - messages: [], - startTime: '2025-01-01T00:00:00Z', - lastUpdated: '2025-01-01T00:00:00Z', - }), - ); - mockFs.unlink.mockResolvedValue(undefined); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(4); - expect(result.deleted).toBe(2); // Should delete the 2-week-old and 1-month-old sessions - expect(result.skipped).toBe(2); // Current session + recent session should be skipped - expect(result.failed).toBe(0); - }); - - it('should never delete current session', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '1d', // Very short retention - }, - }; - - // Mock successful file operations - mockFs.access.mockResolvedValue(undefined); - mockFs.readFile.mockResolvedValue( - JSON.stringify({ - sessionId: 'test', - messages: [], - startTime: '2025-01-01T00:00:00Z', - lastUpdated: '2025-01-01T00:00:00Z', - }), - ); - mockFs.unlink.mockResolvedValue(undefined); - - const result = await cleanupExpiredSessions(config, settings); - - // Should delete all sessions except the current one - expect(result.disabled).toBe(false); - expect(result.deleted).toBe(3); - - // Verify that unlink was never called with the current session file - const unlinkCalls = mockFs.unlink.mock.calls; - const currentSessionPath = path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12.json`, - ); - expect( - unlinkCalls.find((call) => call[0] === currentSessionPath), - ).toBeUndefined(); - }); - - it('should handle count-based retention', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxCount: 2, // Keep only 2 most recent sessions - }, - }; - - // Mock successful file operations - mockFs.access.mockResolvedValue(undefined); - mockFs.readFile.mockResolvedValue( - JSON.stringify({ - sessionId: 'test', - messages: [], - startTime: '2025-01-01T00:00:00Z', - lastUpdated: '2025-01-01T00:00:00Z', - }), - ); - mockFs.unlink.mockResolvedValue(undefined); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(4); - expect(result.deleted).toBe(2); // Should delete 2 oldest sessions (after skipping the current one) - expect(result.skipped).toBe(2); // Current session + 1 recent session should be kept - }); - - it('should handle file system errors gracefully', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '1d', - }, - }; - - // Mock file operations to succeed for access and readFile but fail for unlink - mockFs.access.mockResolvedValue(undefined); - mockFs.readFile.mockResolvedValue( - JSON.stringify({ - sessionId: 'test', - messages: [], - startTime: '2025-01-01T00:00:00Z', - lastUpdated: '2025-01-01T00:00:00Z', - }), - ); - mockFs.unlink.mockRejectedValue(new Error('Permission denied')); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(4); - expect(result.deleted).toBe(0); - expect(result.failed).toBeGreaterThan(0); - }); - - it('should handle empty sessions directory', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '30d', - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - const result = await cleanupExpiredSessions(config, settings); - - 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 handle global errors gracefully', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '30d', - }, - }; - - const errorSpy = vi - .spyOn(DebugLogger.prototype, 'error') - .mockImplementation(() => {}); - - // Mock getSessionFiles to throw an error - mockGetAllSessionFiles.mockRejectedValue( - new Error('Directory access failed'), - ); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(false); - expect(result.failed).toBe(1); - expect(errorSpy).toHaveBeenCalledWith( - 'Session cleanup failed: Directory access failed', - ); - - errorSpy.mockRestore(); - }); - - it('should respect minRetention configuration', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '12h', // Less than 1 day minimum - minRetention: '1d', - }, - }; - - const result = await cleanupExpiredSessions(config, settings); - - // Should disable cleanup due to minRetention violation - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(result.deleted).toBe(0); - }); - - it('should log debug information when enabled', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '10d', - }, - }; - - // Mock successful file operations - mockFs.access.mockResolvedValue(undefined); - mockFs.readFile.mockResolvedValue( - JSON.stringify({ - sessionId: 'test', - messages: [], - startTime: '2025-01-01T00:00:00Z', - lastUpdated: '2025-01-01T00:00:00Z', - }), - ); - mockFs.unlink.mockResolvedValue(undefined); - - const debugSpy = vi - .spyOn(DebugLogger.prototype, 'debug') - .mockImplementation(() => {}); - - await cleanupExpiredSessions(config, settings); - - expect(debugSpy).toHaveBeenCalledWith( - expect.stringContaining('Session cleanup: deleted'), - ); - expect(debugSpy).toHaveBeenCalledWith( - expect.stringContaining('Deleted expired session:'), - ); - - debugSpy.mockRestore(); - }); - }); - - describe('Specific cleanup scenarios', () => { - it('should delete sessions that exceed the cutoff date', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '7d', // Keep sessions for 7 days - }, - }; - - // Create sessions with specific dates - const now = new Date(); - const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); - const eightDaysAgo = new Date(now.getTime() - 8 * 24 * 60 * 60 * 1000); - const fifteenDaysAgo = new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000); - - const testSessions: SessionInfo[] = [ - { - id: 'current', - fileName: `${SESSION_FILE_PREFIX}current.json`, - lastUpdated: now.toISOString(), - isCurrentSession: true, - }, - { - id: 'session5d', - fileName: `${SESSION_FILE_PREFIX}5d.json`, - lastUpdated: fiveDaysAgo.toISOString(), - isCurrentSession: false, - }, - { - id: 'session8d', - fileName: `${SESSION_FILE_PREFIX}8d.json`, - lastUpdated: eightDaysAgo.toISOString(), - isCurrentSession: false, - }, - { - id: 'session15d', - fileName: `${SESSION_FILE_PREFIX}15d.json`, - lastUpdated: fifteenDaysAgo.toISOString(), - isCurrentSession: false, - }, - ]; - - mockGetAllSessionFiles.mockResolvedValue( - testSessions.map((session) => ({ - fileName: session.fileName, - sessionInfo: session, - })), - ); - - // Mock successful file operations - mockFs.access.mockResolvedValue(undefined); - mockFs.readFile.mockResolvedValue( - JSON.stringify({ - sessionId: 'test', - messages: [], - startTime: '2025-01-01T00:00:00Z', - lastUpdated: '2025-01-01T00:00:00Z', - }), - ); - mockFs.unlink.mockResolvedValue(undefined); - - const result = await cleanupExpiredSessions(config, settings); - - // Should delete sessions older than 7 days (8d and 15d sessions) - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(4); - expect(result.deleted).toBe(2); - expect(result.skipped).toBe(2); // Current + 5d session - - // Verify which files were deleted - const unlinkCalls = mockFs.unlink.mock.calls.map((call) => call[0]); - expect(unlinkCalls).toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}8d.json`, - ), - ); - expect(unlinkCalls).toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}15d.json`, - ), - ); - expect(unlinkCalls).not.toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}5d.json`, - ), - ); - }); - - it('should NOT delete sessions within the cutoff date', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '14d', // Keep sessions for 14 days - }, - }; - - // Create sessions all within the retention period - const now = new Date(); - const oneDayAgo = new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000); - const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); - const thirteenDaysAgo = new Date( - now.getTime() - 13 * 24 * 60 * 60 * 1000, - ); - - const testSessions: SessionInfo[] = [ - { - id: 'current', - fileName: `${SESSION_FILE_PREFIX}current.json`, - lastUpdated: now.toISOString(), - isCurrentSession: true, - }, - { - id: 'session1d', - fileName: `${SESSION_FILE_PREFIX}1d.json`, - lastUpdated: oneDayAgo.toISOString(), - isCurrentSession: false, - }, - { - id: 'session7d', - fileName: `${SESSION_FILE_PREFIX}7d.json`, - lastUpdated: sevenDaysAgo.toISOString(), - isCurrentSession: false, - }, - { - id: 'session13d', - fileName: `${SESSION_FILE_PREFIX}13d.json`, - lastUpdated: thirteenDaysAgo.toISOString(), - isCurrentSession: false, - }, - ]; - - mockGetAllSessionFiles.mockResolvedValue( - testSessions.map((session) => ({ - fileName: session.fileName, - sessionInfo: session, - })), - ); - - // Mock successful file operations - mockFs.access.mockResolvedValue(undefined); - mockFs.readFile.mockResolvedValue( - JSON.stringify({ - sessionId: 'test', - messages: [], - startTime: '2025-01-01T00:00:00Z', - lastUpdated: '2025-01-01T00:00:00Z', - }), - ); - mockFs.unlink.mockResolvedValue(undefined); - - const result = await cleanupExpiredSessions(config, settings); - - // Should NOT delete any sessions as all are within 14 days - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(4); - expect(result.deleted).toBe(0); - expect(result.skipped).toBe(4); - expect(result.failed).toBe(0); - - // Verify no files were deleted - expect(mockFs.unlink).not.toHaveBeenCalled(); - }); - - it('should keep N most recent deletable sessions', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxCount: 3, // Keep only 3 most recent sessions - }, - }; - - // Create 6 sessions with different timestamps - const now = new Date(); - const sessions: SessionInfo[] = [ - { - id: 'current', - fileName: `${SESSION_FILE_PREFIX}current.json`, - lastUpdated: now.toISOString(), - isCurrentSession: true, - }, - ]; - - // Add 5 more sessions with decreasing timestamps - for (let i = 1; i <= 5; i++) { - const daysAgo = new Date(now.getTime() - i * 24 * 60 * 60 * 1000); - sessions.push({ - id: `session${i}`, - fileName: `${SESSION_FILE_PREFIX}${i}d.json`, - lastUpdated: daysAgo.toISOString(), - isCurrentSession: false, - }); - } - - mockGetAllSessionFiles.mockResolvedValue( - sessions.map((session) => ({ - fileName: session.fileName, - sessionInfo: session, - })), - ); - - // Mock successful file operations - mockFs.access.mockResolvedValue(undefined); - mockFs.readFile.mockResolvedValue( - JSON.stringify({ - sessionId: 'test', - messages: [], - startTime: '2025-01-01T00:00:00Z', - lastUpdated: '2025-01-01T00:00:00Z', - }), - ); - mockFs.unlink.mockResolvedValue(undefined); - - const result = await cleanupExpiredSessions(config, settings); - - // Should keep current + 2 most recent (1d and 2d), delete 3d, 4d, 5d - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(6); - expect(result.deleted).toBe(3); - expect(result.skipped).toBe(3); - - // Verify which files were deleted (should be the 3 oldest) - const unlinkCalls = mockFs.unlink.mock.calls.map((call) => call[0]); - expect(unlinkCalls).toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}3d.json`, - ), - ); - expect(unlinkCalls).toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}4d.json`, - ), - ); - expect(unlinkCalls).toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}5d.json`, - ), - ); - - // Verify which files were NOT deleted - expect(unlinkCalls).not.toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}current.json`, - ), - ); - expect(unlinkCalls).not.toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}1d.json`, - ), - ); - expect(unlinkCalls).not.toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}2d.json`, - ), - ); - }); - - it('should handle combined maxAge and maxCount retention (most restrictive wins)', async () => { - const config = createMockConfig(); - const settings: Settings = { - sessionRetention: { - enabled: true, - maxAge: '10d', // Keep sessions for 10 days - maxCount: 2, // But also keep only 2 most recent - }, - }; - - // Create sessions where maxCount is more restrictive - const now = new Date(); - const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); - const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); - const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); - const twelveDaysAgo = new Date(now.getTime() - 12 * 24 * 60 * 60 * 1000); - - const testSessions: SessionInfo[] = [ - { - id: 'current', - fileName: `${SESSION_FILE_PREFIX}current.json`, - lastUpdated: now.toISOString(), - isCurrentSession: true, - }, - { - id: 'session3d', - fileName: `${SESSION_FILE_PREFIX}3d.json`, - lastUpdated: threeDaysAgo.toISOString(), - isCurrentSession: false, - }, - { - id: 'session5d', - fileName: `${SESSION_FILE_PREFIX}5d.json`, - lastUpdated: fiveDaysAgo.toISOString(), - isCurrentSession: false, - }, - { - id: 'session7d', - fileName: `${SESSION_FILE_PREFIX}7d.json`, - lastUpdated: sevenDaysAgo.toISOString(), - isCurrentSession: false, - }, - { - id: 'session12d', - fileName: `${SESSION_FILE_PREFIX}12d.json`, - lastUpdated: twelveDaysAgo.toISOString(), - isCurrentSession: false, - }, - ]; - - mockGetAllSessionFiles.mockResolvedValue( - testSessions.map((session) => ({ - fileName: session.fileName, - sessionInfo: session, - })), - ); - - // Mock successful file operations - mockFs.access.mockResolvedValue(undefined); - mockFs.readFile.mockResolvedValue( - JSON.stringify({ - sessionId: 'test', - messages: [], - startTime: '2025-01-01T00:00:00Z', - lastUpdated: '2025-01-01T00:00:00Z', - }), - ); - mockFs.unlink.mockResolvedValue(undefined); - - const result = await cleanupExpiredSessions(config, settings); - - // Should delete: - // - session12d (exceeds maxAge of 10d) - // - session7d and session5d (exceed maxCount of 2, keeping current + 3d) - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(5); - expect(result.deleted).toBe(3); - expect(result.skipped).toBe(2); // Current + 3d session - - // Verify which files were deleted - const unlinkCalls = mockFs.unlink.mock.calls.map((call) => call[0]); - expect(unlinkCalls).toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}5d.json`, - ), - ); - expect(unlinkCalls).toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}7d.json`, - ), - ); - expect(unlinkCalls).toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}12d.json`, - ), - ); - - // Verify which files were NOT deleted - expect(unlinkCalls).not.toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}current.json`, - ), - ); - expect(unlinkCalls).not.toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}3d.json`, - ), - ); - }); - }); -}); diff --git a/packages/cli/src/utils/sessionCleanup.ts b/packages/cli/src/utils/sessionCleanup.ts index 7f66c397de..0226e1544d 100644 --- a/packages/cli/src/utils/sessionCleanup.ts +++ b/packages/cli/src/utils/sessionCleanup.ts @@ -4,370 +4,98 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; -import { type Config } from '@vybestack/llxprt-code-core'; -import { debugLogger } from '@vybestack/llxprt-code-telemetry'; -import { Storage } from '@vybestack/llxprt-code-storage'; -import type { Settings, SessionRetentionSettings } from '../config/settings.js'; -import { getAllSessionFiles, type SessionFileEntry } from './sessionUtils.js'; -import { firstNonEmptyString } from './coalesce.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; - debugLogsDeleted?: number; -} /** - * Attempts to cleanup debug log files associated with a session ID. - * Debug logs reside beneath the platform-standard global log directory. - * This is a best-effort cleanup that silently handles missing files or directories. + * CLI entry point for session-recording cleanup. * - * @param sessionId - The session ID to look for in debug log filenames - * @returns The number of debug log files successfully deleted + * Delegates to the core session-recording janitor which performs a global + * sweep across all 64-hex project-hash directories under the global temp + * root. Default-on with a 4 GiB aggregate size budget, no default age/count + * limits, and a 1-day minimum retention floor. + * + * User-provided `sessionRetention` objects are resolved over defaults at the + * consumer so a partial object cannot accidentally remove default-on size + * bounding (AC-2). */ -async function cleanupDebugLogsForSession(sessionId: string): Promise { - try { - const debugDir = path.join(Storage.getGlobalLogDir(), 'debug'); - - // Check if debug directory exists - try { - await fs.access(debugDir); - } catch { - // Debug directory doesn't exist, nothing to clean - return 0; - } - - // Read all files in the debug directory - const files = await fs.readdir(debugDir); - - // Filter for files that contain the session ID in their name - // Debug log format: llxprt-debug-{runId}-{timestamp}.jsonl - // where runId might be a session ID - const matchingFiles = files.filter( - (file) => file.includes(sessionId) && file.endsWith('.jsonl'), - ); - if (matchingFiles.length === 0) { - return 0; - } - - let deletedCount = 0; - for (const file of matchingFiles) { - try { - await fs.unlink(path.join(debugDir, file)); - deletedCount++; - debugLogger.debug('Deleted debug log file', { file, sessionId }); - } catch (error) { - // Ignore errors (file might have been deleted already, permissions, etc.) - debugLogger.debug('Failed to delete debug log file', { file, error }); - } - } - - return deletedCount; - } catch (error) { - // Silently handle any errors during debug log cleanup - debugLogger.debug('Error during debug log cleanup', { sessionId, error }); - return 0; - } -} - -async function deleteSingleSession( - sessionToDelete: SessionFileEntry, - chatsDir: string, - config: Config, - result: CleanupResult, -): Promise { - try { - const sessionPath = path.join(chatsDir, sessionToDelete.fileName); - await fs.unlink(sessionPath); +import { + emptyResult, + resolveRetentionConfig, + runSessionCleanup, + type Config, + type SessionCleanupResult, +} from '@vybestack/llxprt-code-core'; +import { Storage } from '@vybestack/llxprt-code-storage'; +import { debugLogger } from '@vybestack/llxprt-code-telemetry'; +import type { Settings } from '../config/settings.js'; - if (config.getDebugMode()) { - if (sessionToDelete.sessionInfo === null) { - debugLogger.debug( - `Deleted corrupted session file: ${sessionToDelete.fileName}`, - ); - } else { - debugLogger.debug( - `Deleted expired session: ${sessionToDelete.sessionInfo.id} (${sessionToDelete.sessionInfo.lastUpdated})`, - ); - } - } - result.deleted++; - - const sessionInfo = sessionToDelete.sessionInfo; - if (sessionInfo === null) { - return; - } - const debugLogsDeleted = await cleanupDebugLogsForSession(sessionInfo.id); - if (debugLogsDeleted <= 0) { - return; - } - result.debugLogsDeleted = (result.debugLogsDeleted ?? 0) + debugLogsDeleted; - if (config.getDebugMode()) { - debugLogger.debug( - `Deleted ${debugLogsDeleted} debug log file(s) for session ${sessionInfo.id}`, - ); - } - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - // File already deleted, do nothing. - } else { - const sessionId = - sessionToDelete.sessionInfo === null - ? sessionToDelete.fileName - : sessionToDelete.sessionInfo.id; - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; - debugLogger.error( - `Failed to delete session ${sessionId}: ${errorMessage}`, - ); - result.failed++; - } - } -} +export type { SessionCleanupResult as CleanupResult }; /** - * Main entry point for session cleanup during CLI startup + * Main entry point for session cleanup during CLI startup. + * + * Cleanup is default-on. The global janitor scans all project-hash + * directories, losslessly archives eligible raw sessions, evicts cold + * archives to meet the size budget, cleans stale locks, and removes + * genuinely empty directories — all behind a single cross-process lease. + * + * Configuration resolution is intentionally separated from external, + * best-effort filesystem handling (finding D): an invalid + * `sessionRetention` value surfaces as a thrown configuration error rather + * than being swallowed into a `configuredByteLimit`-0 result. External + * filesystem failures remain best-effort (logged, never blocking startup) + * and preserve the resolved configured limit in their diagnostics. + * + * @param config - The CLI configuration (provides session ID and debug mode). + * @param settings - User settings (provides `sessionRetention` overrides). + * @param globalTempDirOverride - Optional override for the machine-global temp + * root. Production callers omit this; it defaults to + * `Storage.getGlobalTempDir()`. Tests pass a real temp directory so the + * full CLI→core pipeline is exercised without affecting the real machine + * global temp directory. */ export async function cleanupExpiredSessions( config: Config, settings: Settings, -): Promise { - const result: CleanupResult = { - disabled: false, - scanned: 0, - deleted: 0, - skipped: 0, - failed: 0, - }; + globalTempDirOverride?: string, +): Promise { + // Configuration resolution happens before any external filesystem access so + // invalid settings fail fast and clearly (finding D). This throw is + // intentionally NOT caught here — it is a configuration error. + const resolvedConfig = resolveRetentionConfig(settings.sessionRetention); - try { - if (settings.sessionRetention?.enabled !== true) { - return { ...result, disabled: true }; - } - - const retentionConfig = settings.sessionRetention; - const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats'); + const globalTempDir = globalTempDirOverride ?? Storage.getGlobalTempDir(); + const currentSessionId = config.getSessionId(); - const validationErrorMessage = validateRetentionConfig( - config, - retentionConfig, - ); - if (validationErrorMessage) { - debugLogger.error(`Session cleanup disabled: ${validationErrorMessage}`); - return { ...result, disabled: true }; - } - - const allFiles = await getAllSessionFiles(chatsDir, config.getSessionId()); - result.scanned = allFiles.length; - - if (allFiles.length === 0) { - return result; - } - - const sessionsToDelete = await identifySessionsToDelete( - allFiles, - retentionConfig, - ); - - for (const sessionToDelete of sessionsToDelete) { - await deleteSingleSession(sessionToDelete, chatsDir, config, result); - } - - result.skipped = result.scanned - result.deleted - result.failed; + try { + const result = await runSessionCleanup({ + globalTempDir, + currentSessionId, + config: resolvedConfig, + }); - if (config.getDebugMode() && result.deleted > 0) { + if (config.getDebugMode() && !result.disabled) { debugLogger.debug( - `Session cleanup: deleted ${result.deleted}, skipped ${result.skipped}, failed ${result.failed}`, + `Session cleanup: scanned=${result.scanned} archived=${result.archived} ` + + `rawDeleted=${result.rawDeleted} archiveDeleted=${result.archiveDeleted} ` + + `staleLocksRemoved=${result.staleLocksRemoved} skipped=${result.skipped} ` + + `failed=${result.failed} bytesBefore=${result.bytesBefore} ` + + `bytesAfter=${result.bytesAfter} wonLease=${result.janitorWonLease}`, ); } + + return result; } catch (error) { + // Best-effort external filesystem failure — log and continue startup + // (AC-9). The resolved configured limit is preserved so diagnostics + // remain coherent instead of reporting a zeroed limit (finding D). const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - debugLogger.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 { - 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 !== undefined && - retentionConfig.maxCount > 0 && - 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: 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`, + debugLogger.error( + `Session cleanup failed (configuredByteLimit=${resolvedConfig.maxTotalSizeBytes}): ${errorMessage}`, ); + return { + ...emptyResult(false, false, resolvedConfig.maxTotalSizeBytes), + failed: 1, + }; } - - return value * MULTIPLIERS[unit as keyof typeof MULTIPLIERS]; -} - -/** - * Validates retention configuration - */ -function validateRetentionConfig( - config: Config, - retentionConfig: SessionRetentionSettings, -): string | null { - if (retentionConfig.enabled !== true) { - 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 = firstNonEmptyString( - 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()) { - debugLogger.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 && - 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; } diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts deleted file mode 100644 index b664ee98ee..0000000000 --- a/packages/cli/src/utils/sessionUtils.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - * - * @plan PLAN-20260214-SESSIONBROWSER.P29 - */ - -import { - SESSION_FILE_PREFIX, - type ConversationRecord, -} from '@vybestack/llxprt-code-storage'; -import * as fs from 'node:fs/promises'; -import path from 'node:path'; -import { isRecord } from './typeGuards.js'; - -/** - * Session information for display and selection purposes. - */ -export interface SessionInfo { - /** Unique session identifier (filename without .json) */ - id: string; - /** Session file stem (without .json extension) */ - file?: string; - /** Full filename including .json extension */ - fileName: string; - /** ISO timestamp when session started */ - startTime?: string; - /** ISO timestamp when session was last updated */ - lastUpdated: string; - /** First user message in the session */ - firstUserMessage?: 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 => { - 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 => { - const filePath = path.join(chatsDir, file); - try { - const content = JSON.parse( - await fs.readFile(filePath, 'utf8'), - ) as Partial; - - // Validate required fields - if ( - !content.sessionId || - !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 userMsg = content.messages.find( - (message) => isRecord(message) && message.role === 'user', - ); - const userRecord = isRecord(userMsg) ? userMsg : undefined; - const parts = Array.isArray(userRecord?.parts) - ? userRecord.parts - : undefined; - const firstPart = parts?.find(isRecord); - const text = userRecord?.text ?? firstPart?.text; - const firstUserMessage = - typeof text === 'string' ? text : '(no message)'; - - const sessionInfo: SessionInfo = { - id: content.sessionId, - file: file.replace(/\.json$/, ''), - fileName: file, - startTime: content.startTime, - lastUpdated: content.lastUpdated, - firstUserMessage, - 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 => { - 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; -}; diff --git a/packages/core/package.json b/packages/core/package.json index f7ce7808e0..19f92a48dd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,6 +16,11 @@ "bun": "./index.ts", "import": "./dist/index.js" }, + "./recording/janitor/index.js": { + "types": "./dist/src/recording/janitor/index.d.ts", + "bun": "./src/recording/janitor/index.ts", + "import": "./dist/src/recording/janitor/index.js" + }, "./auth-factories.js": { "types": "./dist/src/auth-factories.d.ts", "bun": "./src/auth-factories.ts", diff --git a/packages/core/src/recording/ReplayEngine.ts b/packages/core/src/recording/ReplayEngine.ts index 4d46f20b35..7803b73a5c 100644 --- a/packages/core/src/recording/ReplayEngine.ts +++ b/packages/core/src/recording/ReplayEngine.ts @@ -28,6 +28,7 @@ import * as fs from 'node:fs'; import * as readline from 'node:readline'; +import { readBoundedFirstLine } from './boundedHeaderReader.js'; import { type ReplayResult, type SessionMetadata, @@ -855,6 +856,9 @@ export async function replaySessionThroughSequence( * Read only the session header (first line) from a JSONL file. * Useful for listing sessions without replaying the entire file. * + * Uses the canonical bounded header reader shared with session discovery and + * the janitor (Item 7). + * * @plan PLAN-20260211-SESSIONRECORDING.P08 * @requirement REQ-RPL-001 * @pseudocode replay-engine.md lines 175-198 @@ -865,37 +869,13 @@ export async function replaySessionThroughSequence( export async function readSessionHeader( filePath: string, ): Promise { + const firstLine = await readBoundedFirstLine(filePath); + if (firstLine === null) return null; try { - // @pseudocode line 177-178: Open stream and reader - const stream = fs.createReadStream(filePath, { encoding: 'utf-8' }); - const reader = readline.createInterface({ input: stream }); - let firstLine: string | null = null; - - // @pseudocode line 181-184: Read only first line - for await (const line of reader) { - firstLine = line; - break; - } - - // @pseudocode line 186-187: Clean up - reader.close(); - stream.destroy(); - - // @pseudocode line 189: Check if file was empty - if (firstLine === null) return null; - - // @pseudocode line 190a-190d: Strip UTF-8 BOM - if (firstLine.startsWith('\uFEFF')) { - firstLine = firstLine.slice(1); - } - - // @pseudocode line 192-194: Parse and validate const parsed = JSON.parse(firstLine) as Record; if (parsed.type !== 'session_start') return null; - return parsed.payload as SessionStartPayload; } catch { - // @pseudocode line 196: Return null on any error return null; } } diff --git a/packages/core/src/recording/SessionDiscovery.ts b/packages/core/src/recording/SessionDiscovery.ts index 23066459f0..4d2fc25039 100644 --- a/packages/core/src/recording/SessionDiscovery.ts +++ b/packages/core/src/recording/SessionDiscovery.ts @@ -28,6 +28,7 @@ import { createReadStream } from 'node:fs'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import * as readline from 'node:readline'; +import { readBoundedFirstLine } from './boundedHeaderReader.js'; import { SESSION_TITLE_MAX_LENGTH, type ContinueResolution, @@ -57,35 +58,19 @@ export interface SessionResolutionError { } /** - * Read the first line from a file using a partial buffer read. - * Much faster than opening a readline stream for each file. + * Read the first line from a file using the canonical bounded header reader. + * + * This is the single shared reader used by session discovery, resume, and the + * session-recording janitor. It handles UTF-8 BOM and first-line headers of + * any size up to a documented maximum, classifying no-newline/malformed huge + * files as unreadable without whole-file buffering. */ -async function readFirstLineFromFile( +export async function readFirstLineFromFile( filePath: string, ): Promise { - let fh: fs.FileHandle | undefined; + const firstLine = await readBoundedFirstLine(filePath); + if (firstLine === null || firstLine.trim() === '') return null; try { - fh = await fs.open(filePath, 'r'); - const buf = Buffer.alloc(4096); - const { bytesRead } = await fh.read(buf, 0, 4096, 0); - if (bytesRead === 0) return null; - - let chunk = buf.subarray(0, bytesRead).toString('utf-8'); - if (chunk.startsWith('\uFEFF')) chunk = chunk.slice(1); - - const newlineIdx = chunk.indexOf('\n'); - - // Fallback: if the first line exceeds the 4096-byte buffer, delegate to - // readSessionHeader which uses a full readline stream. - if (newlineIdx < 0 && bytesRead === buf.length) { - await fh.close(); - fh = undefined; - return await readSessionHeader(filePath); - } - - const firstLine = newlineIdx >= 0 ? chunk.slice(0, newlineIdx) : chunk; - if (firstLine.trim() === '') return null; - const parsed = JSON.parse(firstLine) as Record; if (parsed.type !== 'session_start') return null; if ( @@ -97,8 +82,6 @@ async function readFirstLineFromFile( return parsed.payload as SessionStartPayload; } catch { return null; - } finally { - await fh?.close(); } } diff --git a/packages/core/src/recording/SessionJanitor.ts b/packages/core/src/recording/SessionJanitor.ts new file mode 100644 index 0000000000..461c501723 --- /dev/null +++ b/packages/core/src/recording/SessionJanitor.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + SessionCleanupParams, + SessionCleanupResult, +} from './janitor/cleanupTypes.js'; + +export { + emptyResult, + type ResolvedRetentionConfig, + type SessionCleanupParams, + type SessionCleanupResult, + type UserRetentionSettings, +} from './janitor/cleanupTypes.js'; +export { resolveRetentionConfig } from './janitor/retentionPolicy.js'; + +/** Load the filesystem-heavy janitor only when a cleanup sweep is requested. */ +export async function runSessionCleanup( + params: SessionCleanupParams, +): Promise { + const janitor = await import('./janitor/sessionJanitor.js'); + return janitor.runSessionCleanup(params); +} diff --git a/packages/core/src/recording/SessionLockManager.internals.ts b/packages/core/src/recording/SessionLockManager.internals.ts new file mode 100644 index 0000000000..ab1aea8a72 --- /dev/null +++ b/packages/core/src/recording/SessionLockManager.internals.ts @@ -0,0 +1,778 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Heavy implementation for the advisory session-lock manager. + * + * This module is NOT barrel-exported. It is loaded lazily via dynamic + * `import()` from {@link SessionLockManager.ts} (the eager facade) only when + * an async lock operation is actually called. Keeping the hardened + * ownership, atomic-publication, transition-claim, stale-check, cleanup, and + * filesystem code out of the eager import graph prevents the cold-start + * regression where importing the core public root caused per-test timeouts + * in the agents test runner (separate Bun process per file). + * + * @plan PLAN-20260211-SESSIONRECORDING.P11 + * @requirement REQ-CON-001, REQ-CON-002, REQ-CON-003, REQ-CON-004, REQ-CON-005 + * @pseudocode concurrency-lifecycle.md lines 10-134, 257-282, 290-346 + * + * Hardened ownership safety (Item 3): + * - Random backward-compatible owner tokens identify each acquisition. + * - Locks are published atomically via temp-file + hard-link so no partial + * lock content can ever appear at the lock path. + * - Unreadable/recent lock files are treated as **busy**, not instantly stale. + * - Stale takeover re-reads the lock before unlinking to avoid removing a + * replacement live lock; the final create uses atomic exclusive creation. + * - Release unlinks only when the on-disk owner token still matches. + * - A per-session filesystem transition guard serializes ALL pathname + * mutations — acquire, stale takeover, removeStaleLock, orphan cleanup, + * and release — so a stale checker can never unlink a replacement live + * lock. The guard is an **atomic hard-link claim** tied to the current + * lock inode: `link(lockPath, guardPath)` succeeds for exactly one + * contender (EEXIST means another owns it). A crashed claim is safe to + * remove because unlinking a hard link only decrements a link count and + * cannot remove/replace the live lock. Every mutator verifies that the + * guard and lock path still share the same inode before touching + * lockPath. Guard crash recovery preserves the live-PID/EPERM and + * 48-hour PID-reuse semantics. + * - Destructive janitor ownership is verified through the token-bound + * {@link LockHandle.ownsLock} immediately before mutation. + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as crypto from 'node:crypto'; +import { + isValidSafeSessionId, + isDirectChildPath, +} from './janitor/sessionSafety.js'; +import { + SessionLockManager, + SessionLockedError, + type LockHandle, +} from './SessionLockManager.js'; + +/** Fixed age bound for PID-reuse staleness (48 hours). */ +const LOCK_MAX_AGE_MS = 48 * 60 * 60 * 1000; + +/** Suffix for the per-session transition guard file. */ +const TRANSITION_GUARD_SUFFIX = '.tguard'; + +/** Suffix for orphaned temp publication artifacts. */ +const LOCK_TEMP_SUFFIX = '.locktmp'; + +/** + * Exact grammar for stale lock temp publication artifacts. + * Matches `.lock..locktmp` where the uuid is a v4 UUID. + * This is deliberately strict so unknown files are never deleted. + */ +const LOCK_TEMP_GRAMMAR = + /^[A-Za-z0-9_-]+\.lock\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.locktmp$/; + +/** Conservative age threshold for reclaiming orphaned lock temp files. */ +const STALE_LOCK_TEMP_AGE_MS = 5 * 60 * 1000; + +/** + * Maps lock path to owner token for all currently held locks in this process. + * Module-global: a single Map instance shared across all calls within the + * same ESM module identity (guaranteed by the dynamic import cache). + */ +const ownedLockPaths = new Map(); + +/** @pseudocode concurrency-lifecycle.md lines 24-75 */ +export async function acquire( + chatsDir: string, + sessionId: string, +): Promise { + const lockPath = SessionLockManager.getLockPath(chatsDir, sessionId); + if (ownedLockPaths.has(lockPath)) { + throw new SessionLockedError(); + } + const ownerToken = crypto.randomUUID(); + const lockTimestamp = new Date().toISOString(); + const lockContent = JSON.stringify({ + pid: process.pid, + timestamp: lockTimestamp, + sessionId, + ownerToken, + }); + + // Try atomic exclusive creation first. + const created = await tryCreateLock(lockPath, lockContent); + if (!created) { + // Lock exists — attempt conservative stale takeover. + const takenOver = await tryStaleTakeover(lockPath, lockContent); + if (!takenOver) { + throw new SessionLockedError(); + } + } + + ownedLockPaths.set(lockPath, ownerToken); + let released = false; + return { + lockPath, + ownsLock: async (): Promise => + checkOwnership(lockPath, ownerToken), + release: async (): Promise => { + if (released) return; + released = true; + ownedLockPaths.delete(lockPath); + // Ownership-checked release: only unlink when the on-disk lock still + // carries our owner token. This prevents removing a lock that another + // process acquired or replaced after our original acquisition. + await releaseIfOwned(lockPath, ownerToken); + }, + }; +} + +/** + * Write the complete lock payload to a temp file (O_EXCL) and sync it. + * Returns true on success, false for a retryable collision (EEXIST) or a + * missing parent directory (ENOENT). All other errors (ENOSPC, EACCES, + * EROFS, EDQUOT, …) are rethrown so the caller does not mistake them for + * "lock already exists". + */ +async function writeTempLockFile( + tempPath: string, + lockContent: string, +): Promise { + let fd: fs.FileHandle | undefined; + try { + fd = await fs.open(tempPath, 'wx'); + await fd.writeFile(lockContent, 'utf-8'); + await fd.sync(); + return true; + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException).code; + // EEXIST (uuid collision) or ENOENT (parent dir missing) -> retryable. + if (code === 'EEXIST' || code === 'ENOENT') return false; + throw error; // Propagate genuine I/O failures. + } finally { + await fd?.close().catch(() => {}); + } +} + +/** + * Atomically link the temp file to the lock path (exclusive creation). + * Cleans up the temp file regardless of outcome. + * + * Returns `true` on success, `false` **only** for `EEXIST` (the lock + * already exists). Any other error (ENOSPC, EACCES, EROFS, EDQUOT, …) is + * rethrown so the caller cannot mistake a transient I/O failure for "lock + * busy" and proceed to stale-takeover. + */ +async function publishTempToLock( + tempPath: string, + lockPath: string, +): Promise { + try { + await fs.link(tempPath, lockPath); + return true; + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + return false; + } finally { + await safeUnlink(tempPath); + } +} + +/** + * Atomically publish a complete lock file using a temp file + hard link. + * + * The temp file is fully written and synced before linking, so the lock + * path only ever contains a complete payload — never a partial write. + * Returns `true` on success, `false` if the lock already exists (EEXIST). + */ +async function tryCreateLock( + lockPath: string, + lockContent: string, +): Promise { + const tempPath = lockPath + '.' + crypto.randomUUID() + '.locktmp'; + + let written = await writeTempLockFile(tempPath, lockContent); + if (!written) { + // Parent dir may not exist — create it and retry once. + try { + await fs.mkdir(path.dirname(lockPath), { recursive: true }); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + } + written = await writeTempLockFile(tempPath, lockContent); + } + if (!written) { + await safeUnlink(tempPath); + return false; + } + + // Atomically link the temp file to the lock path. + return publishTempToLock(tempPath, lockPath); +} + +/** + * Attempt to take over a stale lock under the transition guard. The guard + * serializes this mutation so a concurrent process cannot replace the lock + * between the stale determination and the unlink. + */ +async function tryStaleTakeover( + lockPath: string, + lockContent: string, +): Promise { + if (!(await acquireTransitionGuard(lockPath))) { + return false; // Another process is transitioning — busy. + } + try { + // Read current content for ownership verification. + let originalContent: string; + try { + originalContent = await fs.readFile(lockPath, 'utf-8'); + } catch { + // Can't read — try to create fresh. + return await tryCreateLock(lockPath, lockContent); + } + + const isStale = await checkStaleWithPidReuse(lockPath); + if (!isStale) { + return false; + } + + // Re-read to verify the lock hasn't been replaced between the stale + // determination and the unlink. If the content changed, another process + // owns this lock now — skip. + try { + const currentContent = await fs.readFile(lockPath, 'utf-8'); + if (currentContent !== originalContent) { + return false; + } + } catch { + // Vanished between reads — try to create fresh. + return await tryCreateLock(lockPath, lockContent); + } + + // Verify the transition claim still identifies the same inode as + // the lock before unlinking. If the lock was replaced by another + // process, the inodes will differ and we must skip. + if (!(await verifyTransitionClaim(lockPath))) { + return false; + } + + // Unlink the stale lock. + try { + await fs.unlink(lockPath); + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') return false; + } + + // Atomically create our lock. If another process won the race between + // our unlink and this create, the link fails with EEXIST and we lose. + return await tryCreateLock(lockPath, lockContent); + } finally { + await releaseTransitionGuard(lockPath); + } +} + +/** @pseudocode concurrency-lifecycle.md lines 77-96 */ +export async function checkStale(lockPath: string): Promise { + let content: string; + try { + content = await fs.readFile(lockPath, 'utf-8'); + } catch { + // Unreadable lock — treat as busy, not instantly stale (Item 3). + return false; + } + + let lockData: { pid?: unknown }; + try { + lockData = JSON.parse(content); + } catch { + // Corrupt JSON — treat as busy, not instantly stale (Item 3). + return false; + } + + // Validate PID as a positive safe integer before process.kill so that an + // undefined/invalid pid is not coerced to signal our own process group. + if ( + typeof lockData.pid !== 'number' || + !Number.isSafeInteger(lockData.pid) || + lockData.pid <= 0 + ) { + return false; + } + const lockPid = lockData.pid; + + if (lockPid === process.pid && ownedLockPaths.has(lockPath)) { + return false; + } + try { + process.kill(lockPid, 0); + return false; + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM') { + return false; + } + return true; + } +} + +/** @pseudocode concurrency-lifecycle.md lines 104-114 */ +export async function isLocked( + chatsDir: string, + sessionId: string, +): Promise { + const lockPath = SessionLockManager.getLockPath(chatsDir, sessionId); + try { + await fs.access(lockPath); + const stale = await checkStale(lockPath); + return !stale; + } catch { + return false; + } +} + +/** @pseudocode concurrency-lifecycle.md lines 116-124 */ +export async function isStale( + chatsDir: string, + sessionId: string, +): Promise { + const lockPath = SessionLockManager.getLockPath(chatsDir, sessionId); + try { + await fs.access(lockPath); + return await checkStale(lockPath); + } catch { + return false; + } +} + +/** @pseudocode concurrency-lifecycle.md lines 126-133 */ +export async function removeStaleLock( + chatsDir: string, + sessionId: string, +): Promise { + const lockPath = SessionLockManager.getLockPath(chatsDir, sessionId); + // Route through the hardened removal path that re-reads the lock content + // before unlinking and holds the transition guard (AC-8 / Item 3). + await tryRemoveStaleLock(lockPath, chatsDir); +} + +/** @pseudocode concurrency-lifecycle.md lines 257-282 */ +export async function cleanupOrphanedLocks(chatsDir: string): Promise { + let files: string[]; + try { + files = await fs.readdir(chatsDir); + } catch { + return 0; + } + const lockFiles = files.filter( + (f) => f.endsWith('.lock') && f.length > '.lock'.length, + ); + let removed = 0; + + for (const lockFile of lockFiles) { + const lockPath = path.join(chatsDir, lockFile); + if (await tryRemoveStaleLock(lockPath, chatsDir)) { + removed++; + } + } + + // Also reclaim orphaned stale transition guards left by crashed processes. + // Validate exact grammar, direct-child, and regular non-symlink lstat + // before any stale check or unlink — consistent with lock temp cleanup. + const guardFiles = files.filter((f) => f.endsWith(TRANSITION_GUARD_SUFFIX)); + for (const guardFile of guardFiles) { + await cleanupStaleGuard(chatsDir, guardFile); + } + + // Clean up orphaned temp publication artifacts from crashed lock + // acquisitions. Only files matching the exact generated grammar + // (`.lock..locktmp`) that are regular non-symlink direct + // children and older than the conservative age threshold are removed. + const tempFiles = files.filter((f) => f.endsWith(LOCK_TEMP_SUFFIX)); + for (const tempFile of tempFiles) { + await cleanupStaleLockTemp(chatsDir, tempFile); + } + + return removed; +} + +/** + * Remove a single lock file only when: + * 1. The filename matches the safe lock grammar (`.lock`). + * 2. The lock is stale. + * 3. Its on-disk content has not changed between stale determination and + * unlink (hardened against ownership replacement, Item 3/AC-8). + * 4. The payload's sessionId matches the filename. + * + * Returns true when removed. + */ +async function tryRemoveStaleLock( + lockPath: string, + chatsDir: string, +): Promise { + // Validate the lock path is a safe direct child of chatsDir. + if (!isDirectChildPath(chatsDir, lockPath)) return false; + + // Validate the lock filename matches the safe grammar. + const basename = path.basename(lockPath); + const lockIdMatch = basename.match(/^(.+)\.lock$/); + if (!lockIdMatch) return false; + const lockSessionId = lockIdMatch[1]; + if (!isValidSafeSessionId(lockSessionId)) return false; + + // Acquire the transition guard to serialize this mutation. + if (!(await acquireTransitionGuard(lockPath))) { + return false; // Another process is transitioning — busy. + } + try { + // Read content for ownership verification before stale determination. + let originalContent: string; + try { + originalContent = await fs.readFile(lockPath, 'utf-8'); + } catch { + return false; // Can't read — treat as busy, skip. + } + + // Validate payload identity: the payload sessionId should match the + // filename's sessionId. + let payloadSessionId: string | undefined; + try { + const parsed = JSON.parse(originalContent) as Record; + if ( + typeof parsed.sessionId === 'string' && + isValidSafeSessionId(parsed.sessionId) + ) { + payloadSessionId = parsed.sessionId; + } + } catch { + // Corrupt payload — skip (busy, not stale). + return false; + } + if (payloadSessionId !== undefined && payloadSessionId !== lockSessionId) { + return false; // Filename/payload identity mismatch — skip. + } + + const isStale = await checkStaleWithPidReuse(lockPath); + if (!isStale) { + return false; + } + + // Re-read to verify the lock hasn't been replaced between stale + // determination and unlink. + try { + const currentContent = await fs.readFile(lockPath, 'utf-8'); + if (currentContent !== originalContent) { + return false; + } + } catch { + return false; // Vanished — benign. + } + + // Verify the transition claim still identifies the same inode as + // the lock before unlinking. + if (!(await verifyTransitionClaim(lockPath))) { + return false; + } + + try { + await fs.unlink(lockPath); + return true; + } catch { + return false; // Best-effort. + } + } finally { + await releaseTransitionGuard(lockPath); + } +} + +/** @pseudocode concurrency-lifecycle.md lines 290-346 */ +export async function checkStaleWithPidReuse( + lockPath: string, +): Promise { + let content: string; + try { + content = await fs.readFile(lockPath, 'utf-8'); + } catch { + // Unreadable — use file mtime as the fallback age bound (Item 3). + return isOlderThanBound(lockPath); + } + + let lockData: { pid?: unknown; timestamp?: unknown }; + try { + lockData = JSON.parse(content); + } catch { + // Corrupt JSON — use file mtime as the fallback age bound (Item 3). + return isOlderThanBound(lockPath); + } + + // Validate PID as a positive safe integer before process.kill so that an + // undefined/invalid pid is not coerced to signal our own process group. + if ( + typeof lockData.pid !== 'number' || + !Number.isSafeInteger(lockData.pid) || + lockData.pid <= 0 + ) { + // Malformed PID — fall back to age-based stale detection. + return isOlderThanBound(lockPath); + } + const lockPid = lockData.pid; + + try { + process.kill(lockPid, 0); + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'EPERM') { + return true; // ESRCH -> dead PID -> stale. + } + } + + // Validate the timestamp as a finite date. A missing or malformed + // timestamp would make `Date.now() - NaN` evaluate to NaN, and + // `NaN > LOCK_MAX_AGE_MS` is always false — making the lock immortal. + // Fall back to the mtime-based age bound instead (consistent with the + // corrupt-JSON and invalid-PID paths above). + const timestampMs = new Date( + typeof lockData.timestamp === 'string' ? lockData.timestamp : '', + ).getTime(); + if (!Number.isFinite(timestampMs)) { + return isOlderThanBound(lockPath); + } + return Date.now() - timestampMs > LOCK_MAX_AGE_MS; +} + +/** + * Check whether the file at `filePath` is older than the PID-reuse age + * bound, using `stat` mtime. Used as the conservative fallback for + * unreadable/corrupt locks so they are eventually reclaimable without + * being instantly treated as stale. + */ +async function isOlderThanBound(filePath: string): Promise { + try { + const stat = await fs.stat(filePath); + return Date.now() - stat.mtimeMs > LOCK_MAX_AGE_MS; + } catch { + return false; // Can't stat — conservatively not stale. + } +} + +/** + * Check whether the on-disk lock at `lockPath` still carries `ownerToken`. + */ +async function checkOwnership( + lockPath: string, + ownerToken: string, +): Promise { + try { + const content = await fs.readFile(lockPath, 'utf-8'); + const data = JSON.parse(content) as Record; + return data.ownerToken === ownerToken; + } catch { + return false; + } +} + +/** + * Release the lock by unlinking only when the on-disk owner token matches. + * Holds the transition guard so a concurrent takeover cannot replace the + * lock between the ownership check and the unlink. + */ +async function releaseIfOwned( + lockPath: string, + ownerToken: string, +): Promise { + if (!(await acquireTransitionGuard(lockPath))) { + return; // Can't acquire guard — best-effort, leave lock in place. + } + try { + const content = await fs.readFile(lockPath, 'utf-8'); + const data = JSON.parse(content) as Record; + if (data.ownerToken !== ownerToken) { + // We don't own this lock anymore — don't remove it. + return; + } + // Verify the transition claim still identifies the same inode as + // the lock before unlinking. + if (!(await verifyTransitionClaim(lockPath))) { + return; + } + await fs.unlink(lockPath); + } catch { + // Best-effort release. + } finally { + await releaseTransitionGuard(lockPath); + } +} + +// ----------------------------------------------------------------------- +// Per-session transition guard (root safety fix 1) +// ----------------------------------------------------------------------- + +/** Return the guard path for a given lock path. */ +function getGuardPath(lockPath: string): string { + return lockPath + TRANSITION_GUARD_SUFFIX; +} + +/** + * Acquire the per-session transition claim by atomically hard-linking + * the **current lock inode** to the guard path. + * + * Unlike a separate-owner guard (which has its own PID and suffers a + * check-stale-then-unlink race), the hard-link claim is tied to the lock + * inode itself: + * + * - `link(lockPath, guardPath)` succeeds for exactly one contender; others + * get EEXIST and fail/skip. + * - ENOENT means the lock does not exist — fresh creates use exclusive + * link/create so no claim is needed. + * - A crashed claim is a hard link, so removing it only decrements a link + * count and **cannot remove or replace the live lock** at lockPath. + * - If claim recovery races, every mutator must re-establish and validate + * its own claim via {@link verifyTransitionClaim} before touching + * lockPath. + * + * Returns true when the claim is held (or no lock exists to guard). + */ +async function acquireTransitionGuard(lockPath: string): Promise { + const guardPath = getGuardPath(lockPath); + + // Atomically claim the lock inode. + try { + await fs.link(lockPath, guardPath); + return true; // Claimed the lock inode. + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + // Lock does not exist — no inode to claim. + return true; + } + if (code !== 'EEXIST') return false; + } + + // Another transition owns the claim. Attempt conservative recovery. + return tryReclaimGuard(lockPath); +} + +/** + * Conservatively reclaim a stale transition claim. + * + * The guard is a hard link to a lock inode, so its content IS the lock + * content. When the lock content indicates staleness (dead PID or past + * the 48-hour PID-reuse bound), the claim owner has crashed and the guard + * is safe to remove — it only decrements a link count. + */ +async function tryReclaimGuard(lockPath: string): Promise { + const guardPath = getGuardPath(lockPath); + + if (!(await checkStaleWithPidReuse(guardPath))) { + return false; // Guard content indicates a live transition owner. + } + + try { + await fs.unlink(guardPath); + } catch { + return false; // Can't reclaim — busy. + } + + // Retry the claim. The lock inode may have changed during recovery. + try { + await fs.link(lockPath, guardPath); + return true; + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return true; // Lock vanished — no claim needed. + return false; + } +} + +/** + * Verify that the transition guard and the lock path still identify the + * **same inode**. Every mutator must call this before unlinking lockPath + * to ensure the lock has not been replaced by another process since the + * claim was acquired. + */ +async function verifyTransitionClaim(lockPath: string): Promise { + const guardPath = getGuardPath(lockPath); + try { + const lockStat = await fs.stat(lockPath); + const guardStat = await fs.stat(guardPath); + return lockStat.dev === guardStat.dev && lockStat.ino === guardStat.ino; + } catch { + return false; + } +} + +/** Release the transition guard (best-effort). */ +async function releaseTransitionGuard(lockPath: string): Promise { + await safeUnlink(getGuardPath(lockPath)); +} + +/** Best-effort unlink that swallows errors. */ +async function safeUnlink(filePath: string): Promise { + try { + await fs.unlink(filePath); + } catch { + // Best-effort. + } +} + +/** + * Safely clean up a single stale lock temp publication artifact. Only + * removes the file when it matches the exact generated grammar, is a + * regular non-symlink direct child of chatsDir, and is older than the + * conservative age threshold. Unknown files are never deleted. + */ +async function cleanupStaleLockTemp( + chatsDir: string, + fileName: string, +): Promise { + if (!LOCK_TEMP_GRAMMAR.test(fileName)) return; + const filePath = path.join(chatsDir, fileName); + if (!isDirectChildPath(chatsDir, filePath)) return; + try { + const lstat = await fs.lstat(filePath); + if (lstat.isSymbolicLink() || !lstat.isFile()) return; + if (Date.now() - lstat.mtimeMs <= STALE_LOCK_TEMP_AGE_MS) return; + } catch { + return; // Can't stat — leave it. + } + await safeUnlink(filePath); +} + +/** + * Safely clean up a single orphaned transition guard file. Only removes the + * file when the filename matches the exact safe-session grammar + * (`.lock.tguard`), it is a regular non-symlink direct child + * of chatsDir, and its content indicates staleness. Unknown files and + * symlinks are never deleted. + */ +async function cleanupStaleGuard( + chatsDir: string, + fileName: string, +): Promise { + const guardIdMatch = fileName.match(/^(.+)\.lock\.tguard$/); + if (!guardIdMatch) return; + if (!isValidSafeSessionId(guardIdMatch[1])) return; + const guardPath = path.join(chatsDir, fileName); + if (!isDirectChildPath(chatsDir, guardPath)) return; + try { + const lstat = await fs.lstat(guardPath); + if (lstat.isSymbolicLink() || !lstat.isFile()) return; + } catch { + return; // Can't stat — leave it. + } + if (await checkStaleWithPidReuse(guardPath)) { + await safeUnlink(guardPath); + } +} diff --git a/packages/core/src/recording/SessionLockManager.lazy.test.ts b/packages/core/src/recording/SessionLockManager.lazy.test.ts new file mode 100644 index 0000000000..0ce38ac261 --- /dev/null +++ b/packages/core/src/recording/SessionLockManager.lazy.test.ts @@ -0,0 +1,171 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Regression test for the lazy facade/implementation split of + * SessionLockManager. + * + * All checks run in fresh Bun subprocesses so that process-wide static state + * (the cached import promise) is not contaminated by other test files in the + * same suite run. This proves: + * + * - Importing SessionLockManager.ts (the facade) does NOT eagerly evaluate + * the heavy internals module. + * - Sync path methods work without loading the internals. + * - An async operation triggers the lazy load. + * - Importing the recording barrel (which re-exports the facade) does NOT + * eagerly load the internals. + * - Importing the core public root (which eagerly imports the recording + * barrel) does NOT eagerly load the internals — this is the exact + * cold-start path that caused the regression. + * + * @plan PLAN-20260211-SESSIONRECORDING.P11 + */ + +import { describe, it, expect } from 'bun:test'; +import * as path from 'node:path'; +import { spawn } from 'node:child_process'; + +/** + * Run a Bun subprocess with the given inline script and return stdout. + * Rejects on non-zero exit, timeout (default 30s), or spawn error. + */ +function runBunScript(code: string, timeoutMs = 30000): Promise { + return new Promise((resolve, reject) => { + const child = spawn('bun', ['-e', code], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env }, + }); + let stdout = ''; + let stderr = ''; + let settled = false; + + const timer = setTimeout(() => { + child.kill('SIGTERM'); + }, timeoutMs); + + const cleanup = () => clearTimeout(timer); + + child.stdout.on('data', (d: Buffer) => (stdout += d.toString())); + child.stderr.on('data', (d: Buffer) => (stderr += d.toString())); + + child.on('close', (code: number | null) => { + cleanup(); + if (settled) return; + settled = true; + if (code === 0) resolve(stdout); + else + reject( + new Error(`Process exited with code ${code}\nstderr: ${stderr}`), + ); + }); + + child.on('error', (err: Error) => { + cleanup(); + try { + child.kill('SIGTERM'); + } catch { + // Already exited. + } + if (!settled) { + settled = true; + reject(err); + } + }); + }); +} + +/** Absolute path to the facade module (resolved at test load time). */ +const FACADE_PATH = path.resolve(__dirname, 'SessionLockManager.js'); +/** Absolute path to the recording barrel. */ +const BARREL_PATH = path.resolve(__dirname, 'index.js'); +/** Absolute path to the core public root. */ +const CORE_ROOT_PATH = path.resolve(__dirname, '..', 'index.js'); + +describe('SessionLockManager lazy loading @plan:PLAN-20260211-SESSIONRECORDING.P11', () => { + it('importing the facade does not eagerly load the heavy implementation', async () => { + const result = await runBunScript( + `const { SessionLockManager } = require(${JSON.stringify(FACADE_PATH)});` + + `process.stdout.write(Object.keys(require.cache).some((loadedPath) => loadedPath.includes('SessionLockManager.internals')) ? 'LOADED' : 'NOT_LOADED');`, + ); + expect(result.trim()).toBe('NOT_LOADED'); + }); + + it('sync getLockPath and getLockPathFromFilePath work without loading the heavy implementation', async () => { + const result = await runBunScript( + `const { SessionLockManager } = require(${JSON.stringify(FACADE_PATH)});` + + `SessionLockManager.getLockPath('/tmp/chats', 'abc');` + + `SessionLockManager.getLockPathFromFilePath('/tmp/chats/session-abc.jsonl');` + + `process.stdout.write(Object.keys(require.cache).some((loadedPath) => loadedPath.includes('SessionLockManager.internals')) ? 'LOADED' : 'NOT_LOADED');`, + ); + expect(result.trim()).toBe('NOT_LOADED'); + }); + + it('an async operation loads the heavy implementation (lazy)', async () => { + const result = await runBunScript( + `const { SessionLockManager } = require(${JSON.stringify(FACADE_PATH)});` + + `(async () => {` + + `const before = Object.keys(require.cache).some((loadedPath) => loadedPath.includes('SessionLockManager.internals'));` + + `await SessionLockManager.isLocked('/tmp/nonexistent-lazy-check', 'x');` + + `const after = Object.keys(require.cache).some((loadedPath) => loadedPath.includes('SessionLockManager.internals'));` + + `process.stdout.write(before ? '1' : '0');` + + `process.stdout.write(after ? '1' : '0');` + + `})();`, + ); + // before=0 (not loaded), after=1 (loaded by async call) + expect(result.trim()).toBe('01'); + }); + + it('importing the recording barrel does not eagerly load the heavy lock implementation', async () => { + const result = await runBunScript( + `const mod = require(${JSON.stringify(BARREL_PATH)});` + + `process.stdout.write(Object.keys(require.cache).some((loadedPath) => loadedPath.includes('SessionLockManager.internals')) ? 'LOADED' : 'NOT_LOADED');`, + ); + expect(result.trim()).toBe('NOT_LOADED'); + }); + + it('importing the core public root does not eagerly load the heavy lock implementation', async () => { + const result = await runBunScript( + `const mod = require(${JSON.stringify(CORE_ROOT_PATH)});` + + `process.stdout.write(Object.keys(require.cache).some((loadedPath) => loadedPath.includes('SessionLockManager.internals')) ? 'LOADED' : 'NOT_LOADED');`, + ); + expect(result.trim()).toBe('NOT_LOADED'); + }, 35000); + + it('importing the core public root does not eagerly load the session janitor', async () => { + const result = await runBunScript( + `const mod = require(${JSON.stringify(CORE_ROOT_PATH)});` + + `mod.resolveRetentionConfig({});` + + `process.stdout.write(Object.keys(require.cache).some((loadedPath) => loadedPath.includes('janitor/sessionJanitor')) ? 'LOADED' : 'NOT_LOADED');`, + ); + expect(result.trim()).toBe('NOT_LOADED'); + }, 35000); + + it('loads the session janitor when a cleanup sweep is requested', async () => { + const result = await runBunScript( + `const mod = require(${JSON.stringify(CORE_ROOT_PATH)});` + + `(async () => {` + + `const config = mod.resolveRetentionConfig({ enabled: false });` + + `const before = Object.keys(require.cache).some((loadedPath) => loadedPath.includes('janitor/sessionJanitor'));` + + `await mod.runSessionCleanup({ globalTempDir: '/tmp', config });` + + `const after = Object.keys(require.cache).some((loadedPath) => loadedPath.includes('janitor/sessionJanitor'));` + + `process.stdout.write(before ? '1' : '0');` + + `process.stdout.write(after ? '1' : '0');` + + `})();`, + ); + expect(result.trim()).toBe('01'); + }, 35000); +}); diff --git a/packages/core/src/recording/SessionLockManager.property.test.ts b/packages/core/src/recording/SessionLockManager.property.test.ts index 5983f45782..aaa8402907 100644 --- a/packages/core/src/recording/SessionLockManager.property.test.ts +++ b/packages/core/src/recording/SessionLockManager.property.test.ts @@ -798,4 +798,77 @@ describe('PID reuse protection @requirement:REQ-CON-005 @plan:PLAN-20260211-SESS } }, ); + + // ------------------------------------------------------------------------- + // Missing/malformed timestamp must not make a live-PID lock immortal + // (OCR finding 2/12). A valid/alive PID with missing/malformed timestamp + // must fall back to the mtime-based 48-hour bound. + // ------------------------------------------------------------------------- + + /** + * Write a lock file whose payload has an alive PID but a missing or + * malformed timestamp, then optionally back-date the file's mtime. + */ + async function writeLockWithBadTimestamp( + lockPath: string, + payload: Record, + ageMs = 0, + ): Promise { + await fs.mkdir(path.dirname(lockPath), { recursive: true }); + await fs.writeFile(lockPath, JSON.stringify(payload), 'utf-8'); + if (ageMs > 0) { + const old = new Date(Date.now() - ageMs); + await fs.utimes(lockPath, old, old); + } + } + + const FORTY_NINE_HOURS = 49 * 60 * 60 * 1000; + + itProp( + 'checkStaleWithPidReuse falls back to age bound for alive PID with missing timestamp older than 48h', + async () => { + const sessionId = 'bad-ts-missing-old'; + const lockPath = SessionLockManager.getLockPath(chatsDir, sessionId); + // Alive PID, NO timestamp field. + await writeLockWithBadTimestamp( + lockPath, + { pid: process.pid, sessionId }, + FORTY_NINE_HOURS, + ); + + const stale = await SessionLockManager.checkStaleWithPidReuse(lockPath); + expect(stale).toBe(true); + }, + ); + + itProp( + 'checkStaleWithPidReuse does not flag a recent alive-PID lock with missing timestamp as stale', + async () => { + const sessionId = 'bad-ts-missing-recent'; + const lockPath = SessionLockManager.getLockPath(chatsDir, sessionId); + await writeLockWithBadTimestamp(lockPath, { + pid: process.pid, + sessionId, + }); + + const stale = await SessionLockManager.checkStaleWithPidReuse(lockPath); + expect(stale).toBe(false); + }, + ); + + itProp( + 'checkStaleWithPidReuse falls back to age bound for alive PID with malformed timestamp older than 48h', + async () => { + const sessionId = 'bad-ts-malformed-old'; + const lockPath = SessionLockManager.getLockPath(chatsDir, sessionId); + await writeLockWithBadTimestamp( + lockPath, + { pid: process.pid, timestamp: 'not-a-date', sessionId }, + FORTY_NINE_HOURS, + ); + + const stale = await SessionLockManager.checkStaleWithPidReuse(lockPath); + expect(stale).toBe(true); + }, + ); }); diff --git a/packages/core/src/recording/SessionLockManager.safety.test.ts b/packages/core/src/recording/SessionLockManager.safety.test.ts new file mode 100644 index 0000000000..4203b6b47a --- /dev/null +++ b/packages/core/src/recording/SessionLockManager.safety.test.ts @@ -0,0 +1,668 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Adversarial safety tests for the hardened SessionLockManager (Items 2 & 3). + * + * Tests prove: + * - Unsafe/path-like session IDs are rejected by getLockPath. + * - Lock paths are always direct children of chatsDir. + * - Lock files carry random owner tokens and are published atomically. + * - Unreadable/recent lock files are treated as busy, not instantly stale. + * - Lock filename/payload identity is validated. + * - Stale takeover does not remove a replacement live lock. + * - Release unlinks only its own current token. + * - The transition guard serializes pathname mutations so a stale checker + * cannot unlink a replacement live lock (genuine subprocess race). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawn } from 'node:child_process'; +import { + SessionLockManager, + SessionLockedError, +} from './SessionLockManager.js'; + +const DEAD_PID = 999999999; + +async function makeTempDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'lock-safety-')); +} + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +describe('SessionLockManager — safe session ID grammar (Item 2)', () => { + let tempDir: string; + let chatsDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + chatsDir = path.join(tempDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('rejects path-traversal session IDs in getLockPath', () => { + expect(() => SessionLockManager.getLockPath(chatsDir, '../evil')).toThrow( + 'Unsafe session ID', + ); + expect(() => + SessionLockManager.getLockPath(chatsDir, '../../etc/passwd'), + ).toThrow('Unsafe session ID'); + }); + + it('rejects session IDs with path separators', () => { + expect(() => SessionLockManager.getLockPath(chatsDir, 'a/b')).toThrow( + 'Unsafe session ID', + ); + expect(() => SessionLockManager.getLockPath(chatsDir, 'a\\b')).toThrow( + 'Unsafe session ID', + ); + }); + + it('rejects session IDs with dots', () => { + expect(() => SessionLockManager.getLockPath(chatsDir, 'a.b')).toThrow( + 'Unsafe session ID', + ); + expect(() => SessionLockManager.getLockPath(chatsDir, '..')).toThrow( + 'Unsafe session ID', + ); + }); + + it('accepts valid UUID and alphanumeric session IDs', () => { + expect(() => + SessionLockManager.getLockPath( + chatsDir, + '550e8400-e29b-41d4-a716-446655440000', + ), + ).not.toThrow(); + expect(() => + SessionLockManager.getLockPath(chatsDir, 'session-abc_123'), + ).not.toThrow(); + }); + + it('guarantees lock path is a direct child of chatsDir', () => { + const lockPath = SessionLockManager.getLockPath( + chatsDir, + 'valid-session-id', + ); + expect(path.dirname(lockPath)).toBe(chatsDir); + expect(path.basename(lockPath)).toBe('valid-session-id.lock'); + }); + + it('accepts a chatsDir with a trailing path separator', () => { + const chatsDirWithSep = chatsDir + path.sep; + const lockPath = SessionLockManager.getLockPath( + chatsDirWithSep, + 'trailing-sep-test', + ); + expect(path.basename(lockPath)).toBe('trailing-sep-test.lock'); + expect(path.dirname(lockPath)).toBe(chatsDir); + }); + + it('refuses to acquire a lock with an unsafe session ID', async () => { + // An unsafe/path-like session ID is a validation failure, distinct from a + // busy session. The rejection must surface the "Unsafe session ID" reason. + await expect( + SessionLockManager.acquire(chatsDir, '../evil'), + ).rejects.toThrow('Unsafe session ID'); + // No lock file should have been created outside the chats dir. + expect(await fileExists(path.join(tempDir, 'evil.lock'))).toBe(false); + }); +}); + +describe('SessionLockManager — owner tokens and atomic publication (Item 3)', () => { + let tempDir: string; + let chatsDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + chatsDir = path.join(tempDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('writes a random ownerToken in the lock file', async () => { + const handle = await SessionLockManager.acquire(chatsDir, 'token-test'); + const lockPath = SessionLockManager.getLockPath(chatsDir, 'token-test'); + const raw = await fs.readFile(lockPath, 'utf-8'); + const data = JSON.parse(raw); + expect(typeof data.ownerToken).toBe('string'); + expect(data.ownerToken.length).toBeGreaterThan(0); + await handle.release(); + }); + + it('does not leave partial/temp lock artifacts after successful acquire', async () => { + const handle = await SessionLockManager.acquire(chatsDir, 'artifact-test'); + // The only .lock file should be the real lock path. + const entries = await fs.readdir(chatsDir); + const lockFiles = entries.filter((f) => f.endsWith('.lock')); + expect(lockFiles).toEqual(['artifact-test.lock']); + // No .locktmp temp files should remain. + const tempFiles = entries.filter((f) => f.endsWith('.locktmp')); + expect(tempFiles).toEqual([]); + await handle.release(); + }); + + it('exposes ownsLock() that returns true while held', async () => { + const handle = await SessionLockManager.acquire(chatsDir, 'owns-test'); + expect(await handle.ownsLock()).toBe(true); + await handle.release(); + expect(await handle.ownsLock()).toBe(false); + }); +}); + +describe('SessionLockManager — unreadable/recent locks treated as busy (Item 3)', () => { + let tempDir: string; + let chatsDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + chatsDir = path.join(tempDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('checkStale returns false for a corrupt (unreadable) recent lock', async () => { + const lockPath = SessionLockManager.getLockPath(chatsDir, 'corrupt-recent'); + await fs.writeFile(lockPath, 'this is garbage', 'utf-8'); + const stale = await SessionLockManager.checkStale(lockPath); + expect(stale).toBe(false); + }); + + it('checkStaleWithPidReuse returns false for a corrupt recent lock', async () => { + const lockPath = SessionLockManager.getLockPath( + chatsDir, + 'corrupt-pidreuse-recent', + ); + await fs.writeFile(lockPath, 'garbage!!!', 'utf-8'); + const stale = await SessionLockManager.checkStaleWithPidReuse(lockPath); + expect(stale).toBe(false); + }); + + it('checkStaleWithPidReuse returns true for a corrupt lock older than 48h', async () => { + const lockPath = SessionLockManager.getLockPath( + chatsDir, + 'corrupt-pidreuse-old', + ); + await fs.writeFile(lockPath, 'garbage!!!', 'utf-8'); + const oldTime = new Date(Date.now() - 49 * 60 * 60 * 1000); + await fs.utimes(lockPath, oldTime, oldTime); + const stale = await SessionLockManager.checkStaleWithPidReuse(lockPath); + expect(stale).toBe(true); + }); + + it('refuses to acquire over a corrupt recent lock (busy, not stale)', async () => { + const lockPath = SessionLockManager.getLockPath(chatsDir, 'corrupt-busy'); + await fs.writeFile(lockPath, 'garbage', 'utf-8'); + await expect( + SessionLockManager.acquire(chatsDir, 'corrupt-busy'), + ).rejects.toBeInstanceOf(SessionLockedError); + // The corrupt lock should survive. + expect(await fileExists(lockPath)).toBe(true); + }); +}); + +describe('SessionLockManager — owner-checked release (Item 3)', () => { + let tempDir: string; + let chatsDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + chatsDir = path.join(tempDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it("release does not remove another owner's lock (token mismatch)", async () => { + const handle = await SessionLockManager.acquire( + chatsDir, + 'release-mismatch', + ); + const lockPath = SessionLockManager.getLockPath( + chatsDir, + 'release-mismatch', + ); + + // Simulate another process replacing the lock with a different token. + await fs.writeFile( + lockPath, + JSON.stringify({ + pid: process.pid + 1, + timestamp: new Date().toISOString(), + sessionId: 'release-mismatch', + ownerToken: 'different-owner-token-xyz', + }), + 'utf-8', + ); + + // Release our handle — should NOT remove the replacement lock. + await handle.release(); + expect(await fileExists(lockPath)).toBe(true); + + // Clean up manually. + await fs.unlink(lockPath); + }); + + it('release removes the lock when we still own it', async () => { + const handle = await SessionLockManager.acquire(chatsDir, 'release-own'); + const lockPath = SessionLockManager.getLockPath(chatsDir, 'release-own'); + await handle.release(); + expect(await fileExists(lockPath)).toBe(false); + }); +}); + +describe('SessionLockManager — stale takeover does not remove replacement live lock (Item 3)', () => { + let tempDir: string; + let chatsDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + chatsDir = path.join(tempDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('cleanupOrphanedLocks does not remove a live (non-stale) replacement lock', async () => { + const sessionId = 'replacement-takeover'; + const lockPath = SessionLockManager.getLockPath(chatsDir, sessionId); + + // A live lock (alive PID, recent timestamp) is never stale. + await fs.writeFile( + lockPath, + JSON.stringify({ + pid: process.pid, + timestamp: new Date().toISOString(), + sessionId, + ownerToken: 'live-replacement', + }), + 'utf-8', + ); + + const removed = await SessionLockManager.cleanupOrphanedLocks(chatsDir); + expect(removed).toBe(0); + expect(await fileExists(lockPath)).toBe(true); + + // Clean up. + await fs.unlink(lockPath); + }); + + it('removeStaleLock routes through the hardened re-read-and-compare path', async () => { + // A genuinely stale lock (dead PID) is removed via the hardened path. + const sessionId = 'remove-stale-route'; + const lockPath = SessionLockManager.getLockPath(chatsDir, sessionId); + await fs.writeFile( + lockPath, + JSON.stringify({ + pid: DEAD_PID, + timestamp: new Date().toISOString(), + sessionId, + ownerToken: 'dead-route', + }), + 'utf-8', + ); + + await SessionLockManager.removeStaleLock(chatsDir, sessionId); + expect(await fileExists(lockPath)).toBe(false); + }); + + it('cleanupOrphanedLocks removes a genuinely stale lock (dead PID)', async () => { + const sessionId = 'genuinely-stale'; + const lockPath = SessionLockManager.getLockPath(chatsDir, sessionId); + await fs.writeFile( + lockPath, + JSON.stringify({ + pid: DEAD_PID, + timestamp: new Date().toISOString(), + sessionId, + ownerToken: 'dead-token', + }), + 'utf-8', + ); + + const removed = await SessionLockManager.cleanupOrphanedLocks(chatsDir); + expect(removed).toBe(1); + expect(await fileExists(lockPath)).toBe(false); + }); + + it('cleanupOrphanedLocks validates lock filename identity', async () => { + // Create one with an unsafe name (contains a dot) inside the dir. + const unsafeName = 'a.b.lock'; + await fs.writeFile( + path.join(chatsDir, unsafeName), + JSON.stringify({ pid: DEAD_PID, timestamp: new Date().toISOString() }), + 'utf-8', + ); + + const removed = await SessionLockManager.cleanupOrphanedLocks(chatsDir); + // The unsafe-named lock should NOT be removed (filename identity validation). + expect(removed).toBe(0); + expect(await fileExists(path.join(chatsDir, unsafeName))).toBe(true); + }); +}); + +/** + * Helper: run a Bun subprocess script and return stdout, capturing stderr. + * + * Manages the child lifecycle explicitly so timeout, error, or rejection + * always terminates only this exact child and awaits its exit — no orphaned + * processes can survive a failed test. Dynamic values are passed via + * environment variables (env) to avoid interpolating untrusted strings into + * JS literals. + */ +function runBunScript( + code: string, + env?: Record, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn('bun', ['-e', code], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, ...env }, + }); + let stdout = ''; + let stderr = ''; + let settled = false; + + const timer = setTimeout(() => { + child.kill('SIGTERM'); + }, 20000); + + const cleanup = () => { + clearTimeout(timer); + }; + + child.stdout.on('data', (d) => (stdout += d.toString())); + child.stderr.on('data', (d) => (stderr += d.toString())); + + child.on('close', (code) => { + cleanup(); + if (settled) return; + settled = true; + if (code === 0) resolve(stdout); + else + reject( + new Error(`Process exited with code ${code}\nstderr: ${stderr}`), + ); + }); + + child.on('error', (err) => { + cleanup(); + // Ensure the child is terminated on spawn failure (e.g. bun not found). + try { + child.kill('SIGTERM'); + } catch { + // Already exited — ignore. + } + if (!settled) { + settled = true; + reject(err); + } + }); + }); +} + +/** + * Run N identical subprocess scripts with an explicit readiness barrier so + * all children start their lock-acquire work simultaneously, reducing race + * flakiness from staggered process startup. + * + * Each child writes a unique ready file, then polls for a shared go file. + * The parent waits for all ready files, creates the go file, then collects + * results. + */ +async function runBunScriptsWithBarrier( + script: string, + baseEnv: Record, + count: number, + barrierDir: string, +): Promise { + const goFile = path.join(barrierDir, 'barrier-go'); + const readyFiles = Array.from({ length: count }, (_, i) => + path.join(barrierDir, `barrier-ready-${i}`), + ); + + // Spawn all children — each will write its ready file and wait. + const promises = readyFiles.map((readyFile) => + runBunScript(script, { + ...baseEnv, + TEST_READY_FILE: readyFile, + TEST_GO_FILE: goFile, + }), + ); + + // Wait for every child to signal readiness. + for (const readyFile of readyFiles) { + const deadline = Date.now() + 15000; + let ready = false; + while (Date.now() < deadline) { + try { + await fs.access(readyFile); + ready = true; + break; + } catch { + await new Promise((r) => setTimeout(r, 5)); + } + } + if (!ready) { + void Promise.allSettled(promises); + throw new Error(`Timed out waiting for child readiness: ${readyFile}`); + } + } + + // Release the barrier — all children proceed simultaneously. + await fs.writeFile(goFile, 'go'); + + return Promise.all(promises); +} + +describe('SessionLockManager — genuine transition race (subprocess, Item 3)', () => { + let tempDir: string; + let chatsDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + chatsDir = path.join(tempDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('exactly one of several competing stale-takeover subprocesses wins', async () => { + const sessionId = 'race-takeover'; + const lockPath = SessionLockManager.getLockPath(chatsDir, sessionId); + + // Pre-create a genuinely stale lock (dead PID, old timestamp). + const oldTime = new Date(Date.now() - 49 * 60 * 60 * 1000); + await fs.writeFile( + lockPath, + JSON.stringify({ + pid: DEAD_PID, + timestamp: oldTime.toISOString(), + sessionId, + ownerToken: 'stale-original', + }), + ); + await fs.utimes(lockPath, oldTime, oldTime); + + const script = ` + const { SessionLockManager } = require(${JSON.stringify(path.resolve(__dirname, 'SessionLockManager.js'))}); + const chatsDir = process.env.TEST_CHATS_DIR; + const sessionId = process.env.TEST_SESSION_ID; + const readyFile = process.env.TEST_READY_FILE; + const goFile = process.env.TEST_GO_FILE; + (async () => { + require('fs').writeFileSync(readyFile, 'ready'); + while (!require('fs').existsSync(goFile)) { + await new Promise(r => setTimeout(r, 5)); + } + try { + const handle = await SessionLockManager.acquire(chatsDir, sessionId); + await new Promise(r => setTimeout(r, 300)); + await handle.release(); + process.stdout.write('WON'); + } catch (e) { + process.stdout.write('SKIP'); + } + })(); + `; + + const results = await runBunScriptsWithBarrier( + script, + { TEST_CHATS_DIR: chatsDir, TEST_SESSION_ID: sessionId }, + 3, + tempDir, + ); + + const winners = results.filter((r) => r === 'WON'); + expect(winners.length).toBe(1); + }, 30000); + + it('transition guard prevents removal of a replacement lock during release', async () => { + // Acquire a lock, then simulate a replacement and verify release does not + // delete the replacement (guarded ownership check + transition guard). + const sessionId = 'release-guard'; + const handle = await SessionLockManager.acquire(chatsDir, sessionId); + const lockPath = SessionLockManager.getLockPath(chatsDir, sessionId); + + // Replace the lock content with a different owner (live PID). + await fs.writeFile( + lockPath, + JSON.stringify({ + pid: process.pid, + timestamp: new Date().toISOString(), + sessionId, + ownerToken: 'replacement-owner', + }), + 'utf-8', + ); + + // Release our handle — must NOT remove the replacement. + await handle.release(); + + const content = await fs.readFile(lockPath, 'utf-8'); + expect(JSON.parse(content).ownerToken).toBe('replacement-owner'); + + await fs.unlink(lockPath); + }); + + /** + * Deterministic real child-process contention test that would FAIL under + * the old separate-owner stale-guard replacement sequence. + * + * Under the old code, `reclaimStaleGuard()` was check-stale-then-unlink: + * two contenders could both classify the old guard as stale, one installs a + * new live guard, and the other unlinks it — allowing a loser to remove the + * winner's lock. Under the hard-link claim, the guard IS the lock inode, + * and {@link verifyTransitionClaim} catches any inode mismatch before + * unlinking lockPath. + * + * Each child process acquires a stale lock, holds it briefly, then verifies + * it STILL owns the lock (`ownsLock()`). Under the old code, a loser could + * unlink the winner's lock, causing `ownsLock()` to return false → the + * winner reports 'LOST'. Under the hard-link claim, no process ever + * reports 'LOST'. + */ + it('winner lock cannot be removed by concurrent stale-takeover contender (hard-link claim)', async () => { + const sessionId = 'winner-survives'; + const lockPath = SessionLockManager.getLockPath(chatsDir, sessionId); + + // Pre-create a genuinely stale lock (dead PID, old timestamp). + const oldTime = new Date(Date.now() - 49 * 60 * 60 * 1000); + await fs.writeFile( + lockPath, + JSON.stringify({ + pid: DEAD_PID, + timestamp: oldTime.toISOString(), + sessionId, + ownerToken: 'stale-original', + }), + ); + await fs.utimes(lockPath, oldTime, oldTime); + + const script = ` + const { SessionLockManager } = require(${JSON.stringify(path.resolve(__dirname, 'SessionLockManager.js'))}); + const chatsDir = process.env.TEST_CHATS_DIR; + const sessionId = process.env.TEST_SESSION_ID; + const readyFile = process.env.TEST_READY_FILE; + const goFile = process.env.TEST_GO_FILE; + (async () => { + require('fs').writeFileSync(readyFile, 'ready'); + while (!require('fs').existsSync(goFile)) { + await new Promise(r => setTimeout(r, 5)); + } + try { + const handle = await SessionLockManager.acquire(chatsDir, sessionId); + // Hold the lock briefly so contenders see a LIVE lock. + await new Promise(r => setTimeout(r, 500)); + // Verify we still own it before releasing. Under the old + // stale-guard race, a loser could have unlinked our lock. + const owns = await handle.ownsLock(); + process.stdout.write(owns ? 'WON' : 'LOST'); + await handle.release(); + } catch (e) { + process.stdout.write('SKIP'); + } + })(); + `; + + const results = await runBunScriptsWithBarrier( + script, + { TEST_CHATS_DIR: chatsDir, TEST_SESSION_ID: sessionId }, + 3, + tempDir, + ); + + const winners = results.filter((r) => r === 'WON'); + const lost = results.filter((r) => r === 'LOST'); + expect(winners.length).toBe(1); + // No process should report LOST — the winner's lock is never removed. + expect(lost.length).toBe(0); + + // All temp/transition artifacts should have converged — no leftovers. + const entries = await fs.readdir(chatsDir); + const guardFiles = entries.filter((f) => f.endsWith('.tguard')); + const tmpFiles = entries.filter((f) => f.endsWith('.locktmp')); + expect(guardFiles).toEqual([]); + expect(tmpFiles).toEqual([]); + }, 30000); +}); diff --git a/packages/core/src/recording/SessionLockManager.test.ts b/packages/core/src/recording/SessionLockManager.test.ts index ab1eaa2038..013cd3d690 100644 --- a/packages/core/src/recording/SessionLockManager.test.ts +++ b/packages/core/src/recording/SessionLockManager.test.ts @@ -27,15 +27,7 @@ * the Phase 09 stub — that is correct TDD. */ -import { - describe, - it, - expect, - beforeEach, - afterEach, - vi, - type Mock, -} from 'bun:test'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'bun:test'; import * as path from 'path'; import * as os from 'os'; import { @@ -43,14 +35,6 @@ import { SessionLockedError, } from './SessionLockManager.js'; -const realPromisesModule = { ...(await import('node:fs/promises')) }; - -const actual = { ...(await import('node:fs/promises')) }; -void vi.mock('node:fs/promises', () => ({ - ...actual, - writeFile: vi.fn(actual.writeFile), -})); - const fs = await import('node:fs/promises'); // --------------------------------------------------------------------------- @@ -113,11 +97,6 @@ describe('SessionLockManager @plan:PLAN-20260211-SESSIONRECORDING.P10', () => { }); afterEach(async () => { - const actualFs = realPromisesModule; - (fs.writeFile as Mock).mockReset(); - (fs.writeFile as Mock).mockImplementation( - actualFs.writeFile, - ); await fs.rm(tempDir, { recursive: true, force: true }); }); @@ -379,10 +358,40 @@ describe('SessionLockManager @plan:PLAN-20260211-SESSIONRECORDING.P10', () => { await handle1.release(); await handle2.release(); }); + /** + * OCR finding 5: a transient I/O failure must propagate, not be + * swallowed as "lock busy" which could cause a dangerous stale-takeover + * of a live lock. + * + * Uses a regular file as a path-component blocker so file creation + * fails with ENOTDIR — deterministic and privilege-independent (works + * on all platforms including root and Windows). + */ + it('acquire propagates I/O errors instead of masking them as lock-busy', async () => { + // Create a regular file that blocks directory traversal. + const blockerPath = path.join(tempDir, 'blocker'); + await fs.writeFile(blockerPath, 'blocker'); + // chatsDir is inside the blocker "directory" (which is actually a file). + const blockedDir = path.join(blockerPath, 'chats'); + + let threw = false; + let error: unknown; + try { + await SessionLockManager.acquire(blockedDir, 'io-fail-session'); + } catch (e) { + threw = true; + error = e; + } + expect(threw).toBe(true); + // On POSIX, traversing a path through a regular file yields ENOTDIR. + // On Windows the same scenario can surface as ENOENT. Either is a + // valid system error — what matters is that it is NOT masked as + // SessionLockedError (lock-busy). + const errno = (error as NodeJS.ErrnoException).code; + expect(['ENOTDIR', 'ENOENT']).toContain(errno); + expect(error).not.toBeInstanceOf(SessionLockedError); + }); }); - - // ------------------------------------------------------------------------- - // Stale Lock Detection // ------------------------------------------------------------------------- describe('Stale lock detection @requirement:REQ-CON-005 @plan:PLAN-20260211-SESSIONRECORDING.P10', () => { @@ -417,15 +426,16 @@ describe('SessionLockManager @plan:PLAN-20260211-SESSIONRECORDING.P10', () => { /** * @plan PLAN-20260211-SESSIONRECORDING.P10 * @requirement REQ-CON-005 - * Test 18: Corrupt lock file treated as stale + * Test 18: Corrupt (unreadable) lock file is treated as busy, not stale + * (Item 3: unreadable/recent lock files are busy, not instantly stale). */ - it('checkStale returns true for corrupt (non-JSON) lock file', async () => { + it('checkStale returns false for corrupt (non-JSON) recent lock file', async () => { const sessionId = 'test-session-018'; const lockPath = SessionLockManager.getLockPath(chatsDir, sessionId); await fs.writeFile(lockPath, 'this is not json garbage!!!', 'utf-8'); const stale = await SessionLockManager.checkStale(lockPath); - expect(stale).toBe(true); + expect(stale).toBe(false); }); /** @@ -546,46 +556,37 @@ describe('SessionLockManager @plan:PLAN-20260211-SESSIONRECORDING.P10', () => { }); }); - it('acquire maps ENOENT then EEXIST race to in-use error', async () => { - const sessionId = 'test-session-enoent-race'; - const lockPath = SessionLockManager.getLockPath(chatsDir, sessionId); - const writeFileMock = fs.writeFile as Mock; - - writeFileMock.mockClear(); - writeFileMock - .mockRejectedValueOnce( - Object.assign(new Error('no such file'), { code: 'ENOENT' }), - ) - .mockRejectedValueOnce( - Object.assign(new Error('already exists'), { code: 'EEXIST' }), - ); + /** + * When a live lock already exists, acquire detects it and rejects with + * SessionLockedError (no mock theater — real filesystem). + */ + it('acquire rejects with SessionLockedError when a live lock already exists', async () => { + const nestedDir = path.join(tempDir, 'race', 'nested', 'chats'); + const sessionId = 'test-session-race-dir'; + const lockPath = SessionLockManager.getLockPath(nestedDir, sessionId); + + // Pre-create the lock before acquire to simulate a concurrent winner. + await fs.mkdir(nestedDir, { recursive: true }); + await fs.writeFile( + lockPath, + JSON.stringify({ + pid: process.pid, + timestamp: new Date().toISOString(), + sessionId, + ownerToken: 'concurrent-winner', + }), + 'utf-8', + ); + // Acquire should detect the existing live lock (PID alive, recent) and fail. await expect( - SessionLockManager.acquire(chatsDir, sessionId), - ).rejects.toThrow('Session is in use by another process'); - - const lockWriteCalls = writeFileMock.mock.calls.filter( - ([targetPath, , options]) => - isWxLockWriteCall(targetPath, options, lockPath), - ); + SessionLockManager.acquire(nestedDir, sessionId), + ).rejects.toBeInstanceOf(SessionLockedError); - expect(lockWriteCalls).toHaveLength(2); + // Clean up (force: true avoids ENOENT if acquire already removed it). + await fs.rm(lockPath, { force: true }); }); - /** - * Helper function to check if a write call is a 'wx' flag lock write. - */ - function isWxLockWriteCall( - targetPath: string, - options: unknown, - expectedLockPath: string, - ): boolean { - if (targetPath !== expectedLockPath) return false; - if (typeof options !== 'object' || options === null) return false; - if (!('flag' in options)) return false; - return (options as { flag?: string }).flag === 'wx'; - } - // ------------------------------------------------------------------------- // removeStaleLock // ------------------------------------------------------------------------- @@ -674,6 +675,103 @@ describe('SessionLockManager @plan:PLAN-20260211-SESSIONRECORDING.P10', () => { expect(await fileExists(lockPath)).toBe(true); }); + + /** + * OCR finding 11: orphaned lock temp publication artifacts from crashed + * acquisitions must be cleaned up, but only when they match the exact + * generated grammar, are regular non-symlink direct children, and are + * older than the conservative age threshold. + */ + it('cleanupOrphanedLocks removes stale lock temp artifacts matching the exact grammar', async () => { + const sessionId = 'stale-temp-session'; + // Real grammar: .lock..locktmp + const tempName = `${sessionId}.lock.550e8400-e29b-41d4-a716-446655440000.locktmp`; + const tempPath = path.join(chatsDir, tempName); + await fs.writeFile(tempPath, 'partial', 'utf-8'); + // Back-date the mtime past the conservative threshold. + const old = new Date(Date.now() - 10 * 60 * 1000); + await fs.utimes(tempPath, old, old); + + await SessionLockManager.cleanupOrphanedLocks(chatsDir); + + expect(await fileExists(tempPath)).toBe(false); + }); + + it('cleanupOrphanedLocks does NOT remove a recent lock temp artifact', async () => { + const sessionId = 'recent-temp-session'; + const tempName = `${sessionId}.lock.550e8400-e29b-41d4-a716-446655440000.locktmp`; + const tempPath = path.join(chatsDir, tempName); + await fs.writeFile(tempPath, 'partial', 'utf-8'); + // Recent mtime — within the conservative threshold. + + await SessionLockManager.cleanupOrphanedLocks(chatsDir); + + expect(await fileExists(tempPath)).toBe(true); + }); + + it('cleanupOrphanedLocks does NOT remove unknown files ending in .locktmp', async () => { + // Does not match the exact grammar (no valid UUID). + const badName = 'random.locktmp'; + const badPath = path.join(chatsDir, badName); + await fs.writeFile(badPath, 'data', 'utf-8'); + const old = new Date(Date.now() - 10 * 60 * 1000); + await fs.utimes(badPath, old, old); + + await SessionLockManager.cleanupOrphanedLocks(chatsDir); + + expect(await fileExists(badPath)).toBe(true); + }); + + it('cleanupOrphanedLocks removes a stale guard with safe grammar and dead PID', async () => { + const sessionId = 'stale-guard-session'; + const guardName = `${sessionId}.lock.tguard`; + const guardPath = path.join(chatsDir, guardName); + await fs.writeFile( + guardPath, + JSON.stringify({ + pid: DEAD_PID, + timestamp: new Date().toISOString(), + sessionId, + ownerToken: 'crashed-guard', + }), + 'utf-8', + ); + + await SessionLockManager.cleanupOrphanedLocks(chatsDir); + + expect(await fileExists(guardPath)).toBe(false); + }); + + it('cleanupOrphanedLocks does NOT remove a guard with an unsafe name', async () => { + // Contains a dot in the session-id portion — does not match safe grammar. + const badName = 'a.b.lock.tguard'; + const badPath = path.join(chatsDir, badName); + await fs.writeFile(badPath, 'data', 'utf-8'); + + await SessionLockManager.cleanupOrphanedLocks(chatsDir); + + expect(await fileExists(badPath)).toBe(true); + }); + + it('cleanupOrphanedLocks does NOT remove a non-stale guard with safe grammar', async () => { + const sessionId = 'live-guard-session'; + const guardName = `${sessionId}.lock.tguard`; + const guardPath = path.join(chatsDir, guardName); + await fs.writeFile( + guardPath, + JSON.stringify({ + pid: process.pid, + timestamp: new Date().toISOString(), + sessionId, + ownerToken: 'live-guard', + }), + 'utf-8', + ); + + await SessionLockManager.cleanupOrphanedLocks(chatsDir); + + expect(await fileExists(guardPath)).toBe(true); + }); }); // ------------------------------------------------------------------------- diff --git a/packages/core/src/recording/SessionLockManager.ts b/packages/core/src/recording/SessionLockManager.ts index 28fcd683bd..0df3cbb6e2 100644 --- a/packages/core/src/recording/SessionLockManager.ts +++ b/packages/core/src/recording/SessionLockManager.ts @@ -23,17 +23,94 @@ * to prevent concurrent writes to the same session. Lock path convention: * `/.lock` — session-ID-based, independent of JSONL * file materialization state. + * + * Lazy facade: the synchronous path-validation primitives and class shape + * live here (eagerly loaded through the recording barrel), while the heavy + * ownership, atomic-publication, transition-claim, stale-check, cleanup, + * and filesystem implementation is dynamically imported from + * SessionLockManager.internals.js only when an async lock operation is + * actually called. This keeps the cold-start import graph of the core + * public root lightweight and prevents per-test timeouts in the agents + * test runner (separate Bun process per file). + * + * Public API (source-compatible): + * - `LockHandle` — interface + * - `SessionLockedError` — error class + * - `SessionLockManager.getLockPath(chatsDir, sessionId)` — sync + * - `SessionLockManager.getLockPathFromFilePath(sessionFilePath)` — sync + * - `SessionLockManager.acquire(chatsDir, sessionId)` — async + * - `SessionLockManager.checkStale(lockPath)` — async + * - `SessionLockManager.isLocked(chatsDir, sessionId)` — async + * - `SessionLockManager.isStale(chatsDir, sessionId)` — async + * - `SessionLockManager.removeStaleLock(chatsDir, sessionId)` — async + * - `SessionLockManager.cleanupOrphanedLocks(chatsDir)` — async + * - `SessionLockManager.checkStaleWithPidReuse(lockPath)` — async + * + * The path-validation helpers below are inlined (rather than imported from + * janitor/sessionSafety.js) to avoid eagerly pulling node:fs/promises into + * the facade's import graph. */ -import * as fs from 'node:fs/promises'; import * as path from 'node:path'; +/** Maximum session-ID length (mirrors the safe grammar). */ +const SAFE_ID_MAX_LENGTH = 256; + +/** + * Canonical safe session-ID grammar. Inlined here so the facade does not + * transitively import `node:fs/promises` via sessionSafety.js. + */ +const SAFE_SESSION_ID_RE = /^[A-Za-z0-9_-]{1,256}$/; + +function isValidSafeSessionId(id: string): boolean { + return SAFE_SESSION_ID_RE.test(id); +} + +/** Normalize a path for comparison without touching the filesystem. */ +function normalizeLexical(p: string): string { + const normalized = path.normalize(p); + // Preserve filesystem roots (including Windows drive and UNC roots) while + // removing a trailing separator from ordinary directory paths. + if ( + normalized !== path.parse(normalized).root && + normalized.endsWith(path.sep) + ) { + return normalized.slice(0, -1); + } + return normalized; +} + +/** + * Return `true` when `childPath` is a direct child file of `parentDir`. + * A direct child has no intermediate directory between itself and the parent. + */ +function isDirectChildPath(parentDir: string, childPath: string): boolean { + const normalizedParent = normalizeLexical(parentDir); + const normalizedChild = normalizeLexical(childPath); + const parentWithSep = normalizedParent + path.sep; + if (!normalizedChild.startsWith(parentWithSep)) return false; + const remainder = normalizedChild.slice(parentWithSep.length); + return !remainder.includes(path.sep) && remainder.length > 0; +} + +/** Assert that `lockPath` is a safe direct child of `chatsDir`. */ +function assertSafeLockPath(chatsDir: string, lockPath: string): void { + if (!isDirectChildPath(chatsDir, lockPath)) { + throw new Error( + `Unsafe lock path "${lockPath}" is not a direct child of "${chatsDir}"`, + ); + } +} + /** * Handle returned by a successful lock acquisition. - * Callers use `release()` to free the lock. + * Callers use `release()` to free the lock and `ownsLock()` to verify + * on-disk ownership before destructive mutations. */ export interface LockHandle { lockPath: string; + /** Verify this handle still owns the live lock on disk. */ + ownsLock(): Promise; release(): Promise; } @@ -44,13 +121,41 @@ export class SessionLockedError extends Error { } } +/** + * Structural type for the lazily-imported heavy implementation module. + * Avoids inline `import()` type annotations (forbidden by the project's + * ESLint consistent-type-imports rule) while preserving full type safety + * for the cached dynamic-import promise. + */ +interface SessionLockInternalsModule { + acquire(chatsDir: string, sessionId: string): Promise; + checkStale(lockPath: string): Promise; + isLocked(chatsDir: string, sessionId: string): Promise; + isStale(chatsDir: string, sessionId: string): Promise; + removeStaleLock(chatsDir: string, sessionId: string): Promise; + cleanupOrphanedLocks(chatsDir: string): Promise; + checkStaleWithPidReuse(lockPath: string): Promise; +} + /** @pseudocode concurrency-lifecycle.md lines 10-134 */ export class SessionLockManager { - private static readonly ownedLockPaths = new Set(); + // ----------------------------------------------------------------------- + // Synchronous path operations (eagerly available) + // ----------------------------------------------------------------------- /** @pseudocode concurrency-lifecycle.md lines 12-14 */ static getLockPath(chatsDir: string, sessionId: string): string { - return path.join(chatsDir, sessionId + '.lock'); + if ( + typeof sessionId !== 'string' || + sessionId.length === 0 || + sessionId.length > SAFE_ID_MAX_LENGTH || + !isValidSafeSessionId(sessionId) + ) { + throw new Error(`Unsafe session ID rejected: "${sessionId}"`); + } + const lockPath = path.join(chatsDir, sessionId + '.lock'); + assertSafeLockPath(chatsDir, lockPath); + return lockPath; } /** @pseudocode concurrency-lifecycle.md lines 16-22 */ @@ -66,148 +171,52 @@ export class SessionLockManager { return SessionLockManager.getLockPath(dir, match[1]); } + // ----------------------------------------------------------------------- + // Lazy async operations (heavy implementation loaded on first call) + // ----------------------------------------------------------------------- + + /** + * Cached promise for the dynamically imported heavy implementation. + * The ESM module cache guarantees that concurrent first calls share the + * same module instance. + */ + private static internalsPromise: + | Promise + | undefined; + + /** Lazily load and cache the heavy implementation module. */ + private static loadInternals(): Promise { + SessionLockManager.internalsPromise ??= import( + './SessionLockManager.internals.js' + ); + return SessionLockManager.internalsPromise; + } + /** @pseudocode concurrency-lifecycle.md lines 24-75 */ static async acquire( chatsDir: string, sessionId: string, ): Promise { - const lockPath = SessionLockManager.getLockPath(chatsDir, sessionId); - if (SessionLockManager.ownedLockPaths.has(lockPath)) { - throw new SessionLockedError(); - } - const pid = process.pid; - const lockContent = JSON.stringify({ - pid, - timestamp: new Date().toISOString(), - sessionId, - }); - - try { - await fs.writeFile(lockPath, lockContent, { flag: 'wx' }); - } catch (error: unknown) { - const code = (error as NodeJS.ErrnoException).code; - if (code === 'EEXIST') { - await SessionLockManager.acquireStaleLock(lockPath, lockContent); - } else if (code === 'ENOENT') { - await SessionLockManager.createLockAfterMkdir(lockPath, lockContent); - } else { - throw error; - } - } - - SessionLockManager.ownedLockPaths.add(lockPath); - let released = false; - return { - lockPath, - release: async (): Promise => { - if (released) return; - released = true; - SessionLockManager.ownedLockPaths.delete(lockPath); - try { - await fs.unlink(lockPath); - } catch { - // Best-effort release - } - }, - }; - } - - /** Create lock directory and retry after ENOENT. */ - private static async createLockAfterMkdir( - lockPath: string, - lockContent: string, - ): Promise { - await fs.mkdir(path.dirname(lockPath), { recursive: true }); - try { - await fs.writeFile(lockPath, lockContent, { flag: 'wx' }); - } catch (writeErr: unknown) { - if ((writeErr as NodeJS.ErrnoException).code === 'EEXIST') { - throw new SessionLockedError(); - } - throw writeErr; - } - } - - /** Attempt to acquire a lock after detecting an EEXIST (stale lock). */ - private static async acquireStaleLock( - lockPath: string, - lockContent: string, - ): Promise { - const isStale = await SessionLockManager.checkStale(lockPath); - if (!isStale) { - throw new SessionLockedError(); - } - try { - await fs.unlink(lockPath); - } catch (unlinkErr: unknown) { - if ((unlinkErr as NodeJS.ErrnoException).code !== 'ENOENT') { - throw unlinkErr; - } - } - try { - await fs.writeFile(lockPath, lockContent, { flag: 'wx' }); - } catch (writeErr: unknown) { - const code = (writeErr as NodeJS.ErrnoException).code; - if (code === 'EEXIST') { - throw new SessionLockedError(); - } - if (code === 'ENOENT') { - await SessionLockManager.createLockAfterMkdir(lockPath, lockContent); - return; - } - throw writeErr; - } + const internals = await SessionLockManager.loadInternals(); + return internals.acquire(chatsDir, sessionId); } /** @pseudocode concurrency-lifecycle.md lines 77-96 */ static async checkStale(lockPath: string): Promise { - try { - const content = await fs.readFile(lockPath, 'utf-8'); - const lockData = JSON.parse(content) as { pid: number }; - const lockPid = lockData.pid; - - if ( - lockPid === process.pid && - SessionLockManager.ownedLockPaths.has(lockPath) - ) { - return false; - } - try { - process.kill(lockPid, 0); - return false; - } catch (error: unknown) { - const code = (error as NodeJS.ErrnoException).code; - if (code === 'EPERM') { - return false; - } - return true; - } - } catch { - return true; - } + const internals = await SessionLockManager.loadInternals(); + return internals.checkStale(lockPath); } /** @pseudocode concurrency-lifecycle.md lines 104-114 */ static async isLocked(chatsDir: string, sessionId: string): Promise { - const lockPath = SessionLockManager.getLockPath(chatsDir, sessionId); - try { - await fs.access(lockPath); - const stale = await SessionLockManager.checkStale(lockPath); - return !stale; - } catch { - return false; - } + const internals = await SessionLockManager.loadInternals(); + return internals.isLocked(chatsDir, sessionId); } /** @pseudocode concurrency-lifecycle.md lines 116-124 */ static async isStale(chatsDir: string, sessionId: string): Promise { - const lockPath = SessionLockManager.getLockPath(chatsDir, sessionId); - try { - await fs.access(lockPath); - return await SessionLockManager.checkStale(lockPath); - } catch { - return false; - } + const internals = await SessionLockManager.loadInternals(); + return internals.isStale(chatsDir, sessionId); } /** @pseudocode concurrency-lifecycle.md lines 126-133 */ @@ -215,69 +224,19 @@ export class SessionLockManager { chatsDir: string, sessionId: string, ): Promise { - const lockPath = SessionLockManager.getLockPath(chatsDir, sessionId); - const isStale = await SessionLockManager.checkStale(lockPath); - if (!isStale) { - return; - } - - try { - await fs.unlink(lockPath); - } catch { - // Best-effort - } + const internals = await SessionLockManager.loadInternals(); + return internals.removeStaleLock(chatsDir, sessionId); } /** @pseudocode concurrency-lifecycle.md lines 257-282 */ - static async cleanupOrphanedLocks(chatsDir: string): Promise { - let files: string[]; - try { - files = await fs.readdir(chatsDir); - } catch { - return; - } - const lockFiles = files.filter((f) => f.endsWith('.lock')); - - for (const lockFile of lockFiles) { - const lockPath = path.join(chatsDir, lockFile); - const isStale = await SessionLockManager.checkStaleWithPidReuse(lockPath); - - if (!isStale) { - continue; - } - - try { - await fs.unlink(lockPath); - } catch { - // Best-effort - } - } + static async cleanupOrphanedLocks(chatsDir: string): Promise { + const internals = await SessionLockManager.loadInternals(); + return internals.cleanupOrphanedLocks(chatsDir); } /** @pseudocode concurrency-lifecycle.md lines 290-346 */ static async checkStaleWithPidReuse(lockPath: string): Promise { - try { - const content = await fs.readFile(lockPath, 'utf-8'); - const lockData = JSON.parse(content) as { - pid: number; - timestamp: string; - }; - const lockPid = lockData.pid; - - try { - process.kill(lockPid, 0); - } catch (error: unknown) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== 'EPERM') { - return true; - } - } - - const lockAge = Date.now() - new Date(lockData.timestamp).getTime(); - const maxAge = 48 * 60 * 60 * 1000; - return lockAge > maxAge; - } catch { - return true; - } + const internals = await SessionLockManager.loadInternals(); + return internals.checkStaleWithPidReuse(lockPath); } } diff --git a/packages/core/src/recording/boundedHeaderReader.test.ts b/packages/core/src/recording/boundedHeaderReader.test.ts new file mode 100644 index 0000000000..eb5ee6a229 --- /dev/null +++ b/packages/core/src/recording/boundedHeaderReader.test.ts @@ -0,0 +1,226 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Behavioral tests for the canonical bounded first-line reader (Item 7). + * + * Proves BOM handling, valid headers beyond 4096 bytes, and that a giant + * no-newline file is classified as unreadable **without** whole-file buffering. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + readBoundedFirstLine, + BOUNDED_HEADER_MAX_BYTES, +} from './boundedHeaderReader.js'; + +async function makeTempDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'bounded-header-')); +} + +describe('readBoundedFirstLine', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('reads a normal single-line header', async () => { + const filePath = path.join(tempDir, 'session-normal.jsonl'); + const payload = JSON.stringify({ + type: 'session_start', + payload: { sessionId: 'abc-123', startTime: '2026-01-01T00:00:00Z' }, + }); + await fs.writeFile(filePath, payload + '\nmore data\n'); + const line = await readBoundedFirstLine(filePath); + expect(line).toBe(payload); + }); + + it('strips a UTF-8 BOM prefix', async () => { + const filePath = path.join(tempDir, 'session-bom.jsonl'); + const payload = JSON.stringify({ + type: 'session_start', + payload: { sessionId: 'bom-id', startTime: '2026-01-01T00:00:00Z' }, + }); + await fs.writeFile(filePath, '\uFEFF' + payload + '\n'); + const line = await readBoundedFirstLine(filePath); + expect(line).toBe(payload); + }); + + it('reads a valid header larger than 4096 bytes', async () => { + const filePath = path.join(tempDir, 'session-long.jsonl'); + const longValue = 'x'.repeat(5000); + const payload = JSON.stringify({ + type: 'session_start', + payload: { + sessionId: 'long-id', + startTime: '2026-01-01T00:00:00Z', + workspaceDirs: [longValue], + }, + }); + expect(payload.length).toBeGreaterThan(4096); + await fs.writeFile(filePath, payload + '\n'); + const line = await readBoundedFirstLine(filePath); + expect(line).toBe(payload); + }); + + it('reads a single-line file with no trailing newline', async () => { + const filePath = path.join(tempDir, 'session-nonl.jsonl'); + const payload = JSON.stringify({ + type: 'session_start', + payload: { sessionId: 'no-newline-id' }, + }); + await fs.writeFile(filePath, payload); + const line = await readBoundedFirstLine(filePath); + expect(line).toBe(payload); + }); + + it('returns null for an empty file', async () => { + const filePath = path.join(tempDir, 'session-empty.jsonl'); + await fs.writeFile(filePath, ''); + const line = await readBoundedFirstLine(filePath); + expect(line).toBeNull(); + }); + + it('returns null for a non-existent file', async () => { + const line = await readBoundedFirstLine(path.join(tempDir, 'nope.jsonl')); + expect(line).toBeNull(); + }); + + it('returns null for a giant no-newline file exceeding the documented maximum', async () => { + const filePath = path.join(tempDir, 'session-giant.jsonl'); + // Write a file larger than the maximum with no newline. + const giant = 'a'.repeat(BOUNDED_HEADER_MAX_BYTES + 4096); + await fs.writeFile(filePath, giant); + const line = await readBoundedFirstLine(filePath); + expect(line).toBeNull(); + }); + + it('returns the first line of a very large file when the newline is within bounds', async () => { + const filePath = path.join(tempDir, 'session-large-early-nl.jsonl'); + const header = JSON.stringify({ + type: 'session_start', + payload: { sessionId: 'large-file' }, + }); + // Small header + newline, followed by a large amount of trailing data. + const trailing = '\n' + 'z'.repeat(BOUNDED_HEADER_MAX_BYTES + 4096); + await fs.writeFile(filePath, header + trailing); + const line = await readBoundedFirstLine(filePath); + expect(line).toBe(header); + }); + + it('reads a first line one byte under the max with a trailing newline', async () => { + const filePath = path.join(tempDir, 'session-under-boundary.jsonl'); + // MAX-1 bytes of content + newline at byte MAX-1 (within the read limit). + const content = 'x'.repeat(BOUNDED_HEADER_MAX_BYTES - 1) + '\n'; + await fs.writeFile(filePath, content); + const line = await readBoundedFirstLine(filePath); + expect(line).not.toBeNull(); + expect(line!.length).toBe(BOUNDED_HEADER_MAX_BYTES - 1); + }); + + it('returns null when the first line is exactly at the max with a trailing newline', async () => { + const filePath = path.join(tempDir, 'session-exact-boundary.jsonl'); + // MAX bytes of content + newline at byte MAX (beyond the read limit). + const content = 'x'.repeat(BOUNDED_HEADER_MAX_BYTES) + '\n'; + await fs.writeFile(filePath, content); + const line = await readBoundedFirstLine(filePath); + expect(line).toBeNull(); + }); + + it('returns null when the first line is one byte over the max with a trailing newline', async () => { + const filePath = path.join(tempDir, 'session-over-boundary.jsonl'); + const content = 'x'.repeat(BOUNDED_HEADER_MAX_BYTES + 1) + '\n'; + await fs.writeFile(filePath, content); + const line = await readBoundedFirstLine(filePath); + expect(line).toBeNull(); + }); + + it('correctly decodes a multi-byte UTF-8 character split across the 64 KiB chunk boundary', async () => { + const filePath = path.join(tempDir, 'session-multibyte.jsonl'); + // Place a 3-byte UTF-8 character so that its first byte is the last byte + // of the first 64 KiB chunk, and its remaining bytes are in the second + // chunk. '€' is U+20AC → UTF-8 bytes E2 82 AC. + const READ_CHUNK_SIZE = 64 * 1024; + const before = 'a'.repeat(READ_CHUNK_SIZE - 1); + const multiChar = '€'; + const after = 'bc'; + const header = before + multiChar + after + '\n'; + await fs.writeFile(filePath, header); + const line = await readBoundedFirstLine(filePath); + expect(line).not.toBeNull(); + expect(line).toBe(before + multiChar + after); + }); + + it('handles a BOM + header larger than 4096 bytes', async () => { + const filePath = path.join(tempDir, 'session-bom-long.jsonl'); + const longValue = 'y'.repeat(5000); + const payload = JSON.stringify({ + type: 'session_start', + payload: { sessionId: 'bom-long', workspaceDirs: [longValue] }, + }); + await fs.writeFile(filePath, '\uFEFF' + payload + '\n'); + const line = await readBoundedFirstLine(filePath); + expect(line).toBe(payload); + }); + + it('returns the first line only when multiple lines exist', async () => { + const filePath = path.join(tempDir, 'session-multi.jsonl'); + const first = JSON.stringify({ type: 'session_start', payload: {} }); + const second = JSON.stringify({ type: 'content', payload: {} }); + await fs.writeFile(filePath, first + '\n' + second + '\n'); + const line = await readBoundedFirstLine(filePath); + expect(line).toBe(first); + }); + + it('correctly strips BOM for a first line spanning multiple 64 KiB chunks', async () => { + const filePath = path.join(tempDir, 'session-bom-chunked.jsonl'); + const READ_CHUNK_SIZE = 64 * 1024; + // BOM + content that exceeds a single 64 KiB read so the reader must + // continue across chunk boundaries with the BOM already stripped. + const padding = 'b'.repeat(READ_CHUNK_SIZE + 100); + const payload = JSON.stringify({ + type: 'session_start', + payload: { sessionId: 'bom-chunked', startTime: '2026-01-01T00:00:00Z' }, + }); + await fs.writeFile(filePath, '\uFEFF' + padding + payload + '\n'); + const line = await readBoundedFirstLine(filePath); + expect(line).toBe(padding + payload); + }); + + it('returns null for a file containing only a BOM', async () => { + const filePath = path.join(tempDir, 'session-bom-only.jsonl'); + await fs.writeFile(filePath, '\uFEFF'); + const line = await readBoundedFirstLine(filePath); + expect(line).toBeNull(); + }); + + it('returns an empty string for a file containing only a BOM and a newline', async () => { + const filePath = path.join(tempDir, 'session-bom-newline.jsonl'); + await fs.writeFile(filePath, '\uFEFF\n'); + const line = await readBoundedFirstLine(filePath); + // The first line is empty after BOM stripping (distinct from an empty + // file which returns null). Downstream JSON parsing handles this. + expect(line).toBe(''); + }); +}); diff --git a/packages/core/src/recording/boundedHeaderReader.ts b/packages/core/src/recording/boundedHeaderReader.ts new file mode 100644 index 0000000000..a0d0a4f451 --- /dev/null +++ b/packages/core/src/recording/boundedHeaderReader.ts @@ -0,0 +1,135 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Canonical bounded first-line reader for session JSONL recordings (Item 7). + * + * This is the **single** shared reader used by session discovery, resume + * (ReplayEngine), and the session-recording janitor. It replaces the former + * split approach (a fixed 4 KiB buffer that fell back to an unbounded + * readline stream) with one implementation that: + * + * - Strips a UTF-8 BOM prefix. + * - Reads the first line in bounded chunks, growing up to a documented maximum. + * - Supports valid first-line headers far larger than the old 4 KiB buffer. + * - Classifies a no-newline/malformed file exceeding the maximum as unreadable + * (`null`) **without** buffering the entire file into memory. + * + * The maximum is deliberately generous (1 MiB) — far above any legitimate + * `session_start` header — so real recordings are always read while a giant + * no-newline file is rejected promptly. + */ + +import * as fs from 'node:fs/promises'; +import { StringDecoder } from 'node:string_decoder'; + +/** + * Maximum number of bytes the reader will inspect when searching for the first + * newline. A file whose first line exceeds this limit (with no newline) is + * classified as unreadable without reading further. + */ +export const BOUNDED_HEADER_MAX_BYTES = 1024 * 1024; // 1 MiB + +/** Size of each read chunk when searching for the first newline. */ +const READ_CHUNK_SIZE = 64 * 1024; // 64 KiB + +/** UTF-8 BOM byte sequence. */ +const BOM = '\uFEFF'; + +/** Strip the UTF-8 BOM from the start of a chunk if present. */ +function stripBom(text: string, alreadyStripped: boolean): string { + if (alreadyStripped) return text; + if (text.startsWith(BOM)) return text.slice(BOM.length); + return text; +} + +/** + * Read the first line from `filePath` using a bounded, chunked read. + * + * Uses a streaming {@link StringDecoder} so that multi-byte UTF-8 characters + * split across the 64 KiB chunk boundary are decoded correctly rather than + * producing stray replacement characters. + * + * @returns The first line as a UTF-8 string (with BOM stripped), or `null` + * when the file is empty, unreadable, or its first line exceeds + * {@link BOUNDED_HEADER_MAX_BYTES} without a newline terminator. + */ +export async function readBoundedFirstLine( + filePath: string, +): Promise { + let fh: fs.FileHandle | undefined; + try { + fh = await fs.open(filePath, 'r'); + } catch { + return null; + } + + const decoder = new StringDecoder('utf-8'); + + try { + let accumulated = ''; + let offset = 0; + let bomStripped = false; + + for (;;) { + if (offset >= BOUNDED_HEADER_MAX_BYTES) return null; + + const chunkSize = Math.min( + READ_CHUNK_SIZE, + BOUNDED_HEADER_MAX_BYTES - offset, + ); + const buf = Buffer.alloc(chunkSize); + const { bytesRead } = await fh.read(buf, 0, chunkSize, offset); + + if (bytesRead === 0) { + // EOF — flush any remaining buffered bytes from the decoder. + const tail = decoder.end(); + const text = stripBom(tail, bomStripped); + accumulated += text; + return accumulated.length > 0 ? accumulated : null; + } + + // The decoder correctly carries over incomplete multi-byte sequences + // across chunk boundaries. + const decoded = decoder.write(buf.subarray(0, bytesRead)); + const text = stripBom(decoded, bomStripped); + // Only mark BOM as handled once the decoder has produced text. + // On a short read the first chunk may contain only the leading bytes + // of a multi-byte BOM, causing the decoder to buffer them without + // emitting any character. Prematurely setting bomStripped here would + // let the BOM leak into a subsequent chunk's output unstripped. + if (decoded.length > 0) { + bomStripped = true; + } + + const newlineIdx = text.indexOf('\n'); + if (newlineIdx >= 0) { + accumulated += text.slice(0, newlineIdx); + return accumulated; + } + accumulated += text; + offset += bytesRead; + } + } catch { + return null; + } finally { + try { + await fh.close(); + } catch { + // Swallow close errors to preserve the null-on-unreadable contract. + } + } +} diff --git a/packages/core/src/recording/index.ts b/packages/core/src/recording/index.ts index 7d2e086b7c..74105c910e 100644 --- a/packages/core/src/recording/index.ts +++ b/packages/core/src/recording/index.ts @@ -83,3 +83,12 @@ export { type HistoryMutationResult, type HistoryMutationError, } from './HistoryMutationService.js'; +export { + emptyResult, + resolveRetentionConfig, + runSessionCleanup, + type ResolvedRetentionConfig, + type SessionCleanupParams, + type SessionCleanupResult, + type UserRetentionSettings, +} from './SessionJanitor.js'; diff --git a/packages/core/src/recording/janitor/archiveCompressor.safety.test.ts b/packages/core/src/recording/janitor/archiveCompressor.safety.test.ts new file mode 100644 index 0000000000..6031a28eea --- /dev/null +++ b/packages/core/src/recording/janitor/archiveCompressor.safety.test.ts @@ -0,0 +1,244 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Adversarial safety tests for archive compressor (Items 1, 6, 8). + * + * Tests prove: + * - A symlinked archive directory is rejected. + * - A symlinked source file is rejected. + * - Stale temp cleanup matches ONLY the exact janitor-generated grammar. + * - Temp cleanup uses lstat (rejects symlink temp files). + * - Temp cleanup does not remove non-matching files. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + compressToArchive, + cleanupStaleTempArchives, +} from './archiveCompressor.js'; + +async function makeTempDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'archive-safety-')); +} + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +describe('compressToArchive — symlink safety (Item 1)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it.skipIf(process.platform === 'win32')( + 'rejects a symlinked archive directory', + async () => { + const chatsDir = path.join(tempDir, 'chats'); + const archiveDir = path.join(chatsDir, 'archive'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Create an outside directory and symlink archive → it. + const outsideDir = path.join(tempDir, 'outside'); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.symlink(outsideDir, archiveDir, 'dir'); + + const sourcePath = path.join(chatsDir, 'session-test.jsonl'); + await fs.writeFile(sourcePath, '{"type":"session_start","payload":{}}\n'); + + const result = await compressToArchive(sourcePath, archiveDir); + expect(result.success).toBe(false); + expect(result.archivePath).toBeNull(); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'rejects a symlinked source file', + async () => { + const chatsDir = path.join(tempDir, 'chats'); + const archiveDir = path.join(chatsDir, 'archive'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Create a real target file and symlink the source to it. + const targetPath = path.join(tempDir, 'real-target.jsonl'); + await fs.writeFile(targetPath, '{"type":"session_start","payload":{}}\n'); + const sourcePath = path.join(chatsDir, 'session-symlinked.jsonl'); + await fs.symlink(targetPath, sourcePath); + + const result = await compressToArchive(sourcePath, archiveDir); + expect(result.success).toBe(false); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'rejects existing-archive reuse through a symlinked archive directory', + async () => { + const realArchiveDir = path.join(tempDir, 'real-archive'); + await fs.mkdir(realArchiveDir, { recursive: true }); + + // Create a valid source and archive it into the real directory first. + const sourcePath = path.join(tempDir, 'session-reuse-symlink.jsonl'); + const content = '{"type":"session_start","payload":{}}\n'.repeat(50); + await fs.writeFile(sourcePath, content); + const result1 = await compressToArchive(sourcePath, realArchiveDir); + expect(result1.success).toBe(true); + + // Symlink a new archiveDir name to the real directory. + const symlinkArchiveDir = path.join(tempDir, 'symlink-archive'); + await fs.symlink(realArchiveDir, symlinkArchiveDir, 'dir'); + + // The final archive path resolves through the symlink to the real + // existing archive. The archiveDir identity check must reject the + // symlink BEFORE attempting reuse so the real directory is never + // mutated through the symlink. + const result2 = await compressToArchive(sourcePath, symlinkArchiveDir); + expect(result2.success).toBe(false); + }, + ); +}); + +describe('cleanupStaleTempArchives — exact grammar (Item 8)', () => { + let tempDir: string; + let archiveDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + archiveDir = path.join(tempDir, 'archive'); + await fs.mkdir(archiveDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('removes only files matching the exact janitor temp grammar', async () => { + const oldTime = new Date(Date.now() - 120 * 1000); + + // Valid janitor temp file (old enough). + const validTemp = path.join( + archiveDir, + 'session-2026-01-01T00-00-00-abc.jsonl.550e8400-e29b-41d4-a716-446655440000.gz.tmp', + ); + await fs.writeFile(validTemp, 'temp'); + await fs.utimes(validTemp, oldTime, oldTime); + + // Non-matching: wrong suffix. + const wrongSuffix = path.join(archiveDir, 'session-data.bak'); + await fs.writeFile(wrongSuffix, 'bak'); + await fs.utimes(wrongSuffix, oldTime, oldTime); + + // Non-matching: not a session file. + const notSession = path.join(archiveDir, 'random.gz.tmp'); + await fs.writeFile(notSession, 'random'); + await fs.utimes(notSession, oldTime, oldTime); + + // Non-matching: normal archive file. + const realArchive = path.join( + archiveDir, + 'session-2026-01-01T00-00-00-xyz.jsonl.gz', + ); + await fs.writeFile(realArchive, 'archive'); + await fs.utimes(realArchive, oldTime, oldTime); + + const removed = await cleanupStaleTempArchives(archiveDir, 60 * 1000); + + expect(removed).toBe(1); + expect(await fileExists(validTemp)).toBe(false); + expect(await fileExists(wrongSuffix)).toBe(true); + expect(await fileExists(notSession)).toBe(true); + expect(await fileExists(realArchive)).toBe(true); + }); + + it('does not remove temp files younger than the age threshold', async () => { + const youngTemp = path.join( + archiveDir, + 'session-2026-01-01T00-00-00-abc.jsonl.550e8400-e29b-41d4-a716-446655440000.gz.tmp', + ); + await fs.writeFile(youngTemp, 'fresh temp'); + + const removed = await cleanupStaleTempArchives(archiveDir, 60 * 1000); + expect(removed).toBe(0); + expect(await fileExists(youngTemp)).toBe(true); + }); + + it.skipIf(process.platform === 'win32')( + 'uses lstat — does not follow symlinked temp files', + async () => { + // Create a target file outside the archive dir. + const target = path.join(tempDir, 'target.txt'); + await fs.writeFile(target, 'target'); + + // Symlink that looks like a valid temp file. + const symlinkTemp = path.join( + archiveDir, + 'session-2026-01-01T00-00-00-abc.jsonl.550e8400-e29b-41d4-a716-446655440000.gz.tmp', + ); + await fs.symlink(target, symlinkTemp); + + // Make it old. + const oldTime = new Date(Date.now() - 120 * 1000); + await fs.utimes(symlinkTemp, oldTime, oldTime).catch(() => {}); + + const removed = await cleanupStaleTempArchives(archiveDir, 60 * 1000); + + // Symlink temp should NOT be removed (lstat rejects it). + expect(removed).toBe(0); + expect(await fileExists(target)).toBe(true); + }, + ); +}); + +describe('compressToArchive — fsync directory durability (Item 6)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('reports durableCommit=true on supported platforms after successful rename', async () => { + const chatsDir = path.join(tempDir, 'chats'); + const archiveDir = path.join(chatsDir, 'archive'); + await fs.mkdir(chatsDir, { recursive: true }); + + const sourcePath = path.join(chatsDir, 'session-test.jsonl'); + const sourceContent = '{"type":"session_start","payload":{}}\n'.repeat(100); + await fs.writeFile(sourcePath, sourceContent); + + const result = await compressToArchive(sourcePath, archiveDir); + expect(result.success).toBe(true); + expect(result.archivePath).not.toBeNull(); + expect(typeof result.durableCommit).toBe('boolean'); + expect(process.platform === 'win32' || result.durableCommit).toBe(true); + }); +}); diff --git a/packages/core/src/recording/janitor/archiveCompressor.test.ts b/packages/core/src/recording/janitor/archiveCompressor.test.ts new file mode 100644 index 0000000000..33c8391a42 --- /dev/null +++ b/packages/core/src/recording/janitor/archiveCompressor.test.ts @@ -0,0 +1,459 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Behavioral tests for the streaming archive compressor (AC-4). + * + * Tests prove lossless gzip round-trip (SHA-256 + byte-count identity), + * crash-safe lifecycle, stale temp cleanup, and bounded archive concurrency. + * Uses real temporary filesystems and real file content — no mocks. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as zlib from 'node:zlib'; +import { randomBytes } from 'node:crypto'; +import { + compressToArchive, + computeFileHashAndSize, + verifyArchiveIntegrity, + cleanupStaleTempArchives, +} from './archiveCompressor.js'; + +async function makeTempDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'janitor-archive-')); +} + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function writeSourceFile( + dir: string, + content: string, +): Promise<{ path: string; sha256: string; bytes: number }> { + const filePath = path.join(dir, 'source.jsonl'); + await fs.writeFile(filePath, content, 'utf8'); + const { sha256, bytes } = await computeFileHashAndSize(filePath); + return { path: filePath, sha256, bytes }; +} + +describe('compressToArchive — lossless round-trip (AC-4)', () => { + let tempDir: string; + let archiveDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + archiveDir = path.join(tempDir, 'archive'); + await fs.mkdir(archiveDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('compresses and the decompressed bytes match the source exactly', async () => { + const content = + JSON.stringify({ + v: 1, + seq: 0, + ts: new Date().toISOString(), + type: 'session_start', + payload: { sessionId: 'test', startTime: new Date().toISOString() }, + }) + + '\n' + + JSON.stringify({ + type: 'content', + payload: { text: 'Hello world'.repeat(100) }, + }) + + '\n'; + + const source = await writeSourceFile(tempDir, content); + const result = await compressToArchive(source.path, archiveDir); + + expect(result.success).toBe(true); + expect(result.archivePath).toBeTruthy(); + + // Verify decompressed content matches source exactly. + const verify = await verifyArchiveIntegrity( + result.archivePath!, + source.sha256, + source.bytes, + ); + expect(verify.ok).toBe(true); + }); + + it('archive is a valid gzip file usable by standard tools', async () => { + const content = 'test content for gzip validation\n'.repeat(50); + const source = await writeSourceFile(tempDir, content); + const result = await compressToArchive(source.path, archiveDir); + + expect(result.success).toBe(true); + + // Read and decompress with standard zlib. + const compressed = await fs.readFile(result.archivePath!); + const decompressed = zlib.gunzipSync(compressed).toString('utf8'); + expect(decompressed).toBe(content); + }); + + it('source file remains intact after compression (not yet unlinked)', async () => { + const content = 'preserve me\n'.repeat(20); + const source = await writeSourceFile(tempDir, content); + await compressToArchive(source.path, archiveDir); + + // Source should still exist after compressToArchive. + expect(await fileExists(source.path)).toBe(true); + }); + + it('handles large compressible content efficiently', async () => { + const content = 'A'.repeat(100_000); + const source = await writeSourceFile(tempDir, content); + const result = await compressToArchive(source.path, archiveDir); + + expect(result.success).toBe(true); + const verify = await verifyArchiveIntegrity( + result.archivePath!, + source.sha256, + source.bytes, + ); + expect(verify.ok).toBe(true); + + // Compressed should be much smaller for highly compressible data. + const archiveSize = (await fs.stat(result.archivePath!)).size; + expect(archiveSize).toBeLessThan(source.bytes / 10); + }); + + it('handles incompressible content (random bytes)', async () => { + const content = randomBytes(50_000).toString('hex'); + const source = await writeSourceFile(tempDir, content); + const result = await compressToArchive(source.path, archiveDir); + + expect(result.success).toBe(true); + const verify = await verifyArchiveIntegrity( + result.archivePath!, + source.sha256, + source.bytes, + ); + expect(verify.ok).toBe(true); + }); + + it('reuses existing verified archive if source is already archived', async () => { + const content = 'reuse test\n'.repeat(30); + const source = await writeSourceFile(tempDir, content); + + const result1 = await compressToArchive(source.path, archiveDir); + expect(result1.success).toBe(true); + + // Capture archive identity before the second call to prove reuse (not + // silent re-compression and overwrite to the same path). + const statBefore = await fs.stat(result1.archivePath!); + const result2 = await compressToArchive(source.path, archiveDir); + expect(result2.success).toBe(true); + expect(result2.archivePath).toBe(result1.archivePath); + expect(result2.archiveBytes).toBe(result1.archiveBytes); + const statAfter = await fs.stat(result2.archivePath!); + // Archive should not have been rewritten (same mtime). + expect(statAfter.mtimeMs).toBe(statBefore.mtimeMs); + }); + + it('returns failure for non-existent source', async () => { + const result = await compressToArchive( + path.join(tempDir, 'nonexistent.jsonl'), + archiveDir, + ); + expect(result.success).toBe(false); + }); +}); + +describe('compressToArchive — source chronology preservation (finding C)', () => { + let tempDir: string; + let archiveDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + archiveDir = path.join(tempDir, 'archive'); + await fs.mkdir(archiveDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('preserves the source recording mtime on the gzip archive', async () => { + const content = 'chronology preservation\n'.repeat(100); + const source = await writeSourceFile(tempDir, content); + const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + await fs.utimes(source.path, oldTime, oldTime); + + const result = await compressToArchive(source.path, archiveDir); + expect(result.success).toBe(true); + + const archiveStat = await fs.stat(result.archivePath!); + // The archive carries the original recording time, not "today", so age + // ranking and minRetention apply by original session age. + expect(Math.abs(archiveStat.mtimeMs - oldTime.getTime())).toBeLessThan( + 2000, + ); + }); + + it('reports the actual physical archive byte size (finding A)', async () => { + const content = 'A'.repeat(100_000); + const source = await writeSourceFile(tempDir, content); + const result = await compressToArchive(source.path, archiveDir); + expect(result.success).toBe(true); + + const archiveStat = await fs.stat(result.archivePath!); + expect(result.archiveBytes).toBe(archiveStat.size); + expect(result.archiveBytes).toBeGreaterThan(0); + expect(result.archiveBytes).toBeLessThan(source.bytes); + }); +}); + +describe('compressToArchive — typed error results (finding E)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('classifies an unreadable/non-regular source as source-invalid', async () => { + const archiveDir = path.join(tempDir, 'archive'); + await fs.mkdir(archiveDir, { recursive: true }); + const target = path.join(tempDir, 'target.jsonl'); + await fs.writeFile(target, 'data'); + const symlinkSource = path.join(tempDir, 'link.jsonl'); + await fs.symlink(target, symlinkSource); + + const result = await compressToArchive(symlinkSource, archiveDir); + expect(result.success).toBe(false); + expect(result.errorKind).toBe('source-invalid'); + }); + + it('classifies a non-directory archive path as a mkdir error', async () => { + // archiveDir is a regular file — cannot be created/used as a directory. + const blockerPath = path.join(tempDir, 'not-a-dir'); + await fs.writeFile(blockerPath, 'blocker'); + const source = path.join(tempDir, 'source.jsonl'); + await fs.writeFile(source, 'data'.repeat(50)); + + const result = await compressToArchive(source, blockerPath); + expect(result.success).toBe(false); + expect(result.errorKind).toBe('mkdir'); + }); + + it('classifies a symlinked existing archive as an existing-archive error', async () => { + const archiveDir = path.join(tempDir, 'archive'); + await fs.mkdir(archiveDir, { recursive: true }); + const source = path.join(tempDir, 'session-x.jsonl'); + await fs.writeFile(source, 'data'.repeat(50)); + + // Pre-create a symlink at the final archive path pointing outside. + const outsideTarget = path.join(tempDir, 'outside.gz'); + await fs.writeFile(outsideTarget, 'evil'); + await fs.symlink( + outsideTarget, + path.join(archiveDir, 'session-x.jsonl.gz'), + ); + + const result = await compressToArchive(source, archiveDir); + expect(result.success).toBe(false); + expect(result.errorKind).toBe('existing-archive'); + }); + + /** + * OCR finding 14/16: when the existing archive fails integrity + * verification, compressToArchive must NOT overwrite it (the rename would + * destroy it). Return a typed existing-archive error and keep both source + * and existing archive untouched. + */ + it('does NOT overwrite an unverifiable existing archive — returns typed error', async () => { + const archiveDir = path.join(tempDir, 'archive'); + await fs.mkdir(archiveDir, { recursive: true }); + const source = path.join(tempDir, 'session-corrupt.jsonl'); + await fs.writeFile(source, 'data'.repeat(50)); + + // Pre-create a corrupt archive at the final path. + const existingArchive = path.join(archiveDir, 'session-corrupt.jsonl.gz'); + const corruptContent = 'not-a-valid-gzip'.repeat(20); + await fs.writeFile(existingArchive, corruptContent); + + const result = await compressToArchive(source, archiveDir); + expect(result.success).toBe(false); + expect(result.errorKind).toBe('existing-archive'); + + // The existing archive must be untouched (not overwritten). + const afterContent = await fs.readFile(existingArchive); + expect(Buffer.compare(afterContent, Buffer.from(corruptContent))).toBe(0); + + // The source must be untouched. + expect(await fileExists(source)).toBe(true); + }); + + /** + * OCR finding 13: if the source file disappears or becomes unreadable + * during the reuse hash computation, compressToArchive must return a typed + * ArchiveResult — not throw an unhandled rejection. + */ + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'returns typed source-invalid error when source becomes unreadable during reuse check', + async () => { + const archiveDir = path.join(tempDir, 'archive'); + await fs.mkdir(archiveDir, { recursive: true }); + const source = path.join(tempDir, 'session-unreadable.jsonl'); + await fs.writeFile(source, 'data'.repeat(50)); + + // Pre-create an existing archive so the reuse path is entered. + const existingArchive = path.join( + archiveDir, + 'session-unreadable.jsonl.gz', + ); + await fs.writeFile(existingArchive, 'placeholder'.repeat(20)); + + // Make the source unreadable so computeFileHashAndSize fails. + await fs.chmod(source, 0o000); + + try { + const result = await compressToArchive(source, archiveDir); + expect(result.success).toBe(false); + expect(result.errorKind).toBe('source-invalid'); + } finally { + await fs.chmod(source, 0o644); + } + }, + ); +}); + +describe('verifyArchiveIntegrity', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('returns ok:true for a correct archive', async () => { + const content = 'integrity test\n'.repeat(10); + const filePath = path.join(tempDir, 'source.jsonl'); + await fs.writeFile(filePath, content); + const { sha256, bytes } = await computeFileHashAndSize(filePath); + + const result = await compressToArchive(filePath, tempDir); + expect(result.success).toBe(true); + + const verify = await verifyArchiveIntegrity( + result.archivePath!, + sha256, + bytes, + ); + expect(verify.ok).toBe(true); + }); + + it('returns ok:false for truncated gzip', async () => { + const content = 'truncate me\n'.repeat(10); + const filePath = path.join(tempDir, 'source.jsonl'); + await fs.writeFile(filePath, content); + const { sha256, bytes } = await computeFileHashAndSize(filePath); + + const result = await compressToArchive(filePath, tempDir); + expect(result.success).toBe(true); + + // Truncate the archive. + const archiveData = await fs.readFile(result.archivePath!); + await fs.writeFile( + result.archivePath!, + archiveData.subarray(0, archiveData.length - 10), + ); + + const verify = await verifyArchiveIntegrity( + result.archivePath!, + sha256, + bytes, + ); + expect(verify.ok).toBe(false); + }); + + it('returns ok:false for SHA-256 mismatch', async () => { + const content = 'sha mismatch\n'.repeat(10); + const filePath = path.join(tempDir, 'source.jsonl'); + await fs.writeFile(filePath, content); + const { bytes } = await computeFileHashAndSize(filePath); + + const result = await compressToArchive(filePath, tempDir); + expect(result.success).toBe(true); + + // Wrong SHA. + const verify = await verifyArchiveIntegrity( + result.archivePath!, + 'deadbeef'.repeat(8), + bytes, + ); + expect(verify.ok).toBe(false); + }); +}); + +describe('cleanupStaleTempArchives', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('removes stale .jsonl.gz.tmp files', async () => { + const tempArchive = path.join(tempDir, 'session-old.jsonl.gz.tmp'); + await fs.writeFile(tempArchive, 'partial'); + + // Set mtime to 5 minutes ago. + const oldTime = new Date(Date.now() - 5 * 60 * 1000); + await fs.utimes(tempArchive, oldTime, oldTime); + + await cleanupStaleTempArchives(tempDir, 60 * 1000); + + await expect(fs.access(tempArchive)).rejects.toThrow(/ENOENT/); + }); + + it('does not remove recent .jsonl.gz.tmp files', async () => { + const tempArchive = path.join(tempDir, 'session-recent.jsonl.gz.tmp'); + await fs.writeFile(tempArchive, 'partial'); + + await cleanupStaleTempArchives(tempDir, 60 * 1000); + + expect(await fileExists(tempArchive)).toBe(true); + }); + + it('handles non-existent directory gracefully', async () => { + await expect( + cleanupStaleTempArchives('/nonexistent/archive', 60 * 1000), + ).resolves.toBe(0); + }); +}); diff --git a/packages/core/src/recording/janitor/archiveCompressor.ts b/packages/core/src/recording/janitor/archiveCompressor.ts new file mode 100644 index 0000000000..3ffded886c --- /dev/null +++ b/packages/core/src/recording/janitor/archiveCompressor.ts @@ -0,0 +1,543 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Lossless cold-archive compression for session recordings (AC-4, hardened + * Items 1, 6, 8). + * + * Safety hardening: + * - **Item 1**: The archive directory and source file are validated as + * regular non-symlink entries at mutation time. A symlinked archive + * directory or symlinked source is rejected, preventing writes/unlinks + * outside the managed root. + * - **Item 6**: After the final gzip rename, the archive directory is fsynced + * where the platform supports it. The `durableCommit` field reports whether + * durability was established. When it is `false` the caller must NOT + * unlink the source (ambiguous failure retains data). + * - **Item 8**: Stale temp cleanup matches the exact janitor-generated temp + * grammar (`session-*.gz.tmp`), uses `lstat` to reject symlinks, and + * verifies containment. + * + * The lifecycle remains crash-safe: at every interruption point at least one + * intact copy remains. + */ + +import * as fs from 'node:fs'; +import * as fsp from 'node:fs/promises'; +import * as path from 'node:path'; +import * as zlib from 'node:zlib'; +import * as crypto from 'node:crypto'; +import { pipeline } from 'node:stream/promises'; +import { Writable } from 'node:stream'; +import { + isRegularNonSymlinkFile, + isRegularNonSymlinkDir, + isPathContainedIn, +} from './sessionSafety.js'; + +/** Suffix for temporary gzip files. */ +const TEMP_ARCHIVE_SUFFIX = '.gz.tmp'; + +/** Exact grammar for janitor-generated temp files (Item 8). */ +const TEMP_ARCHIVE_GRAMMAR = /^session-.+\.gz\.tmp$/; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** Result of archive integrity verification. */ +export interface VerifyResult { + readonly ok: boolean; + readonly error?: string; +} + +/** + * Discriminated kind for an archive failure, so callers can isolate and log + * each external filesystem failure by category (finding E). + */ +export type ArchiveErrorKind = + | 'source-invalid' + | 'mkdir' + | 'existing-archive' + | 'hash' + | 'compress' + | 'verify' + | 'rename'; + +/** Result of an archive operation. */ +export interface ArchiveResult { + readonly success: boolean; + readonly archivePath: string | null; + /** + * When `false`, the archive was written but directory-level durability + * (fsync) could not be established. The caller must NOT unlink the source. + */ + readonly durableCommit: boolean; + /** Actual physical byte size of the resulting archive (0 on failure). */ + readonly archiveBytes: number; + readonly error?: string; + readonly errorKind?: ArchiveErrorKind; +} + +// --------------------------------------------------------------------------- +// Hashing and verification +// --------------------------------------------------------------------------- + +/** + * Compute the SHA-256 hash and byte count of a file by streaming its contents. + */ +export async function computeFileHashAndSize( + filePath: string, +): Promise<{ sha256: string; bytes: number }> { + const hash = crypto.createHash('sha256'); + let bytes = 0; + const sink = new Writable({ + write(chunk: Buffer, _enc, callback) { + bytes += chunk.length; + hash.update(chunk); + callback(); + }, + }); + await pipeline(fs.createReadStream(filePath), sink); + return { sha256: hash.digest('hex'), bytes }; +} + +/** + * Stream-decompress a gzip archive and verify that its decompressed content + * matches the given source SHA-256 and byte count. + */ +export async function verifyArchiveIntegrity( + archivePath: string, + expectedSha256: string, + expectedBytes: number, +): Promise { + try { + const hash = crypto.createHash('sha256'); + let bytes = 0; + const sink = new Writable({ + write(chunk: Buffer, _enc, callback) { + bytes += chunk.length; + hash.update(chunk); + callback(); + }, + }); + await pipeline(fs.createReadStream(archivePath), zlib.createGunzip(), sink); + if (bytes !== expectedBytes) { + return { + ok: false, + error: `byte mismatch: ${bytes} != ${expectedBytes}`, + }; + } + const actualSha = hash.digest('hex'); + if (actualSha !== expectedSha256) { + return { ok: false, error: 'sha256 mismatch' }; + } + return { ok: true }; + } catch (error: unknown) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +// --------------------------------------------------------------------------- +// Compression +// --------------------------------------------------------------------------- + +/** + * Compress a raw JSONL recording into a lossless gzip archive inside the + * specified archive directory (Item 1 hardening: validates non-symlink + * identity at mutation time). + * + * The source file is **not** unlinked by this function. + */ +export async function compressToArchive( + sourcePath: string, + archiveDir: string, +): Promise { + // Item 1: Validate source is a regular non-symlink file. + if (!(await isRegularNonSymlinkFile(sourcePath))) { + return archiveError( + 'Source file is not a regular non-symlink file', + 'source-invalid', + ); + } + + // Finding C: capture the source recording mtime so the archive can preserve + // the original session chronology (age ranking + minRetention by original age). + let sourceMtime: Date; + try { + const stat = await fsp.stat(sourcePath); + sourceMtime = stat.mtime; + } catch (error: unknown) { + return archiveError(error, 'source-invalid'); + } + + const sourceBase = path.basename(sourcePath); + const finalArchivePath = path.join(archiveDir, sourceBase + '.gz'); + + // Item 1: If archiveDir already exists, it must be a regular non-symlink + // dir. This check MUST precede the reuse path so a symlinked archiveDir + // is never traversed or mutated through existing-archive reuse. + const existingDir = await isRegularNonSymlinkDir(archiveDir); + if (existingDir === false && (await pathExists(archiveDir))) { + return archiveError( + 'Archive directory is a symlink or non-directory — refusing to write', + 'mkdir', + ); + } + + // If the archive already exists and is intact, reuse it (with symlink check). + const reused = await tryReuseExistingArchive( + sourcePath, + finalArchivePath, + archiveDir, + sourceMtime, + ); + if (reused !== null) return reused; + + try { + await fsp.mkdir(archiveDir, { recursive: true }); + } catch (error: unknown) { + return archiveError(error, 'mkdir'); + } + + let sourceInfo: { sha256: string; bytes: number }; + try { + sourceInfo = await computeFileHashAndSize(sourcePath); + } catch (error: unknown) { + return archiveError(error, 'hash'); + } + + const tempName = sourceBase + '.' + crypto.randomUUID() + TEMP_ARCHIVE_SUFFIX; + const tempPath = path.join(archiveDir, tempName); + + const compressed = await streamCompress(sourcePath, tempPath); + if (!compressed) return archiveError('Compression failed', 'compress'); + + const verified = await verifyArchiveIntegrity( + tempPath, + sourceInfo.sha256, + sourceInfo.bytes, + ); + if (!verified.ok) { + await safeUnlink(tempPath); + return archiveError( + verified.error ?? 'Archive verification failed', + 'verify', + ); + } + + return finalizeArchive( + tempPath, + finalArchivePath, + sourceInfo, + archiveDir, + sourceMtime, + ); +} + +/** Reuse an existing verified archive. Null = proceed with fresh compression. */ +async function tryReuseExistingArchive( + sourcePath: string, + finalArchivePath: string, + archiveDir: string, + sourceMtime: Date, +): Promise { + if (!(await fileExists(finalArchivePath))) return null; + + // Item 1: Existing archive must be a regular non-symlink file contained in archiveDir. + if (!(await isRegularNonSymlinkFile(finalArchivePath))) { + return archiveError( + 'Existing archive is a symlink — refusing to reuse', + 'existing-archive', + ); + } + if (!isPathContainedIn(archiveDir, finalArchivePath)) { + return archiveError( + 'Existing archive path escapes archive directory', + 'existing-archive', + ); + } + + // Finding 13: the source may disappear or become unreadable between the + // initial validation in compressToArchive and this hash computation. + // Catch and return a typed ArchiveResult — never an unhandled rejection. + let sourceHash: { sha256: string; bytes: number }; + try { + sourceHash = await computeFileHashAndSize(sourcePath); + } catch (error: unknown) { + return archiveError(error, 'source-invalid'); + } + + const verify = await verifyArchiveIntegrity( + finalArchivePath, + sourceHash.sha256, + sourceHash.bytes, + ); + if (verify.ok) { + // Reused archive: fsync the directory to establish durability and preserve + // the source recording chronology on the reused archive (finding C). + const durable = await fsyncDir(archiveDir); + await applySourceMtime(finalArchivePath, sourceMtime); + const archiveBytes = await physicalBytes(finalArchivePath); + return { + success: true, + archivePath: finalArchivePath, + durableCommit: durable, + archiveBytes, + }; + } + // Finding 14/16: the existing archive failed integrity verification. + // Return a typed error so compressToArchive does NOT proceed with fresh + // compression (which would rename-over and destroy the existing archive + // while claiming to "retain it"). Keep source and existing archive + // untouched. Source hashing is not duplicated because this returns a + // concrete result, not null. + return archiveError( + verify.error ?? 'Existing archive failed integrity verification', + 'existing-archive', + ); +} + +/** Stream-compress source into temp and durably flush. False on failure. */ +async function streamCompress( + sourcePath: string, + tempPath: string, +): Promise { + try { + const gzip = zlib.createGzip({ + level: zlib.constants.Z_DEFAULT_COMPRESSION, + }); + await pipeline( + fs.createReadStream(sourcePath), + gzip, + fs.createWriteStream(tempPath), + ); + let fd: fsp.FileHandle | undefined; + try { + fd = await fsp.open(tempPath, 'r+'); + await fd.sync(); + } finally { + await fd?.close().catch(() => {}); + } + return true; + } catch { + await safeUnlink(tempPath); + return false; + } +} + +/** Atomic rename, then preserve source chronology and fsync archive directory (Items 6, C). */ +async function finalizeArchive( + tempPath: string, + finalArchivePath: string, + sourceInfo: { sha256: string; bytes: number }, + archiveDir: string, + sourceMtime: Date, +): Promise { + try { + await fsp.rename(tempPath, finalArchivePath); + } catch (error: unknown) { + await safeUnlink(tempPath); + if (await fileExists(finalArchivePath)) { + const ok = await verifyArchiveIntegrity( + finalArchivePath, + sourceInfo.sha256, + sourceInfo.bytes, + ); + if (ok.ok) { + const durable = await fsyncDir(archiveDir); + await applySourceMtime(finalArchivePath, sourceMtime); + const archiveBytes = await physicalBytes(finalArchivePath); + return { + success: true, + archivePath: finalArchivePath, + durableCommit: durable, + archiveBytes, + }; + } + } + return archiveError(error, 'rename'); + } + + // Finding C: stamp the archive with the original recording mtime so age + // ranking and minRetention apply by original session age. + await applySourceMtime(finalArchivePath, sourceMtime); + + // Item 6: fsync the archive directory after the rename to ensure the + // directory entry is durable. If fsync fails, report durableCommit=false + // so the caller retains the source. + const durable = await fsyncDir(archiveDir); + const archiveBytes = await physicalBytes(finalArchivePath); + return { + success: true, + archivePath: finalArchivePath, + durableCommit: durable, + archiveBytes, + }; +} + +/** + * Apply the source recording mtime to the archive (finding C). Best-effort: + * a failure to set mtime does not invalidate an otherwise-verified archive. + */ +async function applySourceMtime( + archivePath: string, + sourceMtime: Date, +): Promise { + try { + await fsp.utimes(archivePath, sourceMtime, sourceMtime); + } catch { + // Best-effort chronology preservation. + } +} + +/** Read the physical byte size of a file, or 0 when unreadable. */ +async function physicalBytes(filePath: string): Promise { + try { + const stat = await fsp.stat(filePath); + return stat.size; + } catch { + return 0; + } +} + +// --------------------------------------------------------------------------- +// Stale temp cleanup (Item 8) +// --------------------------------------------------------------------------- + +/** + * Clean up stale temporary archive artifacts matching the exact janitor temp + * grammar (`session-*.gz.tmp`). Uses `lstat` to reject symlinks and verifies + * containment. Only files older than `maxAgeMs` are removed (Item 8). + */ +export async function cleanupStaleTempArchives( + archiveDir: string, + maxAgeMs: number, +): Promise { + let files: string[]; + try { + files = await fsp.readdir(archiveDir); + } catch { + return 0; + } + const now = Date.now(); + let cleaned = 0; + for (const file of files) { + if (await shouldUnlinkTempFile(archiveDir, file, now, maxAgeMs)) { + try { + await fsp.unlink(path.join(archiveDir, file)); + cleaned++; + } catch { + // Best-effort. + } + } + } + return cleaned; +} + +/** Check whether a temp file matches grammar, is a regular file, and is old enough to remove. */ +async function shouldUnlinkTempFile( + archiveDir: string, + file: string, + now: number, + maxAgeMs: number, +): Promise { + if (!TEMP_ARCHIVE_GRAMMAR.test(file)) return false; + const filePath = path.join(archiveDir, file); + // Item 8: containment check. + if (!isPathContainedIn(archiveDir, filePath)) return false; + try { + // Item 8: use lstat to reject symlinks. + const lstat = await fsp.lstat(filePath); + if (lstat.isSymbolicLink() || !lstat.isFile()) return false; + return now - lstat.mtimeMs > maxAgeMs; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +/** + * Attempt to fsync a directory to establish durability (Item 6). + * + * On POSIX systems (Linux, macOS) this opens the directory read-only and calls + * fsync. On Windows or other platforms where directory fsync is not + * supported, this returns `false` (ambiguous) rather than throwing. + */ +async function fsyncDir(dirPath: string): Promise { + let fd: fsp.FileHandle | undefined; + try { + fd = await fsp.open(dirPath, 'r'); + await fd.sync(); + return true; + } catch { + return false; + } finally { + await fd?.close().catch(() => {}); + } +} + +async function safeUnlink(filePath: string): Promise { + try { + await fsp.unlink(filePath); + } catch { + // Best-effort. + } +} + +async function fileExists(filePath: string): Promise { + try { + await fsp.access(filePath); + return true; + } catch { + return false; + } +} + +async function pathExists(filePath: string): Promise { + try { + await fsp.lstat(filePath); + return true; + } catch { + return false; + } +} + +/** Build a failure result with a readable error message and kind. */ +function archiveError(error: unknown, kind: ArchiveErrorKind): ArchiveResult { + let message: string; + if (error instanceof Error) { + message = error.message; + } else if (typeof error === 'string') { + message = error; + } else { + message = String(error); + } + return { + success: false, + archivePath: null, + durableCommit: false, + archiveBytes: 0, + error: message, + errorKind: kind, + }; +} diff --git a/packages/core/src/recording/janitor/cleanupTypes.ts b/packages/core/src/recording/janitor/cleanupTypes.ts new file mode 100644 index 0000000000..2011cf3df2 --- /dev/null +++ b/packages/core/src/recording/janitor/cleanupTypes.ts @@ -0,0 +1,166 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Shared types for the session-recording janitor. + * + * The janitor discovers, evaluates, losslessly archives, and evicts inactive + * session recordings across all project hash directories under the global temp + * root to enforce a machine-wide size budget. + */ + +/** + * User-facing retention settings. Fields are all optional; the janitor + * resolves them against built-in defaults at the consumer so a partial object + * cannot accidentally remove default-on size bounding. + * + * This mirrors the CLI-side `SessionRetentionSettings` but lives in the core + * package so the janitor has no upward dependency on the CLI. + */ +export interface UserRetentionSettings { + /** When explicitly `false`, all janitorial filesystem mutations are disabled. */ + enabled?: boolean; + /** Maximum age of sessions to keep (e.g. "30d", "7d", "24h", "1w"). */ + maxAge?: string; + /** Maximum number of recordings to keep (most recent first). */ + maxCount?: number; + /** Minimum retention safety floor (defaults to "1d"). */ + minRetention?: string; + /** Machine-wide aggregate size limit in MiB (defaults to 4096 = 4 GiB). */ + maxTotalSizeMB?: number; +} + +/** + * Fully resolved retention configuration. Every numeric field is concrete; + * `maxAgeMs` / `maxCount` are `null` when no user limit was supplied (meaning + * "no limit"), which differs from `undefined` so callers can distinguish + * "resolved to no limit" from "not yet resolved". + */ +export interface ResolvedRetentionConfig { + readonly enabled: boolean; + readonly maxTotalSizeBytes: number; + readonly maxAgeMs: number | null; + readonly maxCount: number | null; + readonly minRetentionMs: number; +} + +/** Parameters for the cleanup entry point. */ +export interface SessionCleanupParams { + /** The global temp directory root (Storage.getGlobalTempDir()). */ + readonly globalTempDir: string; + /** The current process's session ID (protected from deletion). */ + readonly currentSessionId?: string; + /** Fully resolved retention configuration. */ + readonly config: ResolvedRetentionConfig; + /** When true, suppress debug logging. */ + readonly quiet?: boolean; +} + +/** Kind of physical file a candidate represents. */ +export type CandidateKind = 'raw' | 'archive'; + +/** + * A discovered session recording (raw JSONL or cold gzip archive) with the + * metadata the janitor needs for eligibility evaluation and safe deletion. + */ +export interface SessionCandidate { + readonly kind: CandidateKind; + /** Absolute path to the file. */ + readonly filePath: string; + /** Bare filename inside the chats/archive directory. */ + readonly fileName: string; + /** Absolute path of the chats or archive directory containing this file. */ + readonly containerDir: string; + /** 64-hex project-hash directory name (the direct child of global temp). */ + readonly projectHashDir: string; + /** Session ID extracted from the header, or `null` when unreadable. */ + readonly sessionId: string | null; + /** True when this recording belongs to the current process's session. */ + readonly isCurrentSession: boolean; + /** Physical size on disk in bytes (file length, or allocated blocks when available). */ + readonly sizeBytes: number; + /** File modification time. */ + readonly mtime: Date; + /** Device ID from scan-time lstat (used for mutation-time identity re-check). */ + readonly dev: number; + /** Inode number from scan-time lstat (used for mutation-time identity re-check). */ + readonly ino: number; +} + +/** + * Structured result of a cleanup sweep, carrying enough information to prove + * and diagnose behaviour. + */ +export interface SessionCleanupResult { + /** True when cleanup was disabled by configuration. */ + readonly disabled: boolean; + /** True when this process acquired the global janitor lease and ran the sweep. */ + readonly janitorWonLease: boolean; + /** Total recordings scanned (raw + archive). */ + readonly scanned: number; + /** Raw JSONL recordings compressed into cold archives. */ + readonly archived: number; + /** Raw JSONL recordings deleted (after archival or direct eviction). */ + readonly rawDeleted: number; + /** Cold archive files deleted. */ + readonly archiveDeleted: number; + /** Stale session lock files removed. */ + readonly staleLocksRemoved: number; + /** Candidates retained because they were protected (current/live/recent/unreadable). */ + readonly skipped: number; + /** Candidates the janitor failed to process (filesystem errors etc.). */ + readonly failed: number; + /** + * Number of sessions that breach an explicit age/count limit but are + * retained because they are protected (current/live/recent/unreadable). + * Protected entries count toward the configured limit yet remain retained, + * creating and reporting this shortfall (finding B/G). Zero when no + * explicit age/count limit is configured. + */ + readonly ageCountShortfall: number; + /** Aggregate physical bytes before the sweep. */ + readonly bytesBefore: number; + /** Aggregate physical bytes after the sweep. */ + readonly bytesAfter: number; + /** Configured aggregate byte limit. */ + readonly configuredByteLimit: number; + /** Remaining over-budget bytes that could not be reclaimed because data is protected. */ + readonly overBudgetBytes: number; +} + +/** Build a coherent result when no sweep was run. */ +export function emptyResult( + disabled = false, + janitorWonLease = false, + configuredByteLimit = 0, +): SessionCleanupResult { + return { + disabled, + janitorWonLease, + scanned: 0, + archived: 0, + rawDeleted: 0, + archiveDeleted: 0, + staleLocksRemoved: 0, + skipped: 0, + failed: 0, + ageCountShortfall: 0, + bytesBefore: 0, + bytesAfter: 0, + configuredByteLimit, + overBudgetBytes: 0, + }; +} diff --git a/packages/core/src/recording/janitor/index.ts b/packages/core/src/recording/janitor/index.ts new file mode 100644 index 0000000000..c1245cbcc4 --- /dev/null +++ b/packages/core/src/recording/janitor/index.ts @@ -0,0 +1,77 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Barrel export for the session-recording janitor module. + */ + +export { + emptyResult, + type UserRetentionSettings, + type ResolvedRetentionConfig, + type SessionCandidate, + type SessionCleanupParams, + type SessionCleanupResult, + type CandidateKind, +} from './cleanupTypes.js'; + +export { + DEFAULT_MAX_TOTAL_SIZE_MB, + DEFAULT_MIN_RETENTION, + parseRetentionPeriod, + validateRetentionConfig, + resolveRetentionConfig, +} from './retentionPolicy.js'; + +export { + readSessionJsonlHeader, + type SessionHeaderInfo, +} from './sessionHeaderReader.js'; + +export { + scanGlobalSessions, + ARCHIVE_DIR_NAME, + type ScanResult, +} from './sessionScanner.js'; + +export { JanitorLease, type JanitorLeaseHandle } from './janitorLease.js'; + +export { + compressToArchive, + verifyArchiveIntegrity, + computeFileHashAndSize, + cleanupStaleTempArchives, + type ArchiveResult, + type VerifyResult, +} from './archiveCompressor.js'; + +export { + runSessionCleanup, + runSessionCleanupWithSettings, +} from './sessionJanitor.js'; + +export { + runReclamation, + type ReclamationMetrics, +} from './reclamationEngine.js'; + +export { + buildSessionGroups, + evaluateGroupEligibility, + compareGroupsOldestFirst, + compareGroupsNewestFirst, + type SessionGroup, +} from './sessionGrouping.js'; diff --git a/packages/core/src/recording/janitor/janitorLease.safety.test.ts b/packages/core/src/recording/janitor/janitorLease.safety.test.ts new file mode 100644 index 0000000000..3cf565c1c2 --- /dev/null +++ b/packages/core/src/recording/janitor/janitorLease.safety.test.ts @@ -0,0 +1,663 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Adversarial safety tests for the hardened JanitorLease (Item 5). + * + * Tests prove: + * - An old-createdAt lease with a fresh heartbeat is NOT stale. + * - A replaced lease is not overwritten or deleted by the old owner. + * - A malformed lease is recoverable, not a permanent denial. + * - Heartbeat updates heartbeatAt while ownership is verified. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { JanitorLease } from './janitorLease.js'; +import type { JanitorLeaseHandle } from './janitorLease.js'; + +const LEASE_FILE_NAME = '.llxprt-janitor.lease'; +const LEASE_CLAIM_SUFFIX = '.tclaim'; + +/** + * Tracks any lease acquired by a test so afterEach can reliably release it + * (and stop the heartbeat timer) even if an assertion throws before the + * test's own release call. Local tracked-handle cleanup — no production + * reset API needed. + */ +let trackedLease: JanitorLeaseHandle | null = null; + +afterEach(async () => { + if (trackedLease) { + await trackedLease.release().catch(() => {}); + trackedLease = null; + } + JanitorLease.setPreClaimHookForTest(null); +}); + +async function makeTempDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'lease-safety-')); +} + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +/** + * Wait for a specific marker string on a child's stdout. Rejects with the + * captured stderr on timeout or unexpected exit so failures are diagnosable. + */ +function waitForChildSignal( + child: ChildProcessWithoutNullStreams, + getStdout: () => string, + getStderr: () => string, + marker: string, + timeoutMs = 10000, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = (): void => { + clearTimeout(timer); + child.stdout.off('data', onData); + child.off('close', onClose); + child.off('error', onError); + }; + const succeed = (): void => { + if (settled) return; + settled = true; + cleanup(); + resolve(); + }; + const fail = (message: string): void => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error(message)); + }; + + const timer = setTimeout( + () => + fail( + `Timeout waiting for "${marker}" +stderr: ${getStderr()}`, + ), + timeoutMs, + ); + const onData = (_d: Buffer): void => { + if (getStdout().includes(marker)) succeed(); + }; + const onClose = (): void => { + if (getStdout().includes(marker)) succeed(); + else + fail( + `Child exited before "${marker}" +stderr: ${getStderr()}`, + ); + }; + const onError = (err: Error): void => { + fail( + `Child error before "${marker}": ${err.message} +stderr: ${getStderr()}`, + ); + }; + + if (getStdout().includes(marker)) { + succeed(); + return; + } + child.stdout.on('data', onData); + child.on('close', onClose); + child.on('error', onError); + }); +} + +/** + * Ensure a spawned child is terminated and its exit awaited, regardless of + * test outcome. Idempotent — safe to call in a finally block even if the + * child already exited. Sends SIGTERM to the exact child only; escalates to + * SIGKILL if the child does not close within a grace period; awaits the + * 'close' event in both cases. Fails (rejects) with diagnostics if even + * SIGKILL cannot produce an observed close. + */ +async function killAndAwaitChild( + child: ChildProcessWithoutNullStreams, +): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + + await new Promise((resolve, reject) => { + let settled = false; + const cleanup = (): void => { + child.off('close', onClose); + }; + const onClose = (): void => { + if (settled) return; + settled = true; + clearTimeout(escalationTimer); + cleanup(); + resolve(); + }; + child.on('close', onClose); + + const escalationTimer = setTimeout(() => { + if (settled) return; + // SIGTERM grace period elapsed without close — escalate to SIGKILL on + // the exact child. + try { + child.kill('SIGKILL'); + } catch { + // ignore — fall through to final guard + } + // Final guard: if SIGKILL also fails to produce a close, fail loudly. + setTimeout(() => { + if (settled) return; + settled = true; + cleanup(); + reject( + new Error( + `killAndAwaitChild: child (pid=${child.pid}) did not close after SIGKILL`, + ), + ); + }, 5000); + }, 2000); + + try { + child.kill('SIGTERM'); + } catch { + settled = true; + clearTimeout(escalationTimer); + cleanup(); + resolve(); + } + }); +} + +describe('JanitorLease — old-createdAt / fresh-heartbeat (Item 5)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('does NOT treat a lease with old createdAt but fresh heartbeat as stale', async () => { + const leasePath = path.join(tempDir, LEASE_FILE_NAME); + const oldCreated = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + const freshHeartbeat = new Date().toISOString(); + + await fs.writeFile( + leasePath, + JSON.stringify({ + ownerToken: 'fresh-heartbeat-owner', + pid: process.pid, + hostname: os.hostname(), + createdAt: oldCreated, + heartbeatAt: freshHeartbeat, + }), + ); + + // The lease has a fresh heartbeat → should NOT be taken over. + const lease = await JanitorLease.tryAcquire(tempDir); + expect(lease).toBeNull(); + }); + + it('treats a lease with old createdAt AND old heartbeat as stale', async () => { + const leasePath = path.join(tempDir, LEASE_FILE_NAME); + // Use 3 hours ago: beyond PID_REUSE_BOUND_MS (2h) so even a live PID + // is considered stale. + const oldTime = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(); + + await fs.writeFile( + leasePath, + JSON.stringify({ + ownerToken: 'all-old-owner', + pid: process.pid, + hostname: os.hostname(), + createdAt: oldTime, + heartbeatAt: oldTime, + }), + ); + + // Both createdAt and heartbeatAt are old → stale → can be taken over. + const lease = await JanitorLease.tryAcquire(tempDir); + expect(lease).not.toBeNull(); + trackedLease = lease; + await lease!.release(); + trackedLease = null; + }); +}); + +describe('JanitorLease — replacement ownership safety (Item 5)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it("release does not delete a replacement owner's lease", async () => { + const lease = await JanitorLease.tryAcquire(tempDir); + expect(lease).not.toBeNull(); + trackedLease = lease; + + const leasePath = path.join(tempDir, LEASE_FILE_NAME); + + // Simulate another process replacing the lease. + await fs.writeFile( + leasePath, + JSON.stringify({ + ownerToken: 'replacement-owner', + pid: process.pid, + hostname: os.hostname(), + createdAt: new Date().toISOString(), + heartbeatAt: new Date().toISOString(), + }), + ); + + // Release our old lease — should NOT remove the replacement. + await lease!.release(); + trackedLease = null; + + const content = await fs.readFile(leasePath, 'utf-8'); + expect(JSON.parse(content).ownerToken).toBe('replacement-owner'); + }); + + it('a fresh replacement lease blocks acquisition', async () => { + const leasePath = path.join(tempDir, LEASE_FILE_NAME); + + // Write a fresh live lease directly. + const freshTime = new Date().toISOString(); + await fs.writeFile( + leasePath, + JSON.stringify({ + ownerToken: 'fresh-replacement', + pid: process.pid, + hostname: os.hostname(), + createdAt: freshTime, + heartbeatAt: freshTime, + }), + ); + + // Acquisition should fail — the lease is fresh. + const lease = await JanitorLease.tryAcquire(tempDir); + expect(lease).toBeNull(); + + // The fresh lease should survive. + const content = await fs.readFile(leasePath, 'utf-8'); + expect(JSON.parse(content).ownerToken).toBe('fresh-replacement'); + }); +}); + +describe('JanitorLease — malformed lease recovery (Item 5)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('recovers from a corrupt lease file without permanent denial', async () => { + const leasePath = path.join(tempDir, LEASE_FILE_NAME); + + // Write a corrupt lease file (old enough to be past the age bound). + const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + await fs.utimes(tempDir, new Date(oldTime), new Date(oldTime)); + await fs.writeFile(leasePath, 'this is corrupt garbage!!!'); + + // Set the file mtime to be old so it's past the recovery bound. + const oldDate = new Date(Date.now() - 2 * 60 * 60 * 1000); + await fs.utimes(leasePath, oldDate, oldDate); + + // Acquisition should eventually succeed (recoverable, not permanent denial). + const lease = await JanitorLease.tryAcquire(tempDir); + expect(lease).not.toBeNull(); + trackedLease = lease; + await lease!.release(); + trackedLease = null; + }); + + it('does not remove a recent corrupt lease (conservative skip)', async () => { + const leasePath = path.join(tempDir, LEASE_FILE_NAME); + await fs.writeFile(leasePath, 'corrupt but recent'); + + // The corrupt lease is recent — acquisition should skip conservatively. + const lease = await JanitorLease.tryAcquire(tempDir); + expect(lease).toBeNull(); + + // The corrupt lease should still exist (not removed). + expect(await fileExists(leasePath)).toBe(true); + }); +}); + +describe('JanitorLease — atomic publication (Item 5)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('does not leave temp artifacts after successful acquire', async () => { + const lease = await JanitorLease.tryAcquire(tempDir); + expect(lease).not.toBeNull(); + trackedLease = lease; + + const entries = await fs.readdir(tempDir); + // Only the lease file should exist — no .tmp artifacts. + const tempArtifacts = entries.filter( + (f) => f.includes('.tmp') || f.includes('.lease'), + ); + expect(tempArtifacts).toEqual([LEASE_FILE_NAME]); + + await lease!.release(); + trackedLease = null; + }); +}); + +describe('JanitorLease — in-place heartbeat safety (root fix 2)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('writes a fresh heartbeatAt equal to createdAt on acquire', async () => { + const lease = await JanitorLease.tryAcquire(tempDir); + expect(lease).not.toBeNull(); + trackedLease = lease; + + const leasePath = path.join(tempDir, LEASE_FILE_NAME); + const content = await fs.readFile(leasePath, 'utf-8'); + const record = JSON.parse(content); + expect(record.ownerToken).toBeDefined(); + expect(record.heartbeatAt).toBe(record.createdAt); + + await lease!.release(); + trackedLease = null; + }); + + it('heartbeat in-place write does not overwrite a replacement owner', async () => { + // Acquire a lease, then replace the lease content (simulating a takeover). + // The old owner's in-place heartbeat (r+ on the old inode) must not + // overwrite the replacement's content. + const lease = await JanitorLease.tryAcquire(tempDir); + expect(lease).not.toBeNull(); + trackedLease = lease; + + const leasePath = path.join(tempDir, LEASE_FILE_NAME); + + // Replace the lease with a different owner (new inode at the same path). + await fs.unlink(leasePath); + const replacementContent = JSON.stringify({ + ownerToken: 'replacement-owner', + pid: process.pid, + hostname: os.hostname(), + createdAt: new Date().toISOString(), + heartbeatAt: new Date().toISOString(), + }); + await fs.writeFile(leasePath, replacementContent); + + // Release the old lease — owner-checked release must not remove it. + await lease!.release(); + trackedLease = null; + + const content = await fs.readFile(leasePath, 'utf-8'); + expect(JSON.parse(content).ownerToken).toBe('replacement-owner'); + }); +}); + +// --------------------------------------------------------------------------- +// OCR 18/19: Transition claim protocol — deterministic contention tests +// with real filesystem and subprocess behavior. +// --------------------------------------------------------------------------- + +describe('JanitorLease — transition claim protocol (OCR 18/19)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + /** + * A crashed transition claim (hard link to a stale lease inode) must be + * safely reclaimed so a subsequent stale takeover can proceed. + */ + it('reclaims a crashed stale transition claim and takes over', async () => { + const leasePath = path.join(tempDir, LEASE_FILE_NAME); + const claimPath = leasePath + LEASE_CLAIM_SUFFIX; + const staleTime = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(); + + // Write a stale lease. + await fs.writeFile( + leasePath, + JSON.stringify({ + ownerToken: 'crashed-contender', + pid: process.pid, + hostname: os.hostname(), + createdAt: staleTime, + heartbeatAt: staleTime, + }), + ); + + // Simulate a crashed contender that left a transition claim behind. + await fs.link(leasePath, claimPath); + expect(await fileExists(claimPath)).toBe(true); + + // tryAcquire should reclaim the stale claim and take over the lease. + const lease = await JanitorLease.tryAcquire(tempDir); + expect(lease).not.toBeNull(); + trackedLease = lease; + await lease!.release(); + trackedLease = null; + }); + + /** + * A stale contender holding a crashed claim must NOT unlink a fresh + * replacement lease. The pre-existing claim pins the OLD stale inode; + * the fresh replacement has a different inode. The takeover skips. + */ + it('does not unlink a fresh replacement when a stale claim pins the old inode', async () => { + const leasePath = path.join(tempDir, LEASE_FILE_NAME); + const claimPath = leasePath + LEASE_CLAIM_SUFFIX; + const staleTime = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(); + + // Write a stale lease. + await fs.writeFile( + leasePath, + JSON.stringify({ + ownerToken: 'old-stale', + pid: process.pid, + hostname: os.hostname(), + createdAt: staleTime, + heartbeatAt: staleTime, + }), + ); + + // Crashed contender leaves a claim on the old stale inode. + await fs.link(leasePath, claimPath); + + // Replace the lease with a FRESH live lease (new inode). + await fs.unlink(leasePath); + const freshTime = new Date().toISOString(); + await fs.writeFile( + leasePath, + JSON.stringify({ + ownerToken: 'fresh-replacement', + pid: process.pid, + hostname: os.hostname(), + createdAt: freshTime, + heartbeatAt: freshTime, + }), + ); + + // tryAcquire must NOT take over the fresh replacement. + const lease = await JanitorLease.tryAcquire(tempDir); + expect(lease).toBeNull(); + + // The fresh replacement survives. + const content = await fs.readFile(leasePath, 'utf-8'); + expect(JSON.parse(content).ownerToken).toBe('fresh-replacement'); + }); + + /** + * Fresh-heartbeat contention (subprocess): a subprocess holds a live lease + * with active heartbeats. The main process cannot take it over. After + * the subprocess releases, the main process acquires successfully. + */ + it('fresh-heartbeat subprocess contention: main process cannot take over a live lease', async () => { + const script = ` + const { JanitorLease } = require(${JSON.stringify(path.resolve(__dirname, 'janitorLease.js'))}); + const tempDir = process.env.TEST_TEMP_DIR; + (async () => { + const lease = await JanitorLease.tryAcquire(tempDir); + if (lease) { + process.stdout.write('HOLDING'); + await new Promise(r => setTimeout(r, 1500)); + await lease.release(); + process.stdout.write('RELEASED'); + } else { + process.stdout.write('SKIP'); + } + })().catch(e => { process.stderr.write(String(e)); process.stdout.write('ERROR'); }); + `; + + const child = spawn('bun', ['-e', script], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, TEST_TEMP_DIR: tempDir }, + }); + + let childStdout = ''; + let childStderr = ''; + child.stdout.on('data', (d) => (childStdout += d.toString())); + child.stderr.on('data', (d) => (childStderr += d.toString())); + + try { + // Wait for the subprocess to acquire the lease. + await waitForChildSignal( + child, + () => childStdout, + () => childStderr, + 'HOLDING', + ); + + // The subprocess holds a fresh lease — main process cannot take over. + const attempt = await JanitorLease.tryAcquire(tempDir); + expect(attempt).toBeNull(); + + // Wait for the subprocess to release. + await waitForChildSignal( + child, + () => childStdout, + () => childStderr, + 'RELEASED', + ); + + // After release, the main process can acquire. + const lease = await JanitorLease.tryAcquire(tempDir); + expect(lease).not.toBeNull(); + trackedLease = lease; + await lease!.release(); + trackedLease = null; + } finally { + // Guarantee child termination regardless of pass/fail/timeout. + await killAndAwaitChild(child); + } + }, 30000); + + /** + * When the lease vanishes between the staleness pre-check and the claim + * acquisition (ENOENT), the caller proceeds without owning a claim. The + * finally block must NOT release a claim it never created — otherwise it + * could unlink a contender's subsequently-created claim file. + * + * The pre-claim hook deterministically forces this race: it removes the + * stale lease (so `link` fails with ENOENT) and creates a standalone + * "foreign" claim file. After `tryAcquire` returns, the foreign claim + * must still exist. + */ + it('does not release an unowned claim when the lease vanishes during takeover (ENOENT race)', async () => { + const leasePath = path.join(tempDir, LEASE_FILE_NAME); + const claimPath = leasePath + LEASE_CLAIM_SUFFIX; + const staleTime = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(); + + // Write a stale lease so the pre-check determines staleness. + await fs.writeFile( + leasePath, + JSON.stringify({ + ownerToken: 'stale-to-take-over', + pid: 999999, + hostname: os.hostname(), + createdAt: staleTime, + heartbeatAt: staleTime, + }), + ); + + // Hook fires after the pre-check passes but before claim acquisition. + JanitorLease.setPreClaimHookForTest(async () => { + // Remove the lease so acquireTransitionClaim's `link` hits ENOENT + // (returns canProceed=true, ownsClaim=false). + await fs.unlink(leasePath).catch(() => {}); + // Simulate a contender that created a claim while the lease was + // absent. This standalone file is NOT a hard link to our lease. + const freshTime = new Date().toISOString(); + await fs.writeFile( + claimPath, + JSON.stringify({ + ownerToken: 'foreign-contender', + pid: process.pid, + hostname: os.hostname(), + createdAt: freshTime, + heartbeatAt: freshTime, + }), + ); + }); + + // tryAcquire hits the ENOENT path; it must not take over (fresh claim + // content via the foreign claim) and must not remove the foreign claim. + const lease = await JanitorLease.tryAcquire(tempDir); + expect(lease).toBeNull(); + + // The foreign claim file must survive — we never owned it. + expect(await fileExists(claimPath)).toBe(true); + }); +}); diff --git a/packages/core/src/recording/janitor/janitorLease.test.ts b/packages/core/src/recording/janitor/janitorLease.test.ts new file mode 100644 index 0000000000..170b91d10c --- /dev/null +++ b/packages/core/src/recording/janitor/janitorLease.test.ts @@ -0,0 +1,580 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Behavioral tests for the cross-process janitor lease (AC-6). + * + * Tests use real temporary filesystems and real subprocess competition. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { JanitorLease } from './janitorLease.js'; +import type { JanitorLeaseHandle } from './janitorLease.js'; + +async function makeTempDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'janitor-lease-')); +} + +/** + * Tracks any lease acquired by a test so afterEach can reliably release it + * (and stop the heartbeat timer) even if an assertion throws before the + * test's own release call. + */ +let trackedLease: JanitorLeaseHandle | null = null; + +afterEach(async () => { + if (trackedLease) { + await trackedLease.release().catch(() => {}); + trackedLease = null; + } +}); + +describe('JanitorLease — single-process acquisition', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('acquires a lease when no lease exists', async () => { + const lease = await JanitorLease.tryAcquire(tempDir); + expect(lease).not.toBeNull(); + trackedLease = lease; + await lease!.release(); + trackedLease = null; + }); + + it('returns null when a lease is already held (skip-on-busy)', async () => { + const lease1 = await JanitorLease.tryAcquire(tempDir); + expect(lease1).not.toBeNull(); + trackedLease = lease1; + + const lease2 = await JanitorLease.tryAcquire(tempDir); + expect(lease2).toBeNull(); + + await lease1!.release(); + trackedLease = null; + }); + + it('releases the lease so another process can acquire', async () => { + const lease1 = await JanitorLease.tryAcquire(tempDir); + expect(lease1).not.toBeNull(); + trackedLease = lease1; + await lease1!.release(); + trackedLease = null; + + const lease2 = await JanitorLease.tryAcquire(tempDir); + expect(lease2).not.toBeNull(); + trackedLease = lease2; + await lease2!.release(); + trackedLease = null; + }); + + it("owner-checked release does not remove another owner's lease", async () => { + const lease1 = await JanitorLease.tryAcquire(tempDir); + expect(lease1).not.toBeNull(); + trackedLease = lease1; + + // Simulate another process writing a different lease file. + const leasePath = path.join(tempDir, '.llxprt-janitor.lease'); + const content = JSON.stringify({ + ownerToken: 'different-owner-token', + pid: 999999, + hostname: os.hostname(), + createdAt: new Date().toISOString(), + heartbeatAt: new Date().toISOString(), + }); + await fs.writeFile(leasePath, content); + + // Release lease1 — should NOT remove the replacement. + await lease1!.release(); + trackedLease = null; + + // The replacement lease file should still exist. + const afterContent = await fs.readFile(leasePath, 'utf-8'); + expect(JSON.parse(afterContent).ownerToken).toBe('different-owner-token'); + }); + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'tryAcquire propagates I/O errors instead of masking them as busy', + async () => { + // Make the temp dir read-only so temp-file creation fails with EACCES. + await fs.chmod(tempDir, 0o555); + try { + let threw = false; + try { + await JanitorLease.tryAcquire(tempDir); + } catch { + threw = true; + } + // A genuine I/O error must propagate — not be swallowed as null (busy). + expect(threw).toBe(true); + } finally { + await fs.chmod(tempDir, 0o755); + } + }, + ); +}); + +describe('JanitorLease — stale recovery', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('takes over a stale lease from a dead PID', async () => { + const leasePath = path.join(tempDir, '.llxprt-janitor.lease'); + + // Write a stale lease with a dead PID. + const oldTime = new Date(Date.now() - 30 * 60 * 1000).toISOString(); + await fs.writeFile( + leasePath, + JSON.stringify({ + ownerToken: 'stale-token', + pid: 999999, // Almost certainly dead. + hostname: os.hostname(), + createdAt: oldTime, + heartbeatAt: oldTime, + }), + ); + + // Should be able to acquire. + const lease = await JanitorLease.tryAcquire(tempDir); + expect(lease).not.toBeNull(); + trackedLease = lease; + await lease!.release(); + trackedLease = null; + }); + + it('does not take over a lease with a live PID and recent heartbeat', async () => { + const leasePath = path.join(tempDir, '.llxprt-janitor.lease'); + + // Write a live lease using our own PID. + await fs.writeFile( + leasePath, + JSON.stringify({ + ownerToken: 'live-token', + pid: process.pid, + hostname: os.hostname(), + createdAt: new Date().toISOString(), + heartbeatAt: new Date().toISOString(), + }), + ); + + // Should NOT be able to acquire — lease is live. + const lease = await JanitorLease.tryAcquire(tempDir); + expect(lease).toBeNull(); + }); + + it('takes over a lease with a live PID but stale heartbeat exceeding the absolute PID-reuse bound', async () => { + const leasePath = path.join(tempDir, '.llxprt-janitor.lease'); + + // Write a lease with our PID but createdAt/heartbeatAt far beyond the + // absolute PID-reuse bound (2 hours). Even though the PID is alive, + // the absolute bound ensures the lease is reclaimable. + const staleTime = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(); + await fs.writeFile( + leasePath, + JSON.stringify({ + ownerToken: 'frozen-token', + pid: process.pid, + hostname: os.hostname(), + createdAt: staleTime, + heartbeatAt: staleTime, + }), + ); + + // Should be able to acquire — exceeds the absolute PID-reuse bound. + const lease = await JanitorLease.tryAcquire(tempDir); + expect(lease).not.toBeNull(); + trackedLease = lease; + await lease!.release(); + trackedLease = null; + }); + + it('does NOT take over a live-PID lease with stale heartbeat within the PID-reuse bound', async () => { + const leasePath = path.join(tempDir, '.llxprt-janitor.lease'); + + // Heartbeat is stale (60 min > 10 min) but PID is alive and createdAt + // is within the 2-hour PID-reuse bound. + const staleTime = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + await fs.writeFile( + leasePath, + JSON.stringify({ + ownerToken: 'alive-frozen', + pid: process.pid, + hostname: os.hostname(), + createdAt: staleTime, + heartbeatAt: staleTime, + }), + ); + + // Should NOT be able to acquire — PID is alive and within PID-reuse bound. + const lease = await JanitorLease.tryAcquire(tempDir); + expect(lease).toBeNull(); + }); +}); + +describe('JanitorLease — real subprocess concurrency (AC-6)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('exactly one of two concurrent subprocesses wins the lease', async () => { + // Fully parent-coordinated contention: each subprocess signals READY, + // spin-waits for a start barrier, then races to acquire. The winner + // prints WON immediately and holds the lease until a distinct release + // barrier appears; the loser prints SKIP. The parent waits until each + // child reports WON or SKIP, asserts exactly one winner, then publishes + // the release barrier and awaits clean completion. Child exceptions + // write stderr and exit nonzero — they never masquerade as SKIP. + const startBarrierPath = path.join(tempDir, '.start-barrier'); + const releaseBarrierPath = path.join(tempDir, '.release-barrier'); + const script = ` + const { JanitorLease } = require(${JSON.stringify(path.resolve(__dirname, 'janitorLease.js'))}); + const fs = require('fs'); + const tempDir = process.env.TEST_TEMP_DIR; + const startBarrier = process.env.TEST_START_BARRIER; + const releaseBarrier = process.env.TEST_RELEASE_BARRIER; + (async () => { + try { + process.stdout.write('READY'); + const startDeadline = Date.now() + 15000; + while (!fs.existsSync(startBarrier)) { + if (Date.now() > startDeadline) { process.stdout.write('TIMEOUT'); return; } + await new Promise(r => setTimeout(r, 5)); + } + const lease = await JanitorLease.tryAcquire(tempDir); + if (lease) { + process.stdout.write('WON'); + // Hold the lease until the parent publishes the release barrier. + const releaseDeadline = Date.now() + 15000; + while (!fs.existsSync(releaseBarrier)) { + if (Date.now() > releaseDeadline) { + await lease.release(); + process.stderr.write('release barrier timeout'); + process.exit(1); + } + await new Promise(r => setTimeout(r, 5)); + } + await lease.release(); + } else { + process.stdout.write('SKIP'); + } + } catch (e) { + process.stderr.write(String(e && e.stack ? e.stack : e)); + process.exit(1); + } + })(); + `; + + const childEnv = { + TEST_TEMP_DIR: tempDir, + TEST_START_BARRIER: startBarrierPath, + TEST_RELEASE_BARRIER: releaseBarrierPath, + }; + const managed = [ + spawnManagedChild(script, childEnv), + spawnManagedChild(script, childEnv), + ]; + + try { + // Wait for both subprocesses to reach the start-barrier spin-wait. + await Promise.all(managed.map((m) => waitForManagedSignal(m, 'READY'))); + + // Release the start barrier — both attempt concurrently. + await fs.writeFile(startBarrierPath, 'go'); + + // Wait until each child reports WON or SKIP (before any release). + const reportPromises = managed.map((m) => + waitForManagedSignal(m, ['WON', 'SKIP']), + ); + const reports = await Promise.all(reportPromises); + + // Assert exactly one winner. + const winners = reports.filter((r) => r === 'WON'); + expect(winners.length).toBe(1); + + // Publish the release barrier so the winner can release and exit. + await fs.writeFile(releaseBarrierPath, 'go'); + + // Await clean completion of both children. + await Promise.all(managed.map((m) => awaitManagedCompletion(m))); + } finally { + await Promise.all(managed.map((m) => killManagedChild(m))); + } + }, 30000); +}); + +/** + * A spawned child with accumulated stdout/stderr buffers. + */ +interface ManagedChild { + readonly child: ChildProcessWithoutNullStreams; + stdout: string; + stderr: string; +} + +/** + * Spawn a child script, collecting stdout and stderr into mutable buffers. + */ +function spawnManagedChild( + code: string, + env: Record, +): ManagedChild { + const child = spawn('bun', ['-e', code], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, ...env }, + }); + const managed: ManagedChild = { child, stdout: '', stderr: '' }; + child.stdout.on('data', (d) => (managed.stdout += d.toString())); + child.stderr.on('data', (d) => (managed.stderr += d.toString())); + return managed; +} + +/** + * Find the first of the candidate markers present in stdout, or null. + */ +function findMarker(stdout: string, markers: readonly string[]): string | null { + for (const m of markers) { + if (stdout.includes(m)) return m; + } + return null; +} + +/** + * Wait for one of the candidate markers on the child's stdout. Resolves + * with the matched marker. Rejects with captured stderr on timeout or + * unexpected exit so failures are diagnosable. Handles already-exited + * children and 'error' events, and cleans up all listeners/timers on every + * settle path. + */ +function waitForManagedSignal( + managed: ManagedChild, + markers: string | readonly string[], + timeoutMs = 10000, +): Promise { + const candidates = Array.isArray(markers) ? markers : [markers]; + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = (): void => { + clearTimeout(timer); + managed.child.stdout.off('data', onData); + managed.child.off('close', onClose); + managed.child.off('error', onError); + }; + const succeed = (match: string): void => { + if (settled) return; + settled = true; + cleanup(); + resolve(match); + }; + const fail = (message: string): void => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error(message)); + }; + + const timer = setTimeout( + () => + fail( + `Timeout waiting for ${JSON.stringify(candidates)} +stderr: ${managed.stderr}`, + ), + timeoutMs, + ); + const onData = (_d: Buffer): void => { + const match = findMarker(managed.stdout, candidates); + if (match) succeed(match); + }; + const onClose = (): void => { + const match = findMarker(managed.stdout, candidates); + if (match) succeed(match); + else + fail( + `Child exited before ${JSON.stringify(candidates)} +stderr: ${managed.stderr}`, + ); + }; + const onError = (err: Error): void => { + fail( + `Child error before ${JSON.stringify(candidates)}: ${err.message} +stderr: ${managed.stderr}`, + ); + }; + + // Handle a child that already emitted the marker or already exited. + const early = findMarker(managed.stdout, candidates); + if (early) { + succeed(early); + return; + } + if (managed.child.exitCode !== null || managed.child.signalCode !== null) { + fail( + `Child already exited before ${JSON.stringify(candidates)} +stderr: ${managed.stderr}`, + ); + return; + } + + managed.child.stdout.on('data', onData); + managed.child.on('close', onClose); + managed.child.on('error', onError); + }); +} + +/** + * Resolve with the child's stdout when it exits cleanly (code 0). Handles + * already-exited children and 'error' events; cleans up listeners/timers on + * every settle path via a single `settle` continuation guard. + */ +function awaitManagedCompletion( + managed: ManagedChild, + timeoutMs = 20000, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const settle = (action: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + managed.child.off('close', onClose); + managed.child.off('error', onError); + action(); + }; + + const timer = setTimeout( + () => + settle(() => + reject( + new Error(`Completion timeout +stderr: ${managed.stderr}`), + ), + ), + timeoutMs, + ); + const onClose = (code: number | null): void => { + if (code === 0) settle(() => resolve(managed.stdout)); + else + settle(() => + reject( + new Error(`Exit ${code} +stderr: ${managed.stderr}`), + ), + ); + }; + const onError = (err: Error): void => { + settle(() => + reject( + new Error(`Child error: ${err.message} +stderr: ${managed.stderr}`), + ), + ); + }; + + // Handle a child that already exited before attachment. + if (managed.child.exitCode !== null || managed.child.signalCode !== null) { + if (managed.child.exitCode === 0) settle(() => resolve(managed.stdout)); + else + settle(() => + reject( + new Error( + `Already exited with ${managed.child.exitCode ?? managed.child.signalCode} +stderr: ${managed.stderr}`, + ), + ), + ); + return; + } + + managed.child.on('close', onClose); + managed.child.on('error', onError); + }); +} + +/** + * Ensure a spawned child is terminated and its exit awaited, regardless of + * test outcome. Idempotent. Sends SIGTERM to the exact child only; + * escalates to SIGKILL if the child does not close within a grace period; + * awaits the 'close' event in both cases. Fails (rejects) with diagnostics + * if even SIGKILL cannot produce an observed close. + */ +async function killManagedChild(managed: ManagedChild): Promise { + const { child } = managed; + if (child.exitCode !== null || child.signalCode !== null) return; + + await new Promise((resolve, reject) => { + let settled = false; + const onClose = (): void => { + if (settled) return; + settled = true; + clearTimeout(escalationTimer); + child.off('close', onClose); + resolve(); + }; + child.on('close', onClose); + + const escalationTimer = setTimeout(() => { + if (settled) return; + // SIGTERM grace period elapsed without close — escalate to SIGKILL on + // the exact child. + try { + child.kill('SIGKILL'); + } catch { + // ignore — fall through to final guard + } + // Final guard: if SIGKILL also fails to produce a close, fail loudly. + setTimeout(() => { + if (settled) return; + settled = true; + child.off('close', onClose); + reject( + new Error( + `killManagedChild: child (pid=${child.pid}) did not close after SIGKILL`, + ), + ); + }, 5000); + }, 2000); + + try { + child.kill('SIGTERM'); + } catch { + settled = true; + clearTimeout(escalationTimer); + child.off('close', onClose); + resolve(); + } + }); +} diff --git a/packages/core/src/recording/janitor/janitorLease.ts b/packages/core/src/recording/janitor/janitorLease.ts new file mode 100644 index 0000000000..2cd2bd6136 --- /dev/null +++ b/packages/core/src/recording/janitor/janitorLease.ts @@ -0,0 +1,640 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Global cross-process janitor lease (AC-6, hardened Item 5). + * + * Exactly one concurrent starter wins the lease and performs the full sweep. + * Non-winners detect the live lease and skip cleanup immediately. The lease + * uses atomic exclusive file creation (`O_EXCL` via temp+link), a random owner + * token, PID, hostname, heartbeat, and owner-checked release. + * + * Hardened staleness (Item 5): + * - Normal staleness is determined by **heartbeatAt** (not createdAt): a lease + * whose heartbeat is older than `STALE_LEASE_AGE_MS` and whose PID is gone + * is stale. + * - A separate absolute PID-reuse bound (`PID_REUSE_BOUND_MS`) based on + * `createdAt` ensures a recycled PID cannot hold the lease indefinitely. + * - All state transitions (takeover, heartbeat, release) verify the on-disk + * owner token before modifying, preventing overwrite/deletion of a + * replacement owner's lease. + * - In-flight heartbeats are awaited before release. + * - File descriptors are closed in `finally` blocks. + * - Malformed leases that are older than the age bound are recovered (removed + * and retried); recent malformed leases cause a conservative skip. + * + * This is an internal implementation detail — no public abstraction or IPC + * service. + */ + +import * as fsp from 'node:fs/promises'; +import * as path from 'node:path'; +import * as crypto from 'node:crypto'; +import { hostname } from 'node:os'; + +/** Name of the lease file inside the global temp directory. */ +const LEASE_FILE_NAME = '.llxprt-janitor.lease'; + +/** Normal staleness threshold: heartbeat older than this (with dead PID) is stale. */ +const STALE_LEASE_AGE_MS = 10 * 60 * 1000; // 10 minutes + +/** Absolute PID-reuse bound: a lease older than this is stale regardless of PID. */ +const PID_REUSE_BOUND_MS = 2 * 60 * 60 * 1000; // 2 hours + +/** Heartbeat interval (ms). */ +const HEARTBEAT_INTERVAL_MS = 30 * 1000; // 30 seconds + +/** Suffix for temporary lease files. */ +const LEASE_TEMP_SUFFIX = '.lease.tmp'; + +/** Suffix for the well-known per-lease transition claim file (OCR 18/19). */ +const LEASE_CLAIM_SUFFIX = '.tclaim'; + +/** On-disk lease record. */ +interface LeaseRecord { + readonly ownerToken: string; + readonly pid: number; + readonly hostname: string; + readonly createdAt: string; + readonly heartbeatAt: string; +} + +/** Handle returned when a lease is acquired. */ +export interface JanitorLeaseHandle { + /** Release the lease. Only removes the file if we still own it. */ + release(): Promise; +} + +/** + * Result of acquiring the per-lease transition claim. + * + * `canProceed` indicates whether the caller may run its guarded operation. + * `ownsClaim` indicates whether the caller created a real on-disk claim that + * it MUST release. When the lease vanishes (ENOENT) the caller may proceed + * (nothing to serialize against) but owns no claim, so it must NOT unlink the + * claim path — doing so could remove a contender's subsequently-created claim. + */ +interface TransitionClaimResult { + readonly canProceed: boolean; + readonly ownsClaim: boolean; +} + +/** + * The single global janitor lease manager. + */ +export class JanitorLease { + private static heartbeatTimer: ReturnType | undefined; + private static inFlightHeartbeat: Promise | undefined; + + /** + * Test-only hook invoked in `tryStaleTakeover` after the staleness + * pre-check passes but before the transition claim is acquired. Lets + * tests deterministically simulate a lease vanishing between the + * pre-check and the claim (ENOENT race). Mirrors the test-seam pattern in + * `sessionJanitor.ts`. + */ + private static preClaimHook: (() => Promise) | null | undefined; + + /** Install or clear the pre-claim test hook. */ + static setPreClaimHookForTest(fn: (() => Promise) | null): void { + JanitorLease.preClaimHook = fn; + } + + /** + * Attempt to acquire the global janitor lease. + */ + static async tryAcquire( + globalTempDir: string, + ): Promise { + const leasePath = path.join(globalTempDir, LEASE_FILE_NAME); + + try { + await fsp.mkdir(globalTempDir, { recursive: true }); + } catch { + return null; + } + + const ownerToken = crypto.randomUUID(); + const now = new Date().toISOString(); + const record: LeaseRecord = { + ownerToken, + pid: process.pid, + hostname: hostname(), + createdAt: now, + heartbeatAt: now, + }; + + // Attempt atomic exclusive creation via temp file + hard link. + if (await JanitorLease.tryCreateLease(leasePath, record)) { + JanitorLease.startHeartbeat(leasePath, ownerToken); + return JanitorLease.makeHandle(leasePath, ownerToken); + } + + // A lease file exists — check if it's stale and try to take over. + return JanitorLease.tryStaleTakeover(leasePath, record); + } + + /** + * Atomically publish a lease via temp file + hard link. + * + * Returns `true` on success, `false` **only** for `EEXIST` (the lease + * already exists). Any other error (ENOSPC, EACCES, EROFS, EDQUOT, …) is + * rethrown so the caller does not mistake a transient I/O failure for + * "lease busy" and proceed to stale-takeover. + */ + private static async tryCreateLease( + leasePath: string, + record: LeaseRecord, + ): Promise { + const tempPath = leasePath + '.' + crypto.randomUUID() + LEASE_TEMP_SUFFIX; + + let fd: fsp.FileHandle | undefined; + try { + fd = await fsp.open(tempPath, 'wx'); + await fd.writeFile(JSON.stringify(record), 'utf-8'); + await fd.sync(); + } catch (error: unknown) { + // Close the descriptor before unlinking: Windows refuses to remove a + // file whose handle is still open, so cleanup must close first. If + // close itself fails, surface both the original I/O error and the + // close failure (AggregateError) rather than silently claiming a safe + // close, and skip unlink since the handle may still be open. + if (fd) { + try { + await fd.close(); + } catch (closeError: unknown) { + fd = undefined; + throw new AggregateError( + [error, closeError], + 'tryCreateLease: descriptor close failed during cleanup', + ); + } + fd = undefined; + } + await safeUnlink(tempPath); + // EEXIST (uuid collision) is the only benign retryable case. + if ((error as NodeJS.ErrnoException).code === 'EEXIST') return false; + throw error; // Propagate genuine I/O failures. + } finally { + await fd?.close().catch(() => {}); + } + + try { + await fsp.link(tempPath, leasePath); + return true; + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + return false; + } finally { + // The hard link means leasePath shares the inode; unlinking temp + // just decrements the link count. + await safeUnlink(tempPath); + } + } + + /** + * Attempt conservative stale takeover using heartbeat-based staleness, + * protected by the hard-link inode-claim protocol (OCR 18/19). + * + * The well-known transition claim (`.tclaim`) is a hard link to + * the current lease inode. Only one contender can hold it at a time + * (atomic `link`). After acquiring it, the contender re-verifies + * staleness and inode identity through the claim before unlinking the + * lease pathname — so a stale contender can never unlink a replacement + * live lease. + * + * A lease is stale when: + * - Its heartbeatAt is older than STALE_LEASE_AGE_MS AND its PID is dead + * (or on a different host), OR + * - Its createdAt exceeds the absolute PID_REUSE_BOUND_MS. + */ + private static async tryStaleTakeover( + leasePath: string, + newRecord: LeaseRecord, + ): Promise { + // Pre-check staleness WITHOUT the claim (avoids holding it during slow + // PID checks). Read through leasePath. + let preContent: string | null; + try { + preContent = await fsp.readFile(leasePath, 'utf-8'); + } catch { + preContent = null; + } + if ((await checkLeaseStaleness(preContent, leasePath)) !== 'stale') { + return null; + } + + // Test seam for deterministic race injection between pre-check and claim. + if (JanitorLease.preClaimHook) { + await JanitorLease.preClaimHook(); + } + + // Acquire the transition claim to serialize the mutation. + const claim = await JanitorLease.acquireTransitionClaim(leasePath); + if (!claim.canProceed) { + return null; // Another transition is in progress — busy. + } + + try { + // Re-check staleness through the claim (the pinned inode's content). + // A fresh heartbeat may have won the race since the pre-check. + const claimPath = JanitorLease.getClaimPath(leasePath); + let claimContent: string | null; + try { + claimContent = await fsp.readFile(claimPath, 'utf-8'); + } catch { + // Can't read claim — the lease may have vanished. Try to create + // fresh directly. + if (await JanitorLease.tryCreateLease(leasePath, newRecord)) { + JanitorLease.startHeartbeat(leasePath, newRecord.ownerToken); + return JanitorLease.makeHandle(leasePath, newRecord.ownerToken); + } + return null; + } + + if ((await checkLeaseStaleness(claimContent, claimPath)) !== 'stale') { + return null; // Became fresh — not stale anymore. + } + + // Verify the lease inode still matches our claim. If the lease was + // replaced by another process, the inodes differ and we must skip. + if (!(await JanitorLease.verifyTransitionClaim(leasePath))) { + return null; + } + + // Unlink the stale lease (same inode as claim — safe). + try { + await fsp.unlink(leasePath); + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') return null; + } + } finally { + // Release only the claim we actually own, so a vanished-lease (ENOENT) + // path never unlinks a contender's subsequently-created claim. + if (claim.ownsClaim) { + await JanitorLease.releaseTransitionClaim(leasePath); + } + } + + // Create fresh lease (outside the claim — racing acquisition may win). + if (await JanitorLease.tryCreateLease(leasePath, newRecord)) { + JanitorLease.startHeartbeat(leasePath, newRecord.ownerToken); + return JanitorLease.makeHandle(leasePath, newRecord.ownerToken); + } + return null; + } + + /** + * Start a periodic heartbeat that updates the `heartbeatAt` field. + * The heartbeat verifies ownership before writing to prevent overwriting + * a replacement owner's lease. + */ + private static startHeartbeat(leasePath: string, ownerToken: string): void { + JanitorLease.stopHeartbeat(); + JanitorLease.heartbeatTimer = setInterval(() => { + JanitorLease.inFlightHeartbeat = JanitorLease.updateHeartbeat( + leasePath, + ownerToken, + ).catch(() => { + // Best-effort heartbeat. + }); + }, HEARTBEAT_INTERVAL_MS); + JanitorLease.heartbeatTimer.unref(); + } + + private static stopHeartbeat(): void { + if (JanitorLease.heartbeatTimer) { + clearInterval(JanitorLease.heartbeatTimer); + JanitorLease.heartbeatTimer = undefined; + } + } + + /** + * Update the heartbeat field, but only if we still own the lease. + * + * Participates in the transition claim protocol (OCR 18/19) so a fresh + * heartbeat cannot race a stale-takeover decision. After acquiring the + * claim, the heartbeat verifies ownership and inode identity through the + * claim, then writes the updated record in place. If a takeover + * unlinks/replaces the pathname concurrently, the claim verification + * catches the inode mismatch and the heartbeat is skipped. + */ + private static async updateHeartbeat( + leasePath: string, + ownerToken: string, + ): Promise { + const claim = await JanitorLease.acquireTransitionClaim(leasePath); + if (!claim.canProceed) { + return; // Another transition in progress — skip heartbeat. + } + try { + const claimPath = JanitorLease.getClaimPath(leasePath); + let content: string; + try { + content = await fsp.readFile(claimPath, 'utf-8'); + } catch { + return; // Can't read — skip. + } + let record: LeaseRecord; + try { + record = JSON.parse(content) as LeaseRecord; + } catch { + return; // Malformed — skip. + } + if (record.ownerToken !== ownerToken) return; // Not ours. + + // Verify the lease inode still matches our claim. + if (!(await JanitorLease.verifyTransitionClaim(leasePath))) { + return; // Lease was replaced — skip. + } + + // Write the updated heartbeat in place through leasePath. Since we + // hold the claim, no takeover can race this write. + const updated: LeaseRecord = { + ...record, + heartbeatAt: new Date().toISOString(), + }; + let fd: fsp.FileHandle | undefined; + try { + fd = await fsp.open(leasePath, 'r+'); + await fd.truncate(0); + await fd.write(JSON.stringify(updated), 0, 'utf-8'); + await fd.sync(); + } catch { + // Best-effort heartbeat. + } finally { + await fd?.close().catch(() => {}); + } + } finally { + if (claim.ownsClaim) { + await JanitorLease.releaseTransitionClaim(leasePath); + } + } + } + + /** + * Release the lease, removing the file only when the on-disk owner token + * still matches ours, and protected by the transition claim protocol so a + * concurrent takeover cannot have its replacement unlinked (OCR 18/19). + * Awaits any in-flight heartbeat before releasing. + */ + private static async releaseLease( + leasePath: string, + ownerToken: string, + ): Promise { + JanitorLease.stopHeartbeat(); + + // Await any in-flight heartbeat before checking ownership. + if (JanitorLease.inFlightHeartbeat) { + await JanitorLease.inFlightHeartbeat.catch(() => {}); + JanitorLease.inFlightHeartbeat = undefined; + } + + const claim = await JanitorLease.acquireTransitionClaim(leasePath); + if (!claim.canProceed) { + return; // Can't acquire claim — best-effort, leave lease in place. + } + try { + // Verify ownership through the claim (the pinned inode's content). + const claimPath = JanitorLease.getClaimPath(leasePath); + let content: string; + try { + content = await fsp.readFile(claimPath, 'utf-8'); + } catch { + return; // Can't read — best-effort. + } + let record: LeaseRecord; + try { + record = JSON.parse(content) as LeaseRecord; + } catch { + return; // Malformed — best-effort. + } + if (record.ownerToken !== ownerToken) return; // Not ours anymore. + + // Verify the lease inode still matches our claim. + if (!(await JanitorLease.verifyTransitionClaim(leasePath))) { + return; // Lease was replaced — don't unlink the replacement. + } + + await fsp.unlink(leasePath); + } catch { + // Best-effort release. + } finally { + if (claim.ownsClaim) { + await JanitorLease.releaseTransitionClaim(leasePath); + } + } + } + + // ----------------------------------------------------------------------- + // Per-lease transition claim (hard-link inode-claim protocol, OCR 18/19) + // ----------------------------------------------------------------------- + + /** Return the well-known transition claim path for a given lease path. */ + private static getClaimPath(leasePath: string): string { + return leasePath + LEASE_CLAIM_SUFFIX; + } + + /** + * Acquire the per-lease transition claim by atomically hard-linking the + * current lease inode to the well-known claim path. + * + * - `link(leasePath, claimPath)` succeeds for exactly one contender. + * - ENOENT means the lease does not exist (no inode to claim — proceed + * without owning a claim). + * - EEXIST means another contender owns the claim — try conservative reclaim. + * - A crashed claim is a hard link, so removing it only decrements a link + * count and cannot remove or replace the live lease. + */ + private static async acquireTransitionClaim( + leasePath: string, + ): Promise { + const claimPath = JanitorLease.getClaimPath(leasePath); + try { + await fsp.link(leasePath, claimPath); + return { canProceed: true, ownsClaim: true }; // Claimed the lease inode. + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException).code; + // No lease exists — nothing to serialize against. Proceed without a + // claim so the caller can try to create a fresh lease, but DO NOT + // release a claim we never created. + if (code === 'ENOENT') return { canProceed: true, ownsClaim: false }; + if (code !== 'EEXIST') return { canProceed: false, ownsClaim: false }; + } + return JanitorLease.tryReclaimClaim(leasePath); + } + + /** + * Conservatively reclaim a stale transition claim. + * + * The claim is a hard link to a lease inode, so its content IS the lease + * content. When the lease content indicates staleness, the claim owner has + * crashed and the claim is safe to remove — it only decrements a link + * count. A live claim (fresh heartbeat or alive PID within bound) is + * NEVER removed. + */ + private static async tryReclaimClaim( + leasePath: string, + ): Promise { + const claimPath = JanitorLease.getClaimPath(leasePath); + + let claimContent: string | null; + try { + claimContent = await fsp.readFile(claimPath, 'utf-8'); + } catch { + return { canProceed: false, ownsClaim: false }; // Can't read — can't determine staleness. + } + + if ((await checkLeaseStaleness(claimContent, claimPath)) !== 'stale') { + return { canProceed: false, ownsClaim: false }; // Live claim — never remove. + } + + try { + await fsp.unlink(claimPath); + } catch { + return { canProceed: false, ownsClaim: false }; + } + + // Retry the claim. The lease inode may have changed during recovery. + try { + await fsp.link(leasePath, claimPath); + return { canProceed: true, ownsClaim: true }; + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return { canProceed: true, ownsClaim: false }; // Lease vanished. + return { canProceed: false, ownsClaim: false }; + } + } + + /** + * Verify that the transition claim and the lease path still identify the + * same inode. Every mutator must call this before unlinking leasePath. + */ + private static async verifyTransitionClaim( + leasePath: string, + ): Promise { + const claimPath = JanitorLease.getClaimPath(leasePath); + try { + const leaseStat = await fsp.stat(leasePath); + const claimStat = await fsp.stat(claimPath); + return leaseStat.dev === claimStat.dev && leaseStat.ino === claimStat.ino; + } catch { + return false; + } + } + + /** Release the transition claim (best-effort). */ + private static async releaseTransitionClaim( + leasePath: string, + ): Promise { + await safeUnlink(JanitorLease.getClaimPath(leasePath)); + } + + /** Create a release handle bound to the owner token. */ + private static makeHandle( + leasePath: string, + ownerToken: string, + ): JanitorLeaseHandle { + return { + release: async (): Promise => { + await JanitorLease.releaseLease(leasePath, ownerToken); + }, + }; + } +} + +/** Check whether a PID is alive by sending signal 0. */ +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM') return true; + return false; + } +} + +/** + * Determine whether a lease is stale based on its content and file mtime + * (OCR 18/19 — shared by stale-takeover pre-check, claim re-check, and claim + * reclaim). + * + * Staleness rules: + * - Fresh heartbeat (< STALE_LEASE_AGE_MS) → not stale. + * - Stale heartbeat + dead PID (on same host, within PID-reuse bound) → stale. + * - Stale heartbeat + createdAt > PID_REUSE_BOUND_MS → stale (absolute bound). + * - Malformed/unreadable content → stale only if file mtime > STALE_LEASE_AGE_MS + * (conservative recovery of crashed transitions; OCR 20 rejected — recent + * malformed leases are intentionally retained for 10 minutes). + */ +async function checkLeaseStaleness( + content: string | null, + filePathForMtime: string, +): Promise<'stale' | 'not-stale'> { + if (content !== null) { + try { + const record = JSON.parse(content) as LeaseRecord; + const heartbeatAge = Date.now() - new Date(record.heartbeatAt).getTime(); + const createdAge = Date.now() - new Date(record.createdAt).getTime(); + const heartbeatFresh = heartbeatAge < STALE_LEASE_AGE_MS; + + // A lease with a fresh heartbeat is NOT stale (Item 5). + if (heartbeatFresh) return 'not-stale'; + + // Heartbeat is stale — check PID and absolute age bound. + const exceedsPidReuseBound = createdAge > PID_REUSE_BOUND_MS; + if ( + !exceedsPidReuseBound && + record.hostname === hostname() && + isPidAlive(record.pid) + ) { + return 'not-stale'; + } + return 'stale'; + } catch { + // Malformed JSON — fall through to mtime-based recovery. + } + } + + // Unreadable or malformed — recover only if old enough by mtime. + return (await isFileOlderThan(filePathForMtime, STALE_LEASE_AGE_MS)) + ? 'stale' + : 'not-stale'; +} + +/** Check if a file's mtime is older than the given threshold. */ +async function isFileOlderThan( + filePath: string, + maxAgeMs: number, +): Promise { + try { + const stat = await fsp.stat(filePath); + return Date.now() - stat.mtimeMs > maxAgeMs; + } catch { + return false; + } +} + +/** Best-effort unlink that swallows errors. */ +async function safeUnlink(filePath: string): Promise { + try { + await fsp.unlink(filePath); + } catch { + // Best-effort. + } +} diff --git a/packages/core/src/recording/janitor/reclamationEngine.ts b/packages/core/src/recording/janitor/reclamationEngine.ts new file mode 100644 index 0000000000..001e876fa1 --- /dev/null +++ b/packages/core/src/recording/janitor/reclamationEngine.ts @@ -0,0 +1,682 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Reclamation engine for the session-recording janitor. + * + * Implements the four remaining retention semantics (Items 1–4): + * + * 1. **Actual-byte size reclamation**: removes fixed compression-savings + * estimates. Compresses eligible raws oldest-first, measuring the real + * post-compression bytes after each operation, and continues through all + * eligible raws (including after skips/failures) until the actual + * aggregate is within budget or no raw remains archivable. Only then are + * cold archives evicted. + * + * 2. **Global explicit age/count semantics**: maxAge/maxCount bound the + * complete raw+archive session corpus via {@link SessionGroup} + * deduplication (no double-counting). Protected sessions that breach an + * explicit limit are counted as shortfall. Explicit-policy deletion of a + * raw is lock-owned and post-lock revalidated. + * + * 3. **Archive chronology/floor/order**: uses original-source mtime preserved + * on gzip, enforces minRetention for archives before any eviction, and + * applies deterministic tie-breakers via project hash + normalized + * path/filename. + * + * 4. **Failure isolation/diagnostics**: sequential per-candidate processing + * continues after failures and increments contextual counters truthfully. + * Source unlink failure after successful archive is reported and the + * duplicate state is preserved for the next sweep to reconcile. + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import type { + ResolvedRetentionConfig, + SessionCandidate, +} from './cleanupTypes.js'; +import type { SessionGroup } from './sessionGrouping.js'; +import { + buildSessionGroups, + evaluateGroupEligibility, + compareGroupsOldestFirst, + compareGroupsNewestFirst, +} from './sessionGrouping.js'; +import { compressToArchive } from './archiveCompressor.js'; +import { SessionLockManager, type LockHandle } from '../SessionLockManager.js'; +import { readSessionJsonlHeader } from './sessionHeaderReader.js'; +import { isRegularNonSymlinkFile, isPathContainedIn } from './sessionSafety.js'; +import { ARCHIVE_DIR_NAME, scanGlobalSessions } from './sessionScanner.js'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** Metrics accumulated during the reclamation phases. */ +export interface ReclamationMetrics { + readonly archived: number; + readonly rawDeleted: number; + readonly archiveDeleted: number; + readonly failed: number; + readonly skipped: number; + readonly ageCountShortfall: number; +} + +// --------------------------------------------------------------------------- +// Narrow fault seam for testing platform-only unlink failures (Item 4) +// --------------------------------------------------------------------------- + +/** + * Inject an alternative unlink implementation for testing. When non-null, + * {@link platformUnlink} delegates to it instead of the real `fs.unlink`. + * Tests use this to exercise the post-archive source-unlink failure path + * without mock theatre — the fault is a narrow, platform-only seam. + */ +let unlinkFaultFn: ((filePath: string) => Promise) | null = null; + +/** Install or clear the unlink fault for tests. */ +export function setUnlinkFaultForTest( + fn: ((filePath: string) => Promise) | null, +): void { + unlinkFaultFn = fn; +} + +/** Perform an unlink, delegating to the fault injector when set. */ +async function platformUnlink(filePath: string): Promise { + if (unlinkFaultFn !== null) { + await unlinkFaultFn(filePath); + return; + } + await fs.unlink(filePath); +} + +// --------------------------------------------------------------------------- +// Internal state carried across phases +// --------------------------------------------------------------------------- + +interface ReclamationState { + totalBytes: number; + archived: number; + rawDeleted: number; + archiveDeleted: number; + failed: number; + ageCountShortfall: number; +} + +function freshState(totalBytes: number): ReclamationState { + return { + totalBytes, + archived: 0, + rawDeleted: 0, + archiveDeleted: 0, + failed: 0, + ageCountShortfall: 0, + }; +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +/** + * Run the full reclamation pipeline against the scanned candidates. + * + * Phase 1 applies explicit age/count limits (direct deletion under lock). + * Phase 2 performs actual-byte size reclamation (compress raws first, then + * evict archives). + * + * @returns Aggregated metrics including shortfall. + */ +export async function runReclamation( + candidates: readonly SessionCandidate[], + config: ResolvedRetentionConfig, + bytesBefore: number, + globalTempDir: string, + currentSessionId: string | undefined, +): Promise<{ metrics: ReclamationMetrics; protectedGroupCount: number }> { + const groups = buildSessionGroups(candidates); + + const eligibleGroups: SessionGroup[] = []; + let protectedGroupCount = 0; + for (const group of groups) { + const eligibility = await evaluateGroupEligibility(group, config); + if (eligibility === 'eligible') { + eligibleGroups.push(group); + } else { + protectedGroupCount++; + } + } + + const state = freshState(bytesBefore); + + await processExplicitAgeCount(eligibleGroups, groups, config, state); + + await processSizeReclamation( + eligibleGroups, + config, + state, + globalTempDir, + currentSessionId, + ); + + return { + metrics: { + archived: state.archived, + rawDeleted: state.rawDeleted, + archiveDeleted: state.archiveDeleted, + failed: state.failed, + skipped: protectedGroupCount, + ageCountShortfall: state.ageCountShortfall, + }, + protectedGroupCount, + }; +} + +// --------------------------------------------------------------------------- +// Phase 1: Explicit age/count removal +// --------------------------------------------------------------------------- + +/** + * Identify and remove groups that breach an explicit maxAge or maxCount limit + * (Item 2). + * + * The ranking is built over ALL groups (eligible + protected) so protected + * sessions count toward the limit. Eligible excess groups are removed via + * lock-owned direct deletion. Protected excess groups increment the + * shortfall counter. + * + * Avoids double-counting by operating on groups, not individual files + * (Item 2). + */ +async function processExplicitAgeCount( + eligibleGroups: readonly SessionGroup[], + allGroups: readonly SessionGroup[], + config: ResolvedRetentionConfig, + state: ReclamationState, +): Promise { + const excessSet = identifyExcessGroups(allGroups, config); + if (excessSet.size === 0) return; + + for (const group of eligibleGroups) { + if (!excessSet.has(group.sessionKey)) continue; + + if (group.raw !== null) { + const outcome = await directDeleteRawUnderLock(group); + if (outcome.deleted) { + state.rawDeleted++; + state.totalBytes -= group.raw.sizeBytes; + } + if (outcome.failed) state.failed++; + } + + if (group.archive !== null) { + const outcome = await safeDeleteArchive(group.archive); + if (outcome.kind === 'deleted' || outcome.kind === 'already-absent') { + state.archiveDeleted++; + state.totalBytes -= group.archive.sizeBytes; + } + if (outcome.kind === 'failed') state.failed++; + } + } + + // Protected excess groups → shortfall (O(N+M) via Set lookup). + const eligibleKeys = new Set(eligibleGroups.map((e) => e.sessionKey)); + for (const group of allGroups) { + if (!excessSet.has(group.sessionKey)) continue; + if (!eligibleKeys.has(group.sessionKey)) { + state.ageCountShortfall++; + } + } +} + +/** + * Compute the set of session keys that breach an explicit maxAge or maxCount + * limit over the complete raw+archive corpus (Item 2). + * + * maxAge: groups whose original mtime is older than the cutoff. + * maxCount: groups ranked beyond the keep-count (newest-first). + * + * Both use the global ranking over ALL groups so protected sessions count + * toward the limit. + */ +function identifyExcessGroups( + allGroups: readonly SessionGroup[], + config: ResolvedRetentionConfig, +): Set { + const excess = new Set(); + + if (config.maxAgeMs !== null) { + const cutoff = Date.now() - config.maxAgeMs; + for (const group of allGroups) { + if (group.mtime.getTime() < cutoff) { + excess.add(group.sessionKey); + } + } + } + + if (config.maxCount !== null) { + const ranked = [...allGroups].sort(compareGroupsNewestFirst); + for (let i = config.maxCount; i < ranked.length; i++) { + excess.add(ranked[i].sessionKey); + } + } + + return excess; +} + +/** + * Directly delete a raw recording under exclusive lock ownership and + * post-lock revalidation (Item 2: "Explicit-policy deletion of a raw must + * still be lock-owned and post-lock revalidated"). + */ +async function directDeleteRawUnderLock( + group: SessionGroup, +): Promise<{ deleted: boolean; failed: boolean }> { + if (group.raw === null || group.sessionId === null) { + return { deleted: false, failed: false }; + } + + const lock = await acquireLock(group); + if (lock === null) return { deleted: false, failed: false }; + + try { + return await doDirectDelete(group, lock); + } finally { + await lock.release(); + } +} + +/** Revalidate and directly unlink the raw after lock acquisition. */ +async function doDirectDelete( + group: SessionGroup, + lock: LockHandle, +): Promise<{ deleted: boolean; failed: boolean }> { + const candidate = group.raw; + if (candidate === null) return { deleted: false, failed: false }; + + if (!(await revalidateRawCandidate(candidate, group.sessionId))) { + return { deleted: false, failed: false }; + } + + if (!(await lock.ownsLock())) { + return { deleted: false, failed: false }; + } + + try { + await platformUnlink(candidate.filePath); + return { deleted: true, failed: false }; + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return { deleted: true, failed: false }; + return { deleted: false, failed: true }; + } +} + +// --------------------------------------------------------------------------- +// Phase 2: Actual-byte size reclamation +// --------------------------------------------------------------------------- + +/** + * Reclaim space to meet the configured byte budget using ACTUAL + * post-compression bytes — no fixed estimates (Item 1). + * + * The raw-compression loop processes eligible groups oldest-first. After + * each compression the real archive size is measured and the running total is + * updated. The loop continues through ALL eligible raws (including after + * skips/failures) until the budget is met or no eligible raw remains + * archivable. Only then are cold archives evicted oldest-first. + * + * Archive eviction re-scans the filesystem so that archives created during + * this sweep's compression phase are correctly considered for eviction + * (Item 1: "prove no archive is evicted while another useful eligible raw + * can be compressed"). + */ +async function processSizeReclamation( + eligibleGroups: readonly SessionGroup[], + config: ResolvedRetentionConfig, + state: ReclamationState, + globalTempDir: string, + currentSessionId: string | undefined, +): Promise { + const sorted = [...eligibleGroups].sort(compareGroupsOldestFirst); + + for (const group of sorted) { + if (state.totalBytes <= config.maxTotalSizeBytes) break; + if (group.raw !== null) { + const outcome = await archiveAndDeleteRaw(group); + if (outcome.archived) state.archived++; + if (outcome.rawDeleted) { + state.rawDeleted++; + state.totalBytes -= group.raw.sizeBytes; + } + // Only add archive bytes to the running total when the archive is + // freshly created. When the archive pre-existed (group.archive !== + // null) its bytes were already counted in the initial scan, so adding + // them again would double-count. + if (outcome.archiveBytes > 0 && group.archive === null) { + state.totalBytes += outcome.archiveBytes; + } + if (outcome.failed) state.failed++; + } + } + + await evictArchivesForBudget(config, state, globalTempDir, currentSessionId); +} + +interface ArchiveOutcome { + archived: boolean; + rawDeleted: boolean; + archiveBytes: number; + failed: boolean; +} + +/** + * Compress a raw session to a gzip archive and then unlink the source, all + * under exclusive session-lock ownership (AC-7, Item 4). + * + * Returns the ACTUAL archive byte size so the caller can update its running + * total with real post-compression bytes (Item 1). When the source unlink + * fails after a successful archive, the outcome reports `archived: true` but + * `rawDeleted: false` and `failed: true`; the duplicate (raw + archive) is + * preserved for the next sweep to reconcile (Item 4). + */ +async function archiveAndDeleteRaw( + group: SessionGroup, +): Promise { + if (group.raw === null || group.sessionId === null) { + return { + archived: false, + rawDeleted: false, + archiveBytes: 0, + failed: false, + }; + } + + const lock = await acquireLock(group); + if (lock === null) { + return { + archived: false, + rawDeleted: false, + archiveBytes: 0, + failed: false, + }; + } + + try { + return await doArchiveAndDelete(group, lock); + } finally { + await lock.release(); + } +} + +/** Acquire exclusive ownership for the group's raw, or null when busy. */ +async function acquireLock(group: SessionGroup): Promise { + if (group.raw === null || group.sessionId === null) return null; + try { + return await SessionLockManager.acquire(group.chatsDir, group.sessionId); + } catch { + return null; + } +} + +/** + * Revalidate the raw candidate immediately before archiving (Item 4 / root + * safety fix 3). + * + * Confirms the candidate is still a contained regular non-symlink file, that + * the scan-time dev/ino identity still matches (ruling out a replacement + * between scan and mutation), that its canonical header still matches the + * expected session ID, and that a fresh lstat rules out a symlink swap. All + * of these checks share the same skip outcome, so they are grouped here for + * clarity. + * + * @returns `true` only when the candidate remains safe to archive. + */ +async function revalidateRawCandidate( + candidate: SessionCandidate, + expectedSessionId: string | null, +): Promise { + if (!(await isRegularNonSymlinkFile(candidate.filePath))) return false; + + const header = await readSessionJsonlHeader(candidate.filePath); + if (header === null || header.sessionId !== expectedSessionId) return false; + + try { + const stat = await fs.lstat(candidate.filePath); + if (stat.isSymbolicLink() || !stat.isFile()) return false; + // Mutation-time identity check: compare dev/ino from scan-time to ensure + // the file was not replaced between scan and mutation (root fix 3). + if (stat.dev !== candidate.dev || stat.ino !== candidate.ino) return false; + } catch { + return false; + } + + return true; +} + +/** + * Compress to archive and unlink the source after full revalidation. + * + * Pre-archive identity/safety checks are delegated to + * {@link revalidateRawCandidate} (Item 4). Lock token ownership is + * re-checked immediately before the final source unlink. + */ +async function doArchiveAndDelete( + group: SessionGroup, + lock: LockHandle, +): Promise { + const candidate = group.raw; + if ( + candidate === null || + !(await revalidateRawCandidate(candidate, group.sessionId)) + ) { + return { + archived: false, + rawDeleted: false, + archiveBytes: 0, + failed: false, + }; + } + + const archiveDir = path.join(group.chatsDir, ARCHIVE_DIR_NAME); + const result = await compressToArchive(candidate.filePath, archiveDir); + if (!result.success || !result.archivePath) { + // Protective refusals (source-invalid, existing-archive) are not platform + // failures — data is retained and not counted as failed. Genuine + // platform failures (mkdir, hash, compress, verify, rename) are counted. + const isPlatformFailure = + result.errorKind !== undefined && + result.errorKind !== 'source-invalid' && + result.errorKind !== 'existing-archive'; + return { + archived: false, + rawDeleted: false, + archiveBytes: 0, + failed: isPlatformFailure, + }; + } + + // Item 6: do NOT unlink the source when durability could not be established. + if (!result.durableCommit) { + return { + archived: true, + rawDeleted: false, + archiveBytes: result.archiveBytes, + failed: false, + }; + } + + // Item 4: Verify lock ownership immediately before final source unlink. + if (!(await lock.ownsLock())) { + return { + archived: true, + rawDeleted: false, + archiveBytes: result.archiveBytes, + failed: false, + }; + } + + const unlinkOutcome = await safeUnlinkSource(candidate.filePath); + return { + archived: true, + rawDeleted: unlinkOutcome.deleted, + archiveBytes: result.archiveBytes, + failed: !unlinkOutcome.deleted && !unlinkOutcome.benignSkip, + }; +} + +/** Unlink the source; benign ENOENT counts as deleted. Revalidates identity. */ +async function safeUnlinkSource( + filePath: string, +): Promise<{ deleted: boolean; benignSkip: boolean }> { + if (!(await isRegularNonSymlinkFile(filePath))) { + return { deleted: false, benignSkip: true }; + } + try { + await platformUnlink(filePath); + return { deleted: true, benignSkip: false }; + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return { deleted: true, benignSkip: false }; + // Item 4: source unlink failure after successful archive must be reported. + return { deleted: false, benignSkip: false }; + } +} + +// --------------------------------------------------------------------------- +// Archive eviction (only after all eligible raws are compressed) +// --------------------------------------------------------------------------- + +/** + * Evict cold archives oldest-first until the byte budget is met (Item 1: + * "Only then evict cold archives"). + * + * Archives are only evicted after the raw-compression loop has exhausted all + * eligible raws. The minRetention floor is enforced for each archive using + * its preserved original-source mtime (Item 3). + * + * Re-scans the filesystem so that archives created during this sweep's + * compression phase are correctly considered. + */ +async function evictArchivesForBudget( + config: ResolvedRetentionConfig, + state: ReclamationState, + globalTempDir: string, + currentSessionId: string | undefined, +): Promise { + if (state.totalBytes <= config.maxTotalSizeBytes) return; + + let freshScan; + try { + freshScan = await scanGlobalSessions(globalTempDir, currentSessionId); + } catch { + // External filesystem error during rescan — increment truthful failure + // diagnostic and exit gracefully per failure isolation (Item 4). + state.failed++; + return; + } + // Count per-project scan failures from the rescan (OCR 38/39). + state.failed += freshScan.scanErrorCount; + + // Use the authoritative fresh scan total so any drift from the compression + // phase's running estimate (e.g. reused archives, short-read variance) is + // corrected before eviction decisions. + state.totalBytes = freshScan.candidates.reduce( + (sum, c) => sum + c.sizeBytes, + 0, + ); + + if (state.totalBytes <= config.maxTotalSizeBytes) return; + + const now = Date.now(); + const evictable: SessionCandidate[] = []; + for (const candidate of freshScan.candidates) { + if ( + candidate.kind === 'archive' && + now - candidate.mtime.getTime() >= config.minRetentionMs + ) { + evictable.push(candidate); + } + } + + evictable.sort(compareCandidatesOldestFirst); + + for (const archive of evictable) { + if (state.totalBytes <= config.maxTotalSizeBytes) break; + const outcome = await safeDeleteArchive(archive); + if (outcome.kind === 'deleted' || outcome.kind === 'already-absent') { + state.archiveDeleted++; + state.totalBytes -= archive.sizeBytes; + } + if (outcome.kind === 'failed') state.failed++; + } +} + +/** Deterministic oldest-first comparison with project-hash + path tie-break. */ +function compareCandidatesOldestFirst( + a: SessionCandidate, + b: SessionCandidate, +): number { + const mtimeDiff = a.mtime.getTime() - b.mtime.getTime(); + if (mtimeDiff !== 0) return mtimeDiff; + return path.normalize(a.filePath).localeCompare(path.normalize(b.filePath)); +} + +/** + * Typed outcome of an archive deletion attempt (AC-9, OCR 27/28). + * + * - `deleted`: the archive was unlinked. + * - `already-absent`: the file vanished (ENOENT) — treated as successful + * convergence since the desired end state (no file) is reached. + * - `protected`: a containment or non-symlink identity check failed — the + * file is retained and not counted as a failure (it may be a symlink or + * escape the managed root). + * - `failed`: a platform error (EPERM, EACCES, EBUSY, …) prevented unlink — + * the archive is retained and the failure is counted truthfully. + */ +export type ArchiveDeleteOutcome = + | { readonly kind: 'deleted' } + | { readonly kind: 'already-absent' } + | { readonly kind: 'protected' } + | { readonly kind: 'failed' }; + +/** + * Attempt to unlink a single archive, classifying the outcome (AC-9). + * + * Validates containment and non-symlink identity before unlink. ENOENT is + * benign convergence. Platform errors (EPERM, EACCES, EBUSY) retain the + * archive and are classified as `failed` so callers can increment truthful + * failure diagnostics. + */ +async function safeDeleteArchive( + archive: SessionCandidate, +): Promise { + if (!isPathContainedIn(archive.containerDir, archive.filePath)) { + return { kind: 'protected' }; + } + if (!(await isRegularNonSymlinkFile(archive.filePath))) { + return { kind: 'protected' }; + } + try { + await platformUnlink(archive.filePath); + return { kind: 'deleted' }; + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return { kind: 'already-absent' }; + return { kind: 'failed' }; + } +} diff --git a/packages/core/src/recording/janitor/retentionPolicy.test.ts b/packages/core/src/recording/janitor/retentionPolicy.test.ts new file mode 100644 index 0000000000..6db9fd9d2f --- /dev/null +++ b/packages/core/src/recording/janitor/retentionPolicy.test.ts @@ -0,0 +1,327 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Behavioral tests for retention policy resolution, validation, and period + * parsing (AC-2, AC-3, AC-11). + */ + +import { describe, it, expect } from 'bun:test'; +import { + parseRetentionPeriod, + validateRetentionConfig, + resolveRetentionConfig, + DEFAULT_MAX_TOTAL_SIZE_MB, + DEFAULT_MIN_RETENTION, +} from './retentionPolicy.js'; + +describe('parseRetentionPeriod', () => { + it('parses hours', () => { + expect(parseRetentionPeriod('24h')).toBe(24 * 60 * 60 * 1000); + }); + + it('parses days', () => { + expect(parseRetentionPeriod('7d')).toBe(7 * 24 * 60 * 60 * 1000); + }); + + it('parses weeks', () => { + expect(parseRetentionPeriod('2w')).toBe(14 * 24 * 60 * 60 * 1000); + }); + + it('parses months (30 days)', () => { + expect(parseRetentionPeriod('1m')).toBe(30 * 24 * 60 * 60 * 1000); + }); + + it('throws on invalid format', () => { + expect(() => parseRetentionPeriod('invalid')).toThrow( + /Invalid retention period/, + ); + expect(() => parseRetentionPeriod('30x')).toThrow( + /Invalid retention period/, + ); + expect(() => parseRetentionPeriod('abc')).toThrow( + /Invalid retention period/, + ); + }); + + it('throws on zero value', () => { + expect(() => parseRetentionPeriod('0d')).toThrow(/must be greater than 0/); + }); + + it('throws on missing unit', () => { + expect(() => parseRetentionPeriod('30')).toThrow( + /Invalid retention period/, + ); + }); +}); + +describe('validateRetentionConfig', () => { + it('accepts undefined config', () => { + expect(() => validateRetentionConfig(undefined)).not.toThrow(); + }); + + it('accepts valid maxTotalSizeMB', () => { + expect(() => + validateRetentionConfig({ maxTotalSizeMB: 1024 }), + ).not.toThrow(); + }); + + it('rejects negative maxTotalSizeMB', () => { + expect(() => validateRetentionConfig({ maxTotalSizeMB: -100 })).toThrow( + /Invalid sessionRetention/, + ); + }); + + it('rejects zero maxTotalSizeMB', () => { + expect(() => validateRetentionConfig({ maxTotalSizeMB: 0 })).toThrow( + /Invalid sessionRetention/, + ); + }); + + it('rejects non-finite maxTotalSizeMB', () => { + expect(() => validateRetentionConfig({ maxTotalSizeMB: Infinity })).toThrow( + /Invalid sessionRetention/, + ); + }); + + it('accepts valid maxCount', () => { + expect(() => validateRetentionConfig({ maxCount: 10 })).not.toThrow(); + }); + + it('rejects maxCount less than 1', () => { + expect(() => validateRetentionConfig({ maxCount: 0 })).toThrow( + /Invalid sessionRetention/, + ); + }); + + it('rejects non-finite maxCount', () => { + expect(() => validateRetentionConfig({ maxCount: NaN })).toThrow( + /Invalid sessionRetention/, + ); + }); + + it('accepts valid maxAge', () => { + expect(() => validateRetentionConfig({ maxAge: '30d' })).not.toThrow(); + }); + + it('rejects invalid maxAge format', () => { + expect(() => validateRetentionConfig({ maxAge: 'invalid' })).toThrow( + /Invalid retention period/, + ); + }); + + it('rejects maxAge shorter than minRetention', () => { + expect(() => + validateRetentionConfig({ maxAge: '1h', minRetention: '1d' }), + ).toThrow(/cannot be less than minRetention/); + }); + + it('accepts maxAge equal to minRetention (boundary)', () => { + expect(() => + validateRetentionConfig({ maxAge: '1d', minRetention: '1d' }), + ).not.toThrow(); + }); + + it('rejects maxAge shorter than default minRetention when minRetention not provided', () => { + expect(() => validateRetentionConfig({ maxAge: '1h' })).toThrow( + /cannot be less than minRetention/, + ); + }); + + it('rejects invalid minRetention format', () => { + expect(() => validateRetentionConfig({ minRetention: 'bad' })).toThrow( + /Invalid retention period/, + ); + }); +}); + +describe('resolveRetentionConfig — defaults (AC-2)', () => { + it('is default-on with 4 GiB budget when no config provided', () => { + const resolved = resolveRetentionConfig(undefined); + expect(resolved.enabled).toBe(true); + expect(resolved.maxTotalSizeBytes).toBe(4096 * 1024 * 1024); + }); + + it('has no default maxAge', () => { + const resolved = resolveRetentionConfig(undefined); + expect(resolved.maxAgeMs).toBeNull(); + }); + + it('has no default maxCount', () => { + const resolved = resolveRetentionConfig(undefined); + expect(resolved.maxCount).toBeNull(); + }); + + it('has 1d minRetention floor by default', () => { + const resolved = resolveRetentionConfig(undefined); + expect(resolved.minRetentionMs).toBe(24 * 60 * 60 * 1000); + }); + + it('DEFAULT_MAX_TOTAL_SIZE_MB is 4096', () => { + expect(DEFAULT_MAX_TOTAL_SIZE_MB).toBe(4096); + }); + + it('DEFAULT_MIN_RETENTION is 1d', () => { + expect(DEFAULT_MIN_RETENTION).toBe('1d'); + }); +}); + +describe('resolveRetentionConfig — explicit user settings (AC-3)', () => { + it('respects enabled: false', () => { + const resolved = resolveRetentionConfig({ enabled: false }); + expect(resolved.enabled).toBe(false); + }); + + it('respects explicit maxAge', () => { + const resolved = resolveRetentionConfig({ maxAge: '7d' }); + expect(resolved.maxAgeMs).toBe(7 * 24 * 60 * 60 * 1000); + }); + + it('respects explicit maxCount', () => { + const resolved = resolveRetentionConfig({ maxCount: 5 }); + expect(resolved.maxCount).toBe(5); + }); + + it('respects explicit maxTotalSizeMB', () => { + const resolved = resolveRetentionConfig({ maxTotalSizeMB: 100 }); + expect(resolved.maxTotalSizeBytes).toBe(100 * 1024 * 1024); + }); + + it('respects explicit minRetention', () => { + const resolved = resolveRetentionConfig({ minRetention: '2h' }); + expect(resolved.minRetentionMs).toBe(2 * 60 * 60 * 1000); + }); +}); + +describe('resolveRetentionConfig — partial settings retain defaults (AC-2)', () => { + it('partial config with only maxAge keeps default size budget', () => { + const resolved = resolveRetentionConfig({ maxAge: '30d' }); + expect(resolved.maxTotalSizeBytes).toBe(4096 * 1024 * 1024); + expect(resolved.enabled).toBe(true); + expect(resolved.maxCount).toBeNull(); + expect(resolved.minRetentionMs).toBe(24 * 60 * 60 * 1000); + }); + + it('partial config with only maxCount keeps default size budget', () => { + const resolved = resolveRetentionConfig({ maxCount: 3 }); + expect(resolved.maxTotalSizeBytes).toBe(4096 * 1024 * 1024); + expect(resolved.maxAgeMs).toBeNull(); + }); + + it('partial config with enabled:true and no other fields keeps defaults', () => { + const resolved = resolveRetentionConfig({ enabled: true }); + expect(resolved.enabled).toBe(true); + expect(resolved.maxTotalSizeBytes).toBe(4096 * 1024 * 1024); + expect(resolved.maxAgeMs).toBeNull(); + expect(resolved.maxCount).toBeNull(); + }); + + it('partial config with custom size keeps default minRetention', () => { + const resolved = resolveRetentionConfig({ maxTotalSizeMB: 512 }); + expect(resolved.minRetentionMs).toBe(24 * 60 * 60 * 1000); + }); +}); + +describe('resolveRetentionConfig — invalid config fails fast (AC-11)', () => { + it('throws on invalid maxAge format', () => { + expect(() => resolveRetentionConfig({ maxAge: 'bad' })).toThrow( + /Invalid retention period/, + ); + }); + + it('throws on negative maxTotalSizeMB', () => { + expect(() => resolveRetentionConfig({ maxTotalSizeMB: -1 })).toThrow( + /Invalid sessionRetention/, + ); + }); + + it('throws on maxAge less than minRetention', () => { + expect(() => + resolveRetentionConfig({ maxAge: '12h', minRetention: '1d' }), + ).toThrow(/cannot be less than minRetention/); + }); + + it('throws on maxCount of 0', () => { + expect(() => resolveRetentionConfig({ maxCount: 0 })).toThrow( + /Invalid sessionRetention/, + ); + }); +}); + +describe('validateRetentionConfig — safe-integer and overflow validation (finding D)', () => { + it('rejects fractional maxCount (must be a safe integer)', () => { + expect(() => validateRetentionConfig({ maxCount: 2.5 })).toThrow( + /Invalid sessionRetention/, + ); + }); + + it('rejects maxCount exceeding the safe-integer range', () => { + expect(() => + validateRetentionConfig({ maxCount: Number.MAX_SAFE_INTEGER + 1 }), + ).toThrow(/Invalid sessionRetention/); + }); + + it('accepts maxCount at MAX_SAFE_INTEGER', () => { + expect(() => + validateRetentionConfig({ maxCount: Number.MAX_SAFE_INTEGER }), + ).not.toThrow(); + }); + + it('rejects maxTotalSizeMB whose byte conversion overflows', () => { + expect(() => validateRetentionConfig({ maxTotalSizeMB: 1e15 })).toThrow( + /Invalid sessionRetention/, + ); + }); + + it('accepts fractional maxTotalSizeMB that yields valid bytes', () => { + expect(() => + validateRetentionConfig({ maxTotalSizeMB: 0.5 }), + ).not.toThrow(); + }); +}); + +describe('parseRetentionPeriod — overflow-safe arithmetic (finding D)', () => { + it('rejects period values whose converted arithmetic overflows safe integers', () => { + expect(() => parseRetentionPeriod('99999999999999999d')).toThrow( + /Invalid retention period/, + ); + }); + + it('accepts a large-but-safe period', () => { + // 1000 weeks is ~6e11 ms, well within safe-integer range. + expect(() => parseRetentionPeriod('1000w')).not.toThrow(); + }); +}); + +describe('resolveRetentionConfig — byte overflow surfaces clearly (finding D)', () => { + it('throws on maxTotalSizeMB whose byte conversion overflows', () => { + expect(() => resolveRetentionConfig({ maxTotalSizeMB: 1e15 })).toThrow( + /Invalid sessionRetention/, + ); + }); + + it('resolves fractional maxTotalSizeMB to correct finite bytes', () => { + const resolved = resolveRetentionConfig({ maxTotalSizeMB: 0.5 }); + expect(resolved.maxTotalSizeBytes).toBe(Math.round(0.5 * 1024 * 1024)); + expect(Number.isSafeInteger(resolved.maxTotalSizeBytes)).toBe(true); + }); + + it('resolved maxTotalSizeBytes is always a safe positive integer', () => { + const resolved = resolveRetentionConfig(undefined); + expect(Number.isSafeInteger(resolved.maxTotalSizeBytes)).toBe(true); + expect(resolved.maxTotalSizeBytes).toBeGreaterThan(0); + }); +}); diff --git a/packages/core/src/recording/janitor/retentionPolicy.ts b/packages/core/src/recording/janitor/retentionPolicy.ts new file mode 100644 index 0000000000..984add0d5c --- /dev/null +++ b/packages/core/src/recording/janitor/retentionPolicy.ts @@ -0,0 +1,179 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Retention configuration resolution, validation, and period parsing for the + * session-recording janitor. + * + * Default policy (AC-2): cleanup is enabled with a global 4 GiB aggregate size + * budget, no default age limit, and no default count limit. A minimum + * retention floor of 1 day prevents deleting very recent recordings. + */ + +import type { + ResolvedRetentionConfig, + UserRetentionSettings, +} from './cleanupTypes.js'; + +/** Default global aggregate session budget: 4096 MiB = 4 GiB (AC-2). */ +export const DEFAULT_MAX_TOTAL_SIZE_MB = 4096; + +/** Default minimum retention safety floor (AC-2). */ +export const DEFAULT_MIN_RETENTION = '1d'; + +/** 1 MiB in bytes. */ +const MIB = 1024 * 1024; + +/** Validate that a maxTotalSizeMB value is a positive finite number with a safe byte conversion. */ +function isValidMaxTotalSizeMB(value: number): boolean { + return ( + typeof value === 'number' && + Number.isFinite(value) && + value > 0 && + Number.isSafeInteger(Math.round(value * MIB)) + ); +} + +const PERIOD_MULTIPLIERS: Readonly> = { + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + w: 7 * 24 * 60 * 60 * 1000, + m: 30 * 24 * 60 * 60 * 1000, +}; + +/** + * Parse a human-readable retention period string like `"30d"` or `"24h"` into + * milliseconds. Supported units: h, d, w, m. Throws on malformed input or + * a zero value (zero retention is semantically invalid). + * + * @throws {Error} When `period` is not a valid `` string. + */ +export function parseRetentionPeriod(period: string): number { + const match = period.match(/^(\d+)([hdwm])$/); + if (!match) { + throw new Error( + `Invalid retention period format: ${period}. Expected format: where unit is h, d, w, or m`, + ); + } + const value = Number(match[1]); + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error( + `Invalid retention period: ${period}. Value must be greater than 0`, + ); + } + // Compute in a finite-safe manner so an absurdly large value cannot produce + // a non-integer or overflowing millisecond period (finding D). + const multiplier = PERIOD_MULTIPLIERS[match[2]]; + const result = value * multiplier; + if (!Number.isSafeInteger(result)) { + throw new Error( + `Invalid retention period: ${period}. Converted value overflows the safe integer range`, + ); + } + return result; +} + +/** + * Validate a user-provided retention settings object *before* merging with + * defaults. Invalid values fail fast with a clear error message rather than + * being silently normalized into a different policy (AC-3, AC-11). + * + * @throws {Error} When any supplied field is invalid (e.g. bad period format, + * negative size, maxAge shorter than minRetention). + */ +export function validateRetentionConfig( + userConfig: UserRetentionSettings | undefined, +): void { + if (userConfig === undefined) return; + + if ( + userConfig.maxTotalSizeMB !== undefined && + !isValidMaxTotalSizeMB(userConfig.maxTotalSizeMB) + ) { + throw new Error( + `Invalid sessionRetention.maxTotalSizeMB: must be a positive number whose byte conversion is a finite safe integer, got ${String(userConfig.maxTotalSizeMB)}`, + ); + } + + if ( + userConfig.maxCount !== undefined && + (typeof userConfig.maxCount !== 'number' || + !Number.isSafeInteger(userConfig.maxCount) || + userConfig.maxCount < 1) + ) { + throw new Error( + `Invalid sessionRetention.maxCount: must be a positive safe integer, got ${String(userConfig.maxCount)}`, + ); + } + + if (userConfig.minRetention !== undefined) { + parseRetentionPeriod(userConfig.minRetention); + } + + if (userConfig.maxAge !== undefined) { + const maxAgeMs = parseRetentionPeriod(userConfig.maxAge); + const minRetentionMs = parseRetentionPeriod( + userConfig.minRetention ?? DEFAULT_MIN_RETENTION, + ); + if (maxAgeMs < minRetentionMs) { + throw new Error( + `sessionRetention.maxAge (${userConfig.maxAge}) cannot be less than minRetention (${userConfig.minRetention ?? DEFAULT_MIN_RETENTION})`, + ); + } + } +} + +/** + * Resolve a (possibly partial or absent) user retention settings object into a + * fully concrete {@link ResolvedRetentionConfig}. Defaults are applied at the + * consumer so a partial object cannot accidentally remove default-on size + * bounding (AC-2). + * + * - When `userConfig` is `undefined` → defaults (enabled, 4 GiB, 1d floor). + * - When `userConfig.enabled` is explicitly `false` → disabled. + * - Explicit `maxAge` / `maxCount` are honoured; absence means "no limit". + */ +export function resolveRetentionConfig( + userConfig: UserRetentionSettings | undefined, +): ResolvedRetentionConfig { + validateRetentionConfig(userConfig); + + const enabled = userConfig?.enabled !== false; + const maxTotalSizeMB = + userConfig?.maxTotalSizeMB ?? DEFAULT_MAX_TOTAL_SIZE_MB; + const maxTotalSizeBytes = Math.round(maxTotalSizeMB * MIB); + + // Safety net (finding D): the resolved byte limit must be a finite safe + // positive integer so downstream arithmetic cannot overflow. + if (!Number.isSafeInteger(maxTotalSizeBytes) || maxTotalSizeBytes <= 0) { + throw new Error( + `Invalid sessionRetention.maxTotalSizeMB: must be a positive number whose byte conversion is a finite safe integer, got ${String(maxTotalSizeMB)}`, + ); + } + + const maxAgeMs = + userConfig?.maxAge !== undefined + ? parseRetentionPeriod(userConfig.maxAge) + : null; + + const maxCount = userConfig?.maxCount ?? null; + + const minRetentionMs = parseRetentionPeriod( + userConfig?.minRetention ?? DEFAULT_MIN_RETENTION, + ); + + return { enabled, maxTotalSizeBytes, maxAgeMs, maxCount, minRetentionMs }; +} diff --git a/packages/core/src/recording/janitor/sessionGrouping.ts b/packages/core/src/recording/janitor/sessionGrouping.ts new file mode 100644 index 0000000000..c8d22b06c1 --- /dev/null +++ b/packages/core/src/recording/janitor/sessionGrouping.ts @@ -0,0 +1,241 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Logical session grouping and eligibility for the janitor (findings B, C). + * + * A {@link SessionGroup} deduplicates a transient raw+archive pair that + * represent the same logical session, so the corpus is never double-counted + * for age/count ranking (finding B). Group identity is derived from the + * chats directory and the shared base name (`session-.jsonl`), where an + * archive's base name is its file name without the trailing `.gz`. + * + * The group's recording time is the **original** session age (oldest file), + * which is preserved on the gzip archive by the compressor (finding C). This + * lets `minRetention` apply to archives by original age rather than by the + * moment they were compressed. + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import type { + ResolvedRetentionConfig, + SessionCandidate, +} from './cleanupTypes.js'; +import { SessionLockManager } from '../SessionLockManager.js'; + +/** Archive file-name suffix. */ +const ARCHIVE_SUFFIX = '.jsonl.gz'; + +/** Base JSONL suffix shared by both raw and archive file names. */ +const JSONL_SUFFIX = '.jsonl'; + +/** Compression-suffix length derived from the shared suffix constants. */ +const COMPRESSION_SUFFIX_LEN = ARCHIVE_SUFFIX.length - JSONL_SUFFIX.length; + +/** + * A logical session: at most one raw recording and at most one cold archive + * for the same base name within the same chats directory. + */ +export interface SessionGroup { + /** Stable identity: `|`. */ + readonly sessionKey: string; + readonly chatsDir: string; + /** `session-.jsonl` (archive base name strips the `.gz`). */ + readonly baseName: string; + readonly projectHashDir: string; + readonly raw: SessionCandidate | null; + readonly archive: SessionCandidate | null; + /** Original recording time (oldest member mtime) — drives age ranking. */ + readonly mtime: Date; + /** Session ID from the raw header, or `null` when only an unreadable/raw-less group exists. */ + readonly sessionId: string | null; + /** Aggregate physical bytes of all group files. */ + readonly sizeBytes: number; + readonly isCurrentSession: boolean; +} + +/** Lexically normalize a path for deterministic comparison (finding C). */ +export function normalizedPath(filePath: string): string { + return path.normalize(filePath); +} + +/** The canonical representative file path of a group (for tie-breaking). */ +function representativePath(group: SessionGroup): string { + return group.archive?.filePath ?? group.raw?.filePath ?? ''; +} + +/** + * Deterministic oldest-first comparison. Primary key is the original + * recording mtime; the tie-break is the normalized representative file path, + * which encodes project hash + full path + filename (finding C). + */ +export function compareGroupsOldestFirst( + a: SessionGroup, + b: SessionGroup, +): number { + const mtimeDiff = a.mtime.getTime() - b.mtime.getTime(); + if (mtimeDiff !== 0) return mtimeDiff; + return normalizedPath(representativePath(a)).localeCompare( + normalizedPath(representativePath(b)), + ); +} + +/** Newest-first comparison (mirror of {@link compareGroupsOldestFirst}). */ +export function compareGroupsNewestFirst( + a: SessionGroup, + b: SessionGroup, +): number { + return compareGroupsOldestFirst(b, a); +} + +/** Strip the trailing compression suffix from an archive file name to recover the base name. */ +function baseNameOf(candidate: SessionCandidate): string { + return candidate.kind === 'archive' && + candidate.fileName.endsWith(ARCHIVE_SUFFIX) + ? candidate.fileName.slice(0, -COMPRESSION_SUFFIX_LEN) + : candidate.fileName; +} + +/** Compute the chats directory that owns a candidate. */ +function chatsDirOf(candidate: SessionCandidate): string { + return candidate.kind === 'archive' + ? path.dirname(candidate.containerDir) + : candidate.containerDir; +} + +/** + * Build logical session groups from scanned candidates, deduplicating a + * transient raw+archive pair into a single group (finding B: avoid + * double-counting). + */ +export function buildSessionGroups( + candidates: readonly SessionCandidate[], +): SessionGroup[] { + const byKey = new Map(); + for (const candidate of candidates) { + const chatsDir = chatsDirOf(candidate); + const baseName = baseNameOf(candidate); + const sessionKey = chatsDir + '|' + baseName; + let builder = byKey.get(sessionKey); + if (builder === undefined) { + builder = { + sessionKey, + chatsDir, + baseName, + projectHashDir: candidate.projectHashDir, + raw: null, + archive: null, + }; + byKey.set(sessionKey, builder); + } + if (candidate.kind === 'raw') { + builder.raw = candidate; + } else { + builder.archive = candidate; + } + } + + const groups: SessionGroup[] = []; + for (const builder of byKey.values()) { + const raw = builder.raw; + const archive = builder.archive; + const times: number[] = []; + if (raw) times.push(raw.mtime.getTime()); + if (archive) times.push(archive.mtime.getTime()); + const mtime = new Date(Math.min(...times)); + const sizeBytes = (raw?.sizeBytes ?? 0) + (archive?.sizeBytes ?? 0); + const sessionId = raw?.sessionId ?? null; + const isCurrentSession = raw?.isCurrentSession === true; + groups.push({ + sessionKey: builder.sessionKey, + chatsDir: builder.chatsDir, + baseName: builder.baseName, + projectHashDir: builder.projectHashDir, + raw, + archive, + mtime, + sessionId, + sizeBytes, + isCurrentSession, + }); + } + return groups; +} + +interface SessionGroupBuilder { + readonly sessionKey: string; + readonly chatsDir: string; + readonly baseName: string; + readonly projectHashDir: string; + raw: SessionCandidate | null; + archive: SessionCandidate | null; +} + +type Eligibility = 'eligible' | 'protected'; + +/** + * Evaluate whether a group is eligible for any mutation. Protected groups + * are retained and (when they breach an explicit limit) counted as a + * shortfall (finding B/G). + * + * Protection reasons (AC-7): the current session, a live lock on the raw, + * age below the `minRetention` floor (by original session age), or a raw + * whose session identity cannot be established. + */ +export async function evaluateGroupEligibility( + group: SessionGroup, + config: ResolvedRetentionConfig, +): Promise { + if (group.isCurrentSession) return 'protected'; + + if (group.raw !== null) { + // A raw whose identity cannot be established is unreadable/protected. + if (group.sessionId === null) return 'protected'; + if (await isProtectedByLiveLock(group)) return 'protected'; + } + + // minRetention applies to archives too, by original session age (finding C). + if (Date.now() - group.mtime.getTime() < config.minRetentionMs) { + return 'protected'; + } + return 'eligible'; +} + +/** Return true when the group's raw holds a non-stale session lock (AC-7). */ +async function isProtectedByLiveLock(group: SessionGroup): Promise { + if (group.raw === null || group.sessionId === null) return false; + // getLockPath performs synchronous session-ID validation and can throw. + // Any validation/path error must fail toward retaining data (AC-7). + let lockPath: string; + try { + lockPath = SessionLockManager.getLockPath(group.chatsDir, group.sessionId); + } catch { + return true; + } + try { + await fs.access(lockPath); + } catch { + return false; + } + try { + const isStale = await SessionLockManager.checkStaleWithPidReuse(lockPath); + return !isStale; + } catch { + // Can't determine lock status — fail toward retaining (AC-7). + return true; + } +} diff --git a/packages/core/src/recording/janitor/sessionHeaderReader.test.ts b/packages/core/src/recording/janitor/sessionHeaderReader.test.ts new file mode 100644 index 0000000000..8984c27a22 --- /dev/null +++ b/packages/core/src/recording/janitor/sessionHeaderReader.test.ts @@ -0,0 +1,181 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Behavioral tests for the canonical JSONL session header reader (AC-1). + * + * Creates sessions using the real SessionRecordingService and proves the + * header reader discovers them. Covers BOM-prefixed and long first-line + * (>4096 bytes) behavior. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { readSessionJsonlHeader } from './sessionHeaderReader.js'; +import { SessionRecordingService } from '../SessionRecordingService.js'; +import type { SessionRecordingServiceConfig } from '../types.js'; + +function makeTempDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'janitor-header-')); +} + +function makeConfig(chatsDir: string): SessionRecordingServiceConfig { + return { + sessionId: 'test-session-' + crypto.randomUUID(), + projectHash: crypto.randomUUID().replace(/-/g, '').slice(0, 64), + chatsDir, + workspaceDirs: [chatsDir], + provider: 'test-provider', + model: 'test-model', + }; +} + +describe('readSessionJsonlHeader — real recorder output', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('reads the header from a real SessionRecordingService file', async () => { + const config = makeConfig(tempDir); + const svc = new SessionRecordingService(config); + svc.recordContent({ + speaker: 'human', + blocks: [{ type: 'text', text: 'hello' }], + }); + await svc.dispose(); + + const filePath = svc.getFilePath(); + expect(filePath).not.toBeNull(); + + const header = await readSessionJsonlHeader(filePath!); + expect(header).not.toBeNull(); + expect(header!.sessionId).toBe(config.sessionId); + // The service writes the actual startTime from the session_start event — + // assert it is a real ISO timestamp, not a truthy placeholder. + expect(typeof header!.startTime).toBe('string'); + expect(header!.startTime.length).toBeGreaterThan(0); + // Ensure it parses as a valid date. + expect(new Date(header!.startTime).getTime()).not.toBeNaN(); + }); + + it('returns null for an empty file', async () => { + const filePath = path.join(tempDir, 'session-empty.jsonl'); + await fs.writeFile(filePath, ''); + const header = await readSessionJsonlHeader(filePath); + expect(header).toBeNull(); + }); + + it('returns null for a file with no session_start event', async () => { + const filePath = path.join(tempDir, 'session-noheader.jsonl'); + await fs.writeFile( + filePath, + JSON.stringify({ type: 'content', payload: {} }) + '\n', + ); + const header = await readSessionJsonlHeader(filePath); + expect(header).toBeNull(); + }); + + it('returns null for a non-existent file', async () => { + const header = await readSessionJsonlHeader( + path.join(tempDir, 'nonexistent.jsonl'), + ); + expect(header).toBeNull(); + }); + + it('handles BOM-prefixed JSONL (AC-1)', async () => { + const sessionId = 'bom-test-session-id'; + const startTime = new Date().toISOString(); + const payload = JSON.stringify({ + v: 1, + seq: 0, + ts: startTime, + type: 'session_start', + payload: { sessionId, startTime, projectHash: 'abc123' }, + }); + + const filePath = path.join(tempDir, 'session-bom.jsonl'); + // Write with UTF-8 BOM prefix + await fs.writeFile(filePath, '\uFEFF' + payload + '\n'); + + const header = await readSessionJsonlHeader(filePath); + expect(header).not.toBeNull(); + expect(header!.sessionId).toBe(sessionId); + expect(header!.startTime).toBe(startTime); + }); + + it('handles first header line larger than 4096 bytes (AC-1)', async () => { + const sessionId = 'long-header-session-id'; + const startTime = new Date().toISOString(); + // Create a payload with a very long workspaceDirs entry to exceed 4096 bytes + const longPath = 'x'.repeat(5000); + const payload = JSON.stringify({ + v: 1, + seq: 0, + ts: startTime, + type: 'session_start', + payload: { + sessionId, + startTime, + projectHash: 'abc123', + workspaceDirs: [longPath], + }, + }); + + expect(payload.length).toBeGreaterThan(4096); + + const filePath = path.join(tempDir, 'session-long.jsonl'); + await fs.writeFile(filePath, payload + '\n'); + + const header = await readSessionJsonlHeader(filePath); + expect(header).not.toBeNull(); + expect(header!.sessionId).toBe(sessionId); + expect(header!.startTime).toBe(startTime); + }); + + it('returns null for corrupted/unparseable JSON first line', async () => { + const filePath = path.join(tempDir, 'session-bad.jsonl'); + await fs.writeFile(filePath, 'this is not json\n'); + const header = await readSessionJsonlHeader(filePath); + expect(header).toBeNull(); + }); + + it('returns null for a valid session_start with an unsafe/path-like sessionId', async () => { + const startTime = new Date().toISOString(); + const payload = JSON.stringify({ + v: 1, + seq: 0, + ts: startTime, + type: 'session_start', + payload: { + sessionId: '../../etc/passwd', + startTime, + projectHash: 'abc123', + }, + }); + const filePath = path.join(tempDir, 'session-unsafe.jsonl'); + await fs.writeFile(filePath, payload + '\n'); + const header = await readSessionJsonlHeader(filePath); + expect(header).toBeNull(); + }); +}); diff --git a/packages/core/src/recording/janitor/sessionHeaderReader.ts b/packages/core/src/recording/janitor/sessionHeaderReader.ts new file mode 100644 index 0000000000..2875abc269 --- /dev/null +++ b/packages/core/src/recording/janitor/sessionHeaderReader.ts @@ -0,0 +1,76 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Canonical header reader adapter for the session-recording janitor (AC-1). + * + * Delegates to the single canonical bounded JSONL header reader + * ({@link readFirstLineFromFile} in {@link SessionDiscovery}, which in turn + * falls back to {@link readSessionHeader} in {@link ReplayEngine}) rather than + * maintaining a second drifting copy of the buffer/readline/BOM logic. This + * guarantees the janitor discovers recordings using the exact same header + * handling as session discovery and resume, including UTF-8 BOM stripping and + * first-line headers larger than the initial 4 KiB buffer. + * + * The adapter maps the canonical {@link SessionStartPayload} to the janitor's + * narrower {@link SessionHeaderInfo} view and applies the janitor's stricter + * "sessionId must be present" rule, so unreadable recordings are reported as + * `null` for retention protection (AC-7). + */ + +import { readFirstLineFromFile } from '../SessionDiscovery.js'; +import { isValidSafeSessionId } from './sessionSafety.js'; + +/** Payload fields the janitor extracts from the session_start header. */ +export interface SessionHeaderInfo { + readonly sessionId: string; + readonly startTime: string; + readonly projectHash?: string; +} + +/** + * Read the `session_start` header from a JSONL recording using the canonical + * bounded header reader shared with session discovery/resume. Returns `null` + * for empty files, non-JSON lines, lines that are not `session_start` events, + * or events whose `sessionId` cannot be established safely. + * + * Session IDs are validated against the canonical safe grammar (Item 2): any + * unsafe/path-like identifier makes the recording unreadable/protected so it + * cannot be used to redirect archive/temp writes or escape the chats directory + * via lock path construction. + */ +export async function readSessionJsonlHeader( + filePath: string, +): Promise { + const payload = await readFirstLineFromFile(filePath); + if (payload === null) return null; + if (typeof payload.sessionId !== 'string' || payload.sessionId === '') { + return null; + } + // Validate the session ID against the canonical safe grammar so a + // path-like identifier (e.g. "../../etc/passwd") cannot be used to + // construct a lock path or archive name that escapes the managed root. + if (!isValidSafeSessionId(payload.sessionId)) { + return null; + } + const startTime = + typeof payload.startTime === 'string' && payload.startTime !== '' + ? payload.startTime + : new Date().toISOString(); + const projectHash = + typeof payload.projectHash === 'string' ? payload.projectHash : undefined; + return { sessionId: payload.sessionId, startTime, projectHash }; +} diff --git a/packages/core/src/recording/janitor/sessionJanitor.reclamation.test.ts b/packages/core/src/recording/janitor/sessionJanitor.reclamation.test.ts new file mode 100644 index 0000000000..bd1f0f6ae1 --- /dev/null +++ b/packages/core/src/recording/janitor/sessionJanitor.reclamation.test.ts @@ -0,0 +1,955 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Behavioral tests for the reclamation engine items 1–4. + * + * Uses real temporary filesystems, real SessionRecordingService output, real + * gzip archives, real locks, and a narrow unlink fault seam for Item 4. No + * mocks. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as zlib from 'node:zlib'; +import { runSessionCleanup } from './sessionJanitor.js'; +import { resolveRetentionConfig } from './retentionPolicy.js'; +import { setUnlinkFaultForTest } from './reclamationEngine.js'; +import { SessionRecordingService } from '../SessionRecordingService.js'; +import { SessionLockManager } from '../SessionLockManager.js'; +import type { SessionRecordingServiceConfig } from '../types.js'; +import { ARCHIVE_DIR_NAME } from './sessionScanner.js'; + +async function makeTempDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'janitor-reclaim-')); +} + +function validHash64(): string { + return crypto.randomUUID().replace(/-/g, '').repeat(2).slice(0, 64); +} + +function makeConfig(chatsDir: string): SessionRecordingServiceConfig { + return { + sessionId: 'session-' + crypto.randomUUID(), + projectHash: validHash64(), + chatsDir, + workspaceDirs: [chatsDir], + provider: 'test', + model: 'test', + }; +} + +async function createSession( + chatsDir: string, + opts: { + ageMs?: number; + content?: string; + sessionId?: string; + } = {}, +): Promise<{ filePath: string; sessionId: string }> { + await fs.mkdir(chatsDir, { recursive: true }); + const sessionId = opts.sessionId ?? 'session-' + crypto.randomUUID(); + const config: SessionRecordingServiceConfig = { + ...makeConfig(chatsDir), + sessionId, + }; + const svc = new SessionRecordingService(config); + svc.recordContent({ + speaker: 'human', + blocks: [{ type: 'text', text: opts.content ?? 'test message' }], + }); + await svc.flush(); + await svc.dispose(); + const filePath = svc.getFilePath(); + if (!filePath) throw new Error('No file path'); + + if (opts.ageMs !== undefined) { + const oldTime = new Date(Date.now() - opts.ageMs); + await fs.utimes(filePath, oldTime, oldTime); + } + return { filePath, sessionId }; +} + +async function makeArchive( + archiveDir: string, + fileName: string, + content: string, + ageMs?: number, +): Promise { + await fs.mkdir(archiveDir, { recursive: true }); + const filePath = path.join(archiveDir, fileName); + const gzipped = zlib.gzipSync(Buffer.from(content, 'utf-8')); + await fs.writeFile(filePath, gzipped); + if (ageMs !== undefined) { + const oldTime = new Date(Date.now() - ageMs); + await fs.utimes(filePath, oldTime, oldTime); + } + return filePath; +} + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function listArchives(chatsDir: string): Promise { + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + if (!(await fileExists(archiveDir))) return []; + return fs.readdir(archiveDir); +} + +// =========================================================================== +// Item 1: Actual-byte size reclamation +// =========================================================================== + +describe('Item 1 — actual-byte size reclamation (no fixed estimate)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('compresses oldest incompressible raw first, then newer compressible raw', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Oldest: incompressible random data (~10 KB). + const randomData = crypto + .getRandomValues(Buffer.alloc(10_000)) + .toString('hex'); + const oldest = await createSession(chatsDir, { + ageMs: 5 * 24 * 60 * 60 * 1000, + content: randomData, + }); + + // Newer: highly compressible repeated text (~50 KB). + const compressible = await createSession(chatsDir, { + ageMs: 3 * 24 * 60 * 60 * 1000, + content: 'AB'.repeat(25_000), + }); + + // Budget tight enough that compressing both is needed. + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.015 }); + const result = await runSessionCleanup({ globalTempDir: tempDir, config }); + + // Both raws should be compressed (archived >= 2 means both were processed). + expect(result.archived).toBeGreaterThanOrEqual(2); + expect(await fileExists(oldest.filePath)).toBe(false); + expect(await fileExists(compressible.filePath)).toBe(false); + + // Both archives should exist — no archive was evicted because compressing + // both raws brought the total under budget. + expect(result.archiveDeleted).toBe(0); + const archives = await listArchives(chatsDir); + expect(archives.length).toBe(2); + }); + + it('continues through all eligible raws after a skipped/failed first candidate', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const oldestId = 'session-oldest-fault-' + crypto.randomUUID().slice(0, 8); + const oldest = await createSession(chatsDir, { + ageMs: 5 * 24 * 60 * 60 * 1000, + content: 'A'.repeat(40_000), + sessionId: oldestId, + }); + + const newer = await createSession(chatsDir, { + ageMs: 3 * 24 * 60 * 60 * 1000, + content: 'B'.repeat(40_000), + }); + + // Inject an unlink fault for the oldest session's raw only. + setUnlinkFaultForTest(async (filePath: string) => { + if (filePath === oldest.filePath) { + const err = new Error('EPERM') as NodeJS.ErrnoException; + err.code = 'EPERM'; + throw err; + } + await fs.unlink(filePath); + }); + + try { + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.001 }); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // Oldest: archive was created but source unlink failed. + expect(result.archived).toBeGreaterThanOrEqual(1); + expect(result.failed).toBeGreaterThanOrEqual(1); + + // Newer: should still be processed despite the first failure. + expect(await fileExists(newer.filePath)).toBe(false); + } finally { + setUnlinkFaultForTest(null); + } + }); + + it('does NOT evict an archive while another useful eligible raw can be compressed', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + await fs.mkdir(chatsDir, { recursive: true }); + await fs.mkdir(archiveDir, { recursive: true }); + + // Pre-existing old archive. + const oldArchivePath = await makeArchive( + archiveDir, + 'session-preexisting.jsonl.gz', + 'old archive data'.repeat(500), + 10 * 24 * 60 * 60 * 1000, + ); + + // Two eligible compressible raws. + await createSession(chatsDir, { + ageMs: 5 * 24 * 60 * 60 * 1000, + content: 'A'.repeat(50_000), + }); + await createSession(chatsDir, { + ageMs: 4 * 24 * 60 * 60 * 1000, + content: 'B'.repeat(50_000), + }); + + // Budget: compressing both raws brings total under budget, so the + // pre-existing archive should survive. + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.01 }); + const result = await runSessionCleanup({ globalTempDir: tempDir, config }); + + expect(result.archiveDeleted).toBe(0); + expect(await fileExists(oldArchivePath)).toBe(true); + expect(result.archived).toBeGreaterThanOrEqual(2); + }); + + it('evicts archives only after all eligible raws are exhausted', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + await fs.mkdir(chatsDir, { recursive: true }); + await fs.mkdir(archiveDir, { recursive: true }); + + // Pre-existing old archive (large). + const oldArchivePath = await makeArchive( + archiveDir, + 'session-oldarchive.jsonl.gz', + 'x'.repeat(40_000), + 10 * 24 * 60 * 60 * 1000, + ); + + // One eligible incompressible raw. + const randomData = crypto + .getRandomValues(Buffer.alloc(30_000)) + .toString('hex'); + await createSession(chatsDir, { + ageMs: 5 * 24 * 60 * 60 * 1000, + content: randomData, + }); + + // Budget so tiny that even after compression, archives must be evicted. + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.00001 }); + const result = await runSessionCleanup({ globalTempDir: tempDir, config }); + + // The raw was compressed first (archived >= 1), then archives were evicted. + expect(result.archived).toBeGreaterThanOrEqual(1); + expect(result.archiveDeleted).toBeGreaterThanOrEqual(1); + expect(await fileExists(oldArchivePath)).toBe(false); + }); +}); + +// =========================================================================== +// Item 2: Global explicit age/count semantics +// =========================================================================== + +describe('Item 2 — global age/count over raw+archive corpus', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('removes an old archive by maxAge while under size budget', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + await fs.mkdir(archiveDir, { recursive: true }); + + // Old archive (10 days old), no corresponding raw. + const oldArchivePath = await makeArchive( + archiveDir, + 'session-old-only.jsonl.gz', + 'archived session data'.repeat(100), + 10 * 24 * 60 * 60 * 1000, + ); + + // Large size budget so size reclamation does not kick in. + // maxAge=5d removes the 10-day-old archive. + const config = resolveRetentionConfig({ + maxAge: '5d', + maxTotalSizeMB: 4096, + }); + const result = await runSessionCleanup({ globalTempDir: tempDir, config }); + + expect(result.archiveDeleted).toBeGreaterThanOrEqual(1); + expect(await fileExists(oldArchivePath)).toBe(false); + }); + + it('removes excess archives by maxCount', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + await fs.mkdir(archiveDir, { recursive: true }); + + // Three old archives. + const oldest = await makeArchive( + archiveDir, + 'session-aaa.jsonl.gz', + 'data-a'.repeat(100), + 10 * 24 * 60 * 60 * 1000, + ); + const middle = await makeArchive( + archiveDir, + 'session-bbb.jsonl.gz', + 'data-b'.repeat(100), + 8 * 24 * 60 * 60 * 1000, + ); + await makeArchive( + archiveDir, + 'session-ccc.jsonl.gz', + 'data-c'.repeat(100), + 6 * 24 * 60 * 60 * 1000, + ); + + // maxCount=2 → remove the oldest archive. + const config = resolveRetentionConfig({ + maxCount: 2, + maxTotalSizeMB: 4096, + }); + const result = await runSessionCleanup({ globalTempDir: tempDir, config }); + + expect(result.archiveDeleted).toBeGreaterThanOrEqual(1); + expect(await fileExists(oldest)).toBe(false); + expect(await fileExists(middle)).toBe(true); + }); + + it('reports shortfall when a protected session breaches an explicit limit', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Current session (old mtime, but protected because it is current). + const currentSession = await createSession(chatsDir, { + ageMs: 10 * 24 * 60 * 60 * 1000, + content: 'current', + }); + + // Two newer eligible sessions. + await createSession(chatsDir, { + ageMs: 3 * 24 * 60 * 60 * 1000, + content: 'newer-a', + }); + await createSession(chatsDir, { + ageMs: 2 * 24 * 60 * 60 * 1000, + content: 'newer-b', + }); + + // maxCount=2 with 3 sessions: 1 excess. + // The oldest (current session) is excess but protected → shortfall. + const config = resolveRetentionConfig({ + maxCount: 2, + maxTotalSizeMB: 4096, + }); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + currentSessionId: currentSession.sessionId, + config, + }); + + expect(result.ageCountShortfall).toBeGreaterThanOrEqual(1); + expect(await fileExists(currentSession.filePath)).toBe(true); + }); + + it('does not double-count a raw and its same-session archive for maxCount', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + await fs.mkdir(archiveDir, { recursive: true }); + + // Group A: raw + archive (same session, 5 days old). + const sessionA = 'session-groupa-' + crypto.randomUUID().slice(0, 8); + const rawA = await createSession(chatsDir, { + ageMs: 5 * 24 * 60 * 60 * 1000, + content: 'group-a-data', + sessionId: sessionA, + }); + const archiveAPath = path.join( + archiveDir, + path.basename(rawA.filePath) + '.gz', + ); + const archiveContent = zlib.gzipSync( + Buffer.from('group-a-archive', 'utf-8'), + ); + await fs.writeFile(archiveAPath, archiveContent); + const oldTime = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); + await fs.utimes(archiveAPath, oldTime, oldTime); + + // Group B: raw only (3 days old). + const rawB = await createSession(chatsDir, { + ageMs: 3 * 24 * 60 * 60 * 1000, + content: 'group-b-data', + }); + + // Group C: raw only (2 days old). + const rawC = await createSession(chatsDir, { + ageMs: 2 * 24 * 60 * 60 * 1000, + content: 'group-c-data', + }); + + // maxCount=2 → 3 groups, 1 excess (Group A). + // With duplicate counting, there would be 4 "sessions" (A raw, A archive, + // B, C), causing 2 excess — Group A AND Group B would be removed. + // Correct behaviour: only Group A is removed; B and C survive. + const config = resolveRetentionConfig({ + maxCount: 2, + maxTotalSizeMB: 4096, + }); + const result = await runSessionCleanup({ globalTempDir: tempDir, config }); + + // Group A removed (raw + archive). + expect(result.rawDeleted).toBeGreaterThanOrEqual(1); + expect(result.archiveDeleted).toBeGreaterThanOrEqual(1); + expect(await fileExists(rawA.filePath)).toBe(false); + expect(await fileExists(archiveAPath)).toBe(false); + + // Groups B and C survive — no duplicate counting. + expect(await fileExists(rawB.filePath)).toBe(true); + expect(await fileExists(rawC.filePath)).toBe(true); + }); + + it('protects a live-locked session and reports it in the shortfall', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Oldest session with a live lock. + const locked = await createSession(chatsDir, { + ageMs: 10 * 24 * 60 * 60 * 1000, + content: 'locked', + }); + const lock = await SessionLockManager.acquire(chatsDir, locked.sessionId); + + // Two newer eligible sessions. + await createSession(chatsDir, { + ageMs: 3 * 24 * 60 * 60 * 1000, + content: 'newer-a', + }); + await createSession(chatsDir, { + ageMs: 2 * 24 * 60 * 60 * 1000, + content: 'newer-b', + }); + + // maxCount=2: 3 groups, 1 excess. The locked session is oldest → excess + // but protected → shortfall. + const config = resolveRetentionConfig({ + maxCount: 2, + maxTotalSizeMB: 4096, + }); + let result; + try { + result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + expect(result.ageCountShortfall).toBeGreaterThanOrEqual(1); + expect(await fileExists(locked.filePath)).toBe(true); + } finally { + await lock.release(); + } + }); +}); + +// =========================================================================== +// Item 3: Archive chronology / floor / order +// =========================================================================== + +describe('Item 3 — archive chronology, minRetention floor, deterministic order', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('old raw compressed today retains old mtime ordering', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + await fs.mkdir(archiveDir, { recursive: true }); + + // A newer pre-existing archive (3 days old). + await makeArchive( + archiveDir, + 'session-newer.jsonl.gz', + 'newer archive'.repeat(100), + 3 * 24 * 60 * 60 * 1000, + ); + + // An old raw (5 days old) that will be compressed today. + const oldRaw = await createSession(chatsDir, { + ageMs: 5 * 24 * 60 * 60 * 1000, + content: 'A'.repeat(50_000), + }); + + // Tight budget forces compression of the raw but allows compressed + // archives to survive (allocated blocks ~4 KB each). + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.01 }); + await runSessionCleanup({ globalTempDir: tempDir, config }); + + // The archive created from the old raw should carry the 5-day-old mtime, + // not today's date. + const archives = await listArchives(chatsDir); + const compressedArchive = archives.find( + (f) => f === path.basename(oldRaw.filePath) + '.gz', + ); + expect(compressedArchive).toBeTruthy(); + + const archiveStat = await fs.stat( + path.join(archiveDir, compressedArchive!), + ); + const fiveDaysAgo = Date.now() - 5 * 24 * 60 * 60 * 1000; + // The archive mtime should be close to 5 days ago (within a tolerance). + expect(Math.abs(archiveStat.mtimeMs - fiveDaysAgo)).toBeLessThan(5000); + }); + + it('a recent archive survives size pressure due to minRetention floor', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + await fs.mkdir(archiveDir, { recursive: true }); + + // Recent archive within the default 1d minRetention floor. + const recentArchivePath = await makeArchive( + archiveDir, + 'session-recent.jsonl.gz', + 'x'.repeat(40_000), + 6 * 60 * 60 * 1000, // 6 hours ago + ); + + // An old eligible raw that will be compressed. + await createSession(chatsDir, { + ageMs: 5 * 24 * 60 * 60 * 1000, + content: 'A'.repeat(30_000), + }); + + // Tiny budget so both archives would need to be evicted if not for + // minRetention. + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.00001 }); + await runSessionCleanup({ globalTempDir: tempDir, config }); + + // The recent archive survives the minRetention floor. + expect(await fileExists(recentArchivePath)).toBe(true); + }); + + it('equal mtimes always choose the same lexicographically oldest archive', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + await fs.mkdir(archiveDir, { recursive: true }); + + // Two archives with identical mtime. The lexicographically smaller + // filename should be evicted first. + const alphaPath = await makeArchive( + archiveDir, + 'session-aaa.jsonl.gz', + 'x'.repeat(30_000), + ); + const betaPath = await makeArchive( + archiveDir, + 'session-zzz.jsonl.gz', + 'x'.repeat(30_000), + ); + // Set EXACT same mtime on both. + const fixedTime = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); + await fs.utimes(alphaPath, fixedTime, fixedTime); + await fs.utimes(betaPath, fixedTime, fixedTime); + + // Budget allows only one archive (each ~4 KB allocated, budget ~5 KB). + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.005 }); + await runSessionCleanup({ globalTempDir: tempDir, config }); + + // session-aaa (lexicographically smaller) should be evicted; session-zzz + // survives. + expect(await fileExists(alphaPath)).toBe(false); + expect(await fileExists(betaPath)).toBe(true); + }); +}); + +// =========================================================================== +// Item 4: Failure isolation / diagnostics +// =========================================================================== + +describe('Item 4 — failure isolation and diagnostics', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + setUnlinkFaultForTest(null); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('continues the sweep after a per-candidate compression failure', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // First (oldest) session: make it eligible but its file will be removed + // before archival, causing revalidation failure (not a thrown exception, + // but a graceful skip that the sweep must continue past). + const oldest = await createSession(chatsDir, { + ageMs: 5 * 24 * 60 * 60 * 1000, + content: 'A'.repeat(40_000), + }); + + // Second session: eligible and will succeed. + const newer = await createSession(chatsDir, { + ageMs: 3 * 24 * 60 * 60 * 1000, + content: 'B'.repeat(40_000), + }); + + // Inject unlink fault for the oldest only — the archive succeeds but + // unlink fails. The sweep must continue to the newer session. + setUnlinkFaultForTest(async (filePath: string) => { + if (filePath === oldest.filePath) { + const err = new Error('EPERM') as NodeJS.ErrnoException; + err.code = 'EPERM'; + throw err; + } + await fs.unlink(filePath); + }); + + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.001 }); + const result = await runSessionCleanup({ globalTempDir: tempDir, config }); + + // Oldest: archive created, but source not deleted (failed). + expect(result.archived).toBeGreaterThanOrEqual(1); + expect(result.failed).toBeGreaterThanOrEqual(1); + expect(await fileExists(oldest.filePath)).toBe(true); + + // Newer: successfully archived and deleted despite the first failure. + expect(await fileExists(newer.filePath)).toBe(false); + }); + + it('reports source unlink failure after successful archive and preserves duplicate', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const { filePath } = await createSession(chatsDir, { + ageMs: 5 * 24 * 60 * 60 * 1000, + content: 'A'.repeat(40_000), + }); + + // Fault: ALL unlinks fail (platform-only fault). + setUnlinkFaultForTest(async () => { + const err = new Error('EPERM') as NodeJS.ErrnoException; + err.code = 'EPERM'; + throw err; + }); + + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.001 }); + const result = await runSessionCleanup({ globalTempDir: tempDir, config }); + + // Archive was created (archived >= 1). + expect(result.archived).toBeGreaterThanOrEqual(1); + // Unlink failed (failed >= 1). + expect(result.failed).toBeGreaterThanOrEqual(1); + // Raw was NOT deleted. + expect(await fileExists(filePath)).toBe(true); + + // Archive exists — duplicate state preserved for next sweep reconciliation. + const archives = await listArchives(chatsDir); + expect(archives.some((f) => f.endsWith('.jsonl.gz'))).toBe(true); + + // The duplicate can be reconciled on the next sweep: clear the fault and + // run again. + setUnlinkFaultForTest(null); + const result2 = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // Now the raw is deleted (existing archive is reused, source unlinked). + expect(await fileExists(filePath)).toBe(false); + expect(result2.archived).toBeGreaterThanOrEqual(1); + }); + + it('does not allow a single failure to abort the entire sweep', async () => { + const hash1 = validHash64(); + const hash2 = validHash64(); + const chatsDir1 = path.join(tempDir, hash1, 'chats'); + const chatsDir2 = path.join(tempDir, hash2, 'chats'); + await fs.mkdir(chatsDir1, { recursive: true }); + await fs.mkdir(chatsDir2, { recursive: true }); + + const session1 = await createSession(chatsDir1, { + ageMs: 5 * 24 * 60 * 60 * 1000, + content: 'A'.repeat(40_000), + sessionId: 'session-failtarget-' + crypto.randomUUID().slice(0, 8), + }); + + const session2 = await createSession(chatsDir2, { + ageMs: 3 * 24 * 60 * 60 * 1000, + content: 'B'.repeat(40_000), + }); + + // Fault on the first session only. + setUnlinkFaultForTest(async (filePath: string) => { + if (filePath === session1.filePath) { + const err = new Error('EPERM') as NodeJS.ErrnoException; + err.code = 'EPERM'; + throw err; + } + await fs.unlink(filePath); + }); + + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.001 }); + const result = await runSessionCleanup({ globalTempDir: tempDir, config }); + + // Sweep completed (did not throw) and processed both candidates. + expect(result.archived).toBeGreaterThanOrEqual(2); + expect(result.failed).toBeGreaterThanOrEqual(1); + expect(await fileExists(session2.filePath)).toBe(false); + }); + + /** + * OCR finding 27/28: ENOENT during archive eviction must be treated as + * successful convergence (the desired end state is reached), counted as + * archiveDeleted, and NOT counted as a failure. + */ + it('treats archive ENOENT during eviction as successful convergence', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + await fs.mkdir(chatsDir, { recursive: true }); + + // Create a real archive old enough to be evicted. + const archivePath = await makeArchive( + archiveDir, + 'session-enoent-test.jsonl.gz', + 'X'.repeat(40_000), + 10 * 24 * 60 * 60 * 1000, + ); + + // Inject ENOENT for the archive path — the file vanished between scan + // and unlink (concurrent process or prior sweep). + setUnlinkFaultForTest(async (filePath: string) => { + if (filePath === archivePath) { + const err = new Error('ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + } + await fs.unlink(filePath); + }); + + // maxTotalSizeMB: 0 forces immediate eviction of all archives. + const config = resolveRetentionConfig({ + maxTotalSizeMB: 0.001, + minRetention: '1d', + }); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // ENOENT is convergence — archiveDeleted incremented, no failure counted. + expect(result.archiveDeleted).toBeGreaterThanOrEqual(1); + expect(result.failed).toBe(0); + }); + + /** + * OCR finding 27/28: platform errors (EPERM/EACCES/EBUSY) during archive + * eviction must increment the truthful failure counter. + */ + it('increments failed for EPERM during archive eviction', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + await fs.mkdir(chatsDir, { recursive: true }); + + const archivePath = await makeArchive( + archiveDir, + 'session-eperm-test.jsonl.gz', + 'Y'.repeat(40_000), + 10 * 24 * 60 * 60 * 1000, + ); + + setUnlinkFaultForTest(async (filePath: string) => { + if (filePath === archivePath) { + const err = new Error('EPERM') as NodeJS.ErrnoException; + err.code = 'EPERM'; + throw err; + } + await fs.unlink(filePath); + }); + + const config = resolveRetentionConfig({ + maxTotalSizeMB: 0.001, + minRetention: '1d', + }); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + expect(result.failed).toBeGreaterThanOrEqual(1); + // The archive was NOT deleted (EPERM). + expect(await fileExists(archivePath)).toBe(true); + }); +}); + +// =========================================================================== +// Finding 3: No double-counting of reused archive bytes +// =========================================================================== + +describe('Finding 3 — reused archive bytes are not double-counted', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('does not evict reused archives when the actual total is within budget', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + await fs.mkdir(chatsDir, { recursive: true }); + await fs.mkdir(archiveDir, { recursive: true }); + + // Create two old sessions with compressible content. + const sessionA = await createSession(chatsDir, { + ageMs: 5 * 24 * 60 * 60 * 1000, + content: 'A'.repeat(100_000), + }); + const sessionB = await createSession(chatsDir, { + ageMs: 4 * 24 * 60 * 60 * 1000, + content: 'B'.repeat(100_000), + }); + + // Pre-create valid archives for both sessions so compressToArchive reuses + // them rather than creating fresh archives. + const archivePathA = path.join( + archiveDir, + path.basename(sessionA.filePath) + '.gz', + ); + const archivePathB = path.join( + archiveDir, + path.basename(sessionB.filePath) + '.gz', + ); + const rawContentA = await fs.readFile(sessionA.filePath); + const rawContentB = await fs.readFile(sessionB.filePath); + await fs.writeFile(archivePathA, zlib.gzipSync(rawContentA)); + await fs.writeFile(archivePathB, zlib.gzipSync(rawContentB)); + const oldTime = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); + await fs.utimes(archivePathA, oldTime, oldTime); + await fs.utimes(archivePathB, oldTime, oldTime); + + // Budget tuned between the actual post-archive total (two small archives) + // and the double-counted total. If reused archive bytes are added to the + // running total a second time, the inflated total exceeds the budget and + // triggers unnecessary archive eviction. + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.012 }); + const result = await runSessionCleanup({ globalTempDir: tempDir, config }); + + // Both raws should be archived (reusing existing archives) and deleted. + expect(result.archived).toBeGreaterThanOrEqual(2); + expect(result.rawDeleted).toBeGreaterThanOrEqual(2); + expect(await fileExists(sessionA.filePath)).toBe(false); + expect(await fileExists(sessionB.filePath)).toBe(false); + + // Both reused archives must survive — no double-counting eviction. + expect(result.archiveDeleted).toBe(0); + expect(await fileExists(archivePathA)).toBe(true); + expect(await fileExists(archivePathB)).toBe(true); + }); +}); + +// =========================================================================== +// Finding 4: Compression platform failures increment failed +// =========================================================================== + +describe('Finding 4 — compression platform failures are counted truthfully', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('increments failed when the archive directory is blocked by a file (mkdir error)', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Create an old eligible session. + const session = await createSession(chatsDir, { + ageMs: 5 * 24 * 60 * 60 * 1000, + content: 'A'.repeat(40_000), + }); + + // Block the archive directory by creating a regular file at that path. + // This triggers a mkdir error in compressToArchive — a platform failure. + await fs.writeFile(path.join(chatsDir, ARCHIVE_DIR_NAME), 'blocker'); + + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.001 }); + const result = await runSessionCleanup({ globalTempDir: tempDir, config }); + + // mkdir is a platform failure — must increment the failed counter. + expect(result.failed).toBeGreaterThanOrEqual(1); + expect(result.archived).toBe(0); + expect(await fileExists(session.filePath)).toBe(true); + }); +}); diff --git a/packages/core/src/recording/janitor/sessionJanitor.safety.test.ts b/packages/core/src/recording/janitor/sessionJanitor.safety.test.ts new file mode 100644 index 0000000000..647580eafe --- /dev/null +++ b/packages/core/src/recording/janitor/sessionJanitor.safety.test.ts @@ -0,0 +1,389 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Adversarial safety tests for the session janitor (Items 1, 4, 8). + * + * Tests prove: + * - The global temp root is NEVER removed (Item 1: no global-root removal). + * - Post-scan file replacement is caught by revalidation and data is retained + * (Item 4). + * - A symlinked archive directory is not followed during a full sweep + * (Item 1). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + runSessionCleanup, + setScanToMutationHookForTest, + setRmdirFaultForTest, +} from './sessionJanitor.js'; +import { resolveRetentionConfig } from './retentionPolicy.js'; +import { SessionRecordingService } from '../SessionRecordingService.js'; +import type { SessionRecordingServiceConfig } from '../types.js'; +import { ARCHIVE_DIR_NAME } from './sessionScanner.js'; + +/** + * File-level hook reset so process-global test seams never leak into + * subsequent test files, regardless of which describe block ran last. + */ +afterEach(() => { + setScanToMutationHookForTest(null); + setRmdirFaultForTest(null); +}); + +async function makeTempDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'janitor-safety-')); +} + +function validHash64(): string { + return crypto.randomUUID().replace(/-/g, '').repeat(2).slice(0, 64); +} + +function makeConfig(chatsDir: string): SessionRecordingServiceConfig { + return { + sessionId: 'session-' + crypto.randomUUID(), + projectHash: validHash64(), + chatsDir, + workspaceDirs: [chatsDir], + provider: 'test', + model: 'test', + }; +} + +async function createSession( + chatsDir: string, + opts: { + ageMs?: number; + content?: string; + sessionId?: string; + } = {}, +): Promise<{ filePath: string; sessionId: string }> { + await fs.mkdir(chatsDir, { recursive: true }); + const sessionId = opts.sessionId ?? 'session-' + crypto.randomUUID(); + const config: SessionRecordingServiceConfig = { + ...makeConfig(chatsDir), + sessionId, + }; + const svc = new SessionRecordingService(config); + try { + svc.recordContent({ + speaker: 'human', + blocks: [{ type: 'text', text: opts.content ?? 'test message' }], + }); + await svc.flush(); + } finally { + await svc.dispose(); + } + const filePath = svc.getFilePath(); + if (!filePath) throw new Error('No file path'); + if (opts.ageMs !== undefined) { + const oldTime = new Date(Date.now() - opts.ageMs); + await fs.utimes(filePath, oldTime, oldTime); + } + return { filePath, sessionId }; +} + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +describe('runSessionCleanup — global temp root is never removed (Item 1)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('does not rmdir the global temp root after cleanup', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await createSession(chatsDir, { ageMs: 2 * 24 * 60 * 60 * 1000 }); + + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.001 }); + await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // The global temp root must survive cleanup. + expect(await fileExists(tempDir)).toBe(true); + }); +}); + +describe('runSessionCleanup — symlinked archive directory (Item 1)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it.skipIf(process.platform === 'win32')( + 'does not write into or follow a symlinked archive directory during sweep', + async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Create a target directory outside the tree. + const outsideDir = path.join(tempDir, 'outside-archive-target'); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.writeFile(path.join(outsideDir, 'secret.txt'), 'secret'); + + // Replace chats/archive with a symlink to the outside dir. + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + await fs.symlink(outsideDir, archiveDir, 'dir'); + + // Create an old session that will be over-budget. + await createSession(chatsDir, { + ageMs: 2 * 24 * 60 * 60 * 1000, + content: 'A'.repeat(50_000), + }); + + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.001 }); + await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // The outside secret file must survive — no archive was written through + // the symlink. + expect(await fileExists(path.join(outsideDir, 'secret.txt'))).toBe(true); + // No .gz files should have been created in the outside dir. + const outsideEntries = await fs.readdir(outsideDir); + expect(outsideEntries.some((f) => f.endsWith('.gz'))).toBe(false); + }, + ); +}); + +describe('runSessionCleanup — post-scan file replacement (Item 4)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it.skipIf(process.platform === 'win32')( + 'retains data when a session file is replaced with a symlink between scan and mutation', + async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + const { filePath } = await createSession(chatsDir, { + ageMs: 2 * 24 * 60 * 60 * 1000, + content: 'A'.repeat(50_000), + }); + + // Replace the file with a symlink to an outside file right before cleanup. + const outsideTarget = path.join(tempDir, 'outside-target.jsonl'); + await fs.writeFile( + outsideTarget, + '{"type":"session_start","payload":{"sessionId":"evil"}}\n', + ); + await fs.unlink(filePath); + await fs.symlink(outsideTarget, filePath); + + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.001 }); + await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // The symlinked file must survive — revalidation caught the replacement. + expect(await fileExists(filePath)).toBe(true); + expect(await fileExists(outsideTarget)).toBe(true); + // No archives should have been created from the symlink. + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + let archiveEntries: string[] = []; + if (await fileExists(archiveDir)) { + archiveEntries = await fs.readdir(archiveDir); + } + expect(archiveEntries.some((f) => f.endsWith('.gz'))).toBe(false); + }, + ); +}); + +describe('runSessionCleanup — exact temp grammar cleanup (Item 8)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('does not remove non-session temp files during cleanup', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + await fs.mkdir(archiveDir, { recursive: true }); + + // Create a file that does NOT match the temp grammar. + const innocentFile = path.join(archiveDir, 'random.bak'); + await fs.writeFile(innocentFile, 'data'); + const oldTime = new Date(Date.now() - 120 * 1000); + await fs.utimes(innocentFile, oldTime, oldTime); + + // Also create a valid session (not over budget so no archival happens). + await createSession(chatsDir, { ageMs: 2 * 24 * 60 * 60 * 1000 }); + + const config = resolveRetentionConfig({ maxTotalSizeMB: 4096 }); + await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // Innocent file must survive. + expect(await fileExists(innocentFile)).toBe(true); + }); +}); + +/** + * Mutation-time inode revalidation (Item 4, finding 29). + * + * The previous test replaced the file BEFORE scanning, so the scanner never + * saw the original file — header checks alone could save it. This test uses + * a narrow test-only lifecycle hook to replace the file AFTER scanGlobalSessions + * returns but BEFORE reclamation, with another regular file using the SAME + * canonical session ID. Only inode (dev/ino) revalidation can catch this. + */ +describe('runSessionCleanup — mutation-time inode revalidation (Item 4, finding 29)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('retains a replaced regular file (same session ID) between scan and mutation', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + const { filePath, sessionId } = await createSession(chatsDir, { + ageMs: 5 * 24 * 60 * 60 * 1000, + content: 'A'.repeat(50_000), + }); + + // Install the lifecycle hook: after scan, replace the file with a NEW + // regular file using the SAME session ID and a valid header. Header + // checks alone cannot detect this — only dev/ino revalidation can. + setScanToMutationHookForTest(async () => { + const replacementContent = + JSON.stringify({ + v: 1, + seq: 0, + ts: new Date().toISOString(), + type: 'session_start', + payload: { sessionId, startTime: new Date().toISOString() }, + }) + + '\n' + + '{"type":"user","payload":{"speaker":"human","blocks":[{"type":"text","text":"replacement"}]}}\n'; + + // Create the replacement while the original still exists, guaranteeing a + // distinct inode even on filesystems that immediately recycle unlinked + // inode numbers, then atomically replace the scanned path. + const replacementPath = `${filePath}.replacement`; + await fs.writeFile(replacementPath, replacementContent, 'utf-8'); + await fs.rename(replacementPath, filePath); + }); + + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.001 }); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // The replacement file must survive — inode revalidation caught the swap. + expect(await fileExists(filePath)).toBe(true); + // No archive should have been created. + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + let archiveEntries: string[] = []; + if (await fileExists(archiveDir)) { + archiveEntries = await fs.readdir(archiveDir); + } + expect(archiveEntries.some((f) => f.endsWith('.gz'))).toBe(false); + // The replacement was NOT archived or deleted. + expect(result.archived).toBe(0); + expect(result.rawDeleted).toBe(0); + }); +}); + +/** + * Diagnostic wiring tests (Item 4, finding 34/35). + * + * Prove that non-benign per-directory failures (temp + empty-dir cleanup) + * are counted in `failed` instead of being silently swallowed. Uses a + * narrow rmdir fault seam since chmod cannot reliably induce platform errors. + */ +describe('runSessionCleanup — diagnostic error counting (Item 4)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('counts non-benign rmdir failures in the failed counter', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await createSession(chatsDir, { + ageMs: 2 * 24 * 60 * 60 * 1000, + content: 'A'.repeat(50_000), + }); + + // Inject a fault that makes rmdir fail with a non-benign error. + setRmdirFaultForTest(async (_dirPath: string) => { + const err = new Error('EIO') as NodeJS.ErrnoException; + err.code = 'EIO'; + throw err; + }); + + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.001 }); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // Non-benign cleanup failures must be counted. + expect(result.failed).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/packages/core/src/recording/janitor/sessionJanitor.test.ts b/packages/core/src/recording/janitor/sessionJanitor.test.ts new file mode 100644 index 0000000000..627e0a9b69 --- /dev/null +++ b/packages/core/src/recording/janitor/sessionJanitor.test.ts @@ -0,0 +1,856 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Behavioral tests for the session janitor orchestrator (AC-1 through AC-11). + * + * Uses real temporary filesystems, real SessionRecordingService output, real + * session files, and real lock files. No mocks. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + runSessionCleanup, + runSessionCleanupWithSettings, +} from './sessionJanitor.js'; +import { resolveRetentionConfig } from './retentionPolicy.js'; +import { SessionRecordingService } from '../SessionRecordingService.js'; +import { SessionLockManager } from '../SessionLockManager.js'; +import { JanitorLease } from './janitorLease.js'; +import type { SessionRecordingServiceConfig } from '../types.js'; +import { ARCHIVE_DIR_NAME } from './sessionScanner.js'; + +async function makeTempDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'janitor-int-')); +} + +function validHash64(): string { + return crypto.randomUUID().replace(/-/g, '').repeat(2).slice(0, 64); +} + +function makeConfig(chatsDir: string): SessionRecordingServiceConfig { + return { + sessionId: 'session-' + crypto.randomUUID(), + projectHash: validHash64(), + chatsDir, + workspaceDirs: [chatsDir], + provider: 'test', + model: 'test', + }; +} + +/** Create a real session file, optionally with content events. */ +async function createSession( + chatsDir: string, + opts: { + ageMs?: number; + content?: string; + sessionId?: string; + } = {}, +): Promise<{ filePath: string; sessionId: string }> { + await fs.mkdir(chatsDir, { recursive: true }); + const sessionId = opts.sessionId ?? 'session-' + crypto.randomUUID(); + const config: SessionRecordingServiceConfig = { + ...makeConfig(chatsDir), + sessionId, + }; + const svc = new SessionRecordingService(config); + svc.recordContent({ + speaker: 'human', + blocks: [{ type: 'text', text: opts.content ?? 'test message' }], + }); + await svc.flush(); + await svc.dispose(); + const filePath = svc.getFilePath(); + if (!filePath) throw new Error('No file path'); + + // Optionally set old mtime. + if (opts.ageMs !== undefined) { + const oldTime = new Date(Date.now() - opts.ageMs); + await fs.utimes(filePath, oldTime, oldTime); + } + + return { filePath, sessionId }; +} + +async function makeArchive( + archiveDir: string, + fileName: string, + content: string, + ageMs?: number, +): Promise { + await fs.mkdir(archiveDir, { recursive: true }); + const filePath = path.join(archiveDir, fileName); + await fs.writeFile(filePath, content); + if (ageMs !== undefined) { + const oldTime = new Date(Date.now() - ageMs); + await fs.utimes(filePath, oldTime, oldTime); + } + return filePath; +} + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +describe('runSessionCleanup — defaults and discovery', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('is default-on and discovers real SessionRecordingService files', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + const { filePath } = await createSession(chatsDir, { + ageMs: 2 * 24 * 60 * 60 * 1000, // 2 days old + }); + + const config = resolveRetentionConfig(undefined); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + expect(result.disabled).toBe(false); + expect(result.janitorWonLease).toBe(true); + expect(result.scanned).toBe(1); + // Under budget — should not archive or delete. + expect(result.archived).toBe(0); + expect(result.rawDeleted).toBe(0); + // Old session survives when under budget (no default maxAge). + expect(await fileExists(filePath)).toBe(true); + }); + + it('does not age-delete an old under-budget session (AC-3)', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + const { filePath } = await createSession(chatsDir, { + ageMs: 365 * 24 * 60 * 60 * 1000, // 1 year old + }); + + const config = resolveRetentionConfig(undefined); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // No default maxAge → session retained even though very old. + expect(result.rawDeleted).toBe(0); + expect(await fileExists(filePath)).toBe(true); + }); + + it('returns disabled=true when cleanup is explicitly disabled', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await createSession(chatsDir); + + const config = resolveRetentionConfig({ enabled: false }); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + expect(result.disabled).toBe(true); + expect(result.janitorWonLease).toBe(false); + }); + it('retains the 4 GiB default at runtime for a partial settings object (AC-2)', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await createSession(chatsDir, { ageMs: 2 * 24 * 60 * 60 * 1000 }); + + // A partial settings object that only sets maxAge — size budget must + // default to 4 GiB at the consumer so this small session is retained. + const result = await runSessionCleanupWithSettings(tempDir, undefined, { + maxAge: '30d', + }); + + expect(result.disabled).toBe(false); + expect(result.configuredByteLimit).toBe(4096 * 1024 * 1024); + expect(result.archived).toBe(0); + expect(result.rawDeleted).toBe(0); + }); +}); + +describe('runSessionCleanup — explicit maxAge (AC-3)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('archives sessions older than an explicit maxAge', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const { filePath: oldFile } = await createSession(chatsDir, { + ageMs: 5 * 24 * 60 * 60 * 1000, // 5 days old + }); + const { filePath: recentFile } = await createSession(chatsDir, { + ageMs: 2 * 24 * 60 * 60 * 1000, // 2 days old + }); + + // maxAge 3d: the 5-day session is beyond the limit; the 2-day is within. + // Explicit age/count policy uses direct deletion (lock-owned). + const config = resolveRetentionConfig({ maxAge: '3d' }); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + expect(result.rawDeleted).toBeGreaterThanOrEqual(1); + expect(await fileExists(oldFile)).toBe(false); + // Within maxAge and under budget — retained. + expect(await fileExists(recentFile)).toBe(true); + }); + + it('retains all eligible sessions when none exceed maxAge', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const { filePath: a } = await createSession(chatsDir, { + ageMs: 2 * 24 * 60 * 60 * 1000, + }); + const { filePath: b } = await createSession(chatsDir, { + ageMs: 3 * 24 * 60 * 60 * 1000, + }); + + const config = resolveRetentionConfig({ maxAge: '30d' }); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + expect(result.archived).toBe(0); + expect(await fileExists(a)).toBe(true); + expect(await fileExists(b)).toBe(true); + }); +}); + +describe('runSessionCleanup — explicit maxCount (AC-3)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('keeps only the N most recent sessions under maxCount', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const oldest = await createSession(chatsDir, { + ageMs: 5 * 24 * 60 * 60 * 1000, + }); + const middle = await createSession(chatsDir, { + ageMs: 4 * 24 * 60 * 60 * 1000, + }); + const newest = await createSession(chatsDir, { + ageMs: 3 * 24 * 60 * 60 * 1000, + }); + + const config = resolveRetentionConfig({ maxCount: 2 }); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // Only the 2 most recent survive; the oldest is directly deleted + // (explicit count policy uses lock-owned direct deletion). + expect(result.rawDeleted).toBeGreaterThanOrEqual(1); + expect(await fileExists(oldest.filePath)).toBe(false); + expect(await fileExists(middle.filePath)).toBe(true); + expect(await fileExists(newest.filePath)).toBe(true); + }); + + it('retains all when count is within maxCount', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const a = await createSession(chatsDir, { ageMs: 3 * 24 * 60 * 60 * 1000 }); + const b = await createSession(chatsDir, { ageMs: 2 * 24 * 60 * 60 * 1000 }); + + const config = resolveRetentionConfig({ maxCount: 5 }); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + expect(result.archived).toBe(0); + expect(await fileExists(a.filePath)).toBe(true); + expect(await fileExists(b.filePath)).toBe(true); + }); +}); + +describe('runSessionCleanup — protection (AC-7)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('protects the current session from deletion', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + const { filePath, sessionId } = await createSession(chatsDir, { + ageMs: 2 * 24 * 60 * 60 * 1000, + content: 'x'.repeat(1024 * 1024 * 10), // 10MB to ensure over-budget + }); + + // Tiny budget to force size-driven reclamation. + const config = resolveRetentionConfig({ maxTotalSizeMB: 1 }); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + currentSessionId: sessionId, + config, + }); + + // Current session should survive and be counted as protected. + expect(await fileExists(filePath)).toBe(true); + expect(result.skipped).toBeGreaterThanOrEqual(1); + }); + + it('protects recent sessions (within minRetention)', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + const { filePath } = await createSession(chatsDir, { + content: 'x'.repeat(1024 * 1024 * 10), // 10MB + }); + + const config = resolveRetentionConfig({ maxTotalSizeMB: 1 }); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // Recent session (within 1d default minRetention) should survive. + expect(await fileExists(filePath)).toBe(true); + expect(result.skipped).toBeGreaterThanOrEqual(1); + }); + + it('protects sessions with a live lock', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + const { filePath, sessionId } = await createSession(chatsDir, { + ageMs: 2 * 24 * 60 * 60 * 1000, + content: 'x'.repeat(1024 * 1024 * 10), + }); + + // Acquire a live lock. + const lock = await SessionLockManager.acquire(chatsDir, sessionId); + + try { + const config = resolveRetentionConfig({ maxTotalSizeMB: 1 }); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // Live-locked session should survive. + expect(await fileExists(filePath)).toBe(true); + expect(result.skipped).toBeGreaterThanOrEqual(1); + } finally { + await lock.release(); + } + }); + + it('protects unreadable recordings and counts them (AC-7)', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Create a session file with garbage content. + const garbagePath = path.join( + chatsDir, + 'session-2026-01-01T00-00-00-garbage.jsonl', + ); + await fs.writeFile(garbagePath, 'this is not valid JSON\n'); + const oldTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + await fs.utimes(garbagePath, oldTime, oldTime); + + const config = resolveRetentionConfig({ maxTotalSizeMB: 1 }); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // Unreadable recording should survive. + expect(await fileExists(garbagePath)).toBe(true); + expect(result.scanned).toBeGreaterThanOrEqual(1); + }); +}); + +describe('runSessionCleanup — size-driven archival (AC-4)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('archives eligible raw sessions when over budget', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Create a large old session. + const { filePath } = await createSession(chatsDir, { + ageMs: 2 * 24 * 60 * 60 * 1000, + content: 'A'.repeat(100_000), // ~100KB + }); + + // Small budget. + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.01 }); // ~10KB + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // Session should be archived (raw deleted, archive created). + expect(result.archived).toBeGreaterThanOrEqual(1); + expect(await fileExists(filePath)).toBe(false); + + // Archive should exist. + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + const archives = await fs.readdir(archiveDir); + expect(archives.length).toBeGreaterThanOrEqual(1); + }); + + it('preserves archive integrity (lossless round-trip)', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const content = 'compressible content\n'.repeat(5000); + const { filePath } = await createSession(chatsDir, { + ageMs: 2 * 24 * 60 * 60 * 1000, + content, + }); + + // Read original content. + const original = await fs.readFile(filePath, 'utf-8'); + + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.01 }); + await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // Find the archive and decompress. + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + const archives = await fs.readdir(archiveDir); + expect(archives.length).toBe(1); + + const zlib = await import('node:zlib'); + const archiveData = await fs.readFile(path.join(archiveDir, archives[0])); + const decompressed = zlib.gunzipSync(archiveData).toString('utf-8'); + + expect(decompressed).toBe(original); + }); + + it('archives count toward the same budget and are evicted oldest-first', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + await fs.mkdir(chatsDir, { recursive: true }); + await fs.mkdir(archiveDir, { recursive: true }); + + // Create old raw sessions and old archives. + await createSession(chatsDir, { + ageMs: 5 * 24 * 60 * 60 * 1000, + content: 'A'.repeat(50_000), + }); + + await makeArchive( + archiveDir, + 'session-very-old.jsonl.gz', + 'x'.repeat(50_000), + 10 * 24 * 60 * 60 * 1000, // 10 days old + ); + + // Budget smaller than total. + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.05 }); // ~50KB + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // Some data should have been reclaimed. + expect(result.bytesAfter).toBeLessThanOrEqual(config.maxTotalSizeBytes); + }); + + it('evicts the oldest archive by exact identity, not just budget', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + await fs.mkdir(archiveDir, { recursive: true }); + + // Three archives with distinct ages and content. + const oldest = await makeArchive( + archiveDir, + 'session-oldest-aaa.jsonl.gz', + 'x'.repeat(50_000), + 10 * 24 * 60 * 60 * 1000, // 10 days + ); + const middle = await makeArchive( + archiveDir, + 'session-middle-bbb.jsonl.gz', + 'y'.repeat(50_000), + 8 * 24 * 60 * 60 * 1000, // 8 days + ); + const newest = await makeArchive( + archiveDir, + 'session-newest-ccc.jsonl.gz', + 'z'.repeat(50_000), + 6 * 24 * 60 * 60 * 1000, // 6 days + ); + + // Budget: 0.12 MB (~125 KB). Three archives total ~150 KB. + // After evicting the oldest: ~100 KB < 125 KB → stop. + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.12 }); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // The OLDEST archive is evicted; the two newer survive. + expect(result.archiveDeleted).toBeGreaterThanOrEqual(1); + expect(await fileExists(oldest)).toBe(false); + expect(await fileExists(middle)).toBe(true); + expect(await fileExists(newest)).toBe(true); + }); +}); + +describe('runSessionCleanup — stale lock cleanup (AC-8)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('removes stale lock files in chats directories', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Create a stale lock (dead PID). + const staleLockPath = path.join(chatsDir, 'stale-session-id.lock'); + await fs.writeFile( + staleLockPath, + JSON.stringify({ + pid: 999999, + timestamp: new Date(Date.now() - 49 * 60 * 60 * 1000).toISOString(), + }), + ); + + const config = resolveRetentionConfig(undefined); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + expect(result.staleLocksRemoved).toBeGreaterThanOrEqual(1); + expect(await fileExists(staleLockPath)).toBe(false); + }); + + it('preserves live lock files', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Create a live lock (current PID, recent timestamp). + const liveLockPath = path.join(chatsDir, 'live-session-id.lock'); + await fs.writeFile( + liveLockPath, + JSON.stringify({ + pid: process.pid, + timestamp: new Date().toISOString(), + }), + ); + + const config = resolveRetentionConfig(undefined); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + expect(result.staleLocksRemoved).toBe(0); + expect(await fileExists(liveLockPath)).toBe(true); + }); + + it('removes a live-PID lock whose timestamp exceeds the 48h PID-reuse bound', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Create a lock with a LIVE PID (process.pid) but a timestamp 49 hours + // ago. The PID is alive, so checkStaleWithPidReuse does NOT return true + // via ESRCH — it must use the 48-hour timestamp override. + const lockSessionId = 'pid-reuse-stale'; + const staleLockPath = path.join(chatsDir, lockSessionId + '.lock'); + const oldTimestamp = new Date( + Date.now() - 49 * 60 * 60 * 1000, + ).toISOString(); + await fs.writeFile( + staleLockPath, + JSON.stringify({ + pid: process.pid, + timestamp: oldTimestamp, + sessionId: lockSessionId, + }), + ); + + const config = resolveRetentionConfig(undefined); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // The 48h PID-reuse override marks it stale and removes it. + expect(result.staleLocksRemoved).toBeGreaterThanOrEqual(1); + expect(await fileExists(staleLockPath)).toBe(false); + }); +}); + +describe('runSessionCleanup — blast radius (AC-10)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('never touches unknown files in chats dir', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Create various non-session files. + const logPath = path.join(chatsDir, 'logs.json'); + const backupPath = path.join(chatsDir, 'backup.bak'); + const historyPath = path.join(chatsDir, 'shell_history'); + await fs.writeFile(logPath, '{}'); + await fs.writeFile(backupPath, 'backup'); + await fs.writeFile(historyPath, 'history'); + + // Also create a session. + await createSession(chatsDir, { ageMs: 2 * 24 * 60 * 60 * 1000 }); + + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.001 }); + await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // Unknown files should survive. + expect(await fileExists(logPath)).toBe(true); + expect(await fileExists(backupPath)).toBe(true); + expect(await fileExists(historyPath)).toBe(true); + }); + + it('removes genuinely empty chats and hash dirs (non-recursive)', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Create a session that will be archived+deleted, then the archive + // also evicted because even compressed bytes exceed the tiny budget. + await createSession(chatsDir, { + ageMs: 2 * 24 * 60 * 60 * 1000, + content: 'A'.repeat(50_000), + }); + + // Budget so small that even the compressed archive exceeds it. + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.00001 }); + await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // The chats dir and hash dir should be removed (non-recursive rmdir). + expect(await fileExists(chatsDir)).toBe(false); + expect(await fileExists(path.join(tempDir, hash))).toBe(false); + }); + + it('does not remove non-empty dirs', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Create an unknown file that prevents dir removal. + const unknownPath = path.join(chatsDir, 'unknown.dat'); + await fs.writeFile(unknownPath, 'data'); + + const config = resolveRetentionConfig(undefined); + await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // Dir should survive because it's not empty. + expect(await fileExists(chatsDir)).toBe(true); + expect(await fileExists(unknownPath)).toBe(true); + }); +}); + +describe('runSessionCleanup — structured result (AC-11)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('reports bytesBefore, bytesAfter, and configuredByteLimit', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await createSession(chatsDir, { + ageMs: 2 * 24 * 60 * 60 * 1000, + content: 'data', + }); + + const config = resolveRetentionConfig({ maxTotalSizeMB: 4096 }); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + expect(result.bytesBefore).toBeGreaterThan(0); + expect(result.bytesAfter).toBeGreaterThan(0); + expect(result.configuredByteLimit).toBe(4096 * 1024 * 1024); + expect(result.overBudgetBytes).toBe(0); // Under budget. + }); + + it('reports overBudgetBytes when protected data exceeds budget', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Create garbage file (unreadable → protected). + const garbagePath = path.join( + chatsDir, + 'session-2026-01-01T00-00-00-protected.jsonl', + ); + await fs.writeFile(garbagePath, 'x'.repeat(50_000) + '\n'); + const oldTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + await fs.utimes(garbagePath, oldTime, oldTime); + + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.001 }); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // Protected data survives, overBudgetBytes > 0. + expect(await fileExists(garbagePath)).toBe(true); + expect(result.overBudgetBytes).toBeGreaterThan(0); + }); +}); + +/** + * Lease contention (AC-6, finding 42). + * + * When another process holds the global janitor lease, cleanup must return + * immediately with janitorWonLease: false and NO mutations. + */ +describe('runSessionCleanup — lease contention skip (AC-6, finding 42)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('returns janitorWonLease false and performs no mutations when lease is held', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Create an old session that WOULD be cleaned up if the janitor won. + const { filePath } = await createSession(chatsDir, { + ageMs: 5 * 24 * 60 * 60 * 1000, + content: 'A'.repeat(50_000), + }); + + // Pre-acquire the lease so cleanup cannot win it. + const blockingLease = await JanitorLease.tryAcquire(tempDir); + expect(blockingLease).not.toBeNull(); + + try { + const config = resolveRetentionConfig({ maxTotalSizeMB: 0.001 }); + const result = await runSessionCleanup({ + globalTempDir: tempDir, + config, + }); + + // Cleanup did not win the lease. + expect(result.janitorWonLease).toBe(false); + // No mutations performed. + expect(result.scanned).toBe(0); + expect(result.archived).toBe(0); + expect(result.rawDeleted).toBe(0); + expect(result.archiveDeleted).toBe(0); + // The old session must survive — no mutation happened. + expect(await fileExists(filePath)).toBe(true); + } finally { + await blockingLease!.release(); + } + }); +}); diff --git a/packages/core/src/recording/janitor/sessionJanitor.ts b/packages/core/src/recording/janitor/sessionJanitor.ts new file mode 100644 index 0000000000..bfae5ccc4e --- /dev/null +++ b/packages/core/src/recording/janitor/sessionJanitor.ts @@ -0,0 +1,346 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Session-recording janitor orchestrator (AC-1 through AC-11). + * + * The elected janitor performs a global sweep across all 64-hex project-hash + * directories under the global temp root. It discovers recordings using the + * canonical JSONL header reader, delegates reclamation (age/count + size) to + * the {@link runReclamation} engine, cleans stale locks, and removes + * genuinely empty directories. + * + * Concurrency safety: + * - A single global filesystem lease ensures only one process mutates at a time. + * - Each destructive raw-session operation acquires exclusive session-lock + * ownership and revalidates before proceeding. + * - Unreadable recordings are retained and counted, never deleted. + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { + emptyResult, + type ResolvedRetentionConfig, + type SessionCandidate, + type SessionCleanupParams, + type SessionCleanupResult, + type UserRetentionSettings, +} from './cleanupTypes.js'; +import { scanGlobalSessions, ARCHIVE_DIR_NAME } from './sessionScanner.js'; +import { JanitorLease, type JanitorLeaseHandle } from './janitorLease.js'; +import { cleanupStaleTempArchives } from './archiveCompressor.js'; +import { resolveRetentionConfig } from './retentionPolicy.js'; +import { runReclamation } from './reclamationEngine.js'; +import { SessionLockManager } from '../SessionLockManager.js'; +import { debugLogger } from '../../utils/debugLogger.js'; + +/** Age threshold for recognizing stale temporary archive artifacts. */ +const STALE_TEMP_ARCHIVE_AGE_MS = 60 * 1000; + +// --------------------------------------------------------------------------- +// Narrow fault seam for testing platform-only rmdir failures (Item 4) +// --------------------------------------------------------------------------- + +/** + * Inject an alternative rmdir implementation for testing. When non-null, + * {@link platformRmdir} delegates to it instead of the real `fs.rmdir`. + * Tests use this to exercise non-benign error counting without chmod + * (which cannot reliably induce platform errors). + */ +let rmdirFaultFn: ((dirPath: string) => Promise) | null = null; + +/** Install or clear the rmdir fault for tests. */ +export function setRmdirFaultForTest( + fn: ((dirPath: string) => Promise) | null, +): void { + rmdirFaultFn = fn; +} + +/** Perform an rmdir, delegating to the fault injector when set. */ +async function platformRmdir(dirPath: string): Promise { + if (rmdirFaultFn !== null) { + await rmdirFaultFn(dirPath); + return; + } + await fs.rmdir(dirPath); +} + +// --------------------------------------------------------------------------- +// Narrow test-only lifecycle hook: scan-to-mutation gap (Item 4) +// --------------------------------------------------------------------------- + +/** + * When non-null, invoked after the initial `scanGlobalSessions` returns and + * before reclamation begins. Tests use this to deterministically replace a + * file on the real filesystem during the scan-to-mutation window, proving + * that inode revalidation catches the replacement. + */ +let scanToMutationHook: (() => Promise) | null = null; + +/** Install or clear the scan-to-mutation hook for tests. */ +export function setScanToMutationHookForTest( + fn: (() => Promise) | null, +): void { + scanToMutationHook = fn; +} + +/** + * Main entry point for the session-recording janitor. + * + * This is the core orchestrator. The CLI consumer resolves defaults from + * user settings and passes a fully resolved config. + */ +export async function runSessionCleanup( + params: SessionCleanupParams, +): Promise { + const { config, globalTempDir } = params; + + if (!config.enabled) { + return emptyResult(true, false, config.maxTotalSizeBytes); + } + + // AC-6: Acquire the global janitor lease. Skip-on-busy. + let lease: JanitorLeaseHandle | null; + try { + lease = await JanitorLease.tryAcquire(globalTempDir); + } catch { + // Genuine I/O failure creating the lease file (ENOSPC, EACCES, …). + // Skip this sweep rather than masking the error as "busy". + if (params.quiet !== true) { + debugLogger.debug( + 'Session janitor: lease acquisition failed, skipping sweep.', + ); + } + return emptyResult(false, false, config.maxTotalSizeBytes); + } + if (lease === null) { + if (params.quiet !== true) { + debugLogger.debug( + 'Session janitor: another process holds the lease, skipping.', + ); + } + return emptyResult(false, false, config.maxTotalSizeBytes); + } + + try { + return await performSweep(params); + } finally { + await lease.release(); + } +} + +/** Convenience wrapper that resolves retention settings from user input. */ +export async function runSessionCleanupWithSettings( + globalTempDir: string, + currentSessionId: string | undefined, + userSettings: UserRetentionSettings | undefined, + quiet?: boolean, +): Promise { + const resolved = resolveRetentionConfig(userSettings); + return runSessionCleanup({ + globalTempDir, + currentSessionId, + config: resolved, + quiet, + }); +} + +/** Perform the actual global sweep (assumes lease is already held). */ +async function performSweep( + params: SessionCleanupParams, +): Promise { + const { config, globalTempDir, currentSessionId } = params; + + // AC-5: Scan all 64-hex project-hash dirs globally. + const { candidates, chatsDirs, scanErrorCount } = await scanGlobalSessions( + globalTempDir, + currentSessionId, + ); + + // Test-only hook: pause between scan and reclamation to exercise the + // scan-to-mutation inode revalidation race. + if (scanToMutationHook !== null) { + await scanToMutationHook(); + } + + const bytesBefore = sumBytes(candidates); + const staleLocksRemoved = await runStaleLockCleanup(chatsDirs); + + // Delegate reclamation (age/count + size-driven) to the engine. + const { metrics } = await runReclamation( + candidates, + config, + bytesBefore, + globalTempDir, + currentSessionId, + ); + + const cleanupErrors = await cleanupTempAndEmptyDirs(chatsDirs); + + return buildFinalResult( + globalTempDir, + currentSessionId, + config, + candidates.length, + metrics.archived, + metrics.rawDeleted, + metrics.archiveDeleted, + staleLocksRemoved, + metrics.skipped, + metrics.failed + cleanupErrors + scanErrorCount, + metrics.ageCountShortfall, + bytesBefore, + ); +} + +/** Clean stale locks in every chats directory (AC-8). */ +async function runStaleLockCleanup( + chatsDirs: readonly string[], +): Promise { + let staleLocksRemoved = 0; + for (const chatsDir of chatsDirs) { + try { + staleLocksRemoved += + await SessionLockManager.cleanupOrphanedLocks(chatsDir); + } catch { + // Best-effort. + } + } + return staleLocksRemoved; +} + +// --------------------------------------------------------------------------- +// Temp/empty-dir cleanup + result assembly +// --------------------------------------------------------------------------- + +/** Clean stale temp archives and remove genuinely empty directories (AC-10). + * Returns the count of non-benign per-directory failures. */ +async function cleanupTempAndEmptyDirs( + chatsDirs: readonly string[], +): Promise { + let errors = 0; + for (const chatsDir of chatsDirs) { + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + try { + await cleanupStaleTempArchives(archiveDir, STALE_TEMP_ARCHIVE_AGE_MS); + } catch { + // Non-benign temp-archive cleanup error — count it. + errors++; + } + } + errors += await cleanupEmptyDirs(chatsDirs); + return errors; +} + +/** + * Remove genuinely empty chats/ and 64-hex project directories using + * non-recursive rmdir (AC-10). ENOENT/ENOTEMPTY are benign; other errors + * are counted and reported (Item 4) so diagnostics are not silently lost. + * + * The global temp root is NEVER removed (Item 1: no global-root removal). + */ +async function cleanupEmptyDirs(chatsDirs: readonly string[]): Promise { + let errors = 0; + const hashDirsToCheck = new Set(); + for (const chatsDir of chatsDirs) { + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + const archiveResult = await tryRmdir(archiveDir); + if (archiveResult.error) errors++; + const removed = await tryRmdir(chatsDir); + if (removed.error) errors++; + if (removed.removed) hashDirsToCheck.add(path.dirname(chatsDir)); + } + for (const hashDir of hashDirsToCheck) { + const result = await tryRmdir(hashDir); + if (result.error) errors++; + } + return errors; +} + +/** Non-recursive rmdir that swallows benign ENOENT/ENOTEMPTY (AC-9). */ +async function tryRmdir( + dirPath: string, +): Promise<{ removed: boolean; error: boolean }> { + try { + await platformRmdir(dirPath); + return { removed: true, error: false }; + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException).code; + // Benign: directory vanished or is not empty. + if (code === 'ENOENT' || code === 'ENOTEMPTY') { + return { removed: false, error: false }; + } + // Non-benign: platform error — retain and surface diagnostics. + return { removed: false, error: true }; + } +} + +/** Compute the final result with a fresh byte scan. */ +async function buildFinalResult( + globalTempDir: string, + currentSessionId: string | undefined, + config: ResolvedRetentionConfig, + scanned: number, + archived: number, + rawDeleted: number, + archiveDeleted: number, + staleLocksRemoved: number, + skipped: number, + failed: number, + ageCountShortfall: number, + bytesBefore: number, +): Promise { + let bytesAfter: number; + let rescanErrors = 0; + try { + const finalScan = await scanGlobalSessions(globalTempDir, currentSessionId); + bytesAfter = sumBytes(finalScan.candidates); + rescanErrors = finalScan.scanErrorCount; + } catch { + // External filesystem error during final rescan — use the last known + // bytesBefore as a conservative truthful estimate rather than a + // fabricated zero, and increment failed (Item 4). + bytesAfter = bytesBefore; + rescanErrors = 1; + } + const overBudgetBytes = Math.max(0, bytesAfter - config.maxTotalSizeBytes); + return { + disabled: false, + janitorWonLease: true, + scanned, + archived, + rawDeleted, + archiveDeleted, + staleLocksRemoved, + skipped, + failed: failed + rescanErrors, + ageCountShortfall, + bytesBefore, + bytesAfter, + configuredByteLimit: config.maxTotalSizeBytes, + overBudgetBytes, + }; +} + +/** Sum physical bytes across all candidates. */ +function sumBytes(candidates: readonly SessionCandidate[]): number { + let total = 0; + for (const c of candidates) { + total += c.sizeBytes; + } + return total; +} diff --git a/packages/core/src/recording/janitor/sessionSafety.test.ts b/packages/core/src/recording/janitor/sessionSafety.test.ts new file mode 100644 index 0000000000..e3a420e851 --- /dev/null +++ b/packages/core/src/recording/janitor/sessionSafety.test.ts @@ -0,0 +1,191 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Behavioral tests for the shared session safety primitives used by the + * janitor and lock manager to prevent path-traversal, symlink-redirect, and + * unsafe session ID attacks. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + SAFE_SESSION_ID_RE, + isValidSafeSessionId, + isDirectChildPath, + isPathContainedIn, + assertSafeLockPath, + isRegularNonSymlinkFile, + isRegularNonSymlinkDir, +} from './sessionSafety.js'; + +describe('SAFE_SESSION_ID grammar', () => { + it('accepts a standard UUID', () => { + expect(isValidSafeSessionId('550e8400-e29b-41d4-a716-446655440000')).toBe( + true, + ); + }); + + it('accepts alphanumeric, dash, and underscore identifiers', () => { + expect(isValidSafeSessionId('session-abc_123')).toBe(true); + expect(isValidSafeSessionId('a')).toBe(true); + }); + + it('rejects path separators', () => { + expect(isValidSafeSessionId('a/b')).toBe(false); + expect(isValidSafeSessionId('a\\b')).toBe(false); + }); + + it('rejects dot characters (path traversal prevention)', () => { + expect(isValidSafeSessionId('..')).toBe(false); + expect(isValidSafeSessionId('../etc/passwd')).toBe(false); + expect(isValidSafeSessionId('a.b')).toBe(false); + }); + + it('rejects empty strings and whitespace', () => { + expect(isValidSafeSessionId('')).toBe(false); + expect(isValidSafeSessionId(' ')).toBe(false); + }); + + it('rejects null bytes and control characters', () => { + expect(isValidSafeSessionId('a\x00b')).toBe(false); + expect(isValidSafeSessionId('a\nb')).toBe(false); + }); + + it('rejects identifiers exceeding the maximum length', () => { + expect(isValidSafeSessionId('a'.repeat(257))).toBe(false); + expect(isValidSafeSessionId('a'.repeat(256))).toBe(true); + }); + + it('the exported regex matches the same grammar', () => { + expect(SAFE_SESSION_ID_RE.test('valid-id_1')).toBe(true); + expect(SAFE_SESSION_ID_RE.test('../evil')).toBe(false); + }); +}); + +describe('isDirectChildPath', () => { + it('returns true for a direct child', () => { + expect(isDirectChildPath('/tmp/chats', '/tmp/chats/session-abc.lock')).toBe( + true, + ); + }); + + it('returns false for a nested grandchild', () => { + expect(isDirectChildPath('/tmp/chats', '/tmp/chats/sub/session.lock')).toBe( + false, + ); + }); + + it('returns false for a path outside the parent', () => { + expect(isDirectChildPath('/tmp/chats', '/tmp/evil/session.lock')).toBe( + false, + ); + }); + + it('returns false for path-traversal segments', () => { + expect( + isDirectChildPath('/tmp/chats', '/tmp/chats/../../../etc/passwd.lock'), + ).toBe(false); + }); +}); + +describe('isPathContainedIn', () => { + it('returns true when child is nested inside parent', () => { + expect(isPathContainedIn('/tmp/root', '/tmp/root/a/b')).toBe(true); + }); + + it('returns true when child equals parent', () => { + expect(isPathContainedIn('/tmp/root', '/tmp/root')).toBe(true); + }); + + it('returns false for a sibling outside the parent', () => { + expect(isPathContainedIn('/tmp/root', '/tmp/evil')).toBe(false); + }); + + it('returns false for a path that merely prefixes the name', () => { + expect(isPathContainedIn('/tmp/root', '/tmp/root-evil')).toBe(false); + }); +}); + +describe('assertSafeLockPath', () => { + it('does not throw for a valid direct-child lock path', () => { + expect(() => + assertSafeLockPath('/tmp/chats', '/tmp/chats/session-abc.lock'), + ).not.toThrow(); + }); + + it('throws for a lock path that escapes chatsDir', () => { + expect(() => + assertSafeLockPath('/tmp/chats', '/tmp/evil/session.lock'), + ).toThrow('Unsafe lock path'); + }); + + it('throws for a path-traversal lock path', () => { + expect(() => + assertSafeLockPath('/tmp/chats', '/tmp/chats/../../../etc/passwd.lock'), + ).toThrow('Unsafe lock path'); + }); +}); + +describe('isRegularNonSymlinkFile / isRegularNonSymlinkDir', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'safety-fs-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('isRegularNonSymlinkFile returns true for a real file', async () => { + const filePath = path.join(tempDir, 'real.txt'); + await fs.writeFile(filePath, 'data'); + expect(await isRegularNonSymlinkFile(filePath)).toBe(true); + }); + + it('isRegularNonSymlinkFile returns false for a symlink', async () => { + const target = path.join(tempDir, 'target.txt'); + await fs.writeFile(target, 'data'); + const link = path.join(tempDir, 'link.txt'); + await fs.symlink(target, link); + expect(await isRegularNonSymlinkFile(link)).toBe(false); + }); + + it('isRegularNonSymlinkFile returns false for a directory', async () => { + expect(await isRegularNonSymlinkFile(tempDir)).toBe(false); + }); + + it('isRegularNonSymlinkFile returns false for a non-existent path', async () => { + expect(await isRegularNonSymlinkFile(path.join(tempDir, 'nope.txt'))).toBe( + false, + ); + }); + + it('isRegularNonSymlinkDir returns true for a real directory', async () => { + expect(await isRegularNonSymlinkDir(tempDir)).toBe(true); + }); + + it('isRegularNonSymlinkDir returns false for a symlinked directory', async () => { + const real = path.join(tempDir, 'realdir'); + await fs.mkdir(real); + const link = path.join(tempDir, 'linkdir'); + await fs.symlink(real, link, 'dir'); + expect(await isRegularNonSymlinkDir(link)).toBe(false); + }); +}); diff --git a/packages/core/src/recording/janitor/sessionSafety.ts b/packages/core/src/recording/janitor/sessionSafety.ts new file mode 100644 index 0000000000..1acc22e62f --- /dev/null +++ b/packages/core/src/recording/janitor/sessionSafety.ts @@ -0,0 +1,160 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Shared safety primitives for the session-recording janitor and lock manager. + * + * These helpers prevent path-traversal, symlink-redirect, and unsafe-session-ID + * attacks by validating the canonical safe session-ID grammar, guaranteeing + * that lock paths are direct children of the chats directory, verifying path + * containment, and inspecting file identity with `lstat` (never following + * symlinks). + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +/** + * Canonical safe session-ID grammar. + * + * Permits uppercase/lowercase letters, digits, dashes, and underscores only. + * Explicitly rejects dots (preventing `..` traversal), path separators, + * whitespace, and control characters. This is intentionally more restrictive + * than a "looks like a UUID" check so that legacy and test identifiers + * (`session-abc_123`) remain valid while any path-like component is excluded. + * + * The maximum length of 256 is far above any real identifier (UUIDs are 36 + * characters) yet small enough to reject absurd values. + */ + +/** Maximum allowed session-ID length (single source of truth). */ +const SAFE_SESSION_ID_MAX_LENGTH = 256; + +export const SAFE_SESSION_ID_RE = new RegExp( + `^[A-Za-z0-9_-]{1,${SAFE_SESSION_ID_MAX_LENGTH}}$`, +); + +/** + * Return `true` when `id` matches the canonical safe session-ID grammar. + * + * Any unsafe/path-like identifier (containing dots, slashes, backslashes, + * whitespace, or control characters) is rejected. Callers use this to make + * unsafe recordings unreadable/protected and to guarantee lock paths never + * escape the chats directory. + */ +export function isValidSafeSessionId(id: string): boolean { + return SAFE_SESSION_ID_RE.test(id); +} + +/** + * Normalize a path for comparison by resolving `.` and `..` segments without + * touching the filesystem. This is used purely for lexical containment + * checks so that traversal segments are collapsed before comparison. + * + * A trailing separator is stripped from non-root paths so prefix-based + * containment checks work correctly when the parent path ends with a + * separator, while filesystem roots remain intact. + */ +function normalizeLexical(p: string): string { + const normalized = path.normalize(p); + if ( + normalized !== path.parse(normalized).root && + normalized.endsWith(path.sep) + ) { + return normalized.slice(0, -1); + } + return normalized; +} + +/** + * Return `true` when `childPath` is a direct child file of `parentDir`. + * + * A direct child has no intermediate directory between itself and the parent. + * Path-traversal segments (`..`) are collapsed and rejected. + */ +export function isDirectChildPath( + parentDir: string, + childPath: string, +): boolean { + const normalizedParent = normalizeLexical(parentDir); + const normalizedChild = normalizeLexical(childPath); + const parentWithSep = normalizedParent + path.sep; + if (!normalizedChild.startsWith(parentWithSep)) return false; + const remainder = normalizedChild.slice(parentWithSep.length); + // Reject any remaining path separators — must be a direct child. + return !remainder.includes(path.sep) && remainder.length > 0; +} + +/** + * Return `true` when `childPath` is equal to or nested inside `parentDir`. + * + * Uses lexical normalization to collapse traversal segments. A path that + * merely prefixes the parent name (e.g. `/tmp/root` vs `/tmp/root-evil`) is + * correctly rejected. + */ +export function isPathContainedIn( + parentDir: string, + childPath: string, +): boolean { + const normalizedParent = normalizeLexical(parentDir); + const normalizedChild = normalizeLexical(childPath); + if (normalizedChild === normalizedParent) return true; + return normalizedChild.startsWith(normalizedParent + path.sep); +} + +/** + * Assert that `lockPath` is a safe direct child of `chatsDir`. + * + * @throws {Error} when the lock path escapes the chats directory or is not a + * direct child. + */ +export function assertSafeLockPath(chatsDir: string, lockPath: string): void { + if (!isDirectChildPath(chatsDir, lockPath)) { + throw new Error( + `Unsafe lock path "${lockPath}" is not a direct child of "${chatsDir}"`, + ); + } +} + +/** + * Return `true` when the path exists and is a regular file that is **not** a + * symlink, using `lstat` so symlinks are never followed. + */ +export async function isRegularNonSymlinkFile( + filePath: string, +): Promise { + try { + const stat = await fs.lstat(filePath); + return stat.isFile() && !stat.isSymbolicLink(); + } catch { + return false; + } +} + +/** + * Return `true` when the path exists and is a directory that is **not** a + * symlink, using `lstat` so symlinked directories are rejected. + */ +export async function isRegularNonSymlinkDir( + dirPath: string, +): Promise { + try { + const stat = await fs.lstat(dirPath); + return stat.isDirectory() && !stat.isSymbolicLink(); + } catch { + return false; + } +} diff --git a/packages/core/src/recording/janitor/sessionScanner.test.ts b/packages/core/src/recording/janitor/sessionScanner.test.ts new file mode 100644 index 0000000000..4e0541dead --- /dev/null +++ b/packages/core/src/recording/janitor/sessionScanner.test.ts @@ -0,0 +1,428 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Behavioral tests for the global session scanner (AC-5, AC-10). + * + * Creates real project-hash directories with real JSONL session files and + * verifies the scanner discovers them globally. Covers multiple hash dirs, + * non-hash dirs, symlinks, unknown files, and archive scanning. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { scanGlobalSessions, ARCHIVE_DIR_NAME } from './sessionScanner.js'; +import { SessionRecordingService } from '../SessionRecordingService.js'; +import type { SessionRecordingServiceConfig } from '../types.js'; + +async function makeTempDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'janitor-scan-')); +} + +function validHash64(): string { + return crypto.randomUUID().replace(/-/g, '').repeat(2).slice(0, 64); +} + +function makeConfig(chatsDir: string): SessionRecordingServiceConfig { + return { + sessionId: 'session-' + crypto.randomUUID(), + projectHash: validHash64(), + chatsDir, + workspaceDirs: [chatsDir], + provider: 'test', + model: 'test', + }; +} + +async function createSessionFile( + chatsDir: string, + overrides: Partial = {}, +): Promise<{ filePath: string; sessionId: string }> { + const config = { ...makeConfig(chatsDir), ...overrides }; + const svc = new SessionRecordingService(config); + try { + svc.recordContent({ + speaker: 'human', + blocks: [{ type: 'text', text: 'test message' }], + }); + await svc.flush(); + } finally { + await svc.dispose(); + } + const filePath = svc.getFilePath(); + if (!filePath) throw new Error('No file path'); + return { filePath, sessionId: config.sessionId }; +} + +async function createArchiveFile( + archiveDir: string, + fileName: string, + content: string, +): Promise { + await fs.mkdir(archiveDir, { recursive: true }); + const filePath = path.join(archiveDir, fileName); + await fs.writeFile(filePath, content); + return filePath; +} + +describe('scanGlobalSessions — raw session discovery', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('discovers real SessionRecordingService files', async () => { + const hash1 = validHash64(); + const chatsDir1 = path.join(tempDir, hash1, 'chats'); + await fs.mkdir(chatsDir1, { recursive: true }); + const { filePath, sessionId } = await createSessionFile(chatsDir1); + + const { candidates, chatsDirs } = await scanGlobalSessions(tempDir); + + expect(candidates.length).toBe(1); + expect(candidates[0].filePath).toBe(filePath); + expect(candidates[0].sessionId).toBe(sessionId); + expect(candidates[0].kind).toBe('raw'); + expect(chatsDirs).toContain(chatsDir1); + }); + + it('scans multiple 64-hex project dirs globally (AC-5)', async () => { + const hash1 = validHash64(); + const hash2 = validHash64(); + const chats1 = path.join(tempDir, hash1, 'chats'); + const chats2 = path.join(tempDir, hash2, 'chats'); + await fs.mkdir(chats1, { recursive: true }); + await fs.mkdir(chats2, { recursive: true }); + + await createSessionFile(chats1); + await createSessionFile(chats2); + + const { candidates } = await scanGlobalSessions(tempDir); + expect(candidates.filter((c) => c.kind === 'raw').length).toBe(2); + }); + + it('ignores non-64-hex top-level directories', async () => { + const hash1 = validHash64(); + const chats1 = path.join(tempDir, hash1, 'chats'); + await fs.mkdir(chats1, { recursive: true }); + await createSessionFile(chats1); + + // Non-hash dir. + await fs.mkdir(path.join(tempDir, 'not-a-hash', 'chats'), { + recursive: true, + }); + await fs.writeFile( + path.join(tempDir, 'not-a-hash', 'chats', 'session-fake.jsonl'), + JSON.stringify({ type: 'session_start', payload: { sessionId: 'x' } }) + + '\n', + ); + + const { candidates } = await scanGlobalSessions(tempDir); + expect(candidates.length).toBe(1); // Only the real one in hash1. + }); + + it('ignores uppercase hex dirs', async () => { + const upperHash = 'A'.repeat(64); + await fs.mkdir(path.join(tempDir, upperHash, 'chats'), { recursive: true }); + await fs.writeFile( + path.join(tempDir, upperHash, 'chats', 'session-fake.jsonl'), + 'data\n', + ); + + const { candidates } = await scanGlobalSessions(tempDir); + expect(candidates.length).toBe(0); + }); + + it('ignores non-jsonl files in chats dir', async () => { + const hash1 = validHash64(); + const chats1 = path.join(tempDir, hash1, 'chats'); + await fs.mkdir(chats1, { recursive: true }); + await createSessionFile(chats1); + + // Unknown files should not be discovered. + await fs.writeFile(path.join(chats1, 'logs.json'), '{}'); + await fs.writeFile(path.join(chats1, 'shell_history'), 'history'); + await fs.writeFile(path.join(chats1, 'token-usage.json'), '{}'); + await fs.mkdir(path.join(chats1, 'checkpoints'), { recursive: true }); + + const { candidates } = await scanGlobalSessions(tempDir); + expect(candidates.length).toBe(1); // Only the real session. + }); + + it('marks the current session correctly', async () => { + const hash1 = validHash64(); + const chats1 = path.join(tempDir, hash1, 'chats'); + await fs.mkdir(chats1, { recursive: true }); + const { sessionId } = await createSessionFile(chats1); + + const { candidates } = await scanGlobalSessions(tempDir, sessionId); + expect(candidates[0].isCurrentSession).toBe(true); + }); + + it('does not mark non-matching sessions as current', async () => { + const hash1 = validHash64(); + const chats1 = path.join(tempDir, hash1, 'chats'); + await fs.mkdir(chats1, { recursive: true }); + await createSessionFile(chats1); + + const { candidates } = await scanGlobalSessions( + tempDir, + 'different-session-id', + ); + expect(candidates[0].isCurrentSession).toBe(false); + }); + + it('isCurrentSession is false when both parsed and current IDs are absent', async () => { + const hash1 = validHash64(); + const chats1 = path.join(tempDir, hash1, 'chats'); + await fs.mkdir(chats1, { recursive: true }); + + // Create a session file whose header has NO sessionId (malformed but + // parseable JSON) so the scanner reports sessionId=null. + await fs.writeFile( + path.join(chats1, 'session-noid.jsonl'), + JSON.stringify({ type: 'session_start', payload: {} }) + '\n', + ); + + // Scan without a currentSessionId — both sides are absent. + const { candidates } = await scanGlobalSessions(tempDir); + expect(candidates.length).toBe(1); + expect(candidates[0].sessionId).toBeNull(); + expect(candidates[0].isCurrentSession).toBe(false); + }); +}); + +describe('scanGlobalSessions — archive discovery', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('discovers gzip archives under chats/archive/ (AC-4)', async () => { + const hash1 = validHash64(); + const chats1 = path.join(tempDir, hash1, 'chats'); + const archiveDir = path.join(chats1, ARCHIVE_DIR_NAME); + await fs.mkdir(archiveDir, { recursive: true }); + await createArchiveFile( + archiveDir, + 'session-2026-01-01T00-00-00-abc123.jsonl.gz', + 'compressed-data', + ); + + const { candidates } = await scanGlobalSessions(tempDir); + const archives = candidates.filter((c) => c.kind === 'archive'); + expect(archives.length).toBe(1); + expect(archives[0].fileName).toBe( + 'session-2026-01-01T00-00-00-abc123.jsonl.gz', + ); + }); + + it('counts both raw and archive sizes toward the global budget', async () => { + const hash1 = validHash64(); + const chats1 = path.join(tempDir, hash1, 'chats'); + const archiveDir = path.join(chats1, ARCHIVE_DIR_NAME); + await fs.mkdir(chats1, { recursive: true }); + await fs.mkdir(archiveDir, { recursive: true }); + + await createSessionFile(chats1); + await createArchiveFile( + archiveDir, + 'session-2026-01-01T00-00-00-old.jsonl.gz', + 'archive-content', + ); + + const { candidates } = await scanGlobalSessions(tempDir); + const raws = candidates.filter((c) => c.kind === 'raw'); + const archives = candidates.filter((c) => c.kind === 'archive'); + expect(raws.length).toBe(1); + expect(archives.length).toBe(1); + + const totalBytes = candidates.reduce((sum, c) => sum + c.sizeBytes, 0); + expect(totalBytes).toBeGreaterThan(0); + }); +}); + +describe('scanGlobalSessions — symlink safety (AC-10)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it.skipIf(process.platform === 'win32')( + 'does not follow symlinks to project-hash dirs', + async () => { + const hash1 = validHash64(); + const realChats = path.join(tempDir, hash1, 'chats'); + await fs.mkdir(realChats, { recursive: true }); + await createSessionFile(realChats); + + // Create a symlink that looks like a hash dir pointing outside. + const symlinkHash = validHash64(); + const symlinkPath = path.join(tempDir, symlinkHash); + await fs.symlink(tempDir, symlinkPath, 'dir'); + + const { candidates } = await scanGlobalSessions(tempDir); + // Should find sessions but not double-count through the symlink. + const raws = candidates.filter((c) => c.kind === 'raw'); + expect(raws.length).toBe(1); + }, + ); + it.skipIf(process.platform === 'win32')( + 'does not follow a symlinked chats directory (AC-10)', + async () => { + const hash = validHash64(); + const hashDir = path.join(tempDir, hash); + await fs.mkdir(hashDir, { recursive: true }); + + // A real chats dir with a session elsewhere. + const realChats = path.join(tempDir, 'real-chats'); + await fs.mkdir(realChats, { recursive: true }); + await createSessionFile(realChats); + + // Replace chats with a symlink to the outside dir. + await fs.symlink(realChats, path.join(hashDir, 'chats'), 'dir'); + + const { candidates } = await scanGlobalSessions(tempDir); + // The symlinked chats dir must not be traversed. + expect(candidates.length).toBe(0); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'does not follow a symlinked archive directory (AC-10)', + async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // A real archive dir outside the tree with a fake archive file. + const outsideArchive = path.join(tempDir, 'outside-archive'); + await fs.mkdir(outsideArchive, { recursive: true }); + await fs.writeFile( + path.join(outsideArchive, 'session-fake.jsonl.gz'), + 'data', + ); + + // Symlink archive -> outside. + await fs.symlink(outsideArchive, path.join(chatsDir, 'archive'), 'dir'); + + const { candidates } = await scanGlobalSessions(tempDir); + // The symlinked archive must not be traversed. + const archives = candidates.filter((c) => c.kind === 'archive'); + expect(archives.length).toBe(0); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'does not follow a symlinked archive file (AC-10)', + async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + const archiveDir = path.join(chatsDir, 'archive'); + await fs.mkdir(archiveDir, { recursive: true }); + + // A target file outside. + const target = path.join(tempDir, 'secret.txt'); + await fs.writeFile(target, 'secret'); + + // Symlink a fake archive file to the outside target. + await fs.symlink(target, path.join(archiveDir, 'session-fake.jsonl.gz')); + + const { candidates } = await scanGlobalSessions(tempDir); + const archives = candidates.filter((c) => c.kind === 'archive'); + expect(archives.length).toBe(0); + }, + ); + + /** + * OCR finding 38: when the archive entry is a regular file (not a + * directory), the scanner must skip it without crashing (ENOTDIR). + */ + it('skips an archive path that is a regular file, not a directory (OCR 38)', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Create a regular FILE named "archive" instead of a directory. + await fs.writeFile(path.join(chatsDir, ARCHIVE_DIR_NAME), 'not-a-dir'); + + const { candidates, scanErrorCount } = await scanGlobalSessions(tempDir); + // No crash, no archives discovered. + const archives = candidates.filter((c) => c.kind === 'archive'); + expect(archives.length).toBe(0); + expect(scanErrorCount).toBe(0); + }); + + /** + * OCR finding 39: non-ENOENT errors during archive directory scanning + * (e.g. EACCES) must be counted in scanErrorCount, not silently treated + * as "directory doesn't exist". + */ + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'counts non-ENOENT archive readdir errors in scanErrorCount (OCR 39)', + async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + await fs.mkdir(archiveDir, { recursive: true }); + await fs.writeFile( + path.join(archiveDir, 'session-real.jsonl.gz'), + 'data', + ); + + // Remove read+execute permission on the archive dir so readdir fails + // with EACCES (we are not root). + await fs.chmod(archiveDir, 0o000); + + try { + const { scanErrorCount } = await scanGlobalSessions(tempDir); + expect(scanErrorCount).toBeGreaterThanOrEqual(1); + } finally { + await fs.chmod(archiveDir, 0o755); + } + }, + ); + + /** + * OCR finding 39: ENOENT races remain benign (not counted as errors). + */ + it('does not count ENOENT archive races as scan errors', async () => { + const hash = validHash64(); + const chatsDir = path.join(tempDir, hash, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + // No archive directory exists — ENOENT is benign. + + const { scanErrorCount } = await scanGlobalSessions(tempDir); + expect(scanErrorCount).toBe(0); + }); +}); diff --git a/packages/core/src/recording/janitor/sessionScanner.ts b/packages/core/src/recording/janitor/sessionScanner.ts new file mode 100644 index 0000000000..1d7fff10d5 --- /dev/null +++ b/packages/core/src/recording/janitor/sessionScanner.ts @@ -0,0 +1,297 @@ +/** + * Copyright 2026 Vybestack LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Global session scanner for the janitor. + * + * Scans every direct child of `Storage.getGlobalTempDir()` whose name is + * exactly a 64-character lowercase hexadecimal project hash. Within each + * project hash directory it discovers `chats/session-*.jsonl` (raw recordings) + * and `chats/archive/session-*.jsonl.gz` (cold archives). It uses bounded + * concurrency and the canonical header reader — never reading entire files + * into memory. + * + * Blast-radius safety (AC-10): symlinks are never followed; unknown files are + * ignored; non-hash directories are skipped. + */ + +import * as fs from 'node:fs/promises'; +import type { Stats } from 'node:fs'; +import * as path from 'node:path'; +import type { SessionCandidate } from './cleanupTypes.js'; +import { readSessionJsonlHeader } from './sessionHeaderReader.js'; + +/** Regex matching a 64-character lowercase hex project hash directory name. */ +const PROJECT_HASH_RE = /^[0-9a-f]{64}$/; + +/** Bounded concurrency for stat/header operations within a directory. */ +const MAX_CONCURRENT_OPS = 8; + +/** Archive sub-directory name inside a chats directory. */ +export const ARCHIVE_DIR_NAME = 'archive'; + +/** Prefix and suffix for raw session recordings. */ +const SESSION_PREFIX = 'session-'; +const SESSION_JSONL_SUFFIX = '.jsonl'; +const ARCHIVE_SUFFIX = '.jsonl.gz'; + +/** Result of scanning the global temp tree. */ +export interface ScanResult { + readonly candidates: readonly SessionCandidate[]; + readonly chatsDirs: readonly string[]; + /** Number of non-benign (non-ENOENT) per-project scan failures (OCR 38/39). */ + readonly scanErrorCount: number; +} + +/** Internal batch result from scanning a single directory. */ +interface ScanBatch { + readonly candidates: SessionCandidate[]; + readonly errors: number; +} + +const EMPTY_BATCH: ScanBatch = { candidates: [], errors: 0 }; + +/** + * Determine whether a file uses allocated blocks and return the allocated + * size when available, falling back to file length otherwise. On filesystems + * that do not expose blocks (e.g. some Windows setups), this returns + * `stat.size`. + */ +function getFileSize(stat: Stats): number { + const allocated = + typeof stat.blocks === 'number' && stat.blksize > 0 ? stat.blocks * 512 : 0; + return allocated > 0 ? allocated : stat.size; +} + +/** + * Run an async mapper over an array with a bounded number of concurrent + * operations, preserving the input order in the output. + */ +async function boundedMap( + items: readonly T[], + limit: number, + mapper: (item: T) => Promise, +): Promise { + const results = new Array(items.length); + let nextIndex = 0; + const worker = async (): Promise => { + while (nextIndex < items.length) { + const i = nextIndex++; + results[i] = await mapper(items[i]); + } + }; + const workerCount = Math.min(limit, items.length); + await Promise.all(Array.from({ length: workerCount }, worker)); + return results; +} + +/** + * Scan the global temp directory tree for all session recordings and cold + * archives across every 64-hex project-hash directory. + * + * @param globalTempDir - The return value of `Storage.getGlobalTempDir()`. + * @param currentSessionId - The current process's session ID (to mark as active). + * @returns Discovered candidates and the list of chats directories found. + */ +export async function scanGlobalSessions( + globalTempDir: string, + currentSessionId?: string, +): Promise { + let topEntries: string[]; + try { + topEntries = await fs.readdir(globalTempDir); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { candidates: [], chatsDirs: [], scanErrorCount: 0 }; + } + throw error; + } + + const hashDirs = topEntries.filter((name) => PROJECT_HASH_RE.test(name)); + const chatsDirs: string[] = []; + const allCandidates: SessionCandidate[] = []; + let scanErrorCount = 0; + + // Process project-hash directories with bounded concurrency. + await boundedMap(hashDirs, MAX_CONCURRENT_OPS, async (hashDirName) => { + const hashDirPath = path.join(globalTempDir, hashDirName); + const chatsDir = path.join(hashDirPath, 'chats'); + + let lstat: Stats; + try { + lstat = await fs.lstat(hashDirPath); + } catch { + return; // vanished between readdir and lstat — benign. + } + // Never follow symlinks (AC-10). + if (lstat.isSymbolicLink()) return; + if (!lstat.isDirectory()) return; + + // Never follow symlinks at any directory boundary (AC-10). The chats + // directory itself must be a real directory, not a symlink pointing + // outside the project hash tree. + let chatsExists = true; + try { + const chatsLstat = await fs.lstat(chatsDir); + if (chatsLstat.isSymbolicLink() || !chatsLstat.isDirectory()) { + chatsExists = false; + } + } catch { + chatsExists = false; + } + if (!chatsExists) return; + + chatsDirs.push(chatsDir); + + const rawBatch = await scanRawSessions( + chatsDir, + hashDirName, + currentSessionId, + ); + allCandidates.push(...rawBatch.candidates); + scanErrorCount += rawBatch.errors; + + const archiveDir = path.join(chatsDir, ARCHIVE_DIR_NAME); + const archiveBatch = await scanArchiveSessions(archiveDir, hashDirName); + allCandidates.push(...archiveBatch.candidates); + scanErrorCount += archiveBatch.errors; + }); + + return { candidates: allCandidates, chatsDirs, scanErrorCount }; +} + +/** Scan `session-*.jsonl` files inside a chats directory. */ +async function scanRawSessions( + chatsDir: string, + projectHashDir: string, + currentSessionId?: string, +): Promise { + let files: string[]; + try { + files = await fs.readdir(chatsDir); + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return EMPTY_BATCH; + // Non-ENOENT error (EACCES, EIO, …) — count as a scan failure (OCR 38/39). + return { candidates: [], errors: 1 }; + } + + const sessionFiles = files.filter( + (f) => + f.startsWith(SESSION_PREFIX) && + f.endsWith(SESSION_JSONL_SUFFIX) && + !f.endsWith(ARCHIVE_SUFFIX), + ); + + let perFileErrors = 0; + const candidates = ( + await boundedMap(sessionFiles, MAX_CONCURRENT_OPS, async (fileName) => { + const filePath = path.join(chatsDir, fileName); + try { + const lstat = await fs.lstat(filePath); + if (lstat.isSymbolicLink()) return null; + if (!lstat.isFile()) return null; + const header = await readSessionJsonlHeader(filePath); + const candidate: SessionCandidate = { + kind: 'raw', + filePath, + fileName, + containerDir: chatsDir, + projectHashDir, + sessionId: header?.sessionId ?? null, + isCurrentSession: + header?.sessionId != null && + currentSessionId != null && + header.sessionId === currentSessionId, + sizeBytes: getFileSize(lstat), + mtime: lstat.mtime, + dev: lstat.dev, + ino: lstat.ino, + }; + return candidate; + } catch { + perFileErrors++; + return null; + } + }) + ).filter((c): c is SessionCandidate => c !== null); + + return { candidates, errors: perFileErrors }; +} + +/** Scan `session-*.jsonl.gz` archive files inside a chats/archive directory. */ +async function scanArchiveSessions( + archiveDir: string, + projectHashDir: string, +): Promise { + // Never follow a symlinked archive directory, and require a real directory + // (not a regular file) so readdir does not throw ENOTDIR (AC-10, OCR 38). + try { + const archiveLstat = await fs.lstat(archiveDir); + if (archiveLstat.isSymbolicLink() || !archiveLstat.isDirectory()) { + return EMPTY_BATCH; + } + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return EMPTY_BATCH; + // Non-ENOENT error (EACCES, EIO, …) — count as a scan failure (OCR 39). + return { candidates: [], errors: 1 }; + } + + let files: string[]; + try { + files = await fs.readdir(archiveDir); + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return EMPTY_BATCH; + return { candidates: [], errors: 1 }; + } + + const archiveFiles = files.filter( + (f) => f.startsWith(SESSION_PREFIX) && f.endsWith(ARCHIVE_SUFFIX), + ); + + let perFileErrors = 0; + const candidates = ( + await boundedMap(archiveFiles, MAX_CONCURRENT_OPS, async (fileName) => { + const filePath = path.join(archiveDir, fileName); + try { + const lstat = await fs.lstat(filePath); + if (lstat.isSymbolicLink()) return null; + if (!lstat.isFile()) return null; + const candidate: SessionCandidate = { + kind: 'archive', + filePath, + fileName, + containerDir: archiveDir, + projectHashDir, + sessionId: null, + isCurrentSession: false, + sizeBytes: getFileSize(lstat), + mtime: lstat.mtime, + dev: lstat.dev, + ino: lstat.ino, + }; + return candidate; + } catch { + perFileErrors++; + return null; + } + }) + ).filter((c): c is SessionCandidate => c !== null); + + return { candidates, errors: perFileErrors }; +} diff --git a/packages/test-utils/src/interactive-run.test.ts b/packages/test-utils/src/interactive-run.test.ts index fbda0604b3..ce3c95e866 100644 --- a/packages/test-utils/src/interactive-run.test.ts +++ b/packages/test-utils/src/interactive-run.test.ts @@ -345,6 +345,7 @@ describe('InteractiveRun quota guard integration', () => { keepAliveScript(quotaSignal), true, ); + await run.expectText('HTTP 429 Too Many Requests', 5000); // Buffer the quota signal before testing expectExit's timeout-path scan. await run.expectText(quotaSignal, 5000); diff --git a/project-plans/issue3164/plan.md b/project-plans/issue3164/plan.md new file mode 100644 index 0000000000..0b830b7176 --- /dev/null +++ b/project-plans/issue3164/plan.md @@ -0,0 +1,319 @@ +# Issue #3164 — Functional, bounded, concurrency-safe session cleanup + +Plan ID: PLAN-20260808-SESSION-CLEANUP + +## 1. Accepted behavior + +### AC-1 — Cleanup discovers the recorder's real format + +Cleanup scans `session-*.jsonl` recordings produced by `SessionRecordingService` and +uses the same canonical JSONL header reader as session discovery/resume. It does not +parse recordings as legacy whole-file `ConversationRecord` JSON. + +A behavioral contract test creates a session through the real recorder and proves that +cleanup discovers it. The test must fail if recorder and cleanup filename or header +handling drift again. BOM-prefixed and first-line headers larger than the old fixed +4096-byte read are covered. + +The obsolete CLI-only legacy `.json` reader in `packages/cli/src/utils/sessionUtils.ts` +and legacy cleanup test helpers are removed once no production caller remains. + +### AC-2 — Default-on, global size retention + +With no `sessionRetention` setting, automatic cleanup is enabled with: + +- `maxTotalSizeMB: 4096`, defined as 4096 MiB = 4 GiB; +- no default `maxAge`; +- no default `maxCount`; +- `minRetention: "1d"` as the safety floor. + +`sessionRetention.enabled: false` disables all janitorial filesystem mutations. + +The size budget is machine-wide across all recognized session recordings and cold +archives beneath every 64-hex project directory under `Storage.getGlobalTempDir()`. +It is not a per-project limit. Both raw JSONL and gzip archive physical bytes count. +Where allocated block information is available, cleanup uses it; otherwise, including +Windows filesystems that do not expose blocks, it uses file length. + +User-provided `sessionRetention` objects are resolved over defaults at the consumer so +a partial object cannot accidentally remove default-on size bounding. + +### AC-3 — Optional age and count retention + +Users may explicitly configure `maxAge` and `maxCount`. Neither is supplied by default. +A defaults-only run below the size budget retains an otherwise eligible recording even +if it is years old. + +Explicit age and count limits preserve their retention meaning: once the minimum +retention floor and live-data protections are satisfied, recordings outside an explicit +age/count limit may be removed. The limits apply globally, matching the global sweep. +Invalid retention values fail validation clearly; they are not silently normalized into +a different policy. + +### AC-4 — Cold lossless archive before size-driven deletion + +For size-driven reclamation, eligible inactive JSONL recordings are losslessly gzip +compressed before any session history is deleted. Archives use a standard gzip format +under a non-recursed `chats/archive/` directory and preserve the original JSONL bytes +for long-term telemetry and offline analysis. + +Cold archives are intentionally not listed by `/continue` or the session browser and +are not appendable. Restoring one is a manual/offline operation in this PR. No live +recording, replay, checkpoint, or mutation path is changed to treat gzip as the active +recording format. + +Compression uses built-in streaming zlib with bounded memory and no new dependency. +The lifecycle is crash safe: + +1. Stream the source into a unique temporary gzip file in the destination directory. +2. Close and durably flush the temporary file where supported. +3. Stream-decompress and verify byte count and SHA-256 identity against the source. +4. Atomically rename the verified temporary file to its final archive name. +5. Only then unlink the source while holding exclusive session ownership. + +At every interruption point at least one intact copy remains. Stale temporary artifacts +are recognizable, ignored by normal readers, and removed only by the elected janitor +after a conservative age threshold. + +Archives count toward the same 4 GiB budget and are evicted oldest-first only after +eligible raw recordings have been compressed. This keeps total disk usage bounded while +retaining substantially more original history than direct JSONL deletion. If the +compressed corpus itself exceeds the configured budget, the oldest eligible cold +archives are deleted until the budget is met. + +### AC-5 — Global reach without unsafe project inference + +The elected janitor scans every direct child of `Storage.getGlobalTempDir()` whose name +is exactly a 64-character lowercase hexadecimal project hash. It does not infer project +ownership or orphan status from recording `workspaceDirs`; that metadata does not +reliably identify the project root that produced the directory. + +Oldest eligible sessions are reclaimed globally with deterministic tie breaking. A run +started from one project may therefore reclaim old eligible recordings from another +project; this is required for one machine-wide bound and for reaching abandoned project +hash directories. + +The janitor never recursively deletes a project directory. It removes `chats/` and a +project hash directory only through non-recursive empty-directory removal after a fresh +emptiness check. + +### AC-6 — Single cross-process janitor without a service or socket + +Concurrent LLxprt startups use a filesystem-only lease in the global temp directory. +Lease acquisition uses atomic exclusive creation. The lease carries a random owner +token, PID, hostname, and creation time. + +- Exactly one normally concurrent starter wins and performs the full sweep. +- Non-winners detect the live lease, skip cleanup immediately, and continue startup. +- The winner heartbeats during a long sweep. +- Release removes the lease only when its on-disk owner token still matches. +- A crashed winner cannot disable cleanup forever: stale takeover uses filesystem + identity checks, hostname-aware PID liveness as an accelerator, and a fixed age bound + that PID reuse cannot extend indefinitely. +- Any ambiguous or platform-specific lease error fails toward skipping cleanup. +- Even if a pathological stale-takeover race allows overlapping sweep work, each + destructive session operation remains independently ownership-safe and idempotent. + +This is an internal implementation detail, not a new public abstraction or IPC service. + +### AC-7 — Active-session and resume safety + +Cleanup never archives or deletes: + +- the process's current session ID; +- a recording with a live session lock; +- a recording newer than `minRetention`; +- an unreadable recording whose full session identity and lock ownership cannot be + established safely. + +Before moving or unlinking a raw recording, cleanup acquires exclusive ownership through +`SessionLockManager` and revalidates the candidate after acquisition. A prior PID check +alone is insufficient because lock acquisition/replacement can race cleanup. + +If another process wins the session lock, the candidate is retained. A process that has +not yet acquired the lock for a pending resume target cannot reserve that target merely +through intent; the minimum-retention floor and ownership-at-deletion contract provide +the bounded startup protection without inventing broader coordination. + +If protected or unreadable data prevents reaching the configured budget, cleanup retains +it and reports the remaining over-budget bytes rather than risking user data. + +### AC-8 — Stale session locks are actually cleaned + +Startup's elected global sweep invokes stale-lock cleanup in every recognized chats +directory. It reuses the existing PID-reuse-aware 48-hour lock predicate rather than +inventing a second session-lock age rule. + +Stale-lock takeover, deletion, and release are hardened against ownership replacement. +A cleanup process never removes a lock that another process replaced or acquired after +the stale determination. Live-PID locks whose JSONL file has not materialized are kept. + +### AC-9 — Cross-platform, bounded-resource behavior + +The implementation works on Windows, macOS, and Linux using Node/Bun filesystem and zlib +APIs already available in the repository. + +- Directory/header/stat work uses bounded concurrency; it never launches an unbounded + `Promise.all` over the corpus. +- Header discovery does not read entire recordings. +- Compression and verification are streaming and bounded-memory. +- Files are compressed serially or with a deliberately small fixed concurrency. +- Same-directory temporary files make final rename same-filesystem. +- `ENOENT` from a concurrent unlink/rmdir is benign. +- Windows `EPERM`, `EACCES`, `EBUSY`, antivirus interference, and rename failures retain + the candidate and do not abort CLI startup. +- `ENOTEMPTY` during empty-directory removal retains the directory. +- Cleanup remains best-effort for external filesystem failures and is awaited at the + existing startup integration point. + +The full global metadata sweep is retained as accepted behavior; there is no cursor, +per-project reachability heuristic, or arbitrary wall-clock cutoff that can permanently +leave old project directories unreachable. + +### AC-10 — Strict deletion blast radius + +Cleanup may remove only: + +- selected `session-*.jsonl` recordings after session-lock ownership is acquired; +- cold `archive/session-*.jsonl.gz` files selected by the retention policy; +- its own stale temporary/archive artifacts; +- safely stale session `*.lock` files; +- its owner-checked global janitor lease; +- genuinely empty `chats/` and 64-hex project directories using non-recursive removal. + +It never follows symlinks and never derives a deletion target from recording content. +It never touches checkpoints outside the recording, `logs.json`, backups, +`shell_history`, debug logs, token-usage data, OTEL data, performance logs, or unknown +entries. Existing cleanup behavior that deletes similarly named debug files is removed +rather than carried into the session-recording janitor. + +### AC-11 — Result and diagnostics + +Cleanup reports enough structured result information to prove and diagnose behavior: +scanned recordings, raw recordings archived, raw/archive files deleted, stale locks +removed, skipped/protected candidates, failures, bytes before/after, configured byte +limit, remaining over-budget bytes, and whether this process won or skipped the janitor +lease. + +External filesystem failures are logged and counted without stopping startup. Internal +configuration errors are surfaced clearly rather than swallowed. + +## 2. Behavioral evidence + +All new or changed tests use Bun and `bun:test`. Cleanup tests use real temporary +filesystems rather than filesystem mocks or mock-call assertions. + +### Writer/reader contract + +- Create recordings with the real `SessionRecordingService` and prove cleanup scans them. +- Cover ordinary JSONL, UTF-8 BOM, and a valid first header line beyond 4096 bytes. +- Prove the legacy `.json` reader is no longer in the cleanup path. + +### Retention behavior + +- Defaults are enabled, use 4 GiB, and do not age-delete an old under-budget session. +- A partial configuration retains unspecified defaults. +- Explicit `maxAge`, `maxCount`, `minRetention`, and small injected size budgets exercise + deterministic boundaries without allocating multi-gigabyte fixtures. +- The global budget includes raw JSONL and gzip archives across multiple hash dirs. +- Current, recently created, live-locked, and identity-unreadable recordings survive. +- Protected data exceeding the budget yields a reported shortfall. + +### Compression and crash recovery + +- Gzip round-trip bytes and SHA-256 are identical to the recorder-produced JSONL. +- Source unlink occurs only after archive verification and final rename. +- Interrupted states before rename and between rename/unlink retain an intact copy and + converge safely on the next sweep. +- Truncated or unverifiable gzip leaves the source untouched. +- Large incompressible and compressible fixtures prove bounded-memory streaming. +- Archive files remain available to standard gzip/offline analysis while discovery and + `/continue` intentionally ignore them. + +### Concurrency and locks + +- Real concurrent subprocesses compete for one janitor lease and exactly one normal + winner mutates a marker corpus. +- A busy lease causes immediate skip with no cleanup mutation. +- Owner-token mismatch prevents release from removing another process's lease. +- A killed lease holder is eventually reclaimable, including a simulated reused PID. +- Concurrent session-lock acquisition versus archive/delete retains the session unless + the janitor acquired exclusive ownership first. +- Competing stale-lock cleanup cannot unlink a replacement live lock. +- Concurrent `ENOENT` and `ENOTEMPTY` outcomes are benign. + +### Traversal and platform boundaries + +- Multiple 64-hex project dirs are swept globally. +- Non-hash top-level dirs, symlinks, nested unknown files, `token-usage/`, and `otel/` + remain byte-identical. +- Empty directories are removed non-recursively; repopulated directories survive. +- Platform-specific allocated-size fallback and Windows busy/permission behavior are + tested without weakening assertions on supported platforms. + +## 3. Test-first implementation sequence + +1. Replace legacy cleanup tests with real recorder/filesystem discovery tests and observe + them fail against the `.json` reader. +2. Unify cleanup discovery with the canonical JSONL header reader; remove the dead legacy + reader only after its callers are gone. +3. Add settings/default/validation tests, then implement default-on 4 GiB global policy + resolution with optional explicit age/count. +4. Add real cross-process lease tests, then implement the internal filesystem janitor + election and stale recovery. +5. Add lock-race tests, then harden session-lock stale takeover/release and require + exclusive session ownership for every raw recording mutation. +6. Add global traversal and blast-radius tests, then implement bounded-concurrency scanning + and production stale-lock invocation. +7. Add lossless archive/crash-state tests, then implement streaming gzip archival and + verified source removal. +8. Add aggregate ordering tests, then implement raw compression followed by archive + eviction until the configured global budget is met or only protected data remains. +9. Update generated settings schema/documentation through established project scripts and + run the full verification gate. + +Every production change is made in response to a naturally failing behavioral test. +Tests assert observable filesystem/results behavior and would fail if the production +implementation were removed. + +## 4. Explicitly outside this PR + +- Transparent/resumable gzip recordings or automatic archive restoration. +- Session segmentation, event compaction, or changing the append-only JSONL format. +- A new cleanup CLI command, daemon, IPC service, dependency, public maintenance API, or + workflow. +- Cleanup of token-usage, OTEL, performance, debug, or conversation-log files. +- Wiring or deleting the unrelated `retentionDays`, `maxLogFiles`, and `maxLogSizeMB` + conversation-logger settings. They predate this session janitor and control a distinct + storage subsystem. +- Project-orphan inference from `workspaceDirs`. + +These exclusions do not defer any accepted session-recording cleanup behavior: discovery, +default global bounding, lossless cold archival, eventual archive eviction, global reach, +stale locks, and concurrency safety are all delivered here. + +## 5. Verification gate + +Run on the candidate head: + + npm run test + npm run lint + npm run lint:eslint-guard + npm run typecheck + npm run format + npm run build + bun scripts/start.ts --profile-load stepfun-37 "write me a haiku and nothing else" + +Before push, run Open Code Review detached with a 20-minute floor and verify Bun test files +are included. Review findings are classified as Blocker-Fix, In-scope-Fix, Reject, or +Defer; every Blocker-Fix and In-scope-Fix is resolved before the PR is declared ready. + +## 6. Binding engineering constraints + +- No ESLint or TypeScript suppression directives. +- No lint severity downgrade, ignore expansion, or complexity/size threshold increase. +- No new JavaScript or Vitest/Node test files; changed/new tests use Bun. +- No recursive deletion of project storage. +- No modification of `.llxprt/`. +- Fail fast for invalid internal configuration; fail toward retaining data at external + filesystem and cross-process boundaries. diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index a99aa4f80b..37c8b2f451 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -240,10 +240,45 @@ }, "sessionRetention": { "title": "Session Retention", - "description": "Settings for automatic session cleanup.", - "markdownDescription": "Settings for automatic session cleanup.\n\n- Category: `General`\n- Requires restart: `no`", + "description": "Settings for automatic session cleanup. Cleanup is enabled by default with a machine-wide 4 GiB aggregate size budget.", + "markdownDescription": "Settings for automatic session cleanup. Cleanup is enabled by default with a machine-wide 4 GiB aggregate size budget.\n\n- Category: `General`\n- Requires restart: `no`", "type": "object", - "additionalProperties": true + "properties": { + "enabled": { + "title": "Enabled", + "description": "Enable automatic session cleanup. Set to false to disable all janitorial mutations.", + "markdownDescription": "Enable automatic session cleanup. Set to false to disable all janitorial mutations.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `true`", + "default": true, + "type": "boolean" + }, + "maxTotalSizeMB": { + "title": "Max Total Size (MiB)", + "description": "Machine-wide aggregate size limit for all session recordings and cold archives, in MiB. Defaults to 4096 (4 GiB).", + "markdownDescription": "Machine-wide aggregate size limit for all session recordings and cold archives, in MiB. Defaults to 4096 (4 GiB).\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `4096`", + "default": 4096, + "type": "number" + }, + "maxAge": { + "title": "Max Age", + "description": "Maximum age of sessions to keep (e.g. \"30d\", \"7d\", \"24h\"). No default age limit.", + "markdownDescription": "Maximum age of sessions to keep (e.g. \"30d\", \"7d\", \"24h\"). No default age limit.\n\n- Category: `General`\n- Requires restart: `no`", + "type": "string" + }, + "maxCount": { + "title": "Max Count", + "description": "Maximum number of sessions to keep (most recent). No default count limit.", + "markdownDescription": "Maximum number of sessions to keep (most recent). No default count limit.\n\n- Category: `General`\n- Requires restart: `no`", + "type": "number" + }, + "minRetention": { + "title": "Min Retention", + "description": "Minimum retention period (safety floor, defaults to \"1d\").", + "markdownDescription": "Minimum retention period (safety floor, defaults to \"1d\").\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `1d`", + "default": "1d", + "type": "string" + } + }, + "additionalProperties": false }, "output": { "title": "Output",