fix: lazy init local inference runtime in router instead of app start (#8656)
This commit is contained in:
@@ -92,7 +92,8 @@ const i18n = defineMessages({
|
||||
},
|
||||
tooManyTools: {
|
||||
id: 'chatInput.tooManyTools',
|
||||
defaultMessage: 'Too many tools can degrade performance.\nTool count: {toolCount} (recommend: {recommended})',
|
||||
defaultMessage:
|
||||
'Too many tools can degrade performance.\nTool count: {toolCount} (recommend: {recommended})',
|
||||
},
|
||||
viewExtensions: {
|
||||
id: 'chatInput.viewExtensions',
|
||||
@@ -572,7 +573,10 @@ export default function ChatInput({
|
||||
if (toolCount !== null && toolCount > TOOLS_MAX_SUGGESTED) {
|
||||
addAlert({
|
||||
type: AlertType.Warning,
|
||||
message: intl.formatMessage(i18n.tooManyTools, { toolCount, recommended: TOOLS_MAX_SUGGESTED }),
|
||||
message: intl.formatMessage(i18n.tooManyTools, {
|
||||
toolCount,
|
||||
recommended: TOOLS_MAX_SUGGESTED,
|
||||
}),
|
||||
action: {
|
||||
text: intl.formatMessage(i18n.viewExtensions),
|
||||
onClick: () => setView('extensions'),
|
||||
@@ -1567,7 +1571,9 @@ export default function ChatInput({
|
||||
<p className="text-sm text-text-primary truncate" title={file.name}>
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">{file.type || intl.formatMessage(i18n.unknownType)}</p>
|
||||
<p className="text-xs text-text-secondary">
|
||||
{file.type || intl.formatMessage(i18n.unknownType)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1683,7 +1689,9 @@ export default function ChatInput({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{recipe ? intl.formatMessage(i18n.viewEditRecipe) : intl.formatMessage(i18n.createRecipeFromSession)}
|
||||
{recipe
|
||||
? intl.formatMessage(i18n.viewEditRecipe)
|
||||
: intl.formatMessage(i18n.createRecipeFromSession)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -68,7 +68,9 @@ const CustomProviderCard = memo(function CustomProviderCard({ onClick }: { onCli
|
||||
<Plus className="w-8 h-8 text-gray-400 mb-2" />
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 text-center">
|
||||
<div className="font-medium">{intl.formatMessage(i18n.addProvider)}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{intl.formatMessage(i18n.fromTemplateOrManual)}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{intl.formatMessage(i18n.fromTemplateOrManual)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -277,7 +279,9 @@ function ProviderCards({
|
||||
|
||||
const editable = editingProvider ? editingProvider.isEditable : true;
|
||||
const title = editingProvider
|
||||
? (editable ? intl.formatMessage(i18n.editProvider) : intl.formatMessage(i18n.configureProvider))
|
||||
? editable
|
||||
? intl.formatMessage(i18n.editProvider)
|
||||
: intl.formatMessage(i18n.configureProvider)
|
||||
: intl.formatMessage(i18n.addProviderTitle);
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -6,6 +6,11 @@ import { createServer } from 'net';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { status } from './api';
|
||||
import { Client, createClient, createConfig } from './api/client';
|
||||
import {
|
||||
appendTail,
|
||||
createStartupDiagnostics,
|
||||
type StartupDiagnostics,
|
||||
} from './startupDiagnostics';
|
||||
|
||||
export interface Logger {
|
||||
info: (...args: unknown[]) => void;
|
||||
@@ -77,24 +82,36 @@ export const findGoosedBinaryPath = (options: FindBinaryOptions = {}): string =>
|
||||
);
|
||||
};
|
||||
|
||||
export const checkServerStatus = async (client: Client, errorLog: string[]): Promise<boolean> => {
|
||||
export interface CheckServerStatusOptions {
|
||||
onEvent?: (name: string, details?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export const checkServerStatus = async (
|
||||
client: Client,
|
||||
errorLog: string[],
|
||||
options: CheckServerStatusOptions = {}
|
||||
): Promise<boolean> => {
|
||||
const timeout = 30000;
|
||||
const interval = 100;
|
||||
const maxAttempts = Math.ceil(timeout / interval);
|
||||
options.onEvent?.('healthcheck_start', { timeoutMs: timeout, intervalMs: interval });
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
if (errorLog.some(isFatalError)) {
|
||||
options.onEvent?.('healthcheck_fatal_error', { attempt });
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await status({ client, throwOnError: true });
|
||||
options.onEvent?.('healthcheck_success', { attempt });
|
||||
return true;
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
}
|
||||
}
|
||||
|
||||
options.onEvent?.('healthcheck_timeout', { timeoutMs: timeout });
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -153,6 +170,7 @@ export interface StartGoosedOptions {
|
||||
isPackaged?: boolean;
|
||||
resourcesPath?: string;
|
||||
logger?: Logger;
|
||||
diagnosticsDir?: string;
|
||||
}
|
||||
|
||||
export interface GoosedResult {
|
||||
@@ -164,6 +182,9 @@ export interface GoosedResult {
|
||||
cleanup: () => Promise<void>;
|
||||
client: Client;
|
||||
certFingerprint: string | null;
|
||||
startupDiagnosticsPath: string | null;
|
||||
getStartupDiagnostics: () => StartupDiagnostics | null;
|
||||
recordStartupEvent: (name: string, details?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const goosedClientForUrlAndSecret = (url: string, secret: string): Client => {
|
||||
@@ -187,14 +208,19 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
||||
env: additionalEnv = {},
|
||||
externalGoosed,
|
||||
logger = defaultLogger,
|
||||
diagnosticsDir,
|
||||
} = options;
|
||||
|
||||
const errorLog: string[] = [];
|
||||
const workingDir = dir || os.homedir();
|
||||
const startupTrace = createStartupDiagnostics(diagnosticsDir, workingDir);
|
||||
|
||||
if (externalGoosed?.enabled && externalGoosed.url) {
|
||||
const url = externalGoosed.url.replace(/\/$/, '');
|
||||
logger.info(`Using external goosed backend at ${url}`);
|
||||
if (startupTrace) {
|
||||
startupTrace.diagnostics.baseUrl = url;
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: url,
|
||||
@@ -207,6 +233,9 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
||||
},
|
||||
client: goosedClientForUrlAndSecret(url, serverSecret),
|
||||
certFingerprint: null,
|
||||
startupDiagnosticsPath: startupTrace?.diagnosticsPath ?? null,
|
||||
getStartupDiagnostics: () => startupTrace?.diagnostics ?? null,
|
||||
recordStartupEvent: (name, details) => startupTrace?.record(name, details),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -214,6 +243,9 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
||||
const port = process.env.GOOSE_PORT || '3000';
|
||||
const url = `https://127.0.0.1:${port}`;
|
||||
logger.info(`Using external goosed backend from env at ${url}`);
|
||||
if (startupTrace) {
|
||||
startupTrace.diagnostics.baseUrl = url;
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: url,
|
||||
@@ -226,6 +258,9 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
||||
},
|
||||
client: goosedClientForUrlAndSecret(url, serverSecret),
|
||||
certFingerprint: null,
|
||||
startupDiagnosticsPath: startupTrace?.diagnosticsPath ?? null,
|
||||
getStartupDiagnostics: () => startupTrace?.diagnostics ?? null,
|
||||
recordStartupEvent: (name, details) => startupTrace?.record(name, details),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -235,6 +270,11 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
||||
logger.info(`Starting goosed from: ${goosedPath} on port ${port} in dir ${workingDir}`);
|
||||
|
||||
const baseUrl = `https://127.0.0.1:${port}`;
|
||||
if (startupTrace) {
|
||||
startupTrace.diagnostics.goosedPath = goosedPath;
|
||||
startupTrace.diagnostics.baseUrl = baseUrl;
|
||||
startupTrace.record('spawn_start', { goosedPath, port, workingDir });
|
||||
}
|
||||
|
||||
const spawnEnv: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
@@ -273,6 +313,10 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
||||
logger.info('Spawn options:', JSON.stringify(safeSpawnOptions, null, 2));
|
||||
|
||||
const goosedProcess = spawn(spawnCommand, spawnArgs, spawnOptions);
|
||||
if (startupTrace) {
|
||||
startupTrace.diagnostics.pid = goosedProcess.pid ?? null;
|
||||
startupTrace.record('spawn_success', { pid: goosedProcess.pid ?? null });
|
||||
}
|
||||
|
||||
let certFingerprint: string | null = null;
|
||||
const fingerprintReady = new Promise<string | null>((resolve) => {
|
||||
@@ -288,6 +332,10 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
||||
if (line.startsWith(FINGERPRINT_PREFIX)) {
|
||||
certFingerprint = line.slice(FINGERPRINT_PREFIX.length).trim();
|
||||
logger.info(`Pinned cert fingerprint: ${certFingerprint}`);
|
||||
if (startupTrace) {
|
||||
startupTrace.diagnostics.certFingerprintSeen = true;
|
||||
startupTrace.record('fingerprint_received', { certFingerprint });
|
||||
}
|
||||
resolved = true;
|
||||
resolve(certFingerprint);
|
||||
break;
|
||||
@@ -319,6 +367,8 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
||||
|
||||
const onStderrData = (data: Buffer) => {
|
||||
const lines = data.toString().split('\n');
|
||||
const nonEmptyLines = lines.filter((line) => line.trim());
|
||||
appendTail(startupTrace?.diagnostics.stderrTail ?? [], nonEmptyLines);
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
errorLog.push(line);
|
||||
@@ -334,13 +384,19 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
||||
goosedProcess.stderr?.off('data', onStderrData);
|
||||
};
|
||||
|
||||
goosedProcess.on('exit', (code) => {
|
||||
goosedProcess.on('exit', (code, signal) => {
|
||||
logger.info(`goosed process exited with code ${code} for port ${port} and dir ${workingDir}`);
|
||||
if (startupTrace) {
|
||||
startupTrace.diagnostics.childExitCode = code;
|
||||
startupTrace.diagnostics.childExitSignal = signal;
|
||||
startupTrace.record('child_exit', { code, signal });
|
||||
}
|
||||
});
|
||||
|
||||
goosedProcess.on('error', (err) => {
|
||||
logger.error(`Failed to start goosed on port ${port} and dir ${workingDir}`, err);
|
||||
errorLog.push(err.message);
|
||||
startupTrace?.record('spawn_error', { message: err.message, name: err.name });
|
||||
});
|
||||
|
||||
const cleanup = async (): Promise<void> => {
|
||||
@@ -387,5 +443,8 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
||||
cleanup,
|
||||
client: goosedClientForUrlAndSecret(baseUrl, serverSecret),
|
||||
certFingerprint,
|
||||
startupDiagnosticsPath: startupTrace?.diagnosticsPath ?? null,
|
||||
getStartupDiagnostics: () => startupTrace?.diagnostics ?? null,
|
||||
recordStartupEvent: (name, details) => startupTrace?.record(name, details),
|
||||
};
|
||||
};
|
||||
|
||||
+25
-2
@@ -61,6 +61,7 @@ function shouldSetupUpdater(): boolean {
|
||||
|
||||
// Settings management
|
||||
const SETTINGS_FILE = path.join(app.getPath('userData'), 'settings.json');
|
||||
const STARTUP_LOGS_DIR = path.join(app.getPath('userData'), 'logs', 'startup');
|
||||
|
||||
function getSettings(): Settings {
|
||||
if (fsSync.existsSync(SETTINGS_FILE)) {
|
||||
@@ -617,6 +618,7 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
|
||||
isPackaged: app.isPackaged,
|
||||
resourcesPath: app.isPackaged ? process.resourcesPath : undefined,
|
||||
logger: log,
|
||||
diagnosticsDir: STARTUP_LOGS_DIR,
|
||||
});
|
||||
|
||||
// For locally-spawned goosed, pin using the fingerprint from stdout.
|
||||
@@ -637,6 +639,9 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
|
||||
process: goosedProcess,
|
||||
errorLog,
|
||||
stopErrorLogCollection,
|
||||
startupDiagnosticsPath,
|
||||
getStartupDiagnostics,
|
||||
recordStartupEvent,
|
||||
} = goosedResult;
|
||||
|
||||
const mainWindowState = windowStateKeeper({
|
||||
@@ -705,9 +710,27 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
|
||||
);
|
||||
goosedClients.set(mainWindow.id, goosedClient);
|
||||
|
||||
const serverReady = await checkServerStatus(goosedClient, errorLog);
|
||||
const serverReady = await checkServerStatus(goosedClient, errorLog, {
|
||||
onEvent: recordStartupEvent,
|
||||
});
|
||||
if (!serverReady) {
|
||||
const isUsingExternalBackend = settings.externalGoosed?.enabled;
|
||||
const diagnostics = getStartupDiagnostics();
|
||||
const stderrTail = diagnostics?.stderrTail ?? [];
|
||||
const failureDetailParts = [
|
||||
diagnostics?.childExitCode !== null || diagnostics?.childExitSignal
|
||||
? `Child exit: code=${diagnostics?.childExitCode ?? 'null'} signal=${diagnostics?.childExitSignal ?? 'null'}`
|
||||
: 'Child exit: unavailable',
|
||||
diagnostics?.certFingerprintSeen
|
||||
? 'TLS fingerprint observed: yes'
|
||||
: 'TLS fingerprint observed: no',
|
||||
diagnostics?.healthCheckSucceeded
|
||||
? 'Health check observed: yes'
|
||||
: 'Health check observed: no',
|
||||
startupDiagnosticsPath ? `Startup diagnostics: ${startupDiagnosticsPath}` : '',
|
||||
errorLog.length > 0 ? `Startup errors:\n${errorLog.join('\n')}` : '',
|
||||
stderrTail.length > 0 ? `Captured startup stderr:\n${stderrTail.join('\n')}` : '',
|
||||
].filter(Boolean);
|
||||
|
||||
if (isUsingExternalBackend) {
|
||||
const response = dialog.showMessageBoxSync({
|
||||
@@ -734,7 +757,7 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
|
||||
type: 'error',
|
||||
title: 'Goose Failed to Start',
|
||||
message: 'The backend server failed to start.',
|
||||
detail: errorLog.join('\n'),
|
||||
detail: failureDetailParts.join('\n\n'),
|
||||
buttons: ['OK'],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface StartupTraceEvent {
|
||||
name: string;
|
||||
at: string;
|
||||
elapsedMs: number;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface StartupDiagnostics {
|
||||
attemptId: string;
|
||||
startedAt: string;
|
||||
goosedPath: string | null;
|
||||
workingDir: string;
|
||||
baseUrl: string | null;
|
||||
pid: number | null;
|
||||
certFingerprintSeen: boolean;
|
||||
healthCheckSucceeded: boolean;
|
||||
childExitCode: number | null;
|
||||
childExitSignal: string | null;
|
||||
stderrTail: string[];
|
||||
events: StartupTraceEvent[];
|
||||
}
|
||||
|
||||
export interface StartupTrace {
|
||||
diagnosticsPath: string;
|
||||
diagnostics: StartupDiagnostics;
|
||||
record: (name: string, details?: Record<string, unknown>) => void;
|
||||
flush: () => void;
|
||||
}
|
||||
|
||||
const STARTUP_TAIL_LIMIT = 80;
|
||||
const STARTUP_LOGS_TO_KEEP = 20;
|
||||
|
||||
export const appendTail = (target: string[], lines: string[]) => {
|
||||
target.push(...lines.filter((line) => line.trim()));
|
||||
if (target.length > STARTUP_TAIL_LIMIT) {
|
||||
target.splice(0, target.length - STARTUP_TAIL_LIMIT);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupStartupDiagnostics = (diagnosticsDir: string) => {
|
||||
const startupLogs = fs
|
||||
.readdirSync(diagnosticsDir, { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isFile() && entry.name.startsWith('goosed-startup-') && entry.name.endsWith('.json')
|
||||
)
|
||||
.map((entry) => {
|
||||
const filePath = path.join(diagnosticsDir, entry.name);
|
||||
return {
|
||||
filePath,
|
||||
modifiedMs: fs.statSync(filePath).mtimeMs,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.modifiedMs - a.modifiedMs);
|
||||
|
||||
for (const startupLog of startupLogs.slice(STARTUP_LOGS_TO_KEEP)) {
|
||||
fs.unlinkSync(startupLog.filePath);
|
||||
}
|
||||
};
|
||||
|
||||
export const createStartupDiagnostics = (
|
||||
diagnosticsDir: string | undefined,
|
||||
workingDir: string
|
||||
): StartupTrace | null => {
|
||||
if (!diagnosticsDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
fs.mkdirSync(diagnosticsDir, { recursive: true });
|
||||
cleanupStartupDiagnostics(diagnosticsDir);
|
||||
const startedAt = new Date();
|
||||
const attemptId = `goosed-startup-${startedAt.toISOString().replace(/:/g, '-')}-${process.pid}.json`;
|
||||
const diagnosticsPath = path.join(diagnosticsDir, attemptId);
|
||||
const monotonicStart = Date.now();
|
||||
|
||||
const diagnostics: StartupDiagnostics = {
|
||||
attemptId,
|
||||
startedAt: startedAt.toISOString(),
|
||||
goosedPath: null,
|
||||
workingDir,
|
||||
baseUrl: null,
|
||||
pid: null,
|
||||
certFingerprintSeen: false,
|
||||
healthCheckSucceeded: false,
|
||||
childExitCode: null,
|
||||
childExitSignal: null,
|
||||
stderrTail: [],
|
||||
events: [],
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
fs.writeFileSync(diagnosticsPath, `${JSON.stringify(diagnostics, null, 2)}\n`);
|
||||
};
|
||||
|
||||
const record = (name: string, details?: Record<string, unknown>) => {
|
||||
if (name === 'healthcheck_success') {
|
||||
diagnostics.healthCheckSucceeded = true;
|
||||
}
|
||||
diagnostics.events.push({
|
||||
name,
|
||||
at: new Date().toISOString(),
|
||||
elapsedMs: Date.now() - monotonicStart,
|
||||
...(details ? { details } : {}),
|
||||
});
|
||||
flush();
|
||||
};
|
||||
|
||||
flush();
|
||||
|
||||
return {
|
||||
diagnosticsPath,
|
||||
diagnostics,
|
||||
record,
|
||||
flush,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user