refactor(config): simplify version and allowed-value resolution

This commit is contained in:
Peter Steinberger
2026-03-27 02:37:08 +00:00
parent 417b3dd5e0
commit 66e7e29219
4 changed files with 209 additions and 57 deletions

View File

@@ -8,33 +8,45 @@ import { SignalChannelConfigSchema } from "../../extensions/signal/channel-confi
import { SlackChannelConfigSchema } from "../../extensions/slack/channel-config-api.js";
import { TelegramChannelConfigSchema } from "../../extensions/telegram/channel-config-api.js";
import { WhatsAppChannelConfigSchema } from "../../extensions/whatsapp/channel-config-api.js";
import type { ChannelConfigRuntimeSchema } from "../channels/plugins/types.plugin.js";
import type {
ChannelConfigRuntimeSchema,
ChannelConfigSchema,
} from "../channels/plugins/types.plugin.js";
type BundledChannelRuntimeMap = ReadonlyMap<string, ChannelConfigRuntimeSchema>;
type BundledChannelConfigSchemaMap = ReadonlyMap<string, ChannelConfigSchema>;
const bundledChannelRuntimeEntries: ReadonlyArray<
readonly [string, ChannelConfigRuntimeSchema | undefined]
const bundledChannelSchemaEntries: ReadonlyArray<
readonly [string, ChannelConfigSchema | undefined]
> = [
["bluebubbles", BlueBubblesChannelConfigSchema.runtime],
["discord", DiscordChannelConfigSchema.runtime],
["googlechat", GoogleChatChannelConfigSchema.runtime],
["imessage", IMessageChannelConfigSchema.runtime],
["irc", IrcChannelConfigSchema.runtime],
["msteams", MSTeamsChannelConfigSchema.runtime],
["signal", SignalChannelConfigSchema.runtime],
["slack", SlackChannelConfigSchema.runtime],
["telegram", TelegramChannelConfigSchema.runtime],
["whatsapp", WhatsAppChannelConfigSchema.runtime],
["bluebubbles", BlueBubblesChannelConfigSchema],
["discord", DiscordChannelConfigSchema],
["googlechat", GoogleChatChannelConfigSchema],
["imessage", IMessageChannelConfigSchema],
["irc", IrcChannelConfigSchema],
["msteams", MSTeamsChannelConfigSchema],
["signal", SignalChannelConfigSchema],
["slack", SlackChannelConfigSchema],
["telegram", TelegramChannelConfigSchema],
["whatsapp", WhatsAppChannelConfigSchema],
] as const;
const bundledChannelRuntimeMap = new Map<string, ChannelConfigRuntimeSchema>();
for (const [channelId, runtimeSchema] of bundledChannelRuntimeEntries) {
if (!runtimeSchema) {
const bundledChannelConfigSchemaMap = new Map<string, ChannelConfigSchema>();
for (const [channelId, channelSchema] of bundledChannelSchemaEntries) {
if (!channelSchema) {
continue;
}
bundledChannelRuntimeMap.set(channelId, runtimeSchema);
bundledChannelConfigSchemaMap.set(channelId, channelSchema);
if (channelSchema.runtime) {
bundledChannelRuntimeMap.set(channelId, channelSchema.runtime);
}
}
export function getBundledChannelRuntimeMap(): BundledChannelRuntimeMap {
return bundledChannelRuntimeMap;
}
export function getBundledChannelConfigSchemaMap(): BundledChannelConfigSchemaMap {
return bundledChannelConfigSchemaMap;
}

View File

@@ -21,6 +21,7 @@ import { isCanonicalDottedDecimalIPv4, isLoopbackIpAddress } from "../shared/net
import { isRecord } from "../utils.js";
import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js";
import { appendAllowedValuesHint, summarizeAllowedValues } from "./allowed-values.js";
import { getBundledChannelConfigSchemaMap } from "./bundled-channel-config-runtime.js";
import { collectChannelSchemaMetadata } from "./channel-config-metadata.js";
import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js";
import {
@@ -34,14 +35,15 @@ import { OpenClawSchema } from "./zod-schema.js";
const LEGACY_REMOVED_PLUGIN_IDS = new Set(["google-antigravity-auth", "google-gemini-cli-auth"]);
type UnknownIssueRecord = Record<string, unknown>;
type ConfigPathSegment = string | number;
type AllowedValuesCollection = {
values: unknown[];
incomplete: boolean;
hasValues: boolean;
};
type JsonSchemaNode = Record<string, unknown>;
const CUSTOM_EXPECTED_ONE_OF_RE = /expected one of ((?:"[^"]+"(?:\|"?[^"]+"?)*)+)/i;
const STREAMING_ALLOWED_VALUES = [true, false, "off", "partial", "block", "progress"] as const;
function toIssueRecord(value: unknown): UnknownIssueRecord | null {
if (!value || typeof value !== "object") {
@@ -50,7 +52,142 @@ function toIssueRecord(value: unknown): UnknownIssueRecord | null {
return value as UnknownIssueRecord;
}
function toConfigPathSegments(path: unknown): ConfigPathSegment[] {
if (!Array.isArray(path)) {
return [];
}
return path.filter((segment): segment is ConfigPathSegment => {
const segmentType = typeof segment;
return segmentType === "string" || segmentType === "number";
});
}
function formatConfigPath(segments: readonly ConfigPathSegment[]): string {
return segments.join(".");
}
function toJsonSchemaNode(value: unknown): JsonSchemaNode | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as JsonSchemaNode;
}
function getSchemaCombinatorBranches(node: JsonSchemaNode): JsonSchemaNode[] {
const keys = ["anyOf", "oneOf", "allOf"] as const;
const branches: JsonSchemaNode[] = [];
for (const key of keys) {
const value = node[key];
if (!Array.isArray(value)) {
continue;
}
for (const entry of value) {
const child = toJsonSchemaNode(entry);
if (child) {
branches.push(child);
}
}
}
return branches;
}
function collectAllowedValuesFromSchemaNode(node: JsonSchemaNode): AllowedValuesCollection {
if (Object.prototype.hasOwnProperty.call(node, "const")) {
return { values: [node.const], incomplete: false, hasValues: true };
}
const enumValues = node.enum;
if (Array.isArray(enumValues)) {
return { values: enumValues, incomplete: false, hasValues: enumValues.length > 0 };
}
if (node.type === "boolean") {
return { values: [true, false], incomplete: false, hasValues: true };
}
const branches = getSchemaCombinatorBranches(node);
if (branches.length === 0) {
return { values: [], incomplete: true, hasValues: false };
}
const collected: unknown[] = [];
for (const branch of branches) {
const result = collectAllowedValuesFromSchemaNode(branch);
if (result.incomplete || !result.hasValues) {
return { values: [], incomplete: true, hasValues: false };
}
collected.push(...result.values);
}
return { values: collected, incomplete: false, hasValues: collected.length > 0 };
}
function advanceSchemaNodes(node: JsonSchemaNode, segment: ConfigPathSegment): JsonSchemaNode[] {
const branches = getSchemaCombinatorBranches(node);
if (branches.length > 0) {
return branches.flatMap((branch) => advanceSchemaNodes(branch, segment));
}
if (typeof segment === "number") {
const items = toJsonSchemaNode(node.items);
return items ? [items] : [];
}
const properties = toJsonSchemaNode(node.properties);
const propertyNode = properties ? toJsonSchemaNode(properties[segment]) : null;
if (propertyNode) {
return [propertyNode];
}
const additionalProperties = toJsonSchemaNode(node.additionalProperties);
return additionalProperties ? [additionalProperties] : [];
}
function collectAllowedValuesFromSchemaPath(
root: JsonSchemaNode,
path: readonly ConfigPathSegment[],
): AllowedValuesCollection {
let currentNodes = [root];
for (const segment of path) {
currentNodes = currentNodes.flatMap((node) => advanceSchemaNodes(node, segment));
if (currentNodes.length === 0) {
return { values: [], incomplete: false, hasValues: false };
}
}
const collected: unknown[] = [];
for (const node of currentNodes) {
const result = collectAllowedValuesFromSchemaNode(node);
if (result.incomplete || !result.hasValues) {
return { values: [], incomplete: true, hasValues: false };
}
collected.push(...result.values);
}
return { values: collected, incomplete: false, hasValues: collected.length > 0 };
}
function collectAllowedValuesFromConfigPath(
path: readonly ConfigPathSegment[],
): AllowedValuesCollection {
if (path[0] === "channels" && typeof path[1] === "string") {
const channelSchema = getBundledChannelConfigSchemaMap().get(path[1]);
const schemaRoot = toJsonSchemaNode(channelSchema?.schema);
if (schemaRoot) {
return collectAllowedValuesFromSchemaPath(schemaRoot, path.slice(2));
}
}
return { values: [], incomplete: false, hasValues: false };
}
function collectAllowedValuesFromCustomIssue(record: UnknownIssueRecord): AllowedValuesCollection {
const path = toConfigPathSegments(record.path);
const schemaValues = collectAllowedValuesFromConfigPath(path);
if (schemaValues.hasValues && !schemaValues.incomplete) {
return schemaValues;
}
const message = typeof record.message === "string" ? record.message : "";
const expectedMatch = message.match(CUSTOM_EXPECTED_ONE_OF_RE);
if (expectedMatch?.[1]) {
@@ -58,17 +195,6 @@ function collectAllowedValuesFromCustomIssue(record: UnknownIssueRecord): Allowe
return { values, incomplete: false, hasValues: values.length > 0 };
}
const path = Array.isArray(record.path)
? record.path.filter((segment): segment is string => typeof segment === "string")
: [];
if (path.at(-1) === "streaming") {
return {
values: [...STREAMING_ALLOWED_VALUES],
incomplete: false,
hasValues: true,
};
}
return { values: [], incomplete: false, hasValues: false };
}
@@ -152,14 +278,7 @@ function collectAllowedValuesFromUnknownIssue(issue: unknown): unknown[] {
function mapZodIssueToConfigIssue(issue: unknown): ConfigValidationIssue {
const record = toIssueRecord(issue);
const path = Array.isArray(record?.path)
? record.path
.filter((segment): segment is string | number => {
const segmentType = typeof segment;
return segmentType === "string" || segmentType === "number";
})
.join(".")
: "";
const path = formatConfigPath(toConfigPathSegments(record?.path));
const message = typeof record?.message === "string" ? record.message : "Invalid input";
const allowedValuesSummary = summarizeAllowedValues(collectAllowedValuesFromUnknownIssue(issue));

View File

@@ -160,6 +160,16 @@ describe("version resolution", () => {
}
});
it("keeps explicit env-object overrides for compatibility checks in tests", () => {
expect(
resolveCompatibilityHostVersion({
OPENCLAW_VERSION: "2026.3.99",
OPENCLAW_SERVICE_VERSION: "2026.3.98",
npm_package_version: "2026.3.97",
}),
).toBe("2026.3.99");
});
it("normalizes runtime version candidate for fallback handling", () => {
expect(resolveUsableRuntimeVersion(undefined)).toBeUndefined();
expect(resolveUsableRuntimeVersion("")).toBeUndefined();

View File

@@ -91,6 +91,7 @@ export type RuntimeVersionEnv = {
};
export const RUNTIME_SERVICE_VERSION_FALLBACK = "unknown";
type RuntimeVersionPreference = "env-first" | "runtime-first";
export function resolveUsableRuntimeVersion(version: string | undefined): string | undefined {
const trimmed = version?.trim();
@@ -102,37 +103,47 @@ export function resolveUsableRuntimeVersion(version: string | undefined): string
return trimmed;
}
function resolveVersionFromRuntimeSources(params: {
env: RuntimeVersionEnv;
runtimeVersion: string | undefined;
fallback: string;
preference: RuntimeVersionPreference;
}): string {
const preferredCandidates =
params.preference === "env-first"
? [params.env["OPENCLAW_VERSION"], params.runtimeVersion]
: [params.runtimeVersion, params.env["OPENCLAW_VERSION"]];
return (
firstNonEmpty(
...preferredCandidates,
params.env["OPENCLAW_SERVICE_VERSION"],
params.env["npm_package_version"],
) ?? params.fallback
);
}
export function resolveRuntimeServiceVersion(
env: RuntimeVersionEnv = process.env as RuntimeVersionEnv,
fallback = RUNTIME_SERVICE_VERSION_FALLBACK,
): string {
const runtimeVersion = resolveUsableRuntimeVersion(VERSION);
return (
firstNonEmpty(
env["OPENCLAW_VERSION"],
runtimeVersion,
env["OPENCLAW_SERVICE_VERSION"],
env["npm_package_version"],
) ?? fallback
);
return resolveVersionFromRuntimeSources({
env,
runtimeVersion: resolveUsableRuntimeVersion(VERSION),
fallback,
preference: "env-first",
});
}
export function resolveCompatibilityHostVersion(
env: RuntimeVersionEnv = process.env as RuntimeVersionEnv,
fallback = RUNTIME_SERVICE_VERSION_FALLBACK,
): string {
const runtimeVersion = resolveUsableRuntimeVersion(VERSION);
const prefersExplicitEnvVersion = env !== (process.env as RuntimeVersionEnv);
return (
firstNonEmpty(
prefersExplicitEnvVersion ? env["OPENCLAW_VERSION"] : runtimeVersion,
prefersExplicitEnvVersion ? runtimeVersion : env["OPENCLAW_VERSION"],
env["OPENCLAW_SERVICE_VERSION"],
env["npm_package_version"],
) ?? fallback
);
return resolveVersionFromRuntimeSources({
env,
runtimeVersion: resolveUsableRuntimeVersion(VERSION),
fallback,
preference: env === (process.env as RuntimeVersionEnv) ? "runtime-first" : "env-first",
});
}
// Single source of truth for the current OpenClaw version.