perf(gateway): defer doctor legacy checks

This commit is contained in:
Peter Steinberger
2026-05-02 17:44:53 +01:00
parent 8d67ee112f
commit d92a634fae
11 changed files with 218 additions and 83 deletions

View File

@@ -0,0 +1 @@
export const legacyConfigRules = [];

View File

@@ -0,0 +1 @@
export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/config-compat.js";

View File

@@ -826,6 +826,26 @@ vi.mock("../plugins/doctor-contract-registry.js", () => {
};
});
vi.mock("./doctor/shared/legacy-config-issues.js", async () => {
const {
collectRelevantDoctorPluginIds,
listPluginDoctorLegacyConfigRules,
}: typeof import("../plugins/doctor-contract-registry.js") =
await import("../plugins/doctor-contract-registry.js");
const { findLegacyConfigIssues }: typeof import("../config/legacy.js") =
await import("../config/legacy.js");
return {
findDoctorLegacyConfigIssues: (raw: unknown, sourceRaw?: unknown) =>
findLegacyConfigIssues(
raw,
sourceRaw,
listPluginDoctorLegacyConfigRules({
pluginIds: collectRelevantDoctorPluginIds(raw),
}),
),
};
});
vi.mock("../plugins/setup-registry.js", () => ({
resolvePluginSetupAutoEnableReasons: vi.fn(() => []),
runPluginSetupConfigMigrations: vi.fn(({ config }: { config: unknown }) => ({

View File

@@ -1,6 +1,5 @@
import path from "node:path";
import { formatCliCommand } from "../cli/command-format.js";
import { findLegacyConfigIssues } from "../config/legacy.js";
import { CONFIG_PATH } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -92,15 +91,9 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
if (snapshot.parsed === snapshot.sourceConfig) {
return [];
}
const { collectRelevantDoctorPluginIds, listPluginDoctorLegacyConfigRules } =
await import("../plugins/doctor-contract-registry.js");
return findLegacyConfigIssues(
snapshot.parsed,
snapshot.parsed,
listPluginDoctorLegacyConfigRules({
pluginIds: collectRelevantDoctorPluginIds(snapshot.parsed),
}),
);
const { findDoctorLegacyConfigIssues } =
await import("./doctor/shared/legacy-config-issues.js");
return findDoctorLegacyConfigIssues(snapshot.parsed, snapshot.parsed);
})();
const seenLegacyIssues = new Set(
snapshot.legacyIssues.map((issue) => `${issue.path}:${issue.message}`),

View File

@@ -2,16 +2,12 @@ import fs from "node:fs/promises";
import path from "node:path";
import { readConfigFileSnapshot, recoverConfigFromJsonRootSuffix } from "../config/io.js";
import { formatConfigIssueLines } from "../config/issue-format.js";
import { findLegacyConfigIssues } from "../config/legacy.js";
import type { LegacyConfigIssue } from "../config/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
collectRelevantDoctorPluginIds,
listPluginDoctorLegacyConfigRules,
} from "../plugins/doctor-contract-registry.js";
import { note } from "../terminal/note.js";
import { resolveHomeDir } from "../utils.js";
import { noteIncludeConfinementWarning } from "./doctor-config-analysis.js";
import { findDoctorLegacyConfigIssues } from "./doctor/shared/legacy-config-issues.js";
async function maybeMigrateLegacyConfig(): Promise<string[]> {
const changes: string[] = [];
@@ -69,13 +65,7 @@ function collectDoctorLegacyIssues(
}
const resolvedRaw = snapshot.sourceConfig ?? snapshot.config ?? {};
const sourceRaw = snapshot.parsed ?? resolvedRaw;
return findLegacyConfigIssues(
resolvedRaw,
sourceRaw,
listPluginDoctorLegacyConfigRules({
pluginIds: collectRelevantDoctorPluginIds(resolvedRaw),
}),
);
return findDoctorLegacyConfigIssues(resolvedRaw, sourceRaw);
}
function addDoctorLegacyIssues(

View File

@@ -0,0 +1,52 @@
import { collectChannelLegacyConfigRules } from "../../../channels/plugins/legacy-config.js";
import { findLegacyConfigIssues } from "../../../config/legacy.js";
import type { LegacyConfigRule } from "../../../config/legacy.shared.js";
import type { LegacyConfigIssue } from "../../../config/types.js";
import {
collectRelevantDoctorPluginIds,
collectRelevantDoctorPluginIdsForTouchedPaths,
listPluginDoctorLegacyConfigRules,
} from "../../../plugins/doctor-contract-registry.js";
function collectConfiguredChannelIds(raw: unknown): ReadonlySet<string> {
if (!raw || typeof raw !== "object") {
return new Set();
}
const channels = (raw as { channels?: unknown }).channels;
if (!channels || typeof channels !== "object" || Array.isArray(channels)) {
return new Set();
}
return new Set(Object.keys(channels).filter((channelId) => channelId !== "defaults"));
}
function collectPluginLegacyConfigRules(
raw: unknown,
touchedPaths?: ReadonlyArray<ReadonlyArray<string>>,
): LegacyConfigRule[] {
const channelIds = collectConfiguredChannelIds(raw);
const pluginIds = (
touchedPaths
? collectRelevantDoctorPluginIdsForTouchedPaths({ raw, touchedPaths })
: collectRelevantDoctorPluginIds(raw)
).filter((pluginId) => !channelIds.has(pluginId));
if (pluginIds.length === 0) {
return [];
}
return listPluginDoctorLegacyConfigRules({ pluginIds });
}
export function findDoctorLegacyConfigIssues(
raw: unknown,
sourceRaw?: unknown,
touchedPaths?: ReadonlyArray<ReadonlyArray<string>>,
): LegacyConfigIssue[] {
return findLegacyConfigIssues(
raw,
sourceRaw,
[
...collectChannelLegacyConfigRules(raw, touchedPaths),
...collectPluginLegacyConfigRules(raw, touchedPaths),
],
touchedPaths,
);
}

View File

@@ -882,8 +882,8 @@ describe("config strict validation", () => {
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(false);
expect(snap.issues.some((issue) => issue.path === "memorySearch")).toBe(true);
expect(snap.legacyIssues).toEqual([]);
expect(snap.issues.some((issue) => issue.message.includes('"memorySearch"'))).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "memorySearch")).toBe(true);
expect((snap.sourceConfig as { memorySearch?: unknown }).memorySearch).toMatchObject({
provider: "local",
fallback: "none",
@@ -905,8 +905,8 @@ describe("config strict validation", () => {
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(false);
expect(snap.issues.some((issue) => issue.path === "heartbeat")).toBe(true);
expect(snap.legacyIssues).toEqual([]);
expect(snap.issues.some((issue) => issue.message.includes('"heartbeat"'))).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "heartbeat")).toBe(true);
expect((snap.sourceConfig as { heartbeat?: unknown }).heartbeat).toMatchObject({
every: "30m",
model: "anthropic/claude-3-5-haiku-20241022",
@@ -928,8 +928,8 @@ describe("config strict validation", () => {
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(false);
expect(snap.issues.some((issue) => issue.path === "heartbeat")).toBe(true);
expect(snap.legacyIssues).toEqual([]);
expect(snap.issues.some((issue) => issue.message.includes('"heartbeat"'))).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "heartbeat")).toBe(true);
expect((snap.sourceConfig as { heartbeat?: unknown }).heartbeat).toMatchObject({
showOk: true,
showAlerts: false,
@@ -985,8 +985,11 @@ describe("config strict validation", () => {
expect(snap.valid).toBe(false);
expect(snap.issues.some((issue) => issue.path === "agents.defaults.sandbox")).toBe(true);
expect(snap.issues.some((issue) => issue.path === "agents.list")).toBe(true);
expect(snap.legacyIssues).toEqual([]);
expect(snap.issues.some((issue) => issue.path === "agents.list.0.sandbox")).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "agents.defaults.sandbox")).toBe(
true,
);
expect(snap.legacyIssues.some((issue) => issue.path === "agents.list")).toBe(true);
expect(snap.sourceConfig.agents?.defaults?.sandbox).toEqual({ perSession: true });
expect(snap.sourceConfig.agents?.list?.[0]?.sandbox).toEqual({ perSession: false });
});
@@ -1024,7 +1027,7 @@ describe("config strict validation", () => {
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(false);
expect(snap.issues.some((issue) => issue.path === "gateway.bind")).toBe(true);
expect(snap.legacyIssues).toEqual([]);
expect(snap.legacyIssues.some((issue) => issue.path === "gateway.bind")).toBe(true);
});
});
});

View File

@@ -1185,6 +1185,18 @@ async function finalizeReadConfigSnapshotInternalResult(
return result;
}
async function collectInvalidConfigLegacyIssues(
raw: unknown,
sourceRaw: unknown,
): Promise<LegacyConfigIssue[]> {
if (!raw || typeof raw !== "object") {
return [];
}
const { findDoctorLegacyConfigIssues } =
await import("../commands/doctor/shared/legacy-config-issues.js");
return findDoctorLegacyConfigIssues(raw, sourceRaw);
}
export function createConfigIO(
overrides: ConfigIoDeps & { pluginValidation?: "full" | "skip" } = {},
) {
@@ -1756,6 +1768,9 @@ export function createConfigIO(
}),
);
if (!validated.ok) {
const legacyIssues = await deps.measure("config.snapshot.read.legacy-issues", () =>
collectInvalidConfigLegacyIssues(effectiveConfigRaw, effectiveParsed),
);
return await finalizeReadConfigSnapshotInternalResult(deps, {
snapshot: createConfigFileSnapshot({
path: configPath,
@@ -1768,7 +1783,7 @@ export function createConfigIO(
hash: snapshotHash,
issues: validated.issues,
warnings: [...validated.warnings, ...envVarWarnings],
legacyIssues: [],
legacyIssues,
}),
});
}

View File

@@ -1,4 +1,3 @@
import { collectChannelLegacyConfigRules } from "../channels/plugins/legacy-config.js";
import { LEGACY_CONFIG_RULES } from "./legacy.rules.js";
import type { LegacyConfigRule } from "./legacy.shared.js";
import type { LegacyConfigIssue } from "./types.js";
@@ -14,20 +13,6 @@ function getPathValue(root: Record<string, unknown>, path: string[]): unknown {
return cursor;
}
function collectExplicitRuleOwnedChannelIds(
extraRules: readonly LegacyConfigRule[],
): ReadonlySet<string> | undefined {
const channelIds = new Set<string>();
for (const rule of extraRules) {
const [first, second] = rule.path;
if (first !== "channels" || typeof second !== "string" || second === "defaults") {
continue;
}
channelIds.add(second);
}
return channelIds.size > 0 ? channelIds : undefined;
}
export function findLegacyConfigIssues(
raw: unknown,
sourceRaw?: unknown,
@@ -41,12 +26,7 @@ export function findLegacyConfigIssues(
const sourceRoot =
sourceRaw && typeof sourceRaw === "object" ? (sourceRaw as Record<string, unknown>) : root;
const issues: LegacyConfigIssue[] = [];
const explicitRuleOwnedChannelIds = collectExplicitRuleOwnedChannelIds(extraRules);
for (const rule of [
...LEGACY_CONFIG_RULES,
...collectChannelLegacyConfigRules(raw, touchedPaths, explicitRuleOwnedChannelIds),
...extraRules,
]) {
for (const rule of [...LEGACY_CONFIG_RULES, ...extraRules]) {
const cursor = getPathValue(root, rule.path);
if (cursor !== undefined && (!rule.match || rule.match(cursor, root))) {
if (rule.requireSourceLiteral) {

View File

@@ -0,0 +1,110 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { LegacyConfigRule } from "./legacy.shared.js";
const { collectChannelLegacyConfigRulesMock, listPluginDoctorLegacyConfigRulesMock } = vi.hoisted(
() => ({
collectChannelLegacyConfigRulesMock: vi.fn((): LegacyConfigRule[] => []),
listPluginDoctorLegacyConfigRulesMock: vi.fn((): LegacyConfigRule[] => []),
}),
);
vi.mock("../channels/plugins/legacy-config.js", () => ({
collectChannelLegacyConfigRules: collectChannelLegacyConfigRulesMock,
}));
vi.mock("../plugins/doctor-contract-registry.js", () => ({
listPluginDoctorLegacyConfigRules: listPluginDoctorLegacyConfigRulesMock,
}));
import { validateConfigObjectRaw } from "./validation.js";
describe("config validation legacy rule loading", () => {
beforeEach(() => {
collectChannelLegacyConfigRulesMock.mockReset();
collectChannelLegacyConfigRulesMock.mockReturnValue([]);
listPluginDoctorLegacyConfigRulesMock.mockReset();
listPluginDoctorLegacyConfigRulesMock.mockReturnValue([]);
});
it("does not load channel or plugin doctor legacy rules for valid raw config", () => {
collectChannelLegacyConfigRulesMock.mockReturnValue([
{
path: ["channels", "discord", "legacy"],
message: "legacy discord key",
},
]);
const result = validateConfigObjectRaw({
channels: {
discord: {},
},
});
expect(result.ok).toBe(true);
expect(collectChannelLegacyConfigRulesMock).not.toHaveBeenCalled();
expect(listPluginDoctorLegacyConfigRulesMock).not.toHaveBeenCalled();
});
it("does not load plugin doctor legacy rules for invalid raw config", () => {
listPluginDoctorLegacyConfigRulesMock.mockReturnValue([
{
path: ["plugins", "entries", "demo", "legacy"],
message: "legacy demo key",
},
]);
const result = validateConfigObjectRaw({
plugins: {
entries: {
demo: {
legacy: true,
},
},
},
});
expect(result.ok).toBe(false);
expect(collectChannelLegacyConfigRulesMock).not.toHaveBeenCalled();
expect(listPluginDoctorLegacyConfigRulesMock).not.toHaveBeenCalled();
});
it("skips enabled-only and empty-config plugin entries", () => {
const result = validateConfigObjectRaw({
plugins: {
entries: {
anthropic: {
enabled: true,
},
discord: {
config: {},
enabled: true,
},
},
},
});
expect(result.ok).toBe(true);
expect(collectChannelLegacyConfigRulesMock).not.toHaveBeenCalled();
expect(listPluginDoctorLegacyConfigRulesMock).not.toHaveBeenCalled();
});
it("does not use touched paths to load doctor rules during raw validation", () => {
const result = validateConfigObjectRaw(
{
plugins: {
entries: {
demo: {},
other: {},
},
},
},
{
touchedPaths: [["plugins", "entries", "demo", "enabled"]],
},
);
expect(result.ok).toBe(true);
expect(collectChannelLegacyConfigRulesMock).not.toHaveBeenCalled();
expect(listPluginDoctorLegacyConfigRulesMock).not.toHaveBeenCalled();
});
});

View File

@@ -8,11 +8,6 @@ import {
resolveEffectivePluginActivationState,
resolveMemorySlotDecision,
} from "../plugins/config-state.js";
import {
collectRelevantDoctorPluginIds,
collectRelevantDoctorPluginIdsForTouchedPaths,
listPluginDoctorLegacyConfigRules,
} from "../plugins/doctor-contract-registry.js";
import { loadInstalledPluginIndexInstallRecordsSync } from "../plugins/installed-plugin-index-record-reader.js";
import { resolveManifestCommandAliasOwnerInRegistry } from "../plugins/manifest-command-aliases.js";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
@@ -38,7 +33,6 @@ import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-di
import { appendAllowedValuesHint, summarizeAllowedValues } from "./allowed-values.js";
import { GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA } from "./bundled-channel-config-metadata.generated.js";
import { collectChannelSchemaMetadata } from "./channel-config-metadata.js";
import { findLegacyConfigIssues } from "./legacy.js";
import { materializeRuntimeConfig } from "./materialize.js";
import type { OpenClawConfig, ConfigValidationIssue } from "./types.js";
import { coerceSecretRef } from "./types.secrets.js";
@@ -623,30 +617,6 @@ export function validateConfigObjectRaw(
): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } {
const normalizedRaw = stripDeprecatedValidationKeys(raw);
const policyIssues = collectUnsupportedSecretRefPolicyIssues(normalizedRaw);
const doctorPluginIds = opts?.touchedPaths
? collectRelevantDoctorPluginIdsForTouchedPaths({
raw: normalizedRaw,
touchedPaths: opts.touchedPaths,
})
: collectRelevantDoctorPluginIds(normalizedRaw);
const extraLegacyRules = listPluginDoctorLegacyConfigRules({
pluginIds: doctorPluginIds,
});
const legacyIssues = findLegacyConfigIssues(
normalizedRaw,
opts?.sourceRaw ?? normalizedRaw,
extraLegacyRules,
opts?.touchedPaths,
);
if (legacyIssues.length > 0) {
return {
ok: false,
issues: legacyIssues.map((iss) => ({
path: iss.path,
message: iss.message,
})),
};
}
const validated = OpenClawSchema.safeParse(normalizedRaw);
if (!validated.success) {
const schemaIssues = validated.error.issues.map((issue) => mapZodIssueToConfigIssue(issue));