perf(startup): defer noncritical startup probes
This commit is contained in:
parent
a4861fa77d
commit
34a1b86b21
13 changed files with 353 additions and 53 deletions
|
|
@ -11,6 +11,7 @@ const CODEX_PENDING_LOGIN_REFRESH_MS = 3_000;
|
||||||
const CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS = 10_000;
|
const CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS = 10_000;
|
||||||
const CODEX_VISIBLE_STANDARD_REFRESH_MS = 20_000;
|
const CODEX_VISIBLE_STANDARD_REFRESH_MS = 20_000;
|
||||||
const CODEX_HIDDEN_REFRESH_MS = 60_000;
|
const CODEX_HIDDEN_REFRESH_MS = 60_000;
|
||||||
|
export const CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS = 30_000;
|
||||||
|
|
||||||
function isDocumentVisible(): boolean {
|
function isDocumentVisible(): boolean {
|
||||||
if (typeof document === 'undefined') {
|
if (typeof document === 'undefined') {
|
||||||
|
|
@ -41,6 +42,7 @@ function getRefreshIntervalMs(options: {
|
||||||
export function useCodexAccountSnapshot(options: {
|
export function useCodexAccountSnapshot(options: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
includeRateLimits?: boolean;
|
includeRateLimits?: boolean;
|
||||||
|
initialRefreshDelayMs?: number;
|
||||||
}): {
|
}): {
|
||||||
snapshot: CodexAccountSnapshotDto | null;
|
snapshot: CodexAccountSnapshotDto | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
|
@ -62,6 +64,7 @@ export function useCodexAccountSnapshot(options: {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [visible, setVisible] = useState(() => isDocumentVisible());
|
const [visible, setVisible] = useState(() => isDocumentVisible());
|
||||||
const lastUpdatedAtRef = useRef<number | null>(null);
|
const lastUpdatedAtRef = useRef<number | null>(null);
|
||||||
|
const initialRefreshDelayMs = options.initialRefreshDelayMs ?? 0;
|
||||||
|
|
||||||
const applySnapshot = useCallback((nextSnapshot: CodexAccountSnapshotDto) => {
|
const applySnapshot = useCallback((nextSnapshot: CodexAccountSnapshotDto) => {
|
||||||
lastUpdatedAtRef.current = Date.now();
|
lastUpdatedAtRef.current = Date.now();
|
||||||
|
|
@ -117,38 +120,74 @@ export function useCodexAccountSnapshot(options: {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
let active = true;
|
||||||
if (options.includeRateLimits) {
|
let initialRefreshTimer: number | null = null;
|
||||||
setRateLimitsLoading(true);
|
|
||||||
}
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const initialSnapshotRequest = options.includeRateLimits
|
const startInitialSnapshotRequest = (): void => {
|
||||||
? api.refreshCodexAccountSnapshot({
|
if (!active || lastUpdatedAtRef.current !== null) {
|
||||||
includeRateLimits: true,
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
if (options.includeRateLimits) {
|
||||||
|
setRateLimitsLoading(true);
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const initialSnapshotRequest = options.includeRateLimits
|
||||||
|
? api.refreshCodexAccountSnapshot({
|
||||||
|
includeRateLimits: true,
|
||||||
|
})
|
||||||
|
: api.getCodexAccountSnapshot();
|
||||||
|
|
||||||
|
void initialSnapshotRequest
|
||||||
|
.then((nextSnapshot) => {
|
||||||
|
if (active) {
|
||||||
|
applySnapshot(nextSnapshot);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
: api.getCodexAccountSnapshot();
|
.catch((nextError) => {
|
||||||
|
if (active) {
|
||||||
|
setError(
|
||||||
|
nextError instanceof Error ? nextError.message : 'Failed to load Codex account'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
if (options.includeRateLimits) {
|
||||||
|
setRateLimitsLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
void initialSnapshotRequest
|
if (initialRefreshDelayMs > 0) {
|
||||||
.then((nextSnapshot) => {
|
initialRefreshTimer = window.setTimeout(startInitialSnapshotRequest, initialRefreshDelayMs);
|
||||||
applySnapshot(nextSnapshot);
|
} else {
|
||||||
})
|
startInitialSnapshotRequest();
|
||||||
.catch((nextError) => {
|
}
|
||||||
setError(nextError instanceof Error ? nextError.message : 'Failed to load Codex account');
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setLoading(false);
|
|
||||||
if (options.includeRateLimits) {
|
|
||||||
setRateLimitsLoading(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const unsubscribe = api.onCodexAccountSnapshotChanged((_event, nextSnapshot) => {
|
const unsubscribe = api.onCodexAccountSnapshotChanged((_event, nextSnapshot) => {
|
||||||
applySnapshot(nextSnapshot);
|
applySnapshot(nextSnapshot);
|
||||||
});
|
});
|
||||||
|
|
||||||
return unsubscribe;
|
return () => {
|
||||||
}, [applySnapshot, electronMode, options.enabled, options.includeRateLimits]);
|
active = false;
|
||||||
|
if (initialRefreshTimer) {
|
||||||
|
window.clearTimeout(initialRefreshTimer);
|
||||||
|
}
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
applySnapshot,
|
||||||
|
electronMode,
|
||||||
|
initialRefreshDelayMs,
|
||||||
|
options.enabled,
|
||||||
|
options.includeRateLimits,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!electronMode || !options.enabled || typeof document === 'undefined') {
|
if (!electronMode || !options.enabled || typeof document === 'undefined') {
|
||||||
|
|
@ -167,6 +206,10 @@ export function useCodexAccountSnapshot(options: {
|
||||||
? CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS
|
? CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS
|
||||||
: CODEX_VISIBLE_STANDARD_REFRESH_MS;
|
: CODEX_VISIBLE_STANDARD_REFRESH_MS;
|
||||||
|
|
||||||
|
if (initialRefreshDelayMs > 0 && lastUpdatedAtRef.current === null && snapshot === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
lastUpdatedAtRef.current === null ||
|
lastUpdatedAtRef.current === null ||
|
||||||
Date.now() - lastUpdatedAtRef.current >= staleAfterMs
|
Date.now() - lastUpdatedAtRef.current >= staleAfterMs
|
||||||
|
|
@ -182,12 +225,22 @@ export function useCodexAccountSnapshot(options: {
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
};
|
};
|
||||||
}, [electronMode, options.enabled, options.includeRateLimits, refresh]);
|
}, [
|
||||||
|
electronMode,
|
||||||
|
initialRefreshDelayMs,
|
||||||
|
options.enabled,
|
||||||
|
options.includeRateLimits,
|
||||||
|
refresh,
|
||||||
|
snapshot,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!electronMode || !options.enabled) {
|
if (!electronMode || !options.enabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (initialRefreshDelayMs > 0 && snapshot === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const refreshIntervalMs = getRefreshIntervalMs({
|
const refreshIntervalMs = getRefreshIntervalMs({
|
||||||
loginStatus: snapshot?.login.status,
|
loginStatus: snapshot?.login.status,
|
||||||
|
|
@ -206,9 +259,11 @@ export function useCodexAccountSnapshot(options: {
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
electronMode,
|
electronMode,
|
||||||
|
initialRefreshDelayMs,
|
||||||
options.enabled,
|
options.enabled,
|
||||||
options.includeRateLimits,
|
options.includeRateLimits,
|
||||||
refresh,
|
refresh,
|
||||||
|
snapshot,
|
||||||
snapshot?.login.status,
|
snapshot?.login.status,
|
||||||
visible,
|
visible,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
export { useCodexAccountSnapshot } from './hooks/useCodexAccountSnapshot';
|
export {
|
||||||
|
CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS,
|
||||||
|
useCodexAccountSnapshot,
|
||||||
|
} from './hooks/useCodexAccountSnapshot';
|
||||||
export { mergeCodexCliStatusWithSnapshot } from './mergeCodexCliStatusWithSnapshot';
|
export { mergeCodexCliStatusWithSnapshot } from './mergeCodexCliStatusWithSnapshot';
|
||||||
export { mergeCodexProviderStatusWithSnapshot } from './mergeCodexProviderStatusWithSnapshot';
|
export { mergeCodexProviderStatusWithSnapshot } from './mergeCodexProviderStatusWithSnapshot';
|
||||||
export {
|
export {
|
||||||
|
|
|
||||||
|
|
@ -354,10 +354,18 @@ function describeMemberWorkSyncReviewPickupEscalationReason(reason: string): str
|
||||||
return 'The current review request is still waiting for explicit review pickup.';
|
return 'The current review request is still waiting for explicit review pickup.';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveOpenCodeRuntimeBinaryForBridgeEnv(): Promise<string | null> {
|
async function resolveOpenCodeRuntimeBinaryForBridgeEnv(options?: {
|
||||||
const resolvedBinaryPath = await resolveVerifiedOpenCodeRuntimeBinaryPath();
|
includeShellEnv?: boolean;
|
||||||
|
}): Promise<string | null> {
|
||||||
|
const resolvedBinaryPath = await resolveVerifiedOpenCodeRuntimeBinaryPath({
|
||||||
|
includeShellEnv: options?.includeShellEnv,
|
||||||
|
});
|
||||||
if (resolvedBinaryPath) return resolvedBinaryPath;
|
if (resolvedBinaryPath) return resolvedBinaryPath;
|
||||||
|
|
||||||
|
if (options?.includeShellEnv === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await openCodeRuntimeInstallerService?.getStatus();
|
const status = await openCodeRuntimeInstallerService?.getStatus();
|
||||||
return status?.installed === true && status.binaryPath ? status.binaryPath : null;
|
return status?.installed === true && status.binaryPath ? status.binaryPath : null;
|
||||||
|
|
@ -448,15 +456,18 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
||||||
copyOpenCodeLocalMcpLaunchEnv(targetEnv, bridgeEnv);
|
copyOpenCodeLocalMcpLaunchEnv(targetEnv, bridgeEnv);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const ensureOpenCodeRuntimeBinaryEnv = async (targetEnv: NodeJS.ProcessEnv): Promise<void> => {
|
const ensureOpenCodeRuntimeBinaryEnv = async (
|
||||||
|
targetEnv: NodeJS.ProcessEnv,
|
||||||
|
options: { includeShellEnv?: boolean } = {}
|
||||||
|
): Promise<void> => {
|
||||||
await ensureOpenCodeBridgeRuntimeBinaryEnv({
|
await ensureOpenCodeBridgeRuntimeBinaryEnv({
|
||||||
targetEnv,
|
targetEnv,
|
||||||
bridgeEnv,
|
bridgeEnv,
|
||||||
resolveVerifiedOpenCodeRuntimeBinaryPath: resolveOpenCodeRuntimeBinaryForBridgeEnv,
|
resolveVerifiedOpenCodeRuntimeBinaryPath: () =>
|
||||||
|
resolveOpenCodeRuntimeBinaryForBridgeEnv({ includeShellEnv: options.includeShellEnv }),
|
||||||
onWarning: (message) => logger.warn(message),
|
onWarning: (message) => logger.warn(message),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
await ensureOpenCodeRuntimeBinaryEnv(bridgeEnv);
|
|
||||||
try {
|
try {
|
||||||
reportProgress('runtime-work-sync', 'Preparing runtime work sync hooks...');
|
reportProgress('runtime-work-sync', 'Preparing runtime work sync hooks...');
|
||||||
const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({
|
const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({
|
||||||
|
|
@ -500,7 +511,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
||||||
reportProgress('runtime-bridge', 'Preparing OpenCode bridge...');
|
reportProgress('runtime-bridge', 'Preparing OpenCode bridge...');
|
||||||
const resolveBridgeCommandEnv = async (): Promise<NodeJS.ProcessEnv> => {
|
const resolveBridgeCommandEnv = async (): Promise<NodeJS.ProcessEnv> => {
|
||||||
const nextEnv = { ...bridgeEnv };
|
const nextEnv = { ...bridgeEnv };
|
||||||
await ensureOpenCodeRuntimeBinaryEnv(nextEnv);
|
await ensureOpenCodeRuntimeBinaryEnv(nextEnv, { includeShellEnv: true });
|
||||||
if (!useHttpMcpBridge) {
|
if (!useHttpMcpBridge) {
|
||||||
return nextEnv;
|
return nextEnv;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1030,13 +1030,17 @@ export class CliInstallerService {
|
||||||
providerStatusMode: CliInstallerProviderStatusMode
|
providerStatusMode: CliInstallerProviderStatusMode
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
resetGatherDiag(diag);
|
resetGatherDiag(diag);
|
||||||
const shellEnvStartedAt = Date.now();
|
if (providerStatusMode === 'defer') {
|
||||||
await resolveInteractiveShellEnvBestEffort({
|
diag.shellEnvMs = 0;
|
||||||
timeoutMs: 1_500,
|
} else {
|
||||||
fallbackEnv: process.env,
|
const shellEnvStartedAt = Date.now();
|
||||||
background: false,
|
await resolveInteractiveShellEnvBestEffort({
|
||||||
});
|
timeoutMs: 1_500,
|
||||||
diag.shellEnvMs = Date.now() - shellEnvStartedAt;
|
fallbackEnv: process.env,
|
||||||
|
background: false,
|
||||||
|
});
|
||||||
|
diag.shellEnvMs = Date.now() - shellEnvStartedAt;
|
||||||
|
}
|
||||||
|
|
||||||
const r = ref.current;
|
const r = ref.current;
|
||||||
const binaryResolveStartedAt = Date.now();
|
const binaryResolveStartedAt = Date.now();
|
||||||
|
|
|
||||||
|
|
@ -212,8 +212,13 @@ async function probeFirstWorkingOpenCodeBinaryCandidate(
|
||||||
return { ok: false, firstFailure: nextFirstFailure };
|
return { ok: false, firstFailure: nextFirstFailure };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OpenCodeRuntimeBinaryResolveOptions {
|
||||||
|
shellEnvTimeoutMs?: number;
|
||||||
|
includeShellEnv?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
async function probeFirstWorkingPathOpenCodeBinary(
|
async function probeFirstWorkingPathOpenCodeBinary(
|
||||||
options: { shellEnvTimeoutMs?: number } = {}
|
options: OpenCodeRuntimeBinaryResolveOptions = {}
|
||||||
): Promise<VerifiedOpenCodeBinaryProbe> {
|
): Promise<VerifiedOpenCodeBinaryProbe> {
|
||||||
const seenCandidates = new Set<string>();
|
const seenCandidates = new Set<string>();
|
||||||
let firstFailure: { binaryPath: string; error: string } | null = null;
|
let firstFailure: { binaryPath: string; error: string } | null = null;
|
||||||
|
|
@ -230,6 +235,16 @@ async function probeFirstWorkingPathOpenCodeBinary(
|
||||||
}
|
}
|
||||||
firstFailure = cachedProbe.firstFailure;
|
firstFailure = cachedProbe.firstFailure;
|
||||||
|
|
||||||
|
if (options.includeShellEnv === false) {
|
||||||
|
return probeFirstWorkingOpenCodeBinaryCandidate(
|
||||||
|
collectPathOpenCodeBinaryCandidates([], {
|
||||||
|
includeFallbackPathEntries: true,
|
||||||
|
}),
|
||||||
|
seenCandidates,
|
||||||
|
firstFailure
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const shellEnv = await resolveInteractiveShellEnvBestEffort({
|
const shellEnv = await resolveInteractiveShellEnvBestEffort({
|
||||||
timeoutMs: options.shellEnvTimeoutMs ?? RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS,
|
timeoutMs: options.shellEnvTimeoutMs ?? RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS,
|
||||||
fallbackEnv: process.env,
|
fallbackEnv: process.env,
|
||||||
|
|
@ -254,14 +269,14 @@ async function probeFirstWorkingPathOpenCodeBinary(
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveVerifiedPathOpenCodeBinaryPath(
|
async function resolveVerifiedPathOpenCodeBinaryPath(
|
||||||
options: { shellEnvTimeoutMs?: number } = {}
|
options: OpenCodeRuntimeBinaryResolveOptions = {}
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const result = await probeFirstWorkingPathOpenCodeBinary(options);
|
const result = await probeFirstWorkingPathOpenCodeBinary(options);
|
||||||
return result.ok ? result.binaryPath : null;
|
return result.ok ? result.binaryPath : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveVerifiedOpenCodeRuntimeBinaryPath(
|
export async function resolveVerifiedOpenCodeRuntimeBinaryPath(
|
||||||
options: { shellEnvTimeoutMs?: number } = {}
|
options: OpenCodeRuntimeBinaryResolveOptions = {}
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
return (
|
return (
|
||||||
(await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()) ??
|
(await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()) ??
|
||||||
|
|
|
||||||
|
|
@ -386,15 +386,6 @@ async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<s
|
||||||
if (_resolvedNodePath) return _resolvedNodePath;
|
if (_resolvedNodePath) return _resolvedNodePath;
|
||||||
|
|
||||||
emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...');
|
emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...');
|
||||||
if (shouldPreferShellNodeProbe()) {
|
|
||||||
const shellProbe = await probeShellNodeRuntimePath(options);
|
|
||||||
if (shellProbe.ok) {
|
|
||||||
_resolvedNodePath = shellProbe.path;
|
|
||||||
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
|
|
||||||
return _resolvedNodePath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fastProbe = await probeNodeRuntimePath(buildNodeResolveEnv({}));
|
const fastProbe = await probeNodeRuntimePath(buildNodeResolveEnv({}));
|
||||||
if (fastProbe.ok) {
|
if (fastProbe.ok) {
|
||||||
_resolvedNodePath = fastProbe.path;
|
_resolvedNodePath = fastProbe.path;
|
||||||
|
|
@ -402,6 +393,9 @@ async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<s
|
||||||
return _resolvedNodePath;
|
return _resolvedNodePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldPreferShellNodeProbe()) {
|
||||||
|
emitProgress(options, 'node-runtime-shell-fallback', 'Trying login shell Node.js runtime...');
|
||||||
|
}
|
||||||
const shellProbe = await probeShellNodeRuntimePath(options);
|
const shellProbe = await probeShellNodeRuntimePath(options);
|
||||||
if (shellProbe.ok) {
|
if (shellProbe.ok) {
|
||||||
_resolvedNodePath = shellProbe.path;
|
_resolvedNodePath = shellProbe.path;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS,
|
||||||
mergeCodexCliStatusWithSnapshot,
|
mergeCodexCliStatusWithSnapshot,
|
||||||
useCodexAccountSnapshot,
|
useCodexAccountSnapshot,
|
||||||
} from '@features/codex-account/renderer';
|
} from '@features/codex-account/renderer';
|
||||||
|
|
@ -123,6 +124,7 @@ export const GlobalProviderStatusHeader = (): React.JSX.Element | null => {
|
||||||
loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
|
loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
|
||||||
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')),
|
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')),
|
||||||
includeRateLimits: false,
|
includeRateLimits: false,
|
||||||
|
initialRefreshDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
const effectiveCliStatus = useMemo(
|
const effectiveCliStatus = useMemo(
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS,
|
||||||
mergeCodexProviderStatusWithSnapshot,
|
mergeCodexProviderStatusWithSnapshot,
|
||||||
useCodexAccountSnapshot,
|
useCodexAccountSnapshot,
|
||||||
} from '@features/codex-account/renderer';
|
} from '@features/codex-account/renderer';
|
||||||
|
|
@ -1358,6 +1359,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
|
loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
|
||||||
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')),
|
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')),
|
||||||
includeRateLimits: true,
|
includeRateLimits: true,
|
||||||
|
initialRefreshDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS,
|
||||||
});
|
});
|
||||||
const visibleCliProviders = useMemo(
|
const visibleCliProviders = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS,
|
||||||
mergeCodexProviderStatusWithSnapshot,
|
mergeCodexProviderStatusWithSnapshot,
|
||||||
useCodexAccountSnapshot,
|
useCodexAccountSnapshot,
|
||||||
} from '@features/codex-account/renderer';
|
} from '@features/codex-account/renderer';
|
||||||
|
|
@ -176,6 +177,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
includeRateLimits: true,
|
includeRateLimits: true,
|
||||||
|
initialRefreshDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS,
|
||||||
});
|
});
|
||||||
const codexSnapshotPending =
|
const codexSnapshotPending =
|
||||||
codexAccount.loading &&
|
codexAccount.loading &&
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { act } from 'react';
|
import React, { act } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { useCodexAccountSnapshot } from '../../../../src/features/codex-account/renderer/hooks/useCodexAccountSnapshot';
|
import { useCodexAccountSnapshot } from '../../../../src/features/codex-account/renderer/hooks/useCodexAccountSnapshot';
|
||||||
|
|
@ -124,9 +125,146 @@ describe('useCodexAccountSnapshot', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can defer the initial Codex snapshot without starting interval refreshes first', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const snapshot = createSnapshot();
|
||||||
|
apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(snapshot);
|
||||||
|
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
function Harness(): React.ReactElement {
|
||||||
|
const state = useCodexAccountSnapshot({
|
||||||
|
enabled: true,
|
||||||
|
includeRateLimits: true,
|
||||||
|
initialRefreshDelayMs: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return React.createElement(
|
||||||
|
'div',
|
||||||
|
{ 'data-loading': state.loading ? 'true' : 'false' },
|
||||||
|
state.snapshot?.managedAccount?.email ?? 'empty'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(React.createElement(Harness));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
|
||||||
|
expect(host.firstElementChild?.getAttribute('data-loading')).toBe('false');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(20_000);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledWith({
|
||||||
|
includeRateLimits: true,
|
||||||
|
});
|
||||||
|
expect(host.textContent).toContain('belief@example.com');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears a deferred initial Codex snapshot timer on unmount', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(createSnapshot());
|
||||||
|
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
function Harness(): React.ReactElement {
|
||||||
|
useCodexAccountSnapshot({
|
||||||
|
enabled: true,
|
||||||
|
includeRateLimits: true,
|
||||||
|
initialRefreshDelayMs: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return React.createElement('div', null, 'mounted');
|
||||||
|
}
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(React.createElement(Harness));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(30_000);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not run the deferred initial snapshot after a manual refresh already loaded one', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
Object.defineProperty(document, 'visibilityState', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => 'hidden' satisfies DocumentVisibilityState,
|
||||||
|
});
|
||||||
|
const snapshot = createSnapshot();
|
||||||
|
apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(snapshot);
|
||||||
|
let refreshNow!: () => Promise<void>;
|
||||||
|
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
function Harness(): React.ReactElement {
|
||||||
|
const state = useCodexAccountSnapshot({
|
||||||
|
enabled: true,
|
||||||
|
includeRateLimits: true,
|
||||||
|
initialRefreshDelayMs: 30_000,
|
||||||
|
});
|
||||||
|
refreshNow = () => state.refresh({ includeRateLimits: true });
|
||||||
|
|
||||||
|
return React.createElement('div', null, state.snapshot?.managedAccount?.email ?? 'empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(React.createElement(Harness));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await refreshNow();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(30_000);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('refreshes rate-limit snapshots more often while visible without flipping loading state during background polls', async () => {
|
it('refreshes rate-limit snapshots more often while visible without flipping loading state during background polls', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
let visibilityState: DocumentVisibilityState = 'visible';
|
const visibilityState: DocumentVisibilityState = 'visible';
|
||||||
Object.defineProperty(document, 'visibilityState', {
|
Object.defineProperty(document, 'visibilityState', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
get: () => visibilityState,
|
get: () => visibilityState,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const { realpathMock } = vi.hoisted(() => ({
|
const { realpathMock, resolveInteractiveShellEnvBestEffortMock } = vi.hoisted(() => ({
|
||||||
realpathMock: vi.fn(async (value: string) => value),
|
realpathMock: vi.fn(async (value: string) => value),
|
||||||
|
resolveInteractiveShellEnvBestEffortMock: vi.fn(
|
||||||
|
async (options?: { fallbackEnv?: NodeJS.ProcessEnv }) => options?.fallbackEnv ?? process.env
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock dependencies before importing service
|
// Mock dependencies before importing service
|
||||||
|
|
@ -83,6 +86,14 @@ vi.mock('@main/utils/cliAuthDiagLog', () => ({
|
||||||
appendCliAuthDiag: vi.fn(() => Promise.resolve(null)),
|
appendCliAuthDiag: vi.fn(() => Promise.resolve(null)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@main/utils/shellEnv', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@main/utils/shellEnv')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
resolveInteractiveShellEnvBestEffort: resolveInteractiveShellEnvBestEffortMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CliInstallerService,
|
CliInstallerService,
|
||||||
isVersionOlder,
|
isVersionOlder,
|
||||||
|
|
@ -148,6 +159,9 @@ describe('CliInstallerService', () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
realpathMock.mockReset();
|
realpathMock.mockReset();
|
||||||
realpathMock.mockImplementation(async (value: string) => value);
|
realpathMock.mockImplementation(async (value: string) => value);
|
||||||
|
resolveInteractiveShellEnvBestEffortMock.mockImplementation(
|
||||||
|
async (options?: { fallbackEnv?: NodeJS.ProcessEnv }) => options?.fallbackEnv ?? process.env
|
||||||
|
);
|
||||||
vi.mocked(getConfiguredCliFlavor).mockReturnValue('claude');
|
vi.mocked(getConfiguredCliFlavor).mockReturnValue('claude');
|
||||||
vi.mocked(getCliFlavorUiOptions).mockReturnValue({
|
vi.mocked(getCliFlavorUiOptions).mockReturnValue({
|
||||||
displayName: 'Claude CLI',
|
displayName: 'Claude CLI',
|
||||||
|
|
@ -169,6 +183,13 @@ describe('CliInstallerService', () => {
|
||||||
expect(status.installedVersion).toBeNull();
|
expect(status.installedVersion).toBeNull();
|
||||||
expect(status.binaryPath).toBeNull();
|
expect(status.binaryPath).toBeNull();
|
||||||
expect(status.updateAvailable).toBe(false);
|
expect(status.updateAvailable).toBe(false);
|
||||||
|
expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
timeoutMs: 1_500,
|
||||||
|
fallbackEnv: process.env,
|
||||||
|
background: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not block getStatus on diagnostic file writes', async () => {
|
it('does not block getStatus on diagnostic file writes', async () => {
|
||||||
|
|
@ -356,6 +377,7 @@ describe('CliInstallerService', () => {
|
||||||
.filter((event) => event.type === 'status');
|
.filter((event) => event.type === 'status');
|
||||||
|
|
||||||
expect(status.installed).toBe(true);
|
expect(status.installed).toBe(true);
|
||||||
|
expect(resolveInteractiveShellEnvBestEffortMock).not.toHaveBeenCalled();
|
||||||
expect(status.authStatusChecking).toBe(false);
|
expect(status.authStatusChecking).toBe(false);
|
||||||
expect(status.authLoggedIn).toBe(false);
|
expect(status.authLoggedIn).toBe(false);
|
||||||
expect(status.providers).toHaveLength(3);
|
expect(status.providers).toHaveLength(3);
|
||||||
|
|
|
||||||
|
|
@ -243,6 +243,36 @@ describe('OpenCodeRuntimeInstallerService resolver', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves from fast fallback PATH without spawning shell env when shell env is disabled', async () => {
|
||||||
|
const binaryPath = path.join(tempRoot!, 'merged-cli-path', 'bin', 'opencode');
|
||||||
|
await mkdir(path.dirname(binaryPath), { recursive: true });
|
||||||
|
await writeFile(binaryPath, 'binary', { mode: 0o755 });
|
||||||
|
buildMergedCliPathMock.mockReturnValue(path.dirname(binaryPath));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveVerifiedOpenCodeRuntimeBinaryPath({ includeShellEnv: false })
|
||||||
|
).resolves.toBe(binaryPath);
|
||||||
|
expect(resolveInteractiveShellEnvBestEffortMock).not.toHaveBeenCalled();
|
||||||
|
expect(execCliMock).toHaveBeenCalledWith(binaryPath, ['--version'], {
|
||||||
|
timeout: 10_000,
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not spawn shell env for shell-only PATH installs when shell env is disabled', async () => {
|
||||||
|
const binaryPath = path.join(tempRoot!, 'custom-npm-prefix', 'bin', 'opencode');
|
||||||
|
await mkdir(path.dirname(binaryPath), { recursive: true });
|
||||||
|
await writeFile(binaryPath, 'binary', { mode: 0o755 });
|
||||||
|
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({
|
||||||
|
PATH: path.dirname(binaryPath),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveVerifiedOpenCodeRuntimeBinaryPath({ includeShellEnv: false })
|
||||||
|
).resolves.toBeNull();
|
||||||
|
expect(resolveInteractiveShellEnvBestEffortMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('returns a verified OpenCode binary from nvm when desktop PATH misses npm globals', async () => {
|
it('returns a verified OpenCode binary from nvm when desktop PATH misses npm globals', async () => {
|
||||||
const olderBinaryPath = path.join(
|
const olderBinaryPath = path.join(
|
||||||
tempRoot!,
|
tempRoot!,
|
||||||
|
|
|
||||||
|
|
@ -464,6 +464,28 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('skips strict shell env lookup when fast Node lookup succeeds from a minimal GUI PATH', async () => {
|
||||||
|
mockBuiltWorkspaceEntryAvailable();
|
||||||
|
const previousPath = process.env.PATH;
|
||||||
|
process.env.PATH = ['/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(path.delimiter);
|
||||||
|
hoisted.execCliMock.mockResolvedValue({ stdout: '/fast/node', stderr: '' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const builder = new TeamMcpConfigBuilder();
|
||||||
|
const configPath = await builder.writeConfigFile();
|
||||||
|
createdPaths.push(configPath);
|
||||||
|
|
||||||
|
expect(readGeneratedServer(configPath)?.command).toBe('/fast/node');
|
||||||
|
expect(hoisted.resolveInteractiveShellEnvMock).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
if (previousPath === undefined) {
|
||||||
|
delete process.env.PATH;
|
||||||
|
} else {
|
||||||
|
process.env.PATH = previousPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('falls back to strict shell env lookup when fast Node lookup reports an empty path', async () => {
|
it('falls back to strict shell env lookup when fast Node lookup reports an empty path', async () => {
|
||||||
mockBuiltWorkspaceEntryAvailable();
|
mockBuiltWorkspaceEntryAvailable();
|
||||||
hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({
|
hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue