perf: precompute base config schema

This commit is contained in:
Peter Steinberger
2026-03-22 21:19:28 +00:00
parent 593e333c10
commit ca99163b98
6 changed files with 16481 additions and 38 deletions

View File

@@ -571,7 +571,8 @@
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
"build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts",
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
"check": "pnpm check:host-env-policy:swift && pnpm check:bundled-plugin-metadata && pnpm check:bundled-provider-auth-env-vars && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:no-extension-src-imports && pnpm lint:plugins:no-extension-test-core-imports && pnpm lint:plugins:no-extension-imports && pnpm lint:plugins:plugin-sdk-subpaths-exported && pnpm lint:extensions:no-src-outside-plugin-sdk && pnpm lint:extensions:no-plugin-sdk-internal && pnpm lint:extensions:no-relative-outside-package && pnpm lint:web-search-provider-boundaries && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope",
"check": "pnpm check:host-env-policy:swift && pnpm check:base-config-schema && pnpm check:bundled-plugin-metadata && pnpm check:bundled-provider-auth-env-vars && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:no-extension-src-imports && pnpm lint:plugins:no-extension-test-core-imports && pnpm lint:plugins:no-extension-imports && pnpm lint:plugins:plugin-sdk-subpaths-exported && pnpm lint:extensions:no-src-outside-plugin-sdk && pnpm lint:extensions:no-plugin-sdk-internal && pnpm lint:extensions:no-relative-outside-package && pnpm lint:web-search-provider-boundaries && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope",
"check:base-config-schema": "node --import tsx scripts/generate-base-config-schema.ts --check",
"check:bundled-plugin-metadata": "node scripts/generate-bundled-plugin-metadata.mjs --check",
"check:bundled-provider-auth-env-vars": "node scripts/generate-bundled-provider-auth-env-vars.mjs --check",
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-i18n-glossary && pnpm docs:check-links",
@@ -579,6 +580,8 @@
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
"config:docs:check": "node --import tsx scripts/generate-config-doc-baseline.ts --check",
"config:docs:gen": "node --import tsx scripts/generate-config-doc-baseline.ts --write",
"config:schema:check": "node --import tsx scripts/generate-base-config-schema.ts --check",
"config:schema:gen": "node --import tsx scripts/generate-base-config-schema.ts --write",
"deadcode:ci": "pnpm deadcode:report:ci:knip",
"deadcode:knip": "pnpm dlx knip --config knip.config.ts --isolate-workspaces --production --no-progress --reporter compact --files --dependencies",
"deadcode:report": "pnpm deadcode:knip; pnpm deadcode:ts-prune; pnpm deadcode:ts-unused",

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { computeBaseConfigSchemaResponse } from "../src/config/schema-base.js";
const GENERATED_BY = "scripts/generate-base-config-schema.ts";
const DEFAULT_OUTPUT_PATH = "src/config/schema.base.generated.ts";
function readIfExists(filePath: string): string | null {
try {
return fs.readFileSync(filePath, "utf8");
} catch {
return null;
}
}
function formatTypeScriptModule(source: string, outputPath: string): string {
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const formatter = spawnSync(
process.platform === "win32" ? "pnpm.cmd" : "pnpm",
["exec", "oxfmt", "--stdin-filepath", outputPath],
{
cwd: repoRoot,
input: source,
encoding: "utf8",
},
);
if (formatter.status !== 0) {
const details =
formatter.stderr?.trim() || formatter.stdout?.trim() || "unknown formatter failure";
throw new Error(`failed to format generated base config schema: ${details}`);
}
return formatter.stdout;
}
export function renderBaseConfigSchemaModule(params?: { generatedAt?: string }): string {
const payload = computeBaseConfigSchemaResponse({
generatedAt: params?.generatedAt ?? new Date().toISOString(),
});
return formatTypeScriptModule(
`// Auto-generated by ${GENERATED_BY}. Do not edit directly.
import type { BaseConfigSchemaResponse } from "./schema-base.js";
export const GENERATED_BASE_CONFIG_SCHEMA = ${JSON.stringify(payload, null, 2)} as const satisfies BaseConfigSchemaResponse;
`,
DEFAULT_OUTPUT_PATH,
);
}
export function writeBaseConfigSchemaModule(params?: {
repoRoot?: string;
outputPath?: string;
check?: boolean;
}): { changed: boolean; wrote: boolean; outputPath: string } {
const repoRoot = path.resolve(
params?.repoRoot ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."),
);
const outputPath = path.resolve(repoRoot, params?.outputPath ?? DEFAULT_OUTPUT_PATH);
const current = readIfExists(outputPath);
const generatedAt =
current?.match(/generatedAt:\s*"([^"]+)"/u)?.[1] ??
current?.match(/"generatedAt":\s*"([^"]+)"/u)?.[1] ??
new Date().toISOString();
const next = renderBaseConfigSchemaModule({ generatedAt });
const changed = current !== next;
if (params?.check) {
return { changed, wrote: false, outputPath };
}
if (changed) {
fs.writeFileSync(outputPath, next, "utf8");
}
return { changed, wrote: changed, outputPath };
}
const args = new Set(process.argv.slice(2));
if (args.has("--check") && args.has("--write")) {
throw new Error("Use either --check or --write, not both.");
}
if (import.meta.url === new URL(process.argv[1] ?? "", "file://").href) {
const result = writeBaseConfigSchemaModule({ check: args.has("--check") });
if (result.changed) {
if (args.has("--check")) {
console.error(
`[base-config-schema] stale generated output at ${path.relative(process.cwd(), result.outputPath)}`,
);
process.exitCode = 1;
} else {
console.log(`[base-config-schema] wrote ${path.relative(process.cwd(), result.outputPath)}`);
}
}
}

