From 97ee0c6fd33950ace50dc61744a1ef9e0e49e1d6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 15 Apr 2026 00:38:38 +0100 Subject: [PATCH] perf(migrations): trim legacy migration and bind cold paths --- extensions/telegram/setup-entry.ts | 3 ++ extensions/telegram/src/channel.setup.ts | 5 +++ extensions/whatsapp/setup-entry.ts | 4 ++ extensions/whatsapp/src/channel.setup.ts | 5 +++ src/channels/plugins/bundled.ts | 24 ++++++++++++ .../agents.bind.matrix.integration.test.ts | 6 +-- src/commands/agents.bind.test-support.ts | 6 +++ src/commands/doctor-state-migrations.test.ts | 37 ++++++++++++++++++- src/infra/state-migrations.ts | 25 ++++++++----- src/plugin-sdk/channel-entry-contract.ts | 9 +++++ 10 files changed, 110 insertions(+), 14 deletions(-) diff --git a/extensions/telegram/setup-entry.ts b/extensions/telegram/setup-entry.ts index 30f8cefce93..d87b102fbfe 100644 --- a/extensions/telegram/setup-entry.ts +++ b/extensions/telegram/setup-entry.ts @@ -2,6 +2,9 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr export default defineBundledChannelSetupEntry({ importMetaUrl: import.meta.url, + features: { + legacyStateMigrations: true, + }, plugin: { specifier: "./setup-plugin-api.js", exportName: "telegramSetupPlugin", diff --git a/extensions/telegram/src/channel.setup.ts b/extensions/telegram/src/channel.setup.ts index 5cc70a6502f..b3a75545623 100644 --- a/extensions/telegram/src/channel.setup.ts +++ b/extensions/telegram/src/channel.setup.ts @@ -4,10 +4,15 @@ import type { TelegramProbe } from "./probe.js"; import { telegramSetupAdapter } from "./setup-core.js"; import { telegramSetupWizard } from "./setup-surface.js"; import { createTelegramPluginBase } from "./shared.js"; +import { detectTelegramLegacyStateMigrations } from "./state-migrations.js"; export const telegramSetupPlugin: ChannelPlugin = { ...createTelegramPluginBase({ setupWizard: telegramSetupWizard, setup: telegramSetupAdapter, }), + lifecycle: { + detectLegacyStateMigrations: ({ cfg, env }) => + detectTelegramLegacyStateMigrations({ cfg, env }), + }, }; diff --git a/extensions/whatsapp/setup-entry.ts b/extensions/whatsapp/setup-entry.ts index b25cf20de33..b6c896a9dec 100644 --- a/extensions/whatsapp/setup-entry.ts +++ b/extensions/whatsapp/setup-entry.ts @@ -2,6 +2,10 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr export default defineBundledChannelSetupEntry({ importMetaUrl: import.meta.url, + features: { + legacyStateMigrations: true, + legacySessionSurfaces: true, + }, plugin: { specifier: "./setup-plugin-api.js", exportName: "whatsappSetupPlugin", diff --git a/extensions/whatsapp/src/channel.setup.ts b/extensions/whatsapp/src/channel.setup.ts index bb4b8026ea8..5f31952e02c 100644 --- a/extensions/whatsapp/src/channel.setup.ts +++ b/extensions/whatsapp/src/channel.setup.ts @@ -8,6 +8,7 @@ import { } from "./group-policy.js"; import { whatsappSetupAdapter } from "./setup-core.js"; import { createWhatsAppPluginBase, whatsappSetupWizardProxy } from "./shared.js"; +import { detectWhatsAppLegacyStateMigrations } from "./state-migrations.js"; export const whatsappSetupPlugin: ChannelPlugin = { ...createWhatsAppPluginBase({ @@ -20,4 +21,8 @@ export const whatsappSetupPlugin: ChannelPlugin = { setup: whatsappSetupAdapter, isConfigured: async (account) => await webAuthExists(account.authDir), }), + lifecycle: { + detectLegacyStateMigrations: ({ oauthDir }) => + detectWhatsAppLegacyStateMigrations({ oauthDir }), + }, }; diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index a1eb2b11a5a..4eb940c013d 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -29,6 +29,10 @@ type BundledChannelSetupEntryRuntimeContract = { kind: "bundled-channel-setup-entry"; loadSetupPlugin: () => ChannelPlugin; loadSetupSecrets?: () => ChannelPlugin["secrets"] | undefined; + features?: { + legacyStateMigrations?: boolean; + legacySessionSurfaces?: boolean; + }; }; type GeneratedBundledChannelEntry = { @@ -88,6 +92,13 @@ function resolveChannelSetupModuleEntry( return record as BundledChannelSetupEntryRuntimeContract; } +function hasSetupEntryFeature( + entry: BundledChannelSetupEntryRuntimeContract | undefined, + feature: keyof NonNullable, +): boolean { + return entry?.features?.[feature] === true; +} + function resolveBundledChannelBoundaryRoot(params: { metadata: BundledChannelPluginMetadata; modulePath: string; @@ -263,6 +274,19 @@ export function listBundledChannelSetupPlugins(): readonly ChannelPlugin[] { }); } +export function listBundledChannelSetupPluginsByFeature( + feature: keyof NonNullable, +): readonly ChannelPlugin[] { + return listBundledChannelPluginIds().flatMap((id) => { + const setupEntry = getLazyGeneratedBundledChannelEntry(id, { includeSetup: true })?.setupEntry; + if (!hasSetupEntryFeature(setupEntry, feature)) { + return []; + } + const plugin = getBundledChannelSetupPlugin(id); + return plugin ? [plugin] : []; + }); +} + export function getBundledChannelPlugin(id: ChannelId): ChannelPlugin | undefined { const cached = lazyPluginsById.get(id); if (cached) { diff --git a/src/commands/agents.bind.matrix.integration.test.ts b/src/commands/agents.bind.matrix.integration.test.ts index 370fd59f0b4..bdf976bd351 100644 --- a/src/commands/agents.bind.matrix.integration.test.ts +++ b/src/commands/agents.bind.matrix.integration.test.ts @@ -5,7 +5,7 @@ import { createTestRegistry, } from "../test-utils/channel-plugins.js"; import { - loadFreshAgentsCommandModuleForTest, + loadFreshAgentsBindCommandModuleForTest, readConfigFileSnapshotMock, resetAgentsBindTestHarness, runtime, @@ -25,11 +25,11 @@ const matrixBindingPlugin = createBindingResolverTestPlugin({ }, }); -let agentsBindCommand: typeof import("./agents.js").agentsBindCommand; +let agentsBindCommand: typeof import("./agents.commands.bind.js").agentsBindCommand; describe("agents bind matrix integration", () => { beforeEach(async () => { - ({ agentsBindCommand } = await loadFreshAgentsCommandModuleForTest()); + ({ agentsBindCommand } = await loadFreshAgentsBindCommandModuleForTest()); resetAgentsBindTestHarness(); setActivePluginRegistry( diff --git a/src/commands/agents.bind.test-support.ts b/src/commands/agents.bind.test-support.ts index 48a3bd72399..5e08c211114 100644 --- a/src/commands/agents.bind.test-support.ts +++ b/src/commands/agents.bind.test-support.ts @@ -39,12 +39,18 @@ vi.mock("../config/config.js", async () => { export const runtime = createTestRuntime(); let agentsCommandModulePromise: Promise | undefined; +let agentsBindCommandModulePromise: Promise | undefined; export async function loadFreshAgentsCommandModuleForTest() { agentsCommandModulePromise ??= import("./agents.js"); return await agentsCommandModulePromise; } +export async function loadFreshAgentsBindCommandModuleForTest() { + agentsBindCommandModulePromise ??= import("./agents.commands.bind.js"); + return await agentsBindCommandModulePromise; +} + export function resetAgentsBindTestHarness(): void { readConfigFileSnapshotMock.mockClear(); writeConfigFileMock.mockClear(); diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts index 65028486e07..6493501f1b4 100644 --- a/src/commands/doctor-state-migrations.test.ts +++ b/src/commands/doctor-state-migrations.test.ts @@ -1,8 +1,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { + resetSessionStoreLockRuntimeForTests, + setSessionWriteLockAcquirerForTests, +} from "../config/sessions/store.js"; import { autoMigrateLegacyStateDir, autoMigrateLegacyState, @@ -14,6 +18,30 @@ import { let tempRoot: string | null = null; +vi.mock("../infra/json-files.js", async () => { + const actual = + await vi.importActual("../infra/json-files.js"); + return { + ...actual, + writeTextAtomic: async ( + filePath: string, + content: string, + options?: { mode?: number; ensureDirMode?: number; appendTrailingNewline?: boolean }, + ) => { + const payload = + options?.appendTrailingNewline && !content.endsWith("\n") ? `${content}\n` : content; + await fs.promises.mkdir(path.dirname(filePath), { + recursive: true, + ...(typeof options?.ensureDirMode === "number" ? { mode: options.ensureDirMode } : {}), + }); + await fs.promises.writeFile(filePath, payload, { + encoding: "utf8", + mode: options?.mode ?? 0o600, + }); + }, + }; +}); + async function makeTempRoot() { const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-doctor-")); tempRoot = root; @@ -52,9 +80,16 @@ async function runTelegramAllowFromMigration(params: { root: string; cfg: OpenCl return { oauthDir, detected, result }; } +beforeEach(() => { + setSessionWriteLockAcquirerForTests(async () => ({ + release: async () => undefined, + })); +}); + afterEach(async () => { resetAutoMigrateLegacyStateForTest(); resetAutoMigrateLegacyStateDirForTest(); + resetSessionStoreLockRuntimeForTests(); if (!tempRoot) { return; } diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index bf7c2957ae6..f43d77e6350 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -2,8 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { iterateBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js"; -import { listBundledChannelPlugins } from "../channels/plugins/bundled.js"; +import { listBundledChannelSetupPluginsByFeature } from "../channels/plugins/bundled.js"; import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js"; import { resolveLegacyStateDirs, @@ -73,6 +72,7 @@ type MigrationLogger = { let autoMigrateChecked = false; let autoMigrateStateDirChecked = false; +let cachedLegacySessionSurfaces: LegacySessionSurface[] | null = null; type LegacySessionSurface = { isLegacyGroupSessionKey?: (key: string) => boolean; @@ -83,14 +83,16 @@ type LegacySessionSurface = { }; function getLegacySessionSurfaces(): LegacySessionSurface[] { - const surfaces: LegacySessionSurface[] = []; - for (const plugin of iterateBootstrapChannelPlugins()) { + // 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; - if (surface && typeof surface === "object") { - surfaces.push(surface); - } - } - return surfaces; + return surface && typeof surface === "object" ? [surface] : []; + }); + return cachedLegacySessionSurfaces; } function isSurfaceGroupKey(key: string): boolean { @@ -419,6 +421,7 @@ function removeDirIfEmpty(dir: string) { export function resetAutoMigrateLegacyStateForTest() { autoMigrateChecked = false; + cachedLegacySessionSurfaces = null; } export function resetAutoMigrateLegacyAgentDirForTest() { @@ -667,7 +670,9 @@ async function collectChannelLegacyStateMigrationPlans(params: { oauthDir: string; }): Promise { const plans: ChannelLegacyStateMigrationPlan[] = []; - for (const plugin of listBundledChannelPlugins()) { + // Legacy state detection belongs on the lightweight setup surface so doctor + // does not cold-load unrelated runtime channel code. + for (const plugin of listBundledChannelSetupPluginsByFeature("legacyStateMigrations")) { const detected = await plugin.lifecycle?.detectLegacyStateMigrations?.({ cfg: params.cfg, env: params.env, diff --git a/src/plugin-sdk/channel-entry-contract.ts b/src/plugin-sdk/channel-entry-contract.ts index 8712c73538c..e75c8a49588 100644 --- a/src/plugin-sdk/channel-entry-contract.ts +++ b/src/plugin-sdk/channel-entry-contract.ts @@ -44,6 +44,12 @@ type DefineBundledChannelSetupEntryOptions = { importMetaUrl: string; plugin: BundledEntryModuleRef; secrets?: BundledEntryModuleRef; + features?: BundledChannelSetupEntryFeatures; +}; + +export type BundledChannelSetupEntryFeatures = { + legacyStateMigrations?: boolean; + legacySessionSurfaces?: boolean; }; export type BundledChannelEntryContract = { @@ -62,6 +68,7 @@ export type BundledChannelSetupEntryContract = { kind: "bundled-channel-setup-entry"; loadSetupPlugin: () => TPlugin; loadSetupSecrets?: () => ChannelPlugin["secrets"] | undefined; + features?: BundledChannelSetupEntryFeatures; }; const nodeRequire = createRequire(import.meta.url); @@ -373,6 +380,7 @@ export function defineBundledChannelSetupEntry({ importMetaUrl, plugin, secrets, + features, }: DefineBundledChannelSetupEntryOptions): BundledChannelSetupEntryContract { return { kind: "bundled-channel-setup-entry", @@ -386,5 +394,6 @@ export function defineBundledChannelSetupEntry({ ), } : {}), + ...(features ? { features } : {}), }; }