feat(gateway): improve Gateway process management & logging (#26)
This commit is contained in:
committed by
GitHub
Unverified
parent
3379229914
commit
a27f3b4113
@@ -90,10 +90,10 @@ function getNodeExecutablePath(): string {
|
|||||||
helperName,
|
helperName,
|
||||||
);
|
);
|
||||||
if (existsSync(helperPath)) {
|
if (existsSync(helperPath)) {
|
||||||
logger.info(`Using Electron Helper binary to avoid dock icon: ${helperPath}`);
|
logger.debug(`Using Electron Helper binary to avoid dock icon: ${helperPath}`);
|
||||||
return helperPath;
|
return helperPath;
|
||||||
}
|
}
|
||||||
logger.warn(`Electron Helper binary not found at ${helperPath}, falling back to process.execPath`);
|
logger.debug(`Electron Helper binary not found at ${helperPath}, falling back to process.execPath`);
|
||||||
}
|
}
|
||||||
return process.execPath;
|
return process.execPath;
|
||||||
}
|
}
|
||||||
@@ -104,6 +104,7 @@ function getNodeExecutablePath(): string {
|
|||||||
*/
|
*/
|
||||||
export class GatewayManager extends EventEmitter {
|
export class GatewayManager extends EventEmitter {
|
||||||
private process: ChildProcess | null = null;
|
private process: ChildProcess | null = null;
|
||||||
|
private ownsProcess = false;
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private status: GatewayStatus = { state: 'stopped', port: PORTS.OPENCLAW_GATEWAY };
|
private status: GatewayStatus = { state: 'stopped', port: PORTS.OPENCLAW_GATEWAY };
|
||||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||||
@@ -112,6 +113,8 @@ export class GatewayManager extends EventEmitter {
|
|||||||
private reconnectAttempts = 0;
|
private reconnectAttempts = 0;
|
||||||
private reconnectConfig: ReconnectConfig;
|
private reconnectConfig: ReconnectConfig;
|
||||||
private shouldReconnect = true;
|
private shouldReconnect = true;
|
||||||
|
private startLock = false;
|
||||||
|
private lastSpawnSummary: string | null = null;
|
||||||
private pendingRequests: Map<string, {
|
private pendingRequests: Map<string, {
|
||||||
resolve: (value: unknown) => void;
|
resolve: (value: unknown) => void;
|
||||||
reject: (error: Error) => void;
|
reject: (error: Error) => void;
|
||||||
@@ -123,6 +126,37 @@ export class GatewayManager extends EventEmitter {
|
|||||||
this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...config };
|
this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...config };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sanitizeSpawnArgs(args: string[]): string[] {
|
||||||
|
const sanitized = [...args];
|
||||||
|
const tokenIdx = sanitized.indexOf('--token');
|
||||||
|
if (tokenIdx !== -1 && tokenIdx + 1 < sanitized.length) {
|
||||||
|
sanitized[tokenIdx + 1] = '[redacted]';
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatExit(code: number | null, signal: NodeJS.Signals | null): string {
|
||||||
|
if (code !== null) return `code=${code}`;
|
||||||
|
if (signal) return `signal=${signal}`;
|
||||||
|
return 'code=null signal=null';
|
||||||
|
}
|
||||||
|
|
||||||
|
private classifyStderrMessage(message: string): { level: 'drop' | 'debug' | 'warn'; normalized: string } {
|
||||||
|
const msg = message.trim();
|
||||||
|
if (!msg) return { level: 'drop', normalized: msg };
|
||||||
|
|
||||||
|
// Known noisy lines that are not actionable for Gateway lifecycle debugging.
|
||||||
|
if (msg.includes('openclaw-control-ui') && msg.includes('token_mismatch')) return { level: 'drop', normalized: msg };
|
||||||
|
if (msg.includes('closed before connect') && msg.includes('token mismatch')) return { level: 'drop', normalized: msg };
|
||||||
|
|
||||||
|
// Downgrade frequent non-fatal noise.
|
||||||
|
if (msg.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg };
|
||||||
|
if (msg.includes('DeprecationWarning')) return { level: 'debug', normalized: msg };
|
||||||
|
if (msg.includes('Debugger attached')) return { level: 'debug', normalized: msg };
|
||||||
|
|
||||||
|
return { level: 'warn', normalized: msg };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current Gateway status
|
* Get current Gateway status
|
||||||
*/
|
*/
|
||||||
@@ -141,12 +175,28 @@ export class GatewayManager extends EventEmitter {
|
|||||||
* Start Gateway process
|
* Start Gateway process
|
||||||
*/
|
*/
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
if (this.status.state === 'running') {
|
if (this.startLock) {
|
||||||
logger.info('Gateway already running, skipping start');
|
logger.debug('Gateway start ignored because a start flow is already in progress');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.status.state === 'running') {
|
||||||
|
logger.debug('Gateway already running, skipping start');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.startLock = true;
|
||||||
|
logger.info(`Gateway start requested (port=${this.status.port})`);
|
||||||
|
this.lastSpawnSummary = null;
|
||||||
this.shouldReconnect = true;
|
this.shouldReconnect = true;
|
||||||
|
|
||||||
|
// Manual start should override and cancel any pending reconnect timer.
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
logger.debug('Cleared pending reconnect timer because start was requested manually');
|
||||||
|
}
|
||||||
|
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
this.setStatus({ state: 'starting', reconnectAttempts: 0 });
|
this.setStatus({ state: 'starting', reconnectAttempts: 0 });
|
||||||
|
|
||||||
@@ -163,36 +213,41 @@ export class GatewayManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if Gateway is already running
|
// Check if Gateway is already running
|
||||||
logger.info('Checking for existing Gateway...');
|
logger.debug('Checking for existing Gateway...');
|
||||||
const existing = await this.findExistingGateway();
|
const existing = await this.findExistingGateway();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
logger.info(`Found existing Gateway on port ${existing.port}`);
|
logger.debug(`Found existing Gateway on port ${existing.port}`);
|
||||||
await this.connect(existing.port);
|
await this.connect(existing.port);
|
||||||
|
this.ownsProcess = false;
|
||||||
|
this.setStatus({ pid: undefined });
|
||||||
this.startHealthCheck();
|
this.startHealthCheck();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('No existing Gateway found, starting new process...');
|
logger.debug('No existing Gateway found, starting new process...');
|
||||||
|
|
||||||
// Start new Gateway process
|
// Start new Gateway process
|
||||||
await this.startProcess();
|
await this.startProcess();
|
||||||
|
|
||||||
// Wait for Gateway to be ready
|
// Wait for Gateway to be ready
|
||||||
logger.info('Waiting for Gateway to be ready...');
|
|
||||||
await this.waitForReady();
|
await this.waitForReady();
|
||||||
|
|
||||||
// Connect WebSocket
|
// Connect WebSocket
|
||||||
logger.info('Connecting WebSocket...');
|
|
||||||
await this.connect(this.status.port);
|
await this.connect(this.status.port);
|
||||||
|
|
||||||
// Start health monitoring
|
// Start health monitoring
|
||||||
this.startHealthCheck();
|
this.startHealthCheck();
|
||||||
logger.info('Gateway started successfully');
|
logger.debug('Gateway started successfully');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Gateway start failed:', error);
|
logger.error(
|
||||||
|
`Gateway start failed (port=${this.status.port}, reconnectAttempts=${this.reconnectAttempts}, spawn=${this.lastSpawnSummary ?? 'n/a'})`,
|
||||||
|
error
|
||||||
|
);
|
||||||
this.setStatus({ state: 'error', error: String(error) });
|
this.setStatus({ state: 'error', error: String(error) });
|
||||||
throw error;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.startLock = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,12 +255,23 @@ export class GatewayManager extends EventEmitter {
|
|||||||
* Stop Gateway process
|
* Stop Gateway process
|
||||||
*/
|
*/
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
|
logger.info('Gateway stop requested');
|
||||||
// Disable auto-reconnect
|
// Disable auto-reconnect
|
||||||
this.shouldReconnect = false;
|
this.shouldReconnect = false;
|
||||||
|
|
||||||
// Clear all timers
|
// Clear all timers
|
||||||
this.clearAllTimers();
|
this.clearAllTimers();
|
||||||
|
|
||||||
|
// If this manager is attached to an external gateway process, ask it to shut down
|
||||||
|
// over protocol before closing the socket.
|
||||||
|
if (!this.ownsProcess && this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
try {
|
||||||
|
await this.rpc('shutdown', undefined, 5000);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to request shutdown for externally managed Gateway:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Close WebSocket
|
// Close WebSocket
|
||||||
if (this.ws) {
|
if (this.ws) {
|
||||||
this.ws.close(1000, 'Gateway stopped by user');
|
this.ws.close(1000, 'Gateway stopped by user');
|
||||||
@@ -213,17 +279,23 @@ export class GatewayManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Kill process
|
// Kill process
|
||||||
if (this.process) {
|
if (this.process && this.ownsProcess) {
|
||||||
this.process.kill('SIGTERM');
|
const child = this.process;
|
||||||
|
logger.info(`Sending SIGTERM to Gateway (pid=${child.pid ?? 'unknown'})`);
|
||||||
|
child.kill('SIGTERM');
|
||||||
// Force kill after timeout
|
// Force kill after timeout
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.process) {
|
if (child.exitCode === null) {
|
||||||
this.process.kill('SIGKILL');
|
logger.warn(`Gateway did not exit in time, sending SIGKILL (pid=${child.pid ?? 'unknown'})`);
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
if (this.process === child) {
|
||||||
this.process = null;
|
this.process = null;
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
this.process = null;
|
this.process = null;
|
||||||
}
|
}
|
||||||
|
this.ownsProcess = false;
|
||||||
|
|
||||||
// Reject all pending requests
|
// Reject all pending requests
|
||||||
for (const [, request] of this.pendingRequests) {
|
for (const [, request] of this.pendingRequests) {
|
||||||
@@ -232,14 +304,14 @@ export class GatewayManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
this.pendingRequests.clear();
|
this.pendingRequests.clear();
|
||||||
|
|
||||||
this.setStatus({ state: 'stopped', error: undefined });
|
this.setStatus({ state: 'stopped', error: undefined, pid: undefined, connectedAt: undefined, uptime: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restart Gateway process
|
* Restart Gateway process
|
||||||
*/
|
*/
|
||||||
async restart(): Promise<void> {
|
async restart(): Promise<void> {
|
||||||
console.log('Restarting Gateway...');
|
logger.debug('Gateway restart requested');
|
||||||
await this.stop();
|
await this.stop();
|
||||||
// Brief delay before restart
|
// Brief delay before restart
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
@@ -324,11 +396,11 @@ export class GatewayManager extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
const health = await this.checkHealth();
|
const health = await this.checkHealth();
|
||||||
if (!health.ok) {
|
if (!health.ok) {
|
||||||
console.warn('Gateway health check failed:', health.error);
|
logger.warn(`Gateway health check failed: ${health.error ?? 'unknown'}`);
|
||||||
this.emit('error', new Error(health.error || 'Health check failed'));
|
this.emit('error', new Error(health.error || 'Health check failed'));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Health check error:', error);
|
logger.error('Gateway health check error:', error);
|
||||||
}
|
}
|
||||||
}, 30000); // Check every 30 seconds
|
}, 30000); // Check every 30 seconds
|
||||||
}
|
}
|
||||||
@@ -391,17 +463,6 @@ export class GatewayManager extends EventEmitter {
|
|||||||
const openclawDir = getOpenClawDir();
|
const openclawDir = getOpenClawDir();
|
||||||
const entryScript = getOpenClawEntryPath();
|
const entryScript = getOpenClawEntryPath();
|
||||||
|
|
||||||
logger.info('=== Gateway startProcess begin ===');
|
|
||||||
logger.info(`app.isPackaged: ${app.isPackaged}`);
|
|
||||||
logger.info(`openclawDir: ${openclawDir}`);
|
|
||||||
logger.info(`entryScript: ${entryScript}`);
|
|
||||||
logger.info(`openclawDir exists: ${existsSync(openclawDir)}`);
|
|
||||||
logger.info(`entryScript exists: ${existsSync(entryScript)}`);
|
|
||||||
logger.info(`process.execPath: ${process.execPath}`);
|
|
||||||
logger.info(`process.resourcesPath: ${process.resourcesPath}`);
|
|
||||||
logger.info(`process.cwd(): ${process.cwd()}`);
|
|
||||||
logger.info(`process.platform: ${process.platform}, process.arch: ${process.arch}`);
|
|
||||||
|
|
||||||
// Verify OpenClaw package exists
|
// Verify OpenClaw package exists
|
||||||
if (!isOpenClawPresent()) {
|
if (!isOpenClawPresent()) {
|
||||||
const errMsg = `OpenClaw package not found at: ${openclawDir}`;
|
const errMsg = `OpenClaw package not found at: ${openclawDir}`;
|
||||||
@@ -411,10 +472,10 @@ export class GatewayManager extends EventEmitter {
|
|||||||
|
|
||||||
// Get or generate gateway token
|
// Get or generate gateway token
|
||||||
const gatewayToken = await getSetting('gatewayToken');
|
const gatewayToken = await getSetting('gatewayToken');
|
||||||
logger.info(`Using gateway token: ${gatewayToken.substring(0, 10)}...`);
|
|
||||||
|
|
||||||
let command: string;
|
let command: string;
|
||||||
let args: string[];
|
let args: string[];
|
||||||
|
let mode: 'packaged' | 'dev-built' | 'dev-pnpm';
|
||||||
|
|
||||||
// Determine the Node.js executable
|
// Determine the Node.js executable
|
||||||
// In packaged Electron app, use process.execPath with ELECTRON_RUN_AS_NODE=1
|
// In packaged Electron app, use process.execPath with ELECTRON_RUN_AS_NODE=1
|
||||||
@@ -428,8 +489,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
if (existsSync(entryScript)) {
|
if (existsSync(entryScript)) {
|
||||||
command = getNodeExecutablePath();
|
command = getNodeExecutablePath();
|
||||||
args = [entryScript, ...gatewayArgs];
|
args = [entryScript, ...gatewayArgs];
|
||||||
logger.info('Starting Gateway in PACKAGED mode (ELECTRON_RUN_AS_NODE)');
|
mode = 'packaged';
|
||||||
logger.info(`Using executable: ${command}`);
|
|
||||||
} else {
|
} else {
|
||||||
const errMsg = `OpenClaw entry script not found at: ${entryScript}`;
|
const errMsg = `OpenClaw entry script not found at: ${entryScript}`;
|
||||||
logger.error(errMsg);
|
logger.error(errMsg);
|
||||||
@@ -439,17 +499,14 @@ export class GatewayManager extends EventEmitter {
|
|||||||
// Development with built package: use system node
|
// Development with built package: use system node
|
||||||
command = 'node';
|
command = 'node';
|
||||||
args = [entryScript, ...gatewayArgs];
|
args = [entryScript, ...gatewayArgs];
|
||||||
logger.info('Starting Gateway in DEV mode (node + built dist)');
|
mode = 'dev-built';
|
||||||
} else {
|
} else {
|
||||||
// Development without build: use pnpm dev
|
// Development without build: use pnpm dev
|
||||||
command = 'pnpm';
|
command = 'pnpm';
|
||||||
args = ['run', 'dev', ...gatewayArgs];
|
args = ['run', 'dev', ...gatewayArgs];
|
||||||
logger.info('Starting Gateway in DEV mode (pnpm dev)');
|
mode = 'dev-pnpm';
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Spawning: ${command} ${args.join(' ')}`);
|
|
||||||
logger.info(`Working directory: ${openclawDir}`);
|
|
||||||
|
|
||||||
// Resolve bundled bin path for uv
|
// Resolve bundled bin path for uv
|
||||||
const platform = process.platform;
|
const platform = process.platform;
|
||||||
const arch = process.arch;
|
const arch = process.arch;
|
||||||
@@ -464,11 +521,10 @@ export class GatewayManager extends EventEmitter {
|
|||||||
? `${binPath}${path.delimiter}${process.env.PATH || ''}`
|
? `${binPath}${path.delimiter}${process.env.PATH || ''}`
|
||||||
: process.env.PATH || '';
|
: process.env.PATH || '';
|
||||||
|
|
||||||
logger.info(`Bundled bin path: ${binPath}, exists: ${binPathExists}`);
|
|
||||||
|
|
||||||
// Load provider API keys from secure storage to pass as environment variables
|
// Load provider API keys from secure storage to pass as environment variables
|
||||||
const providerEnv: Record<string, string> = {};
|
const providerEnv: Record<string, string> = {};
|
||||||
const providerTypes = ['anthropic', 'openai', 'google', 'openrouter'];
|
const providerTypes = ['anthropic', 'openai', 'google', 'openrouter'];
|
||||||
|
let loadedProviderKeyCount = 0;
|
||||||
for (const providerType of providerTypes) {
|
for (const providerType of providerTypes) {
|
||||||
try {
|
try {
|
||||||
const key = await getApiKey(providerType);
|
const key = await getApiKey(providerType);
|
||||||
@@ -476,7 +532,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
const envVar = getProviderEnvVar(providerType);
|
const envVar = getProviderEnvVar(providerType);
|
||||||
if (envVar) {
|
if (envVar) {
|
||||||
providerEnv[envVar] = key;
|
providerEnv[envVar] = key;
|
||||||
logger.info(`Loaded API key for ${providerType} -> ${envVar}`);
|
loadedProviderKeyCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -485,6 +541,10 @@ export class GatewayManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uvEnv = await getUvMirrorEnv();
|
const uvEnv = await getUvMirrorEnv();
|
||||||
|
logger.info(
|
||||||
|
`Starting Gateway process (mode=${mode}, port=${this.status.port}, command="${command}", args="${this.sanitizeSpawnArgs(args).join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'}, providerKeys=${loadedProviderKeyCount})`
|
||||||
|
);
|
||||||
|
this.lastSpawnSummary = `mode=${mode}, command="${command}", args="${this.sanitizeSpawnArgs(args).join(' ')}", cwd="${openclawDir}"`;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const spawnEnv: Record<string, string | undefined> = {
|
const spawnEnv: Record<string, string | undefined> = {
|
||||||
@@ -518,14 +578,23 @@ export class GatewayManager extends EventEmitter {
|
|||||||
shell: !app.isPackaged && process.platform === 'win32', // shell only in dev on Windows
|
shell: !app.isPackaged && process.platform === 'win32', // shell only in dev on Windows
|
||||||
env: spawnEnv,
|
env: spawnEnv,
|
||||||
});
|
});
|
||||||
|
const child = this.process;
|
||||||
|
this.ownsProcess = true;
|
||||||
|
|
||||||
this.process.on('error', (error) => {
|
child.on('error', (error) => {
|
||||||
|
this.ownsProcess = false;
|
||||||
logger.error('Gateway process spawn error:', error);
|
logger.error('Gateway process spawn error:', error);
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.process.on('exit', (code) => {
|
child.on('exit', (code, signal) => {
|
||||||
logger.info(`Gateway process exited with code: ${code}`);
|
const expectedExit = !this.shouldReconnect || this.status.state === 'stopped';
|
||||||
|
const level = expectedExit ? logger.info : logger.warn;
|
||||||
|
level(`Gateway process exited (${this.formatExit(code, signal)}, expected=${expectedExit ? 'yes' : 'no'})`);
|
||||||
|
this.ownsProcess = false;
|
||||||
|
if (this.process === child) {
|
||||||
|
this.process = null;
|
||||||
|
}
|
||||||
this.emit('exit', code);
|
this.emit('exit', code);
|
||||||
|
|
||||||
if (this.status.state === 'running') {
|
if (this.status.state === 'running') {
|
||||||
@@ -534,25 +603,28 @@ export class GatewayManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log stdout
|
child.on('close', (code, signal) => {
|
||||||
this.process.stdout?.on('data', (data) => {
|
logger.debug(`Gateway process stdio closed (${this.formatExit(code, signal)})`);
|
||||||
const msg = data.toString().trimEnd();
|
|
||||||
logger.debug(`[Gateway stdout] ${msg}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log stderr
|
// Log stderr
|
||||||
this.process.stderr?.on('data', (data) => {
|
child.stderr?.on('data', (data) => {
|
||||||
const msg = data.toString().trimEnd();
|
const raw = data.toString();
|
||||||
// Suppress noisy control-ui token_mismatch messages
|
for (const line of raw.split(/\r?\n/)) {
|
||||||
if (msg.includes('openclaw-control-ui') && msg.includes('token_mismatch')) return;
|
const classified = this.classifyStderrMessage(line);
|
||||||
if (msg.includes('closed before connect') && msg.includes('token mismatch')) return;
|
if (classified.level === 'drop') continue;
|
||||||
logger.warn(`[Gateway stderr] ${msg}`);
|
if (classified.level === 'debug') {
|
||||||
|
logger.debug(`[Gateway stderr] ${classified.normalized}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
logger.warn(`[Gateway stderr] ${classified.normalized}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store PID
|
// Store PID
|
||||||
if (this.process.pid) {
|
if (child.pid) {
|
||||||
logger.info(`Gateway process PID: ${this.process.pid}`);
|
logger.info(`Gateway process started (pid=${child.pid})`);
|
||||||
this.setStatus({ pid: this.process.pid });
|
this.setStatus({ pid: child.pid });
|
||||||
} else {
|
} else {
|
||||||
logger.warn('Gateway process spawned but PID is undefined');
|
logger.warn('Gateway process spawned but PID is undefined');
|
||||||
}
|
}
|
||||||
@@ -567,10 +639,11 @@ export class GatewayManager extends EventEmitter {
|
|||||||
private async waitForReady(retries = 120, interval = 1000): Promise<void> {
|
private async waitForReady(retries = 120, interval = 1000): Promise<void> {
|
||||||
for (let i = 0; i < retries; i++) {
|
for (let i = 0; i < retries; i++) {
|
||||||
// Early exit if the gateway process has already exited
|
// Early exit if the gateway process has already exited
|
||||||
if (this.process && this.process.exitCode !== null) {
|
if (this.process && (this.process.exitCode !== null || this.process.signalCode !== null)) {
|
||||||
const code = this.process.exitCode;
|
const code = this.process.exitCode;
|
||||||
logger.error(`Gateway process exited with code ${code} before becoming ready`);
|
const signal = this.process.signalCode;
|
||||||
throw new Error(`Gateway process exited with code ${code} before becoming ready`);
|
logger.error(`Gateway process exited before ready (${this.formatExit(code, signal)})`);
|
||||||
|
throw new Error(`Gateway process exited before becoming ready (${this.formatExit(code, signal)})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -594,7 +667,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (ready) {
|
if (ready) {
|
||||||
logger.info(`Gateway ready after ${i + 1} attempt(s)`);
|
logger.debug(`Gateway ready after ${i + 1} attempt(s)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -602,7 +675,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (i > 0 && i % 10 === 0) {
|
if (i > 0 && i % 10 === 0) {
|
||||||
logger.info(`Still waiting for Gateway... (attempt ${i + 1}/${retries})`);
|
logger.debug(`Still waiting for Gateway... (attempt ${i + 1}/${retries})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||||
@@ -618,6 +691,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
private async connect(port: number): Promise<void> {
|
private async connect(port: number): Promise<void> {
|
||||||
// Get token for WebSocket authentication
|
// Get token for WebSocket authentication
|
||||||
const gatewayToken = await getSetting('gatewayToken');
|
const gatewayToken = await getSetting('gatewayToken');
|
||||||
|
logger.debug(`Connecting Gateway WebSocket (ws://localhost:${port}/ws)`);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// WebSocket URL (token will be sent in connect handshake, not URL)
|
// WebSocket URL (token will be sent in connect handshake, not URL)
|
||||||
@@ -625,13 +699,45 @@ export class GatewayManager extends EventEmitter {
|
|||||||
|
|
||||||
this.ws = new WebSocket(wsUrl);
|
this.ws = new WebSocket(wsUrl);
|
||||||
let handshakeComplete = false;
|
let handshakeComplete = false;
|
||||||
|
let connectId: string | null = null;
|
||||||
|
let handshakeTimeout: NodeJS.Timeout | null = null;
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const cleanupHandshakeRequest = () => {
|
||||||
|
if (handshakeTimeout) {
|
||||||
|
clearTimeout(handshakeTimeout);
|
||||||
|
handshakeTimeout = null;
|
||||||
|
}
|
||||||
|
if (connectId && this.pendingRequests.has(connectId)) {
|
||||||
|
const request = this.pendingRequests.get(connectId);
|
||||||
|
if (request) {
|
||||||
|
clearTimeout(request.timeout);
|
||||||
|
}
|
||||||
|
this.pendingRequests.delete(connectId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveOnce = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanupHandshakeRequest();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectOnce = (error: unknown) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanupHandshakeRequest();
|
||||||
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
|
reject(err);
|
||||||
|
};
|
||||||
|
|
||||||
this.ws.on('open', async () => {
|
this.ws.on('open', async () => {
|
||||||
console.log('WebSocket opened, sending connect handshake...');
|
logger.debug('Gateway WebSocket opened, sending connect handshake');
|
||||||
|
|
||||||
// Send proper connect handshake as required by OpenClaw Gateway protocol
|
// Send proper connect handshake as required by OpenClaw Gateway protocol
|
||||||
// The Gateway expects: { type: "req", id: "...", method: "connect", params: ConnectParams }
|
// The Gateway expects: { type: "req", id: "...", method: "connect", params: ConnectParams }
|
||||||
const connectId = `connect-${Date.now()}`;
|
connectId = `connect-${Date.now()}`;
|
||||||
const connectFrame = {
|
const connectFrame = {
|
||||||
type: 'req',
|
type: 'req',
|
||||||
id: connectId,
|
id: connectId,
|
||||||
@@ -655,37 +761,35 @@ export class GatewayManager extends EventEmitter {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('Sending connect handshake:', JSON.stringify(connectFrame));
|
|
||||||
this.ws?.send(JSON.stringify(connectFrame));
|
this.ws?.send(JSON.stringify(connectFrame));
|
||||||
|
|
||||||
// Store pending connect request
|
// Store pending connect request
|
||||||
const connectTimeout = setTimeout(() => {
|
const requestTimeout = setTimeout(() => {
|
||||||
if (!handshakeComplete) {
|
if (!handshakeComplete) {
|
||||||
console.error('Connect handshake timeout');
|
logger.error('Gateway connect handshake timed out');
|
||||||
reject(new Error('Connect handshake timeout'));
|
|
||||||
this.ws?.close();
|
this.ws?.close();
|
||||||
|
rejectOnce(new Error('Connect handshake timeout'));
|
||||||
}
|
}
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
handshakeTimeout = requestTimeout;
|
||||||
|
|
||||||
this.pendingRequests.set(connectId, {
|
this.pendingRequests.set(connectId, {
|
||||||
resolve: (_result) => {
|
resolve: (_result) => {
|
||||||
clearTimeout(connectTimeout);
|
|
||||||
handshakeComplete = true;
|
handshakeComplete = true;
|
||||||
console.log('WebSocket handshake complete, gateway connected');
|
logger.debug('Gateway connect handshake completed');
|
||||||
this.setStatus({
|
this.setStatus({
|
||||||
state: 'running',
|
state: 'running',
|
||||||
port,
|
port,
|
||||||
connectedAt: Date.now(),
|
connectedAt: Date.now(),
|
||||||
});
|
});
|
||||||
this.startPing();
|
this.startPing();
|
||||||
resolve();
|
resolveOnce();
|
||||||
},
|
},
|
||||||
reject: (error) => {
|
reject: (error) => {
|
||||||
clearTimeout(connectTimeout);
|
logger.error('Gateway connect handshake failed:', error);
|
||||||
console.error('Connect handshake failed:', error);
|
rejectOnce(error);
|
||||||
reject(error);
|
|
||||||
},
|
},
|
||||||
timeout: connectTimeout,
|
timeout: requestTimeout,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -694,17 +798,18 @@ export class GatewayManager extends EventEmitter {
|
|||||||
const message = JSON.parse(data.toString());
|
const message = JSON.parse(data.toString());
|
||||||
this.handleMessage(message);
|
this.handleMessage(message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to parse WebSocket message:', error);
|
logger.debug('Failed to parse Gateway WebSocket message:', error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ws.on('close', (code, reason) => {
|
this.ws.on('close', (code, reason) => {
|
||||||
const reasonStr = reason?.toString() || 'unknown';
|
const reasonStr = reason?.toString() || 'unknown';
|
||||||
console.log(`WebSocket disconnected: code=${code}, reason=${reasonStr}`);
|
logger.warn(`Gateway WebSocket closed (code=${code}, reason=${reasonStr}, handshake=${handshakeComplete ? 'ok' : 'pending'})`);
|
||||||
if (!handshakeComplete) {
|
if (!handshakeComplete) {
|
||||||
reject(new Error(`WebSocket closed before handshake: ${reasonStr}`));
|
rejectOnce(new Error(`WebSocket closed before handshake: ${reasonStr}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
cleanupHandshakeRequest();
|
||||||
if (this.status.state === 'running') {
|
if (this.status.state === 'running') {
|
||||||
this.setStatus({ state: 'stopped' });
|
this.setStatus({ state: 'stopped' });
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
@@ -712,9 +817,9 @@ export class GatewayManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.ws.on('error', (error) => {
|
this.ws.on('error', (error) => {
|
||||||
console.error('WebSocket error:', error);
|
logger.error('Gateway WebSocket error:', error);
|
||||||
if (!handshakeComplete) {
|
if (!handshakeComplete) {
|
||||||
reject(error);
|
rejectOnce(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -725,7 +830,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
private handleMessage(message: unknown): void {
|
private handleMessage(message: unknown): void {
|
||||||
if (typeof message !== 'object' || message === null) {
|
if (typeof message !== 'object' || message === null) {
|
||||||
console.warn('Received non-object message:', message);
|
logger.debug('Received non-object Gateway message');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -827,7 +932,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
|
|
||||||
default:
|
default:
|
||||||
// Unknown notification type, just log it
|
// Unknown notification type, just log it
|
||||||
console.log('Unknown Gateway notification:', notification.method);
|
logger.debug(`Unknown Gateway notification: ${notification.method}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -851,7 +956,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
private scheduleReconnect(): void {
|
private scheduleReconnect(): void {
|
||||||
if (!this.shouldReconnect) {
|
if (!this.shouldReconnect) {
|
||||||
console.log('Auto-reconnect disabled, not scheduling reconnect');
|
logger.debug('Gateway reconnect skipped (auto-reconnect disabled)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -860,7 +965,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.reconnectAttempts >= this.reconnectConfig.maxAttempts) {
|
if (this.reconnectAttempts >= this.reconnectConfig.maxAttempts) {
|
||||||
console.error(`Max reconnection attempts (${this.reconnectConfig.maxAttempts}) reached`);
|
logger.error(`Gateway reconnect failed: max attempts reached (${this.reconnectConfig.maxAttempts})`);
|
||||||
this.setStatus({
|
this.setStatus({
|
||||||
state: 'error',
|
state: 'error',
|
||||||
error: 'Failed to reconnect after maximum attempts',
|
error: 'Failed to reconnect after maximum attempts',
|
||||||
@@ -876,7 +981,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.reconnectAttempts++;
|
this.reconnectAttempts++;
|
||||||
console.log(`Scheduling reconnect attempt ${this.reconnectAttempts} in ${delay}ms`);
|
logger.warn(`Scheduling Gateway reconnect attempt ${this.reconnectAttempts}/${this.reconnectConfig.maxAttempts} in ${delay}ms`);
|
||||||
|
|
||||||
this.setStatus({
|
this.setStatus({
|
||||||
state: 'reconnecting',
|
state: 'reconnecting',
|
||||||
@@ -890,6 +995,8 @@ export class GatewayManager extends EventEmitter {
|
|||||||
const existing = await this.findExistingGateway();
|
const existing = await this.findExistingGateway();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await this.connect(existing.port);
|
await this.connect(existing.port);
|
||||||
|
this.ownsProcess = false;
|
||||||
|
this.setStatus({ pid: undefined });
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
this.startHealthCheck();
|
this.startHealthCheck();
|
||||||
return;
|
return;
|
||||||
@@ -902,7 +1009,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
this.startHealthCheck();
|
this.startHealthCheck();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Reconnection failed:', error);
|
logger.error('Gateway reconnection attempt failed:', error);
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
}
|
}
|
||||||
}, delay);
|
}, delay);
|
||||||
@@ -924,7 +1031,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
|
|
||||||
// Log state transitions
|
// Log state transitions
|
||||||
if (previousState !== this.status.state) {
|
if (previousState !== this.status.state) {
|
||||||
console.log(`Gateway state: ${previousState} -> ${this.status.state}`);
|
logger.debug(`Gateway state changed: ${previousState} -> ${this.status.state}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,13 +104,9 @@ async function initialize(): Promise<void> {
|
|||||||
// Initialize logger first
|
// Initialize logger first
|
||||||
logger.init();
|
logger.init();
|
||||||
logger.info('=== ClawX Application Starting ===');
|
logger.info('=== ClawX Application Starting ===');
|
||||||
logger.info(`Platform: ${process.platform}, Arch: ${process.arch}`);
|
logger.debug(
|
||||||
logger.info(`Electron: ${process.versions.electron}, Node: ${process.versions.node}`);
|
`Runtime: platform=${process.platform}/${process.arch}, electron=${process.versions.electron}, node=${process.versions.node}, packaged=${app.isPackaged}`
|
||||||
logger.info(`App path: ${app.getAppPath()}`);
|
);
|
||||||
logger.info(`User data: ${app.getPath('userData')}`);
|
|
||||||
logger.info(`Is packaged: ${app.isPackaged}`);
|
|
||||||
logger.info(`Resources path: ${process.resourcesPath}`);
|
|
||||||
logger.info(`Exec path: ${process.execPath}`);
|
|
||||||
|
|
||||||
// Warm up network optimization (non-blocking)
|
// Warm up network optimization (non-blocking)
|
||||||
void warmupNetworkOptimization();
|
void warmupNetworkOptimization();
|
||||||
@@ -171,7 +167,7 @@ async function initialize(): Promise<void> {
|
|||||||
|
|
||||||
// Start Gateway automatically
|
// Start Gateway automatically
|
||||||
try {
|
try {
|
||||||
logger.info('Auto-starting Gateway...');
|
logger.debug('Auto-starting Gateway...');
|
||||||
await gatewayManager.start();
|
await gatewayManager.start();
|
||||||
logger.info('Gateway auto-start succeeded');
|
logger.info('Gateway auto-start succeeded');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -98,9 +98,7 @@ export function getOpenClawEntryPath(): string {
|
|||||||
export function isOpenClawPresent(): boolean {
|
export function isOpenClawPresent(): boolean {
|
||||||
const dir = getOpenClawDir();
|
const dir = getOpenClawDir();
|
||||||
const pkgJsonPath = join(dir, 'package.json');
|
const pkgJsonPath = join(dir, 'package.json');
|
||||||
const exists = existsSync(dir) && existsSync(pkgJsonPath);
|
return existsSync(dir) && existsSync(pkgJsonPath);
|
||||||
logger.debug(`isOpenClawPresent: dir=${dir}, exists=${exists}`);
|
|
||||||
return exists;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,12 +107,8 @@ export function isOpenClawPresent(): boolean {
|
|||||||
*/
|
*/
|
||||||
export function isOpenClawBuilt(): boolean {
|
export function isOpenClawBuilt(): boolean {
|
||||||
const dir = getOpenClawDir();
|
const dir = getOpenClawDir();
|
||||||
// Check for dist/entry.js or just the dist directory with JS files
|
|
||||||
const entryPath = join(dir, 'dist', 'entry.js');
|
|
||||||
const distDir = join(dir, 'dist');
|
const distDir = join(dir, 'dist');
|
||||||
const hasEntry = existsSync(entryPath);
|
|
||||||
const hasDist = existsSync(distDir);
|
const hasDist = existsSync(distDir);
|
||||||
logger.debug(`isOpenClawBuilt: distDir=${distDir}, hasDist=${hasDist}, hasEntry=${hasEntry}`);
|
|
||||||
return hasDist;
|
return hasDist;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,5 +104,5 @@
|
|||||||
"vitest": "^4.0.18",
|
"vitest": "^4.0.18",
|
||||||
"zx": "^8.8.5"
|
"zx": "^8.8.5"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.29.1+sha512.48dae233635a645768a3028d19545cacc1688639eeb1f3734e42d6d6b971afbf22aa1ac9af52a173d9c3a20c15857cfa400f19994d79a2f626fcc73fccda9bbc"
|
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8"
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Key,
|
Key,
|
||||||
Download,
|
Download,
|
||||||
Copy,
|
Copy,
|
||||||
|
FileText,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -59,6 +60,30 @@ export function Settings() {
|
|||||||
const isLinux = window.electron.platform === 'linux';
|
const isLinux = window.electron.platform === 'linux';
|
||||||
const isDev = window.electron.isDev;
|
const isDev = window.electron.isDev;
|
||||||
const showCliTools = isMac || isWindows || isLinux;
|
const showCliTools = isMac || isWindows || isLinux;
|
||||||
|
const [showLogs, setShowLogs] = useState(false);
|
||||||
|
const [logContent, setLogContent] = useState('');
|
||||||
|
|
||||||
|
const handleShowLogs = async () => {
|
||||||
|
try {
|
||||||
|
const logs = await window.electron.ipcRenderer.invoke('log:readFile', 100) as string;
|
||||||
|
setLogContent(logs);
|
||||||
|
setShowLogs(true);
|
||||||
|
} catch {
|
||||||
|
setLogContent('(Failed to load logs)');
|
||||||
|
setShowLogs(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenLogDir = async () => {
|
||||||
|
try {
|
||||||
|
const logDir = await window.electron.ipcRenderer.invoke('log:getDir') as string;
|
||||||
|
if (logDir) {
|
||||||
|
await window.electron.ipcRenderer.invoke('shell:showItemInFolder', logDir);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Open developer console
|
// Open developer console
|
||||||
const openDevConsole = async () => {
|
const openDevConsole = async () => {
|
||||||
@@ -275,9 +300,33 @@ export function Settings() {
|
|||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
Restart
|
Restart
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleShowLogs}>
|
||||||
|
<FileText className="h-4 w-4 mr-2" />
|
||||||
|
Logs
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showLogs && (
|
||||||
|
<div className="mt-4 p-4 rounded-lg bg-black/10 dark:bg-black/40 border border-border">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="font-medium text-sm">Application Logs</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={handleOpenLogDir}>
|
||||||
|
<ExternalLink className="h-3 w-3 mr-1" />
|
||||||
|
Open Folder
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setShowLogs(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs text-muted-foreground bg-background/50 p-3 rounded max-h-60 overflow-auto whitespace-pre-wrap font-mono">
|
||||||
|
{logContent || '(No logs available yet)'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
Reference in New Issue
Block a user