fix(codex-account): keep account snapshots fresh

This commit is contained in:
777genius 2026-05-26 23:41:54 +03:00
parent 1cae11da34
commit b15de780cb
7 changed files with 298 additions and 15 deletions

View file

@ -263,7 +263,8 @@ async function resolveCodexBinaryForAccountSnapshot(): Promise<string | null> {
await resolveInteractiveShellEnvBestEffort({
timeoutMs: CODEX_BINARY_COLD_RETRY_TIMEOUT_MS,
fallbackEnv: process.env,
background: false,
background: true,
source: 'codex-account-binary-discovery',
});
CodexBinaryResolver.clearCache();
return CodexBinaryResolver.resolve();
@ -293,6 +294,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
private snapshotCache: CodexAccountSnapshotDto | null = null;
private snapshotObservedAt = 0;
private lastPublishedSnapshotUpdatedAtMs = 0;
private refreshPromise: Promise<CodexAccountSnapshotDto> | null = null;
private pendingRefreshOptions: CodexSnapshotRefreshOptions | null = null;
private lastKnownAccount: CodexLastKnownAccount | null = null;
@ -446,6 +448,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
this.lastKnownAccount = null;
this.lastKnownRateLimits = null;
this.lastKnownRuntimeContext = null;
this.lastPublishedSnapshotUpdatedAtMs = 0;
this.activeMutationCount = 0;
if (this.mutationQueueRelease) {
this.mutationQueueRelease();
@ -519,7 +522,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
runtimeContext: freshRuntimeContext,
login,
rateLimits: this.snapshotCache?.rateLimits ?? null,
updatedAt: new Date(now).toISOString(),
updatedAt: new Date().toISOString(),
});
return snapshot;
}
@ -539,7 +542,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
localActiveChatgptAccountPresent,
login,
rateLimits: null,
updatedAt: new Date(now).toISOString(),
updatedAt: new Date().toISOString(),
});
return snapshot;
}
@ -699,20 +702,27 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
runtimeContext,
login,
rateLimits,
updatedAt: new Date(now).toISOString(),
updatedAt: new Date().toISOString(),
});
return snapshot;
}
private setSnapshot(nextSnapshot: CodexAccountSnapshotDto): CodexAccountSnapshotDto {
const publishedAtMs = Math.max(Date.now(), this.lastPublishedSnapshotUpdatedAtMs + 1);
this.lastPublishedSnapshotUpdatedAtMs = publishedAtMs;
const publishedSnapshot = {
...nextSnapshot,
updatedAt: new Date(publishedAtMs).toISOString(),
};
if (this.disposed) {
return deepClone(nextSnapshot);
return deepClone(publishedSnapshot);
}
this.snapshotCache = deepClone(nextSnapshot);
this.snapshotCache = deepClone(publishedSnapshot);
this.snapshotObservedAt = Date.now();
const snapshot = deepClone(nextSnapshot);
const snapshot = deepClone(publishedSnapshot);
this.presenter.publish(snapshot);
for (const listener of this.listeners) {
listener(snapshot);

View file

@ -42,6 +42,11 @@ function getRefreshIntervalMs(options: {
: CODEX_VISIBLE_STANDARD_REFRESH_MS;
}
function getSnapshotUpdatedAtMs(snapshot: CodexAccountSnapshotDto): number | null {
const updatedAtMs = Date.parse(snapshot.updatedAt);
return Number.isFinite(updatedAtMs) ? updatedAtMs : null;
}
export function useCodexAccountSnapshot(options: {
enabled: boolean;
includeRateLimits?: boolean;
@ -68,6 +73,7 @@ export function useCodexAccountSnapshot(options: {
const [error, setError] = useState<string | null>(null);
const [visible, setVisible] = useState(() => isDocumentVisible());
const lastUpdatedAtRef = useRef<number | null>(null);
const snapshotUpdatedAtRef = useRef<number | null>(null);
const initialRefreshDelayMs = options.initialRefreshDelayMs ?? 0;
const initialRefreshMaxDelayMs = options.initialRefreshMaxDelayMs;
const [initialRefreshAttempted, setInitialRefreshAttempted] = useState(
@ -75,6 +81,16 @@ export function useCodexAccountSnapshot(options: {
);
const applySnapshot = useCallback((nextSnapshot: CodexAccountSnapshotDto) => {
const nextUpdatedAtMs = getSnapshotUpdatedAtMs(nextSnapshot);
if (
nextUpdatedAtMs !== null &&
snapshotUpdatedAtRef.current !== null &&
nextUpdatedAtMs < snapshotUpdatedAtRef.current
) {
return;
}
snapshotUpdatedAtRef.current = nextUpdatedAtMs ?? Date.now();
lastUpdatedAtRef.current = Date.now();
setSnapshot(nextSnapshot);
setError(null);

View file

@ -144,6 +144,21 @@ function mergeCodexNativeBackendOption(
});
}
function mergeCodexCapabilitiesWithSnapshot(
provider: CliProviderStatus,
snapshot: CodexAccountSnapshotDto
): CliProviderStatus['capabilities'] {
if (!snapshot.launchAllowed) {
return provider.capabilities;
}
return {
...provider.capabilities,
teamLaunch: true,
oneShot: true,
};
}
export function mergeCodexProviderStatusWithSnapshot(
provider: CliProviderStatus,
snapshot: CodexAccountSnapshotDto | null
@ -166,8 +181,10 @@ export function mergeCodexProviderStatusWithSnapshot(
return {
...provider,
supported: provider.supported || isCodexBootstrapPlaceholder(provider),
supported:
provider.supported || isCodexBootstrapPlaceholder(provider) || snapshot.launchAllowed,
authenticated: snapshot.launchAllowed,
capabilities: mergeCodexCapabilitiesWithSnapshot(provider, snapshot),
authMethod:
snapshot.effectiveAuthMode === 'chatgpt'
? 'chatgpt'

View file

@ -370,9 +370,23 @@ describe('createCodexAccountFeature', () => {
});
it('retries Codex binary discovery after cold shell env resolves before publishing runtime-missing', async () => {
binaryResolveMock.mockResolvedValueOnce(null).mockResolvedValue('/usr/local/bin/codex');
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({
PATH: '/usr/local/bin:/usr/bin:/bin',
getCachedShellEnvMock.mockReturnValue(null);
binaryResolveMock.mockImplementation(async () =>
getCachedShellEnvMock()?.PATH?.includes('/custom/bin') ? '/custom/bin/codex' : null
);
resolveInteractiveShellEnvBestEffortMock.mockImplementation(async (options?: {
background?: boolean;
fallbackEnv?: NodeJS.ProcessEnv;
}) => {
if (options?.background === false) {
return options.fallbackEnv ?? {};
}
const shellEnv = {
PATH: '/custom/bin:/usr/bin:/bin',
};
getCachedShellEnvMock.mockReturnValue(shellEnv);
return shellEnv;
});
readAccountMock.mockResolvedValue({
account: createAccountResponse(),
@ -395,6 +409,8 @@ describe('createCodexAccountFeature', () => {
expect.objectContaining({
timeoutMs: 12_000,
fallbackEnv: process.env,
background: true,
source: 'codex-account-binary-discovery',
})
);
expect(binaryClearCacheMock).toHaveBeenCalledTimes(1);
@ -407,6 +423,99 @@ describe('createCodexAccountFeature', () => {
}
});
it('timestamps snapshots at publication time after a slow account read', async () => {
vi.useFakeTimers({
toFake: ['Date'],
});
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
const accountReadDeferred = createDeferred<void>();
readAccountMock.mockImplementation(async () => {
await accountReadDeferred.promise;
return {
account: createAccountResponse(),
initialize: {
codexHome: '/Users/test/.codex',
platformFamily: 'unix',
platformOs: 'macos',
},
};
});
const feature = createCodexAccountFeature({
logger: createLoggerPort(),
configManager: createConfigManager('chatgpt'),
});
try {
const snapshotPromise = feature.refreshSnapshot();
await vi.waitFor(() => {
expect(readAccountMock).toHaveBeenCalledTimes(1);
});
vi.setSystemTime(new Date('2026-01-01T00:00:05.000Z'));
accountReadDeferred.resolve();
const snapshot = await snapshotPromise;
expect(snapshot.updatedAt).toBe('2026-01-01T00:00:05.000Z');
expect(snapshot.appServerState).toBe('healthy');
} finally {
vi.useRealTimers();
await feature.dispose();
}
});
it('publishes strictly increasing snapshot timestamps within the same millisecond', async () => {
vi.useFakeTimers({
toFake: ['Date'],
});
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
readAccountMock.mockResolvedValue({
account: createAccountResponse(),
initialize: {
codexHome: '/Users/test/.codex',
platformFamily: 'unix',
platformOs: 'macos',
},
});
const feature = createCodexAccountFeature({
logger: createLoggerPort(),
configManager: createConfigManager('chatgpt'),
});
const publishedSnapshots: CodexAccountSnapshotDto[] = [];
const unsubscribe = feature.subscribe((snapshot) => {
publishedSnapshots.push(snapshot);
});
try {
await feature.refreshSnapshot();
emitLoginState({
status: 'pending',
error: null,
startedAt: '2026-01-01T00:00:00.000Z',
authUrl: 'https://chatgpt.com/auth',
});
emitLoginState({
status: 'cancelled',
error: null,
startedAt: null,
authUrl: null,
});
expect(publishedSnapshots.map((snapshot) => Date.parse(snapshot.updatedAt))).toEqual([
1_767_225_600_000,
1_767_225_600_001,
1_767_225_600_002,
]);
expect(publishedSnapshots.at(-1)?.login.status).toBe('cancelled');
} finally {
unsubscribe();
vi.useRealTimers();
await feature.dispose();
}
});
it('still reports runtime-missing after the cold binary retry cannot find Codex', async () => {
binaryResolveMock.mockResolvedValue(null);
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({

View file

@ -150,6 +150,66 @@ describe('mergeCodexProviderStatusWithSnapshot', () => {
});
});
it('clears a stale runtime-missing provider once the live snapshot is ready', () => {
const baseProvider = createBaseCodexProvider();
const baseConnection = baseProvider.connection!;
const merged = mergeCodexProviderStatusWithSnapshot(
{
...baseProvider,
supported: false,
authenticated: false,
verificationState: 'error',
statusMessage: 'Codex CLI not found. Install Codex to use native account management.',
capabilities: {
teamLaunch: false,
oneShot: false,
extensions: createDefaultCliExtensionCapabilities(),
},
availableBackends: [
{
id: 'codex-native',
label: 'Codex native',
description: 'Use codex exec JSON mode.',
selectable: false,
recommended: true,
available: false,
state: 'runtime-missing',
audience: 'general',
statusMessage: 'Codex CLI not found',
detailMessage: 'Codex CLI not found',
},
],
connection: {
...baseConnection,
codex: {
...baseConnection.codex!,
appServerState: 'runtime-missing',
appServerStatusMessage: 'Codex CLI not found',
launchAllowed: false,
launchIssueMessage: 'Codex CLI not found',
launchReadinessState: 'runtime_missing',
},
},
},
createReadyChatgptSnapshot()
);
expect(merged.supported).toBe(true);
expect(merged.authenticated).toBe(true);
expect(merged.verificationState).toBe('verified');
expect(merged.statusMessage).toBe('ChatGPT account ready');
expect(merged.capabilities.teamLaunch).toBe(true);
expect(merged.capabilities.oneShot).toBe(true);
expect(merged.connection?.codex?.appServerState).toBe('healthy');
expect(merged.connection?.codex?.launchReadinessState).toBe('ready_chatgpt');
expect(merged.availableBackends?.find((option) => option.id === 'codex-native')).toMatchObject({
available: true,
selectable: true,
state: 'ready',
statusMessage: 'Ready',
});
});
it('hydrates codex connection truth even when the stale provider payload had no connection block', () => {
const merged = mergeCodexProviderStatusWithSnapshot(
{

View file

@ -13,7 +13,9 @@ const apiMocks = vi.hoisted(() => ({
startCodexChatgptLogin: vi.fn(),
cancelCodexChatgptLogin: vi.fn(),
logoutCodexAccount: vi.fn(),
onCodexAccountSnapshotChanged: vi.fn(() => () => undefined),
onCodexAccountSnapshotChanged: vi.fn<
(callback: (event: unknown, snapshot: CodexAccountSnapshotDto) => void) => () => void
>(() => () => undefined),
}));
type IdleCallbackForTest = (deadline: {
@ -71,6 +73,16 @@ function createSnapshot(): CodexAccountSnapshotDto {
};
}
function withSnapshotOverrides(
snapshot: CodexAccountSnapshotDto,
overrides: Partial<CodexAccountSnapshotDto>
): CodexAccountSnapshotDto {
return {
...snapshot,
...overrides,
};
}
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
@ -133,6 +145,65 @@ describe('useCodexAccountSnapshot', () => {
});
});
it('ignores older pushed Codex snapshots after a fresher snapshot was applied', async () => {
let snapshotListener:
| ((event: unknown, snapshot: CodexAccountSnapshotDto) => void)
| null = null;
const staleSnapshot = withSnapshotOverrides(createSnapshot(), {
updatedAt: '2026-01-01T00:00:00.000Z',
managedAccount: {
type: 'chatgpt',
email: 'stale@example.com',
planType: 'pro',
},
});
const freshSnapshot = withSnapshotOverrides(createSnapshot(), {
updatedAt: '2026-01-01T00:00:01.000Z',
managedAccount: {
type: 'chatgpt',
email: 'fresh@example.com',
planType: 'pro',
},
});
apiMocks.getCodexAccountSnapshot.mockResolvedValue(freshSnapshot);
apiMocks.onCodexAccountSnapshotChanged.mockImplementation(
(callback: (event: unknown, snapshot: CodexAccountSnapshotDto) => void) => {
snapshotListener = callback;
return () => undefined;
}
);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
function Harness(): React.ReactElement {
const state = useCodexAccountSnapshot({
enabled: true,
});
return React.createElement('div', null, state.snapshot?.managedAccount?.email ?? 'empty');
}
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
});
expect(host.textContent).toContain('fresh@example.com');
await act(async () => {
snapshotListener?.({}, staleSnapshot);
await Promise.resolve();
});
expect(host.textContent).toContain('fresh@example.com');
act(() => {
root.unmount();
});
});
it('can defer the initial Codex snapshot without starting interval refreshes first', async () => {
vi.useFakeTimers();
const snapshot = createSnapshot();

View file

@ -228,11 +228,11 @@ describe('useRuntimeProviderManagement', () => {
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(ConfigurableHarness, { enabled: true }));
await Promise.resolve();
await Promise.resolve();
});
expect(state?.error).toContain('wrong runtime binary');
await vi.waitFor(() => {
expect(state?.error).toContain('wrong runtime binary');
});
expect(state?.errorDiagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode');
await act(async () => {