perf(runtime): speed up dashboard provider status loading
This commit is contained in:
parent
8da5b7f25d
commit
8ac0b43a2a
5 changed files with 290 additions and 27 deletions
|
|
@ -353,6 +353,26 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
|
|||
};
|
||||
}
|
||||
|
||||
function createPendingProviderStatus(providerId: CliProviderId): CliProviderStatus {
|
||||
return {
|
||||
...createDefaultProviderStatus(providerId),
|
||||
statusMessage: 'Checking...',
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntimeStatusErrorProviderStatus(
|
||||
providerId: CliProviderId,
|
||||
error: unknown
|
||||
): CliProviderStatus {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
...createDefaultProviderStatus(providerId),
|
||||
verificationState: 'error',
|
||||
statusMessage: 'Provider status unavailable',
|
||||
detailMessage: message,
|
||||
};
|
||||
}
|
||||
|
||||
function mapRuntimeExtensionCapabilities(
|
||||
providerId: CliProviderId,
|
||||
capabilities?: RuntimeExtensionCapabilitiesResponse
|
||||
|
|
@ -668,6 +688,97 @@ export class ClaudeMultimodelBridgeService {
|
|||
return providers.map((provider) => this.applyConnectionIssue(provider, connectionIssues));
|
||||
}
|
||||
|
||||
private buildProviderStatusesSnapshot(
|
||||
providers: Map<CliProviderId, CliProviderStatus>
|
||||
): CliProviderStatus[] {
|
||||
return ORDERED_PROVIDER_IDS.map(
|
||||
(providerId) => providers.get(providerId) ?? createPendingProviderStatus(providerId)
|
||||
);
|
||||
}
|
||||
|
||||
private async getProviderStatusFromRuntimeStatusCommand(
|
||||
binaryPath: string,
|
||||
providerId: CliProviderId,
|
||||
env: NodeJS.ProcessEnv,
|
||||
connectionIssues: Partial<Record<CliProviderId, string>>
|
||||
): Promise<CliProviderStatus> {
|
||||
const { stdout } = await execCli(
|
||||
binaryPath,
|
||||
['runtime', 'status', '--json', '--provider', providerId],
|
||||
{
|
||||
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
||||
env,
|
||||
}
|
||||
);
|
||||
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
|
||||
return providerConnectionService.enrichProviderStatus(
|
||||
this.applyConnectionIssue(
|
||||
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]),
|
||||
connectionIssues
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async getProviderStatusFromScopedRuntimeStatus(
|
||||
binaryPath: string,
|
||||
providerId: CliProviderId
|
||||
): Promise<CliProviderStatus> {
|
||||
const { env, connectionIssues } = await this.buildProviderCliEnv(binaryPath, providerId);
|
||||
return this.getProviderStatusFromRuntimeStatusCommand(
|
||||
binaryPath,
|
||||
providerId,
|
||||
env,
|
||||
connectionIssues
|
||||
);
|
||||
}
|
||||
|
||||
private async getProviderStatusesFromScopedRuntimeStatus(
|
||||
binaryPath: string,
|
||||
onUpdate?: (providers: CliProviderStatus[]) => void
|
||||
): Promise<CliProviderStatus[] | null> {
|
||||
const providers = new Map<CliProviderId, CliProviderStatus>(
|
||||
ORDERED_PROVIDER_IDS.map((providerId) => [
|
||||
providerId,
|
||||
createPendingProviderStatus(providerId),
|
||||
])
|
||||
);
|
||||
const failures: { providerId: CliProviderId; error: unknown }[] = [];
|
||||
|
||||
await Promise.all(
|
||||
ORDERED_PROVIDER_IDS.map(async (providerId) => {
|
||||
try {
|
||||
providers.set(
|
||||
providerId,
|
||||
await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId)
|
||||
);
|
||||
onUpdate?.(this.buildProviderStatusesSnapshot(providers));
|
||||
} catch (error) {
|
||||
failures.push({ providerId, error });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (failures.length === 0) {
|
||||
return this.buildProviderStatusesSnapshot(providers);
|
||||
}
|
||||
|
||||
if (failures.length === ORDERED_PROVIDER_IDS.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`Provider-scoped runtime status failed for ${failures
|
||||
.map(({ providerId }) => providerId)
|
||||
.join(', ')}; using partial provider statuses`
|
||||
);
|
||||
|
||||
for (const { providerId, error } of failures) {
|
||||
providers.set(providerId, createRuntimeStatusErrorProviderStatus(providerId, error));
|
||||
}
|
||||
onUpdate?.(this.buildProviderStatusesSnapshot(providers));
|
||||
return this.buildProviderStatusesSnapshot(providers);
|
||||
}
|
||||
|
||||
private async getOpenCodeVerifySnapshot(
|
||||
binaryPath: string
|
||||
): Promise<OpenCodeRuntimeVerifyResponse['snapshot'] | null> {
|
||||
|
|
@ -761,24 +872,9 @@ export class ClaudeMultimodelBridgeService {
|
|||
providerId: CliProviderId
|
||||
): Promise<CliProviderStatus> {
|
||||
await resolveInteractiveShellEnv();
|
||||
const { env, connectionIssues } = await this.buildCliEnv(binaryPath);
|
||||
|
||||
try {
|
||||
const { stdout } = await execCli(
|
||||
binaryPath,
|
||||
['runtime', 'status', '--json', '--provider', providerId],
|
||||
{
|
||||
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
||||
env,
|
||||
}
|
||||
);
|
||||
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
|
||||
return providerConnectionService.enrichProviderStatus(
|
||||
this.applyConnectionIssue(
|
||||
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]),
|
||||
connectionIssues
|
||||
)
|
||||
);
|
||||
return await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId);
|
||||
} catch (error) {
|
||||
if (!this.isUnifiedRuntimeUnsupported(error)) {
|
||||
logger.warn(
|
||||
|
|
@ -937,6 +1033,20 @@ export class ClaudeMultimodelBridgeService {
|
|||
onUpdate?: (providers: CliProviderStatus[]) => void
|
||||
): Promise<CliProviderStatus[]> {
|
||||
await resolveInteractiveShellEnv();
|
||||
|
||||
try {
|
||||
const providers = await this.getProviderStatusesFromScopedRuntimeStatus(binaryPath, onUpdate);
|
||||
if (providers) {
|
||||
return providers;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Provider-scoped runtime status unavailable, falling back to full probe: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
const { env, connectionIssues } = await this.buildCliEnv(binaryPath);
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@ import { create } from 'zustand';
|
|||
import { createChangeReviewSlice } from './slices/changeReviewSlice';
|
||||
import {
|
||||
createCliInstallerSlice,
|
||||
getIncompleteMultimodelProviderIds,
|
||||
getModelOnlyFallbackProviderIds,
|
||||
mergeCliStatusPreservingHydratedProviders,
|
||||
reconcileMultimodelProviderLoading,
|
||||
} from './slices/cliInstallerSlice';
|
||||
import { createConfigSlice } from './slices/configSlice';
|
||||
import { createConnectionSlice } from './slices/connectionSlice';
|
||||
|
|
@ -1485,20 +1485,14 @@ export function initializeNotificationListeners(): () => void {
|
|||
state.cliStatus,
|
||||
progress.status!
|
||||
);
|
||||
const incompleteProviderIds = getIncompleteMultimodelProviderIds(nextStatus);
|
||||
modelOnlyFallbackProviderIds = getModelOnlyFallbackProviderIds(nextStatus);
|
||||
|
||||
return {
|
||||
cliStatus: nextStatus,
|
||||
cliProviderStatusLoading:
|
||||
incompleteProviderIds.length > 0
|
||||
? {
|
||||
...state.cliProviderStatusLoading,
|
||||
...Object.fromEntries(
|
||||
incompleteProviderIds.map((providerId) => [providerId, true])
|
||||
),
|
||||
}
|
||||
: state.cliProviderStatusLoading,
|
||||
cliProviderStatusLoading: reconcileMultimodelProviderLoading(
|
||||
nextStatus,
|
||||
state.cliProviderStatusLoading
|
||||
),
|
||||
};
|
||||
});
|
||||
for (const providerId of modelOnlyFallbackProviderIds) {
|
||||
|
|
|
|||
|
|
@ -125,6 +125,24 @@ export function getModelOnlyFallbackProviderIds(
|
|||
.map((provider) => provider.providerId);
|
||||
}
|
||||
|
||||
export function reconcileMultimodelProviderLoading(
|
||||
status: CliInstallationStatus | null,
|
||||
currentLoading: Partial<Record<CliProviderId, boolean>>
|
||||
): Partial<Record<CliProviderId, boolean>> {
|
||||
if (status?.flavor !== 'agent_teams_orchestrator' || !status.installed) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const incompleteProviderIds = new Set(getIncompleteMultimodelProviderIds(status));
|
||||
return status.providers.reduce<Partial<Record<CliProviderId, boolean>>>(
|
||||
(nextLoading, provider) => ({
|
||||
...nextLoading,
|
||||
[provider.providerId]: incompleteProviderIds.has(provider.providerId),
|
||||
}),
|
||||
{ ...currentLoading }
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeCliStatusPreservingHydratedProviders(
|
||||
current: CliInstallationStatus | null,
|
||||
incoming: CliInstallationStatus
|
||||
|
|
|
|||
|
|
@ -233,6 +233,108 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('loads all providers with parallel provider-scoped runtime status probes', async () => {
|
||||
const providerPayloads = {
|
||||
anthropic: {
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'oauth_token',
|
||||
verificationState: 'verified',
|
||||
canLoginFromUi: true,
|
||||
models: ['claude-sonnet-4-5'],
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
backend: { kind: 'anthropic', label: 'Anthropic' },
|
||||
},
|
||||
codex: {
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'api_key',
|
||||
verificationState: 'verified',
|
||||
canLoginFromUi: false,
|
||||
models: ['gpt-5-codex'],
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
backend: { kind: 'codex-native', label: 'Codex native' },
|
||||
},
|
||||
gemini: {
|
||||
supported: true,
|
||||
authenticated: false,
|
||||
verificationState: 'unknown',
|
||||
canLoginFromUi: true,
|
||||
statusMessage: 'No Gemini runtime backend is ready',
|
||||
models: ['gemini-2.5-pro'],
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
},
|
||||
opencode: {
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
verificationState: 'verified',
|
||||
canLoginFromUi: false,
|
||||
models: ['openai/gpt-5.4-mini'],
|
||||
capabilities: { teamLaunch: true, oneShot: false },
|
||||
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
execCliMock.mockImplementation((_binaryPath, args) => {
|
||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||
const providerArgIndex = Array.isArray(args) ? args.indexOf('--provider') : -1;
|
||||
const providerId =
|
||||
providerArgIndex >= 0 && Array.isArray(args)
|
||||
? (args[providerArgIndex + 1] as keyof typeof providerPayloads)
|
||||
: null;
|
||||
|
||||
if (
|
||||
normalizedArgs.startsWith('runtime status --json --provider ') &&
|
||||
providerId &&
|
||||
providerPayloads[providerId]
|
||||
) {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
providers: {
|
||||
[providerId]: providerPayloads[providerId],
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
|
||||
});
|
||||
|
||||
const { ClaudeMultimodelBridgeService } =
|
||||
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
|
||||
const service = new ClaudeMultimodelBridgeService();
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
const providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator', onUpdate);
|
||||
|
||||
expect(execCliMock).toHaveBeenCalledTimes(4);
|
||||
expect(execCliMock.mock.calls.map((call) => call[1].join(' '))).toEqual(
|
||||
expect.arrayContaining([
|
||||
'runtime status --json --provider anthropic',
|
||||
'runtime status --json --provider codex',
|
||||
'runtime status --json --provider gemini',
|
||||
'runtime status --json --provider opencode',
|
||||
])
|
||||
);
|
||||
expect(providers.map((provider) => provider.providerId)).toEqual([
|
||||
'anthropic',
|
||||
'codex',
|
||||
'gemini',
|
||||
'opencode',
|
||||
]);
|
||||
expect(providers.find((provider) => provider.providerId === 'codex')).toMatchObject({
|
||||
authenticated: true,
|
||||
models: ['gpt-5-codex'],
|
||||
backend: { kind: 'codex-native' },
|
||||
});
|
||||
expect(onUpdate).toHaveBeenCalled();
|
||||
expect(onUpdate.mock.calls.at(-1)?.[0]).toEqual(providers);
|
||||
});
|
||||
|
||||
it('overrides provider auth status when provider-aware env reports a missing API key', async () => {
|
||||
buildProviderAwareCliEnvMock.mockResolvedValue({
|
||||
env: { HOME: '/Users/tester' },
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import {
|
|||
getIncompleteMultimodelProviderIds,
|
||||
getModelOnlyFallbackProviderIds,
|
||||
mergeCliStatusPreservingHydratedProviders,
|
||||
reconcileMultimodelProviderLoading,
|
||||
} from '@renderer/store/slices/cliInstallerSlice';
|
||||
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||
|
||||
|
|
@ -251,6 +252,44 @@ describe('cliInstallerSlice', () => {
|
|||
expect(getModelOnlyFallbackProviderIds(status)).toEqual([]);
|
||||
});
|
||||
|
||||
it('clears loading for hydrated providers while keeping pending providers marked', () => {
|
||||
const status = createMultimodelStatus([
|
||||
createMultimodelProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
authenticated: true,
|
||||
authMethod: 'oauth_token',
|
||||
statusMessage: null,
|
||||
models: ['claude-sonnet-4-5'],
|
||||
backend: { kind: 'anthropic', label: 'Anthropic' },
|
||||
}),
|
||||
createMultimodelProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
models: [],
|
||||
backend: null,
|
||||
availableBackends: [],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
reconcileMultimodelProviderLoading(status, {
|
||||
anthropic: true,
|
||||
codex: true,
|
||||
opencode: true,
|
||||
})
|
||||
).toEqual({
|
||||
anthropic: false,
|
||||
codex: true,
|
||||
opencode: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('still allows real OpenCode runtime errors to replace previous ready status', () => {
|
||||
const current = createMultimodelStatus([
|
||||
createMultimodelProvider({
|
||||
|
|
|
|||
Loading…
Reference in a new issue