72
src/config/schema-base.ts Normal file
View File

@@ -0,0 +1,72 @@
import { VERSION } from "../version.js";
import type { ConfigUiHints } from "./schema.hints.js";
import { buildBaseHints, mapSensitivePaths } from "./schema.hints.js";
import { applyDerivedTags } from "./schema.tags.js";
import { OpenClawSchema } from "./zod-schema.js";
type ConfigSchema = Record<string, unknown>;
type JsonSchemaObject = Record<string, unknown> & {
properties?: Record<string, JsonSchemaObject>;
required?: string[];
additionalProperties?: JsonSchemaObject | boolean;
};
export type BaseConfigSchemaResponse = {
schema: ConfigSchema;
uiHints: ConfigUiHints;
version: string;
generatedAt: string;
};
function cloneSchema<T>(value: T): T {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
}
function asSchemaObject(value: unknown): JsonSchemaObject | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as JsonSchemaObject;
}
function stripChannelSchema(schema: ConfigSchema): ConfigSchema {
const next = cloneSchema(schema);
const root = asSchemaObject(next);
if (!root || !root.properties) {
return next;
}
// Allow `$schema` in config files for editor tooling, but hide it from the
// Control UI form schema so it does not show up as a configurable section.
delete root.properties.$schema;
if (Array.isArray(root.required)) {
root.required = root.required.filter((key) => key !== "$schema");
}
const channelsNode = asSchemaObject(root.properties.channels);
if (channelsNode) {
channelsNode.properties = {};
channelsNode.required = [];
channelsNode.additionalProperties = true;
}
return next;
}
export function computeBaseConfigSchemaResponse(params?: {
generatedAt?: string;
}): BaseConfigSchemaResponse {
const schema = OpenClawSchema.toJSONSchema({
target: "draft-07",
unrepresentable: "any",
});
schema.title = "OpenClawConfig";
const hints = applyDerivedTags(mapSensitivePaths(OpenClawSchema, "", buildBaseHints()));
return {
schema: stripChannelSchema(schema),
uiHints: hints,
version: VERSION,
generatedAt: params?.generatedAt ?? new Date().toISOString(),
};
}

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { computeBaseConfigSchemaResponse } from "./schema-base.js";
import { GENERATED_BASE_CONFIG_SCHEMA } from "./schema.base.generated.js";
describe("generated base config schema", () => {
it("matches the computed base config schema payload", () => {
expect(
computeBaseConfigSchemaResponse({
generatedAt: GENERATED_BASE_CONFIG_SCHEMA.generatedAt,
}),
).toEqual(GENERATED_BASE_CONFIG_SCHEMA);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,14 @@
import crypto from "node:crypto";
import { CHANNEL_IDS } from "../channels/registry.js";
import { VERSION } from "../version.js";
import { GENERATED_BASE_CONFIG_SCHEMA } from "./schema.base.generated.js";
import type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js";
import { applySensitiveHints, buildBaseHints, mapSensitivePaths } from "./schema.hints.js";
import { applySensitiveHints } from "./schema.hints.js";
import { findWildcardHintMatch, schemaHasChildren } from "./schema.shared.js";
import { applyDerivedTags } from "./schema.tags.js";
import { OpenClawSchema } from "./zod-schema.js";
export type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js";
export type ConfigSchema = ReturnType<typeof OpenClawSchema.toJSONSchema>;
export type ConfigSchema = Record<string, unknown>;
type JsonSchemaNode = Record<string, unknown>;
@@ -406,43 +405,11 @@ function setMergedSchemaCache(key: string, value: ConfigSchemaResponse): void {
mergedSchemaCache.set(key, value);
}
function stripChannelSchema(schema: ConfigSchema): ConfigSchema {
const next = cloneSchema(schema);
const root = asSchemaObject(next);
if (!root || !root.properties) {
return next;
}
// Allow `$schema` in config files for editor tooling, but hide it from the
// Control UI form schema so it does not show up as a configurable section.
delete root.properties.$schema;
if (Array.isArray(root.required)) {
root.required = root.required.filter((key) => key !== "$schema");
}
const channelsNode = asSchemaObject(root.properties.channels);
if (channelsNode) {
channelsNode.properties = {};
channelsNode.required = [];
channelsNode.additionalProperties = true;
}
return next;
}
function buildBaseConfigSchema(): ConfigSchemaResponse {
if (cachedBase) {
return cachedBase;
}
const schema = OpenClawSchema.toJSONSchema({
target: "draft-07",
unrepresentable: "any",
});
schema.title = "OpenClawConfig";
const hints = applyDerivedTags(mapSensitivePaths(OpenClawSchema, "", buildBaseHints()));
const next = {
schema: stripChannelSchema(schema),
uiHints: hints,
version: VERSION,
generatedAt: new Date().toISOString(),
};
const next = GENERATED_BASE_CONFIG_SCHEMA as unknown as ConfigSchemaResponse;
cachedBase = next;
return next;
}