Optimize gateway comms reload behavior and strengthen regression coverage (#496)
This commit is contained in:
committed by
GitHub
Unverified
parent
08960d700f
commit
1dbe4a8466
@@ -12,7 +12,7 @@ import {
|
||||
validateChannelConfig,
|
||||
validateChannelCredentials,
|
||||
} from '../../utils/channel-config';
|
||||
import { clearAllBindingsForChannel } from '../../utils/agent-config';
|
||||
import { assignChannelToAgent, clearAllBindingsForChannel } from '../../utils/agent-config';
|
||||
import { whatsAppLoginManager } from '../../utils/whatsapp-login';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
@@ -25,6 +25,25 @@ function scheduleGatewayChannelRestart(ctx: HostApiContext, reason: string): voi
|
||||
void reason;
|
||||
}
|
||||
|
||||
const FORCE_RESTART_CHANNELS = new Set(['dingtalk', 'wecom', 'feishu', 'whatsapp']);
|
||||
|
||||
function scheduleGatewayChannelSaveRefresh(
|
||||
ctx: HostApiContext,
|
||||
channelType: string,
|
||||
reason: string,
|
||||
): void {
|
||||
if (ctx.gatewayManager.getStatus().state === 'stopped') {
|
||||
return;
|
||||
}
|
||||
if (FORCE_RESTART_CHANNELS.has(channelType)) {
|
||||
ctx.gatewayManager.debouncedRestart();
|
||||
void reason;
|
||||
return;
|
||||
}
|
||||
ctx.gatewayManager.debouncedReload();
|
||||
void reason;
|
||||
}
|
||||
|
||||
// ── Generic plugin installer with version-aware upgrades ─────────
|
||||
|
||||
function readPluginVersion(pkgJsonPath: string): string | null {
|
||||
@@ -119,6 +138,49 @@ function ensureQQBotPluginInstalled(): { installed: boolean; warning?: string }
|
||||
return ensurePluginInstalled('qqbot', buildCandidateSources('qqbot'), 'QQ Bot');
|
||||
}
|
||||
|
||||
function toComparableConfig(input: Record<string, unknown>): Record<string, string> {
|
||||
const next: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
if (typeof value === 'string') {
|
||||
next[key] = value.trim();
|
||||
continue;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
next[key] = String(value);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function isSameConfigValues(
|
||||
existing: Record<string, string> | undefined,
|
||||
incoming: Record<string, unknown>,
|
||||
): boolean {
|
||||
if (!existing) return false;
|
||||
const next = toComparableConfig(incoming);
|
||||
const keys = new Set([...Object.keys(existing), ...Object.keys(next)]);
|
||||
if (keys.size === 0) return false;
|
||||
for (const key of keys) {
|
||||
if ((existing[key] ?? '') !== (next[key] ?? '')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function inferAgentIdFromAccountId(accountId: string): string {
|
||||
if (accountId === 'default') return 'main';
|
||||
return accountId;
|
||||
}
|
||||
|
||||
async function ensureScopedChannelBinding(channelType: string, accountId?: string): Promise<void> {
|
||||
// Multi-agent safety: only bind when the caller explicitly scopes the account.
|
||||
// Global channel saves (no accountId) must not override routing to "main".
|
||||
if (!accountId) return;
|
||||
await assignChannelToAgent(inferAgentIdFromAccountId(accountId), channelType).catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function handleChannelRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
@@ -202,8 +264,15 @@ export async function handleChannelRoutes(
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const existingValues = await getChannelFormValues(body.channelType, body.accountId);
|
||||
if (isSameConfigValues(existingValues, body.config)) {
|
||||
await ensureScopedChannelBinding(body.channelType, body.accountId);
|
||||
sendJson(res, 200, { success: true, noChange: true });
|
||||
return true;
|
||||
}
|
||||
await saveChannelConfig(body.channelType, body.config, body.accountId);
|
||||
scheduleGatewayChannelRestart(ctx, `channel:saveConfig:${body.channelType}`);
|
||||
await ensureScopedChannelBinding(body.channelType, body.accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:saveConfig:${body.channelType}`);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
|
||||
@@ -25,6 +25,16 @@ import { logger } from '../../utils/logger';
|
||||
|
||||
const legacyProviderRoutesWarned = new Set<string>();
|
||||
|
||||
function hasObjectChanges<T extends Record<string, unknown>>(
|
||||
existing: T,
|
||||
patch: Partial<T> | undefined,
|
||||
): boolean {
|
||||
if (!patch) return false;
|
||||
const keys = Object.keys(patch) as Array<keyof T>;
|
||||
if (keys.length === 0) return false;
|
||||
return keys.some((key) => JSON.stringify(existing[key]) !== JSON.stringify(patch[key]));
|
||||
}
|
||||
|
||||
export async function handleProviderRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
@@ -70,6 +80,11 @@ export async function handleProviderRoutes(
|
||||
if (url.pathname === '/api/provider-accounts/default' && req.method === 'PUT') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ accountId: string }>(req);
|
||||
const currentDefault = await providerService.getDefaultAccountId();
|
||||
if (currentDefault === body.accountId) {
|
||||
sendJson(res, 200, { success: true, noChange: true });
|
||||
return true;
|
||||
}
|
||||
await providerService.setDefaultAccount(body.accountId);
|
||||
await syncDefaultProviderToRuntime(body.accountId, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
@@ -94,6 +109,11 @@ export async function handleProviderRoutes(
|
||||
sendJson(res, 404, { success: false, error: 'Provider account not found' });
|
||||
return true;
|
||||
}
|
||||
const hasPatchChanges = hasObjectChanges(existing as unknown as Record<string, unknown>, body.updates);
|
||||
if (!hasPatchChanges && body.apiKey === undefined) {
|
||||
sendJson(res, 200, { success: true, noChange: true, account: existing });
|
||||
return true;
|
||||
}
|
||||
const nextAccount = await providerService.updateAccount(accountId, body.updates, body.apiKey);
|
||||
await syncUpdatedProviderToRuntime(providerAccountToConfig(nextAccount), body.apiKey, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true, account: nextAccount });
|
||||
@@ -152,6 +172,11 @@ export async function handleProviderRoutes(
|
||||
logLegacyProviderRoute('PUT /api/providers/default');
|
||||
try {
|
||||
const body = await parseJsonBody<{ providerId: string }>(req);
|
||||
const currentDefault = await providerService.getDefaultLegacyProvider();
|
||||
if (currentDefault === body.providerId) {
|
||||
sendJson(res, 200, { success: true, noChange: true });
|
||||
return true;
|
||||
}
|
||||
await providerService.setDefaultLegacyProvider(body.providerId);
|
||||
await syncDefaultProviderToRuntime(body.providerId, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
@@ -280,6 +305,11 @@ export async function handleProviderRoutes(
|
||||
sendJson(res, 404, { success: false, error: 'Provider not found' });
|
||||
return true;
|
||||
}
|
||||
const hasPatchChanges = hasObjectChanges(existing as unknown as Record<string, unknown>, body.updates);
|
||||
if (!hasPatchChanges && body.apiKey === undefined) {
|
||||
sendJson(res, 200, { success: true, noChange: true });
|
||||
return true;
|
||||
}
|
||||
const nextConfig: ProviderConfig = { ...existing, ...body.updates, updatedAt: new Date().toISOString() };
|
||||
await providerService.saveLegacyProvider(nextConfig);
|
||||
if (body.apiKey !== undefined) {
|
||||
|
||||
@@ -17,18 +17,8 @@ export function dispatchProtocolEvent(
|
||||
emitter.emit('chat:message', { message: payload });
|
||||
break;
|
||||
case 'agent': {
|
||||
const p = payload as Record<string, unknown>;
|
||||
const data = (p.data && typeof p.data === 'object') ? p.data as Record<string, unknown> : {};
|
||||
const chatEvent: Record<string, unknown> = {
|
||||
...data,
|
||||
runId: p.runId ?? data.runId,
|
||||
sessionKey: p.sessionKey ?? data.sessionKey,
|
||||
state: p.state ?? data.state,
|
||||
message: p.message ?? data.message,
|
||||
};
|
||||
if (chatEvent.state || chatEvent.message) {
|
||||
emitter.emit('chat:message', { message: chatEvent });
|
||||
}
|
||||
// Keep "agent" on the canonical notification path to avoid double
|
||||
// handling in renderer when both notification and chat-message are wired.
|
||||
emitter.emit('notification', { method: event, params: payload });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import WebSocket from 'ws';
|
||||
import { PORTS } from '../utils/config';
|
||||
import { JsonRpcNotification, isNotification, isResponse } from './protocol';
|
||||
import { logger } from '../utils/logger';
|
||||
import { captureTelemetryEvent, trackMetric } from '../utils/telemetry';
|
||||
import {
|
||||
loadOrCreateDeviceIdentity,
|
||||
type DeviceIdentity,
|
||||
@@ -95,6 +96,10 @@ export class GatewayManager extends EventEmitter {
|
||||
private readonly restartController = new GatewayRestartController();
|
||||
private reloadDebounceTimer: NodeJS.Timeout | null = null;
|
||||
private externalShutdownSupported: boolean | null = null;
|
||||
private lastRestartAt = 0;
|
||||
private reconnectAttemptsTotal = 0;
|
||||
private reconnectSuccessTotal = 0;
|
||||
private static readonly RESTART_COOLDOWN_MS = 2500;
|
||||
|
||||
constructor(config?: Partial<ReconnectConfig>) {
|
||||
super();
|
||||
@@ -348,7 +353,18 @@ export class GatewayManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('Gateway restart requested');
|
||||
const now = Date.now();
|
||||
const sinceLastRestart = now - this.lastRestartAt;
|
||||
if (sinceLastRestart < GatewayManager.RESTART_COOLDOWN_MS) {
|
||||
logger.info(
|
||||
`Gateway restart skipped due to cooldown (${sinceLastRestart}ms < ${GatewayManager.RESTART_COOLDOWN_MS}ms)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const pidBefore = this.status.pid;
|
||||
logger.info(`[gateway-refresh] mode=restart requested pidBefore=${pidBefore ?? 'n/a'}`);
|
||||
this.lastRestartAt = now;
|
||||
this.restartInFlight = (async () => {
|
||||
await this.stop();
|
||||
await this.start();
|
||||
@@ -356,6 +372,9 @@ export class GatewayManager extends EventEmitter {
|
||||
|
||||
try {
|
||||
await this.restartInFlight;
|
||||
logger.info(
|
||||
`[gateway-refresh] mode=restart result=applied pidBefore=${pidBefore ?? 'n/a'} pidAfter=${this.status.pid ?? 'n/a'}`,
|
||||
);
|
||||
} finally {
|
||||
this.restartInFlight = null;
|
||||
this.restartController.flushDeferredRestart(
|
||||
@@ -405,13 +424,18 @@ export class GatewayManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
const pidBefore = this.process?.pid;
|
||||
logger.info(`[gateway-refresh] mode=reload requested pid=${pidBefore ?? 'n/a'} state=${this.status.state}`);
|
||||
|
||||
if (!this.process?.pid || this.status.state !== 'running') {
|
||||
logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=not_running');
|
||||
logger.warn('Gateway reload requested while not running; falling back to restart');
|
||||
await this.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=windows');
|
||||
logger.debug('Windows detected, falling back to Gateway restart for reload');
|
||||
await this.restart();
|
||||
return;
|
||||
@@ -423,6 +447,9 @@ export class GatewayManager extends EventEmitter {
|
||||
|
||||
// Avoid signaling a process that just came up; it will already read latest config.
|
||||
if (connectedForMs < 8000) {
|
||||
logger.info(
|
||||
`[gateway-refresh] mode=reload result=skipped_recent_connect connectedForMs=${connectedForMs} pid=${this.process.pid}`,
|
||||
);
|
||||
logger.info(`Gateway connected ${connectedForMs}ms ago, skipping reload signal`);
|
||||
return;
|
||||
}
|
||||
@@ -434,10 +461,17 @@ export class GatewayManager extends EventEmitter {
|
||||
// If process state doesn't recover quickly, fall back to restart.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
if (this.status.state !== 'running' || !this.process?.pid) {
|
||||
logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=post_signal_unhealthy');
|
||||
logger.warn('Gateway did not stay running after reload signal, falling back to restart');
|
||||
await this.restart();
|
||||
} else {
|
||||
const pidAfter = this.process.pid;
|
||||
logger.info(
|
||||
`[gateway-refresh] mode=reload result=applied_in_place pidBefore=${pidBefore} pidAfter=${pidAfter}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=signal_error');
|
||||
logger.warn('Gateway reload signal failed, falling back to restart:', error);
|
||||
await this.restart();
|
||||
}
|
||||
@@ -731,9 +765,11 @@ export class GatewayManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
const cooldownRemaining = Math.max(0, GatewayManager.RESTART_COOLDOWN_MS - (Date.now() - this.lastRestartAt));
|
||||
const { delay, nextAttempt, maxAttempts } = decision;
|
||||
const effectiveDelay = Math.max(delay, cooldownRemaining);
|
||||
this.reconnectAttempts = nextAttempt;
|
||||
logger.warn(`Scheduling Gateway reconnect attempt ${nextAttempt}/${maxAttempts} in ${delay}ms`);
|
||||
logger.warn(`Scheduling Gateway reconnect attempt ${nextAttempt}/${maxAttempts} in ${effectiveDelay}ms`);
|
||||
|
||||
this.setStatus({
|
||||
state: 'reconnecting',
|
||||
@@ -752,16 +788,58 @@ export class GatewayManager extends EventEmitter {
|
||||
logger.debug(`Skipping reconnect attempt: ${skipReason}`);
|
||||
return;
|
||||
}
|
||||
const attemptNo = this.reconnectAttempts;
|
||||
this.reconnectAttemptsTotal += 1;
|
||||
try {
|
||||
// Use the guarded start() flow so reconnect attempts cannot bypass
|
||||
// lifecycle locking and accidentally start duplicate Gateway processes.
|
||||
await this.start();
|
||||
this.reconnectSuccessTotal += 1;
|
||||
this.emitReconnectMetric('success', {
|
||||
attemptNo,
|
||||
maxAttempts,
|
||||
delayMs: effectiveDelay,
|
||||
});
|
||||
this.reconnectAttempts = 0;
|
||||
} catch (error) {
|
||||
logger.error('Gateway reconnection attempt failed:', error);
|
||||
this.emitReconnectMetric('failure', {
|
||||
attemptNo,
|
||||
maxAttempts,
|
||||
delayMs: effectiveDelay,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}, delay);
|
||||
}, effectiveDelay);
|
||||
}
|
||||
|
||||
private emitReconnectMetric(
|
||||
outcome: 'success' | 'failure',
|
||||
payload: {
|
||||
attemptNo: number;
|
||||
maxAttempts: number;
|
||||
delayMs: number;
|
||||
error?: string;
|
||||
},
|
||||
): void {
|
||||
const successRate = this.reconnectAttemptsTotal > 0
|
||||
? this.reconnectSuccessTotal / this.reconnectAttemptsTotal
|
||||
: 0;
|
||||
|
||||
const properties = {
|
||||
outcome,
|
||||
attemptNo: payload.attemptNo,
|
||||
maxAttempts: payload.maxAttempts,
|
||||
delayMs: payload.delayMs,
|
||||
gateway_reconnect_success_count: this.reconnectSuccessTotal,
|
||||
gateway_reconnect_attempt_count: this.reconnectAttemptsTotal,
|
||||
gateway_reconnect_success_rate: Number(successRate.toFixed(4)),
|
||||
...(payload.error ? { error: payload.error } : {}),
|
||||
};
|
||||
|
||||
trackMetric('gateway.reconnect', properties);
|
||||
captureTelemetryEvent('gateway_reconnect', properties);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -770,4 +848,4 @@ export class GatewayManager extends EventEmitter {
|
||||
private setStatus(update: Partial<GatewayStatus>): void {
|
||||
this.stateController.setStatus(update);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ export function classifyGatewayStderrMessage(message: string): GatewayStderrClas
|
||||
if (msg.includes('closed before connect') && msg.includes('token mismatch')) {
|
||||
return { level: 'drop', normalized: msg };
|
||||
}
|
||||
if (msg.includes('[ws] closed before connect') && msg.includes('code=1005')) {
|
||||
return { level: 'debug', normalized: msg };
|
||||
}
|
||||
if (msg.includes('security warning: dangerous config flags enabled')) {
|
||||
return { level: 'debug', normalized: msg };
|
||||
}
|
||||
|
||||
// Downgrade frequent non-fatal noise.
|
||||
if (msg.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg };
|
||||
|
||||
@@ -1159,13 +1159,18 @@ function registerGatewayHandlers(
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const response = await proxyAwareFetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
const response = await (async () => {
|
||||
try {
|
||||
return await proxyAwareFetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
})();
|
||||
|
||||
const contentType = (response.headers.get('content-type') || '').toLowerCase();
|
||||
if (contentType.includes('application/json')) {
|
||||
@@ -1350,6 +1355,8 @@ function registerGatewayHandlers(
|
||||
* For checking package status and channel configuration
|
||||
*/
|
||||
function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
|
||||
const forceRestartChannels = new Set(['dingtalk', 'wecom', 'feishu', 'whatsapp']);
|
||||
|
||||
const scheduleGatewayChannelRestart = (reason: string): void => {
|
||||
if (gatewayManager.getStatus().state !== 'stopped') {
|
||||
logger.info(`Scheduling Gateway restart after ${reason}`);
|
||||
@@ -1359,6 +1366,20 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleGatewayChannelSaveRefresh = (channelType: string, reason: string): void => {
|
||||
if (gatewayManager.getStatus().state === 'stopped') {
|
||||
logger.info(`Gateway is stopped; skip immediate refresh after ${reason}`);
|
||||
return;
|
||||
}
|
||||
if (forceRestartChannels.has(channelType)) {
|
||||
logger.info(`Scheduling Gateway restart after ${reason}`);
|
||||
gatewayManager.debouncedRestart();
|
||||
return;
|
||||
}
|
||||
logger.info(`Scheduling Gateway reload after ${reason}`);
|
||||
gatewayManager.debouncedReload();
|
||||
};
|
||||
|
||||
// ── Generic plugin installer with version-aware upgrades ─────────
|
||||
|
||||
function readPluginVersion(pkgJsonPath: string): string | null {
|
||||
@@ -1517,7 +1538,7 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
|
||||
};
|
||||
}
|
||||
await saveChannelConfig(channelType, config);
|
||||
scheduleGatewayChannelRestart(`channel:saveConfig (${channelType})`);
|
||||
scheduleGatewayChannelSaveRefresh(channelType, `channel:saveConfig (${channelType})`);
|
||||
return {
|
||||
success: true,
|
||||
pluginInstalled: installResult.installed,
|
||||
@@ -1533,7 +1554,7 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
|
||||
};
|
||||
}
|
||||
await saveChannelConfig(channelType, config);
|
||||
scheduleGatewayChannelRestart(`channel:saveConfig (${channelType})`);
|
||||
scheduleGatewayChannelSaveRefresh(channelType, `channel:saveConfig (${channelType})`);
|
||||
return {
|
||||
success: true,
|
||||
pluginInstalled: installResult.installed,
|
||||
@@ -1549,12 +1570,7 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
|
||||
};
|
||||
}
|
||||
await saveChannelConfig(channelType, config);
|
||||
if (gatewayManager.getStatus().state !== 'stopped') {
|
||||
logger.info(`Scheduling Gateway reload after channel:saveConfig (${channelType})`);
|
||||
gatewayManager.debouncedReload();
|
||||
} else {
|
||||
logger.info(`Gateway is stopped; skip immediate reload after channel:saveConfig (${channelType})`);
|
||||
}
|
||||
scheduleGatewayChannelSaveRefresh(channelType, `channel:saveConfig (${channelType})`);
|
||||
return {
|
||||
success: true,
|
||||
pluginInstalled: installResult.installed,
|
||||
@@ -1570,7 +1586,7 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
|
||||
};
|
||||
}
|
||||
await saveChannelConfig(channelType, config);
|
||||
scheduleGatewayChannelRestart(`channel:saveConfig (${channelType})`);
|
||||
scheduleGatewayChannelSaveRefresh(channelType, `channel:saveConfig (${channelType})`);
|
||||
return {
|
||||
success: true,
|
||||
pluginInstalled: installResult.installed,
|
||||
@@ -1578,7 +1594,7 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
|
||||
};
|
||||
}
|
||||
await saveChannelConfig(channelType, config);
|
||||
scheduleGatewayChannelRestart(`channel:saveConfig (${channelType})`);
|
||||
scheduleGatewayChannelSaveRefresh(channelType, `channel:saveConfig (${channelType})`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to save channel config:', error);
|
||||
@@ -1777,10 +1793,8 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
||||
};
|
||||
|
||||
// Listen for OAuth success to automatically restart the Gateway with new tokens/configs.
|
||||
// Use a longer debounce (8s) so that provider:setDefault — which writes the full config
|
||||
// and then calls debouncedRestart(2s) — has time to fire and coalesce into a single
|
||||
// restart. Without this, the OAuth restart fires first with stale config, and the
|
||||
// subsequent provider:setDefault restart is deferred and dropped.
|
||||
// Keep a longer debounce (8s) so provider config writes and OAuth token persistence
|
||||
// can settle before applying the process-level refresh.
|
||||
deviceOAuthManager.on('oauth:success', ({ provider, accountId }) => {
|
||||
logger.info(`[IPC] Scheduling Gateway restart after ${provider} OAuth success for ${accountId}...`);
|
||||
gatewayManager.debouncedRestart(8000);
|
||||
|
||||
@@ -160,10 +160,12 @@ export async function getProviderFallbackModelRefs(config: ProviderConfig): Prom
|
||||
return results;
|
||||
}
|
||||
|
||||
function scheduleGatewayRestart(
|
||||
type GatewayRefreshMode = 'reload' | 'restart';
|
||||
|
||||
function scheduleGatewayRefresh(
|
||||
gatewayManager: GatewayManager | undefined,
|
||||
message: string,
|
||||
options?: { delayMs?: number; onlyIfRunning?: boolean },
|
||||
options?: { delayMs?: number; onlyIfRunning?: boolean; mode?: GatewayRefreshMode },
|
||||
): void {
|
||||
if (!gatewayManager) {
|
||||
return;
|
||||
@@ -174,7 +176,11 @@ function scheduleGatewayRestart(
|
||||
}
|
||||
|
||||
logger.info(message);
|
||||
gatewayManager.debouncedRestart(options?.delayMs);
|
||||
if (options?.mode === 'restart') {
|
||||
gatewayManager.debouncedRestart(options?.delayMs);
|
||||
return;
|
||||
}
|
||||
gatewayManager.debouncedReload(options?.delayMs);
|
||||
}
|
||||
|
||||
export async function syncProviderApiKeyToRuntime(
|
||||
@@ -340,9 +346,9 @@ export async function syncSavedProviderToRuntime(
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleGatewayRestart(
|
||||
scheduleGatewayRefresh(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway restart after saving provider "${context.runtimeProviderKey}" config`,
|
||||
`Scheduling Gateway reload after saving provider "${context.runtimeProviderKey}" config`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -381,9 +387,9 @@ export async function syncUpdatedProviderToRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
scheduleGatewayRestart(
|
||||
scheduleGatewayRefresh(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway restart after updating provider "${ock}" config`,
|
||||
`Scheduling Gateway reload after updating provider "${ock}" config`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -400,9 +406,10 @@ export async function syncDeletedProviderToRuntime(
|
||||
const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId });
|
||||
await removeProviderFromOpenClaw(ock);
|
||||
|
||||
scheduleGatewayRestart(
|
||||
scheduleGatewayRefresh(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway restart after deleting provider "${ock}"`,
|
||||
{ mode: 'restart' },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -487,9 +494,9 @@ export async function syncDefaultProviderToRuntime(
|
||||
|
||||
await setOpenClawDefaultModel(browserOAuthRuntimeProvider, modelOverride, fallbackModels);
|
||||
logger.info(`Configured openclaw.json for browser OAuth provider "${provider.id}"`);
|
||||
scheduleGatewayRestart(
|
||||
scheduleGatewayRefresh(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway restart after provider switch to "${browserOAuthRuntimeProvider}"`,
|
||||
`Scheduling Gateway reload after provider switch to "${browserOAuthRuntimeProvider}"`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -548,9 +555,9 @@ export async function syncDefaultProviderToRuntime(
|
||||
});
|
||||
}
|
||||
|
||||
scheduleGatewayRestart(
|
||||
scheduleGatewayRefresh(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway restart after provider switch to "${ock}"`,
|
||||
`Scheduling Gateway reload after provider switch to "${ock}"`,
|
||||
{ onlyIfRunning: true },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { app } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { existsSync, mkdirSync, appendFileSync } from 'fs';
|
||||
import { appendFile, readFile, readdir, stat } from 'fs/promises';
|
||||
import { appendFile, open, readdir, stat } from 'fs/promises';
|
||||
|
||||
/**
|
||||
* Log levels
|
||||
@@ -230,11 +230,33 @@ export function getRecentLogs(count?: number, minLevel?: LogLevel): string[] {
|
||||
*/
|
||||
export async function readLogFile(tailLines = 200): Promise<string> {
|
||||
if (!logFilePath) return '(No log file found)';
|
||||
const safeTailLines = Math.max(1, Math.floor(tailLines));
|
||||
try {
|
||||
const content = await readFile(logFilePath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
if (lines.length <= tailLines) return content;
|
||||
return lines.slice(-tailLines).join('\n');
|
||||
const file = await open(logFilePath, 'r');
|
||||
try {
|
||||
const fileStat = await file.stat();
|
||||
if (fileStat.size === 0) return '';
|
||||
|
||||
const chunkSize = 64 * 1024;
|
||||
let position = fileStat.size;
|
||||
let content = '';
|
||||
let lineCount = 0;
|
||||
|
||||
while (position > 0 && lineCount <= safeTailLines) {
|
||||
const bytesToRead = Math.min(chunkSize, position);
|
||||
position -= bytesToRead;
|
||||
const buffer = Buffer.allocUnsafe(bytesToRead);
|
||||
await file.read(buffer, 0, bytesToRead, position);
|
||||
content = `${buffer.toString('utf-8')}${content}`;
|
||||
lineCount = content.split('\n').length - 1;
|
||||
}
|
||||
|
||||
const lines = content.split('\n');
|
||||
if (lines.length <= safeTailLines) return content;
|
||||
return lines.slice(-safeTailLines).join('\n');
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
} catch (err) {
|
||||
return `(Failed to read log file: ${err})`;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,15 @@ const TELEMETRY_SHUTDOWN_TIMEOUT_MS = 1500;
|
||||
let posthogClient: PostHog | null = null;
|
||||
let distinctId: string = '';
|
||||
|
||||
function getCommonProperties(): Record<string, string> {
|
||||
return {
|
||||
$app_version: app.getVersion(),
|
||||
$os: process.platform,
|
||||
os_tag: process.platform,
|
||||
arch: process.arch,
|
||||
};
|
||||
}
|
||||
|
||||
function isIgnorablePostHogShutdownError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
@@ -54,12 +63,7 @@ export async function initTelemetry(): Promise<void> {
|
||||
logger.debug(`Generated new machine ID for telemetry: ${distinctId}`);
|
||||
}
|
||||
|
||||
// Common properties for all events
|
||||
const properties = {
|
||||
$app_version: app.getVersion(),
|
||||
$os: process.platform,
|
||||
arch: process.arch,
|
||||
};
|
||||
const properties = getCommonProperties();
|
||||
|
||||
// Check if this is a new installation
|
||||
const hasReportedInstall = await getSetting('hasReportedInstall');
|
||||
@@ -86,6 +90,29 @@ export async function initTelemetry(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export function trackMetric(event: string, properties: Record<string, unknown> = {}): void {
|
||||
logger.info(`[metric] ${event}`, properties);
|
||||
}
|
||||
|
||||
export function captureTelemetryEvent(event: string, properties: Record<string, unknown> = {}): void {
|
||||
if (!posthogClient || !distinctId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
posthogClient.capture({
|
||||
distinctId,
|
||||
event,
|
||||
properties: {
|
||||
...getCommonProperties(),
|
||||
...properties,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to capture telemetry event "${event}":`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort telemetry shutdown that never blocks app exit on network issues.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user