fix: improve startup readiness performance
This commit is contained in:
+10
-2
@@ -56,6 +56,7 @@ import { SyncAppEffects } from '@/apps/AppEffects';
|
||||
import { useAppFontEffects } from '@/apps/useAppFontEffects';
|
||||
import { resetStreamingState } from '@/sync/streaming';
|
||||
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
|
||||
import { markStartupTrace, startupTraceEnabled } from '@/lib/startupTrace';
|
||||
|
||||
// Lazy-loaded heavy views — loaded on demand to reduce initial bundle size.
|
||||
const OnboardingScreen = lazyWithChunkRecovery(() =>
|
||||
@@ -201,6 +202,13 @@ const EmbeddedSessionChatContent: React.FC<{
|
||||
};
|
||||
|
||||
function App({ apis }: AppProps) {
|
||||
React.useEffect(() => {
|
||||
markStartupTrace('App:mounted');
|
||||
if (startupTraceEnabled()) {
|
||||
console.info('[startup-trace] enabled. Run console.table(window.__OPENCHAMBER_STARTUP_TRACE__) after startup.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const initializeApp = useConfigStore((s) => s.initializeApp);
|
||||
const isInitialized = useConfigStore((s) => s.isInitialized);
|
||||
const isConnected = useConfigStore((s) => s.isConnected);
|
||||
@@ -470,8 +478,8 @@ function App({ apis }: AppProps) {
|
||||
const state = useConfigStore.getState();
|
||||
if (state.providers.length > 0 && state.agents.length > 0) return;
|
||||
try {
|
||||
if (state.providers.length === 0) await loadProviders();
|
||||
if (useConfigStore.getState().agents.length === 0) await loadAgents();
|
||||
if (state.providers.length === 0) await loadProviders({ source: 'startupRecovery' });
|
||||
if (useConfigStore.getState().agents.length === 0) await loadAgents({ source: 'startupRecovery' });
|
||||
} catch { /* retry next interval */ }
|
||||
};
|
||||
|
||||
|
||||
@@ -114,8 +114,8 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isConnected) return;
|
||||
if (providersCount === 0) void loadProviders();
|
||||
if (agentsCount === 0) void loadAgents();
|
||||
if (providersCount === 0) void loadProviders({ source: 'electronMiniChat:recovery' });
|
||||
if (agentsCount === 0) void loadAgents({ source: 'electronMiniChat:recovery' });
|
||||
}, [agentsCount, isConnected, loadAgents, loadProviders, providersCount]);
|
||||
|
||||
const sessionBootstrappedRef = React.useRef(false);
|
||||
|
||||
@@ -373,8 +373,8 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isConnected) return;
|
||||
if (providersCount === 0) void loadProviders();
|
||||
if (agentsCount === 0) void loadAgents();
|
||||
if (providersCount === 0) void loadProviders({ source: 'mobileApp:recovery' });
|
||||
if (agentsCount === 0) void loadAgents({ source: 'mobileApp:recovery' });
|
||||
}, [agentsCount, isConnected, loadAgents, loadProviders, providersCount]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -49,14 +49,15 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
|
||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||
const ignoreTabClickRef = React.useRef(false);
|
||||
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
||||
const configAgentsCount = useConfigStore((state) => state.agents.length);
|
||||
const agentsWithMetadata = useAgentsStore((state) => state.agents);
|
||||
const loadAgents = useAgentsStore((state) => state.loadAgents);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (agentsWithMetadata.length === 0) {
|
||||
if (agentsWithMetadata.length === 0 && configAgentsCount === 0) {
|
||||
void loadAgents();
|
||||
}
|
||||
}, [loadAgents, agentsWithMetadata.length]);
|
||||
}, [loadAgents, agentsWithMetadata.length, configAgentsCount]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const visibleAgents = getVisibleAgents();
|
||||
|
||||
@@ -38,6 +38,7 @@ import { formatEffortLabel, getCycledPrimaryAgentName, type MobileControlsPanel
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useOpenCodeReadiness } from '@/hooks/useOpenCodeReadiness';
|
||||
import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts';
|
||||
import { markStartupTrace } from '@/lib/startupTrace';
|
||||
|
||||
type IconComponent = IconName;
|
||||
|
||||
@@ -312,6 +313,19 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
// Use visible agents (excludes hidden internal agents)
|
||||
const agents = getVisibleAgents();
|
||||
const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]);
|
||||
const tracedReadyRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (tracedReadyRef.current || !isReady) return;
|
||||
tracedReadyRef.current = true;
|
||||
markStartupTrace('ModelControls:ready', {
|
||||
providers: providers.length,
|
||||
agents: agents.length,
|
||||
currentProviderId,
|
||||
currentModelId,
|
||||
currentAgentName,
|
||||
});
|
||||
}, [agents.length, currentAgentName, currentModelId, currentProviderId, isReady, providers.length]);
|
||||
|
||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||
const getDirectoryForSession = useSessionUIStore((s) => s.getDirectoryForSession);
|
||||
|
||||
@@ -320,10 +320,10 @@ export const VSCodeLayout: React.FC = () => {
|
||||
// Keep trying to fetch core datasets on cold starts.
|
||||
if (configStore.isConnected) {
|
||||
if (configStore.providers.length === 0) {
|
||||
await configStore.loadProviders();
|
||||
await configStore.loadProviders({ source: 'vscodeLayout:bootstrap' });
|
||||
}
|
||||
if (configStore.agents.length === 0) {
|
||||
await configStore.loadAgents();
|
||||
await configStore.loadAgents({ source: 'vscodeLayout:bootstrap' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,8 +50,8 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
|
||||
// Load agents on mount
|
||||
React.useEffect(() => {
|
||||
loadAgents();
|
||||
}, [loadAgents]);
|
||||
if (agents.length === 0) void loadAgents();
|
||||
}, [agents.length, loadAgents]);
|
||||
|
||||
// Ensure we always have a valid selection (defaults to current default agent, then first selectable agent).
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
const GITHUB_URL = 'https://github.com/btriapitsyn/openchamber';
|
||||
|
||||
@@ -17,6 +18,7 @@ export const AboutSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
||||
const [showChecking, setShowChecking] = React.useState(false);
|
||||
const [openCodeVersion, setOpenCodeVersion] = React.useState<string | null>(null);
|
||||
const updateStore = useUpdateStore(useShallow((s) => ({
|
||||
info: s.info,
|
||||
checking: s.checking,
|
||||
@@ -34,6 +36,33 @@ export const AboutSettings: React.FC = () => {
|
||||
|
||||
const currentVersion = updateStore.info?.currentVersion || 'unknown';
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadOpenCodeVersion = async () => {
|
||||
try {
|
||||
const response = await runtimeFetch('/api/opencode/version', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const data = await response.json().catch(() => null) as { version?: unknown } | null;
|
||||
const version = typeof data?.version === 'string' && data.version.trim().length > 0
|
||||
? data.version.trim()
|
||||
: null;
|
||||
if (!cancelled) setOpenCodeVersion(version);
|
||||
} catch {
|
||||
if (!cancelled) setOpenCodeVersion(null);
|
||||
}
|
||||
};
|
||||
|
||||
void loadOpenCodeVersion();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Track if we initiated a check to show toast on completion
|
||||
const didInitiateCheck = React.useRef(false);
|
||||
|
||||
@@ -91,6 +120,11 @@ export const AboutSettings: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.openchamber.about.field.openCodeVersion')}</span>
|
||||
<span className="typography-meta text-muted-foreground font-mono">{openCodeVersion || t('settings.openchamber.about.state.unknown')}</span>
|
||||
</div>
|
||||
|
||||
{updateStore.error && (
|
||||
<p className="typography-micro text-[var(--status-error)] truncate">{updateStore.error}</p>
|
||||
)}
|
||||
@@ -159,6 +193,10 @@ export const AboutSettings: React.FC = () => {
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.about.field.version')}</span>
|
||||
<span className="typography-meta text-muted-foreground font-mono">{currentVersion}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.about.field.openCodeVersion')}</span>
|
||||
<span className="typography-meta text-muted-foreground font-mono">{openCodeVersion || t('settings.openchamber.about.state.unknown')}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{updateStore.checking && (
|
||||
|
||||
@@ -53,7 +53,7 @@ export function ForkSessionDialog(props: ForkSessionDialogProps) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
void loadProviders({ directory: projectDirectory });
|
||||
void loadProviders({ directory: projectDirectory, source: 'forkSessionDialog' });
|
||||
void loadConfigAgents({ directory: projectDirectory });
|
||||
void loadAgentsStoreAgents();
|
||||
}, [open, loadProviders, loadConfigAgents, loadAgentsStoreAgents, projectDirectory]);
|
||||
|
||||
@@ -662,8 +662,8 @@ export function ScheduledTaskEditorDialog(props: {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
void loadProviders();
|
||||
void loadAgents();
|
||||
void loadProviders({ source: 'scheduledTaskEditor' });
|
||||
void loadAgents({ source: 'scheduledTaskEditor' });
|
||||
}, [open, loadProviders, loadAgents]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -66,7 +66,7 @@ export function TodoSendDialog(props: TodoSendDialogProps) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
void loadProviders({ directory: projectDirectory });
|
||||
void loadProviders({ directory: projectDirectory, source: 'todoSendDialog' });
|
||||
void loadConfigAgents({ directory: projectDirectory });
|
||||
void loadAgentsStoreAgents();
|
||||
}, [open, loadProviders, loadConfigAgents, loadAgentsStoreAgents, projectDirectory]);
|
||||
|
||||
@@ -360,8 +360,10 @@ export const settingsDict = {
|
||||
'settings.projects.actions.toast.saved': 'Project actions saved',
|
||||
'settings.openchamber.about.title': 'About OpenChamber',
|
||||
'settings.openchamber.about.field.version': 'Version',
|
||||
'settings.openchamber.about.field.openCodeVersion': 'OpenCode version',
|
||||
'settings.openchamber.about.state.checking': 'Checking...',
|
||||
'settings.openchamber.about.state.upToDate': 'Up to date',
|
||||
'settings.openchamber.about.state.unknown': 'unknown',
|
||||
'settings.openchamber.about.actions.checkUpdates': 'Check updates',
|
||||
'settings.openchamber.about.actions.update': 'Update',
|
||||
'settings.openchamber.about.actions.updateToVersion': 'Update to {version}',
|
||||
|
||||
@@ -327,8 +327,10 @@ export const settingsDict = {
|
||||
"settings.projects.actions.toast.saved": "Acciones del proyecto guardadas",
|
||||
"settings.openchamber.about.title": "Acerca de OpenChamber",
|
||||
"settings.openchamber.about.field.version": "Versión",
|
||||
"settings.openchamber.about.field.openCodeVersion": "Versión de OpenCode",
|
||||
"settings.openchamber.about.state.checking": "Comprobando...",
|
||||
"settings.openchamber.about.state.upToDate": "Actualizado",
|
||||
"settings.openchamber.about.state.unknown": "desconocido",
|
||||
"settings.openchamber.about.actions.checkUpdates": "Comprobar actualizaciones",
|
||||
"settings.openchamber.about.actions.update": "Actualizar",
|
||||
"settings.openchamber.about.actions.updateToVersion": "Actualizar a {version}",
|
||||
|
||||
@@ -327,8 +327,10 @@ export const settingsDict = {
|
||||
'settings.projects.actions.toast.saved': '프로젝트 작업이 저장되었습니다',
|
||||
'settings.openchamber.about.title': 'OpenChamber 정보',
|
||||
'settings.openchamber.about.field.version': '버전',
|
||||
'settings.openchamber.about.field.openCodeVersion': 'OpenCode 버전',
|
||||
'settings.openchamber.about.state.checking': '확인 중...',
|
||||
'settings.openchamber.about.state.upToDate': '최신 상태',
|
||||
'settings.openchamber.about.state.unknown': '알 수 없음',
|
||||
'settings.openchamber.about.actions.checkUpdates': '업데이트 확인',
|
||||
'settings.openchamber.about.actions.update': '업데이트',
|
||||
'settings.openchamber.about.actions.updateToVersion': '{version}으로 업데이트',
|
||||
|
||||
@@ -640,8 +640,10 @@ export const settingsDict = {
|
||||
'settings.openchamber.about.actions.update': 'Aktualizuj',
|
||||
'settings.openchamber.about.actions.updateToVersion': 'Aktualizuj do wersji {version}',
|
||||
'settings.openchamber.about.field.version': 'Wersja',
|
||||
'settings.openchamber.about.field.openCodeVersion': 'Wersja OpenCode',
|
||||
'settings.openchamber.about.state.checking': 'Sprawdzanie...',
|
||||
'settings.openchamber.about.state.upToDate': 'Aktualna wersja',
|
||||
'settings.openchamber.about.state.unknown': 'nieznane',
|
||||
'settings.openchamber.about.title': 'O OpenChamber',
|
||||
'settings.openchamber.about.toast.latestVersion': 'Używasz najnowszej wersji',
|
||||
'settings.openchamber.defaults.field.defaultAgent': 'Domyślny Agent',
|
||||
|
||||
@@ -327,8 +327,10 @@ export const settingsDict = {
|
||||
"settings.projects.actions.toast.saved": "Ações do projeto salvas",
|
||||
"settings.openchamber.about.title": "Sobre o OpenChamber",
|
||||
"settings.openchamber.about.field.version": "Versão",
|
||||
"settings.openchamber.about.field.openCodeVersion": "Versão do OpenCode",
|
||||
"settings.openchamber.about.state.checking": "Verificando...",
|
||||
"settings.openchamber.about.state.upToDate": "Atualizado",
|
||||
"settings.openchamber.about.state.unknown": "desconhecido",
|
||||
"settings.openchamber.about.actions.checkUpdates": "Verificar atualizações",
|
||||
"settings.openchamber.about.actions.update": "Atualizar",
|
||||
"settings.openchamber.about.actions.updateToVersion": "Atualizar a {version}",
|
||||
|
||||
@@ -327,8 +327,10 @@ export const settingsDict = {
|
||||
"settings.projects.actions.toast.saved": "Дії проєкту збережено",
|
||||
"settings.openchamber.about.title": "Про OpenChamber",
|
||||
"settings.openchamber.about.field.version": "Версія",
|
||||
"settings.openchamber.about.field.openCodeVersion": "Версія OpenCode",
|
||||
"settings.openchamber.about.state.checking": "Перевірка...",
|
||||
"settings.openchamber.about.state.upToDate": "В актуальному стані",
|
||||
"settings.openchamber.about.state.unknown": "невідомо",
|
||||
"settings.openchamber.about.actions.checkUpdates": "Перевірити оновлення",
|
||||
"settings.openchamber.about.actions.update": "Оновити",
|
||||
"settings.openchamber.about.actions.updateToVersion": "Оновити до {version}",
|
||||
|
||||
@@ -327,8 +327,10 @@ export const settingsDict = {
|
||||
'settings.projects.actions.toast.saved': '项目操作已保存',
|
||||
'settings.openchamber.about.title': '关于 OpenChamber',
|
||||
'settings.openchamber.about.field.version': '版本',
|
||||
'settings.openchamber.about.field.openCodeVersion': 'OpenCode 版本',
|
||||
'settings.openchamber.about.state.checking': '检查中...',
|
||||
'settings.openchamber.about.state.upToDate': '已是最新',
|
||||
'settings.openchamber.about.state.unknown': '未知',
|
||||
'settings.openchamber.about.actions.checkUpdates': '检查更新',
|
||||
'settings.openchamber.about.actions.update': '更新',
|
||||
'settings.openchamber.about.actions.updateToVersion': '更新到 {version}',
|
||||
|
||||
@@ -324,8 +324,10 @@
|
||||
'settings.projects.actions.toast.saved': '專案操作已儲存',
|
||||
'settings.openchamber.about.title': '關於 OpenChamber',
|
||||
'settings.openchamber.about.field.version': '版本',
|
||||
'settings.openchamber.about.field.openCodeVersion': 'OpenCode 版本',
|
||||
'settings.openchamber.about.state.checking': '檢查中...',
|
||||
'settings.openchamber.about.state.upToDate': '已是最新',
|
||||
'settings.openchamber.about.state.unknown': '未知',
|
||||
'settings.openchamber.about.actions.checkUpdates': '檢查更新',
|
||||
'settings.openchamber.about.actions.update': '更新',
|
||||
'settings.openchamber.about.actions.updateToVersion': '更新到 {version}',
|
||||
|
||||
@@ -235,7 +235,7 @@ export const buildOpenCodeStatusReport = async (): Promise<string> => {
|
||||
};
|
||||
|
||||
const probeTargets: Array<{ label: string; path: string; includeDirectory?: boolean; timeoutMs?: number }> = [
|
||||
{ label: 'health', path: '/global/health', includeDirectory: false },
|
||||
{ label: 'health', path: '/api/health', includeDirectory: false },
|
||||
{ label: 'config', path: '/config', includeDirectory: true },
|
||||
{ label: 'providers', path: '/config/providers', includeDirectory: true },
|
||||
{ label: 'agents', path: '/agent', includeDirectory: true, timeoutMs: 12000 },
|
||||
|
||||
@@ -18,6 +18,7 @@ import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap";
|
||||
import { getRuntimeUrlResolver } from "@/lib/runtime-url";
|
||||
import { runtimeFetch } from "@/lib/runtime-fetch";
|
||||
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry";
|
||||
import { markStartupTrace } from "@/lib/startupTrace";
|
||||
import {
|
||||
assertProviderCircuitClosed,
|
||||
recordProviderSuccess,
|
||||
@@ -346,7 +347,14 @@ class OpencodeService {
|
||||
|
||||
// Set the current working directory for all API calls
|
||||
setDirectory(directory: string | undefined) {
|
||||
this.currentDirectory = this.normalizeCandidatePath(directory) ?? directory;
|
||||
const normalized = this.normalizeCandidatePath(directory) ?? directory;
|
||||
if (this.currentDirectory !== normalized) {
|
||||
markStartupTrace('opencodeClient:setDirectory', {
|
||||
previous: this.currentDirectory ?? null,
|
||||
next: normalized ?? null,
|
||||
});
|
||||
}
|
||||
this.currentDirectory = normalized;
|
||||
}
|
||||
|
||||
getDirectory(): string | undefined {
|
||||
@@ -1414,33 +1422,24 @@ class OpencodeService {
|
||||
}
|
||||
}
|
||||
|
||||
// Health Check - using /health endpoint for detailed status
|
||||
// Lightweight readiness check. Full diagnostics still live at /health.
|
||||
async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
// Health endpoint is at root, not under /api
|
||||
let healthUrl: string;
|
||||
const normalizedBase = this.baseUrl.endsWith('/') ? this.baseUrl.replace(/\/+$/, '') : this.baseUrl;
|
||||
if (normalizedBase === '/api') {
|
||||
healthUrl = '/health';
|
||||
} else if (normalizedBase.endsWith('/api')) {
|
||||
// Desktop: http://127.0.0.1:PORT/api -> http://127.0.0.1:PORT/health
|
||||
healthUrl = `${normalizedBase.slice(0, -4)}/health`;
|
||||
} else {
|
||||
healthUrl = `${normalizedBase}/health`;
|
||||
}
|
||||
const healthUrl = normalizedBase === '/api' || normalizedBase.endsWith('/api')
|
||||
? '/api/opencode/health'
|
||||
: `${normalizedBase}/opencode/health`;
|
||||
markStartupTrace('opencodeClient.checkHealth:url', { baseUrl: this.baseUrl, healthUrl });
|
||||
const response = await runtimeFetch(healthUrl);
|
||||
markStartupTrace('opencodeClient.checkHealth:response', { status: response.status });
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const healthData = await response.json();
|
||||
markStartupTrace('opencodeClient.checkHealth:result', { healthy: healthData?.healthy });
|
||||
|
||||
// Check if the upstream API is ready (not just OpenChamber server)
|
||||
if (healthData.isOpenCodeReady === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return healthData?.healthy === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -126,14 +126,12 @@ export const runtimeFetch = async (input: string | URL | Request, init: RuntimeF
|
||||
const inputHeaders = resolvedInput instanceof Request ? resolvedInput.headers : undefined;
|
||||
const headers = await mergeHeaders(inputHeaders, requestInit.headers, shouldAttachRuntimeAuth(resolvedInput));
|
||||
|
||||
if (resolvedInput instanceof Request) {
|
||||
return fetch(new Request(resolvedInput, { ...requestInit, headers }));
|
||||
}
|
||||
|
||||
return fetch(resolvedInput, {
|
||||
...requestInit,
|
||||
headers,
|
||||
});
|
||||
return resolvedInput instanceof Request
|
||||
? fetch(new Request(resolvedInput, { ...requestInit, headers }))
|
||||
: fetch(resolvedInput, {
|
||||
...requestInit,
|
||||
headers,
|
||||
});
|
||||
};
|
||||
|
||||
let runtimeFetchBridgeInstalled = false;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
export type StartupTraceEvent = {
|
||||
t: number;
|
||||
name: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCHAMBER_STARTUP_TRACE__?: StartupTraceEvent[];
|
||||
__OPENCHAMBER_STARTUP_TRACE_START__?: number;
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_STARTUP_TRACE_EVENTS = 500;
|
||||
|
||||
const enabled = () => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('startupTrace') === '1' || window.localStorage?.getItem('OPENCHAMBER_STARTUP_TRACE') === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const startupTraceEnabled = () => enabled();
|
||||
|
||||
export const markStartupTrace = (name: string, data?: Record<string, unknown>) => {
|
||||
if (!enabled()) return;
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
window.__OPENCHAMBER_STARTUP_TRACE_START__ ??= now;
|
||||
window.__OPENCHAMBER_STARTUP_TRACE__ ??= [];
|
||||
window.__OPENCHAMBER_STARTUP_TRACE__.push({
|
||||
t: Math.round(now - window.__OPENCHAMBER_STARTUP_TRACE_START__),
|
||||
name,
|
||||
...(data ? { data } : {}),
|
||||
});
|
||||
if (window.__OPENCHAMBER_STARTUP_TRACE__.length > MAX_STARTUP_TRACE_EVENTS) {
|
||||
window.__OPENCHAMBER_STARTUP_TRACE__.splice(
|
||||
0,
|
||||
window.__OPENCHAMBER_STARTUP_TRACE__.length - MAX_STARTUP_TRACE_EVENTS,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const getStartupTraceSummary = () => {
|
||||
const trace = typeof window !== 'undefined' ? window.__OPENCHAMBER_STARTUP_TRACE__ ?? [] : [];
|
||||
const readyIndex = trace.findIndex((event) => event.name === 'ModelControls:ready');
|
||||
const endIndex = readyIndex >= 0 ? Math.min(trace.length, readyIndex + 8) : trace.length;
|
||||
return trace.slice(0, endIndex).filter((event) => (
|
||||
event.name.includes('checkConnection')
|
||||
|| event.name.includes('checkHealth')
|
||||
|| event.name.includes('initializeApp')
|
||||
|| event.name.includes('initApp')
|
||||
|| event.name.includes('loadProviders')
|
||||
|| event.name.includes('loadAgents')
|
||||
|| event.name.includes('config.defaults')
|
||||
|| event.name.includes('modelsMetadata')
|
||||
|| event.name.includes('ModelControls')
|
||||
|| event.name.includes('activateDirectory')
|
||||
|| event.name.includes('opencodeClient:setDirectory')
|
||||
));
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as typeof window & { __OPENCHAMBER_STARTUP_TRACE_SUMMARY__?: typeof getStartupTraceSummary })
|
||||
.__OPENCHAMBER_STARTUP_TRACE_SUMMARY__ = getStartupTraceSummary;
|
||||
}
|
||||
|
||||
export const measureStartupTrace = async <T>(
|
||||
name: string,
|
||||
fn: () => Promise<T>,
|
||||
data?: Record<string, unknown>,
|
||||
): Promise<T> => {
|
||||
const started = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
markStartupTrace(`${name}:start`, data);
|
||||
try {
|
||||
const result = await fn();
|
||||
const ended = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
markStartupTrace(`${name}:end`, { durationMs: Math.round(ended - started) });
|
||||
return result;
|
||||
} catch (error) {
|
||||
const ended = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
markStartupTrace(`${name}:error`, {
|
||||
durationMs: Math.round(ended - started),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -620,10 +620,10 @@ async function performConfigRefresh(options: {
|
||||
const sdkRefreshTasks: Promise<void>[] = [];
|
||||
for (const directory of directoriesToRefresh) {
|
||||
if (refreshProviders) {
|
||||
sdkRefreshTasks.push(configStore.loadProviders({ directory }).then(() => undefined));
|
||||
sdkRefreshTasks.push(configStore.loadProviders({ directory, source: 'agentsStore:refreshConfig' }).then(() => undefined));
|
||||
}
|
||||
if (refreshSdkAgents) {
|
||||
sdkRefreshTasks.push(configStore.loadAgents({ directory }).then(() => undefined));
|
||||
sdkRefreshTasks.push(configStore.loadAgents({ directory, source: 'agentsStore:refreshConfig' }).then(() => undefined));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useDirectoryStore } from "@/stores/useDirectoryStore";
|
||||
import { streamDebugEnabled } from "@/stores/utils/streamDebug";
|
||||
import { parseModelIdentifier } from "@/lib/modelIdentifier";
|
||||
import { runtimeFetch } from "@/lib/runtime-fetch";
|
||||
import { markStartupTrace, measureStartupTrace } from "@/lib/startupTrace";
|
||||
|
||||
const MODELS_DEV_API_URL = "https://models.dev/api.json";
|
||||
const MODELS_DEV_PROXY_URL = "/api/openchamber/models-metadata";
|
||||
@@ -61,6 +62,18 @@ interface OpenChamberDefaults {
|
||||
}
|
||||
|
||||
const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
||||
markStartupTrace('config.defaults:start');
|
||||
const started = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
const finish = (source: string, result: OpenChamberDefaults) => {
|
||||
const ended = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
markStartupTrace('config.defaults:end', {
|
||||
source,
|
||||
durationMs: Math.round(ended - started),
|
||||
hasDefaultModel: Boolean(result.defaultModel),
|
||||
hasDefaultAgent: Boolean(result.defaultAgent),
|
||||
});
|
||||
return result;
|
||||
};
|
||||
try {
|
||||
// 1. Runtime settings API (VSCode)
|
||||
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
||||
@@ -86,7 +99,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
||||
const sttSilenceThresholdDb = normalizeSttSilenceThresholdDb(data?.sttSilenceThresholdDb);
|
||||
const sttSilenceHoldMs = normalizeSttSilenceHoldMs(data?.sttSilenceHoldMs);
|
||||
|
||||
return {
|
||||
return finish('runtime-settings', {
|
||||
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
|
||||
defaultVariant: defaultVariant.length > 0 ? defaultVariant : undefined,
|
||||
defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined,
|
||||
@@ -101,7 +114,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
||||
sttLanguage,
|
||||
sttSilenceThresholdDb,
|
||||
sttSilenceHoldMs,
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Fall through to fetch
|
||||
@@ -114,7 +127,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
return {};
|
||||
return finish('settings-route-not-ok', {});
|
||||
}
|
||||
const data = await response.json();
|
||||
const defaultModel = typeof data?.defaultModel === 'string' ? data.defaultModel.trim() : '';
|
||||
@@ -134,7 +147,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
||||
const sttSilenceThresholdDb = normalizeSttSilenceThresholdDb(data?.sttSilenceThresholdDb);
|
||||
const sttSilenceHoldMs = normalizeSttSilenceHoldMs(data?.sttSilenceHoldMs);
|
||||
|
||||
return {
|
||||
return finish('settings-route', {
|
||||
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
|
||||
defaultVariant: defaultVariant.length > 0 ? defaultVariant : undefined,
|
||||
defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined,
|
||||
@@ -149,9 +162,10 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
||||
sttLanguage,
|
||||
sttSilenceThresholdDb,
|
||||
sttSilenceHoldMs,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
});
|
||||
} catch (error) {
|
||||
markStartupTrace('config.defaults:error', { error: error instanceof Error ? error.message : String(error) });
|
||||
return finish('error', {});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -458,9 +472,11 @@ const ensureModelsMetadataFetch = (
|
||||
return;
|
||||
}
|
||||
|
||||
modelsMetadataInFlight = fetchModelsDevMetadata()
|
||||
markStartupTrace('modelsMetadata:queued');
|
||||
modelsMetadataInFlight = measureStartupTrace('modelsMetadata', fetchModelsDevMetadata)
|
||||
.then((metadata) => {
|
||||
if (metadata.size > 0) {
|
||||
markStartupTrace('modelsMetadata:set', { entries: metadata.size });
|
||||
setModelsMetadata(metadata);
|
||||
}
|
||||
return metadata;
|
||||
@@ -602,8 +618,8 @@ interface ConfigStore {
|
||||
|
||||
activateDirectory: (directory: string | null | undefined) => Promise<void>;
|
||||
|
||||
loadProviders: (options?: { directory?: string | null }) => Promise<void>;
|
||||
loadAgents: (options?: { directory?: string | null }) => Promise<boolean>;
|
||||
loadProviders: (options?: { directory?: string | null; source?: string }) => Promise<void>;
|
||||
loadAgents: (options?: { directory?: string | null; source?: string }) => Promise<boolean>;
|
||||
invalidateModelMetadataCache: () => void;
|
||||
setProvider: (providerId: string) => void;
|
||||
setModel: (modelId: string) => void;
|
||||
@@ -906,10 +922,14 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
})(),
|
||||
activateDirectory: async (directory) => {
|
||||
const directoryKey = toDirectoryKey(directory);
|
||||
let snapshotHadProviders = false;
|
||||
let snapshotHadAgents = false;
|
||||
|
||||
set((state) => {
|
||||
const snapshot = state.directoryScoped[directoryKey];
|
||||
if (snapshot) {
|
||||
snapshotHadProviders = snapshot.providers.length > 0;
|
||||
snapshotHadAgents = snapshot.agents.length > 0;
|
||||
return {
|
||||
activeDirectoryKey: directoryKey,
|
||||
providers: snapshot.providers,
|
||||
@@ -941,18 +961,36 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
return;
|
||||
}
|
||||
|
||||
await get().loadProviders({ directory: fromDirectoryKey(directoryKey) });
|
||||
await get().loadAgents({ directory: fromDirectoryKey(directoryKey) });
|
||||
if (snapshotHadProviders) {
|
||||
markStartupTrace('activateDirectory:skipProviders', { directoryKey });
|
||||
} else {
|
||||
await get().loadProviders({ directory: fromDirectoryKey(directoryKey), source: 'activateDirectory' });
|
||||
}
|
||||
|
||||
if (snapshotHadAgents) {
|
||||
markStartupTrace('activateDirectory:skipAgents', { directoryKey });
|
||||
} else {
|
||||
await get().loadAgents({ directory: fromDirectoryKey(directoryKey), source: 'activateDirectory' });
|
||||
}
|
||||
},
|
||||
|
||||
loadProviders: async (options) => {
|
||||
const directoryKey = toDirectoryKey(options?.directory ?? fromDirectoryKey(get().activeDirectoryKey));
|
||||
const requestedDirectory = options?.directory ?? fromDirectoryKey(get().activeDirectoryKey);
|
||||
const effectiveDirectory = requestedDirectory ?? opencodeClient.getDirectory() ?? null;
|
||||
const directoryKey = toDirectoryKey(requestedDirectory);
|
||||
const source = options?.source ?? 'unknown';
|
||||
markStartupTrace('loadProviders:called', { directoryKey, source, requestedDirectory, effectiveDirectory });
|
||||
|
||||
// Dedup: if a load is already in-flight for this directory, reuse it
|
||||
const existing = _inFlightProviders.get(directoryKey);
|
||||
if (existing) return existing;
|
||||
if (existing) {
|
||||
markStartupTrace('loadProviders:deduped', { directoryKey, source, requestedDirectory, effectiveDirectory });
|
||||
return existing;
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
const loaderStarted = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
markStartupTrace('loadProviders:start', { directoryKey, source, requestedDirectory, effectiveDirectory });
|
||||
const existingSnapshot = get().directoryScoped[directoryKey];
|
||||
const previousProviders = existingSnapshot?.providers ?? (get().activeDirectoryKey === directoryKey ? get().providers : []);
|
||||
const previousDefaults = existingSnapshot?.defaultProviders ?? (get().activeDirectoryKey === directoryKey ? get().defaultProviders : {});
|
||||
@@ -964,9 +1002,13 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
() => get().modelsMetadata,
|
||||
(metadata) => set({ modelsMetadata: metadata }),
|
||||
);
|
||||
const apiResult = await opencodeClient.withDirectory(
|
||||
fromDirectoryKey(directoryKey),
|
||||
() => opencodeClient.getProviders()
|
||||
const apiResult = await measureStartupTrace(
|
||||
'loadProviders:api',
|
||||
() => opencodeClient.withDirectory(
|
||||
fromDirectoryKey(directoryKey),
|
||||
() => opencodeClient.getProviders()
|
||||
),
|
||||
{ directoryKey, source, requestedDirectory, effectiveDirectory, attempt: attempt + 1 },
|
||||
);
|
||||
const providers = Array.isArray(apiResult?.providers) ? apiResult.providers : [];
|
||||
const defaults = apiResult?.default || {};
|
||||
@@ -1036,15 +1078,40 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
return nextState;
|
||||
});
|
||||
|
||||
const loaderEnded = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
markStartupTrace('loadProviders:end', {
|
||||
directoryKey,
|
||||
source,
|
||||
requestedDirectory,
|
||||
effectiveDirectory,
|
||||
durationMs: Math.round(loaderEnded - loaderStarted),
|
||||
providers: processedProviders.length,
|
||||
models: processedProviders.reduce((count, provider) => count + provider.models.length, 0),
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
markStartupTrace('loadProviders:attemptError', {
|
||||
directoryKey,
|
||||
source,
|
||||
requestedDirectory,
|
||||
effectiveDirectory,
|
||||
attempt: attempt + 1,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
const waitMs = 200 * (attempt + 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
}
|
||||
}
|
||||
|
||||
console.error("Failed to load providers:", lastError);
|
||||
markStartupTrace('loadProviders:error', {
|
||||
directoryKey,
|
||||
source,
|
||||
requestedDirectory,
|
||||
effectiveDirectory,
|
||||
error: lastError instanceof Error ? lastError.message : String(lastError),
|
||||
});
|
||||
|
||||
set((state) => {
|
||||
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
||||
@@ -1314,13 +1381,22 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
},
|
||||
|
||||
loadAgents: async (options) => {
|
||||
const directoryKey = toDirectoryKey(options?.directory ?? fromDirectoryKey(get().activeDirectoryKey));
|
||||
const requestedDirectory = options?.directory ?? fromDirectoryKey(get().activeDirectoryKey);
|
||||
const effectiveDirectory = requestedDirectory ?? opencodeClient.getDirectory() ?? null;
|
||||
const directoryKey = toDirectoryKey(requestedDirectory);
|
||||
const source = options?.source ?? 'unknown';
|
||||
markStartupTrace('loadAgents:called', { directoryKey, source, requestedDirectory, effectiveDirectory });
|
||||
|
||||
// Dedup: if a load is already in-flight for this directory, reuse it
|
||||
const existing = _inFlightAgents.get(directoryKey);
|
||||
if (existing) return existing;
|
||||
if (existing) {
|
||||
markStartupTrace('loadAgents:deduped', { directoryKey, source, requestedDirectory, effectiveDirectory });
|
||||
return existing;
|
||||
}
|
||||
|
||||
const promise = (async (): Promise<boolean> => {
|
||||
const loaderStarted = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
markStartupTrace('loadAgents:start', { directoryKey, source, requestedDirectory, effectiveDirectory });
|
||||
const existingSnapshot = get().directoryScoped[directoryKey];
|
||||
const previousAgents = existingSnapshot?.agents ?? (get().activeDirectoryKey === directoryKey ? get().agents : []);
|
||||
let lastError: unknown = null;
|
||||
@@ -1329,12 +1405,22 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
try {
|
||||
// Fetch agents and OpenChamber settings in parallel
|
||||
const [agents, openChamberDefaults] = await Promise.all([
|
||||
opencodeClient.withDirectory(fromDirectoryKey(directoryKey), () => opencodeClient.listAgents()),
|
||||
measureStartupTrace(
|
||||
'loadAgents:api',
|
||||
() => opencodeClient.withDirectory(fromDirectoryKey(directoryKey), () => opencodeClient.listAgents()),
|
||||
{ directoryKey, source, requestedDirectory, effectiveDirectory, attempt: attempt + 1 },
|
||||
),
|
||||
fetchOpenChamberDefaults(),
|
||||
]);
|
||||
|
||||
const safeAgents = Array.isArray(agents) ? agents : [];
|
||||
|
||||
const providerLoad = _inFlightProviders.get(directoryKey);
|
||||
if (providerLoad) {
|
||||
markStartupTrace('loadAgents:awaitProviders', { directoryKey, source });
|
||||
await providerLoad;
|
||||
}
|
||||
|
||||
const providers = get().activeDirectoryKey === directoryKey
|
||||
? get().providers
|
||||
: (get().directoryScoped[directoryKey]?.providers ?? []);
|
||||
@@ -1452,6 +1538,15 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
return nextState;
|
||||
});
|
||||
|
||||
const loaderEnded = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
markStartupTrace('loadAgents:end', {
|
||||
directoryKey,
|
||||
source,
|
||||
requestedDirectory,
|
||||
effectiveDirectory,
|
||||
durationMs: Math.round(loaderEnded - loaderStarted),
|
||||
agents: safeAgents.length,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1592,15 +1687,39 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
});
|
||||
}
|
||||
|
||||
const loaderEnded = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
markStartupTrace('loadAgents:end', {
|
||||
directoryKey,
|
||||
source,
|
||||
requestedDirectory,
|
||||
effectiveDirectory,
|
||||
durationMs: Math.round(loaderEnded - loaderStarted),
|
||||
agents: safeAgents.length,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
markStartupTrace('loadAgents:attemptError', {
|
||||
directoryKey,
|
||||
source,
|
||||
requestedDirectory,
|
||||
effectiveDirectory,
|
||||
attempt: attempt + 1,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
const waitMs = 200 * (attempt + 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
}
|
||||
}
|
||||
|
||||
console.error("Failed to load agents:", lastError);
|
||||
markStartupTrace('loadAgents:error', {
|
||||
directoryKey,
|
||||
source,
|
||||
requestedDirectory,
|
||||
effectiveDirectory,
|
||||
error: lastError instanceof Error ? lastError.message : String(lastError),
|
||||
});
|
||||
|
||||
set((state) => {
|
||||
const providers = state.activeDirectoryKey === directoryKey
|
||||
@@ -2064,13 +2183,31 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
},
|
||||
|
||||
checkConnection: async () => {
|
||||
markStartupTrace('checkConnection:start');
|
||||
const maxAttempts = 5;
|
||||
let attempt = 0;
|
||||
let lastError: unknown = null;
|
||||
|
||||
while (attempt < maxAttempts) {
|
||||
try {
|
||||
const isHealthy = await opencodeClient.checkHealth();
|
||||
markStartupTrace('checkConnection:attempt', { attempt: attempt + 1 });
|
||||
const isHealthy = await measureStartupTrace(
|
||||
'checkConnection:health',
|
||||
() => opencodeClient.checkHealth(),
|
||||
{ attempt: attempt + 1 },
|
||||
);
|
||||
if (!isHealthy && attempt < maxAttempts - 1) {
|
||||
const hasEverConnected = get().hasEverConnected;
|
||||
set({
|
||||
isConnected: false,
|
||||
connectionPhase: hasEverConnected ? "reconnecting" : "connecting",
|
||||
lastDisconnectReason: 'health_check_unhealthy',
|
||||
});
|
||||
attempt += 1;
|
||||
await sleep(400 * attempt);
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasEverConnected = get().hasEverConnected;
|
||||
set(isHealthy
|
||||
? { isConnected: true, hasEverConnected: true, connectionPhase: "connected" }
|
||||
@@ -2079,6 +2216,7 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
connectionPhase: hasEverConnected ? "reconnecting" : "connecting",
|
||||
lastDisconnectReason: 'health_check_unhealthy',
|
||||
});
|
||||
markStartupTrace('checkConnection:end', { healthy: isHealthy, attempts: attempt + 1 });
|
||||
return isHealthy;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
@@ -2096,15 +2234,19 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
connectionPhase: get().hasEverConnected ? "reconnecting" : "connecting",
|
||||
lastDisconnectReason: 'health_check_failed',
|
||||
});
|
||||
markStartupTrace('checkConnection:end', { healthy: false, attempts: maxAttempts });
|
||||
return false;
|
||||
},
|
||||
|
||||
initializeApp: async () => {
|
||||
if (_initializeAppInFlight) {
|
||||
markStartupTrace('initializeApp:deduped');
|
||||
return _initializeAppInFlight;
|
||||
}
|
||||
|
||||
const run = (async () => {
|
||||
const initStarted = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
markStartupTrace('initializeApp:start');
|
||||
try {
|
||||
const debug = streamDebugEnabled();
|
||||
if (debug) console.log("Starting app initialization...");
|
||||
@@ -2123,15 +2265,21 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
}
|
||||
|
||||
if (debug) console.log("Initializing app...");
|
||||
await opencodeClient.initApp();
|
||||
markStartupTrace('initApp:skipped', { reason: 'checkConnection already verified health' });
|
||||
|
||||
if (debug) console.log("Loading providers...");
|
||||
await get().loadProviders();
|
||||
|
||||
if (debug) console.log("Loading agents...");
|
||||
await get().loadAgents();
|
||||
if (debug) console.log("Loading providers and agents...");
|
||||
await Promise.all([
|
||||
get().loadProviders({ source: 'initializeApp' }),
|
||||
get().loadAgents({ source: 'initializeApp' }),
|
||||
]);
|
||||
|
||||
set({ isInitialized: true, isConnected: true, hasEverConnected: true, connectionPhase: "connected" });
|
||||
const initEnded = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
markStartupTrace('initializeApp:end', {
|
||||
durationMs: Math.round(initEnded - initStarted),
|
||||
providers: get().providers.length,
|
||||
agents: get().agents.length,
|
||||
});
|
||||
if (debug) console.log("App initialized successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize app:", error);
|
||||
@@ -2141,6 +2289,7 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
connectionPhase: get().hasEverConnected ? "reconnecting" : "connecting",
|
||||
lastDisconnectReason: 'init_error',
|
||||
});
|
||||
markStartupTrace('initializeApp:error', { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
})().finally(() => {
|
||||
_initializeAppInFlight = null;
|
||||
@@ -2235,16 +2384,16 @@ let unsubscribeConfigStoreChanges: (() => void) | null = null;
|
||||
|
||||
if (!unsubscribeConfigStoreChanges) {
|
||||
unsubscribeConfigStoreChanges = subscribeToConfigChanges(async (event) => {
|
||||
const tasks: Promise<void>[] = [];
|
||||
const tasks: Promise<void>[] = [];
|
||||
|
||||
if (scopeMatches(event, "agents")) {
|
||||
const { loadAgents } = useConfigStore.getState();
|
||||
tasks.push(loadAgents().then(() => {}));
|
||||
tasks.push(loadAgents({ source: 'configChange:agents' }).then(() => {}));
|
||||
}
|
||||
|
||||
if (scopeMatches(event, "providers")) {
|
||||
const { loadProviders } = useConfigStore.getState();
|
||||
tasks.push(loadProviders());
|
||||
tasks.push(loadProviders({ source: 'configChange:providers' }));
|
||||
}
|
||||
|
||||
if (tasks.length > 0) {
|
||||
@@ -2263,6 +2412,7 @@ if (typeof window !== "undefined" && !unsubscribeConfigStoreDirectoryChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
markStartupTrace('directoryStore:changed', { previous: prevKey, next: nextKey });
|
||||
void useConfigStore.getState().activateDirectory(state.currentDirectory);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -241,6 +241,32 @@ export async function handleSystemBridgeMessage(
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:opencode/version': {
|
||||
try {
|
||||
const apiUrl = ctx?.manager?.getApiUrl();
|
||||
if (!apiUrl) {
|
||||
return { id, type, success: true, data: { version: null, error: 'OpenCode manager unavailable' } };
|
||||
}
|
||||
const base = `${apiUrl.replace(/\/+$/, '')}/`;
|
||||
const response = await fetch(new URL('global/health', base).toString(), {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json', ...ctx?.manager?.getOpenCodeAuthHeaders() },
|
||||
});
|
||||
const health = await response.json().catch(() => null) as { version?: unknown; error?: unknown } | null;
|
||||
if (!response.ok) {
|
||||
const message = typeof health?.error === 'string' ? health.error : response.statusText || 'Failed to read OpenCode version';
|
||||
return { id, type, success: true, data: { version: null, error: message } };
|
||||
}
|
||||
const version = typeof health?.version === 'string' && health.version.trim().length > 0
|
||||
? health.version.trim().replace(/^v/, '')
|
||||
: null;
|
||||
return { id, type, success: true, data: { version } };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: true, data: { version: null, error: errorMessage } };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:session-activity:get': {
|
||||
return { id, type, success: true, data: getSessionActivitySnapshot() };
|
||||
}
|
||||
|
||||
@@ -547,7 +547,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
};
|
||||
|
||||
const probeTargets: Array<{ label: string; path: string; includeDirectory?: boolean; timeoutMs?: number }> = [
|
||||
{ label: 'health', path: '/global/health', includeDirectory: false },
|
||||
{ label: 'health', path: '/api/health', includeDirectory: false },
|
||||
{ label: 'config', path: '/config', includeDirectory: true },
|
||||
{ label: 'providers', path: '/config/providers', includeDirectory: true },
|
||||
// Can be slower on large configs; keep the probe from producing false negatives.
|
||||
|
||||
@@ -568,7 +568,7 @@ async function waitForReady(
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
// OpenCode readiness check.
|
||||
const url = new URL(`${baseUrl}/global/health`);
|
||||
const url = new URL(`${baseUrl}/api/health`);
|
||||
const res = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json', ...authHeaders },
|
||||
|
||||
@@ -968,6 +968,24 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/api/opencode/version' && method === 'GET') {
|
||||
try {
|
||||
const data = await sendBridgeMessage('api:opencode/version');
|
||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return new Response(JSON.stringify({ version: null, error: message }), { status: 502, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/api/opencode/health' && method === 'GET') {
|
||||
const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status;
|
||||
return new Response(JSON.stringify({ healthy: connectionStatus === 'connected' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (pathname === '/api/zen/models' && method === 'GET') {
|
||||
try {
|
||||
const data = await sendBridgeMessage('api:zen:models');
|
||||
|
||||
@@ -392,7 +392,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(buildOpenCodeUrl('/global/health', ''), {
|
||||
const response = await fetch(buildOpenCodeUrl('/api/health', ''), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@@ -417,7 +417,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
const base = origin ?? `http://127.0.0.1:${port}`;
|
||||
const response = await fetch(`${base}/global/health`, {
|
||||
const response = await fetch(`${base}/api/health`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@@ -659,51 +659,40 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
let lastError = null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
let timeout = null;
|
||||
try {
|
||||
const [configResult, agentResult] = await Promise.all([
|
||||
fetch(buildOpenCodeUrl('/config', ''), {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
||||
}).catch((error) => error),
|
||||
fetch(buildOpenCodeUrl('/agent', ''), {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
||||
}).catch((error) => error),
|
||||
]);
|
||||
const controller = new AbortController();
|
||||
timeout = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS);
|
||||
const response = await fetch(buildOpenCodeUrl('/api/health', ''), {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
|
||||
if (configResult instanceof Error) {
|
||||
lastError = configResult;
|
||||
if (!response.ok) {
|
||||
lastError = new Error(`OpenCode health endpoint responded with status ${response.status}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!configResult.ok) {
|
||||
lastError = new Error(`OpenCode config endpoint responded with status ${configResult.status}`);
|
||||
const body = await response.json().catch(() => null);
|
||||
if (body?.healthy !== true) {
|
||||
lastError = new Error('OpenCode health endpoint returned unhealthy response');
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
await configResult.json().catch(() => null);
|
||||
|
||||
if (agentResult instanceof Error) {
|
||||
lastError = agentResult;
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!agentResult.ok) {
|
||||
lastError = new Error(`Agent endpoint responded with status ${agentResult.status}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
await agentResult.json().catch(() => []);
|
||||
|
||||
state.isOpenCodeReady = true;
|
||||
state.lastOpenCodeError = null;
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
|
||||
@@ -33,7 +33,7 @@ export const createOpenCodeNetworkRuntime = (deps) => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
timeout = setTimeout(() => controller.abort(), 3000);
|
||||
const response = await fetch(`${url.replace(/\/+$/, '')}/global/health`, {
|
||||
const response = await fetch(`${url.replace(/\/+$/, '')}/api/health`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
|
||||
@@ -212,6 +212,51 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/opencode/health', async (_req, res) => {
|
||||
try {
|
||||
const healthResponse = await fetch(buildOpenCodeUrl('/api/health', ''), {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
||||
});
|
||||
const health = await healthResponse.json().catch(() => null);
|
||||
if (!healthResponse.ok) {
|
||||
return res.status(healthResponse.status).json({
|
||||
healthy: false,
|
||||
error: health?.error || healthResponse.statusText || 'OpenCode health check failed',
|
||||
});
|
||||
}
|
||||
return res.json({ healthy: health?.healthy === true });
|
||||
} catch (error) {
|
||||
return res.status(503).json({
|
||||
healthy: false,
|
||||
error: error instanceof Error ? error.message : 'OpenCode health check failed',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/opencode/version', async (_req, res) => {
|
||||
try {
|
||||
const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
||||
});
|
||||
const health = await healthResponse.json().catch(() => null);
|
||||
if (!healthResponse.ok) {
|
||||
return res.status(healthResponse.status).json({
|
||||
version: null,
|
||||
error: health?.error || healthResponse.statusText || 'Failed to read OpenCode version',
|
||||
});
|
||||
}
|
||||
const version = typeof health?.version === 'string' ? health.version.replace(/^v/, '') : null;
|
||||
return res.json({ version });
|
||||
} catch (error) {
|
||||
return res.status(500).json({
|
||||
version: null,
|
||||
error: error instanceof Error ? error.message : 'Failed to read OpenCode version',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/config/settings', async (req, res) => {
|
||||
console.log('[API:PUT /api/config/settings] Received request');
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user