mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:40:44 +00:00
perf(config): scope dry-run legacy validation
This commit is contained in:
@@ -105,4 +105,34 @@ describe("collectChannelLegacyConfigRules", () => {
|
|||||||
pluginIds: ["custom-chat"],
|
pluginIds: ["custom-chat"],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("scopes channel legacy scans to touched channels during dry-run validation", () => {
|
||||||
|
loadBundledChannelDoctorContractApiMock.mockImplementation((channelId: string) => ({
|
||||||
|
legacyConfigRules: [
|
||||||
|
{
|
||||||
|
path: ["channels", channelId],
|
||||||
|
message: `legacy ${channelId} rule`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rules = collectChannelLegacyConfigRules(
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
discord: {},
|
||||||
|
telegram: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[["channels", "discord", "token"]],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rules).toEqual([
|
||||||
|
{
|
||||||
|
path: ["channels", "discord"],
|
||||||
|
message: "legacy discord rule",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(loadBundledChannelDoctorContractApiMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(loadBundledChannelDoctorContractApiMock).toHaveBeenCalledWith("discord");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,8 +17,59 @@ function collectConfiguredChannelIds(raw: unknown): ChannelId[] {
|
|||||||
.map((channelId) => channelId as ChannelId);
|
.map((channelId) => channelId as ChannelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function collectChannelLegacyConfigRules(raw?: unknown): LegacyConfigRule[] {
|
function shouldIncludeLegacyRuleForTouchedPaths(
|
||||||
const channelIds = collectConfiguredChannelIds(raw);
|
rulePath: readonly string[],
|
||||||
|
touchedPaths?: ReadonlyArray<ReadonlyArray<string>>,
|
||||||
|
): boolean {
|
||||||
|
if (!touchedPaths || touchedPaths.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return touchedPaths.some((touchedPath) => {
|
||||||
|
const sharedLength = Math.min(rulePath.length, touchedPath.length);
|
||||||
|
for (let index = 0; index < sharedLength; index += 1) {
|
||||||
|
if (rulePath[index] !== touchedPath[index]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRelevantChannelIdsForTouchedPaths(params: {
|
||||||
|
raw?: unknown;
|
||||||
|
touchedPaths?: ReadonlyArray<ReadonlyArray<string>>;
|
||||||
|
}): ChannelId[] {
|
||||||
|
const channelIds = collectConfiguredChannelIds(params.raw);
|
||||||
|
if (!params.touchedPaths || params.touchedPaths.length === 0) {
|
||||||
|
return channelIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const touchedChannelIds = new Set<ChannelId>();
|
||||||
|
for (const touchedPath of params.touchedPaths) {
|
||||||
|
const [first, second] = touchedPath;
|
||||||
|
if (first !== "channels") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!second) {
|
||||||
|
return channelIds;
|
||||||
|
}
|
||||||
|
if (second === "defaults") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
touchedChannelIds.add(second as ChannelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (touchedChannelIds.size === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return channelIds.filter((channelId) => touchedChannelIds.has(channelId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectChannelLegacyConfigRules(
|
||||||
|
raw?: unknown,
|
||||||
|
touchedPaths?: ReadonlyArray<ReadonlyArray<string>>,
|
||||||
|
): LegacyConfigRule[] {
|
||||||
|
const channelIds = collectRelevantChannelIdsForTouchedPaths({ raw, touchedPaths });
|
||||||
const rules: LegacyConfigRule[] = [];
|
const rules: LegacyConfigRule[] = [];
|
||||||
const unresolvedChannelIds: ChannelId[] = [];
|
const unresolvedChannelIds: ChannelId[] = [];
|
||||||
for (const channelId of channelIds) {
|
for (const channelId of channelIds) {
|
||||||
@@ -42,6 +93,9 @@ export function collectChannelLegacyConfigRules(raw?: unknown): LegacyConfigRule
|
|||||||
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return rules.filter((rule) => {
|
return rules.filter((rule) => {
|
||||||
|
if (!shouldIncludeLegacyRuleForTouchedPaths(rule.path, touchedPaths)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const key = `${rule.path.join(".")}::${rule.message}`;
|
const key = `${rule.path.join(".")}::${rule.message}`;
|
||||||
if (seen.has(key)) {
|
if (seen.has(key)) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -921,8 +921,13 @@ function selectDryRunRefsForResolution(params: { refs: SecretRef[]; allowExecInD
|
|||||||
return { refsToResolve, skippedExecRefs };
|
return { refsToResolve, skippedExecRefs };
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectDryRunSchemaErrors(config: OpenClawConfig): ConfigSetDryRunError[] {
|
function collectDryRunSchemaErrors(params: {
|
||||||
const validated = validateConfigObjectRaw(config);
|
config: OpenClawConfig;
|
||||||
|
operations: ReadonlyArray<ConfigSetOperation>;
|
||||||
|
}): ConfigSetDryRunError[] {
|
||||||
|
const validated = validateConfigObjectRaw(params.config, {
|
||||||
|
touchedPaths: params.operations.map((operation) => operation.setPath),
|
||||||
|
});
|
||||||
if (validated.ok) {
|
if (validated.ok) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -1062,7 +1067,12 @@ export async function runConfigSet(opts: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (requiresFullSchemaValidation) {
|
if (requiresFullSchemaValidation) {
|
||||||
errors.push(...collectDryRunSchemaErrors(nextConfig));
|
errors.push(
|
||||||
|
...collectDryRunSchemaErrors({
|
||||||
|
config: nextConfig,
|
||||||
|
operations,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (hasJsonMode || hasBuilderMode) {
|
if (hasJsonMode || hasBuilderMode) {
|
||||||
errors.push(
|
errors.push(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function findLegacyConfigIssues(
|
|||||||
raw: unknown,
|
raw: unknown,
|
||||||
sourceRaw?: unknown,
|
sourceRaw?: unknown,
|
||||||
extraRules: LegacyConfigRule[] = [],
|
extraRules: LegacyConfigRule[] = [],
|
||||||
|
touchedPaths?: ReadonlyArray<ReadonlyArray<string>>,
|
||||||
): LegacyConfigIssue[] {
|
): LegacyConfigIssue[] {
|
||||||
if (!raw || typeof raw !== "object") {
|
if (!raw || typeof raw !== "object") {
|
||||||
return [];
|
return [];
|
||||||
@@ -28,7 +29,7 @@ export function findLegacyConfigIssues(
|
|||||||
const issues: LegacyConfigIssue[] = [];
|
const issues: LegacyConfigIssue[] = [];
|
||||||
for (const rule of [
|
for (const rule of [
|
||||||
...LEGACY_CONFIG_RULES,
|
...LEGACY_CONFIG_RULES,
|
||||||
...collectChannelLegacyConfigRules(raw),
|
...collectChannelLegacyConfigRules(raw, touchedPaths),
|
||||||
...extraRules,
|
...extraRules,
|
||||||
]) {
|
]) {
|
||||||
const cursor = getPathValue(root, rule.path);
|
const cursor = getPathValue(root, rule.path);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "../plugins/config-state.js";
|
} from "../plugins/config-state.js";
|
||||||
import {
|
import {
|
||||||
collectRelevantDoctorPluginIds,
|
collectRelevantDoctorPluginIds,
|
||||||
|
collectRelevantDoctorPluginIdsForTouchedPaths,
|
||||||
listPluginDoctorLegacyConfigRules,
|
listPluginDoctorLegacyConfigRules,
|
||||||
} from "../plugins/doctor-contract-registry.js";
|
} from "../plugins/doctor-contract-registry.js";
|
||||||
import { resolveManifestCommandAliasOwner } from "../plugins/manifest-command-aliases.runtime.js";
|
import { resolveManifestCommandAliasOwner } from "../plugins/manifest-command-aliases.runtime.js";
|
||||||
@@ -571,12 +572,21 @@ function validateGatewayTailscaleBind(config: OpenClawConfig): ConfigValidationI
|
|||||||
*/
|
*/
|
||||||
export function validateConfigObjectRaw(
|
export function validateConfigObjectRaw(
|
||||||
raw: unknown,
|
raw: unknown,
|
||||||
|
opts?: {
|
||||||
|
touchedPaths?: ReadonlyArray<ReadonlyArray<string>>;
|
||||||
|
},
|
||||||
): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } {
|
): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } {
|
||||||
const policyIssues = collectUnsupportedSecretRefPolicyIssues(raw);
|
const policyIssues = collectUnsupportedSecretRefPolicyIssues(raw);
|
||||||
|
const doctorPluginIds = opts?.touchedPaths
|
||||||
|
? collectRelevantDoctorPluginIdsForTouchedPaths({
|
||||||
|
raw,
|
||||||
|
touchedPaths: opts.touchedPaths,
|
||||||
|
})
|
||||||
|
: collectRelevantDoctorPluginIds(raw);
|
||||||
const extraLegacyRules = listPluginDoctorLegacyConfigRules({
|
const extraLegacyRules = listPluginDoctorLegacyConfigRules({
|
||||||
pluginIds: collectRelevantDoctorPluginIds(raw),
|
pluginIds: doctorPluginIds,
|
||||||
});
|
});
|
||||||
const legacyIssues = findLegacyConfigIssues(raw, raw, extraLegacyRules);
|
const legacyIssues = findLegacyConfigIssues(raw, raw, extraLegacyRules, opts?.touchedPaths);
|
||||||
if (legacyIssues.length > 0) {
|
if (legacyIssues.length > 0) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const tempDirs: string[] = [];
|
|||||||
const mocks = getRegistryJitiMocks();
|
const mocks = getRegistryJitiMocks();
|
||||||
|
|
||||||
let clearPluginDoctorContractRegistryCache: typeof import("./doctor-contract-registry.js").clearPluginDoctorContractRegistryCache;
|
let clearPluginDoctorContractRegistryCache: typeof import("./doctor-contract-registry.js").clearPluginDoctorContractRegistryCache;
|
||||||
|
let collectRelevantDoctorPluginIdsForTouchedPaths: typeof import("./doctor-contract-registry.js").collectRelevantDoctorPluginIdsForTouchedPaths;
|
||||||
let listPluginDoctorLegacyConfigRules: typeof import("./doctor-contract-registry.js").listPluginDoctorLegacyConfigRules;
|
let listPluginDoctorLegacyConfigRules: typeof import("./doctor-contract-registry.js").listPluginDoctorLegacyConfigRules;
|
||||||
|
|
||||||
function makeTempDir(): string {
|
function makeTempDir(): string {
|
||||||
@@ -25,8 +26,11 @@ describe("doctor-contract-registry getJiti", () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
resetRegistryJitiMocks();
|
resetRegistryJitiMocks();
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
({ clearPluginDoctorContractRegistryCache, listPluginDoctorLegacyConfigRules } =
|
({
|
||||||
await import("./doctor-contract-registry.js"));
|
clearPluginDoctorContractRegistryCache,
|
||||||
|
collectRelevantDoctorPluginIdsForTouchedPaths,
|
||||||
|
listPluginDoctorLegacyConfigRules,
|
||||||
|
} = await import("./doctor-contract-registry.js"));
|
||||||
clearPluginDoctorContractRegistryCache();
|
clearPluginDoctorContractRegistryCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,4 +60,49 @@ describe("doctor-contract-registry getJiti", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("narrows touched-path doctor ids for scoped dry-run validation", () => {
|
||||||
|
expect(
|
||||||
|
collectRelevantDoctorPluginIdsForTouchedPaths({
|
||||||
|
raw: {
|
||||||
|
channels: {
|
||||||
|
discord: {},
|
||||||
|
telegram: {},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
entries: {
|
||||||
|
"memory-wiki": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
talk: {
|
||||||
|
voiceId: "legacy-voice",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
touchedPaths: [
|
||||||
|
["channels", "discord", "token"],
|
||||||
|
["plugins", "entries", "memory-wiki", "enabled"],
|
||||||
|
["talk", "voiceId"],
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual(["discord", "elevenlabs", "memory-wiki"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the full doctor-id set when touched paths are too broad", () => {
|
||||||
|
expect(
|
||||||
|
collectRelevantDoctorPluginIdsForTouchedPaths({
|
||||||
|
raw: {
|
||||||
|
channels: {
|
||||||
|
discord: {},
|
||||||
|
telegram: {},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
entries: {
|
||||||
|
"memory-wiki": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
touchedPaths: [["channels"]],
|
||||||
|
}),
|
||||||
|
).toEqual(["discord", "memory-wiki", "telegram"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -161,6 +161,42 @@ export function collectRelevantDoctorPluginIds(raw: unknown): string[] {
|
|||||||
return [...ids].toSorted();
|
return [...ids].toSorted();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function collectRelevantDoctorPluginIdsForTouchedPaths(params: {
|
||||||
|
raw: unknown;
|
||||||
|
touchedPaths: ReadonlyArray<ReadonlyArray<string>>;
|
||||||
|
}): string[] {
|
||||||
|
const root = asNullableRecord(params.raw);
|
||||||
|
if (!root) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const touchedPath of params.touchedPaths) {
|
||||||
|
const [first, second, third] = touchedPath;
|
||||||
|
if (first === "channels") {
|
||||||
|
if (!second) {
|
||||||
|
return collectRelevantDoctorPluginIds(params.raw);
|
||||||
|
}
|
||||||
|
if (second !== "defaults") {
|
||||||
|
ids.add(second);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (first === "plugins") {
|
||||||
|
if (second !== "entries" || !third) {
|
||||||
|
return collectRelevantDoctorPluginIds(params.raw);
|
||||||
|
}
|
||||||
|
ids.add(third);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (first === "talk" && hasLegacyElevenLabsTalkFields(root)) {
|
||||||
|
ids.add("elevenlabs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...ids].toSorted();
|
||||||
|
}
|
||||||
|
|
||||||
function getDoctorContractRecordCache(
|
function getDoctorContractRecordCache(
|
||||||
baseCacheKey: string,
|
baseCacheKey: string,
|
||||||
): Map<string, PluginDoctorContractEntry | null> {
|
): Map<string, PluginDoctorContractEntry | null> {
|
||||||
|
|||||||
Reference in New Issue
Block a user