feat(gateway): enhance gateway readiness handling and batch sync configuration (#851)
Co-authored-by: paisley <8197966+su8su@users.noreply.github.com>
This commit is contained in:
@@ -64,6 +64,7 @@ import {
|
|||||||
normalizeSlackMessagingTarget,
|
normalizeSlackMessagingTarget,
|
||||||
normalizeWhatsAppMessagingTarget,
|
normalizeWhatsAppMessagingTarget,
|
||||||
} from '../../utils/openclaw-sdk';
|
} from '../../utils/openclaw-sdk';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
// listWhatsAppDirectory*FromConfig were removed from openclaw's public exports
|
// listWhatsAppDirectory*FromConfig were removed from openclaw's public exports
|
||||||
// in 2026.3.23-1. No-op stubs; WhatsApp target picker uses session discovery.
|
// in 2026.3.23-1. No-op stubs; WhatsApp target picker uses session discovery.
|
||||||
@@ -263,11 +264,11 @@ function scheduleGatewayChannelSaveRefresh(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (FORCE_RESTART_CHANNELS.has(storedChannelType)) {
|
if (FORCE_RESTART_CHANNELS.has(storedChannelType)) {
|
||||||
ctx.gatewayManager.debouncedRestart();
|
ctx.gatewayManager.debouncedRestart(150);
|
||||||
void reason;
|
void reason;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ctx.gatewayManager.debouncedReload();
|
ctx.gatewayManager.debouncedReload(150);
|
||||||
void reason;
|
void reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,6 +417,28 @@ interface ChannelAccountsView {
|
|||||||
accounts: ChannelAccountView[];
|
accounts: ChannelAccountView[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildGatewayStatusSnapshot(status: GatewayChannelStatusPayload | null): string {
|
||||||
|
if (!status?.channelAccounts) return 'none';
|
||||||
|
const entries = Object.entries(status.channelAccounts);
|
||||||
|
if (entries.length === 0) return 'empty';
|
||||||
|
return entries
|
||||||
|
.slice(0, 12)
|
||||||
|
.map(([channelType, accounts]) => {
|
||||||
|
const channelStatus = pickChannelRuntimeStatus(accounts);
|
||||||
|
const flags = accounts.slice(0, 4).map((account) => {
|
||||||
|
const accountId = typeof account.accountId === 'string' ? account.accountId : 'default';
|
||||||
|
const connected = account.connected === true ? '1' : '0';
|
||||||
|
const running = account.running === true ? '1' : '0';
|
||||||
|
const linked = account.linked === true ? '1' : '0';
|
||||||
|
const probeOk = account.probe?.ok === true ? '1' : '0';
|
||||||
|
const hasErr = typeof account.lastError === 'string' && account.lastError.trim().length > 0 ? '1' : '0';
|
||||||
|
return `${accountId}[c${connected}r${running}l${linked}p${probeOk}e${hasErr}]`;
|
||||||
|
}).join('|');
|
||||||
|
return `${channelType}:${channelStatus}{${flags}}`;
|
||||||
|
})
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
function shouldIncludeRuntimeAccountId(
|
function shouldIncludeRuntimeAccountId(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
configuredAccountIds: Set<string>,
|
configuredAccountIds: Set<string>,
|
||||||
@@ -458,7 +481,11 @@ const CHANNEL_TARGET_CACHE_TTL_MS = 60_000;
|
|||||||
const CHANNEL_TARGET_CACHE_ENABLED = process.env.VITEST !== 'true';
|
const CHANNEL_TARGET_CACHE_ENABLED = process.env.VITEST !== 'true';
|
||||||
const channelTargetCache = new Map<string, { expiresAt: number; targets: ChannelTargetOptionView[] }>();
|
const channelTargetCache = new Map<string, { expiresAt: number; targets: ChannelTargetOptionView[] }>();
|
||||||
|
|
||||||
async function buildChannelAccountsView(ctx: HostApiContext): Promise<ChannelAccountsView[]> {
|
async function buildChannelAccountsView(
|
||||||
|
ctx: HostApiContext,
|
||||||
|
options?: { probe?: boolean },
|
||||||
|
): Promise<ChannelAccountsView[]> {
|
||||||
|
const startedAt = Date.now();
|
||||||
// Read config once and share across all sub-calls (was 5 readFile calls before).
|
// Read config once and share across all sub-calls (was 5 readFile calls before).
|
||||||
const openClawConfig = await readOpenClawConfig();
|
const openClawConfig = await readOpenClawConfig();
|
||||||
|
|
||||||
@@ -470,11 +497,24 @@ async function buildChannelAccountsView(ctx: HostApiContext): Promise<ChannelAcc
|
|||||||
|
|
||||||
let gatewayStatus: GatewayChannelStatusPayload | null;
|
let gatewayStatus: GatewayChannelStatusPayload | null;
|
||||||
try {
|
try {
|
||||||
// probe: false — use cached runtime state instead of active network probes
|
// probe=false uses cached runtime state (lighter); probe=true forces
|
||||||
// per channel. Real-time status updates arrive via channel.status events.
|
// adapter-level connectivity checks for faster post-restart convergence.
|
||||||
|
const probe = options?.probe === true;
|
||||||
// 8s timeout — fail fast when Gateway is busy with AI tasks.
|
// 8s timeout — fail fast when Gateway is busy with AI tasks.
|
||||||
gatewayStatus = await ctx.gatewayManager.rpc<GatewayChannelStatusPayload>('channels.status', { probe: false }, 8000);
|
const rpcStartedAt = Date.now();
|
||||||
|
gatewayStatus = await ctx.gatewayManager.rpc<GatewayChannelStatusPayload>(
|
||||||
|
'channels.status',
|
||||||
|
{ probe },
|
||||||
|
probe ? 5000 : 8000,
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
`[channels.accounts] channels.status probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - rpcStartedAt} snapshot=${buildGatewayStatusSnapshot(gatewayStatus)}`
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
|
const probe = options?.probe === true;
|
||||||
|
logger.warn(
|
||||||
|
`[channels.accounts] channels.status probe=${probe ? '1' : '0'} failed after ${Date.now() - startedAt}ms`
|
||||||
|
);
|
||||||
gatewayStatus = null;
|
gatewayStatus = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,7 +593,11 @@ async function buildChannelAccountsView(ctx: HostApiContext): Promise<ChannelAcc
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return channels.sort((left, right) => left.channelType.localeCompare(right.channelType));
|
const sorted = channels.sort((left, right) => left.channelType.localeCompare(right.channelType));
|
||||||
|
logger.info(
|
||||||
|
`[channels.accounts] response probe=${options?.probe === true ? '1' : '0'} elapsedMs=${Date.now() - startedAt} view=${sorted.map((item) => `${item.channelType}:${item.status}`).join(',')}`
|
||||||
|
);
|
||||||
|
return sorted;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildChannelTargetLabel(baseLabel: string, value: string): string {
|
function buildChannelTargetLabel(baseLabel: string, value: string): string {
|
||||||
@@ -1147,7 +1191,9 @@ export async function handleChannelRoutes(
|
|||||||
|
|
||||||
if (url.pathname === '/api/channels/accounts' && req.method === 'GET') {
|
if (url.pathname === '/api/channels/accounts' && req.method === 'GET') {
|
||||||
try {
|
try {
|
||||||
const channels = await buildChannelAccountsView(ctx);
|
const probe = url.searchParams.get('probe') === '1';
|
||||||
|
logger.info(`[channels.accounts] request probe=${probe ? '1' : '0'}`);
|
||||||
|
const channels = await buildChannelAccountsView(ctx, { probe });
|
||||||
sendJson(res, 200, { success: true, channels });
|
sendJson(res, 200, { success: true, channels });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendJson(res, 500, { success: false, error: String(error) });
|
sendJson(res, 500, { success: false, error: String(error) });
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-stor
|
|||||||
import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry';
|
import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry';
|
||||||
import { getOpenClawDir, getOpenClawEntryPath, isOpenClawPresent } from '../utils/paths';
|
import { getOpenClawDir, getOpenClawEntryPath, isOpenClawPresent } from '../utils/paths';
|
||||||
import { getUvMirrorEnv } from '../utils/uv-env';
|
import { getUvMirrorEnv } from '../utils/uv-env';
|
||||||
import { cleanupDanglingWeChatPluginState, listConfiguredChannels, readOpenClawConfig } from '../utils/channel-config';
|
import { cleanupDanglingWeChatPluginState, listConfiguredChannelsFromConfig, readOpenClawConfig } from '../utils/channel-config';
|
||||||
import { syncGatewayTokenToConfig, syncBrowserConfigToOpenClaw, syncSessionIdleMinutesToOpenClaw, sanitizeOpenClawConfig } from '../utils/openclaw-auth';
|
import { sanitizeOpenClawConfig, batchSyncConfigFields } from '../utils/openclaw-auth';
|
||||||
import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
|
import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
|
||||||
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
|
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
@@ -180,7 +180,20 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): void {
|
|||||||
* resolution algorithm find them. Skip-if-exists avoids overwriting
|
* resolution algorithm find them. Skip-if-exists avoids overwriting
|
||||||
* openclaw's own deps (they take priority).
|
* openclaw's own deps (they take priority).
|
||||||
*/
|
*/
|
||||||
|
let _extensionDepsLinked = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the extension-deps-linked cache so the next
|
||||||
|
* ensureExtensionDepsResolvable() call re-scans and links.
|
||||||
|
* Called before each Gateway launch to pick up newly installed extensions.
|
||||||
|
*/
|
||||||
|
export function resetExtensionDepsLinked(): void {
|
||||||
|
_extensionDepsLinked = false;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureExtensionDepsResolvable(openclawDir: string): void {
|
function ensureExtensionDepsResolvable(openclawDir: string): void {
|
||||||
|
if (_extensionDepsLinked) return;
|
||||||
|
|
||||||
const extDir = join(openclawDir, 'dist', 'extensions');
|
const extDir = join(openclawDir, 'dist', 'extensions');
|
||||||
const topNM = join(openclawDir, 'node_modules');
|
const topNM = join(openclawDir, 'node_modules');
|
||||||
let linkedCount = 0;
|
let linkedCount = 0;
|
||||||
@@ -229,6 +242,8 @@ function ensureExtensionDepsResolvable(openclawDir: string): void {
|
|||||||
if (linkedCount > 0) {
|
if (linkedCount > 0) {
|
||||||
logger.info(`[extension-deps] Linked ${linkedCount} extension packages into ${topNM}`);
|
logger.info(`[extension-deps] Linked ${linkedCount} extension packages into ${topNM}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_extensionDepsLinked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pre-launch sync ──────────────────────────────────────────────
|
// ── Pre-launch sync ──────────────────────────────────────────────
|
||||||
@@ -236,6 +251,11 @@ function ensureExtensionDepsResolvable(openclawDir: string): void {
|
|||||||
export async function syncGatewayConfigBeforeLaunch(
|
export async function syncGatewayConfigBeforeLaunch(
|
||||||
appSettings: Awaited<ReturnType<typeof getAllSettings>>,
|
appSettings: Awaited<ReturnType<typeof getAllSettings>>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// Reset the extension-deps cache so that newly installed extensions
|
||||||
|
// (e.g. user added a channel while the app was running) get their
|
||||||
|
// node_modules linked on the next Gateway spawn.
|
||||||
|
resetExtensionDepsLinked();
|
||||||
|
|
||||||
await syncProxyConfigToOpenClaw(appSettings, { preserveExistingWhenDisabled: true });
|
await syncProxyConfigToOpenClaw(appSettings, { preserveExistingWhenDisabled: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -260,21 +280,20 @@ export async function syncGatewayConfigBeforeLaunch(
|
|||||||
|
|
||||||
// Auto-upgrade installed plugins before Gateway starts so that
|
// Auto-upgrade installed plugins before Gateway starts so that
|
||||||
// the plugin manifest ID matches what sanitize wrote to the config.
|
// the plugin manifest ID matches what sanitize wrote to the config.
|
||||||
|
// Read config once and reuse for both listConfiguredChannels and plugins.allow.
|
||||||
try {
|
try {
|
||||||
const configuredChannels = await listConfiguredChannels();
|
const rawCfg = await readOpenClawConfig();
|
||||||
|
const configuredChannels = await listConfiguredChannelsFromConfig(rawCfg);
|
||||||
|
|
||||||
// Also ensure plugins referenced in plugins.allow are installed even if
|
// Also ensure plugins referenced in plugins.allow are installed even if
|
||||||
// they have no channels.X section yet (e.g. qqbot added via plugins.allow
|
// they have no channels.X section yet (e.g. qqbot added via plugins.allow
|
||||||
// but never fully saved through ClawX UI).
|
// but never fully saved through ClawX UI).
|
||||||
try {
|
try {
|
||||||
const rawCfg = await readOpenClawConfig();
|
|
||||||
const allowList = Array.isArray(rawCfg.plugins?.allow) ? (rawCfg.plugins!.allow as string[]) : [];
|
const allowList = Array.isArray(rawCfg.plugins?.allow) ? (rawCfg.plugins!.allow as string[]) : [];
|
||||||
// Build reverse maps: dirName → channelType AND known manifest IDs → channelType
|
|
||||||
const pluginIdToChannel: Record<string, string> = {};
|
const pluginIdToChannel: Record<string, string> = {};
|
||||||
for (const [channelType, info] of Object.entries(CHANNEL_PLUGIN_MAP)) {
|
for (const [channelType, info] of Object.entries(CHANNEL_PLUGIN_MAP)) {
|
||||||
pluginIdToChannel[info.dirName] = channelType;
|
pluginIdToChannel[info.dirName] = channelType;
|
||||||
}
|
}
|
||||||
// Known manifest IDs that differ from their dirName/channelType
|
|
||||||
|
|
||||||
pluginIdToChannel['openclaw-lark'] = 'feishu';
|
pluginIdToChannel['openclaw-lark'] = 'feishu';
|
||||||
pluginIdToChannel['feishu-openclaw-plugin'] = 'feishu';
|
pluginIdToChannel['feishu-openclaw-plugin'] = 'feishu';
|
||||||
@@ -295,22 +314,11 @@ export async function syncGatewayConfigBeforeLaunch(
|
|||||||
logger.warn('Failed to auto-upgrade plugins:', err);
|
logger.warn('Failed to auto-upgrade plugins:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Batch gateway token, browser config, and session idle into one read+write cycle.
|
||||||
try {
|
try {
|
||||||
await syncGatewayTokenToConfig(appSettings.gatewayToken);
|
await batchSyncConfigFields(appSettings.gatewayToken);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to sync gateway token to openclaw.json:', err);
|
logger.warn('Failed to batch-sync config fields to openclaw.json:', err);
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await syncBrowserConfigToOpenClaw();
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Failed to sync browser config to openclaw.json:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await syncSessionIdleMinutesToOpenClaw();
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Failed to sync session idle minutes to openclaw.json:', err);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +368,8 @@ async function resolveChannelStartupPolicy(): Promise<{
|
|||||||
channelStartupSummary: string;
|
channelStartupSummary: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const configuredChannels = await listConfiguredChannels();
|
const rawCfg = await readOpenClawConfig();
|
||||||
|
const configuredChannels = await listConfiguredChannelsFromConfig(rawCfg);
|
||||||
if (configuredChannels.length === 0) {
|
if (configuredChannels.length === 0) {
|
||||||
return {
|
return {
|
||||||
skipChannels: true,
|
skipChannels: true,
|
||||||
|
|||||||
@@ -23,8 +23,13 @@ export function dispatchProtocolEvent(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'channel.status':
|
case 'channel.status':
|
||||||
|
case 'channel.status_changed':
|
||||||
emitter.emit('channel:status', payload as { channelId: string; status: string });
|
emitter.emit('channel:status', payload as { channelId: string; status: string });
|
||||||
break;
|
break;
|
||||||
|
case 'gateway.ready':
|
||||||
|
case 'ready':
|
||||||
|
emitter.emit('gateway:ready', payload);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
emitter.emit('notification', { method: event, params: payload });
|
emitter.emit('notification', { method: event, params: payload });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export interface GatewayStatus {
|
|||||||
connectedAt?: number;
|
connectedAt?: number;
|
||||||
version?: string;
|
version?: string;
|
||||||
reconnectAttempts?: number;
|
reconnectAttempts?: number;
|
||||||
|
/** True once the gateway's internal subsystems (skills, plugins) are ready for RPC calls. */
|
||||||
|
gatewayReady?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,9 +121,11 @@ export class GatewayManager extends EventEmitter {
|
|||||||
private static readonly HEARTBEAT_TIMEOUT_MS_WIN = 25_000;
|
private static readonly HEARTBEAT_TIMEOUT_MS_WIN = 25_000;
|
||||||
private static readonly HEARTBEAT_MAX_MISSES_WIN = 5;
|
private static readonly HEARTBEAT_MAX_MISSES_WIN = 5;
|
||||||
public static readonly RESTART_COOLDOWN_MS = 5_000;
|
public static readonly RESTART_COOLDOWN_MS = 5_000;
|
||||||
|
private static readonly GATEWAY_READY_FALLBACK_MS = 30_000;
|
||||||
private lastRestartAt = 0;
|
private lastRestartAt = 0;
|
||||||
/** Set by scheduleReconnect() before calling start() to signal auto-reconnect. */
|
/** Set by scheduleReconnect() before calling start() to signal auto-reconnect. */
|
||||||
private isAutoReconnectStart = false;
|
private isAutoReconnectStart = false;
|
||||||
|
private gatewayReadyFallbackTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
constructor(config?: Partial<ReconnectConfig>) {
|
constructor(config?: Partial<ReconnectConfig>) {
|
||||||
super();
|
super();
|
||||||
@@ -152,6 +156,14 @@ export class GatewayManager extends EventEmitter {
|
|||||||
this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...config };
|
this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...config };
|
||||||
// Device identity is loaded lazily in start() — not in the constructor —
|
// Device identity is loaded lazily in start() — not in the constructor —
|
||||||
// so that async file I/O and key generation don't block module loading.
|
// so that async file I/O and key generation don't block module loading.
|
||||||
|
|
||||||
|
this.on('gateway:ready', () => {
|
||||||
|
this.clearGatewayReadyFallback();
|
||||||
|
if (this.status.state === 'running' && !this.status.gatewayReady) {
|
||||||
|
logger.info('Gateway subsystems ready (event received)');
|
||||||
|
this.setStatus({ gatewayReady: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async initDeviceIdentity(): Promise<void> {
|
private async initDeviceIdentity(): Promise<void> {
|
||||||
@@ -231,12 +243,16 @@ export class GatewayManager extends EventEmitter {
|
|||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
}
|
}
|
||||||
this.isAutoReconnectStart = false; // consume the flag
|
this.isAutoReconnectStart = false; // consume the flag
|
||||||
this.setStatus({ state: 'starting', reconnectAttempts: this.reconnectAttempts });
|
this.setStatus({ state: 'starting', reconnectAttempts: this.reconnectAttempts, gatewayReady: false });
|
||||||
|
|
||||||
// Check if Python environment is ready (self-healing) asynchronously.
|
// Check if Python environment is ready (self-healing) asynchronously.
|
||||||
// Fire-and-forget: only needs to run once, not on every retry.
|
// Fire-and-forget: only needs to run once, not on every retry.
|
||||||
warmupManagedPythonReadiness();
|
warmupManagedPythonReadiness();
|
||||||
|
|
||||||
|
const t0 = Date.now();
|
||||||
|
let tSpawned = 0;
|
||||||
|
let tReady = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runGatewayStartupSequence({
|
await runGatewayStartupSequence({
|
||||||
port: this.status.port,
|
port: this.status.port,
|
||||||
@@ -262,7 +278,6 @@ export class GatewayManager extends EventEmitter {
|
|||||||
await this.connect(port, externalToken);
|
await this.connect(port, externalToken);
|
||||||
},
|
},
|
||||||
onConnectedToExistingGateway: () => {
|
onConnectedToExistingGateway: () => {
|
||||||
|
|
||||||
// If the existing gateway is actually our own spawned UtilityProcess
|
// If the existing gateway is actually our own spawned UtilityProcess
|
||||||
// (e.g. after a self-restart code=1012), keep ownership so that
|
// (e.g. after a self-restart code=1012), keep ownership so that
|
||||||
// stop() can still terminate the process during a restart() cycle.
|
// stop() can still terminate the process during a restart() cycle.
|
||||||
@@ -288,16 +303,24 @@ export class GatewayManager extends EventEmitter {
|
|||||||
},
|
},
|
||||||
startProcess: async () => {
|
startProcess: async () => {
|
||||||
await this.startProcess();
|
await this.startProcess();
|
||||||
|
tSpawned = Date.now();
|
||||||
},
|
},
|
||||||
waitForReady: async (port) => {
|
waitForReady: async (port) => {
|
||||||
await waitForGatewayReady({
|
await waitForGatewayReady({
|
||||||
port,
|
port,
|
||||||
getProcessExitCode: () => this.processExitCode,
|
getProcessExitCode: () => this.processExitCode,
|
||||||
});
|
});
|
||||||
|
tReady = Date.now();
|
||||||
},
|
},
|
||||||
onConnectedToManagedGateway: () => {
|
onConnectedToManagedGateway: () => {
|
||||||
this.startHealthCheck();
|
this.startHealthCheck();
|
||||||
logger.debug('Gateway started successfully');
|
const tConnected = Date.now();
|
||||||
|
logger.info('[metric] gateway.startup', {
|
||||||
|
configSyncMs: tSpawned ? tSpawned - t0 : undefined,
|
||||||
|
spawnToReadyMs: tReady && tSpawned ? tReady - tSpawned : undefined,
|
||||||
|
readyToConnectMs: tReady ? tConnected - tReady : undefined,
|
||||||
|
totalMs: tConnected - t0,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
runDoctorRepair: async () => await runOpenClawDoctorRepair(),
|
runDoctorRepair: async () => await runOpenClawDoctorRepair(),
|
||||||
onDoctorRepairSuccess: () => {
|
onDoctorRepairSuccess: () => {
|
||||||
@@ -390,7 +413,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
|
|
||||||
this.restartController.resetDeferredRestart();
|
this.restartController.resetDeferredRestart();
|
||||||
this.isAutoReconnectStart = false;
|
this.isAutoReconnectStart = false;
|
||||||
this.setStatus({ state: 'stopped', error: undefined, pid: undefined, connectedAt: undefined, uptime: undefined });
|
this.setStatus({ state: 'stopped', error: undefined, pid: undefined, connectedAt: undefined, uptime: undefined, gatewayReady: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -663,6 +686,25 @@ export class GatewayManager extends EventEmitter {
|
|||||||
clearTimeout(this.reloadDebounceTimer);
|
clearTimeout(this.reloadDebounceTimer);
|
||||||
this.reloadDebounceTimer = null;
|
this.reloadDebounceTimer = null;
|
||||||
}
|
}
|
||||||
|
this.clearGatewayReadyFallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearGatewayReadyFallback(): void {
|
||||||
|
if (this.gatewayReadyFallbackTimer) {
|
||||||
|
clearTimeout(this.gatewayReadyFallbackTimer);
|
||||||
|
this.gatewayReadyFallbackTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleGatewayReadyFallback(): void {
|
||||||
|
this.clearGatewayReadyFallback();
|
||||||
|
this.gatewayReadyFallbackTimer = setTimeout(() => {
|
||||||
|
this.gatewayReadyFallbackTimer = null;
|
||||||
|
if (this.status.state === 'running' && !this.status.gatewayReady) {
|
||||||
|
logger.info('Gateway ready fallback triggered (no gateway.ready event within timeout)');
|
||||||
|
this.setStatus({ gatewayReady: true });
|
||||||
|
}
|
||||||
|
}, GatewayManager.GATEWAY_READY_FALLBACK_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -843,6 +885,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
connectedAt: Date.now(),
|
connectedAt: Date.now(),
|
||||||
});
|
});
|
||||||
this.startPing();
|
this.startPing();
|
||||||
|
this.scheduleGatewayReadyFallback();
|
||||||
},
|
},
|
||||||
onMessage: (message) => {
|
onMessage: (message) => {
|
||||||
this.handleMessage(message);
|
this.handleMessage(message);
|
||||||
|
|||||||
@@ -1452,7 +1452,7 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
|
|||||||
const scheduleGatewayChannelRestart = (reason: string): void => {
|
const scheduleGatewayChannelRestart = (reason: string): void => {
|
||||||
if (gatewayManager.getStatus().state !== 'stopped') {
|
if (gatewayManager.getStatus().state !== 'stopped') {
|
||||||
logger.info(`Scheduling Gateway restart after ${reason}`);
|
logger.info(`Scheduling Gateway restart after ${reason}`);
|
||||||
gatewayManager.debouncedRestart();
|
gatewayManager.debouncedRestart(150);
|
||||||
} else {
|
} else {
|
||||||
logger.info(`Gateway is stopped; skip immediate restart after ${reason}`);
|
logger.info(`Gateway is stopped; skip immediate restart after ${reason}`);
|
||||||
}
|
}
|
||||||
@@ -1465,11 +1465,11 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
|
|||||||
}
|
}
|
||||||
if (forceRestartChannels.has(channelType)) {
|
if (forceRestartChannels.has(channelType)) {
|
||||||
logger.info(`Scheduling Gateway restart after ${reason}`);
|
logger.info(`Scheduling Gateway restart after ${reason}`);
|
||||||
gatewayManager.debouncedRestart();
|
gatewayManager.debouncedRestart(150);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logger.info(`Scheduling Gateway reload after ${reason}`);
|
logger.info(`Scheduling Gateway reload after ${reason}`);
|
||||||
gatewayManager.debouncedReload();
|
gatewayManager.debouncedReload(150);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get OpenClaw package status
|
// Get OpenClaw package status
|
||||||
|
|||||||
@@ -1235,6 +1235,89 @@ export async function syncSessionIdleMinutesToOpenClaw(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch-apply gateway token, browser config, and session idle minutes in a
|
||||||
|
* single config lock + read + write cycle. Replaces three separate
|
||||||
|
* withConfigLock calls during pre-launch sync.
|
||||||
|
*/
|
||||||
|
export async function batchSyncConfigFields(token: string): Promise<void> {
|
||||||
|
const DEFAULT_IDLE_MINUTES = 10_080; // 7 days
|
||||||
|
|
||||||
|
return withConfigLock(async () => {
|
||||||
|
const config = await readOpenClawJson();
|
||||||
|
let modified = true;
|
||||||
|
|
||||||
|
// ── Gateway token + controlUi ──
|
||||||
|
const gateway = (
|
||||||
|
config.gateway && typeof config.gateway === 'object'
|
||||||
|
? { ...(config.gateway as Record<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const auth = (
|
||||||
|
gateway.auth && typeof gateway.auth === 'object'
|
||||||
|
? { ...(gateway.auth as Record<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
auth.mode = 'token';
|
||||||
|
auth.token = token;
|
||||||
|
gateway.auth = auth;
|
||||||
|
|
||||||
|
const controlUi = (
|
||||||
|
gateway.controlUi && typeof gateway.controlUi === 'object'
|
||||||
|
? { ...(gateway.controlUi as Record<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
const allowedOrigins = Array.isArray(controlUi.allowedOrigins)
|
||||||
|
? (controlUi.allowedOrigins as unknown[]).filter((v): v is string => typeof v === 'string')
|
||||||
|
: [];
|
||||||
|
if (!allowedOrigins.includes('file://')) {
|
||||||
|
controlUi.allowedOrigins = [...allowedOrigins, 'file://'];
|
||||||
|
}
|
||||||
|
gateway.controlUi = controlUi;
|
||||||
|
if (!gateway.mode) gateway.mode = 'local';
|
||||||
|
config.gateway = gateway;
|
||||||
|
|
||||||
|
// ── Browser config ──
|
||||||
|
const browser = (
|
||||||
|
config.browser && typeof config.browser === 'object'
|
||||||
|
? { ...(config.browser as Record<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
if (browser.enabled === undefined) {
|
||||||
|
browser.enabled = true;
|
||||||
|
config.browser = browser;
|
||||||
|
modified = true;
|
||||||
|
}
|
||||||
|
if (browser.defaultProfile === undefined) {
|
||||||
|
browser.defaultProfile = 'openclaw';
|
||||||
|
config.browser = browser;
|
||||||
|
modified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session idle minutes ──
|
||||||
|
const session = (
|
||||||
|
config.session && typeof config.session === 'object'
|
||||||
|
? { ...(config.session as Record<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
const hasExplicitSessionConfig = session.idleMinutes !== undefined
|
||||||
|
|| session.reset !== undefined
|
||||||
|
|| session.resetByType !== undefined
|
||||||
|
|| session.resetByChannel !== undefined;
|
||||||
|
if (!hasExplicitSessionConfig) {
|
||||||
|
session.idleMinutes = DEFAULT_IDLE_MINUTES;
|
||||||
|
config.session = session;
|
||||||
|
modified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modified) {
|
||||||
|
await writeOpenClawJson(config);
|
||||||
|
console.log('Synced gateway token, browser config, and session idle to openclaw.json');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a provider entry in every discovered agent's models.json.
|
* Update a provider entry in every discovered agent's models.json.
|
||||||
*/
|
*/
|
||||||
@@ -1670,9 +1753,10 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
|||||||
// that conflicts with the official @larksuite/openclaw-lark plugin
|
// that conflicts with the official @larksuite/openclaw-lark plugin
|
||||||
// (id: 'openclaw-lark'). When the canonical feishu plugin is NOT the
|
// (id: 'openclaw-lark'). When the canonical feishu plugin is NOT the
|
||||||
// built-in 'feishu' itself, we must:
|
// built-in 'feishu' itself, we must:
|
||||||
// 1. Remove bare 'feishu' from plugins.allow
|
// 1. Remove bare 'feishu' from plugins.allow (already done above at line ~1648)
|
||||||
// 2. Always set plugins.entries.feishu = { enabled: false } to explicitly
|
// 2. Delete plugins.entries.feishu entirely — keeping it with enabled:false
|
||||||
// disable the built-in — it loads automatically unless disabled.
|
// causes the Gateway to report the feishu channel as "disabled".
|
||||||
|
// Since 'feishu' is not in plugins.allow, the built-in won't load.
|
||||||
const allowArr2 = Array.isArray(pluginsObj.allow) ? pluginsObj.allow as string[] : [];
|
const allowArr2 = Array.isArray(pluginsObj.allow) ? pluginsObj.allow as string[] : [];
|
||||||
const hasCanonicalFeishu = allowArr2.includes(canonicalFeishuId) || !!pEntries[canonicalFeishuId];
|
const hasCanonicalFeishu = allowArr2.includes(canonicalFeishuId) || !!pEntries[canonicalFeishuId];
|
||||||
if (hasCanonicalFeishu && canonicalFeishuId !== 'feishu') {
|
if (hasCanonicalFeishu && canonicalFeishuId !== 'feishu') {
|
||||||
@@ -1683,11 +1767,13 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
|||||||
console.log('[sanitize] Removed bare "feishu" from plugins.allow (openclaw-lark plugin is configured)');
|
console.log('[sanitize] Removed bare "feishu" from plugins.allow (openclaw-lark plugin is configured)');
|
||||||
modified = true;
|
modified = true;
|
||||||
}
|
}
|
||||||
// Always ensure the built-in feishu plugin is explicitly disabled.
|
// Delete the built-in feishu entry entirely instead of setting enabled:false.
|
||||||
// Built-in extensions load automatically unless plugins.entries.<id>.enabled = false.
|
// Setting enabled:false causes the Gateway to report the channel as "disabled"
|
||||||
if (!pEntries.feishu || pEntries.feishu.enabled !== false) {
|
// which shows as an error in the UI. Since 'feishu' is removed from
|
||||||
pEntries.feishu = { ...(pEntries.feishu || {}), enabled: false };
|
// plugins.allow above, the built-in extension won't auto-load.
|
||||||
console.log('[sanitize] Disabled built-in feishu plugin (openclaw-lark plugin is configured)');
|
if (pEntries.feishu) {
|
||||||
|
delete pEntries.feishu;
|
||||||
|
console.log('[sanitize] Removed built-in feishu plugin entry (openclaw-lark plugin is configured)');
|
||||||
modified = true;
|
modified = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,9 +128,10 @@ export function Sidebar() {
|
|||||||
|
|
||||||
const gatewayStatus = useGatewayStore((s) => s.status);
|
const gatewayStatus = useGatewayStore((s) => s.status);
|
||||||
const isGatewayRunning = gatewayStatus.state === 'running';
|
const isGatewayRunning = gatewayStatus.state === 'running';
|
||||||
|
const isGatewayReady = isGatewayRunning && gatewayStatus.gatewayReady !== false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isGatewayRunning) return;
|
if (!isGatewayReady) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const hasExistingMessages = useChatStore.getState().messages.length > 0;
|
const hasExistingMessages = useChatStore.getState().messages.length > 0;
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -141,7 +142,7 @@ export function Sidebar() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [isGatewayRunning, loadHistory, loadSessions]);
|
}, [isGatewayReady, loadHistory, loadSessions]);
|
||||||
const agents = useAgentsStore((s) => s.agents);
|
const agents = useAgentsStore((s) => s.agents);
|
||||||
const fetchAgents = useAgentsStore((s) => s.fetchAgents);
|
const fetchAgents = useAgentsStore((s) => s.fetchAgents);
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ export function Channels() {
|
|||||||
const [existingAccountIdsForModal, setExistingAccountIdsForModal] = useState<string[]>([]);
|
const [existingAccountIdsForModal, setExistingAccountIdsForModal] = useState<string[]>([]);
|
||||||
const [initialConfigValuesForModal, setInitialConfigValuesForModal] = useState<Record<string, string> | undefined>(undefined);
|
const [initialConfigValuesForModal, setInitialConfigValuesForModal] = useState<Record<string, string> | undefined>(undefined);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null);
|
||||||
|
const convergenceRefreshTimersRef = useRef<number[]>([]);
|
||||||
|
const fetchInFlightRef = useRef(false);
|
||||||
|
const queuedFetchOptionsRef = useRef<{ probe?: boolean } | null>(null);
|
||||||
|
|
||||||
const displayedChannelTypes = getPrimaryChannels();
|
const displayedChannelTypes = getPrimaryChannels();
|
||||||
const visibleChannelGroups = channelGroups;
|
const visibleChannelGroups = channelGroups;
|
||||||
@@ -104,7 +107,24 @@ export function Channels() {
|
|||||||
const agentsRef = useRef(agents);
|
const agentsRef = useRef(agents);
|
||||||
agentsRef.current = agents;
|
agentsRef.current = agents;
|
||||||
|
|
||||||
const fetchPageData = useCallback(async () => {
|
const mergeFetchOptions = (
|
||||||
|
base: { probe?: boolean } | null,
|
||||||
|
incoming: { probe?: boolean } | undefined,
|
||||||
|
): { probe?: boolean } => {
|
||||||
|
return {
|
||||||
|
probe: Boolean(base?.probe) || Boolean(incoming?.probe),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPageData = useCallback(async (options?: { probe?: boolean }) => {
|
||||||
|
if (fetchInFlightRef.current) {
|
||||||
|
queuedFetchOptionsRef.current = mergeFetchOptions(queuedFetchOptionsRef.current, options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchInFlightRef.current = true;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const probe = options?.probe === true;
|
||||||
|
console.info(`[channels-ui] fetch start probe=${probe ? '1' : '0'}`);
|
||||||
// Only show loading spinner on first load (stale-while-revalidate).
|
// Only show loading spinner on first load (stale-while-revalidate).
|
||||||
const hasData = channelGroupsRef.current.length > 0 || agentsRef.current.length > 0;
|
const hasData = channelGroupsRef.current.length > 0 || agentsRef.current.length > 0;
|
||||||
if (!hasData) {
|
if (!hasData) {
|
||||||
@@ -113,7 +133,9 @@ export function Channels() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [channelsRes, agentsRes] = await Promise.all([
|
const [channelsRes, agentsRes] = await Promise.all([
|
||||||
hostApiFetch<{ success: boolean; channels?: ChannelGroupItem[]; error?: string }>('/api/channels/accounts'),
|
hostApiFetch<{ success: boolean; channels?: ChannelGroupItem[]; error?: string }>(
|
||||||
|
options?.probe ? '/api/channels/accounts?probe=1' : '/api/channels/accounts'
|
||||||
|
),
|
||||||
hostApiFetch<{ success: boolean; agents?: AgentItem[]; error?: string }>('/api/agents'),
|
hostApiFetch<{ success: boolean; agents?: AgentItem[]; error?: string }>('/api/agents'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -127,20 +149,64 @@ export function Channels() {
|
|||||||
|
|
||||||
setChannelGroups(channelsRes.channels || []);
|
setChannelGroups(channelsRes.channels || []);
|
||||||
setAgents(agentsRes.agents || []);
|
setAgents(agentsRes.agents || []);
|
||||||
|
console.info(
|
||||||
|
`[channels-ui] fetch ok probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - startedAt} view=${(channelsRes.channels || []).map((item) => `${item.channelType}:${item.status}`).join(',')}`
|
||||||
|
);
|
||||||
} catch (fetchError) {
|
} catch (fetchError) {
|
||||||
// Preserve previous data on error — don't clear channelGroups/agents.
|
// Preserve previous data on error — don't clear channelGroups/agents.
|
||||||
setError(String(fetchError));
|
setError(String(fetchError));
|
||||||
|
console.warn(
|
||||||
|
`[channels-ui] fetch fail probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - startedAt} error=${String(fetchError)}`
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
fetchInFlightRef.current = false;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
const queued = queuedFetchOptionsRef.current;
|
||||||
|
if (queued) {
|
||||||
|
queuedFetchOptionsRef.current = null;
|
||||||
|
void fetchPageData(queued);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Stable reference — reads state via refs, no deps needed.
|
// Stable reference — reads state via refs, no deps needed.
|
||||||
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const clearConvergenceRefreshTimers = useCallback(() => {
|
||||||
|
convergenceRefreshTimersRef.current.forEach((timerId) => {
|
||||||
|
window.clearTimeout(timerId);
|
||||||
|
});
|
||||||
|
convergenceRefreshTimersRef.current = [];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scheduleConvergenceRefresh = useCallback(() => {
|
||||||
|
clearConvergenceRefreshTimers();
|
||||||
|
// Channel adapters can take time to reconnect after gateway restart.
|
||||||
|
// First few rounds use probe=true to force runtime connectivity checks,
|
||||||
|
// then fall back to cached pulls to reduce load.
|
||||||
|
[
|
||||||
|
{ delay: 1200, probe: true },
|
||||||
|
{ delay: 2600, probe: false },
|
||||||
|
{ delay: 4500, probe: false },
|
||||||
|
{ delay: 7000, probe: false },
|
||||||
|
{ delay: 10500, probe: false },
|
||||||
|
].forEach(({ delay, probe }) => {
|
||||||
|
const timerId = window.setTimeout(() => {
|
||||||
|
void fetchPageData({ probe });
|
||||||
|
}, delay);
|
||||||
|
convergenceRefreshTimersRef.current.push(timerId);
|
||||||
|
});
|
||||||
|
}, [clearConvergenceRefreshTimers, fetchPageData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchPageData();
|
void fetchPageData();
|
||||||
}, [fetchPageData]);
|
}, [fetchPageData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearConvergenceRefreshTimers();
|
||||||
|
};
|
||||||
|
}, [clearConvergenceRefreshTimers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Throttle channel-status events to avoid flooding fetchPageData during AI tasks.
|
// Throttle channel-status events to avoid flooding fetchPageData during AI tasks.
|
||||||
let throttleTimer: ReturnType<typeof setTimeout> | null = null;
|
let throttleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -176,8 +242,9 @@ export function Channels() {
|
|||||||
|
|
||||||
if (previousGatewayState !== 'running' && gatewayStatus.state === 'running') {
|
if (previousGatewayState !== 'running' && gatewayStatus.state === 'running') {
|
||||||
void fetchPageData();
|
void fetchPageData();
|
||||||
|
scheduleConvergenceRefresh();
|
||||||
}
|
}
|
||||||
}, [fetchPageData, gatewayStatus.state]);
|
}, [fetchPageData, gatewayStatus.state, scheduleConvergenceRefresh]);
|
||||||
|
|
||||||
const configuredTypes = useMemo(
|
const configuredTypes = useMemo(
|
||||||
() => visibleChannelGroups.map((group) => group.channelType),
|
() => visibleChannelGroups.map((group) => group.channelType),
|
||||||
@@ -199,7 +266,7 @@ export function Channels() {
|
|||||||
const unsupportedGroups = displayedChannelTypes.filter((type) => !configuredTypes.includes(type));
|
const unsupportedGroups = displayedChannelTypes.filter((type) => !configuredTypes.includes(type));
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
void fetchPageData();
|
void fetchPageData({ probe: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBindAgent = async (channelType: string, accountId: string, agentId: string) => {
|
const handleBindAgent = async (channelType: string, accountId: string, agentId: string) => {
|
||||||
@@ -525,7 +592,8 @@ export function Channels() {
|
|||||||
setInitialConfigValuesForModal(undefined);
|
setInitialConfigValuesForModal(undefined);
|
||||||
}}
|
}}
|
||||||
onChannelSaved={async () => {
|
onChannelSaved={async () => {
|
||||||
await fetchPageData();
|
await fetchPageData({ probe: true });
|
||||||
|
scheduleConvergenceRefresh();
|
||||||
setShowConfigModal(false);
|
setShowConfigModal(false);
|
||||||
setSelectedChannelType(null);
|
setSelectedChannelType(null);
|
||||||
setSelectedAccountId(undefined);
|
setSelectedAccountId(undefined);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { invokeIpc } from '@/lib/api-client';
|
import { invokeIpc } from '@/lib/api-client';
|
||||||
import { getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers';
|
import { getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers';
|
||||||
import { classifyHistoryStartupRetryError, sleep } from './history-startup-retry';
|
|
||||||
import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types';
|
import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types';
|
||||||
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
|
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
|
||||||
|
|
||||||
|
const LABEL_FETCH_CONCURRENCY = 5;
|
||||||
|
|
||||||
function getAgentIdFromSessionKey(sessionKey: string): string {
|
function getAgentIdFromSessionKey(sessionKey: string): string {
|
||||||
if (!sessionKey.startsWith('agent:')) return 'main';
|
if (!sessionKey.startsWith('agent:')) return 'main';
|
||||||
const [, agentId] = sessionKey.split(':');
|
const [, agentId] = sessionKey.split(':');
|
||||||
@@ -111,30 +112,24 @@ export function createSessionActions(
|
|||||||
get().loadHistory();
|
get().loadHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Background: fetch first user message for every non-main session to populate labels upfront.
|
// Background: fetch first user message for every non-main session to populate labels.
|
||||||
// Retries on "gateway startup" errors since the gateway may still be initializing.
|
// Concurrency-limited to avoid flooding the gateway with parallel RPCs.
|
||||||
|
// By the time this runs, the gateway should already be fully ready (Sidebar
|
||||||
|
// gates on gatewayReady), so no startup-retry loop is needed.
|
||||||
const sessionsToLabel = sessionsWithCurrent.filter((s) => !s.key.endsWith(':main'));
|
const sessionsToLabel = sessionsWithCurrent.filter((s) => !s.key.endsWith(':main'));
|
||||||
if (sessionsToLabel.length > 0) {
|
if (sessionsToLabel.length > 0) {
|
||||||
const LABEL_RETRY_DELAYS = [2_000, 5_000, 10_000];
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
let pending = sessionsToLabel;
|
for (let i = 0; i < sessionsToLabel.length; i += LABEL_FETCH_CONCURRENCY) {
|
||||||
for (let attempt = 0; attempt <= LABEL_RETRY_DELAYS.length; attempt += 1) {
|
const batch = sessionsToLabel.slice(i, i + LABEL_FETCH_CONCURRENCY);
|
||||||
const failed: typeof pending = [];
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
pending.map(async (session) => {
|
batch.map(async (session) => {
|
||||||
try {
|
try {
|
||||||
const r = await invokeIpc(
|
const r = await invokeIpc(
|
||||||
'gateway:rpc',
|
'gateway:rpc',
|
||||||
'chat.history',
|
'chat.history',
|
||||||
{ sessionKey: session.key, limit: 1000 },
|
{ sessionKey: session.key, limit: 1000 },
|
||||||
) as { success: boolean; result?: Record<string, unknown>; error?: string };
|
) as { success: boolean; result?: Record<string, unknown>; error?: string };
|
||||||
if (!r.success) {
|
if (!r.success || !r.result) return;
|
||||||
if (classifyHistoryStartupRetryError(r.error) === 'gateway_startup') {
|
|
||||||
failed.push(session);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!r.result) return;
|
|
||||||
const msgs = Array.isArray(r.result.messages) ? r.result.messages as RawMessage[] : [];
|
const msgs = Array.isArray(r.result.messages) ? r.result.messages as RawMessage[] : [];
|
||||||
const firstUser = msgs.find((m) => m.role === 'user');
|
const firstUser = msgs.find((m) => m.role === 'user');
|
||||||
const lastMsg = msgs[msgs.length - 1];
|
const lastMsg = msgs[msgs.length - 1];
|
||||||
@@ -155,9 +150,6 @@ export function createSessionActions(
|
|||||||
} catch { /* ignore per-session errors */ }
|
} catch { /* ignore per-session errors */ }
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (failed.length === 0 || attempt >= LABEL_RETRY_DELAYS.length) break;
|
|
||||||
await sleep(LABEL_RETRY_DELAYS[attempt]!);
|
|
||||||
pending = failed;
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface GatewayStatus {
|
|||||||
connectedAt?: number;
|
connectedAt?: number;
|
||||||
version?: string;
|
version?: string;
|
||||||
reconnectAttempts?: number;
|
reconnectAttempts?: number;
|
||||||
|
/** True once the gateway's internal subsystems (skills, plugins) are ready for RPC calls. */
|
||||||
|
gatewayReady?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
51
tests/unit/gateway-event-dispatch.test.ts
Normal file
51
tests/unit/gateway-event-dispatch.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { dispatchProtocolEvent } from '@electron/gateway/event-dispatch';
|
||||||
|
|
||||||
|
function createMockEmitter() {
|
||||||
|
const emitted: Array<{ event: string; payload: unknown }> = [];
|
||||||
|
return {
|
||||||
|
emit: vi.fn((event: string, payload: unknown) => {
|
||||||
|
emitted.push({ event, payload });
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
emitted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('dispatchProtocolEvent', () => {
|
||||||
|
it('dispatches gateway.ready event to gateway:ready', () => {
|
||||||
|
const emitter = createMockEmitter();
|
||||||
|
dispatchProtocolEvent(emitter, 'gateway.ready', { version: '4.11' });
|
||||||
|
expect(emitter.emit).toHaveBeenCalledWith('gateway:ready', { version: '4.11' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches ready event to gateway:ready', () => {
|
||||||
|
const emitter = createMockEmitter();
|
||||||
|
dispatchProtocolEvent(emitter, 'ready', { skills: 31 });
|
||||||
|
expect(emitter.emit).toHaveBeenCalledWith('gateway:ready', { skills: 31 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches channel.status to channel:status', () => {
|
||||||
|
const emitter = createMockEmitter();
|
||||||
|
dispatchProtocolEvent(emitter, 'channel.status', { channelId: 'telegram', status: 'connected' });
|
||||||
|
expect(emitter.emit).toHaveBeenCalledWith('channel:status', { channelId: 'telegram', status: 'connected' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches chat to chat:message', () => {
|
||||||
|
const emitter = createMockEmitter();
|
||||||
|
dispatchProtocolEvent(emitter, 'chat', { text: 'hello' });
|
||||||
|
expect(emitter.emit).toHaveBeenCalledWith('chat:message', { message: { text: 'hello' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses tick events', () => {
|
||||||
|
const emitter = createMockEmitter();
|
||||||
|
dispatchProtocolEvent(emitter, 'tick', {});
|
||||||
|
expect(emitter.emit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches unknown events as notifications', () => {
|
||||||
|
const emitter = createMockEmitter();
|
||||||
|
dispatchProtocolEvent(emitter, 'some.custom.event', { data: 1 });
|
||||||
|
expect(emitter.emit).toHaveBeenCalledWith('notification', { method: 'some.custom.event', params: { data: 1 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -38,4 +38,42 @@ describe('gateway store event wiring', () => {
|
|||||||
handlers.get('gateway:status')?.({ state: 'stopped', port: 18789 });
|
handlers.get('gateway:status')?.({ state: 'stopped', port: 18789 });
|
||||||
expect(useGatewayStore.getState().status.state).toBe('stopped');
|
expect(useGatewayStore.getState().status.state).toBe('stopped');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('propagates gatewayReady field from status events', async () => {
|
||||||
|
hostApiFetchMock.mockResolvedValueOnce({ state: 'running', port: 18789, gatewayReady: false });
|
||||||
|
|
||||||
|
const handlers = new Map<string, (payload: unknown) => void>();
|
||||||
|
subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => {
|
||||||
|
handlers.set(eventName, handler);
|
||||||
|
return () => {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { useGatewayStore } = await import('@/stores/gateway');
|
||||||
|
await useGatewayStore.getState().init();
|
||||||
|
|
||||||
|
// Initially gatewayReady=false from the status fetch
|
||||||
|
expect(useGatewayStore.getState().status.gatewayReady).toBe(false);
|
||||||
|
|
||||||
|
// Simulate gateway.ready event setting gatewayReady=true
|
||||||
|
handlers.get('gateway:status')?.({ state: 'running', port: 18789, gatewayReady: true });
|
||||||
|
expect(useGatewayStore.getState().status.gatewayReady).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats undefined gatewayReady as ready for backwards compatibility', async () => {
|
||||||
|
hostApiFetchMock.mockResolvedValueOnce({ state: 'running', port: 18789 });
|
||||||
|
|
||||||
|
const handlers = new Map<string, (payload: unknown) => void>();
|
||||||
|
subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => {
|
||||||
|
handlers.set(eventName, handler);
|
||||||
|
return () => {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { useGatewayStore } = await import('@/stores/gateway');
|
||||||
|
await useGatewayStore.getState().init();
|
||||||
|
|
||||||
|
const status = useGatewayStore.getState().status;
|
||||||
|
// gatewayReady is undefined (old gateway version) — should be treated as ready
|
||||||
|
expect(status.gatewayReady).toBeUndefined();
|
||||||
|
expect(status.state === 'running' && status.gatewayReady !== false).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
127
tests/unit/gateway-ready-fallback.test.ts
Normal file
127
tests/unit/gateway-ready-fallback.test.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('electron', () => ({
|
||||||
|
app: {
|
||||||
|
getPath: () => '/tmp',
|
||||||
|
isPackaged: false,
|
||||||
|
},
|
||||||
|
utilityProcess: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@electron/utils/logger', () => ({
|
||||||
|
logger: {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@electron/utils/config', () => ({
|
||||||
|
PORTS: { OPENCLAW_GATEWAY: 18789 },
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('GatewayManager gatewayReady fallback', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets gatewayReady=false when entering starting state', async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
const { GatewayManager } = await import('@electron/gateway/manager');
|
||||||
|
const manager = new GatewayManager();
|
||||||
|
|
||||||
|
const statusUpdates: Array<{ gatewayReady?: boolean }> = [];
|
||||||
|
manager.on('status', (status: { gatewayReady?: boolean }) => {
|
||||||
|
statusUpdates.push({ gatewayReady: status.gatewayReady });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate start attempt (will fail but we can check the initial status)
|
||||||
|
try {
|
||||||
|
await manager.start();
|
||||||
|
} catch {
|
||||||
|
// expected to fail — no actual gateway process
|
||||||
|
}
|
||||||
|
|
||||||
|
const startingUpdate = statusUpdates.find((u) => u.gatewayReady === false);
|
||||||
|
expect(startingUpdate).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits gatewayReady=true when gateway:ready event is received', async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
const { GatewayManager } = await import('@electron/gateway/manager');
|
||||||
|
const manager = new GatewayManager();
|
||||||
|
|
||||||
|
// Force internal state to 'running' for the test
|
||||||
|
const stateController = (manager as unknown as { stateController: { setStatus: (u: Record<string, unknown>) => void } }).stateController;
|
||||||
|
stateController.setStatus({ state: 'running', connectedAt: Date.now() });
|
||||||
|
|
||||||
|
const statusUpdates: Array<{ gatewayReady?: boolean; state: string }> = [];
|
||||||
|
manager.on('status', (status: { gatewayReady?: boolean; state: string }) => {
|
||||||
|
statusUpdates.push({ gatewayReady: status.gatewayReady, state: status.state });
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.emit('gateway:ready', {});
|
||||||
|
|
||||||
|
const readyUpdate = statusUpdates.find((u) => u.gatewayReady === true);
|
||||||
|
expect(readyUpdate).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-sets gatewayReady=true after fallback timeout if no event received', async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
const { GatewayManager } = await import('@electron/gateway/manager');
|
||||||
|
const manager = new GatewayManager();
|
||||||
|
|
||||||
|
// Force internal state to 'running' without gatewayReady
|
||||||
|
const stateController = (manager as unknown as { stateController: { setStatus: (u: Record<string, unknown>) => void } }).stateController;
|
||||||
|
stateController.setStatus({ state: 'running', connectedAt: Date.now() });
|
||||||
|
|
||||||
|
const statusUpdates: Array<{ gatewayReady?: boolean }> = [];
|
||||||
|
manager.on('status', (status: { gatewayReady?: boolean }) => {
|
||||||
|
statusUpdates.push({ gatewayReady: status.gatewayReady });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call the private scheduleGatewayReadyFallback method
|
||||||
|
(manager as unknown as { scheduleGatewayReadyFallback: () => void }).scheduleGatewayReadyFallback();
|
||||||
|
|
||||||
|
// Before timeout, no gatewayReady update
|
||||||
|
vi.advanceTimersByTime(29_000);
|
||||||
|
expect(statusUpdates.find((u) => u.gatewayReady === true)).toBeUndefined();
|
||||||
|
|
||||||
|
// After 30s fallback timeout
|
||||||
|
vi.advanceTimersByTime(2_000);
|
||||||
|
const readyUpdate = statusUpdates.find((u) => u.gatewayReady === true);
|
||||||
|
expect(readyUpdate).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels fallback timer when gateway:ready event arrives first', async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
const { GatewayManager } = await import('@electron/gateway/manager');
|
||||||
|
const manager = new GatewayManager();
|
||||||
|
|
||||||
|
const stateController = (manager as unknown as { stateController: { setStatus: (u: Record<string, unknown>) => void } }).stateController;
|
||||||
|
stateController.setStatus({ state: 'running', connectedAt: Date.now() });
|
||||||
|
|
||||||
|
const statusUpdates: Array<{ gatewayReady?: boolean }> = [];
|
||||||
|
manager.on('status', (status: { gatewayReady?: boolean }) => {
|
||||||
|
statusUpdates.push({ gatewayReady: status.gatewayReady });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schedule fallback
|
||||||
|
(manager as unknown as { scheduleGatewayReadyFallback: () => void }).scheduleGatewayReadyFallback();
|
||||||
|
|
||||||
|
// gateway:ready event arrives at 5s
|
||||||
|
vi.advanceTimersByTime(5_000);
|
||||||
|
manager.emit('gateway:ready', {});
|
||||||
|
expect(statusUpdates.filter((u) => u.gatewayReady === true)).toHaveLength(1);
|
||||||
|
|
||||||
|
// After 30s, no duplicate gatewayReady=true
|
||||||
|
vi.advanceTimersByTime(30_000);
|
||||||
|
expect(statusUpdates.filter((u) => u.gatewayReady === true)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
89
tests/unit/session-label-fetch.test.ts
Normal file
89
tests/unit/session-label-fetch.test.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
const invokeIpcMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/api-client', () => ({
|
||||||
|
invokeIpc: (...args: unknown[]) => invokeIpcMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/chat/helpers', () => ({
|
||||||
|
getCanonicalPrefixFromSessions: () => 'agent:main',
|
||||||
|
getMessageText: (content: unknown) => typeof content === 'string' ? content : '',
|
||||||
|
toMs: (v: unknown) => typeof v === 'number' ? v : 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('session label fetch concurrency', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits concurrent chat.history RPCs during label fetches', async () => {
|
||||||
|
// Track max concurrent RPCs
|
||||||
|
let currentConcurrency = 0;
|
||||||
|
let maxConcurrency = 0;
|
||||||
|
const resolvers: Array<() => void> = [];
|
||||||
|
|
||||||
|
invokeIpcMock.mockImplementation(async (channel: string, method: string) => {
|
||||||
|
if (method === 'sessions.list') {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
sessions: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
key: `agent:main:session-${i}`,
|
||||||
|
label: `Session ${i}`,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (method === 'chat.history') {
|
||||||
|
currentConcurrency++;
|
||||||
|
maxConcurrency = Math.max(maxConcurrency, currentConcurrency);
|
||||||
|
await new Promise<void>((resolve) => resolvers.push(resolve));
|
||||||
|
currentConcurrency--;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
messages: [{ role: 'user', content: 'hello', timestamp: Date.now() }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
const { createSessionActions } = await import('@/stores/chat/session-actions');
|
||||||
|
const state = {
|
||||||
|
currentSessionKey: 'agent:main:main',
|
||||||
|
messages: [],
|
||||||
|
sessions: [],
|
||||||
|
sessionLabels: {},
|
||||||
|
sessionLastActivity: {},
|
||||||
|
};
|
||||||
|
const set = vi.fn();
|
||||||
|
const get = vi.fn().mockReturnValue({
|
||||||
|
...state,
|
||||||
|
loadHistory: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions = createSessionActions(set as never, get as never);
|
||||||
|
await actions.loadSessions();
|
||||||
|
|
||||||
|
// Wait for the label-fetch loop to start its batches
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
// Resolve first batch (up to 5 concurrent)
|
||||||
|
while (resolvers.length > 0 && resolvers.length <= 5) {
|
||||||
|
resolvers.shift()?.();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve remaining
|
||||||
|
while (resolvers.length > 0) {
|
||||||
|
resolvers.shift()?.();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxConcurrency should be capped at 5 (LABEL_FETCH_CONCURRENCY)
|
||||||
|
expect(maxConcurrency).toBeLessThanOrEqual(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user