fix(codex): publish Windows system-default sessions (#12611)
* fix(codex): publish Windows system-default sessions * fix(codex): close launch-scheduling races in session migration scheduler * fix(codex): bound repeated session migration audits * fix(codex): preserve delayed session publication passes * fix(codex): bound failed session audit events * fix(codex): fence stale session migration markers * chore: preserve main formatting after merge * perf(codex): bound launch session migration scans * perf(codex): preserve coalesced migration scope * fix(codex): preserve scheduled migration recovery * fix(codex): preserve session migration recovery * fix(codex): close session migration launch races * fix(codex): harden session migration completion
This commit is contained in:
parent
20aeb0cb99
commit
38275c2aa2
|
|
@ -1004,10 +1004,62 @@ describe('CodexRuntimeHomeService', () => {
|
|||
const store = createStore(createSettings())
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
|
||||
// Why: a mirror launch never leaves the real home, so its backfill stays valid.
|
||||
expect(existsSync(markerPath)).toBe(true)
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
service.finishHostSystemDefaultSessionMigrationPass()
|
||||
expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe(
|
||||
true
|
||||
)
|
||||
service.finishHostSystemDefaultSessionMigrationPass()
|
||||
writeFileSync(
|
||||
markerPath,
|
||||
`${JSON.stringify({
|
||||
version: 3,
|
||||
systemSessionsRoot: join(getSystemCodexHomePath(), 'sessions'),
|
||||
summary: { scannedFiles: 1 }
|
||||
})}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
service.prepareForCodexLaunch()
|
||||
writeFileSync(
|
||||
markerPath,
|
||||
`${JSON.stringify({
|
||||
version: 3,
|
||||
systemSessionsRoot: join(getSystemCodexHomePath(), 'sessions'),
|
||||
summary: { scannedFiles: 1 }
|
||||
})}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe(
|
||||
false
|
||||
)
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
service.prepareForCodexLaunch()
|
||||
expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe(
|
||||
false
|
||||
)
|
||||
expect(service.beginHostSystemDefaultSessionMigrationLaunch(null)).toBeNull()
|
||||
service.finishHostSystemDefaultSessionMigrationPass()
|
||||
writeFileSync(
|
||||
markerPath,
|
||||
`${JSON.stringify({
|
||||
version: 3,
|
||||
systemSessionsRoot: join(getSystemCodexHomePath(), 'sessions'),
|
||||
summary: { scannedFiles: 1 }
|
||||
})}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
expect(service.beginHostSystemDefaultSessionMigrationLaunch(null, { reattached: true })).toBe(
|
||||
false
|
||||
)
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
store.updateSettings({
|
||||
codexSessionSourceHome: { host: join(testState.fakeHomeDir, 'moved-history'), wsl: {} }
|
||||
})
|
||||
service.prepareForCodexLaunch()
|
||||
expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe(
|
||||
true
|
||||
)
|
||||
expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath())
|
||||
expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()])
|
||||
expect(existsSync(getRuntimeCodexHomePath())).toBe(true)
|
||||
|
|
@ -1037,6 +1089,10 @@ describe('CodexRuntimeHomeService', () => {
|
|||
writeFileSync(markerPath, '{}\n', 'utf-8')
|
||||
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe(
|
||||
true
|
||||
)
|
||||
service.finishHostSystemDefaultSessionMigrationPass()
|
||||
service.setRealHomeLaneGate(() => true)
|
||||
const perSpawnCustomHome = join(testState.fakeHomeDir, 'per-spawn-custom-codex-home')
|
||||
writeFileSync(markerPath, '{}\n', 'utf-8')
|
||||
|
|
@ -1045,6 +1101,11 @@ describe('CodexRuntimeHomeService', () => {
|
|||
getRuntimeCodexHomePath()
|
||||
)
|
||||
expect(existsSync(markerPath)).toBe(true)
|
||||
expect(
|
||||
service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath(), {
|
||||
launchEnv: { CODEX_HOME: perSpawnCustomHome }
|
||||
})
|
||||
).toBeNull()
|
||||
if (process.platform !== 'win32') {
|
||||
// Why: shell startup CODEX_HOME discovery is a POSIX-shell lane; Windows
|
||||
// must not invoke an ambient WSL bash while evaluating this contract.
|
||||
|
|
@ -1719,7 +1780,11 @@ describe('CodexRuntimeHomeService', () => {
|
|||
|
||||
// A host managed account's own home is its CODEX_HOME.
|
||||
expect(service.isHostSystemDefaultRealHome()).toBe(false)
|
||||
expect(service.isHostSystemDefaultSessionMigrationEligible()).toBe(false)
|
||||
expect(service.prepareForCodexLaunch()).toBe(managedHomePath)
|
||||
expect(
|
||||
service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())
|
||||
).toBeNull()
|
||||
// The per-account home keeps its own auth in place; the shared mirror's
|
||||
// auth.json is never hot-swapped, so two accounts cannot race one file.
|
||||
expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ import {
|
|||
import {
|
||||
getOrcaManagedCodexHomePath,
|
||||
getOrcaUserDataPath,
|
||||
getCodexSessionBackfillStateDirPath,
|
||||
getSystemCodexHomePath,
|
||||
resolveOrcaManagedCodexHomePath,
|
||||
syncCodexGlobalInstructionsIntoManagedHome,
|
||||
|
|
@ -68,7 +67,11 @@ import {
|
|||
} from './runtime-selection'
|
||||
import { getDefaultWslDistro, getWslHome } from '../wsl'
|
||||
import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path'
|
||||
import { invalidateCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker'
|
||||
import {
|
||||
hasCompletedCodexSessionBackfillMarker,
|
||||
invalidateCodexSessionBackfillMarker
|
||||
} from '../codex/codex-session-backfill-marker'
|
||||
import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill'
|
||||
import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership'
|
||||
import {
|
||||
codexAuthCouldBelongToManagedAccount,
|
||||
|
|
@ -181,6 +184,9 @@ export class CodexRuntimeHomeService {
|
|||
private sharedAuthRefreshBlockedByManagedTransition = false
|
||||
// Why: transient auth.json read/parse failures must not deselect an account.
|
||||
private readonly credentialAbsenceGrace = new CodexCredentialAbsenceGrace()
|
||||
private hostSystemDefaultSessionMigrationPending = false
|
||||
private pendingHostSystemDefaultSessionMigrationNeedsFullScan = false
|
||||
private pendingHostSystemDefaultSessionMigrationTarget: string | null = null
|
||||
|
||||
constructor(private readonly store: Store) {
|
||||
this.safeRecoverInterruptedRuntimeAuthOperation()
|
||||
|
|
@ -253,6 +259,53 @@ export class CodexRuntimeHomeService {
|
|||
return this.getRuntimeHomePath()
|
||||
}
|
||||
|
||||
beginHostSystemDefaultSessionMigrationLaunch(
|
||||
codexHomePath: string | null,
|
||||
options: { reattached?: boolean; launchEnv?: NodeJS.ProcessEnv } = {}
|
||||
): boolean | null {
|
||||
if (
|
||||
!this.isHostSystemDefaultSessionMigrationEligible() ||
|
||||
(!codexHomePath && !options.reattached) ||
|
||||
(codexHomePath &&
|
||||
normalizeRuntimePathForComparison(codexHomePath) !==
|
||||
normalizeRuntimePathForComparison(this.getRuntimeHomePath()))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
// Why: an older pass can clear launch preparation while PTY spawn awaits recovery.
|
||||
return this.invalidateBackfillAfterManagedSystemDefaultLaunch(
|
||||
options.reattached && !codexHomePath ? undefined : options.launchEnv
|
||||
)
|
||||
}
|
||||
|
||||
isHostSystemDefaultSessionMigrationEligible(): boolean {
|
||||
return (
|
||||
normalizeCodexRuntimeSelection(this.store.getSettings()).host === null &&
|
||||
!hasCustomCodexHomeOverrideForLaunch()
|
||||
)
|
||||
}
|
||||
|
||||
prepareHostSystemDefaultSessionMigrationPass(): boolean {
|
||||
const paths = resolveCodexSessionBackfillPaths(
|
||||
resolveHostCodexSessionSourceHome(this.store.getSettings())
|
||||
)
|
||||
if (
|
||||
this.hostSystemDefaultSessionMigrationPending &&
|
||||
this.pendingHostSystemDefaultSessionMigrationTarget !== paths.systemSessionsRoot
|
||||
) {
|
||||
this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = true
|
||||
this.pendingHostSystemDefaultSessionMigrationTarget = paths.systemSessionsRoot
|
||||
}
|
||||
invalidateCodexSessionBackfillMarker(paths.markerPath)
|
||||
return this.pendingHostSystemDefaultSessionMigrationNeedsFullScan
|
||||
}
|
||||
|
||||
finishHostSystemDefaultSessionMigrationPass(): void {
|
||||
this.hostSystemDefaultSessionMigrationPending = false
|
||||
this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = false
|
||||
this.pendingHostSystemDefaultSessionMigrationTarget = null
|
||||
}
|
||||
|
||||
// Why: a managed HOST account runs against its own self-contained CODEX_HOME
|
||||
// (codex-accounts/<id>/home) rather than the shared runtime mirror. Its
|
||||
// auth.json lives there and codex refreshes it in place, so two accounts never
|
||||
|
|
@ -404,18 +457,26 @@ export class CodexRuntimeHomeService {
|
|||
this.lastHostAccountUsedSelfContainedHome = false
|
||||
}
|
||||
|
||||
private invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv?: NodeJS.ProcessEnv): void {
|
||||
private invalidateBackfillAfterManagedSystemDefaultLaunch(
|
||||
launchEnv?: NodeJS.ProcessEnv
|
||||
): boolean | null {
|
||||
const settings = this.store.getSettings()
|
||||
if (normalizeCodexRuntimeSelection(settings).host !== null) {
|
||||
return
|
||||
if (
|
||||
normalizeCodexRuntimeSelection(settings).host !== null ||
|
||||
hasCustomCodexHomeOverrideForLaunch(launchEnv)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
// Why: reached only when the real-home lane is selected but its gate is off,
|
||||
// so the launch runs on the mirror and the backfill marker is stale.
|
||||
if (this.isHostSystemDefaultRealHomeSelected(launchEnv)) {
|
||||
invalidateCodexSessionBackfillMarker(
|
||||
join(getCodexSessionBackfillStateDirPath(), 'backfill-complete.json')
|
||||
if (!this.hostSystemDefaultSessionMigrationPending) {
|
||||
const paths = resolveCodexSessionBackfillPaths(
|
||||
resolveHostCodexSessionSourceHome(this.store.getSettings())
|
||||
)
|
||||
this.pendingHostSystemDefaultSessionMigrationNeedsFullScan =
|
||||
!hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot)
|
||||
this.pendingHostSystemDefaultSessionMigrationTarget = paths.systemSessionsRoot
|
||||
this.hostSystemDefaultSessionMigrationPending = true
|
||||
}
|
||||
return this.prepareHostSystemDefaultSessionMigrationPass()
|
||||
}
|
||||
|
||||
private startWslSessionBridgeForLaunch(
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ describe('Windows System Default Codex home ownership', () => {
|
|||
SHELL: 'powershell.exe'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(service.isHostSystemDefaultSessionMigrationEligible()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
import type { Stats } from 'node:fs'
|
||||
import { lstat } from 'node:fs/promises'
|
||||
import {
|
||||
appendCodexSessionHealAuditRecord,
|
||||
createCodexSessionBackfillAuditWriter,
|
||||
createCodexSessionBackfillDiagnosticEventId,
|
||||
createCodexSessionBackfillFileEventId,
|
||||
readCodexSessionBackfillAuditCoverage,
|
||||
recordExistingCodexSessionForHeal,
|
||||
type CodexSessionBackfillAuditWriter
|
||||
} from './codex-session-backfill-audit'
|
||||
import type { CodexSessionBackfillSummary } from './codex-session-backfill-types'
|
||||
|
||||
export type CodexSessionBackfillAuditPass = {
|
||||
appendRecord: CodexSessionBackfillAuditWriter
|
||||
recordExisting(
|
||||
summary: CodexSessionBackfillSummary,
|
||||
source: string,
|
||||
target: string,
|
||||
targetStat: Stats | null
|
||||
): Promise<void>
|
||||
recordPublished(
|
||||
summary: CodexSessionBackfillSummary,
|
||||
action: 'hardlink' | 'copy',
|
||||
source: string,
|
||||
target: string
|
||||
): Promise<void>
|
||||
recordDiagnostic(
|
||||
record: {
|
||||
action: 'copy-unsupported' | 'failed'
|
||||
source: string
|
||||
target: string
|
||||
error?: string
|
||||
linkError?: string
|
||||
errorCode?: string
|
||||
linkErrorCode?: string
|
||||
},
|
||||
sourceStat: Stats | null
|
||||
): Promise<void>
|
||||
finish(summary: CodexSessionBackfillSummary): Promise<void>
|
||||
}
|
||||
|
||||
export async function createCodexSessionBackfillAuditPass(
|
||||
auditLogPath: string
|
||||
): Promise<CodexSessionBackfillAuditPass> {
|
||||
const coverage = await readCodexSessionBackfillAuditCoverage(auditLogPath)
|
||||
const writeAuditRecord = createCodexSessionBackfillAuditWriter(auditLogPath)
|
||||
let auditChanged = false
|
||||
const appendRecord: CodexSessionBackfillAuditWriter = async (record) => {
|
||||
const appended = await writeAuditRecord(record)
|
||||
auditChanged ||= appended
|
||||
return appended
|
||||
}
|
||||
|
||||
return {
|
||||
appendRecord,
|
||||
async recordExisting(summary, source, target, targetStat): Promise<void> {
|
||||
const fileEventId = targetStat
|
||||
? createCodexSessionBackfillFileEventId(target, targetStat)
|
||||
: undefined
|
||||
if (fileEventId && coverage.fileEventIds.has(fileEventId)) {
|
||||
summary.skippedExistingFiles += 1
|
||||
return
|
||||
}
|
||||
if (
|
||||
await recordExistingCodexSessionForHeal(appendRecord, summary, source, target, fileEventId)
|
||||
) {
|
||||
if (fileEventId) {
|
||||
coverage.fileEventIds.add(fileEventId)
|
||||
}
|
||||
}
|
||||
},
|
||||
async recordPublished(summary, action, source, target): Promise<void> {
|
||||
const targetStat = await readCodexSessionTargetStat(target)
|
||||
const fileEventId = targetStat
|
||||
? createCodexSessionBackfillFileEventId(target, targetStat)
|
||||
: undefined
|
||||
if (
|
||||
await appendCodexSessionHealAuditRecord(appendRecord, summary, {
|
||||
action,
|
||||
source,
|
||||
target,
|
||||
...(fileEventId ? { fileEventId } : {})
|
||||
})
|
||||
) {
|
||||
if (fileEventId) {
|
||||
coverage.fileEventIds.add(fileEventId)
|
||||
}
|
||||
}
|
||||
},
|
||||
async recordDiagnostic(record, sourceStat): Promise<void> {
|
||||
const diagnosticEventId = createCodexSessionBackfillDiagnosticEventId({
|
||||
...record,
|
||||
sourceStat
|
||||
})
|
||||
if (coverage.diagnosticEventIds.has(diagnosticEventId)) {
|
||||
return
|
||||
}
|
||||
if (await appendRecord({ ...record, diagnosticEventId })) {
|
||||
coverage.diagnosticEventIds.add(diagnosticEventId)
|
||||
}
|
||||
},
|
||||
async finish(summary): Promise<void> {
|
||||
if (auditChanged || !coverage.hasRunSummary) {
|
||||
await appendRecord({ action: 'run-summary', ...summary })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A broken symlink at the target still counts as taken. */
|
||||
export async function readCodexSessionTargetStat(entryPath: string): Promise<Stats | null> {
|
||||
try {
|
||||
return await lstat(entryPath)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,22 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { createReadStream, type Stats } from 'node:fs'
|
||||
import { appendFile, mkdir } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import type { CodexSessionBackfillSummary } from './codex-session-backfill-types'
|
||||
|
||||
export type CodexSessionBackfillAuditWriter = (record: Record<string, unknown>) => Promise<boolean>
|
||||
|
||||
export type CodexSessionBackfillAuditCoverage = {
|
||||
fileEventIds: Set<string>
|
||||
diagnosticEventIds: Set<string>
|
||||
hasRunSummary: boolean
|
||||
}
|
||||
|
||||
const HEAL_AUDIT_ACTIONS = new Set(['hardlink', 'copy', 'existing'])
|
||||
const DIAGNOSTIC_AUDIT_ACTIONS = new Set(['copy-unsupported', 'failed'])
|
||||
|
||||
export function createCodexSessionBackfillAuditWriter(
|
||||
auditLogPath: string
|
||||
): CodexSessionBackfillAuditWriter {
|
||||
|
|
@ -48,28 +60,139 @@ export function createCodexSessionBackfillAuditWriter(
|
|||
}
|
||||
}
|
||||
|
||||
export async function readCodexSessionBackfillAuditCoverage(
|
||||
auditLogPath: string
|
||||
): Promise<CodexSessionBackfillAuditCoverage> {
|
||||
const coverage: CodexSessionBackfillAuditCoverage = {
|
||||
fileEventIds: new Set<string>(),
|
||||
diagnosticEventIds: new Set<string>(),
|
||||
hasRunSummary: false
|
||||
}
|
||||
const input = createReadStream(auditLogPath, { encoding: 'utf-8' })
|
||||
const lines = createInterface({ input, crlfDelay: Infinity })
|
||||
try {
|
||||
for await (const raw of lines) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
continue
|
||||
}
|
||||
const record = parsed as Record<string, unknown>
|
||||
coverage.hasRunSummary ||= record.action === 'run-summary'
|
||||
if (
|
||||
typeof record.action === 'string' &&
|
||||
HEAL_AUDIT_ACTIONS.has(record.action) &&
|
||||
typeof record.fileEventId === 'string'
|
||||
) {
|
||||
coverage.fileEventIds.add(record.fileEventId)
|
||||
}
|
||||
if (
|
||||
typeof record.action === 'string' &&
|
||||
DIAGNOSTIC_AUDIT_ACTIONS.has(record.action) &&
|
||||
typeof record.diagnosticEventId === 'string'
|
||||
) {
|
||||
coverage.diagnosticEventIds.add(record.diagnosticEventId)
|
||||
}
|
||||
} catch {
|
||||
// Torn audit tails are quarantined by the writer's leading newline.
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isNotFoundError(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return coverage
|
||||
}
|
||||
|
||||
export function createCodexSessionBackfillFileEventId(targetPath: string, stat: Stats): string {
|
||||
const fileIdentity = [
|
||||
stat.dev,
|
||||
stat.ino,
|
||||
stat.birthtimeMs,
|
||||
stat.size,
|
||||
stat.mtimeMs,
|
||||
stat.ctimeMs
|
||||
].join('\0')
|
||||
return createHash('sha256')
|
||||
.update(normalizeRuntimePathForComparison(targetPath))
|
||||
.update('\0')
|
||||
.update(fileIdentity)
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
export function createCodexSessionBackfillDiagnosticEventId(args: {
|
||||
action: 'copy-unsupported' | 'failed'
|
||||
source: string
|
||||
target: string
|
||||
sourceStat: Stats | null
|
||||
errorCode?: string
|
||||
linkErrorCode?: string
|
||||
}): string {
|
||||
const sourceIdentity = args.sourceStat
|
||||
? [
|
||||
args.sourceStat.dev,
|
||||
args.sourceStat.ino,
|
||||
args.sourceStat.birthtimeMs,
|
||||
args.sourceStat.size,
|
||||
args.sourceStat.mtimeMs,
|
||||
args.sourceStat.ctimeMs
|
||||
].join('\0')
|
||||
: 'unavailable'
|
||||
return createHash('sha256')
|
||||
.update(args.action)
|
||||
.update('\0')
|
||||
.update(normalizeRuntimePathForComparison(args.source))
|
||||
.update('\0')
|
||||
.update(normalizeRuntimePathForComparison(args.target))
|
||||
.update('\0')
|
||||
.update(sourceIdentity)
|
||||
.update('\0')
|
||||
.update(args.errorCode ?? '')
|
||||
.update('\0')
|
||||
.update(args.linkErrorCode ?? '')
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
export function describeCodexSessionBackfillErrorCode(error: unknown): string {
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code
|
||||
return typeof code === 'string' && code
|
||||
? code
|
||||
: error instanceof Error
|
||||
? error.name
|
||||
: typeof error
|
||||
}
|
||||
|
||||
export async function appendCodexSessionHealAuditRecord(
|
||||
writer: CodexSessionBackfillAuditWriter,
|
||||
summary: CodexSessionBackfillSummary,
|
||||
record: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
if (!(await writer(record))) {
|
||||
): Promise<boolean> {
|
||||
const appended = await writer(record)
|
||||
if (!appended) {
|
||||
summary.failedHealAuditRecords += 1
|
||||
}
|
||||
return appended
|
||||
}
|
||||
|
||||
export async function recordExistingCodexSessionForHeal(
|
||||
writer: CodexSessionBackfillAuditWriter,
|
||||
summary: CodexSessionBackfillSummary,
|
||||
source: string,
|
||||
target: string
|
||||
): Promise<void> {
|
||||
target: string,
|
||||
fileEventId?: string
|
||||
): Promise<boolean> {
|
||||
summary.skippedExistingFiles += 1
|
||||
// Why: this also recovers a rollout installed before a crash or audit
|
||||
// failure; thread/read is idempotent for a pre-existing real-home file.
|
||||
await appendCodexSessionHealAuditRecord(writer, summary, {
|
||||
return appendCodexSessionHealAuditRecord(writer, summary, {
|
||||
action: 'existing',
|
||||
source,
|
||||
target
|
||||
target,
|
||||
...(fileEventId ? { fileEventId } : {})
|
||||
})
|
||||
}
|
||||
|
||||
function isNotFoundError(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
import { join, relative, sep } from 'node:path'
|
||||
import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing'
|
||||
import type {
|
||||
CodexSessionBackfillDate,
|
||||
CodexSessionBackfillOptions
|
||||
} from './codex-session-backfill-types'
|
||||
|
||||
export function getCodexSessionBackfillDate(date = new Date()): CodexSessionBackfillDate {
|
||||
return [
|
||||
String(date.getUTCFullYear()).padStart(4, '0'),
|
||||
String(date.getUTCMonth() + 1).padStart(2, '0'),
|
||||
String(date.getUTCDate()).padStart(2, '0')
|
||||
]
|
||||
}
|
||||
|
||||
export function isCodexSessionRolloutPath(sessionsRoot: string, filePath: string): boolean {
|
||||
const pathParts = relative(sessionsRoot, filePath).split(sep)
|
||||
if (pathParts.length !== 4) {
|
||||
return false
|
||||
}
|
||||
const [year, month, day, fileName] = pathParts
|
||||
return (
|
||||
/^\d{4}$/.test(year) &&
|
||||
/^\d{2}$/.test(month) &&
|
||||
/^\d{2}$/.test(day) &&
|
||||
/^rollout-.+\.jsonl$/.test(fileName)
|
||||
)
|
||||
}
|
||||
|
||||
export async function* listCodexSessionBackfillFilesForDates(
|
||||
sessionsRoot: string,
|
||||
options: CodexSessionBackfillOptions,
|
||||
onDirectoryError: (directoryPath: string, error: unknown) => void | Promise<void>
|
||||
): AsyncGenerator<string> {
|
||||
const scanRoots = resolveCodexSessionBackfillDateRoots(sessionsRoot, options.scanDates)
|
||||
for (const scanRoot of scanRoots) {
|
||||
yield* listCodexSessionJsonlFilesIncrementally(
|
||||
scanRoot,
|
||||
options,
|
||||
async (directoryPath, error) => {
|
||||
if (directoryPath !== scanRoot || !isNotFoundError(error)) {
|
||||
await onDirectoryError(directoryPath, error)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCodexSessionBackfillDateRoots(
|
||||
sessionsRoot: string,
|
||||
scanDates: readonly CodexSessionBackfillDate[] | undefined
|
||||
): string[] {
|
||||
if (!scanDates?.length) {
|
||||
return [sessionsRoot]
|
||||
}
|
||||
return scanDates
|
||||
.filter(
|
||||
([year, month, day]) => /^\d{4}$/.test(year) && /^\d{2}$/.test(month) && /^\d{2}$/.test(day)
|
||||
)
|
||||
.map(([year, month, day]) => join(sessionsRoot, year, month, day))
|
||||
}
|
||||
|
||||
function isNotFoundError(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
|
||||
}
|
||||
|
|
@ -6,6 +6,11 @@ import type { CodexSessionBackfillSummary } from './codex-session-backfill-types
|
|||
// 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 = 3
|
||||
let markerInvalidationGeneration = 0
|
||||
|
||||
export function captureCodexSessionBackfillMarkerGeneration(): number {
|
||||
return markerInvalidationGeneration
|
||||
}
|
||||
|
||||
export function hasCompletedCodexSessionBackfillMarker(
|
||||
markerPath: string,
|
||||
|
|
@ -16,13 +21,22 @@ export function hasCompletedCodexSessionBackfillMarker(
|
|||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return false
|
||||
}
|
||||
const marker = parsed as { version?: unknown; systemSessionsRoot?: unknown }
|
||||
const marker = parsed as {
|
||||
version?: unknown
|
||||
systemSessionsRoot?: unknown
|
||||
summary?: { scannedFiles?: unknown }
|
||||
}
|
||||
// Why: changing the configured real Codex home must backfill the new
|
||||
// target instead of honoring a marker written for a different history.
|
||||
return (
|
||||
const markerMatchesTarget =
|
||||
marker.version === CODEX_SESSION_BACKFILL_MARKER_VERSION &&
|
||||
marker.systemSessionsRoot === systemSessionsRoot
|
||||
)
|
||||
if (!markerMatchesTarget) {
|
||||
return false
|
||||
}
|
||||
// Why: an empty source can become populated after an early migration run;
|
||||
// let the incremental async walk verify it without blocking the main thread.
|
||||
return marker.summary?.scannedFiles !== 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
|
@ -31,8 +45,13 @@ export function hasCompletedCodexSessionBackfillMarker(
|
|||
export function writeCodexSessionBackfillMarker(
|
||||
markerPath: string,
|
||||
systemSessionsRoot: string,
|
||||
summary: CodexSessionBackfillSummary
|
||||
summary: CodexSessionBackfillSummary,
|
||||
expectedGeneration: number
|
||||
): void {
|
||||
// Why: a launch can invalidate this pass before its delayed replacement begins.
|
||||
if (expectedGeneration !== markerInvalidationGeneration) {
|
||||
return
|
||||
}
|
||||
mkdirSync(dirname(markerPath), { recursive: true })
|
||||
writeFileAtomically(
|
||||
markerPath,
|
||||
|
|
@ -50,11 +69,23 @@ export function writeCodexSessionBackfillMarker(
|
|||
}
|
||||
|
||||
export function invalidateCodexSessionBackfillMarker(markerPath: string): void {
|
||||
markerInvalidationGeneration += 1
|
||||
try {
|
||||
// Why: a managed-lane system-default launch can create new source
|
||||
// rollouts, so a prior one-time marker must not suppress the next opt-in.
|
||||
rmSync(markerPath, { force: true })
|
||||
} catch (error) {
|
||||
console.warn('[codex-session-backfill] Failed to invalidate completion marker:', error)
|
||||
try {
|
||||
writeFileAtomically(
|
||||
markerPath,
|
||||
`${JSON.stringify({ version: 0, invalidatedAt: Date.now() })}\n`
|
||||
)
|
||||
} catch (fallbackError) {
|
||||
throw new AggregateError(
|
||||
[error, fallbackError],
|
||||
'Failed to invalidate Codex session backfill marker'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,4 +24,16 @@ export type CodexSessionBackfillPaths = {
|
|||
export type CodexSessionBackfillOptions = CodexSessionBridgeIncrementalOptions & {
|
||||
/** Polled before each target mutation; true stops with progress preserved. */
|
||||
shouldStop?: () => boolean
|
||||
/** Limits a launch-triggered pass to the date directories that can contain its rollouts. */
|
||||
scanDates?: readonly CodexSessionBackfillDate[]
|
||||
/** A scheduled launch pass must not be suppressed by a marker it just invalidated. */
|
||||
ignoreCompletionMarker?: boolean
|
||||
/** Active launch passes defer global completion until their final exit scan. */
|
||||
writeCompletionMarker?: boolean
|
||||
/** Final launch scans can extend a previously certified full-tree baseline. */
|
||||
writeBoundedCompletionMarker?: boolean
|
||||
/** Rechecks launch scheduling state immediately before marker publication. */
|
||||
canWriteCompletionMarker?: () => boolean
|
||||
}
|
||||
|
||||
export type CodexSessionBackfillDate = readonly [year: string, month: string, day: string]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
appendFileSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
|
|
@ -23,12 +24,14 @@ const { homedirMock } = vi.hoisted(() => ({
|
|||
const { fsMockState } = vi.hoisted(() => ({
|
||||
fsMockState: {
|
||||
failLink: false,
|
||||
failInstallLink: false,
|
||||
failInstallLinkTransiently: false,
|
||||
failLinkTransiently: false,
|
||||
failLinkPermission: false,
|
||||
raceTargetIntoExistence: false,
|
||||
failCopy: false,
|
||||
failMarkerRm: false,
|
||||
failMarkerReplacement: false,
|
||||
failAuditMkdirOnce: false,
|
||||
failAuditWrites: false,
|
||||
failMkdirPath: null as string | null,
|
||||
failDirectoryPath: null as string | null,
|
||||
failLstatPath: null as string | null
|
||||
}
|
||||
|
|
@ -43,6 +46,30 @@ vi.mock('node:fs', async () => {
|
|||
return false
|
||||
}
|
||||
return actual.existsSync(...args)
|
||||
},
|
||||
rmSync: (...args: Parameters<typeof actual.rmSync>) => {
|
||||
if (
|
||||
fsMockState.failMarkerRm &&
|
||||
String(args[0]).includes('codex-session-backfill') &&
|
||||
String(args[0]).endsWith('backfill-complete.json')
|
||||
) {
|
||||
const error = new Error('EACCES: marker removal failed') as NodeJS.ErrnoException
|
||||
error.code = 'EACCES'
|
||||
throw error
|
||||
}
|
||||
return actual.rmSync(...args)
|
||||
},
|
||||
renameSync: (...args: Parameters<typeof actual.renameSync>) => {
|
||||
if (
|
||||
fsMockState.failMarkerReplacement &&
|
||||
String(args[1]).includes('codex-session-backfill') &&
|
||||
String(args[1]).endsWith('backfill-complete.json')
|
||||
) {
|
||||
const error = new Error('EACCES: marker replacement failed') as NodeJS.ErrnoException
|
||||
error.code = 'EACCES'
|
||||
throw error
|
||||
}
|
||||
return actual.renameSync(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -52,6 +79,11 @@ vi.mock('node:fs/promises', async () => {
|
|||
return {
|
||||
...actual,
|
||||
mkdir: (...args: Parameters<typeof actual.mkdir>) => {
|
||||
if (args[0] === fsMockState.failMkdirPath) {
|
||||
const error = new Error('EACCES: target directory inaccessible') as NodeJS.ErrnoException
|
||||
error.code = 'EACCES'
|
||||
throw error
|
||||
}
|
||||
if (fsMockState.failAuditMkdirOnce && String(args[0]).includes('codex-session-backfill')) {
|
||||
fsMockState.failAuditMkdirOnce = false
|
||||
const error = new Error(
|
||||
|
|
@ -91,30 +123,17 @@ vi.mock('node:fs/promises', async () => {
|
|||
error.code = 'EXDEV'
|
||||
throw error
|
||||
}
|
||||
// Simulate a target filesystem with no hardlink support: even the
|
||||
// same-volume staged-copy install link (.orca-backfill-*.tmp) fails.
|
||||
if (fsMockState.failInstallLink && String(args[0]).includes('.orca-backfill-')) {
|
||||
const error = new Error('EPERM: hardlinks unsupported') as NodeJS.ErrnoException
|
||||
error.code = 'EPERM'
|
||||
throw error
|
||||
}
|
||||
if (fsMockState.failInstallLinkTransiently && String(args[0]).includes('.orca-backfill-')) {
|
||||
const error = new Error('EIO: transient install failure') as NodeJS.ErrnoException
|
||||
if (fsMockState.failLinkTransiently && String(args[0]).includes('codex-runtime-home')) {
|
||||
const error = new Error('EIO: transient hardlink failure') as NodeJS.ErrnoException
|
||||
error.code = 'EIO'
|
||||
throw error
|
||||
}
|
||||
return actual.link(...args)
|
||||
},
|
||||
copyFile: async (...args: Parameters<typeof actual.copyFile>) => {
|
||||
if (fsMockState.failCopy) {
|
||||
// Simulate a copy that fails after opening its destination, which is
|
||||
// the dangerous case for resumability rather than a preflight error.
|
||||
await actual.writeFile(args[1], 'partial copy\n', 'utf-8')
|
||||
const error = new Error('EACCES: copy disabled for test') as NodeJS.ErrnoException
|
||||
if (fsMockState.failLinkPermission && String(args[0]).includes('codex-runtime-home')) {
|
||||
const error = new Error('EACCES: hardlink permission denied') as NodeJS.ErrnoException
|
||||
error.code = 'EACCES'
|
||||
throw error
|
||||
}
|
||||
return actual.copyFile(...args)
|
||||
return actual.link(...args)
|
||||
},
|
||||
opendir: (...args: Parameters<typeof actual.opendir>) => {
|
||||
if (args[0] === fsMockState.failDirectoryPath) {
|
||||
|
|
@ -140,6 +159,7 @@ import {
|
|||
resolveCodexSessionBackfillPaths,
|
||||
startCodexSessionBackfillInBackground
|
||||
} from './codex-session-backfill'
|
||||
import { invalidateCodexSessionBackfillMarker } from './codex-session-backfill-marker'
|
||||
|
||||
let fakeHomeDir: string
|
||||
let userDataDir: string
|
||||
|
|
@ -168,27 +188,40 @@ function writeManagedSession(relativePath: string, contents: string): string {
|
|||
return filePath
|
||||
}
|
||||
|
||||
function readAuditActions(): string[] {
|
||||
type BackfillAuditRecord = {
|
||||
action: string
|
||||
target?: string
|
||||
fileEventId?: string
|
||||
diagnosticEventId?: string
|
||||
}
|
||||
|
||||
function readBackfillAuditRecords(): BackfillAuditRecord[] {
|
||||
return readFileSync(getAuditLogPath(), 'utf-8')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.flatMap((line) => {
|
||||
try {
|
||||
return [(JSON.parse(line) as { action: string }).action]
|
||||
return [JSON.parse(line) as BackfillAuditRecord]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function readAuditActions(): string[] {
|
||||
return readBackfillAuditRecords().map((record) => record.action)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fsMockState.failLink = false
|
||||
fsMockState.failInstallLink = false
|
||||
fsMockState.failInstallLinkTransiently = false
|
||||
fsMockState.failLinkTransiently = false
|
||||
fsMockState.failLinkPermission = false
|
||||
fsMockState.raceTargetIntoExistence = false
|
||||
fsMockState.failCopy = false
|
||||
fsMockState.failMarkerRm = false
|
||||
fsMockState.failMarkerReplacement = false
|
||||
fsMockState.failAuditMkdirOnce = false
|
||||
fsMockState.failAuditWrites = false
|
||||
fsMockState.failMkdirPath = null
|
||||
fsMockState.failDirectoryPath = null
|
||||
fsMockState.failLstatPath = null
|
||||
fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-backfill-home-'))
|
||||
|
|
@ -367,27 +400,22 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => {
|
|||
expect(readAuditActions()).toEqual(['hardlink', 'run-summary'])
|
||||
})
|
||||
|
||||
it('falls back to copy when hardlinking fails across volumes', async () => {
|
||||
it('skips cross-volume rollouts instead of freezing a mutable snapshot', async () => {
|
||||
fsMockState.failLink = true
|
||||
const managedPath = writeManagedSession(
|
||||
join('2026', '05', '26', 'rollout-a ü.jsonl'),
|
||||
'{"id":"a"}\n'
|
||||
)
|
||||
const relativePath = join('2026', '05', '26', 'rollout-a ü.jsonl')
|
||||
writeManagedSession(relativePath, '{"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'])
|
||||
expect(summary).toMatchObject({ copiedFiles: 0, skippedUnsupportedFilesystemFiles: 1 })
|
||||
expect(existsSync(join(getSystemSessionsRoot(), relativePath))).toBe(false)
|
||||
expect(readAuditActions()).toEqual(['copy-unsupported', 'run-summary'])
|
||||
})
|
||||
|
||||
it('fails closed when the target filesystem cannot install without overwrite', async () => {
|
||||
fsMockState.failLink = true
|
||||
fsMockState.failInstallLink = true
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
|
||||
const summary = await backfillManagedCodexSessionsIntoSystemHome(
|
||||
|
|
@ -406,9 +434,8 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => {
|
|||
expect(readAuditActions()).toEqual(['copy-unsupported', 'run-summary'])
|
||||
})
|
||||
|
||||
it('keeps transient install failures retryable', async () => {
|
||||
fsMockState.failLink = true
|
||||
fsMockState.failInstallLinkTransiently = true
|
||||
it('keeps transient hardlink failures retryable', async () => {
|
||||
fsMockState.failLinkTransiently = true
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
|
||||
const summary = await backfillManagedCodexSessionsIntoSystemHome(
|
||||
|
|
@ -422,9 +449,33 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => {
|
|||
expect(readAuditActions()).toEqual(['failed', 'run-summary'])
|
||||
})
|
||||
|
||||
it('keeps target directory permission failures retryable', async () => {
|
||||
const relativePath = join('2026', '05', '26', 'rollout-a.jsonl')
|
||||
writeManagedSession(relativePath, '{"id":"a"}\n')
|
||||
fsMockState.failMkdirPath = dirname(join(getSystemSessionsRoot(), relativePath))
|
||||
|
||||
const summary = await startCodexSessionBackfillInBackground()
|
||||
|
||||
expect(summary).toMatchObject({ failedFiles: 1, skippedUnsupportedFilesystemFiles: 0 })
|
||||
expect(existsSync(join(getSystemSessionsRoot(), relativePath))).toBe(false)
|
||||
expect(existsSync(getMarkerPath())).toBe(false)
|
||||
expect(readAuditActions()).toEqual(['failed', 'run-summary'])
|
||||
})
|
||||
|
||||
it('keeps hardlink permission failures retryable', async () => {
|
||||
fsMockState.failLinkPermission = true
|
||||
const relativePath = join('2026', '05', '26', 'rollout-a.jsonl')
|
||||
writeManagedSession(relativePath, '{"id":"a"}\n')
|
||||
|
||||
const summary = await startCodexSessionBackfillInBackground()
|
||||
|
||||
expect(summary).toMatchObject({ failedFiles: 1, skippedUnsupportedFilesystemFiles: 0 })
|
||||
expect(existsSync(join(getSystemSessionsRoot(), relativePath))).toBe(false)
|
||||
expect(existsSync(getMarkerPath())).toBe(false)
|
||||
})
|
||||
|
||||
it('records per-file failures without aborting the run', async () => {
|
||||
fsMockState.failLink = true
|
||||
fsMockState.failCopy = true
|
||||
fsMockState.failLinkTransiently = true
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
|
||||
const summary = await backfillManagedCodexSessionsIntoSystemHome(
|
||||
|
|
@ -446,6 +497,33 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => {
|
|||
expect(summary).toMatchObject({ scannedFiles: 0 })
|
||||
expect(existsSync(getSystemSessionsRoot())).toBe(false)
|
||||
})
|
||||
|
||||
it('bounds a launch pass to its rollout date directories', async () => {
|
||||
const oldRelativePath = join('2025', '12', '31', 'rollout-old.jsonl')
|
||||
const launchRelativePath = join('2026', '08', '05', 'rollout-launch.jsonl')
|
||||
writeManagedSession(oldRelativePath, 'old\n')
|
||||
writeManagedSession(launchRelativePath, 'launch\n')
|
||||
|
||||
const summary = await backfillManagedCodexSessionsIntoSystemHome(
|
||||
resolveCodexSessionBackfillPaths(),
|
||||
{ scanDates: [['2026', '08', '05']] }
|
||||
)
|
||||
|
||||
expect(summary).toMatchObject({ scannedFiles: 1, linkedFiles: 1, failedDirectories: 0 })
|
||||
expect(existsSync(join(getSystemSessionsRoot(), launchRelativePath))).toBe(true)
|
||||
expect(existsSync(join(getSystemSessionsRoot(), oldRelativePath))).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a not-yet-created launch date as an empty bounded pass', async () => {
|
||||
writeManagedSession(join('2025', '12', '31', 'rollout-old.jsonl'), 'old\n')
|
||||
|
||||
const summary = await backfillManagedCodexSessionsIntoSystemHome(
|
||||
resolveCodexSessionBackfillPaths(),
|
||||
{ scanDates: [['2026', '08', '05']] }
|
||||
)
|
||||
|
||||
expect(summary).toMatchObject({ scannedFiles: 0, linkedFiles: 0, failedDirectories: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('startCodexSessionBackfillInBackground', () => {
|
||||
|
|
@ -479,6 +557,81 @@ describe('startCodexSessionBackfillInBackground', () => {
|
|||
expect(existsSync(getMarkerPath())).toBe(false)
|
||||
})
|
||||
|
||||
it('defers completion while a launch lease is active', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
|
||||
const active = await startCodexSessionBackfillInBackground({
|
||||
writeCompletionMarker: false
|
||||
})
|
||||
expect(active).toMatchObject({ linkedFiles: 1 })
|
||||
expect(existsSync(getMarkerPath())).toBe(false)
|
||||
|
||||
const completed = await startCodexSessionBackfillInBackground()
|
||||
expect(completed).toMatchObject({ skippedExistingFiles: 1 })
|
||||
expect(existsSync(getMarkerPath())).toBe(true)
|
||||
})
|
||||
|
||||
it('rechecks launch state before publishing completion', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
|
||||
const summary = await startCodexSessionBackfillInBackground({
|
||||
canWriteCompletionMarker: () => false
|
||||
})
|
||||
|
||||
expect(summary).toMatchObject({ linkedFiles: 1 })
|
||||
expect(existsSync(getMarkerPath())).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps an invalidated active pass from recreating the completion marker', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
let invalidated = false
|
||||
|
||||
const raced = await startCodexSessionBackfillInBackground({
|
||||
shouldStop: () => {
|
||||
if (!invalidated) {
|
||||
invalidated = true
|
||||
invalidateCodexSessionBackfillMarker(getMarkerPath())
|
||||
}
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
expect(raced).toMatchObject({ linkedFiles: 1, stopped: false })
|
||||
expect(existsSync(getMarkerPath())).toBe(false)
|
||||
const racedAudit = readFileSync(getAuditLogPath(), 'utf-8')
|
||||
|
||||
const recovered = await startCodexSessionBackfillInBackground()
|
||||
|
||||
expect(recovered).toMatchObject({ skippedExistingFiles: 1, failedHealAuditRecords: 0 })
|
||||
expect(existsSync(getMarkerPath())).toBe(true)
|
||||
expect(readFileSync(getAuditLogPath(), 'utf-8')).toBe(racedAudit)
|
||||
})
|
||||
|
||||
it('replaces a stale marker when direct removal fails', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
await startCodexSessionBackfillInBackground()
|
||||
fsMockState.failMarkerRm = true
|
||||
|
||||
invalidateCodexSessionBackfillMarker(getMarkerPath())
|
||||
|
||||
expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 0 })
|
||||
const recovered = await startCodexSessionBackfillInBackground()
|
||||
expect(recovered).toMatchObject({ skippedExistingFiles: 1 })
|
||||
expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 })
|
||||
})
|
||||
|
||||
it('fails launch preparation when a stale marker cannot be invalidated', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
await startCodexSessionBackfillInBackground()
|
||||
fsMockState.failMarkerRm = true
|
||||
fsMockState.failMarkerReplacement = true
|
||||
|
||||
expect(() => invalidateCodexSessionBackfillMarker(getMarkerPath())).toThrow(
|
||||
'Failed to invalidate Codex session backfill marker'
|
||||
)
|
||||
expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 })
|
||||
})
|
||||
|
||||
it('writes a completion marker and skips the walk on later runs', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
|
||||
|
|
@ -487,13 +640,160 @@ describe('startCodexSessionBackfillInBackground', () => {
|
|||
expect(existsSync(getMarkerPath())).toBe(true)
|
||||
expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 })
|
||||
|
||||
// A file appearing after the marker must not be backfilled again.
|
||||
// An ordinary call remains a no-op; only a launch-scheduled pass bypasses the marker.
|
||||
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)
|
||||
|
||||
const scheduled = await startCodexSessionBackfillInBackground({
|
||||
ignoreCompletionMarker: true,
|
||||
scanDates: [['2026', '07', '01']]
|
||||
})
|
||||
expect(scheduled).toMatchObject({ scannedFiles: 1, linkedFiles: 1 })
|
||||
})
|
||||
|
||||
it('does not let a bounded pass certify older unscanned history', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-baseline.jsonl'), 'baseline\n')
|
||||
await startCodexSessionBackfillInBackground()
|
||||
|
||||
invalidateCodexSessionBackfillMarker(getMarkerPath())
|
||||
const missedRelativePath = join('2026', '06', '01', 'rollout-missed.jsonl')
|
||||
writeManagedSession(missedRelativePath, 'missed\n')
|
||||
writeManagedSession(join('2026', '08', '05', 'rollout-launch.jsonl'), 'launch\n')
|
||||
|
||||
const bounded = await startCodexSessionBackfillInBackground({
|
||||
ignoreCompletionMarker: true,
|
||||
scanDates: [['2026', '08', '05']]
|
||||
})
|
||||
expect(bounded).toMatchObject({ scannedFiles: 1, linkedFiles: 1 })
|
||||
expect(existsSync(getMarkerPath())).toBe(false)
|
||||
|
||||
const recovered = await startCodexSessionBackfillInBackground()
|
||||
expect(recovered).toMatchObject({ scannedFiles: 3, linkedFiles: 1 })
|
||||
expect(existsSync(join(getSystemSessionsRoot(), missedRelativePath))).toBe(true)
|
||||
expect(existsSync(getMarkerPath())).toBe(true)
|
||||
})
|
||||
|
||||
it('lets an explicit bounded final pass restore a certified baseline', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-baseline.jsonl'), 'baseline\n')
|
||||
await startCodexSessionBackfillInBackground()
|
||||
|
||||
invalidateCodexSessionBackfillMarker(getMarkerPath())
|
||||
writeManagedSession(join('2026', '08', '05', 'rollout-launch.jsonl'), 'launch\n')
|
||||
|
||||
const bounded = await startCodexSessionBackfillInBackground({
|
||||
ignoreCompletionMarker: true,
|
||||
scanDates: [['2026', '08', '05']],
|
||||
writeBoundedCompletionMarker: true
|
||||
})
|
||||
|
||||
expect(bounded).toMatchObject({ scannedFiles: 1, linkedFiles: 1 })
|
||||
expect(existsSync(getMarkerPath())).toBe(true)
|
||||
})
|
||||
|
||||
it('records a new heal event when a linked rollout grows in place', async () => {
|
||||
const relativePath = join('2026', '05', '26', 'rollout-growing.jsonl')
|
||||
const managedPath = writeManagedSession(relativePath, '{"id":"a"}\n')
|
||||
await startCodexSessionBackfillInBackground()
|
||||
|
||||
const firstRecord = readBackfillAuditRecords().find((record) => record.action === 'hardlink')
|
||||
invalidateCodexSessionBackfillMarker(getMarkerPath())
|
||||
appendFileSync(managedPath, '{"event":"later"}\n', 'utf-8')
|
||||
await startCodexSessionBackfillInBackground()
|
||||
|
||||
const fileRecords = readBackfillAuditRecords().filter((record) =>
|
||||
['hardlink', 'existing'].includes(record.action)
|
||||
)
|
||||
expect(fileRecords).toHaveLength(2)
|
||||
expect(fileRecords[1]).toMatchObject({ action: 'existing' })
|
||||
expect(fileRecords[1]?.fileEventId).not.toBe(firstRecord?.fileEventId)
|
||||
})
|
||||
|
||||
it('keeps repeated launch invalidations audit-stable', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
|
||||
const first = await startCodexSessionBackfillInBackground()
|
||||
expect(first).toMatchObject({ linkedFiles: 1, failedHealAuditRecords: 0 })
|
||||
const firstAudit = readFileSync(getAuditLogPath(), 'utf-8')
|
||||
|
||||
for (let pass = 0; pass < 2; pass += 1) {
|
||||
invalidateCodexSessionBackfillMarker(getMarkerPath())
|
||||
const repeated = await startCodexSessionBackfillInBackground()
|
||||
expect(repeated).toMatchObject({
|
||||
linkedFiles: 0,
|
||||
copiedFiles: 0,
|
||||
skippedExistingFiles: 1,
|
||||
failedHealAuditRecords: 0
|
||||
})
|
||||
expect(readFileSync(getAuditLogPath(), 'utf-8')).toBe(firstAudit)
|
||||
}
|
||||
|
||||
const fileRecords = readBackfillAuditRecords().filter((record) =>
|
||||
['hardlink', 'copy', 'existing'].includes(record.action)
|
||||
)
|
||||
expect(fileRecords).toEqual([
|
||||
expect.objectContaining({ action: 'hardlink', fileEventId: expect.any(String) })
|
||||
])
|
||||
})
|
||||
|
||||
it('recovers a post-install audit interruption without duplicating prior events', async () => {
|
||||
const firstRelativePath = join('2026', '05', '26', 'rollout-a.jsonl')
|
||||
const secondRelativePath = join('2026', '05', '26', 'rollout-b.jsonl')
|
||||
writeManagedSession(firstRelativePath, '{"id":"a"}\n')
|
||||
await startCodexSessionBackfillInBackground()
|
||||
|
||||
invalidateCodexSessionBackfillMarker(getMarkerPath())
|
||||
writeManagedSession(secondRelativePath, '{"id":"b"}\n')
|
||||
fsMockState.failAuditWrites = true
|
||||
|
||||
const interrupted = await startCodexSessionBackfillInBackground()
|
||||
|
||||
expect(interrupted).toMatchObject({ linkedFiles: 1, failedHealAuditRecords: 1 })
|
||||
expect(existsSync(getMarkerPath())).toBe(false)
|
||||
expect(
|
||||
readBackfillAuditRecords().filter((record) =>
|
||||
['hardlink', 'copy', 'existing'].includes(record.action)
|
||||
)
|
||||
).toHaveLength(1)
|
||||
|
||||
fsMockState.failAuditWrites = false
|
||||
const recovered = await startCodexSessionBackfillInBackground()
|
||||
|
||||
expect(recovered).toMatchObject({
|
||||
linkedFiles: 0,
|
||||
skippedExistingFiles: 2,
|
||||
failedHealAuditRecords: 0
|
||||
})
|
||||
expect(existsSync(getMarkerPath())).toBe(true)
|
||||
const recoveredFileRecords = readBackfillAuditRecords().filter((record) =>
|
||||
['hardlink', 'copy', 'existing'].includes(record.action)
|
||||
)
|
||||
expect(recoveredFileRecords).toHaveLength(2)
|
||||
expect(new Set(recoveredFileRecords.map((record) => record.target))).toEqual(
|
||||
new Set([
|
||||
join(getSystemSessionsRoot(), firstRelativePath),
|
||||
join(getSystemSessionsRoot(), secondRelativePath)
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('self-heals a zero-file marker when managed rollouts appear later', async () => {
|
||||
const empty = await startCodexSessionBackfillInBackground()
|
||||
expect(empty).toMatchObject({ scannedFiles: 0, linkedFiles: 0 })
|
||||
expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({
|
||||
summary: { scannedFiles: 0 }
|
||||
})
|
||||
|
||||
writeManagedSession(join('2026', '07', '28', 'rollout-later.jsonl'), '{"id":"later"}\n')
|
||||
const healed = await startCodexSessionBackfillInBackground()
|
||||
|
||||
expect(healed).toMatchObject({ scannedFiles: 1, linkedFiles: 1 })
|
||||
expect(
|
||||
existsSync(join(getSystemSessionsRoot(), '2026', '07', '28', 'rollout-later.jsonl'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('recovers an installed rollout after the completion marker write fails', async () => {
|
||||
|
|
@ -534,19 +834,52 @@ describe('startCodexSessionBackfillInBackground', () => {
|
|||
|
||||
it('does not retry a stable hardlink-less filesystem limitation', async () => {
|
||||
fsMockState.failLink = true
|
||||
fsMockState.failInstallLink = true
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
|
||||
const first = await startCodexSessionBackfillInBackground()
|
||||
expect(first).toMatchObject({ skippedUnsupportedFilesystemFiles: 1, failedFiles: 0 })
|
||||
expect(existsSync(getMarkerPath())).toBe(true)
|
||||
|
||||
const firstAudit = readFileSync(getAuditLogPath(), 'utf-8')
|
||||
invalidateCodexSessionBackfillMarker(getMarkerPath())
|
||||
const repeated = await startCodexSessionBackfillInBackground()
|
||||
|
||||
expect(repeated).toMatchObject({ skippedUnsupportedFilesystemFiles: 1, failedFiles: 0 })
|
||||
expect(readFileSync(getAuditLogPath(), 'utf-8')).toBe(firstAudit)
|
||||
|
||||
expect(await startCodexSessionBackfillInBackground()).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps repeated unchanged per-file failures audit-stable', async () => {
|
||||
fsMockState.failLinkTransiently = true
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
|
||||
const first = await startCodexSessionBackfillInBackground()
|
||||
expect(first).toMatchObject({ failedFiles: 1 })
|
||||
const firstAudit = readFileSync(getAuditLogPath(), 'utf-8')
|
||||
|
||||
const repeated = await startCodexSessionBackfillInBackground()
|
||||
|
||||
expect(repeated).toMatchObject({ failedFiles: 1 })
|
||||
expect(readFileSync(getAuditLogPath(), 'utf-8')).toBe(firstAudit)
|
||||
})
|
||||
|
||||
it('records a new failure event when the source file changes', async () => {
|
||||
fsMockState.failLinkTransiently = true
|
||||
const relativePath = join('2026', '05', '26', 'rollout-a.jsonl')
|
||||
writeManagedSession(relativePath, '{"id":"a"}\n')
|
||||
await startCodexSessionBackfillInBackground()
|
||||
|
||||
writeManagedSession(relativePath, '{"id":"a","changed":true}\n')
|
||||
await startCodexSessionBackfillInBackground()
|
||||
|
||||
const failedRecords = readBackfillAuditRecords().filter((record) => record.action === 'failed')
|
||||
expect(failedRecords).toHaveLength(2)
|
||||
expect(new Set(failedRecords.map((record) => record.diagnosticEventId)).size).toBe(2)
|
||||
})
|
||||
|
||||
it('leaves the marker unset when any file fails so the next startup retries', async () => {
|
||||
fsMockState.failLink = true
|
||||
fsMockState.failCopy = true
|
||||
fsMockState.failLinkTransiently = true
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
|
||||
const first = await startCodexSessionBackfillInBackground()
|
||||
|
|
@ -555,8 +888,7 @@ describe('startCodexSessionBackfillInBackground', () => {
|
|||
const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl')
|
||||
expect(existsSync(targetPath)).toBe(false)
|
||||
|
||||
fsMockState.failLink = false
|
||||
fsMockState.failCopy = false
|
||||
fsMockState.failLinkTransiently = false
|
||||
const second = await startCodexSessionBackfillInBackground()
|
||||
expect(second).toMatchObject({ linkedFiles: 1, failedFiles: 0 })
|
||||
expect(readFileSync(targetPath, 'utf-8')).toBe('{"id":"a"}\n')
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
import { link, lstat, mkdir } from 'node:fs/promises'
|
||||
import { dirname, join, relative, sep } from 'node:path'
|
||||
import { dirname, join, relative } from 'node:path'
|
||||
import {
|
||||
getCodexSessionBackfillStateDirPath,
|
||||
getOrcaManagedCodexHomePath,
|
||||
getSystemCodexHomePath
|
||||
} from './codex-home-paths'
|
||||
import {
|
||||
appendCodexSessionHealAuditRecord,
|
||||
createCodexSessionBackfillAuditWriter,
|
||||
recordExistingCodexSessionForHeal,
|
||||
type CodexSessionBackfillAuditWriter
|
||||
} from './codex-session-backfill-audit'
|
||||
createCodexSessionBackfillAuditPass,
|
||||
readCodexSessionTargetStat,
|
||||
type CodexSessionBackfillAuditPass
|
||||
} from './codex-session-backfill-audit-pass'
|
||||
import { describeCodexSessionBackfillErrorCode } from './codex-session-backfill-audit'
|
||||
import {
|
||||
copySessionFileWithoutOverwrite,
|
||||
isAtomicNoReplaceUnsupportedError
|
||||
} from './codex-session-backfill-copy'
|
||||
import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing'
|
||||
isCodexSessionRolloutPath,
|
||||
listCodexSessionBackfillFilesForDates
|
||||
} from './codex-session-backfill-date'
|
||||
import {
|
||||
captureCodexSessionBackfillMarkerGeneration,
|
||||
hasCompletedCodexSessionBackfillMarker,
|
||||
writeCodexSessionBackfillMarker
|
||||
writeCodexSessionBackfillMarker as writeBackfillMarker
|
||||
} from './codex-session-backfill-marker'
|
||||
import type {
|
||||
CodexSessionBackfillOptions,
|
||||
|
|
@ -87,7 +87,11 @@ async function runCodexSessionBackfillOncePerHost(
|
|||
systemCodexHomePathOverride?: string
|
||||
): Promise<CodexSessionBackfillSummary | null> {
|
||||
const paths = resolveCodexSessionBackfillPaths(systemCodexHomePathOverride)
|
||||
if (hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot)) {
|
||||
const markerGeneration = captureCodexSessionBackfillMarkerGeneration()
|
||||
if (
|
||||
!options.ignoreCompletionMarker &&
|
||||
hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const summary = await backfillManagedCodexSessionsIntoSystemHome(paths, options)
|
||||
|
|
@ -96,11 +100,14 @@ async function runCodexSessionBackfillOncePerHost(
|
|||
if (
|
||||
!summary.stopped &&
|
||||
options.shouldStop?.() !== true &&
|
||||
options.writeCompletionMarker !== false &&
|
||||
options.canWriteCompletionMarker?.() !== false &&
|
||||
(options.scanDates === undefined || options.writeBoundedCompletionMarker === true) &&
|
||||
summary.failedFiles === 0 &&
|
||||
summary.failedDirectories === 0 &&
|
||||
summary.failedHealAuditRecords === 0
|
||||
) {
|
||||
writeCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot, summary)
|
||||
writeBackfillMarker(paths.markerPath, paths.systemSessionsRoot, summary, markerGeneration)
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
|
@ -109,8 +116,8 @@ async function runCodexSessionBackfillOncePerHost(
|
|||
* 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.
|
||||
* nothing in either home is deleted or moved. A hardlink keeps mutable rollout
|
||||
* contents coherent; cross-volume snapshots are skipped as unsupported.
|
||||
*/
|
||||
export async function backfillManagedCodexSessionsIntoSystemHome(
|
||||
paths: CodexSessionBackfillPaths,
|
||||
|
|
@ -129,22 +136,18 @@ export async function backfillManagedCodexSessionsIntoSystemHome(
|
|||
failedFiles: 0,
|
||||
failedHealAuditRecords: 0
|
||||
}
|
||||
const appendAuditRecord = createCodexSessionBackfillAuditWriter(paths.auditLogPath)
|
||||
const auditPass = await createCodexSessionBackfillAuditPass(paths.auditLogPath)
|
||||
const ensuredTargetDirectories = new Set<string>()
|
||||
const managedSessionsRootExists = await checkManagedSessionsRoot(
|
||||
paths,
|
||||
summary,
|
||||
appendAuditRecord
|
||||
)
|
||||
const managedSessionsRootExists = await checkManagedSessionsRoot(paths, summary, auditPass)
|
||||
if (managedSessionsRootExists) {
|
||||
for await (const managedSessionFilePath of listCodexSessionJsonlFilesIncrementally(
|
||||
for await (const managedSessionFilePath of listCodexSessionBackfillFilesForDates(
|
||||
paths.managedSessionsRoot,
|
||||
options,
|
||||
async (directoryPath, error) => {
|
||||
// Why: a partial walk must remain retryable; otherwise an unreadable
|
||||
// date directory would be silently omitted behind a completion marker.
|
||||
summary.failedDirectories += 1
|
||||
await appendAuditRecord({
|
||||
await auditPass.appendRecord({
|
||||
action: 'scan-failed',
|
||||
source: directoryPath,
|
||||
error: describeError(error)
|
||||
|
|
@ -158,7 +161,7 @@ export async function backfillManagedCodexSessionsIntoSystemHome(
|
|||
break
|
||||
}
|
||||
summary.scannedFiles += 1
|
||||
if (!isCodexRolloutPath(paths.managedSessionsRoot, managedSessionFilePath)) {
|
||||
if (!isCodexSessionRolloutPath(paths.managedSessionsRoot, managedSessionFilePath)) {
|
||||
summary.skippedUnexpectedFiles += 1
|
||||
continue
|
||||
}
|
||||
|
|
@ -168,13 +171,13 @@ export async function backfillManagedCodexSessionsIntoSystemHome(
|
|||
paths,
|
||||
managedSessionFilePath,
|
||||
summary,
|
||||
appendAuditRecord,
|
||||
ensuredTargetDirectories
|
||||
ensuredTargetDirectories,
|
||||
auditPass
|
||||
)
|
||||
}
|
||||
}
|
||||
summary.stopped ||= options.shouldStop?.() === true
|
||||
await appendAuditRecord({ action: 'run-summary', ...summary })
|
||||
await auditPass.finish(summary)
|
||||
// Why: opt-out can land while the async summary append is pending; carry it
|
||||
// back to the marker gate so a managed launch cannot be hidden by stale completion.
|
||||
summary.stopped ||= options.shouldStop?.() === true
|
||||
|
|
@ -184,7 +187,7 @@ export async function backfillManagedCodexSessionsIntoSystemHome(
|
|||
async function checkManagedSessionsRoot(
|
||||
paths: CodexSessionBackfillPaths,
|
||||
summary: CodexSessionBackfillSummary,
|
||||
appendAuditRecord: CodexSessionBackfillAuditWriter
|
||||
auditPass: CodexSessionBackfillAuditPass
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await lstat(paths.managedSessionsRoot)
|
||||
|
|
@ -196,7 +199,7 @@ async function checkManagedSessionsRoot(
|
|||
// Why: existsSync collapses access failures into "missing," which could
|
||||
// permanently hide sessions behind an incorrect completion marker.
|
||||
summary.failedDirectories += 1
|
||||
await appendAuditRecord({
|
||||
await auditPass.appendRecord({
|
||||
action: 'scan-failed',
|
||||
source: paths.managedSessionsRoot,
|
||||
error: describeError(error)
|
||||
|
|
@ -205,26 +208,12 @@ async function checkManagedSessionsRoot(
|
|||
}
|
||||
}
|
||||
|
||||
function isCodexRolloutPath(sessionsRoot: string, filePath: string): boolean {
|
||||
const pathParts = relative(sessionsRoot, filePath).split(sep)
|
||||
if (pathParts.length !== 4) {
|
||||
return false
|
||||
}
|
||||
const [year, month, day, fileName] = pathParts
|
||||
return (
|
||||
/^\d{4}$/.test(year) &&
|
||||
/^\d{2}$/.test(month) &&
|
||||
/^\d{2}$/.test(day) &&
|
||||
/^rollout-.+\.jsonl$/.test(fileName)
|
||||
)
|
||||
}
|
||||
|
||||
async function backfillOneManagedSessionFile(
|
||||
paths: CodexSessionBackfillPaths,
|
||||
managedSessionFilePath: string,
|
||||
summary: CodexSessionBackfillSummary,
|
||||
appendAuditRecord: CodexSessionBackfillAuditWriter,
|
||||
ensuredTargetDirectories: Set<string>
|
||||
ensuredTargetDirectories: Set<string>,
|
||||
auditPass: CodexSessionBackfillAuditPass
|
||||
): Promise<void> {
|
||||
if (await isSymbolicLink(managedSessionFilePath)) {
|
||||
// Why: bridge-created symlinks already point at a file in the user's own
|
||||
|
|
@ -234,16 +223,18 @@ async function backfillOneManagedSessionFile(
|
|||
}
|
||||
const relativePath = relative(paths.managedSessionsRoot, managedSessionFilePath)
|
||||
const systemSessionFilePath = join(paths.systemSessionsRoot, relativePath)
|
||||
if (await pathEntryExists(systemSessionFilePath)) {
|
||||
await recordExistingCodexSessionForHeal(
|
||||
appendAuditRecord,
|
||||
const existingTargetStat = await readCodexSessionTargetStat(systemSessionFilePath)
|
||||
if (existingTargetStat) {
|
||||
await auditPass.recordExisting(
|
||||
summary,
|
||||
managedSessionFilePath,
|
||||
systemSessionFilePath
|
||||
systemSessionFilePath,
|
||||
existingTargetStat
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let linkAttempted = false
|
||||
try {
|
||||
const targetDirectory = dirname(systemSessionFilePath)
|
||||
if (!ensuredTargetDirectories.has(targetDirectory)) {
|
||||
|
|
@ -252,66 +243,56 @@ async function backfillOneManagedSessionFile(
|
|||
await mkdir(targetDirectory, { recursive: true })
|
||||
ensuredTargetDirectories.add(targetDirectory)
|
||||
}
|
||||
linkAttempted = true
|
||||
await link(managedSessionFilePath, systemSessionFilePath)
|
||||
summary.linkedFiles += 1
|
||||
await appendCodexSessionHealAuditRecord(appendAuditRecord, summary, {
|
||||
action: 'hardlink',
|
||||
source: managedSessionFilePath,
|
||||
target: systemSessionFilePath
|
||||
})
|
||||
await auditPass.recordPublished(
|
||||
summary,
|
||||
'hardlink',
|
||||
managedSessionFilePath,
|
||||
systemSessionFilePath
|
||||
)
|
||||
} catch (linkError) {
|
||||
if (isExistsError(linkError)) {
|
||||
if (linkAttempted && isExistsError(linkError)) {
|
||||
// Why: another window can publish the target after our existence probe;
|
||||
// enqueue it here too in case that writer died before its audit append.
|
||||
await recordExistingCodexSessionForHeal(
|
||||
appendAuditRecord,
|
||||
await auditPass.recordExisting(
|
||||
summary,
|
||||
managedSessionFilePath,
|
||||
systemSessionFilePath
|
||||
systemSessionFilePath,
|
||||
await readCodexSessionTargetStat(systemSessionFilePath)
|
||||
)
|
||||
return
|
||||
}
|
||||
if (isNotFoundError(linkError)) {
|
||||
ensuredTargetDirectories.delete(dirname(systemSessionFilePath))
|
||||
}
|
||||
try {
|
||||
// Why: cross-volume copies are staged so failures cannot strand a
|
||||
// truncated rollout, then installed without overwriting collisions.
|
||||
await copySessionFileWithoutOverwrite(managedSessionFilePath, systemSessionFilePath)
|
||||
summary.copiedFiles += 1
|
||||
await appendCodexSessionHealAuditRecord(appendAuditRecord, summary, {
|
||||
action: 'copy',
|
||||
source: managedSessionFilePath,
|
||||
target: systemSessionFilePath
|
||||
})
|
||||
} catch (copyError) {
|
||||
if (isExistsError(copyError)) {
|
||||
await recordExistingCodexSessionForHeal(
|
||||
appendAuditRecord,
|
||||
summary,
|
||||
managedSessionFilePath,
|
||||
systemSessionFilePath
|
||||
)
|
||||
return
|
||||
}
|
||||
if (isAtomicNoReplaceUnsupportedError(copyError)) {
|
||||
summary.skippedUnsupportedFilesystemFiles += 1
|
||||
await appendAuditRecord({
|
||||
const sourceStat = await readCodexSessionTargetStat(managedSessionFilePath)
|
||||
if (linkAttempted && isUnsupportedHardlinkError(linkError)) {
|
||||
// Why: a mutable rollout cannot be kept coherent by a cross-volume snapshot.
|
||||
summary.skippedUnsupportedFilesystemFiles += 1
|
||||
await auditPass.recordDiagnostic(
|
||||
{
|
||||
action: 'copy-unsupported',
|
||||
source: managedSessionFilePath,
|
||||
target: systemSessionFilePath
|
||||
})
|
||||
return
|
||||
}
|
||||
summary.failedFiles += 1
|
||||
await appendAuditRecord({
|
||||
target: systemSessionFilePath,
|
||||
linkErrorCode: describeCodexSessionBackfillErrorCode(linkError)
|
||||
},
|
||||
sourceStat
|
||||
)
|
||||
return
|
||||
}
|
||||
summary.failedFiles += 1
|
||||
await auditPass.recordDiagnostic(
|
||||
{
|
||||
action: 'failed',
|
||||
source: managedSessionFilePath,
|
||||
target: systemSessionFilePath,
|
||||
error: describeError(copyError),
|
||||
linkError: describeError(linkError)
|
||||
})
|
||||
}
|
||||
linkError: describeError(linkError),
|
||||
linkErrorCode: describeCodexSessionBackfillErrorCode(linkError)
|
||||
},
|
||||
sourceStat
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -323,16 +304,6 @@ async function isSymbolicLink(filePath: string): Promise<boolean> {
|
|||
}
|
||||
}
|
||||
|
||||
/** Existence via lstat so a broken symlink at the target still counts as taken. */
|
||||
async function pathEntryExists(entryPath: string): Promise<boolean> {
|
||||
try {
|
||||
await lstat(entryPath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isExistsError(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
|
||||
}
|
||||
|
|
@ -341,6 +312,11 @@ function isNotFoundError(error: unknown): boolean {
|
|||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
|
||||
}
|
||||
|
||||
function isUnsupportedHardlinkError(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code
|
||||
return code === 'EXDEV' || code === 'ENOTSUP' || code === 'EOPNOTSUPP' || code === 'ENOSYS'
|
||||
}
|
||||
|
||||
function describeError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,10 +72,11 @@ export function collectPendingHealThreads(paths: CodexSessionIndexHealPaths): Pe
|
|||
const threadId = match[2].toLowerCase()
|
||||
const auditRecordId = typeof line.recordId === 'string' ? line.recordId : null
|
||||
if (
|
||||
processed.healedThreadIds.has(threadId) ||
|
||||
(auditRecordId
|
||||
? processed.missingAuditRecords.has(`${threadId}\0${auditRecordId}`)
|
||||
: processed.legacyMissingThreadIds.has(threadId))
|
||||
auditRecordId
|
||||
? processed.healedAuditRecords.has(`${threadId}\0${auditRecordId}`) ||
|
||||
processed.missingAuditRecords.has(`${threadId}\0${auditRecordId}`)
|
||||
: processed.legacyHealedThreadIds.has(threadId) ||
|
||||
processed.legacyMissingThreadIds.has(threadId)
|
||||
) {
|
||||
// Why: only the newest publication event for a thread matters. A later
|
||||
// processed event must displace an older pending event from this scan.
|
||||
|
|
@ -94,11 +95,13 @@ function lastPathSegment(filePath: string): string {
|
|||
}
|
||||
|
||||
function readProcessedHealThreads(paths: CodexSessionIndexHealPaths): {
|
||||
healedThreadIds: Set<string>
|
||||
healedAuditRecords: Set<string>
|
||||
legacyHealedThreadIds: Set<string>
|
||||
missingAuditRecords: Set<string>
|
||||
legacyMissingThreadIds: Set<string>
|
||||
} {
|
||||
const healedThreadIds = new Set<string>()
|
||||
const healedAuditRecords = new Set<string>()
|
||||
const legacyHealedThreadIds = new Set<string>()
|
||||
const missingAuditRecords = new Set<string>()
|
||||
const legacyMissingThreadIds = new Set<string>()
|
||||
const expectedRoot = normalizeRuntimePathForComparison(paths.systemSessionsRoot)
|
||||
|
|
@ -112,7 +115,11 @@ function readProcessedHealThreads(paths: CodexSessionIndexHealPaths): {
|
|||
) {
|
||||
const threadId = line.threadId.toLowerCase()
|
||||
if (line.outcome === 'healed') {
|
||||
healedThreadIds.add(threadId)
|
||||
if (typeof line.auditRecordId === 'string') {
|
||||
healedAuditRecords.add(`${threadId}\0${line.auditRecordId}`)
|
||||
} else {
|
||||
legacyHealedThreadIds.add(threadId)
|
||||
}
|
||||
} else if (typeof line.auditRecordId === 'string') {
|
||||
missingAuditRecords.add(`${threadId}\0${line.auditRecordId}`)
|
||||
} else {
|
||||
|
|
@ -120,7 +127,12 @@ function readProcessedHealThreads(paths: CodexSessionIndexHealPaths): {
|
|||
}
|
||||
}
|
||||
}
|
||||
return { healedThreadIds, missingAuditRecords, legacyMissingThreadIds }
|
||||
return {
|
||||
healedAuditRecords,
|
||||
legacyHealedThreadIds,
|
||||
missingAuditRecords,
|
||||
legacyMissingThreadIds
|
||||
}
|
||||
}
|
||||
|
||||
export function appendHealLedgerRecord(
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ describe('runCodexSessionIndexHeal', () => {
|
|||
expect(marker.healedThreads).toBe(3)
|
||||
})
|
||||
|
||||
it('is a no-op when the marker matches the audit ledger size', async () => {
|
||||
it('keeps second and later passes cheap when the audit ledger is unchanged', async () => {
|
||||
const rig = createHealRig({
|
||||
auditedThreads: [{ stamp: '2026-07-01T10-00-00', id: threadId('1') }]
|
||||
})
|
||||
|
|
@ -245,11 +245,38 @@ describe('runCodexSessionIndexHeal', () => {
|
|||
buildInvocation: rig.buildInvocation,
|
||||
interBatchDelayMs: 0
|
||||
})
|
||||
const third = await runCodexSessionIndexHeal(rig.paths, {
|
||||
buildInvocation: rig.buildInvocation,
|
||||
interBatchDelayMs: 0
|
||||
})
|
||||
expect(second.outcome).toBe('up-to-date')
|
||||
// One spawn from the first run only — the no-op run must not hit the CLI.
|
||||
expect(third.outcome).toBe('up-to-date')
|
||||
// One spawn from the first run only — no-op runs must not hit the CLI.
|
||||
expect(rig.readLog().serverStarts).toBe(1)
|
||||
})
|
||||
|
||||
it('re-reads a healed thread after a later publication event', async () => {
|
||||
const id = threadId('1')
|
||||
const stamp = '2026-07-01T10-00-00'
|
||||
const rig = createHealRig({ auditedThreads: [{ stamp, id }] })
|
||||
await runCodexSessionIndexHeal(rig.paths, {
|
||||
buildInvocation: rig.buildInvocation,
|
||||
interBatchDelayMs: 0
|
||||
})
|
||||
|
||||
await createCodexSessionBackfillAuditWriter(rig.paths.auditLogPath)({
|
||||
action: 'existing',
|
||||
target: rolloutTarget(rig.paths.systemSessionsRoot, stamp, id)
|
||||
})
|
||||
const repeated = await runCodexSessionIndexHeal(rig.paths, {
|
||||
buildInvocation: rig.buildInvocation,
|
||||
interBatchDelayMs: 0
|
||||
})
|
||||
|
||||
expect(repeated).toMatchObject({ pendingThreads: 1, healedThreads: 1 })
|
||||
expect(rig.readLog().threadIds).toEqual([id, id])
|
||||
})
|
||||
|
||||
it('resumes only unprocessed sessions when the audit ledger grows', async () => {
|
||||
const rig = createHealRig({
|
||||
auditedThreads: [{ stamp: '2026-07-01T10-00-00', id: threadId('1') }]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { CodexSessionBackfillOptions } from './codex-session-backfill-types'
|
||||
import { createCodexSessionMigrationScheduler } from './codex-session-migration-scheduler'
|
||||
|
||||
describe('createCodexSessionMigrationScheduler', () => {
|
||||
|
|
@ -8,12 +9,14 @@ describe('createCodexSessionMigrationScheduler', () => {
|
|||
|
||||
it('runs after a managed-account startup switches to host system default', async () => {
|
||||
let eligible = false
|
||||
const startBackfill = vi.fn().mockResolvedValue(null)
|
||||
const prepareScheduledRun = vi.fn()
|
||||
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
|
||||
const startIndexHeal = vi.fn().mockResolvedValue(null)
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => eligible,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
prepareScheduledRun,
|
||||
startBackfill,
|
||||
startIndexHeal
|
||||
})
|
||||
|
|
@ -26,6 +29,222 @@ describe('createCodexSessionMigrationScheduler', () => {
|
|||
scheduler.requestRun()
|
||||
await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce())
|
||||
expect(startBackfill).toHaveBeenCalledOnce()
|
||||
expect(prepareScheduledRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('schedules a delayed rerun after a shared-home launch', async () => {
|
||||
vi.setSystemTime(new Date(2026, 7, 5, 10, 0, 0))
|
||||
const prepareScheduledRun = vi.fn()
|
||||
const finishScheduledRun = vi.fn()
|
||||
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
|
||||
const startIndexHeal = vi.fn().mockResolvedValue(null)
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
prepareScheduledRun,
|
||||
finishScheduledRun,
|
||||
startBackfill,
|
||||
startIndexHeal,
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.scheduleRun()
|
||||
scheduler.scheduleRun()
|
||||
await vi.advanceTimersByTimeAsync(999)
|
||||
expect(startBackfill).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce())
|
||||
expect(startBackfill).toHaveBeenCalledOnce()
|
||||
expect(startBackfill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scanDates: [['2026', '08', '05']],
|
||||
ignoreCompletionMarker: true
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
expect(prepareScheduledRun).toHaveBeenCalledOnce()
|
||||
await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('covers both launch and run dates when a delayed pass crosses midnight', async () => {
|
||||
vi.setSystemTime(new Date('2026-08-05T23:59:59.500Z'))
|
||||
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
startBackfill,
|
||||
startIndexHeal: vi.fn().mockResolvedValue(null),
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.scheduleRun()
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(startBackfill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scanDates: [
|
||||
['2026', '08', '05'],
|
||||
['2026', '08', '06']
|
||||
]
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps launch passes full when no completed baseline can cover older failures', async () => {
|
||||
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
startBackfill,
|
||||
startIndexHeal: vi.fn().mockResolvedValue(null),
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.scheduleRun(true)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(startBackfill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ scanDates: undefined }),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('upgrades a bounded pass when its target changed before the timer fired', async () => {
|
||||
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => '/moved-history',
|
||||
prepareScheduledRun: () => true,
|
||||
startBackfill,
|
||||
startIndexHeal: vi.fn().mockResolvedValue(null),
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.scheduleRun()
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(startBackfill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ scanDates: undefined }),
|
||||
'/moved-history'
|
||||
)
|
||||
})
|
||||
|
||||
it('delays the startup run from the latest shared-home launch', async () => {
|
||||
const prepareScheduledRun = vi.fn()
|
||||
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
|
||||
const startIndexHeal = vi.fn().mockResolvedValue(null)
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
prepareScheduledRun,
|
||||
startBackfill,
|
||||
startIndexHeal,
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.scheduleInitialRun()
|
||||
await vi.advanceTimersByTimeAsync(999)
|
||||
scheduler.scheduleRun()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(startBackfill).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(999)
|
||||
await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce())
|
||||
expect(startBackfill).toHaveBeenCalledOnce()
|
||||
expect(prepareScheduledRun).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('preserves a delayed launch rerun while an earlier migration is active', async () => {
|
||||
let releaseFirstIndexHeal: (() => void) | undefined
|
||||
const prepareScheduledRun = vi.fn()
|
||||
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
|
||||
const startIndexHeal = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseFirstIndexHeal = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(null)
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
prepareScheduledRun,
|
||||
startBackfill,
|
||||
startIndexHeal,
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.requestRun()
|
||||
await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce())
|
||||
|
||||
scheduler.scheduleRun()
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(startBackfill).toHaveBeenCalledOnce()
|
||||
expect(prepareScheduledRun).not.toHaveBeenCalled()
|
||||
|
||||
releaseFirstIndexHeal?.()
|
||||
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2))
|
||||
await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledTimes(2))
|
||||
expect(prepareScheduledRun).toHaveBeenCalledOnce()
|
||||
expect(prepareScheduledRun.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
startBackfill.mock.invocationCallOrder[1]!
|
||||
)
|
||||
})
|
||||
|
||||
it('prepares a delayed launch pass after an earlier migration settles before the timer', async () => {
|
||||
let releaseFirstBackfill: (() => void) | undefined
|
||||
let markerPresent = false
|
||||
const prepareScheduledRun = vi.fn(() => {
|
||||
markerPresent = false
|
||||
})
|
||||
const startBackfill = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseFirstBackfill = () => {
|
||||
markerPresent = true
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
)
|
||||
.mockImplementationOnce(async () => {
|
||||
expect(markerPresent).toBe(false)
|
||||
})
|
||||
const startIndexHeal = vi.fn().mockResolvedValue(null)
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
prepareScheduledRun,
|
||||
startBackfill,
|
||||
startIndexHeal,
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.requestRun()
|
||||
scheduler.scheduleRun()
|
||||
releaseFirstBackfill?.()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(markerPresent).toBe(true)
|
||||
expect(startIndexHeal).toHaveBeenCalledOnce()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2))
|
||||
expect(prepareScheduledRun).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('coalesces concurrent run requests and stops before index heal after opt-out', async () => {
|
||||
|
|
@ -90,4 +309,233 @@ describe('createCodexSessionMigrationScheduler', () => {
|
|||
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2))
|
||||
await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('preserves scheduled identity and target recovery after a stopped pass', async () => {
|
||||
let eligible = true
|
||||
let target = '/old-history'
|
||||
let releaseFirstBackfill: ((result: { stopped: boolean }) => void) | undefined
|
||||
const prepareScheduledRun = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true)
|
||||
const finishScheduledRun = vi.fn()
|
||||
const startBackfill = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ stopped: boolean }>((resolve) => {
|
||||
releaseFirstBackfill = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ stopped: false })
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => eligible,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => target,
|
||||
prepareScheduledRun,
|
||||
finishScheduledRun,
|
||||
startBackfill,
|
||||
startIndexHeal: vi.fn().mockResolvedValue(null),
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.scheduleRun()
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
const firstOptions = startBackfill.mock.calls[0]?.[0]
|
||||
eligible = false
|
||||
expect(firstOptions?.shouldStop()).toBe(true)
|
||||
target = '/new-history'
|
||||
releaseFirstBackfill?.({ stopped: true })
|
||||
await vi.waitFor(() => expect(prepareScheduledRun).toHaveBeenCalledOnce())
|
||||
expect(finishScheduledRun).not.toHaveBeenCalled()
|
||||
|
||||
eligible = true
|
||||
scheduler.requestRun()
|
||||
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2))
|
||||
expect(startBackfill).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ ignoreCompletionMarker: true, scanDates: undefined }),
|
||||
'/new-history'
|
||||
)
|
||||
await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('finishes a launch generation only after its PTY exits and every date is rescanned', async () => {
|
||||
vi.setSystemTime(new Date('2026-08-05T23:59:59Z'))
|
||||
const finishScheduledRun = vi.fn()
|
||||
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
prepareScheduledRun: vi.fn(),
|
||||
finishScheduledRun,
|
||||
startBackfill,
|
||||
startIndexHeal: vi.fn().mockResolvedValue(null),
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.beginLaunch('pty-1')
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledOnce())
|
||||
expect(startBackfill).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ writeCompletionMarker: false }),
|
||||
undefined
|
||||
)
|
||||
expect(finishScheduledRun).not.toHaveBeenCalled()
|
||||
|
||||
vi.setSystemTime(new Date('2026-08-07T01:00:00Z'))
|
||||
scheduler.finishLaunch('pty-1')
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2))
|
||||
expect(startBackfill).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
scanDates: [
|
||||
['2026', '08', '05'],
|
||||
['2026', '08', '06'],
|
||||
['2026', '08', '07']
|
||||
],
|
||||
ignoreCompletionMarker: true,
|
||||
writeCompletionMarker: true,
|
||||
writeBoundedCompletionMarker: true
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('keeps a failed full scan required for the final launch pass', async () => {
|
||||
const finishScheduledRun = vi.fn()
|
||||
const startBackfill = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ stopped: false, failedFiles: 1 })
|
||||
.mockResolvedValueOnce({ stopped: false, failedFiles: 0 })
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
prepareScheduledRun: () => false,
|
||||
finishScheduledRun,
|
||||
startBackfill,
|
||||
startIndexHeal: vi.fn().mockResolvedValue(null),
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.beginLaunch('pty-1', true)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledOnce())
|
||||
expect(startBackfill).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ scanDates: undefined, writeBoundedCompletionMarker: false }),
|
||||
undefined
|
||||
)
|
||||
|
||||
scheduler.finishLaunch('pty-1')
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2))
|
||||
expect(startBackfill).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ scanDates: undefined, writeBoundedCompletionMarker: false }),
|
||||
undefined
|
||||
)
|
||||
await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('blocks marker publication when a newer launch pass is pending', async () => {
|
||||
let releaseBackfill: ((result: { stopped: boolean }) => void) | undefined
|
||||
const startBackfill = vi.fn(
|
||||
(_options: CodexSessionBackfillOptions) =>
|
||||
new Promise<{ stopped: boolean }>((resolve) => {
|
||||
releaseBackfill = resolve
|
||||
})
|
||||
)
|
||||
const startIndexHeal = vi.fn().mockResolvedValue(null)
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
startBackfill,
|
||||
startIndexHeal,
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.scheduleRun()
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
const firstOptions = startBackfill.mock.calls[0]?.[0]
|
||||
expect(firstOptions?.canWriteCompletionMarker?.()).toBe(true)
|
||||
|
||||
scheduler.beginLaunch('pty-2')
|
||||
expect(firstOptions?.canWriteCompletionMarker?.()).toBe(false)
|
||||
scheduler.finishLaunch('pty-2')
|
||||
expect(firstOptions?.canWriteCompletionMarker?.()).toBe(false)
|
||||
|
||||
releaseBackfill?.({ stopped: false })
|
||||
await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('turns an exit-before-begin race into a full recovery pass', async () => {
|
||||
const finishScheduledRun = vi.fn()
|
||||
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
finishScheduledRun,
|
||||
startBackfill,
|
||||
startIndexHeal: vi.fn().mockResolvedValue(null),
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.finishLaunch('pty-fast-exit', 2)
|
||||
scheduler.beginLaunch('pty-fast-exit', false, new Date(), 1)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(startBackfill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ scanDates: undefined, writeBoundedCompletionMarker: false }),
|
||||
undefined
|
||||
)
|
||||
await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('does not consume an exit from an earlier stable-id incarnation', async () => {
|
||||
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
startBackfill,
|
||||
startIndexHeal: vi.fn().mockResolvedValue(null),
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.finishLaunch('stable-pty', 1)
|
||||
scheduler.beginLaunch('stable-pty', false, new Date(), 2)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(startBackfill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ writeCompletionMarker: false }),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves the pre-spawn UTC date when launch setup crosses midnight', async () => {
|
||||
vi.setSystemTime(new Date('2026-08-06T00:00:01Z'))
|
||||
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
startBackfill,
|
||||
startIndexHeal: vi.fn().mockResolvedValue(null),
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.beginLaunch('pty-midnight', false, new Date('2026-08-05T23:59:59Z'))
|
||||
scheduler.finishLaunch('pty-midnight')
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(startBackfill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scanDates: [
|
||||
['2026', '08', '05'],
|
||||
['2026', '08', '06']
|
||||
]
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,12 +1,29 @@
|
|||
import type { CodexSessionBackfillOptions } from './codex-session-backfill-types'
|
||||
import { getCodexSessionBackfillDate } from './codex-session-backfill-date'
|
||||
import type {
|
||||
CodexSessionBackfillDate,
|
||||
CodexSessionBackfillOptions
|
||||
} from './codex-session-backfill-types'
|
||||
|
||||
type MigrationRun = (
|
||||
options: CodexSessionBackfillOptions,
|
||||
systemCodexHomePathOverride?: string
|
||||
) => Promise<unknown>
|
||||
|
||||
const EARLY_PTY_EXIT_RETENTION_MS = 60_000
|
||||
const MAX_EARLY_PTY_EXITS = 256
|
||||
|
||||
type EarlyPtyExit = { sequence: number; recordedAt: number }
|
||||
|
||||
export type CodexSessionMigrationScheduler = {
|
||||
beginLaunch(
|
||||
leaseId: string,
|
||||
fullScanRequired?: boolean,
|
||||
startedAt?: Date,
|
||||
startedSequence?: number
|
||||
): void
|
||||
finishLaunch(leaseId: string, exitSequence?: number): void
|
||||
scheduleInitialRun(): void
|
||||
scheduleRun(fullScanRequired?: boolean): void
|
||||
requestRun(): void
|
||||
}
|
||||
|
||||
|
|
@ -14,24 +31,64 @@ export function createCodexSessionMigrationScheduler(args: {
|
|||
isEligible: () => boolean
|
||||
isQuitting: () => boolean
|
||||
resolveSystemCodexHomePathOverride: () => string | undefined
|
||||
prepareScheduledRun?: () => boolean | void
|
||||
finishScheduledRun?: () => void
|
||||
startBackfill: MigrationRun
|
||||
startIndexHeal: MigrationRun
|
||||
initialDelayMs?: number
|
||||
}): CodexSessionMigrationScheduler {
|
||||
let initialTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let scheduledTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let scheduledRunGeneration = 0
|
||||
let pendingScheduledRunGeneration: number | null = null
|
||||
const scheduledScanDates = new Map<string, CodexSessionBackfillDate>()
|
||||
const pendingScanDates = new Map<string, CodexSessionBackfillDate>()
|
||||
let scheduledFullScan = false
|
||||
let pendingFullScan = false
|
||||
let migrationTask: Promise<void> | null = null
|
||||
const activeLaunches = new Map<string, Date>()
|
||||
const earlyPtyExits = new Map<string, EarlyPtyExit>()
|
||||
let activeRunStopObserved = false
|
||||
let rerunRequested = false
|
||||
|
||||
const requestRun = (): void => {
|
||||
const requestRun = (
|
||||
rerunIfActive = false,
|
||||
requestedGeneration?: number,
|
||||
requestedScanDates: readonly CodexSessionBackfillDate[] = [],
|
||||
requestedFullScan = false
|
||||
): void => {
|
||||
if (requestedGeneration !== undefined) {
|
||||
pendingScheduledRunGeneration = Math.max(
|
||||
requestedGeneration,
|
||||
pendingScheduledRunGeneration ?? requestedGeneration
|
||||
)
|
||||
for (const scanDate of requestedScanDates) {
|
||||
pendingScanDates.set(scanDate.join('-'), scanDate)
|
||||
}
|
||||
pendingFullScan ||= requestedFullScan
|
||||
}
|
||||
if (args.isQuitting() || !args.isEligible()) {
|
||||
return
|
||||
}
|
||||
if (migrationTask) {
|
||||
// Why: an account transition can re-enable migration while the prior run is still stopping.
|
||||
rerunRequested ||= activeRunStopObserved
|
||||
// Why: delayed launches and resumed account transitions must survive an older active pass.
|
||||
rerunRequested ||= rerunIfActive || activeRunStopObserved
|
||||
return
|
||||
}
|
||||
const isScheduledRun = pendingScheduledRunGeneration !== null
|
||||
const activeScheduledRunGeneration = pendingScheduledRunGeneration
|
||||
let preparationNeedsFullScan = false
|
||||
if (isScheduledRun) {
|
||||
pendingScheduledRunGeneration = null
|
||||
// Why: an older active pass can rewrite the marker after launch invalidates it.
|
||||
preparationNeedsFullScan = args.prepareScheduledRun?.() === true
|
||||
}
|
||||
const fullScanRequired = pendingFullScan || preparationNeedsFullScan
|
||||
const scanDates =
|
||||
!fullScanRequired && pendingScanDates.size > 0
|
||||
? [...pendingScanDates.values()].sort(compareBackfillDates)
|
||||
: undefined
|
||||
pendingScanDates.clear()
|
||||
pendingFullScan = false
|
||||
activeRunStopObserved = false
|
||||
rerunRequested = false
|
||||
const shouldStop = (): boolean => {
|
||||
|
|
@ -41,10 +98,27 @@ export function createCodexSessionMigrationScheduler(args: {
|
|||
}
|
||||
const systemCodexHomePathOverride = args.resolveSystemCodexHomePathOverride()
|
||||
let stoppedBackfill = false
|
||||
let incompleteBackfill = true
|
||||
const task = args
|
||||
.startBackfill({ shouldStop }, systemCodexHomePathOverride)
|
||||
.startBackfill(
|
||||
{
|
||||
shouldStop,
|
||||
scanDates,
|
||||
ignoreCompletionMarker: isScheduledRun,
|
||||
writeCompletionMarker: activeLaunches.size === 0,
|
||||
writeBoundedCompletionMarker:
|
||||
isScheduledRun && activeLaunches.size === 0 && !fullScanRequired,
|
||||
canWriteCompletionMarker: () =>
|
||||
activeLaunches.size === 0 &&
|
||||
scheduledTimer === null &&
|
||||
pendingScheduledRunGeneration === null &&
|
||||
(!isScheduledRun || activeScheduledRunGeneration === scheduledRunGeneration)
|
||||
},
|
||||
systemCodexHomePathOverride
|
||||
)
|
||||
.then((result) => {
|
||||
stoppedBackfill = isStoppedMigrationResult(result)
|
||||
incompleteBackfill = isIncompleteBackfillResult(result)
|
||||
if (stoppedBackfill || shouldStop()) {
|
||||
return
|
||||
}
|
||||
|
|
@ -58,9 +132,29 @@ export function createCodexSessionMigrationScheduler(args: {
|
|||
void task.finally(() => {
|
||||
if (migrationTask === task) {
|
||||
migrationTask = null
|
||||
const shouldRerun = rerunRequested || stoppedBackfill
|
||||
const scheduledRunIncomplete = stoppedBackfill || activeRunStopObserved
|
||||
const shouldRerun = rerunRequested || scheduledRunIncomplete
|
||||
rerunRequested = false
|
||||
activeRunStopObserved = false
|
||||
if ((shouldRerun || incompleteBackfill) && isScheduledRun) {
|
||||
pendingScheduledRunGeneration = Math.max(
|
||||
activeScheduledRunGeneration!,
|
||||
pendingScheduledRunGeneration ?? activeScheduledRunGeneration!
|
||||
)
|
||||
pendingFullScan ||= fullScanRequired
|
||||
for (const scanDate of scanDates ?? []) {
|
||||
pendingScanDates.set(scanDate.join('-'), scanDate)
|
||||
}
|
||||
}
|
||||
if (
|
||||
isScheduledRun &&
|
||||
!incompleteBackfill &&
|
||||
activeLaunches.size === 0 &&
|
||||
scheduledTimer === null &&
|
||||
pendingScheduledRunGeneration === null
|
||||
) {
|
||||
args.finishScheduledRun?.()
|
||||
}
|
||||
if (shouldRerun) {
|
||||
requestRun()
|
||||
}
|
||||
|
|
@ -68,20 +162,150 @@ export function createCodexSessionMigrationScheduler(args: {
|
|||
})
|
||||
}
|
||||
|
||||
const armScheduledRun = (generation?: number): void => {
|
||||
scheduledTimer = setTimeout(() => {
|
||||
scheduledTimer = null
|
||||
if (generation !== undefined) {
|
||||
const currentDate = getCodexSessionBackfillDate()
|
||||
scheduledScanDates.set(currentDate.join('-'), currentDate)
|
||||
}
|
||||
const scanDates = [...scheduledScanDates.values()].sort(compareBackfillDates)
|
||||
scheduledScanDates.clear()
|
||||
const fullScanRequired = scheduledFullScan
|
||||
scheduledFullScan = false
|
||||
// Why: a launch can invalidate the marker while a long index-heal pass is active.
|
||||
requestRun(true, generation, scanDates, fullScanRequired)
|
||||
}, args.initialDelayMs ?? 15_000)
|
||||
}
|
||||
|
||||
const scheduleRun = (fullScanRequired = false): void => {
|
||||
if (scheduledTimer) {
|
||||
clearTimeout(scheduledTimer)
|
||||
}
|
||||
scheduledRunGeneration += 1
|
||||
scheduledFullScan ||= fullScanRequired
|
||||
const launchDate = getCodexSessionBackfillDate()
|
||||
scheduledScanDates.set(launchDate.join('-'), launchDate)
|
||||
armScheduledRun(scheduledRunGeneration)
|
||||
}
|
||||
|
||||
return {
|
||||
scheduleInitialRun(): void {
|
||||
if (initialTimer) {
|
||||
beginLaunch(leaseId, fullScanRequired = false, startedAt = new Date(), startedSequence): void {
|
||||
if (args.isQuitting() || activeLaunches.has(leaseId)) {
|
||||
return
|
||||
}
|
||||
initialTimer = setTimeout(() => {
|
||||
initialTimer = null
|
||||
requestRun()
|
||||
}, args.initialDelayMs ?? 15_000)
|
||||
if (consumeEarlyPtyExit(earlyPtyExits, leaseId, startedSequence)) {
|
||||
scheduleRun(true)
|
||||
return
|
||||
}
|
||||
activeLaunches.set(leaseId, startedAt)
|
||||
scheduleRun(fullScanRequired)
|
||||
},
|
||||
requestRun
|
||||
finishLaunch(leaseId, exitSequence): void {
|
||||
const startedAt = activeLaunches.get(leaseId)
|
||||
if (!startedAt) {
|
||||
if (exitSequence !== undefined) {
|
||||
recordEarlyPtyExit(earlyPtyExits, leaseId, exitSequence)
|
||||
}
|
||||
return
|
||||
}
|
||||
activeLaunches.delete(leaseId)
|
||||
if (args.isQuitting()) {
|
||||
return
|
||||
}
|
||||
for (const scanDate of getCodexSessionBackfillDatesBetween(startedAt, new Date())) {
|
||||
scheduledScanDates.set(scanDate.join('-'), scanDate)
|
||||
}
|
||||
scheduleRun()
|
||||
},
|
||||
scheduleInitialRun(): void {
|
||||
if (!scheduledTimer) {
|
||||
armScheduledRun()
|
||||
}
|
||||
},
|
||||
scheduleRun,
|
||||
requestRun: () => requestRun()
|
||||
}
|
||||
}
|
||||
|
||||
function getCodexSessionBackfillDatesBetween(
|
||||
startedAt: Date,
|
||||
finishedAt: Date
|
||||
): CodexSessionBackfillDate[] {
|
||||
const dates: CodexSessionBackfillDate[] = []
|
||||
const cursor = new Date(
|
||||
Date.UTC(startedAt.getUTCFullYear(), startedAt.getUTCMonth(), startedAt.getUTCDate())
|
||||
)
|
||||
const last = new Date(
|
||||
Date.UTC(finishedAt.getUTCFullYear(), finishedAt.getUTCMonth(), finishedAt.getUTCDate())
|
||||
)
|
||||
while (cursor <= last) {
|
||||
dates.push(getCodexSessionBackfillDate(cursor))
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1)
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
function compareBackfillDates(
|
||||
left: CodexSessionBackfillDate,
|
||||
right: CodexSessionBackfillDate
|
||||
): number {
|
||||
return left.join('-').localeCompare(right.join('-'))
|
||||
}
|
||||
|
||||
function recordEarlyPtyExit(
|
||||
exits: Map<string, EarlyPtyExit>,
|
||||
leaseId: string,
|
||||
sequence: number
|
||||
): void {
|
||||
const now = Date.now()
|
||||
for (const [id, exit] of exits) {
|
||||
if (now - exit.recordedAt > EARLY_PTY_EXIT_RETENTION_MS) {
|
||||
exits.delete(id)
|
||||
}
|
||||
}
|
||||
exits.set(leaseId, { sequence, recordedAt: now })
|
||||
while (exits.size > MAX_EARLY_PTY_EXITS) {
|
||||
const oldestLeaseId = exits.keys().next().value
|
||||
if (oldestLeaseId === undefined) {
|
||||
break
|
||||
}
|
||||
exits.delete(oldestLeaseId)
|
||||
}
|
||||
}
|
||||
|
||||
function consumeEarlyPtyExit(
|
||||
exits: Map<string, EarlyPtyExit>,
|
||||
leaseId: string,
|
||||
startedSequence: number | undefined
|
||||
): boolean {
|
||||
const exit = exits.get(leaseId)
|
||||
exits.delete(leaseId)
|
||||
return (
|
||||
exit !== undefined &&
|
||||
startedSequence !== undefined &&
|
||||
exit.sequence > startedSequence &&
|
||||
Date.now() - exit.recordedAt <= EARLY_PTY_EXIT_RETENTION_MS
|
||||
)
|
||||
}
|
||||
|
||||
function isStoppedMigrationResult(result: unknown): boolean {
|
||||
return Boolean(result && typeof result === 'object' && 'stopped' in result && result.stopped)
|
||||
}
|
||||
|
||||
function isIncompleteBackfillResult(result: unknown): boolean {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
isStoppedMigrationResult(result) ||
|
||||
readPositiveResultCount(result, 'failedFiles') ||
|
||||
readPositiveResultCount(result, 'failedDirectories') ||
|
||||
readPositiveResultCount(result, 'failedHealAuditRecords')
|
||||
)
|
||||
}
|
||||
|
||||
function readPositiveResultCount(result: object, key: string): boolean {
|
||||
const value = key in result ? (result as Record<string, unknown>)[key] : undefined
|
||||
return typeof value === 'number' && value > 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -336,6 +336,7 @@ let codexUsage: CodexUsageStore | null = null
|
|||
let openCodeUsage: OpenCodeUsageStore | null = null
|
||||
let codexAccounts: CodexAccountService | null = null
|
||||
let codexRuntimeHome: CodexRuntimeHomeService | null = null
|
||||
let codexSessionMigration: ReturnType<typeof createCodexSessionMigrationScheduler> | null = null
|
||||
let claudeAccounts: ClaudeAccountService | null = null
|
||||
let claudeRuntimeAuth: ClaudeRuntimeAuthService | null = null
|
||||
let runtime: OrcaRuntimeService | null = null
|
||||
|
|
@ -389,6 +390,33 @@ let gpuFeatureStatus: Electron.GPUFeatureStatus | null = null
|
|||
let localPtyStartupReady: Promise<void> = Promise.resolve()
|
||||
let localPtyProviderStartupReady: Promise<void> = Promise.resolve()
|
||||
const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000
|
||||
|
||||
function handleCodexHomePtySpawned(args: {
|
||||
id: string
|
||||
codexHomePath: string | null
|
||||
reattached?: boolean
|
||||
launchEnv?: NodeJS.ProcessEnv
|
||||
startedAt?: Date
|
||||
startedSequence?: number
|
||||
}): void {
|
||||
const fullScanRequired =
|
||||
codexRuntimeHome?.beginHostSystemDefaultSessionMigrationLaunch(args.codexHomePath, {
|
||||
reattached: args.reattached,
|
||||
launchEnv: args.launchEnv
|
||||
}) ?? null
|
||||
if (fullScanRequired !== null) {
|
||||
codexSessionMigration?.beginLaunch(
|
||||
args.id,
|
||||
args.reattached === true || fullScanRequired,
|
||||
args.startedAt,
|
||||
args.startedSequence
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePtyExit(id: string, exitSequence: number): void {
|
||||
codexSessionMigration?.finishLaunch(id, exitSequence)
|
||||
}
|
||||
// Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below.
|
||||
// Both redirects run before the serve-argv rewrite so they still match on the launch argv verbatim.
|
||||
// It is load-bearing for the AppImage one: rewriting first replaces the `serve` positional, so its
|
||||
|
|
@ -1400,6 +1428,8 @@ function openMainWindow(): BrowserWindow {
|
|||
},
|
||||
// Why: let the PTY layer skip its orphan sweep on the recovery reload that re-fires did-finish-load, so live local sessions survive (#5787).
|
||||
isRecoveryReloadInFlight,
|
||||
onCodexHomePtySpawned: handleCodexHomePtySpawned,
|
||||
onPtyExit: handlePtyExit,
|
||||
onBeforeUpdateQuit: () =>
|
||||
preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }),
|
||||
updateInstallMode: resolveUpdateInstallMode(isServeMode),
|
||||
|
|
@ -2254,21 +2284,21 @@ void app.whenReady().then(async () => {
|
|||
codexRuntimeHome.isHostSystemDefaultRealHome() &&
|
||||
isAgentStatusHooksEnabled(store?.getSettings())
|
||||
)
|
||||
const codexSessionMigration = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => codexRuntimeHome?.isHostSystemDefaultRealHome() === true,
|
||||
codexSessionMigration = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => codexRuntimeHome?.isHostSystemDefaultSessionMigrationEligible() === true,
|
||||
isQuitting: () => isQuitting,
|
||||
resolveSystemCodexHomePathOverride: () =>
|
||||
resolveHostCodexSessionSourceHome(store!.getSettings()),
|
||||
prepareScheduledRun: () => codexRuntimeHome?.prepareHostSystemDefaultSessionMigrationPass(),
|
||||
finishScheduledRun: () => codexRuntimeHome?.finishHostSystemDefaultSessionMigrationPass(),
|
||||
startBackfill: startCodexSessionBackfillInBackground,
|
||||
startIndexHeal: startCodexSessionIndexHealInBackground
|
||||
})
|
||||
codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome, {
|
||||
onHostSystemDefaultSelected: codexSessionMigration.requestRun
|
||||
})
|
||||
// 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.
|
||||
// Why: migrate historical shared-home sessions after startup; compatibility
|
||||
// launches re-arm the non-destructive pass for new rollouts (#4444, #8612, #12480).
|
||||
codexSessionMigration.scheduleInitialRun()
|
||||
claudeRuntimeAuth = new ClaudeRuntimeAuthService(store)
|
||||
claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth)
|
||||
|
|
@ -2883,7 +2913,11 @@ void app.whenReady().then(async () => {
|
|||
() => store!.getSettings(),
|
||||
(target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target),
|
||||
store,
|
||||
prepareCodexSessionResumeForLaunch
|
||||
prepareCodexSessionResumeForLaunch,
|
||||
{
|
||||
onCodexHomePtySpawned: handleCodexHomePtySpawned,
|
||||
onPtyExit: handlePtyExit
|
||||
}
|
||||
)
|
||||
await runtime.refreshRestoredOrchestrationAuthority()
|
||||
await runtime.reconcileLegacyWorkerTerminals()
|
||||
|
|
|
|||
|
|
@ -3074,16 +3074,25 @@ describe('registerPtyHandlers', () => {
|
|||
new Promise<void>((resolve) => (releaseRecovery = resolve))
|
||||
)
|
||||
readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON)
|
||||
const onCodexHomePtySpawned = vi.fn()
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
managedHomePath: TEST_CODEX_HOME,
|
||||
managedHomeRuntime: 'host'
|
||||
}
|
||||
]
|
||||
})) as never)
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
undefined,
|
||||
() => TEST_CODEX_HOME,
|
||||
(() => ({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
managedHomePath: TEST_CODEX_HOME,
|
||||
managedHomeRuntime: 'host'
|
||||
}
|
||||
]
|
||||
})) as never,
|
||||
undefined,
|
||||
undefined,
|
||||
{ onCodexHomePtySpawned }
|
||||
)
|
||||
|
||||
const spawnPromise = handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
|
|
@ -3094,10 +3103,17 @@ describe('registerPtyHandlers', () => {
|
|||
expect(ensureCodexBackfillRecoveryMock).toHaveBeenCalledWith(TEST_CODEX_HOME)
|
||||
)
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
expect(onCodexHomePtySpawned).not.toHaveBeenCalled()
|
||||
|
||||
releaseRecovery()
|
||||
await spawnPromise
|
||||
const result = (await spawnPromise) as { id: string }
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
expect(onCodexHomePtySpawned).toHaveBeenCalledWith({
|
||||
id: result.id,
|
||||
codexHomePath: TEST_CODEX_HOME,
|
||||
startedAt: expect.any(Date),
|
||||
startedSequence: expect.any(Number)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not gate a bare local shell on managed Codex auth', async () => {
|
||||
|
|
@ -3108,19 +3124,37 @@ describe('registerPtyHandlers', () => {
|
|||
return ''
|
||||
})
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
managedHomePath: TEST_CODEX_HOME,
|
||||
managedHomeRuntime: 'host'
|
||||
}
|
||||
]
|
||||
})) as never)
|
||||
const onCodexHomePtySpawned = vi.fn()
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
undefined,
|
||||
() => TEST_CODEX_HOME,
|
||||
(() => ({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
managedHomePath: TEST_CODEX_HOME,
|
||||
managedHomeRuntime: 'host'
|
||||
}
|
||||
]
|
||||
})) as never,
|
||||
undefined,
|
||||
undefined,
|
||||
{ onCodexHomePtySpawned }
|
||||
)
|
||||
|
||||
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
const result = (await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})) as { id: string }
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledOnce()
|
||||
expect(onCodexHomePtySpawned).toHaveBeenCalledWith({
|
||||
id: result.id,
|
||||
codexHomePath: TEST_CODEX_HOME,
|
||||
startedAt: expect.any(Date),
|
||||
startedSequence: expect.any(Number)
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves an inherited CODEX_HOME untouched for system default when the flag is OFF', async () => {
|
||||
|
|
@ -8883,6 +8917,7 @@ describe('registerPtyHandlers', () => {
|
|||
const prepareClaudeAuth = vi.fn(() => {
|
||||
throw new Error('replacement auth preflight must not run')
|
||||
})
|
||||
const onCodexHomePtySpawned = vi.fn()
|
||||
let controller: RuntimeSpawnController | null = null
|
||||
const runtime = {
|
||||
setPtyController: vi.fn((value) => {
|
||||
|
|
@ -8920,7 +8955,8 @@ describe('registerPtyHandlers', () => {
|
|||
undefined,
|
||||
undefined,
|
||||
prepareClaudeAuth,
|
||||
store as never
|
||||
store as never,
|
||||
{ onCodexHomePtySpawned }
|
||||
)
|
||||
const spawnController = controller as unknown as RuntimeSpawnController
|
||||
await spawnController.spawn({
|
||||
|
|
@ -9014,6 +9050,7 @@ describe('registerPtyHandlers', () => {
|
|||
rows: 40,
|
||||
cwd,
|
||||
command: 'codex resume should-not-run',
|
||||
launchAgent: 'codex',
|
||||
worktreeId,
|
||||
preAllocatedHandle: 'term-live-owner',
|
||||
tabId,
|
||||
|
|
@ -9039,6 +9076,13 @@ describe('registerPtyHandlers', () => {
|
|||
incarnationId: 'inc-live-owner',
|
||||
isReattach: true
|
||||
})
|
||||
expect(onCodexHomePtySpawned).toHaveBeenCalledWith({
|
||||
id: 'pty-live-owner',
|
||||
codexHomePath: null,
|
||||
reattached: true,
|
||||
startedAt: expect.any(Date),
|
||||
startedSequence: expect.any(Number)
|
||||
})
|
||||
expect(claimedResult).toMatchObject({
|
||||
id: 'pty-live-owner',
|
||||
stablePaneOwner: { handle: 'term-live-owner', tabId, leafId }
|
||||
|
|
|
|||
|
|
@ -1202,6 +1202,18 @@ export type PrepareCodexSessionResume = (args: {
|
|||
launchEnv?: NodeJS.ProcessEnv
|
||||
workspacePath?: string
|
||||
}) => Promise<CodexSessionResumePreparation | null>
|
||||
|
||||
export type CodexHomePtySpawnedLifecycleArgs = {
|
||||
id: string
|
||||
codexHomePath: string | null
|
||||
reattached?: boolean
|
||||
launchEnv?: NodeJS.ProcessEnv
|
||||
startedAt?: Date
|
||||
startedSequence?: number
|
||||
}
|
||||
|
||||
let ptyLifecycleSequence = 0
|
||||
|
||||
type PrepareClaudeAuth = (
|
||||
target?: ClaudeAccountSelectionTarget
|
||||
) => Promise<ClaudeRuntimeAuthPreparation>
|
||||
|
|
@ -2243,6 +2255,8 @@ export function registerPtyHandlers(
|
|||
awaitLocalPtyProviderStartup?: () => Promise<void>
|
||||
// Why: returns true once for the crash-recovery reload so its did-finish-load skips the orphan sweep and keeps live PTYs (#5787).
|
||||
isRecoveryReloadInFlight?: (webContentsId: number) => boolean
|
||||
onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void
|
||||
onPtyExit?: (id: string, exitSequence: number) => void
|
||||
}
|
||||
): void {
|
||||
// Why: a re-registration means a new window owns delivery — cancel the prior closure's watchdog and neutralize its bridged reset so mark-hidden below can't arm a timer against the dead closure.
|
||||
|
|
@ -3563,6 +3577,7 @@ export function registerPtyHandlers(
|
|||
}
|
||||
|
||||
function sendPtyExitToRenderer(payload: { id: string; code: number }): void {
|
||||
options?.onPtyExit?.(payload.id, ++ptyLifecycleSequence)
|
||||
const release = preparePtyExitForRenderer(payload)
|
||||
if (!release) {
|
||||
return
|
||||
|
|
@ -4290,6 +4305,8 @@ export function registerPtyHandlers(
|
|||
},
|
||||
adoptStablePane,
|
||||
spawn: async (args) => {
|
||||
const codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined
|
||||
const codexHomeLaunchStartedSequence = !args.connectionId ? ++ptyLifecycleSequence : undefined
|
||||
const preAdoptedStablePane = args.adoptedStablePane ?? null
|
||||
const startupPromise = getLocalPtyStartupPromise(args.connectionId)
|
||||
if (startupPromise) {
|
||||
|
|
@ -4300,7 +4317,7 @@ export function registerPtyHandlers(
|
|||
if (!handle) {
|
||||
throw new Error('terminal_pane_owner_unknown')
|
||||
}
|
||||
return {
|
||||
const result = {
|
||||
id: preAdoptedStablePane.result.id,
|
||||
...(preAdoptedStablePane.result.incarnationId
|
||||
? { incarnationId: preAdoptedStablePane.result.incarnationId }
|
||||
|
|
@ -4314,6 +4331,16 @@ export function registerPtyHandlers(
|
|||
leafId: preAdoptedStablePane.owner.leafId
|
||||
}
|
||||
}
|
||||
if (!args.connectionId) {
|
||||
options?.onCodexHomePtySpawned?.({
|
||||
id: result.id,
|
||||
codexHomePath: null,
|
||||
reattached: true,
|
||||
startedAt: codexHomeLaunchStartedAt,
|
||||
startedSequence: codexHomeLaunchStartedSequence
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
if (!preAdoptedStablePane) {
|
||||
await assertFolderWorkspacePtyPathUsable(args.worktreeId)
|
||||
|
|
@ -4976,6 +5003,16 @@ export function registerPtyHandlers(
|
|||
leafId: owner.surface.leafId,
|
||||
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
|
||||
})
|
||||
if (!args.connectionId) {
|
||||
options?.onCodexHomePtySpawned?.({
|
||||
id: result.id,
|
||||
codexHomePath: selectedCodexHomePath,
|
||||
reattached: true,
|
||||
startedAt: codexHomeLaunchStartedAt,
|
||||
startedSequence: codexHomeLaunchStartedSequence,
|
||||
...(env ? { launchEnv: env } : {})
|
||||
})
|
||||
}
|
||||
return {
|
||||
id: result.id,
|
||||
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
|
||||
|
|
@ -5140,6 +5177,15 @@ export function registerPtyHandlers(
|
|||
}
|
||||
// Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal.
|
||||
sendPtySpawnedToRenderer(result.id)
|
||||
if (!args.connectionId) {
|
||||
options?.onCodexHomePtySpawned?.({
|
||||
id: result.id,
|
||||
codexHomePath: selectedCodexHomePath,
|
||||
startedAt: codexHomeLaunchStartedAt,
|
||||
startedSequence: codexHomeLaunchStartedSequence,
|
||||
...(result.isReattach === true ? { reattached: true } : env ? { launchEnv: env } : {})
|
||||
})
|
||||
}
|
||||
const response = {
|
||||
id: result.id,
|
||||
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
|
||||
|
|
@ -5623,6 +5669,8 @@ export function registerPtyHandlers(
|
|||
}
|
||||
}
|
||||
) => {
|
||||
const codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined
|
||||
const codexHomeLaunchStartedSequence = !args.connectionId ? ++ptyLifecycleSequence : undefined
|
||||
const spawnTiming = createPtySpawnTiming()
|
||||
const startupPromise = getLocalPtyStartupPromise(args.connectionId)
|
||||
if (startupPromise) {
|
||||
|
|
@ -6601,6 +6649,19 @@ export function registerPtyHandlers(
|
|||
}
|
||||
// Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory.
|
||||
sendPtySpawnedToRenderer(result.id)
|
||||
if (!args.connectionId) {
|
||||
options?.onCodexHomePtySpawned?.({
|
||||
id: result.id,
|
||||
codexHomePath: selectedCodexHomePath,
|
||||
startedAt: codexHomeLaunchStartedAt,
|
||||
startedSequence: codexHomeLaunchStartedSequence,
|
||||
...(result.isReattach === true
|
||||
? { reattached: true }
|
||||
: baseEnv
|
||||
? { launchEnv: baseEnv }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
return resolvePaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, response)
|
||||
} catch (err) {
|
||||
if (pendingRegistrationPtyId) {
|
||||
|
|
@ -7387,7 +7448,11 @@ export function registerHeadlessPtyRuntime(
|
|||
getSettings?: () => GlobalSettings,
|
||||
prepareClaudeAuth?: PrepareClaudeAuth,
|
||||
store?: Store,
|
||||
prepareCodexSessionResume?: PrepareCodexSessionResume
|
||||
prepareCodexSessionResume?: PrepareCodexSessionResume,
|
||||
lifecycle?: {
|
||||
onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void
|
||||
onPtyExit?: (id: string, exitSequence: number) => void
|
||||
}
|
||||
): void {
|
||||
// Why: headless `orca serve` has no renderer window but still needs the same PTY handlers so remote clients can drive terminals.
|
||||
const headlessWindow = {
|
||||
|
|
@ -7405,7 +7470,7 @@ export function registerHeadlessPtyRuntime(
|
|||
getSettings,
|
||||
prepareClaudeAuth,
|
||||
store,
|
||||
{ prepareCodexSessionResume }
|
||||
{ prepareCodexSessionResume, ...lifecycle }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup'
|
|||
import {
|
||||
getLocalPtyProvider,
|
||||
registerPtyHandlers,
|
||||
type CodexHomePtySpawnedLifecycleArgs,
|
||||
type GetSelectedCodexHomePath,
|
||||
type PrepareCodexSessionResume
|
||||
} from '../ipc/pty'
|
||||
|
|
@ -98,6 +99,8 @@ export function attachMainWindowServices(
|
|||
onBeforeRendererReload?: (args: { webContentsId: number; ignoreCache: boolean }) => void
|
||||
// Why: lets the PTY orphan sweep skip the one crash-recovery reload (#5787).
|
||||
isRecoveryReloadInFlight?: (webContentsId: number) => boolean
|
||||
onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void
|
||||
onPtyExit?: (id: string, exitSequence: number) => void
|
||||
onBeforeUpdateQuit?: () => void | Promise<void>
|
||||
updateInstallMode?: UpdateInstallMode
|
||||
onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void
|
||||
|
|
@ -125,7 +128,9 @@ export function attachMainWindowServices(
|
|||
prepareCodexSessionResume: options?.prepareCodexSessionResume,
|
||||
awaitLocalPtyStartup: options?.awaitLocalPtyStartup,
|
||||
awaitLocalPtyProviderStartup: options?.awaitLocalPtyProviderStartup,
|
||||
isRecoveryReloadInFlight: options?.isRecoveryReloadInFlight
|
||||
isRecoveryReloadInFlight: options?.isRecoveryReloadInFlight,
|
||||
onCodexHomePtySpawned: options?.onCodexHomePtySpawned,
|
||||
onPtyExit: options?.onPtyExit
|
||||
}
|
||||
)
|
||||
// Why: register after registerPtyHandlers so pty:management:* IPC re-installs on macOS re-activation (docs/daemon-staleness-ux.md §Phase 1).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
declare module 'psl' {
|
||||
export type ParsedDomain = {
|
||||
input: string
|
||||
tld: string | null
|
||||
sld: string | null
|
||||
domain: string | null
|
||||
subdomain: string | null
|
||||
listed: boolean
|
||||
}
|
||||
|
||||
export type ParseError = {
|
||||
input: string
|
||||
error: { code: string; message: string }
|
||||
}
|
||||
|
||||
export function parse(input: string): ParsedDomain | ParseError
|
||||
}
|
||||
Loading…
Reference in New Issue