Optimize gateway comms reload behavior and strengthen regression coverage (#496)

This commit is contained in:
Lingxuan Zuo
2026-03-15 20:36:48 +08:00
committed by GitHub
Unverified
parent 08960d700f
commit 1dbe4a8466
36 changed files with 1511 additions and 197 deletions

View File

@@ -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) });

View File

@@ -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) {