refactor(config): declarative doctor alias-migration DSL with inheritance-aware account seeding (#105636)

* fix(config): seed inherited root streaming into doctor-materialized account streaming objects

* feat(plugin-sdk): add defineChannelAliasMigration doctor alias-migration DSL

* refactor(plugins): migrate channel doctor contracts onto the alias-migration DSL

* refactor(config): drop the prod-unused offMode legacy notice option

* fix(config): make account streaming seeding opt-in for wholesale-replace channels

* test(imessage): keep account streaming account-local for deep-merge runtime
This commit is contained in:
Peter Steinberger
2026-07-12 12:28:42 -07:00
committed by GitHub
parent 5fd7b5c40a
commit 6556d1d173
14 changed files with 739 additions and 279 deletions
@@ -1,2 +1,2 @@
4d48d01820bdf49966c3a6bdc69da909779d81db7214ed5b54651a8cd544a24c plugin-sdk-api-baseline.json
72bad044a66391e838dcbdab5ef6897a40a7026d53c86d7130a55d53d37b15f2 plugin-sdk-api-baseline.jsonl
8f362012108b8b2db08e836883fac095f88e70a4c1ef66a713980e1361ec48b6 plugin-sdk-api-baseline.json
ddbb02207930b419658209039696e8008d76c8f839d7ecde95e1c8ba91c19c7a plugin-sdk-api-baseline.jsonl
+48 -81
View File
@@ -8,20 +8,46 @@ import {
isSupportedRealtimeVoiceActivationName,
normalizeRealtimeVoiceActivationNamePrefix,
} from "openclaw/plugin-sdk/realtime-voice";
import {
asObjectRecord,
hasLegacyAccountStreamingAliases,
hasLegacyStreamingAliases,
normalizeLegacyChannelAliases,
resolveLegacyAliasStreamingMode,
} from "openclaw/plugin-sdk/runtime-doctor";
import { asObjectRecord, defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor";
const LEGACY_TTS_PROVIDER_KEYS = ["openai", "elevenlabs", "microsoft", "edge"] as const;
type AgentBindingConfig = NonNullable<OpenClawConfig["bindings"]>[number];
function hasLegacyDiscordStreamingAliases(value: unknown): boolean {
return hasLegacyStreamingAliases(value, { includePreviewChunk: true });
}
const streamingAliasMigration = defineChannelAliasMigration({
channelId: "discord",
streaming: {
// Runtime mode resolution dropped legacy streamMode reads; the doctor
// resolver keeps them so migration preserves configured intent.
defaultMode: "off",
// Discord previews default to progress only while `streaming` is absent;
// any present object (even without mode) resolves off, so migration pins
// progress when delivery-only aliases create the object with no root
// streaming object to inherit from.
absentObjectDefault: "progress",
includePreviewChunk: true,
},
// Discord's account merge replaces the root streaming object wholesale
// (`streaming` not in mergeDiscordAccountConfig nestedObjectKeys), so doctor
// must seed materialized account objects with the inherited root settings.
accountStreamingReplacesRoot: true,
dm: { root: true, accounts: true },
normalizeAccountExtra: ({ account, pathPrefix, changes }) => {
const accountVoice = asObjectRecord(account.voice);
if (
!accountVoice ||
!migrateLegacyTtsConfig(asObjectRecord(accountVoice.tts), `${pathPrefix}.voice.tts`, changes)
) {
return { entry: account, changed: false };
}
return {
entry: {
...account,
voice: accountVoice,
},
changed: true,
};
},
});
function hasLegacyTtsProviderKeys(value: unknown): boolean {
const tts = asObjectRecord(value);
@@ -475,18 +501,7 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
'channels.discord.accounts.<id>.voice.realtime.wakeNames entries longer than two words are unsupported; use one- or two-word activation names. Run "openclaw doctor --fix".',
match: hasUnsupportedDiscordAccountRealtimeWakeNames,
},
{
path: ["channels", "discord"],
message:
'channels.discord.streamMode, channels.discord.streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}. Run "openclaw doctor --fix".',
match: hasLegacyDiscordStreamingAliases,
},
{
path: ["channels", "discord", "accounts"],
message:
'channels.discord.accounts.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.accounts.<id>.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}. Run "openclaw doctor --fix".',
match: (value) => hasLegacyAccountStreamingAliases(value, hasLegacyDiscordStreamingAliases),
},
...streamingAliasMigration.legacyConfigRules,
];
export function normalizeCompatibilityConfig({
@@ -494,66 +509,18 @@ export function normalizeCompatibilityConfig({
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const rawEntry = asObjectRecord((cfg.channels as Record<string, unknown> | undefined)?.discord);
const changes: string[] = [];
const bindingsToAdd: AgentBindingConfig[] = [];
const aliases = streamingAliasMigration.normalizeChannelConfig({ cfg, changes });
const rawEntry = asObjectRecord(
(aliases.config.channels as Record<string, unknown> | undefined)?.discord,
);
if (!rawEntry) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updated;
let changed;
const bindingsToAdd: AgentBindingConfig[] = [];
// Discord previews default to progress only while `streaming` is absent;
// any present object (even without `mode`) resolves off. Migration must pin
// the entry's previous effective mode when delivery-only aliases create the
// object, or doctor silently flips preview behavior.
const discordEffectiveMode = (entry: Record<string, unknown>) =>
entry.streaming === undefined && entry.streamMode === undefined
? "progress"
: resolveLegacyAliasStreamingMode(entry, "off");
const rootEffectiveMode = discordEffectiveMode(rawEntry);
const aliases = normalizeLegacyChannelAliases({
entry: rawEntry,
pathPrefix: "channels.discord",
changes,
normalizeDm: true,
normalizeAccountDm: true,
resolveStreamingOptions: (entry) => ({
// Runtime mode resolution dropped legacy streamMode reads; the doctor
// resolver keeps them so migration preserves configured intent.
resolvedMode: resolveLegacyAliasStreamingMode(entry, "off"),
// Accounts without their own mode source inherit the root's effective
// mode at runtime (account `streaming` objects replace the root object
// wholesale on merge), so pin that. The root only hits the alias-only
// branch when both mode sources are absent, where this is "progress".
aliasOnlyMode: rootEffectiveMode,
includePreviewChunk: true,
}),
normalizeAccountExtra: ({ account, pathPrefix }) => {
const accountVoice = asObjectRecord(account.voice);
if (
!accountVoice ||
!migrateLegacyTtsConfig(
asObjectRecord(accountVoice.tts),
`${pathPrefix}.voice.tts`,
changes,
)
) {
return { entry: account, changed: false };
}
return {
entry: {
...account,
voice: accountVoice,
},
changed: true,
};
},
});
updated = aliases.entry;
changed = aliases.changed;
let updated = rawEntry;
let changed = aliases.config !== cfg;
const guildAliases = normalizeDiscordGuildChannelAllowAliases({
entry: updated,
@@ -639,9 +606,9 @@ export function normalizeCompatibilityConfig({
}
return {
config: {
...cfg,
...aliases.config,
channels: {
...cfg.channels,
...aliases.config.channels,
discord: updated,
} as OpenClawConfig["channels"],
bindings:
+12 -6
View File
@@ -108,16 +108,16 @@ describe("discord doctor", () => {
]);
});
it("pins the inherited root mode when migrating account delivery aliases", () => {
it("seeds the inherited root streaming settings when migrating account delivery aliases", () => {
const normalize = getDiscordCompatibilityNormalizer();
// Account `streaming` objects replace the root object wholesale on merge,
// so the migrated account must carry the root mode it previously inherited.
// so the migrated account must carry the settings it previously inherited.
const result = normalize({
cfg: {
channels: {
discord: {
streaming: { mode: "off" },
streaming: { mode: "off", block: { coalesce: { idleMs: 5 } } },
accounts: { work: { chunkMode: "newline" } },
},
},
@@ -125,14 +125,20 @@ describe("discord doctor", () => {
});
expect(result.config.channels?.discord).toEqual({
streaming: { mode: "off" },
streaming: { mode: "off", block: { coalesce: { idleMs: 5 } } },
accounts: {
work: { streaming: { mode: "off", chunkMode: "newline" } },
work: {
streaming: {
mode: "off",
chunkMode: "newline",
block: { coalesce: { idleMs: 5 } },
},
},
},
});
expect(result.changes).toEqual([
"Moved channels.discord.accounts.work.chunkMode → channels.discord.accounts.work.streaming.chunkMode.",
"Set channels.discord.accounts.work.streaming.mode (off) to keep the previous default while migrating flat streaming keys.",
"Copied channels.discord.streaming into channels.discord.accounts.work.streaming to keep inherited settings while migrating flat streaming keys.",
]);
});
@@ -54,7 +54,12 @@ describe("imessage normalizeCompatibilityConfig streaming aliases", () => {
(imessage.accounts as Record<string, Record<string, unknown>>).personal,
"personal iMessage account",
);
expect(personal.streaming).toEqual({ block: { coalesce: { idleMs: 250 } } });
// iMessage deep-merges root+account streaming at runtime
// (mergeIMessageStreamingConfig), so migration keeps the account object
// account-local instead of seeding root values into it.
expect(personal.streaming).toEqual({
block: { coalesce: { idleMs: 250 } },
});
expect(personal.blockStreamingCoalesce).toBeUndefined();
for (const change of [
"Moved channels.imessage.chunkMode → channels.imessage.streaming.chunkMode.",
+14 -52
View File
@@ -4,11 +4,7 @@ import type {
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
hasLegacyAccountStreamingAliases,
normalizeLegacyChannelAliases,
resolveLegacyAliasStreamingMode,
} from "openclaw/plugin-sdk/runtime-doctor";
import { defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
// Disabled `channels.imessage.catchup` blocks are retired. Enabled blocks stay
@@ -38,16 +34,10 @@ function imessageEntryHasRetiredCatchup(entry: unknown): boolean {
// iMessage's nested streaming schema is delivery-only ({chunkMode, block}); it
// has no preview mode, so only the delivery flat aliases are legal legacy input.
function hasLegacyIMessageStreamingAliases(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
return (
value.chunkMode !== undefined ||
value.blockStreaming !== undefined ||
value.blockStreamingCoalesce !== undefined
);
}
const streamingAliasMigration = defineChannelAliasMigration({
channelId: "imessage",
streaming: { defaultMode: "partial", deliveryOnly: true },
});
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
@@ -57,18 +47,7 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
'Run "openclaw doctor --fix" to remove disabled catchup blocks.',
match: (value) => imessageEntryHasRetiredCatchup(value),
},
{
path: ["channels", "imessage"],
message:
'channels.imessage.chunkMode, blockStreaming, and blockStreamingCoalesce are legacy; use channels.imessage.streaming.{chunkMode,block.enabled,block.coalesce}. Run "openclaw doctor --fix".',
match: hasLegacyIMessageStreamingAliases,
},
{
path: ["channels", "imessage", "accounts"],
message:
'channels.imessage.accounts.<id>.chunkMode, blockStreaming, and blockStreamingCoalesce are legacy; use channels.imessage.accounts.<id>.streaming.{chunkMode,block.enabled,block.coalesce}. Run "openclaw doctor --fix".',
match: (value) => hasLegacyAccountStreamingAliases(value, hasLegacyIMessageStreamingAliases),
},
...streamingAliasMigration.legacyConfigRules,
];
export function normalizeCompatibilityConfig({
@@ -111,32 +90,15 @@ export function normalizeCompatibilityConfig({
}
}
// Only run the shared alias migration when the delivery flat aliases exist;
// iMessage has no streaming mode, so scalar `streaming` values are plain
// validation errors rather than migratable legacy shapes.
const hasStreamingAliases =
hasLegacyIMessageStreamingAliases(nextImessage) ||
hasLegacyAccountStreamingAliases(nextImessage.accounts, hasLegacyIMessageStreamingAliases);
if (hasStreamingAliases) {
const aliases = normalizeLegacyChannelAliases({
entry: nextImessage,
pathPrefix: "channels.imessage",
changes,
resolveStreamingOptions: (entry) => ({
resolvedMode: resolveLegacyAliasStreamingMode(entry, "partial"),
}),
});
nextImessage = aliases.entry;
}
const aliases = streamingAliasMigration.normalizeChannelConfig({
cfg:
nextImessage === imessage
? cfg
: ({ ...cfg, channels: { ...channels, imessage: nextImessage } } as OpenClawConfig),
changes,
});
if (changes.length === 0) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: { ...channels, imessage: nextImessage },
} as OpenClawConfig,
changes,
};
return { config: aliases.config, changes };
}
+11 -37
View File
@@ -10,9 +10,7 @@ import type {
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
archiveLegacyStateSource,
hasLegacyStreamingAliases,
normalizeLegacyChannelAliases,
resolveLegacyAliasStreamingMode,
defineChannelAliasMigration,
type PluginDoctorStateMigration,
} from "openclaw/plugin-sdk/runtime-doctor";
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
@@ -55,46 +53,22 @@ import {
type MSTeamsSsoStoredToken,
} from "./src/sso-token-store.js";
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "msteams"],
message:
'channels.msteams.streamMode, channels.msteams.streaming (scalar), chunkMode, blockStreaming, and blockStreamingCoalesce are legacy; use channels.msteams.streaming.{mode,chunkMode,block.enabled,block.coalesce}. Run "openclaw doctor --fix".',
match: (value) => hasLegacyStreamingAliases(value),
},
];
const streamingAliasMigration = defineChannelAliasMigration({
channelId: "msteams",
// Teams previews default to partial streaming, matching the runtime default
// in reply-dispatcher when no mode is configured.
streaming: { defaultMode: "partial" },
});
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] =
streamingAliasMigration.legacyConfigRules;
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const channels = cfg.channels as Record<string, unknown> | undefined;
const msteams = channels?.msteams;
if (!isRecord(msteams)) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
const aliases = normalizeLegacyChannelAliases({
entry: msteams,
pathPrefix: "channels.msteams",
changes,
resolveStreamingOptions: (entry) => ({
// Teams previews default to partial streaming, matching the runtime
// default in reply-dispatcher when no mode is configured.
resolvedMode: resolveLegacyAliasStreamingMode(entry, "partial"),
}),
});
if (!aliases.changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: { ...channels, msteams: aliases.entry },
} as OpenClawConfig,
changes,
};
return streamingAliasMigration.normalizeChannelConfig({ cfg });
}
type FeedbackLearningEntry = {
+22 -42
View File
@@ -4,17 +4,20 @@ import type {
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
asObjectRecord,
hasLegacyAccountStreamingAliases,
hasLegacyStreamingAliases,
normalizeLegacyChannelAliases,
} from "openclaw/plugin-sdk/runtime-doctor";
import { asObjectRecord, defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor";
import { resolveSlackNativeStreaming, resolveSlackStreamingMode } from "./streaming-compat.js";
function hasLegacySlackStreamingAliases(value: unknown): boolean {
return hasLegacyStreamingAliases(value, { includeNativeTransport: true });
}
const streamingAliasMigration = defineChannelAliasMigration({
channelId: "slack",
streaming: {
// Slack maps its legacy draft stream modes (replace/status_final/append)
// through its own resolver instead of the generic mode parser.
defaultMode: "partial",
resolveMode: resolveSlackStreamingMode,
resolveNativeTransport: resolveSlackNativeStreaming,
},
dm: { root: true, accounts: true },
});
function hasLegacySlackChannelAllowAlias(value: unknown): boolean {
const channels = asObjectRecord(asObjectRecord(value)?.channels);
@@ -57,18 +60,7 @@ function normalizeSlackChannelAllowAliases(params: {
}
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "slack"],
message:
"channels.slack.streamMode, channels.slack.streaming (scalar), chunkMode, blockStreaming, blockStreamingCoalesce, and nativeStreaming are legacy; use channels.slack.streaming.{mode,chunkMode,block.enabled,block.coalesce,nativeTransport}.",
match: hasLegacySlackStreamingAliases,
},
{
path: ["channels", "slack", "accounts"],
message:
"channels.slack.accounts.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, blockStreamingCoalesce, and nativeStreaming are legacy; use channels.slack.accounts.<id>.streaming.{mode,chunkMode,block.enabled,block.coalesce,nativeTransport}.",
match: (value) => hasLegacyAccountStreamingAliases(value, hasLegacySlackStreamingAliases),
},
...streamingAliasMigration.legacyConfigRules,
{
path: ["channels", "slack"],
message:
@@ -94,28 +86,16 @@ export function normalizeCompatibilityConfig({
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const rawEntry = asObjectRecord((cfg.channels as Record<string, unknown> | undefined)?.slack);
const changes: string[] = [];
const aliases = streamingAliasMigration.normalizeChannelConfig({ cfg, changes });
const rawEntry = asObjectRecord(
(aliases.config.channels as Record<string, unknown> | undefined)?.slack,
);
if (!rawEntry) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updated;
let changed;
const aliases = normalizeLegacyChannelAliases({
entry: rawEntry,
pathPrefix: "channels.slack",
changes,
normalizeDm: true,
normalizeAccountDm: true,
resolveStreamingOptions: (entry) => ({
resolvedMode: resolveSlackStreamingMode(entry),
resolvedNativeTransport: resolveSlackNativeStreaming(entry),
}),
});
updated = aliases.entry;
changed = aliases.changed;
let updated = rawEntry;
let changed = aliases.config !== cfg;
const channels = asObjectRecord(updated.channels);
if (channels) {
@@ -162,9 +142,9 @@ export function normalizeCompatibilityConfig({
}
return {
config: {
...cfg,
...aliases.config,
channels: {
...cfg.channels,
...aliases.config.channels,
slack: updated as unknown as NonNullable<OpenClawConfig["channels"]>["slack"],
} as OpenClawConfig["channels"],
},
+16 -41
View File
@@ -5,17 +5,14 @@ import type {
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history";
import {
asObjectRecord,
hasLegacyAccountStreamingAliases,
hasLegacyStreamingAliases,
normalizeLegacyChannelAliases,
resolveLegacyAliasStreamingMode,
} from "openclaw/plugin-sdk/runtime-doctor";
import { asObjectRecord, defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor";
function hasLegacyTelegramStreamingAliases(value: unknown): boolean {
return hasLegacyStreamingAliases(value, { includePreviewChunk: true });
}
const streamingAliasMigration = defineChannelAliasMigration({
channelId: "telegram",
// Runtime mode resolution dropped legacy streamMode reads; the doctor
// resolver keeps them so migration preserves configured intent.
streaming: { defaultMode: "partial", includePreviewChunk: true },
});
function hasRetiredTelegramDmConfig(value: unknown): boolean {
const entry = asObjectRecord(value);
@@ -241,18 +238,7 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
'channels.telegram.accounts.<id>.includeGroupHistoryContext was removed; Telegram group history is always on for groups and bounded by historyLimit. Run "openclaw doctor --fix".',
match: hasRetiredTelegramAccountGroupHistoryContextConfig,
},
{
path: ["channels", "telegram"],
message:
"channels.telegram.streamMode, channels.telegram.streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.telegram.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}.",
match: hasLegacyTelegramStreamingAliases,
},
{
path: ["channels", "telegram", "accounts"],
message:
"channels.telegram.accounts.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.telegram.accounts.<id>.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}.",
match: (value) => hasLegacyAccountStreamingAliases(value, hasLegacyTelegramStreamingAliases),
},
...streamingAliasMigration.legacyConfigRules,
];
export function normalizeCompatibilityConfig({
@@ -260,14 +246,17 @@ export function normalizeCompatibilityConfig({
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const rawEntry = asObjectRecord((cfg.channels as Record<string, unknown> | undefined)?.telegram);
const changes: string[] = [];
const aliases = streamingAliasMigration.normalizeChannelConfig({ cfg, changes });
const rawEntry = asObjectRecord(
(aliases.config.channels as Record<string, unknown> | undefined)?.telegram,
);
if (!rawEntry) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updated = rawEntry;
let changed = false;
let changed = aliases.config !== cfg;
const rootGroupHistoryContextMode = updated.includeGroupHistoryContext;
const rootGroupHistoryLimitBeforeMigration =
typeof updated.historyLimit === "number"
@@ -324,20 +313,6 @@ export function normalizeCompatibilityConfig({
}
}
const aliases = normalizeLegacyChannelAliases({
entry: updated,
pathPrefix: "channels.telegram",
changes,
resolveStreamingOptions: (entry) => ({
includePreviewChunk: true,
// Runtime mode resolution dropped legacy streamMode reads; the doctor
// resolver keeps them so migration preserves configured intent.
resolvedMode: resolveLegacyAliasStreamingMode(entry, "partial"),
}),
});
updated = aliases.entry;
changed = changed || aliases.changed;
const accounts = asObjectRecord(updated.accounts);
if (accounts) {
let accountsChanged = false;
@@ -389,9 +364,9 @@ export function normalizeCompatibilityConfig({
}
return {
config: {
...cfg,
...aliases.config,
channels: {
...cfg.channels,
...aliases.config.channels,
telegram: updated as unknown as NonNullable<OpenClawConfig["channels"]>["telegram"],
} as OpenClawConfig["channels"],
},
+2 -2
View File
@@ -199,12 +199,12 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
),
publicExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS",
10636,
10639,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS",
5354,
5355,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
+132 -5
View File
@@ -1,6 +1,10 @@
// Doctor legacy-config tests cover compatibility normalizers for old channel, browser, and config shapes.
import { describe, expect, it } from "vitest";
import { normalizeLegacyStreamingAliases } from "../config/channel-compat-normalization.js";
import {
normalizeLegacyChannelAliases,
normalizeLegacyStreamingAliases,
resolveLegacyAliasStreamingMode,
} from "../config/channel-compat-normalization.js";
import type { OpenClawConfig } from "../config/config.js";
import { normalizeLegacyBrowserConfig } from "./doctor/shared/legacy-config-core-normalizers.js";
@@ -21,7 +25,6 @@ function normalizeStreaming(params: {
resolvedMode: string;
aliasOnlyMode?: string;
resolvedNativeTransport?: unknown;
offModeLegacyNotice?: (pathPrefix: string) => string;
}) {
const changes: string[] = [];
const result = normalizeLegacyStreamingAliases({
@@ -80,15 +83,12 @@ describe("normalizeCompatibilityConfigValues preview streaming aliases", () => {
entry: { streamMode: "off" },
pathPrefix: "channels.discord",
resolvedMode: "off",
offModeLegacyNotice: (pathPrefix) =>
`${pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${pathPrefix}.streaming.mode="partial" to opt in explicitly.`,
});
expect(res.entry.streaming).toEqual({ mode: "off" });
expect(getLegacyProperty(res.entry, "streamMode")).toBeUndefined();
expect(res.changes).toEqual([
"Moved channels.discord.streamMode → channels.discord.streaming.mode (off).",
'channels.discord.streaming remains off by default to avoid Discord preview-edit rate limits; set channels.discord.streaming.mode="partial" to opt in explicitly.',
]);
});
@@ -173,6 +173,133 @@ describe("normalizeCompatibilityConfigValues preview streaming aliases", () => {
});
});
describe("normalizeLegacyChannelAliases account inheritance seeding", () => {
// Discord-shaped options: object-without-mode default "off", absent default
// "progress", account merge replaces the root streaming object wholesale.
function normalizeChannel(
entry: Record<string, unknown>,
options?: { seedAccountStreamingFromRoot?: boolean },
) {
const changes: string[] = [];
const result = normalizeLegacyChannelAliases({
entry,
pathPrefix: "channels.discord",
changes,
seedAccountStreamingFromRoot: options?.seedAccountStreamingFromRoot ?? true,
resolveStreamingOptions: (value) => ({
resolvedMode: resolveLegacyAliasStreamingMode(value, "off"),
aliasOnlyMode: "progress",
includePreviewChunk: true,
}),
});
return { entry: result.entry, changes };
}
function workStreaming(entry: Record<string, unknown>): unknown {
return (entry.accounts as { work: Record<string, unknown> }).work.streaming;
}
it("pins the absent-object default when no root streaming object exists", () => {
// Truth table row 1: root absent → account previously resolved the
// channel's streaming-absent default, so migration pins it explicitly.
const res = normalizeChannel({
accounts: { work: { blockStreaming: true } },
});
expect(workStreaming(res.entry)).toEqual({
mode: "progress",
block: { enabled: true },
});
expect(res.changes).toEqual([
"Moved channels.discord.accounts.work.blockStreaming → channels.discord.accounts.work.streaming.block.enabled.",
"Set channels.discord.accounts.work.streaming.mode (progress) to keep the previous default while migrating flat streaming keys.",
]);
});
it("seeds the root object's mode and subfields when the root has a mode", () => {
// Truth table row 2: account previously inherited the root object wholesale,
// so the created account object copies mode plus subfields; no pin needed.
const res = normalizeChannel({
streaming: { mode: "block", block: { coalesce: { idleMs: 5 } } },
accounts: { work: { chunkMode: "newline" } },
});
expect(workStreaming(res.entry)).toEqual({
mode: "block",
chunkMode: "newline",
block: { coalesce: { idleMs: 5 } },
});
expect(res.entry.streaming).toEqual({ mode: "block", block: { coalesce: { idleMs: 5 } } });
expect(res.changes).toEqual([
"Moved channels.discord.accounts.work.chunkMode → channels.discord.accounts.work.streaming.chunkMode.",
"Copied channels.discord.streaming into channels.discord.accounts.work.streaming to keep inherited settings while migrating flat streaming keys.",
]);
});
it("seeds subfields without pinning a mode when the root object has no mode", () => {
// Truth table row 3: the account previously resolved the root object's
// object-without-mode default; pinning absentObjectDefault would change it.
const res = normalizeChannel({
streaming: { chunkMode: "word" },
accounts: { work: { blockStreaming: true } },
});
const streaming = workStreaming(res.entry) as Record<string, unknown>;
expect(streaming).toEqual({
chunkMode: "word",
block: { enabled: true },
});
expect(streaming.mode).toBeUndefined();
expect(res.changes).toEqual([
"Moved channels.discord.accounts.work.blockStreaming → channels.discord.accounts.work.streaming.block.enabled.",
"Copied channels.discord.streaming into channels.discord.accounts.work.streaming to keep inherited settings while migrating flat streaming keys.",
]);
});
it("keeps account values over seeded root values on conflict", () => {
const res = normalizeChannel({
streaming: { mode: "block", chunkMode: "word" },
accounts: { work: { streamMode: "partial", chunkMode: "newline" } },
});
expect(workStreaming(res.entry)).toEqual({
mode: "partial",
chunkMode: "newline",
});
});
it("does not seed accounts whose streaming key already existed", () => {
const res = normalizeChannel({
streaming: { mode: "block", chunkMode: "word" },
accounts: { work: { streaming: false } },
});
expect(workStreaming(res.entry)).toEqual({
mode: "off",
});
expect(res.changes).toEqual([
"Moved channels.discord.accounts.work.streaming (boolean) → channels.discord.accounts.work.streaming.mode (off).",
]);
});
it("does not seed for deep-merge channels so runtime inheritance keeps composing", () => {
// Slack/iMessage deep-merge root+account streaming at runtime; copying root
// values into the account config would freeze inheritance at fix time.
const res = normalizeChannel(
{
streaming: { mode: "block", block: { coalesce: { idleMs: 5 } } },
accounts: { work: { chunkMode: "newline" } },
},
{ seedAccountStreamingFromRoot: false },
);
expect(workStreaming(res.entry)).toEqual({ chunkMode: "newline" });
expect(res.changes).toEqual([
"Moved channels.discord.accounts.work.chunkMode → channels.discord.accounts.work.streaming.chunkMode.",
]);
});
});
describe("normalizeCompatibilityConfigValues browser compatibility aliases", () => {
it("removes legacy browser relay bind host and stale extension relay cdpUrl", () => {
const changes: string[] = [];
+198
View File
@@ -0,0 +1,198 @@
// Tests for the declarative channel doctor alias-migration DSL.
import { describe, expect, it } from "vitest";
import { defineChannelAliasMigration } from "./channel-alias-migration.js";
import type { OpenClawConfig } from "./types.openclaw.js";
function cfgWith(channelId: string, entry: Record<string, unknown>): OpenClawConfig {
return { channels: { [channelId]: entry } } as never;
}
describe("defineChannelAliasMigration message generation", () => {
it("generates preview-chunk channel messages (discord shape)", () => {
const migration = defineChannelAliasMigration({
channelId: "discord",
streaming: { defaultMode: "off", absentObjectDefault: "progress", includePreviewChunk: true },
});
expect(migration.legacyConfigRules.map((rule) => rule.message)).toEqual([
'channels.discord.streamMode, channels.discord.streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}. Run "openclaw doctor --fix".',
'channels.discord.accounts.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.accounts.<id>.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}. Run "openclaw doctor --fix".',
]);
expect(migration.legacyConfigRules.map((rule) => rule.path)).toEqual([
["channels", "discord"],
["channels", "discord", "accounts"],
]);
});
it("generates native-transport channel messages (slack shape)", () => {
const migration = defineChannelAliasMigration({
channelId: "slack",
streaming: { defaultMode: "partial", resolveNativeTransport: () => true },
});
expect(migration.legacyConfigRules.map((rule) => rule.message)).toEqual([
'channels.slack.streamMode, channels.slack.streaming (scalar), chunkMode, blockStreaming, blockStreamingCoalesce, and nativeStreaming are legacy; use channels.slack.streaming.{mode,chunkMode,block.enabled,block.coalesce,nativeTransport}. Run "openclaw doctor --fix".',
'channels.slack.accounts.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, blockStreamingCoalesce, and nativeStreaming are legacy; use channels.slack.accounts.<id>.streaming.{mode,chunkMode,block.enabled,block.coalesce,nativeTransport}. Run "openclaw doctor --fix".',
]);
});
it("generates delivery-only channel messages (imessage shape)", () => {
const migration = defineChannelAliasMigration({
channelId: "imessage",
streaming: { defaultMode: "partial", deliveryOnly: true },
});
expect(migration.legacyConfigRules.map((rule) => rule.message)).toEqual([
'channels.imessage.chunkMode, blockStreaming, and blockStreamingCoalesce are legacy; use channels.imessage.streaming.{chunkMode,block.enabled,block.coalesce}. Run "openclaw doctor --fix".',
'channels.imessage.accounts.<id>.chunkMode, blockStreaming, and blockStreamingCoalesce are legacy; use channels.imessage.accounts.<id>.streaming.{chunkMode,block.enabled,block.coalesce}. Run "openclaw doctor --fix".',
]);
});
it("generates plain mode channel messages (msteams shape)", () => {
const migration = defineChannelAliasMigration({
channelId: "msteams",
streaming: { defaultMode: "partial" },
});
expect(migration.legacyConfigRules[0]?.message).toBe(
'channels.msteams.streamMode, channels.msteams.streaming (scalar), chunkMode, blockStreaming, and blockStreamingCoalesce are legacy; use channels.msteams.streaming.{mode,chunkMode,block.enabled,block.coalesce}. Run "openclaw doctor --fix".',
);
});
});
describe("defineChannelAliasMigration rule matching", () => {
it("matches root and account entries per spec options", () => {
const migration = defineChannelAliasMigration({
channelId: "discord",
streaming: { defaultMode: "off", includePreviewChunk: true },
});
const [rootRule, accountsRule] = migration.legacyConfigRules;
expect(rootRule?.match?.({ streamMode: "block" }, {})).toBe(true);
expect(rootRule?.match?.({ streaming: false }, {})).toBe(true);
expect(rootRule?.match?.({ draftChunk: { minChars: 5 } }, {})).toBe(true);
expect(rootRule?.match?.({ nativeStreaming: false }, {})).toBe(false);
expect(rootRule?.match?.({ streaming: { mode: "off" } }, {})).toBe(false);
expect(accountsRule?.match?.({ work: { blockStreaming: true } }, {})).toBe(true);
expect(accountsRule?.match?.({ work: { streaming: { mode: "off" } } }, {})).toBe(false);
});
it("matches nativeStreaming only for native-transport channels", () => {
const migration = defineChannelAliasMigration({
channelId: "slack",
streaming: { defaultMode: "partial", resolveNativeTransport: () => true },
});
expect(migration.hasLegacyAliases({ nativeStreaming: false })).toBe(true);
expect(migration.hasLegacyAliases({ draftChunk: {} })).toBe(false);
});
it("excludes mode sources for delivery-only channels", () => {
const migration = defineChannelAliasMigration({
channelId: "imessage",
streaming: { defaultMode: "partial", deliveryOnly: true },
});
expect(migration.hasLegacyAliases({ chunkMode: "newline" })).toBe(true);
expect(migration.hasLegacyAliases({ streamMode: "block" })).toBe(false);
expect(migration.hasLegacyAliases({ streaming: "partial" })).toBe(false);
expect(migration.hasLegacyAliases({ streaming: false })).toBe(false);
});
});
describe("defineChannelAliasMigration normalizeChannelConfig", () => {
it("migrates root and account aliases with dm normalization", () => {
const migration = defineChannelAliasMigration({
channelId: "discord",
streaming: { defaultMode: "off", absentObjectDefault: "progress", includePreviewChunk: true },
accountStreamingReplacesRoot: true,
dm: { root: true, accounts: true },
});
const changes: string[] = [];
const result = migration.normalizeChannelConfig({
cfg: cfgWith("discord", {
streamMode: "block",
dm: { policy: "open" },
accounts: { work: { draftChunk: { minChars: 9 } } },
}),
changes,
});
expect(result.changes).toBe(changes);
expect((result.config.channels as Record<string, unknown>).discord).toEqual({
dmPolicy: "open",
streaming: { mode: "block" },
accounts: {
work: {
streaming: { mode: "block", preview: { chunk: { minChars: 9 } } },
},
},
});
expect(changes).toEqual([
"Moved channels.discord.dm.policy → channels.discord.dmPolicy.",
"Removed empty channels.discord.dm after migration.",
"Moved channels.discord.streamMode → channels.discord.streaming.mode (block).",
"Moved channels.discord.accounts.work.draftChunk → channels.discord.accounts.work.streaming.preview.chunk.",
"Copied channels.discord.streaming into channels.discord.accounts.work.streaming to keep inherited settings while migrating flat streaming keys.",
]);
});
it("routes the escape hatch into per-account migration", () => {
const migration = defineChannelAliasMigration({
channelId: "discord",
streaming: { defaultMode: "off" },
normalizeAccountExtra: ({ account, pathPrefix, changes }) => {
if (account.legacyFlag === undefined) {
return { entry: account, changed: false };
}
const { legacyFlag: _ignored, ...rest } = account;
changes.push(`Removed ${pathPrefix}.legacyFlag.`);
return { entry: rest, changed: true };
},
});
const result = migration.normalizeChannelConfig({
cfg: cfgWith("discord", { accounts: { work: { legacyFlag: true } } }),
});
expect(result.changes).toEqual(["Removed channels.discord.accounts.work.legacyFlag."]);
expect((result.config.channels as Record<string, unknown>).discord).toEqual({
accounts: { work: {} },
});
});
it("returns the unchanged sentinel when nothing matches", () => {
const migration = defineChannelAliasMigration({
channelId: "msteams",
streaming: { defaultMode: "partial" },
});
const modern = cfgWith("msteams", { streaming: { mode: "partial" } });
const untouched = migration.normalizeChannelConfig({ cfg: modern });
expect(untouched.config).toBe(modern);
expect(untouched.changes).toEqual([]);
const missing = { channels: {} } as never;
expect(migration.normalizeChannelConfig({ cfg: missing }).config).toBe(missing);
});
it("skips scalar streaming values entirely for delivery-only channels", () => {
const migration = defineChannelAliasMigration({
channelId: "imessage",
streaming: { defaultMode: "partial", deliveryOnly: true },
});
// Scalar `streaming` is a validation error for delivery-only channels, not
// a migratable legacy shape, so the migration must not touch it.
const scalarOnly = cfgWith("imessage", { streaming: "partial" });
expect(migration.normalizeChannelConfig({ cfg: scalarOnly }).config).toBe(scalarOnly);
const result = migration.normalizeChannelConfig({
cfg: cfgWith("imessage", { accounts: { work: { chunkMode: "newline" } } }),
});
expect(result.changes).toEqual([
"Moved channels.imessage.accounts.work.chunkMode → channels.imessage.accounts.work.streaming.chunkMode.",
]);
});
});
+196
View File
@@ -0,0 +1,196 @@
// Declarative front for channel doctor streaming/dm alias migrations.
import {
asObjectRecord,
hasLegacyAccountStreamingAliases,
hasLegacyStreamingAliases,
normalizeLegacyChannelAliases,
resolveLegacyAliasStreamingMode,
type CompatMutationResult,
type LegacyStreamingAliasOptions,
type NormalizeLegacyChannelAccountParams,
} from "./channel-compat-normalization.js";
import type { LegacyConfigRule } from "./legacy.shared.js";
import type { OpenClawConfig } from "./types.openclaw.js";
export type StreamingAliasMode = "off" | "partial" | "block" | "progress";
/** Streaming half of a channel alias-migration spec. */
type StreamingAliasSpec = {
/** Default passed to resolveLegacyAliasStreamingMode for mode-source migration. */
defaultMode: StreamingAliasMode;
/** Channel-specific mode resolver override (Slack maps legacy draft stream modes). */
resolveMode?: (entry: Record<string, unknown>) => StreamingAliasMode;
/**
* The channel's runtime default when `streaming` is entirely absent, if it
* differs from the object-without-mode default (Discord: progress vs off).
* Pinned when delivery-only aliases materialize the object and no root
* streaming object exists to seed inherited settings from.
*/
absentObjectDefault?: StreamingAliasMode;
/** Channel accepts flat `draftChunk` (Discord, Telegram). */
includePreviewChunk?: boolean;
/** Channel accepts flat `nativeStreaming`; returns the resolved nativeTransport (Slack). */
resolveNativeTransport?: (entry: Record<string, unknown>) => unknown;
/**
* Channel has no streaming mode: only delivery flat aliases migrate, and
* scalar `streaming` values are plain validation errors (iMessage). The
* detection matcher excludes streamMode/scalar streaming, and the migration
* only runs when a delivery flat alias exists somewhere in the entry.
*/
deliveryOnly?: boolean;
};
export type ChannelAliasMigrationSpec = {
/** Channel id under `channels.<id>`; also the doctor message path prefix. */
channelId: string;
streaming: StreamingAliasSpec;
/**
* Set when the channel's runtime account merge replaces the root `streaming`
* object wholesale (Discord). Migration then seeds account objects it
* materializes with the inherited root settings. Leave unset for channels
* that deep-merge streaming at runtime (Slack, iMessage) — seeding there
* would freeze inheritance into the account config.
*/
accountStreamingReplacesRoot?: boolean;
dm?: {
root?: boolean;
accounts?: boolean;
rootPromoteAllowFrom?: boolean;
};
/** Escape hatch for channel-specific per-account migrations (Discord voice.tts). */
normalizeAccountExtra?: (params: NormalizeLegacyChannelAccountParams) => CompatMutationResult;
};
function buildAliasRuleMessage(params: {
streaming: StreamingAliasSpec;
prefix: string;
root: boolean;
}): string {
const { streaming, prefix } = params;
const native = streaming.resolveNativeTransport !== undefined;
const flat = [
...(streaming.deliveryOnly ? [] : ["streamMode", "streaming (scalar)"]),
"chunkMode",
"blockStreaming",
...(streaming.includePreviewChunk ? ["draftChunk"] : []),
"blockStreamingCoalesce",
...(native ? ["nativeStreaming"] : []),
];
const nested = [
...(streaming.deliveryOnly ? [] : ["mode"]),
"chunkMode",
...(streaming.includePreviewChunk ? ["preview.chunk"] : []),
"block.enabled",
"block.coalesce",
...(native ? ["nativeTransport"] : []),
];
// Root messages spell out the ambiguous scalar `streaming` key with its full
// path; account messages prefix only the first key. Matches the established
// hand-written doctor message format.
const prefixedCount = params.root && !streaming.deliveryOnly ? 2 : 1;
const keys = flat.map((key, index) => (index < prefixedCount ? `${prefix}.${key}` : key));
const keyList = `${keys.slice(0, -1).join(", ")}, and ${keys.at(-1)}`;
return `${keyList} are legacy; use ${prefix}.streaming.{${nested.join(",")}}. Run "openclaw doctor --fix".`;
}
/**
* Builds the standard channel doctor alias-migration surface from a small spec:
* detection rules (root + accounts), the per-entry matcher, and the config
* normalizer. Channels with additional migrations compose around these pieces.
*/
export function defineChannelAliasMigration(spec: ChannelAliasMigrationSpec): {
legacyConfigRules: LegacyConfigRule[];
hasLegacyAliases: (value: unknown) => boolean;
normalizeChannelConfig: (params: { cfg: OpenClawConfig; changes?: string[] }) => {
config: OpenClawConfig;
changes: string[];
};
} {
const { streaming } = spec;
const pathPrefix = `channels.${spec.channelId}`;
const hasLegacyAliases = (value: unknown): boolean => {
if (streaming.deliveryOnly === true) {
const entry = asObjectRecord(value);
return (
entry !== null &&
(entry.chunkMode !== undefined ||
entry.blockStreaming !== undefined ||
entry.blockStreamingCoalesce !== undefined)
);
}
return hasLegacyStreamingAliases(value, {
includePreviewChunk: streaming.includePreviewChunk,
includeNativeTransport: streaming.resolveNativeTransport !== undefined,
});
};
const resolveStreamingOptions = (
entry: Record<string, unknown>,
): LegacyStreamingAliasOptions => ({
resolvedMode:
streaming.resolveMode?.(entry) ??
resolveLegacyAliasStreamingMode(entry, streaming.defaultMode),
aliasOnlyMode: streaming.absentObjectDefault,
includePreviewChunk: streaming.includePreviewChunk,
resolvedNativeTransport: streaming.resolveNativeTransport?.(entry),
});
const normalizeChannelConfig = (params: { cfg: OpenClawConfig; changes?: string[] }) => {
const changes = params.changes ?? [];
const channels = params.cfg.channels as Record<string, unknown> | undefined;
const entry = asObjectRecord(channels?.[spec.channelId]);
if (!entry) {
return { config: params.cfg, changes };
}
if (
streaming.deliveryOnly === true &&
!hasLegacyAliases(entry) &&
!hasLegacyAccountStreamingAliases(entry.accounts, hasLegacyAliases)
) {
return { config: params.cfg, changes };
}
const result = normalizeLegacyChannelAliases({
entry,
pathPrefix,
changes,
normalizeDm: spec.dm?.root,
rootDmPromoteAllowFrom: spec.dm?.rootPromoteAllowFrom,
normalizeAccountDm: spec.dm?.accounts,
seedAccountStreamingFromRoot: spec.accountStreamingReplacesRoot,
resolveStreamingOptions,
normalizeAccountExtra: spec.normalizeAccountExtra,
});
if (!result.changed) {
return { config: params.cfg, changes };
}
return {
config: {
...params.cfg,
channels: { ...channels, [spec.channelId]: result.entry },
} as OpenClawConfig,
changes,
};
};
return {
legacyConfigRules: [
{
path: ["channels", spec.channelId],
message: buildAliasRuleMessage({ streaming, prefix: pathPrefix, root: true }),
match: hasLegacyAliases,
},
{
path: ["channels", spec.channelId, "accounts"],
message: buildAliasRuleMessage({
streaming,
prefix: `${pathPrefix}.accounts.<id>`,
root: false,
}),
match: (value) => hasLegacyAccountStreamingAliases(value, hasLegacyAliases),
},
],
hasLegacyAliases,
normalizeChannelConfig,
};
}
+75 -10
View File
@@ -19,7 +19,6 @@ export type LegacyStreamingAliasOptions = {
aliasOnlyMode?: string;
includePreviewChunk?: boolean;
resolvedNativeTransport?: unknown;
offModeLegacyNotice?: (pathPrefix: string) => string;
};
/** Account-level channel config passed to channel-specific doctor migrations. */
@@ -227,7 +226,10 @@ export function normalizeLegacyStreamingAliases(
// object without `mode` (Discord defaults to progress only when the whole
// object is absent). Pin the previous effective mode so migration never
// changes behavior. Guarded on `changed` so entries with no movable alias
// stay a no-op instead of minting a mode-only mutation.
// stay a no-op instead of minting a mode-only mutation. Account callers
// suppress aliasOnlyMode when a root streaming object exists: the seed in
// normalizeLegacyChannelAliases carries the inherited settings instead, and
// pinning the absent-object default there would change effective behavior.
if (
changed &&
beforeStreaming === undefined &&
@@ -248,16 +250,41 @@ export function normalizeLegacyStreamingAliases(
streaming.block = block;
}
updated.streaming = streaming;
if (
hadLegacyStreamMode &&
params.resolvedMode === "off" &&
params.offModeLegacyNotice !== undefined
) {
params.changes.push(params.offModeLegacyNotice(params.pathPrefix));
}
return { entry: updated, changed };
}
/** Deep-fills record fields missing from target with copies of source values. */
function fillMissingRecordFields(
target: Record<string, unknown>,
source: Record<string, unknown>,
): { value: Record<string, unknown>; filled: boolean } {
let filled = false;
const value = { ...target };
for (const [key, sourceValue] of Object.entries(source)) {
if (sourceValue === undefined) {
continue;
}
const existing = value[key];
if (existing === undefined) {
// Copy so later account-level edits never alias the root config object.
value[key] = structuredClone(sourceValue);
filled = true;
continue;
}
const existingRecord = asObjectRecord(existing);
const sourceRecord = asObjectRecord(sourceValue);
if (!existingRecord || !sourceRecord) {
continue;
}
const merged = fillMissingRecordFields(existingRecord, sourceRecord);
if (merged.filled) {
value[key] = merged.value;
filled = true;
}
}
return { value, filled };
}
/**
* Runs generic channel doctor alias migration for the root entry and accounts.
*
@@ -271,6 +298,14 @@ export function normalizeLegacyChannelAliases(params: {
normalizeDm?: boolean;
rootDmPromoteAllowFrom?: boolean;
normalizeAccountDm?: boolean;
/**
* Set for channels whose runtime account merge replaces the root `streaming`
* object wholesale (`streaming` not deep-merged). Doctor then seeds account
* objects it materializes with the inherited root settings. Channels that
* deep-merge streaming (slack, imessage) must NOT seed: their runtime keeps
* composing root+account, and seeded copies would freeze inheritance.
*/
seedAccountStreamingFromRoot?: boolean;
resolveStreamingOptions: (entry: Record<string, unknown>) => LegacyStreamingAliasOptions;
normalizeAccountExtra?: (params: NormalizeLegacyChannelAccountParams) => CompatMutationResult;
}): CompatMutationResult {
@@ -302,6 +337,12 @@ export function normalizeLegacyChannelAliases(params: {
return { entry: updated, changed };
}
// For replace-semantics channels (seedAccountStreamingFromRoot), an account
// object materialized by migration must be seeded with the settings the
// account previously inherited from the root object, or `doctor --fix`
// silently changes effective delivery/preview behavior for that account.
const rootStreaming = asObjectRecord(updated.streaming);
let accountsChanged = false;
const accounts = { ...rawAccounts };
for (const [accountId, rawAccount] of Object.entries(rawAccounts)) {
@@ -323,15 +364,39 @@ export function normalizeLegacyChannelAliases(params: {
accountChanged = accountDm.changed;
}
const accountStreamingOptions = { ...params.resolveStreamingOptions(accountEntry) };
if (rootStreaming) {
// Truth table rows 2-3: with a root object to seed from, the account
// previously resolved that object's semantics (its mode, or the
// object-without-mode default), so pinning absentObjectDefault is wrong.
delete accountStreamingOptions.aliasOnlyMode;
}
const beforeAccountStreaming = accountEntry.streaming;
const accountStreaming = normalizeLegacyStreamingAliases({
entry: accountEntry,
pathPrefix: accountPathPrefix,
changes: params.changes,
...params.resolveStreamingOptions(accountEntry),
...accountStreamingOptions,
});
accountEntry = accountStreaming.entry;
accountChanged = accountChanged || accountStreaming.changed;
if (
params.seedAccountStreamingFromRoot === true &&
accountStreaming.changed &&
beforeAccountStreaming === undefined &&
rootStreaming
) {
const created = asObjectRecord(accountEntry.streaming);
const seeded = created ? fillMissingRecordFields(created, rootStreaming) : null;
if (seeded?.filled) {
accountEntry = { ...accountEntry, streaming: seeded.value };
params.changes.push(
`Copied ${params.pathPrefix}.streaming into ${accountPathPrefix}.streaming to keep inherited settings while migrating flat streaming keys.`,
);
}
}
const accountExtra = params.normalizeAccountExtra?.({
account: accountEntry,
accountId,
+5
View File
@@ -2,6 +2,11 @@
* Runtime SDK subpath for plugin doctor migrations, compat checks, and uninstall helpers.
*/
export { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js";
export { defineChannelAliasMigration } from "../config/channel-alias-migration.js";
export type {
ChannelAliasMigrationSpec,
StreamingAliasMode,
} from "../config/channel-alias-migration.js";
export {
asObjectRecord,
hasLegacyAccountStreamingAliases,