Stabilize channels UX, reload flow, and i18n consistency
This commit is contained in:
@@ -220,6 +220,7 @@ export class GatewayManager extends EventEmitter {
|
||||
}> = new Map();
|
||||
private deviceIdentity: DeviceIdentity | null = null;
|
||||
private restartDebounceTimer: NodeJS.Timeout | null = null;
|
||||
private reloadDebounceTimer: NodeJS.Timeout | null = null;
|
||||
private lifecycleEpoch = 0;
|
||||
private deferredRestartPending = false;
|
||||
private restartInFlight: Promise<void> | null = null;
|
||||
@@ -640,6 +641,71 @@ export class GatewayManager extends EventEmitter {
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the Gateway process to reload config in-place when possible.
|
||||
* Falls back to restart on unsupported platforms or signaling failures.
|
||||
*/
|
||||
async reload(): Promise<void> {
|
||||
if (this.isRestartDeferred()) {
|
||||
this.markDeferredRestart('reload');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.process?.pid || this.status.state !== 'running') {
|
||||
logger.warn('Gateway reload requested while not running; falling back to restart');
|
||||
await this.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
logger.debug('Windows detected, falling back to Gateway restart for reload');
|
||||
await this.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
const connectedForMs = this.status.connectedAt
|
||||
? Date.now() - this.status.connectedAt
|
||||
: Number.POSITIVE_INFINITY;
|
||||
|
||||
// Avoid signaling a process that just came up; it will already read latest config.
|
||||
if (connectedForMs < 8000) {
|
||||
logger.info(`Gateway connected ${connectedForMs}ms ago, skipping reload signal`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(this.process.pid, 'SIGUSR1');
|
||||
logger.info(`Sent SIGUSR1 to Gateway for config reload (pid=${this.process.pid})`);
|
||||
// Some gateway builds do not handle SIGUSR1 as an in-process reload.
|
||||
// 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 did not stay running after reload signal, falling back to restart');
|
||||
await this.restart();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Gateway reload signal failed, falling back to restart:', error);
|
||||
await this.restart();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced reload — coalesces multiple rapid config-change events into one
|
||||
* in-process reload when possible.
|
||||
*/
|
||||
debouncedReload(delayMs = 1200): void {
|
||||
if (this.reloadDebounceTimer) {
|
||||
clearTimeout(this.reloadDebounceTimer);
|
||||
}
|
||||
logger.debug(`Gateway reload debounced (will fire in ${delayMs}ms)`);
|
||||
this.reloadDebounceTimer = setTimeout(() => {
|
||||
this.reloadDebounceTimer = null;
|
||||
void this.reload().catch((err) => {
|
||||
logger.warn('Debounced Gateway reload failed:', err);
|
||||
});
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all active timers
|
||||
*/
|
||||
@@ -660,6 +726,10 @@ export class GatewayManager extends EventEmitter {
|
||||
clearTimeout(this.restartDebounceTimer);
|
||||
this.restartDebounceTimer = null;
|
||||
}
|
||||
if (this.reloadDebounceTimer) {
|
||||
clearTimeout(this.reloadDebounceTimer);
|
||||
this.reloadDebounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -160,7 +160,7 @@ export function registerIpcHandlers(
|
||||
registerClawHubHandlers(clawHubService);
|
||||
|
||||
// OpenClaw handlers
|
||||
registerOpenClawHandlers();
|
||||
registerOpenClawHandlers(gatewayManager);
|
||||
|
||||
// Provider handlers
|
||||
registerProviderHandlers(gatewayManager);
|
||||
@@ -1486,7 +1486,7 @@ function registerGatewayHandlers(
|
||||
* OpenClaw-related IPC handlers
|
||||
* For checking package status and channel configuration
|
||||
*/
|
||||
function registerOpenClawHandlers(): void {
|
||||
function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
|
||||
async function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
const targetDir = join(homedir(), '.openclaw', 'extensions', 'dingtalk');
|
||||
const targetManifest = join(targetDir, 'openclaw.plugin.json');
|
||||
@@ -1599,9 +1599,12 @@ function registerOpenClawHandlers(): void {
|
||||
};
|
||||
}
|
||||
await saveChannelConfig(channelType, config);
|
||||
logger.info(
|
||||
`Skipping app-forced Gateway restart after channel:saveConfig (${channelType}); Gateway handles channel config reload/restart internally`
|
||||
);
|
||||
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})`);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
pluginInstalled: installResult.installed,
|
||||
@@ -1609,12 +1612,12 @@ function registerOpenClawHandlers(): void {
|
||||
};
|
||||
}
|
||||
await saveChannelConfig(channelType, config);
|
||||
// Do not force stop/start here. Recent Gateway builds detect channel config
|
||||
// changes and perform an internal service restart; forcing another restart
|
||||
// from Electron can race with reconnect and kill the newly spawned process.
|
||||
logger.info(
|
||||
`Skipping app-forced Gateway restart after channel:saveConfig (${channelType}); waiting for Gateway internal channel reload`
|
||||
);
|
||||
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})`);
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to save channel config:', error);
|
||||
@@ -1648,6 +1651,12 @@ function registerOpenClawHandlers(): void {
|
||||
ipcMain.handle('channel:deleteConfig', async (_, channelType: string) => {
|
||||
try {
|
||||
await deleteChannelConfig(channelType);
|
||||
if (gatewayManager.getStatus().state !== 'stopped') {
|
||||
logger.info(`Scheduling Gateway reload after channel:deleteConfig (${channelType})`);
|
||||
gatewayManager.debouncedReload();
|
||||
} else {
|
||||
logger.info(`Gateway is stopped; skip immediate reload after channel:deleteConfig (${channelType})`);
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to delete channel config:', error);
|
||||
@@ -1670,6 +1679,12 @@ function registerOpenClawHandlers(): void {
|
||||
ipcMain.handle('channel:setEnabled', async (_, channelType: string, enabled: boolean) => {
|
||||
try {
|
||||
await setChannelEnabled(channelType, enabled);
|
||||
if (gatewayManager.getStatus().state !== 'stopped') {
|
||||
logger.info(`Scheduling Gateway reload after channel:setEnabled (${channelType}, enabled=${enabled})`);
|
||||
gatewayManager.debouncedReload();
|
||||
} else {
|
||||
logger.info(`Gateway is stopped; skip immediate reload after channel:setEnabled (${channelType})`);
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to set channel enabled:', error);
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface PluginsConfig {
|
||||
export interface OpenClawConfig {
|
||||
channels?: Record<string, ChannelConfigData>;
|
||||
plugins?: PluginsConfig;
|
||||
commands?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -71,6 +72,14 @@ export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void>
|
||||
await ensureConfigDir();
|
||||
|
||||
try {
|
||||
// Enable graceful in-process reload authorization for SIGUSR1 flows.
|
||||
const commands =
|
||||
config.commands && typeof config.commands === 'object'
|
||||
? { ...(config.commands as Record<string, unknown>) }
|
||||
: {};
|
||||
commands.restart = true;
|
||||
config.commands = commands;
|
||||
|
||||
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
||||
} catch (error) {
|
||||
logger.error('Failed to write OpenClaw config', error);
|
||||
|
||||
@@ -131,6 +131,15 @@ async function readOpenClawJson(): Promise<Record<string, unknown>> {
|
||||
}
|
||||
|
||||
async function writeOpenClawJson(config: Record<string, unknown>): Promise<void> {
|
||||
// Ensure SIGUSR1 graceful reload is authorized by OpenClaw config.
|
||||
const commands = (
|
||||
config.commands && typeof config.commands === 'object'
|
||||
? { ...(config.commands as Record<string, unknown>) }
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
commands.restart = true;
|
||||
config.commands = commands;
|
||||
|
||||
await writeJsonFile(OPENCLAW_CONFIG_PATH, config);
|
||||
}
|
||||
|
||||
@@ -781,6 +790,20 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── commands section ───────────────────────────────────────────
|
||||
// Required for SIGUSR1 in-process reload authorization.
|
||||
const commands = (
|
||||
config.commands && typeof config.commands === 'object'
|
||||
? { ...(config.commands as Record<string, unknown>) }
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
if (commands.restart !== true) {
|
||||
commands.restart = true;
|
||||
config.commands = commands;
|
||||
modified = true;
|
||||
console.log('[sanitize] Enabling commands.restart for graceful reload support');
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
await writeOpenClawJson(config);
|
||||
console.log('[sanitize] openclaw.json sanitized successfully');
|
||||
|
||||
Reference in New Issue
Block a user