fix(codex-account): keep account snapshots fresh
This commit is contained in:
parent
1cae11da34
commit
b15de780cb
7 changed files with 298 additions and 15 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue