test: speed legacy state migration discovery

Keep bundled legacy migration discovery on narrow setup-entry surfaces so
state-migration tests and doctor cold paths avoid unrelated channel runtime
loads. Add targeted setup feature metadata, narrow Telegram/WhatsApp legacy
contracts, and a path-only pairing SDK helper.
This commit is contained in:
Gustavo Madeira Santana
2026-04-17 16:35:25 -04:00
parent a8a701291b
commit 5ae059db16
20 changed files with 549 additions and 81 deletions

View File

@@ -1,2 +1,2 @@
e3df4c13b4dcdc07809775c56eed15c3ab924db191a08fb5a7b48d6f73001966 plugin-sdk-api-baseline.json
2bb30ad45d5b382e92fd6b8a240a47f7679c59f9b524e54420879fadc28264b8 plugin-sdk-api-baseline.jsonl
052943a9f1eb82a49452b6715f4c08faeb650d16a36c150a3c726ff392ecad0d plugin-sdk-api-baseline.json
a5077395f009f5064331dc1c38bb2d6d2864299d3c1fbd9e40956c1700fa253c plugin-sdk-api-baseline.jsonl

View File

@@ -0,0 +1 @@
export { detectTelegramLegacyStateMigrations } from "./src/state-migrations.js";

View File

