mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
feat(codex): backfill managed-home sessions into the real Codex home once per host
Orca-launched Codex sessions currently land only in the Orca-managed runtime home, so the user's own `codex resume` picker and app history never see them (#4444, #8612). Backfill the managed sessions tree into the real ~/.codex/sessions/YYYY/MM/DD layout once per host: - hardlink first (one physical rollout log), copy as the cross-volume fallback; existing target files are always skipped, nothing in either home is deleted or moved - idempotent; per-file failures leave the completion marker unset so the next startup retries cheaply - JSONL audit log of every link/copy/failure under <userData>/codex-session-backfill/ - honors the custom Codex session source home override, mirroring the existing system->managed bridge WSL managed homes are distro-local and need an in-distro variant; that is a follow-up.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<typeof NodeFs>('node:fs')
|
||||
return {
|
||||
...actual,
|
||||
linkSync: (...args: Parameters<typeof actual.linkSync>) => {
|
||||
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<typeof actual.copyFileSync>) => {
|
||||
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<typeof NodeOs>('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)
|
||||
})
|
||||
})
|
||||
@@ -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<CodexSessionBackfillSummary | null> | 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<CodexSessionBackfillSummary | null> {
|
||||
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<CodexSessionBackfillSummary | null> {
|
||||
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<CodexSessionBackfillSummary> {
|
||||
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<string, unknown>): 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`
|
||||
)
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user