diff --git a/src/main/codex/codex-home-paths.ts b/src/main/codex/codex-home-paths.ts index a2c397dd132..7c267c5a9ed 100644 --- a/src/main/codex/codex-home-paths.ts +++ b/src/main/codex/codex-home-paths.ts @@ -38,6 +38,10 @@ export function getOrcaManagedCodexHomePath(): string { return managedHomePath } +export function getCodexSessionBackfillStateDirPath(): string { + return join(getOrcaUserDataPath(), 'codex-session-backfill') +} + function getOrcaUserDataPath(): string { if (process.env.ORCA_USER_DATA_PATH) { return process.env.ORCA_USER_DATA_PATH diff --git a/src/main/codex/codex-session-backfill.test.ts b/src/main/codex/codex-session-backfill.test.ts new file mode 100644 index 00000000000..ef400a1360d --- /dev/null +++ b/src/main/codex/codex-session-backfill.test.ts @@ -0,0 +1,297 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import type * as NodeFs from 'node:fs' +import type * as NodeOs from 'node:os' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>() +})) + +const { fsMockState } = vi.hoisted(() => ({ + fsMockState: { + failLink: false, + failCopy: false + } +})) + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + linkSync: (...args: Parameters) => { + if (fsMockState.failLink) { + const error = new Error('EXDEV: cross-device link') as NodeJS.ErrnoException + error.code = 'EXDEV' + throw error + } + return actual.linkSync(...args) + }, + copyFileSync: (...args: Parameters) => { + if (fsMockState.failCopy) { + const error = new Error('EACCES: copy disabled for test') as NodeJS.ErrnoException + error.code = 'EACCES' + throw error + } + return actual.copyFileSync(...args) + } + } +}) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { + ...actual, + homedir: homedirMock + } +}) + +import { + backfillManagedCodexSessionsIntoSystemHome, + resolveCodexSessionBackfillPaths, + startCodexSessionBackfillInBackground +} from './codex-session-backfill' + +let fakeHomeDir: string +let userDataDir: string +let previousUserDataPath: string | undefined + +function getSystemSessionsRoot(): string { + return join(fakeHomeDir, '.codex', 'sessions') +} + +function getManagedSessionsRoot(): string { + return join(userDataDir, 'codex-runtime-home', 'home', 'sessions') +} + +function getMarkerPath(): string { + return join(userDataDir, 'codex-session-backfill', 'backfill-complete.json') +} + +function getAuditLogPath(): string { + return join(userDataDir, 'codex-session-backfill', 'audit.jsonl') +} + +function writeManagedSession(relativePath: string, contents: string): string { + const filePath = join(getManagedSessionsRoot(), relativePath) + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, contents, 'utf-8') + return filePath +} + +function readAuditActions(): string[] { + return readFileSync(getAuditLogPath(), 'utf-8') + .split('\n') + .filter(Boolean) + .map((line) => (JSON.parse(line) as { action: string }).action) +} + +beforeEach(() => { + fsMockState.failLink = false + fsMockState.failCopy = false + fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-backfill-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-backfill-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(fakeHomeDir) +}) + +afterEach(() => { + rmSync(fakeHomeDir, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +describe('backfillManagedCodexSessionsIntoSystemHome', () => { + it('hardlinks managed rollout files into the real home preserving layout', async () => { + const managedPath = writeManagedSession( + join('2026', '05', '26', 'rollout-a.jsonl'), + '{"type":"session_meta","id":"a"}\n' + ) + writeManagedSession(join('2026', '06', '01', 'rollout-b.jsonl'), '{"id":"b"}\n') + writeFileSync(join(getManagedSessionsRoot(), '2026', '05', '26', 'notes.txt'), 'skip me\n') + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths() + ) + + expect(summary).toMatchObject({ scannedFiles: 2, linkedFiles: 2, failedFiles: 0 }) + const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') + expect(lstatSync(targetPath).ino).toBe(lstatSync(managedPath).ino) + expect(existsSync(join(getSystemSessionsRoot(), '2026', '06', '01', 'rollout-b.jsonl'))).toBe( + true + ) + expect(existsSync(join(getSystemSessionsRoot(), '2026', '05', '26', 'notes.txt'))).toBe(false) + expect(readAuditActions()).toEqual(['hardlink', 'hardlink', 'run-summary']) + }) + + it('never overwrites an existing target file, even with different contents', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), 'managed contents\n') + const collidingPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') + mkdirSync(dirname(collidingPath), { recursive: true }) + writeFileSync(collidingPath, 'user contents\n', 'utf-8') + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths() + ) + + expect(summary).toMatchObject({ scannedFiles: 1, linkedFiles: 0, skippedExistingFiles: 1 }) + expect(readFileSync(collidingPath, 'utf-8')).toBe('user contents\n') + expect(readAuditActions()).toEqual(['run-summary']) + }) + + it('treats a broken symlink at the target as taken', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), 'managed contents\n') + const collidingPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') + mkdirSync(dirname(collidingPath), { recursive: true }) + try { + symlinkSync(join(fakeHomeDir, 'missing-target.jsonl'), collidingPath) + } catch { + // Windows without symlink privilege cannot set up this fixture. + return + } + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths() + ) + + expect(summary).toMatchObject({ linkedFiles: 0, copiedFiles: 0, skippedExistingFiles: 1 }) + expect(lstatSync(collidingPath).isSymbolicLink()).toBe(true) + }) + + it('does not backfill symlinked managed session files', async () => { + const realSource = join(fakeHomeDir, 'outside.jsonl') + writeFileSync(realSource, 'outside contents\n', 'utf-8') + const managedLinkPath = join(getManagedSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') + mkdirSync(dirname(managedLinkPath), { recursive: true }) + try { + symlinkSync(realSource, managedLinkPath) + } catch { + return + } + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths() + ) + + // Why: the session walker skips symlink dirents, so bridge-created links + // (which point back into the user's own home) never reach the copier. + expect(summary).toMatchObject({ linkedFiles: 0, copiedFiles: 0, failedFiles: 0 }) + expect(existsSync(join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl'))).toBe( + false + ) + }) + + it('is idempotent: a second run links nothing new and changes nothing', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + const paths = resolveCodexSessionBackfillPaths() + + const first = await backfillManagedCodexSessionsIntoSystemHome(paths) + const second = await backfillManagedCodexSessionsIntoSystemHome(paths) + + expect(first).toMatchObject({ linkedFiles: 1 }) + expect(second).toMatchObject({ linkedFiles: 0, copiedFiles: 0, skippedExistingFiles: 1 }) + }) + + it('falls back to copy when hardlinking fails across volumes', async () => { + fsMockState.failLink = true + const managedPath = writeManagedSession( + join('2026', '05', '26', 'rollout-a.jsonl'), + '{"id":"a"}\n' + ) + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths() + ) + + expect(summary).toMatchObject({ linkedFiles: 0, copiedFiles: 1, failedFiles: 0 }) + const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') + expect(readFileSync(targetPath, 'utf-8')).toBe(readFileSync(managedPath, 'utf-8')) + expect(lstatSync(targetPath).ino).not.toBe(lstatSync(managedPath).ino) + expect(readAuditActions()).toEqual(['copy', 'run-summary']) + }) + + it('records per-file failures without aborting the run', async () => { + fsMockState.failLink = true + fsMockState.failCopy = true + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths() + ) + + expect(summary).toMatchObject({ failedFiles: 1, linkedFiles: 0, copiedFiles: 0 }) + expect(readAuditActions()).toEqual(['failed', 'run-summary']) + }) + + it('does not create the real sessions tree when there is nothing to backfill', async () => { + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths() + ) + + expect(summary).toMatchObject({ scannedFiles: 0 }) + expect(existsSync(getSystemSessionsRoot())).toBe(false) + }) +}) + +describe('startCodexSessionBackfillInBackground', () => { + it('writes a completion marker and skips the walk on later runs', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const first = await startCodexSessionBackfillInBackground() + expect(first).toMatchObject({ linkedFiles: 1, failedFiles: 0 }) + expect(existsSync(getMarkerPath())).toBe(true) + + // A file appearing after the marker must not be backfilled again. + writeManagedSession(join('2026', '07', '01', 'rollout-later.jsonl'), '{"id":"later"}\n') + const second = await startCodexSessionBackfillInBackground() + expect(second).toBeNull() + expect( + existsSync(join(getSystemSessionsRoot(), '2026', '07', '01', 'rollout-later.jsonl')) + ).toBe(false) + }) + + it('leaves the marker unset when any file fails so the next startup retries', async () => { + fsMockState.failLink = true + fsMockState.failCopy = true + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const first = await startCodexSessionBackfillInBackground() + expect(first).toMatchObject({ failedFiles: 1 }) + expect(existsSync(getMarkerPath())).toBe(false) + + fsMockState.failLink = false + fsMockState.failCopy = false + const second = await startCodexSessionBackfillInBackground() + expect(second).toMatchObject({ linkedFiles: 1, failedFiles: 0 }) + expect(existsSync(getMarkerPath())).toBe(true) + }) + + it('honors a custom system Codex home override', async () => { + const customHome = join(fakeHomeDir, 'custom-codex-home') + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const summary = await startCodexSessionBackfillInBackground({}, customHome) + + expect(summary).toMatchObject({ linkedFiles: 1 }) + expect(existsSync(join(customHome, 'sessions', '2026', '05', '26', 'rollout-a.jsonl'))).toBe( + true + ) + expect(existsSync(getSystemSessionsRoot())).toBe(false) + }) +}) diff --git a/src/main/codex/codex-session-backfill.ts b/src/main/codex/codex-session-backfill.ts new file mode 100644 index 00000000000..9018a61928d --- /dev/null +++ b/src/main/codex/codex-session-backfill.ts @@ -0,0 +1,264 @@ +import { + appendFileSync, + constants, + copyFileSync, + existsSync, + linkSync, + lstatSync, + mkdirSync, + readFileSync +} from 'node:fs' +import { dirname, join, relative } from 'node:path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { + getCodexSessionBackfillStateDirPath, + getOrcaManagedCodexHomePath, + getSystemCodexHomePath +} from './codex-home-paths' +import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' +import type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing' + +// Why: bump to re-run the backfill for every host after a layout or semantics +// change; the run itself stays skip-existing so re-runs never overwrite. +const CODEX_SESSION_BACKFILL_MARKER_VERSION = 1 + +export type CodexSessionBackfillSummary = { + scannedFiles: number + linkedFiles: number + copiedFiles: number + skippedExistingFiles: number + skippedSymlinkFiles: number + failedFiles: number +} + +export type CodexSessionBackfillPaths = { + managedSessionsRoot: string + systemSessionsRoot: string + auditLogPath: string + markerPath: string +} + +let backgroundBackfillTask: Promise | null = null + +/** + * Resolves the production source/target/state paths for the session backfill. + * + * `systemCodexHomePathOverride` mirrors the session bridge: users who run + * Codex with a custom CODEX_HOME need their history placed where their own + * `codex resume` actually looks. + */ +export function resolveCodexSessionBackfillPaths( + systemCodexHomePathOverride?: string +): CodexSessionBackfillPaths { + const stateDir = getCodexSessionBackfillStateDirPath() + return { + managedSessionsRoot: join(getOrcaManagedCodexHomePath(), 'sessions'), + systemSessionsRoot: join(systemCodexHomePathOverride || getSystemCodexHomePath(), 'sessions'), + auditLogPath: join(stateDir, 'audit.jsonl'), + markerPath: join(stateDir, 'backfill-complete.json') + } +} + +/** + * Starts the once-per-host background backfill of managed-home session files + * into the user's real Codex home. + * + * Concurrent callers share one in-flight task; a completed-marker host resolves + * to null without walking the sessions tree. + */ +export function startCodexSessionBackfillInBackground( + options: CodexSessionBridgeIncrementalOptions = {}, + systemCodexHomePathOverride?: string +): Promise { + if (backgroundBackfillTask) { + return backgroundBackfillTask + } + const task = runCodexSessionBackfillOncePerHost(options, systemCodexHomePathOverride).catch( + (error: unknown) => { + console.warn('[codex-session-backfill] Background session backfill failed:', error) + return null + } + ) + backgroundBackfillTask = task + void task.finally(() => { + if (backgroundBackfillTask === task) { + backgroundBackfillTask = null + } + }) + return task +} + +async function runCodexSessionBackfillOncePerHost( + options: CodexSessionBridgeIncrementalOptions, + systemCodexHomePathOverride?: string +): Promise { + const paths = resolveCodexSessionBackfillPaths(systemCodexHomePathOverride) + if (hasCompletedBackfillMarker(paths.markerPath)) { + return null + } + const summary = await backfillManagedCodexSessionsIntoSystemHome(paths, options) + // Why: per-file failures (locked or unreadable files) leave the marker unset + // so the next startup retries; skip-existing keeps those retries cheap. + if (summary.failedFiles === 0) { + writeBackfillMarker(paths.markerPath, summary) + } + return summary +} + +/** + * Backfills managed-home session rollout files into the real Codex home. + * + * Non-destructive by contract: existing target files are always skipped, and + * nothing in either home is deleted or moved. Hardlink first so resume sees + * one physical JSONL log; copy is the cross-volume fallback. + */ +export async function backfillManagedCodexSessionsIntoSystemHome( + paths: CodexSessionBackfillPaths, + options: CodexSessionBridgeIncrementalOptions = {} +): Promise { + const summary: CodexSessionBackfillSummary = { + scannedFiles: 0, + linkedFiles: 0, + copiedFiles: 0, + skippedExistingFiles: 0, + skippedSymlinkFiles: 0, + failedFiles: 0 + } + if (existsSync(paths.managedSessionsRoot)) { + for await (const managedSessionFilePath of listCodexSessionJsonlFilesIncrementally( + paths.managedSessionsRoot, + options + )) { + summary.scannedFiles += 1 + backfillOneManagedSessionFile(paths, managedSessionFilePath, summary) + } + } + appendAuditRecord(paths.auditLogPath, { action: 'run-summary', ...summary }) + return summary +} + +function backfillOneManagedSessionFile( + paths: CodexSessionBackfillPaths, + managedSessionFilePath: string, + summary: CodexSessionBackfillSummary +): void { + if (isSymbolicLink(managedSessionFilePath)) { + // Why: bridge-created symlinks already point at a file in the user's own + // home; materializing them here could duplicate a foreign tree. + summary.skippedSymlinkFiles += 1 + return + } + const relativePath = relative(paths.managedSessionsRoot, managedSessionFilePath) + const systemSessionFilePath = join(paths.systemSessionsRoot, relativePath) + if (pathEntryExists(systemSessionFilePath)) { + summary.skippedExistingFiles += 1 + return + } + + try { + mkdirSync(dirname(systemSessionFilePath), { recursive: true }) + linkSync(managedSessionFilePath, systemSessionFilePath) + summary.linkedFiles += 1 + appendAuditRecord(paths.auditLogPath, { + action: 'hardlink', + source: managedSessionFilePath, + target: systemSessionFilePath + }) + } catch (linkError) { + if (isExistsError(linkError)) { + summary.skippedExistingFiles += 1 + return + } + try { + // Why: hardlinks fail across volumes; COPYFILE_EXCL keeps the + // never-overwrite contract even if the target appeared mid-run. + copyFileSync(managedSessionFilePath, systemSessionFilePath, constants.COPYFILE_EXCL) + summary.copiedFiles += 1 + appendAuditRecord(paths.auditLogPath, { + action: 'copy', + source: managedSessionFilePath, + target: systemSessionFilePath + }) + } catch (copyError) { + if (isExistsError(copyError)) { + summary.skippedExistingFiles += 1 + return + } + summary.failedFiles += 1 + appendAuditRecord(paths.auditLogPath, { + action: 'failed', + source: managedSessionFilePath, + target: systemSessionFilePath, + error: describeError(copyError), + linkError: describeError(linkError) + }) + } + } +} + +function isSymbolicLink(filePath: string): boolean { + try { + return lstatSync(filePath).isSymbolicLink() + } catch { + return false + } +} + +/** Existence via lstat so a broken symlink at the target still counts as taken. */ +function pathEntryExists(entryPath: string): boolean { + try { + lstatSync(entryPath) + return true + } catch { + return false + } +} + +function isExistsError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function appendAuditRecord(auditLogPath: string, record: Record): void { + try { + mkdirSync(dirname(auditLogPath), { recursive: true }) + appendFileSync( + auditLogPath, + `${JSON.stringify({ at: new Date().toISOString(), ...record })}\n`, + { + encoding: 'utf-8' + } + ) + } catch (error) { + // Why: the audit trail is diagnostics; losing a line must not fail the + // backfill or leave a half-linked tree unrecorded in the summary counts. + console.warn('[codex-session-backfill] Failed to append audit record:', error) + } +} + +function hasCompletedBackfillMarker(markerPath: string): boolean { + try { + const parsed: unknown = JSON.parse(readFileSync(markerPath, 'utf-8')) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return false + } + return (parsed as { version?: unknown }).version === CODEX_SESSION_BACKFILL_MARKER_VERSION + } catch { + return false + } +} + +function writeBackfillMarker(markerPath: string, summary: CodexSessionBackfillSummary): void { + mkdirSync(dirname(markerPath), { recursive: true }) + writeFileAtomically( + markerPath, + `${JSON.stringify( + { version: CODEX_SESSION_BACKFILL_MARKER_VERSION, completedAt: Date.now(), summary }, + null, + 2 + )}\n` + ) +} diff --git a/src/main/index.ts b/src/main/index.ts index 8bda7328eda..56770fcdc1e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -129,6 +129,8 @@ import { } from './codex-accounts/runtime-selection' import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection' import { codexHookService } from './codex/hook-service' +import { startCodexSessionBackfillInBackground } from './codex/codex-session-backfill' +import { resolveHostCodexSessionSourceHome } from './codex/codex-session-source-home' import { getDefaultWslDistro } from './wsl' import { ClaudeAccountService } from './claude-accounts/service' import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' @@ -1749,6 +1751,16 @@ app.whenReady().then(async () => { rateLimits = new RateLimitService() codexRuntimeHome = new CodexRuntimeHomeService(store) codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome) + // Why: one-time per-host backfill makes historical Orca-managed Codex + // sessions visible to the user's own resume picker and app history (#4444, + // #8612). Deferred so startup and first PTY spawns never compete with the + // sessions tree walk. + setTimeout(() => { + void startCodexSessionBackfillInBackground( + {}, + resolveHostCodexSessionSourceHome(store!.getSettings()) + ) + }, 15_000) claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth) rateLimits.setCodexHomePathResolver((target) =>