@@ -17,6 +17,9 @@
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"setupFeatures": {
"legacyStateMigrations": true
},
"channel": {
"id": "telegram",
"label": "Telegram",

View File

@@ -9,6 +9,10 @@ export default defineBundledChannelSetupEntry({
specifier: "./setup-plugin-api.js",
exportName: "telegramSetupPlugin",
},
legacyStateMigrations: {
specifier: "./legacy-state-migrations-api.js",
exportName: "detectTelegramLegacyStateMigrations",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",

View File

@@ -0,0 +1,151 @@
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
const DEFAULT_AGENT_ID = "main";
function normalizeAgentId(value: string | undefined | null): string {
const normalized = (value ?? "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+/g, "")
.replace(/-+$/g, "");
return normalized || DEFAULT_AGENT_ID;
}
function normalizeChannelId(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function resolveDefaultAgentId(cfg: OpenClawConfig): string {
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
const chosen = (agents.find((agent) => agent?.default) ?? agents[0])?.id;
return normalizeAgentId(chosen);
}
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
const ids = new Set<string>();
for (const key of Object.keys(cfg.channels?.telegram?.accounts ?? {})) {
if (key) {
ids.add(normalizeAccountId(key));
}
}
return [...ids];
}
function resolveBindingAccount(params: {
binding: unknown;
channelId: string;
}): { agentId: string; accountId: string } | null {
if (!params.binding || typeof params.binding !== "object") {
return null;
}
const binding = params.binding as {
agentId?: unknown;
match?: { channel?: unknown; accountId?: unknown };
};
if (normalizeChannelId(binding.match?.channel) !== params.channelId) {
return null;
}
const accountId = typeof binding.match?.accountId === "string" ? binding.match.accountId : "";
if (!accountId.trim() || accountId.trim() === "*") {
return null;
}
return {
agentId: normalizeAgentId(typeof binding.agentId === "string" ? binding.agentId : undefined),
accountId: normalizeAccountId(accountId),
};
}
function listBoundAccountIds(cfg: OpenClawConfig, channelId: string): string[] {
const ids = new Set<string>();
for (const binding of cfg.bindings ?? []) {
const resolved = resolveBindingAccount({ binding, channelId });
if (resolved) {
ids.add(resolved.accountId);
}
}
return [...ids].toSorted((left, right) => left.localeCompare(right));
}
function resolveDefaultAgentBoundAccountId(cfg: OpenClawConfig, channelId: string): string | null {
const defaultAgentId = resolveDefaultAgentId(cfg);
for (const binding of cfg.bindings ?? []) {
const resolved = resolveBindingAccount({ binding, channelId });
if (resolved?.agentId === defaultAgentId) {
return resolved.accountId;
}
}
return null;
}
function combineAccountIds(params: {
configuredAccountIds: readonly string[];
additionalAccountIds: readonly string[];
}): string[] {
const ids = new Set<string>();
for (const id of [...params.configuredAccountIds, ...params.additionalAccountIds]) {
ids.add(normalizeAccountId(id));
}
if (ids.size === 0) {
return [DEFAULT_ACCOUNT_ID];
}
return [...ids].toSorted((left, right) => left.localeCompare(right));
}
function resolveListedDefaultAccountId(params: {
accountIds: readonly string[];
configuredDefaultAccountId: string | null | undefined;
}): string {
const configured = normalizeOptionalAccountId(params.configuredDefaultAccountId);
if (configured && params.accountIds.includes(configured)) {
return configured;
}
if (params.accountIds.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return params.accountIds[0] ?? DEFAULT_ACCOUNT_ID;
}
export function listTelegramAccountIds(cfg: OpenClawConfig): string[] {
return combineAccountIds({
configuredAccountIds: listConfiguredAccountIds(cfg),
additionalAccountIds: listBoundAccountIds(cfg, "telegram"),
});
}
export function resolveDefaultTelegramAccountSelection(cfg: OpenClawConfig): {
accountId: string;
accountIds: string[];
shouldWarnMissingDefault: boolean;
} {
const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram");
if (boundDefault) {
return {
accountId: boundDefault,
accountIds: listTelegramAccountIds(cfg),
shouldWarnMissingDefault: false,
};
}
const accountIds = listTelegramAccountIds(cfg);
const resolved = resolveListedDefaultAccountId({
accountIds,
configuredDefaultAccountId: cfg.channels?.telegram?.defaultAccount,
});
return {
accountId: resolved,
accountIds,
shouldWarnMissingDefault:
resolved === accountIds[0] &&
!accountIds.includes(DEFAULT_ACCOUNT_ID) &&
accountIds.length > 1,
};
}
export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string {
return resolveDefaultTelegramAccountSelection(cfg).accountId;
}

View File

@@ -1,12 +1,9 @@
import util from "node:util";
import {
createAccountActionGate,
DEFAULT_ACCOUNT_ID,
listCombinedAccountIds,
normalizeAccountId,
normalizeOptionalAccountId,
resolveAccountEntry,
resolveListedDefaultAccountId,
resolveAccountWithDefaultFallback,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-core";
@@ -14,13 +11,13 @@ import type {
TelegramAccountConfig,
TelegramActionConfig,
} from "openclaw/plugin-sdk/config-runtime";
import {
listBoundAccountIds,
resolveDefaultAgentBoundAccountId,
} from "openclaw/plugin-sdk/routing";
import { formatSetExplicitDefaultInstruction } from "openclaw/plugin-sdk/routing";
import { createSubsystemLogger, isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
listTelegramAccountIds as listSelectedTelegramAccountIds,
resolveDefaultTelegramAccountSelection,
} from "./account-selection.js";
import type { TelegramTransport } from "./fetch.js";
import { resolveTelegramToken } from "./token.js";
@@ -67,22 +64,8 @@ export type TelegramMediaRuntimeOptions = {
dangerouslyAllowPrivateNetwork?: boolean;
};
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
const ids = new Set<string>();
for (const key of Object.keys(cfg.channels?.telegram?.accounts ?? {})) {
if (key) {
ids.add(normalizeAccountId(key));
}
}
return [...ids];
}
export function listTelegramAccountIds(cfg: OpenClawConfig): string[] {
const ids = listCombinedAccountIds({
configuredAccountIds: listConfiguredAccountIds(cfg),
additionalAccountIds: listBoundAccountIds(cfg, "telegram"),
fallbackAccountIdWhenEmpty: DEFAULT_ACCOUNT_ID,
});
const ids = listSelectedTelegramAccountIds(cfg);
debugAccounts("listTelegramAccountIds", ids);
return ids;
}
@@ -95,26 +78,15 @@ export function resetMissingDefaultWarnFlag(): void {
}
export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string {
const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram");
if (boundDefault) {
return boundDefault;
}
const ids = listTelegramAccountIds(cfg);
const resolved = resolveListedDefaultAccountId({
accountIds: ids,
configuredDefaultAccountId: normalizeOptionalAccountId(cfg.channels?.telegram?.defaultAccount),
});
if (resolved !== ids[0] || ids.includes(DEFAULT_ACCOUNT_ID) || ids.length <= 1) {
return resolved;
}
if (ids.length > 1 && !emittedMissingDefaultWarn) {
const selection = resolveDefaultTelegramAccountSelection(cfg);
if (selection.shouldWarnMissingDefault && !emittedMissingDefaultWarn) {
emittedMissingDefaultWarn = true;
getLog().warn(
`channels.telegram: accounts.default is missing; falling back to "${ids[0]}". ` +
`channels.telegram: accounts.default is missing; falling back to "${selection.accountId}". ` +
`${formatSetExplicitDefaultInstruction("telegram")} to avoid routing surprises in multi-account setups.`,
);
}
return resolved;
return selection.accountId;
}
export function resolveTelegramAccountConfig(

View File

@@ -1,8 +1,8 @@
import fs from "node:fs";
import type { ChannelLegacyStateMigrationPlan } from "openclaw/plugin-sdk/channel-contract";
import { resolveChannelAllowFromPath } from "openclaw/plugin-sdk/channel-pairing";
import { resolveChannelAllowFromPath } from "openclaw/plugin-sdk/channel-pairing-paths";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveDefaultTelegramAccountId } from "./accounts.js";
import { resolveDefaultTelegramAccountId } from "./account-selection.js";
function fileExists(pathValue: string): boolean {
try {

View File

@@ -0,0 +1,6 @@
import { canonicalizeLegacySessionKey, isLegacyGroupSessionKey } from "./src/session-contract.js";
export const whatsappLegacySessionSurface = {
isLegacyGroupSessionKey,
canonicalizeLegacySessionKey,
};

View File

@@ -0,0 +1 @@
export { detectWhatsAppLegacyStateMigrations } from "./src/state-migrations.js";

View File

@@ -25,6 +25,10 @@
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"setupFeatures": {
"legacyStateMigrations": true,
"legacySessionSurfaces": true
},
"channel": {
"id": "whatsapp",
"label": "WhatsApp",

View File

@@ -10,4 +10,12 @@ export default defineBundledChannelSetupEntry({
specifier: "./setup-plugin-api.js",
exportName: "whatsappSetupPlugin",
},
legacyStateMigrations: {
specifier: "./legacy-state-migrations-api.js",
exportName: "detectWhatsAppLegacyStateMigrations",
},
legacySessionSurface: {
specifier: "./legacy-session-surface-api.js",
exportName: "whatsappLegacySessionSurface",
},
});

View File

@@ -1,4 +1,4 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
function extractLegacyWhatsAppGroupId(key: string): string | null {
const trimmed = key.trim();

View File

@@ -640,6 +640,10 @@
"types": "./dist/plugin-sdk/channel-pairing.d.ts",
"default": "./dist/plugin-sdk/channel-pairing.js"
},
"./plugin-sdk/channel-pairing-paths": {
"types": "./dist/plugin-sdk/channel-pairing-paths.d.ts",
"default": "./dist/plugin-sdk/channel-pairing-paths.js"
},
"./plugin-sdk/channel-policy": {
"types": "./dist/plugin-sdk/channel-policy.d.ts",
"default": "./dist/plugin-sdk/channel-policy.js"

View File

@@ -146,6 +146,7 @@
"channel-mention-gating",
"channel-lifecycle",
"channel-pairing",
"channel-pairing-paths",
"channel-policy",
"channel-send-result",
"channel-targets",

View File

@@ -385,6 +385,121 @@ describe("bundled channel entry shape guards", () => {
}
});
it("loads setup-entry feature plugins without loading the main channel entry", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-setup-only-"));
const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
const pluginDir = path.join(root, "dist", "extensions", "alpha");
const testGlobal = globalThis as typeof globalThis & {
__bundledSetupOnlyMainLoaded?: boolean;
__bundledSetupOnlySetupLoaded?: number;
__bundledSetupOnlyPluginLoaded?: boolean;
};
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "index.js"),
[
"globalThis.__bundledSetupOnlyMainLoaded = true;",
"throw new Error('main entry loaded');",
"",
].join("\n"),
"utf8",
);
fs.writeFileSync(
path.join(pluginDir, "setup-entry.js"),
[
"globalThis.__bundledSetupOnlySetupLoaded = (globalThis.__bundledSetupOnlySetupLoaded ?? 0) + 1;",
"export default {",
" kind: 'bundled-channel-setup-entry',",
" features: { legacyStateMigrations: true },",
" loadSetupPlugin() {",
" globalThis.__bundledSetupOnlyPluginLoaded = true;",
" throw new Error('setup plugin loaded');",
" },",
" loadLegacyStateMigrationDetector() {",
" return ({ oauthDir }) => [{",
" kind: 'copy',",
" label: 'Alpha state',",
" sourcePath: oauthDir + '/legacy.json',",
" targetPath: oauthDir + '/alpha/legacy.json',",
" }];",
" },",
"};",
"",
].join("\n"),
"utf8",
);
vi.doMock("../../plugins/bundled-channel-runtime.js", () => ({
listBundledChannelPluginMetadata: () => [
{
dirName: "alpha",
manifest: {
id: "alpha",
channels: ["alpha"],
},
source: {
source: "./index.js",
built: "./index.js",
},
setupSource: {
source: "./setup-entry.js",
built: "./setup-entry.js",
},
},
],
resolveBundledChannelGeneratedPath: (
rootDir: string,
entry: { built?: string; source?: string },
pluginDirName?: string,
) =>
path.join(
rootDir,
"dist",
"extensions",
pluginDirName ?? "alpha",
(entry.built ?? entry.source ?? "./index.js").replace(/^\.\//u, ""),
),
}));
try {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(root, "dist", "extensions");
const bundled = await importFreshModule<typeof import("./bundled.js")>(
import.meta.url,
"./bundled.js?scope=bundled-setup-only-feature",
);
const detectors = bundled.listBundledChannelLegacyStateMigrationDetectors();
expect(
detectors.map((detector) =>
detector({ cfg: {}, env: {}, stateDir: "/state", oauthDir: "/oauth" } as never),
),
).toEqual([
[
{
kind: "copy",
label: "Alpha state",
sourcePath: "/oauth/legacy.json",
targetPath: "/oauth/alpha/legacy.json",
},
],
]);
expect(testGlobal.__bundledSetupOnlySetupLoaded).toBe(1);
expect(testGlobal.__bundledSetupOnlyMainLoaded).toBeUndefined();
expect(testGlobal.__bundledSetupOnlyPluginLoaded).toBeUndefined();
} finally {
if (previousBundledPluginsDir === undefined) {
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
} else {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = previousBundledPluginsDir;
}
fs.rmSync(root, { recursive: true, force: true });
delete testGlobal.__bundledSetupOnlyMainLoaded;
delete testGlobal.__bundledSetupOnlySetupLoaded;
delete testGlobal.__bundledSetupOnlyPluginLoaded;
}
});
it("keeps channel entrypoints on the dedicated entry-contract SDK surface", () => {
const offenders: string[] = [];
@@ -414,6 +529,33 @@ describe("bundled channel entry shape guards", () => {
expect(offenders).toEqual([]);
});
it("keeps setup-entry legacy feature hints mirrored in package metadata", () => {
const offenders: string[] = [];
for (const extensionDir of bundledPluginRoots) {
const setupEntryPath = path.join(extensionDir, "setup-entry.ts");
const packageJsonPath = path.join(extensionDir, "package.json");
if (!fs.existsSync(setupEntryPath) || !fs.existsSync(packageJsonPath)) {
continue;
}
const setupEntrySource = fs.readFileSync(setupEntryPath, "utf8");
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
openclaw?: {
setupFeatures?: Record<string, boolean>;
};
};
for (const feature of ["legacyStateMigrations", "legacySessionSurfaces"]) {
const usesFeature = setupEntrySource.includes(`${feature}: true`);
const hasHint = packageJson.openclaw?.setupFeatures?.[feature] === true;
if (usesFeature !== hasHint) {
offenders.push(`${path.relative(process.cwd(), extensionDir)}:${feature}`);
}
}
}
expect(offenders).toEqual([]);
});
it("keeps bundled channel entrypoints free of static src imports", () => {
const offenders: string[] = [];

View File

@@ -1,6 +1,10 @@
import path from "node:path";
import { formatErrorMessage } from "../../infra/errors.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import type {
BundledChannelLegacySessionSurface,
BundledChannelLegacyStateMigrationDetector,
} from "../../plugin-sdk/channel-entry-contract.js";
import {
listBundledChannelPluginMetadata,
resolveBundledChannelGeneratedPath,
@@ -32,6 +36,8 @@ type BundledChannelSetupEntryRuntimeContract = {
kind: "bundled-channel-setup-entry";
loadSetupPlugin: () => ChannelPlugin;
loadSetupSecrets?: () => ChannelPlugin["secrets"] | undefined;
loadLegacyStateMigrationDetector?: () => BundledChannelLegacyStateMigrationDetector;
loadLegacySessionSurface?: () => BundledChannelLegacySessionSurface;
features?: {
legacyStateMigrations?: boolean;
legacySessionSurfaces?: boolean;
@@ -41,14 +47,15 @@ type BundledChannelSetupEntryRuntimeContract = {
type GeneratedBundledChannelEntry = {
id: string;
entry: BundledChannelEntryRuntimeContract;
setupEntry?: BundledChannelSetupEntryRuntimeContract;
};
type BundledChannelCacheContext = {
pluginLoadInProgressIds: Set<ChannelId>;
setupPluginLoadInProgressIds: Set<ChannelId>;
entryLoadInProgressIds: Set<ChannelId>;
setupEntryLoadInProgressIds: Set<ChannelId>;
lazyEntriesById: Map<ChannelId, GeneratedBundledChannelEntry | null>;
lazySetupEntriesById: Map<ChannelId, BundledChannelSetupEntryRuntimeContract | null>;
lazyPluginsById: Map<ChannelId, ChannelPlugin>;
lazySetupPluginsById: Map<ChannelId, ChannelPlugin>;
lazySecretsById: Map<ChannelId, ChannelPlugin["secrets"] | null>;
@@ -102,7 +109,7 @@ function resolveChannelSetupModuleEntry(
}
function hasSetupEntryFeature(
entry: BundledChannelSetupEntryRuntimeContract | undefined,
entry: BundledChannelSetupEntryRuntimeContract | null | undefined,
feature: keyof NonNullable<BundledChannelSetupEntryRuntimeContract["features"]>,
): boolean {
return entry?.features?.[feature] === true;
@@ -186,7 +193,6 @@ function loadGeneratedBundledChannelModule(params: {
function loadGeneratedBundledChannelEntry(params: {
rootScope: BundledChannelRootScope;
metadata: BundledChannelPluginMetadata;
includeSetup: boolean;
}): GeneratedBundledChannelEntry | null {
try {
const entry = resolveChannelPluginModuleEntry(
@@ -202,20 +208,9 @@ function loadGeneratedBundledChannelEntry(params: {
);
return null;
}
const setupEntry =
params.includeSetup && params.metadata.setupSource
? resolveChannelSetupModuleEntry(
loadGeneratedBundledChannelModule({
rootScope: params.rootScope,
metadata: params.metadata,
entry: params.metadata.setupSource,
}),
)
: null;
return {
id: params.metadata.manifest.id,
entry,
...(setupEntry ? { setupEntry } : {}),
};
} catch (error) {
const detail = formatErrorMessage(error);
@@ -224,6 +219,37 @@ function loadGeneratedBundledChannelEntry(params: {
}
}
function loadGeneratedBundledChannelSetupEntry(params: {
rootScope: BundledChannelRootScope;
metadata: BundledChannelPluginMetadata;
}): BundledChannelSetupEntryRuntimeContract | null {
if (!params.metadata.setupSource) {
return null;
}
try {
const setupEntry = resolveChannelSetupModuleEntry(
loadGeneratedBundledChannelModule({
rootScope: params.rootScope,
metadata: params.metadata,
entry: params.metadata.setupSource,
}),
);
if (!setupEntry) {
log.warn(
`[channels] bundled channel setup entry ${params.metadata.manifest.id} missing bundled-channel-setup-entry contract; skipping`,
);
return null;
}
return setupEntry;
} catch (error) {
const detail = formatErrorMessage(error);
log.warn(
`[channels] failed to load bundled channel setup entry ${params.metadata.manifest.id}: ${detail}`,
);
return null;
}
}
const cachedBundledChannelMetadata = new Map<string, readonly BundledChannelPluginMetadata[]>();
const bundledChannelCacheContexts = new Map<string, BundledChannelCacheContext>();
@@ -232,7 +258,9 @@ function createBundledChannelCacheContext(): BundledChannelCacheContext {
pluginLoadInProgressIds: new Set(),
setupPluginLoadInProgressIds: new Set(),
entryLoadInProgressIds: new Set(),
setupEntryLoadInProgressIds: new Set(),
lazyEntriesById: new Map(),
lazySetupEntriesById: new Map(),
lazyPluginsById: new Map(),
lazySetupPluginsById: new Map(),
lazySecretsById: new Map(),
@@ -288,6 +316,17 @@ function listBundledChannelPluginIdsForRoot(
.toSorted((left, right) => left.localeCompare(right));
}
function listBundledChannelPluginIdsForSetupFeature(
rootScope: BundledChannelRootScope,
feature: keyof NonNullable<BundledChannelSetupEntryRuntimeContract["features"]>,
): readonly ChannelId[] {
const hinted = listBundledChannelMetadata(rootScope)
.filter((metadata) => metadata.packageManifest?.setupFeatures?.[feature] === true)
.map((metadata) => metadata.manifest.id)
.toSorted((left, right) => left.localeCompare(right));
return hinted.length > 0 ? hinted : listBundledChannelPluginIdsForRoot(rootScope);
}
export function listBundledChannelPluginIds(): readonly ChannelId[] {
return listBundledChannelPluginIdsForRoot(resolveBundledChannelRootScope());
}
@@ -305,13 +344,12 @@ function getLazyGeneratedBundledChannelEntryForRoot(
id: ChannelId,
rootScope: BundledChannelRootScope,
cacheContext: BundledChannelCacheContext,
params?: { includeSetup?: boolean },
): GeneratedBundledChannelEntry | null {
const cached = cacheContext.lazyEntriesById.get(id);
if (cached && (!params?.includeSetup || cached.setupEntry)) {
if (cached) {
return cached;
}
if (cached === null && !params?.includeSetup) {
if (cached === null) {
return null;
}
const metadata = resolveBundledChannelMetadata(id, rootScope);
@@ -327,7 +365,6 @@ function getLazyGeneratedBundledChannelEntryForRoot(
const entry = loadGeneratedBundledChannelEntry({
rootScope,
metadata,
includeSetup: params?.includeSetup === true,
});
cacheContext.lazyEntriesById.set(id, entry);
if (entry?.entry.id && entry.entry.id !== id) {
@@ -339,6 +376,51 @@ function getLazyGeneratedBundledChannelEntryForRoot(
}
}
function cacheBundledChannelSetupEntry(
metadata: BundledChannelPluginMetadata,
cacheContext: BundledChannelCacheContext,
entry: BundledChannelSetupEntryRuntimeContract | null,
requestedId?: ChannelId,
) {
const ids = new Set<ChannelId>([
metadata.manifest.id,
...(metadata.manifest.channels ?? []),
...(requestedId ? [requestedId] : []),
]);
for (const id of ids) {
cacheContext.lazySetupEntriesById.set(id, entry);
}
}
function getLazyGeneratedBundledChannelSetupEntryForRoot(
id: ChannelId,
rootScope: BundledChannelRootScope,
cacheContext: BundledChannelCacheContext,
): BundledChannelSetupEntryRuntimeContract | null {
if (cacheContext.lazySetupEntriesById.has(id)) {
return cacheContext.lazySetupEntriesById.get(id) ?? null;
}
const metadata = resolveBundledChannelMetadata(id, rootScope);
if (!metadata) {
cacheContext.lazySetupEntriesById.set(id, null);
return null;
}
if (cacheContext.setupEntryLoadInProgressIds.has(id)) {
return null;
}
cacheContext.setupEntryLoadInProgressIds.add(id);
try {
const setupEntry = loadGeneratedBundledChannelSetupEntry({
rootScope,
metadata,
});
cacheBundledChannelSetupEntry(metadata, cacheContext, setupEntry, id);
return setupEntry;
} finally {
cacheContext.setupEntryLoadInProgressIds.delete(id);
}
}
function getBundledChannelPluginForRoot(
id: ChannelId,
rootScope: BundledChannelRootScope,
@@ -414,9 +496,7 @@ function getBundledChannelSetupPluginForRoot(
if (cacheContext.setupPluginLoadInProgressIds.has(id)) {
return undefined;
}
const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext, {
includeSetup: true,
})?.setupEntry;
const entry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext);
if (!entry) {
return undefined;
}
@@ -438,9 +518,7 @@ function getBundledChannelSetupSecretsForRoot(
if (cacheContext.lazySetupSecretsById.has(id)) {
return cacheContext.lazySetupSecretsById.get(id) ?? undefined;
}
const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext, {
includeSetup: true,
})?.setupEntry;
const entry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext);
if (!entry) {
return undefined;
}
@@ -471,10 +549,8 @@ export function listBundledChannelSetupPluginsByFeature(
feature: keyof NonNullable<BundledChannelSetupEntryRuntimeContract["features"]>,
): readonly ChannelPlugin[] {
const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope();
return listBundledChannelPluginIdsForRoot(rootScope).flatMap((id) => {
const setupEntry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext, {
includeSetup: true,
})?.setupEntry;
return listBundledChannelPluginIdsForSetupFeature(rootScope, feature).flatMap((id) => {
const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext);
if (!hasSetupEntryFeature(setupEntry, feature)) {
return [];
}
@@ -483,6 +559,52 @@ export function listBundledChannelSetupPluginsByFeature(
});
}
export function listBundledChannelLegacySessionSurfaces(): readonly BundledChannelLegacySessionSurface[] {
const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope();
return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacySessionSurfaces").flatMap(
(id) => {
const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(
id,
rootScope,
cacheContext,
);
const surface = setupEntry?.loadLegacySessionSurface?.();
if (surface) {
return [surface];
}
if (!hasSetupEntryFeature(setupEntry, "legacySessionSurfaces")) {
return [];
}
const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext);
return plugin?.messaging ? [plugin.messaging] : [];
},
);
}
export function listBundledChannelLegacyStateMigrationDetectors(): readonly BundledChannelLegacyStateMigrationDetector[] {
const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope();
return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacyStateMigrations").flatMap(
(id) => {
const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(
id,
rootScope,
cacheContext,
);
const detector = setupEntry?.loadLegacyStateMigrationDetector?.();
if (detector) {
return [detector];
}
if (!hasSetupEntryFeature(setupEntry, "legacyStateMigrations")) {
return [];
}
const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext);
return plugin?.lifecycle?.detectLegacyStateMigrations
? [plugin.lifecycle.detectLegacyStateMigrations]
: [];
},
);
}
export function hasBundledChannelEntryFeature(
id: ChannelId,
feature: keyof NonNullable<BundledChannelEntryRuntimeContract["features"]>,

View File

@@ -2,7 +2,10 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { listBundledChannelSetupPluginsByFeature } from "../channels/plugins/bundled.js";
import {
listBundledChannelLegacySessionSurfaces,
listBundledChannelLegacyStateMigrationDetectors,
} from "../channels/plugins/bundled.js";
import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js";
import {
resolveLegacyStateDirs,
@@ -86,12 +89,7 @@ function getLegacySessionSurfaces(): LegacySessionSurface[] {
// Legacy migrations run on cold doctor/startup paths. Prefer the narrower
// setup plugin surface here so session-key cleanup does not materialize full
// bundled channel runtimes.
cachedLegacySessionSurfaces ??= listBundledChannelSetupPluginsByFeature(
"legacySessionSurfaces",
).flatMap((plugin) => {
const surface = plugin.messaging;
return surface && typeof surface === "object" ? [surface] : [];
});
cachedLegacySessionSurfaces ??= [...listBundledChannelLegacySessionSurfaces()];
return cachedLegacySessionSurfaces;
}
@@ -670,10 +668,11 @@ async function collectChannelLegacyStateMigrationPlans(params: {
oauthDir: string;
}): Promise<ChannelLegacyStateMigrationPlan[]> {
const plans: ChannelLegacyStateMigrationPlan[] = [];
// Legacy state detection belongs on the lightweight setup surface so doctor
// Legacy state detection belongs on a narrow setup-entry surface so doctor
// does not cold-load unrelated runtime channel code.
for (const plugin of listBundledChannelSetupPluginsByFeature("legacyStateMigrations")) {
const detected = await plugin.lifecycle?.detectLegacyStateMigrations?.({
const detectors = listBundledChannelLegacyStateMigrationDetectors();
for (const detectLegacyStateMigrations of detectors) {
const detected = await detectLegacyStateMigrations({
cfg: params.cfg,
env: params.env,
stateDir: params.stateDir,

View File

@@ -4,7 +4,9 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { emptyChannelConfigSchema } from "../channels/plugins/config-schema.js";
import type { ChannelConfigSchema } from "../channels/plugins/types.config.js";
import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js";
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import {
getCachedPluginJitiLoader,
@@ -47,6 +49,8 @@ type DefineBundledChannelSetupEntryOptions = {
plugin: BundledEntryModuleRef;
secrets?: BundledEntryModuleRef;
runtime?: BundledEntryModuleRef;
legacyStateMigrations?: BundledEntryModuleRef;
legacySessionSurface?: BundledEntryModuleRef;
features?: BundledChannelSetupEntryFeatures;
};
@@ -59,6 +63,25 @@ export type BundledChannelEntryFeatures = {
accountInspect?: boolean;
};
export type BundledChannelLegacySessionSurface = {
isLegacyGroupSessionKey?: (key: string) => boolean;
canonicalizeLegacySessionKey?: (params: {
key: string;
agentId: string;
}) => string | null | undefined;
};
export type BundledChannelLegacyStateMigrationDetector = (params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
stateDir: string;
oauthDir: string;
}) =>
| ChannelLegacyStateMigrationPlan[]
| Promise<ChannelLegacyStateMigrationPlan[] | null | undefined>
| null
| undefined;
export type BundledChannelEntryContract<TPlugin = ChannelPlugin> = {
kind: "bundled-channel-entry";
id: string;
@@ -77,6 +100,8 @@ export type BundledChannelSetupEntryContract<TPlugin = ChannelPlugin> = {
kind: "bundled-channel-setup-entry";
loadSetupPlugin: () => TPlugin;
loadSetupSecrets?: () => ChannelPlugin["secrets"] | undefined;
loadLegacyStateMigrationDetector?: () => BundledChannelLegacyStateMigrationDetector;
loadLegacySessionSurface?: () => BundledChannelLegacySessionSurface;
setChannelRuntime?: (runtime: PluginRuntime) => void;
features?: BundledChannelSetupEntryFeatures;
};
@@ -404,6 +429,8 @@ export function defineBundledChannelSetupEntry<TPlugin = ChannelPlugin>({
plugin,
secrets,
runtime,
legacyStateMigrations,
legacySessionSurface,
features,
}: DefineBundledChannelSetupEntryOptions): BundledChannelSetupEntryContract<TPlugin> {
// Bundled setup entries stay on a light path during setup-only/setup-runtime loads.
@@ -418,6 +445,20 @@ export function defineBundledChannelSetupEntry<TPlugin = ChannelPlugin>({
setter(pluginRuntime);
}
: undefined;
const loadLegacyStateMigrationDetector = legacyStateMigrations
? () =>
loadBundledEntryExportSync<BundledChannelLegacyStateMigrationDetector>(
importMetaUrl,
legacyStateMigrations,
)
: undefined;
const loadLegacySessionSurface = legacySessionSurface
? () =>
loadBundledEntryExportSync<BundledChannelLegacySessionSurface>(
importMetaUrl,
legacySessionSurface,
)
: undefined;
return {
kind: "bundled-channel-setup-entry",
loadSetupPlugin: () => loadBundledEntryExportSync<TPlugin>(importMetaUrl, plugin),
@@ -430,6 +471,8 @@ export function defineBundledChannelSetupEntry<TPlugin = ChannelPlugin>({
),
}
: {}),
...(loadLegacyStateMigrationDetector ? { loadLegacyStateMigrationDetector } : {}),
...(loadLegacySessionSurface ? { loadLegacySessionSurface } : {}),
...(setChannelRuntime ? { setChannelRuntime } : {}),
...(features ? { features } : {}),
};

View File

@@ -0,0 +1 @@
export { resolveChannelAllowFromPath } from "../pairing/allow-from-store-read.js";

View File

@@ -809,9 +809,15 @@ export type OpenClawPackageStartup = {
deferConfiguredChannelFullLoadUntilAfterListen?: boolean;
};
export type OpenClawPackageSetupFeatures = {
legacyStateMigrations?: boolean;
legacySessionSurfaces?: boolean;
};
export type OpenClawPackageManifest = {
extensions?: string[];
setupEntry?: string;
setupFeatures?: OpenClawPackageSetupFeatures;
channel?: PluginPackageChannel;
install?: PluginPackageInstall;
startup?: OpenClawPackageStartup;