mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-23 14:45:46 +00:00
refactor(config): simplify version and allowed-value resolution
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user