perf(doctor): fast-path bundled channel compat migrations

This commit is contained in:
Vincent Koc
2026-04-14 18:26:48 +01:00
parent 088b41b04b
commit 66e06b50ba
4 changed files with 209 additions and 8 deletions

View File

@@ -1,8 +1,17 @@
import type { LegacyConfigRule } from "../../config/legacy.shared.js";
import type { OpenClawConfig } from "../../config/types.js";
import { loadBundledPluginPublicArtifactModuleSync } from "../../plugins/public-surface-loader.js";
type BundledChannelDoctorCompatibilityMutation = {
config: OpenClawConfig;
changes: string[];
};
type BundledChannelDoctorContractApi = {
legacyConfigRules?: readonly LegacyConfigRule[];
normalizeCompatibilityConfig?: (params: {
cfg: OpenClawConfig;
}) => BundledChannelDoctorCompatibilityMutation;
};
function loadBundledChannelPublicArtifact(

View File

@@ -60,7 +60,55 @@ vi.mock("../config/validation.js", () => ({
}));
vi.mock("../channels/plugins/bootstrap-registry.js", () => ({
getBootstrapChannelPlugin: vi.fn(() => undefined),
getBootstrapChannelPlugin: vi.fn((channelId: string) => {
if (channelId !== "discord") {
return undefined;
}
return {
doctor: {
normalizeCompatibilityConfig: ({
cfg,
}: {
cfg: { channels?: { discord?: Record<string, unknown> } };
}) => {
const discord = cfg.channels?.discord;
if (!discord) {
return { config: cfg, changes: [] };
}
if (
!("streamMode" in discord) &&
typeof discord.streaming !== "boolean" &&
typeof discord.streaming !== "string"
) {
return { config: cfg, changes: [] };
}
const next = structuredClone(cfg);
const nextDiscord = next.channels?.discord;
if (!nextDiscord) {
return { config: cfg, changes: [] };
}
const nextStreaming =
nextDiscord.streaming && typeof nextDiscord.streaming === "object"
? { ...(nextDiscord.streaming as Record<string, unknown>) }
: {};
if (!("mode" in nextStreaming)) {
nextStreaming.mode =
nextDiscord.streamMode === "block"
? "partial"
: nextDiscord.streaming === false
? "off"
: "partial";
}
delete nextDiscord.streamMode;
nextDiscord.streaming = nextStreaming;
return {
config: next,
changes: ["Discord allowlist ids normalized to strings."],
};
},
},
};
}),
}));
vi.mock("../plugins/doctor-contract-registry.js", () => {
@@ -535,6 +583,43 @@ vi.mock("./doctor-config-preflight.js", async () => {
return process.env.OPENCLAW_CONFIG_PATH || path.join(stateDir, "openclaw.json");
}
function normalizeDiscordStreamingCompat(cfg: Record<string, unknown>): Record<string, unknown> {
const channels =
cfg.channels && typeof cfg.channels === "object" && !Array.isArray(cfg.channels)
? (cfg.channels as Record<string, unknown>)
: null;
const discord =
channels?.discord && typeof channels.discord === "object" && !Array.isArray(channels.discord)
? (channels.discord as Record<string, unknown>)
: null;
if (
!discord ||
(!("streamMode" in discord) &&
typeof discord.streaming !== "boolean" &&
typeof discord.streaming !== "string")
) {
return cfg;
}
const next = structuredClone(cfg);
const nextDiscord = ((next.channels as Record<string, unknown> | undefined)?.discord ??
{}) as Record<string, unknown>;
const nextStreaming =
nextDiscord.streaming && typeof nextDiscord.streaming === "object"
? { ...(nextDiscord.streaming as Record<string, unknown>) }
: {};
if (!("mode" in nextStreaming)) {
nextStreaming.mode =
nextDiscord.streamMode === "block"
? "partial"
: nextDiscord.streaming === false
? "off"
: "partial";
}
delete nextDiscord.streamMode;
nextDiscord.streaming = nextStreaming;
return next;
}
return {
runDoctorConfigPreflight: vi.fn(async () => {
const injected = getDoctorConfigInputForTest();
@@ -596,7 +681,7 @@ vi.mock("./doctor-config-preflight.js", async () => {
}),
);
const compat = applyRuntimeLegacyConfigMigrations(parsed);
const effectiveConfig = compat.next ?? parsed;
const effectiveConfig = normalizeDiscordStreamingCompat(compat.next ?? parsed);
return {
snapshot: {
exists,

View File

@@ -1,12 +1,23 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const applyPluginDoctorCompatibilityMigrations = vi.hoisted(() => vi.fn());
const loadBundledChannelDoctorContractApi = vi.hoisted(() => vi.fn());
const getBootstrapChannelPlugin = vi.hoisted(() => vi.fn());
vi.mock("../../../plugins/doctor-contract-registry.js", () => ({
applyPluginDoctorCompatibilityMigrations: (...args: unknown[]) =>
applyPluginDoctorCompatibilityMigrations(...args),
}));
vi.mock("../../../channels/plugins/doctor-contract-api.js", () => ({
loadBundledChannelDoctorContractApi: (...args: unknown[]) =>
loadBundledChannelDoctorContractApi(...args),
}));
vi.mock("../../../channels/plugins/bootstrap-registry.js", () => ({
getBootstrapChannelPlugin: (...args: unknown[]) => getBootstrapChannelPlugin(...args),
}));
let applyChannelDoctorCompatibilityMigrations: typeof import("./channel-legacy-config-migrate.js").applyChannelDoctorCompatibilityMigrations;
beforeAll(async () => {
@@ -17,8 +28,59 @@ beforeAll(async () => {
await import("./channel-legacy-config-migrate.js"));
});
beforeEach(() => {
applyPluginDoctorCompatibilityMigrations.mockReset();
loadBundledChannelDoctorContractApi.mockReset();
getBootstrapChannelPlugin.mockReset();
});
describe("bundled channel legacy config migrations", () => {
it("prefers bundled channel doctor contract normalizers before plugin registry fallback", () => {
loadBundledChannelDoctorContractApi.mockImplementation((channelId: string) =>
channelId === "slack"
? {
normalizeCompatibilityConfig: ({
cfg,
}: {
cfg: { channels?: { slack?: Record<string, unknown> } };
}) => ({
config: {
...cfg,
channels: {
...cfg.channels,
slack: {
...cfg.channels?.slack,
normalizedByBundledContract: true,
},
},
},
changes: ["Normalized channels.slack via bundled doctor contract."],
}),
}
: undefined,
);
getBootstrapChannelPlugin.mockReturnValue(undefined);
const result = applyChannelDoctorCompatibilityMigrations({
channels: {
slack: {
streaming: true,
},
},
});
expect(applyPluginDoctorCompatibilityMigrations).not.toHaveBeenCalled();
expect(loadBundledChannelDoctorContractApi).toHaveBeenCalledWith("slack");
expect(result.next.channels?.slack).toMatchObject({
streaming: true,
normalizedByBundledContract: true,
});
expect(result.changes).toEqual(["Normalized channels.slack via bundled doctor contract."]);
});
it("normalizes legacy private-network aliases exposed through bundled contract surfaces", () => {
loadBundledChannelDoctorContractApi.mockReturnValue(undefined);
getBootstrapChannelPlugin.mockReturnValue(undefined);
applyPluginDoctorCompatibilityMigrations.mockReturnValueOnce({
config: {
channels: {

View File

@@ -1,7 +1,18 @@
import { getBootstrapChannelPlugin } from "../../../channels/plugins/bootstrap-registry.js";
import { loadBundledChannelDoctorContractApi } from "../../../channels/plugins/doctor-contract-api.js";
import type { OpenClawConfig } from "../../../config/types.js";
import { applyPluginDoctorCompatibilityMigrations } from "../../../plugins/doctor-contract-registry.js";
import { isRecord } from "./legacy-config-record-shared.js";
type ChannelDoctorCompatibilityMutation = {
config: OpenClawConfig;
changes: string[];
};
type ChannelDoctorCompatibilityNormalizer = (params: {
cfg: OpenClawConfig;
}) => ChannelDoctorCompatibilityMutation;
function collectRelevantDoctorChannelIds(raw: unknown): string[] {
const channels = isRecord(raw) && isRecord(raw.channels) ? raw.channels : null;
if (!channels) {
@@ -12,15 +23,49 @@ function collectRelevantDoctorChannelIds(raw: unknown): string[] {
.toSorted();
}
function resolveBundledChannelCompatibilityNormalizer(
channelId: string,
): ChannelDoctorCompatibilityNormalizer | undefined {
const contractNormalizer =
loadBundledChannelDoctorContractApi(channelId)?.normalizeCompatibilityConfig;
if (typeof contractNormalizer === "function") {
return contractNormalizer;
}
return getBootstrapChannelPlugin(channelId)?.doctor?.normalizeCompatibilityConfig;
}
export function applyChannelDoctorCompatibilityMigrations(cfg: Record<string, unknown>): {
next: Record<string, unknown>;
changes: string[];
} {
const compat = applyPluginDoctorCompatibilityMigrations(cfg as OpenClawConfig, {
pluginIds: collectRelevantDoctorChannelIds(cfg),
});
let nextCfg = cfg as OpenClawConfig;
const changes: string[] = [];
const unresolvedChannelIds: string[] = [];
for (const channelId of collectRelevantDoctorChannelIds(cfg)) {
const normalizeCompatibilityConfig = resolveBundledChannelCompatibilityNormalizer(channelId);
if (!normalizeCompatibilityConfig) {
unresolvedChannelIds.push(channelId);
continue;
}
const mutation = normalizeCompatibilityConfig({ cfg: nextCfg });
if (!mutation || mutation.changes.length === 0) {
continue;
}
nextCfg = mutation.config;
changes.push(...mutation.changes);
}
if (unresolvedChannelIds.length > 0) {
const compat = applyPluginDoctorCompatibilityMigrations(nextCfg, {
pluginIds: unresolvedChannelIds,
});
nextCfg = compat.config;
changes.push(...compat.changes);
}
return {
next: compat.config as OpenClawConfig & Record<string, unknown>,
changes: compat.changes,
next: nextCfg as OpenClawConfig & Record<string, unknown>,
changes,
};
}