diff --git a/src/channels/plugins/bootstrap-registry.ts b/src/channels/plugins/bootstrap-registry.ts index 33efc11ab97..db1dbb1568f 100644 --- a/src/channels/plugins/bootstrap-registry.ts +++ b/src/channels/plugins/bootstrap-registry.ts @@ -1,5 +1,6 @@ import { normalizeOptionalString } from "../../shared/string-coerce.js"; -import { listBundledChannelPluginIds } from "./bundled-ids.js"; +import { listBundledChannelPluginIdsForRoot } from "./bundled-ids.js"; +import { resolveBundledChannelPackageRoot } from "./bundled-root.js"; import { getBundledChannelPlugin, getBundledChannelSecrets, @@ -16,7 +17,7 @@ type CachedBootstrapPlugins = { missingIds: Set; }; -let cachedBootstrapPlugins: CachedBootstrapPlugins | null = null; +const cachedBootstrapPluginsByRoot = new Map(); function mergePluginSection( runtimeValue: T | undefined, @@ -63,18 +64,29 @@ function mergeBootstrapPlugin( } as ChannelPlugin; } -function buildBootstrapPlugins(): CachedBootstrapPlugins { +function buildBootstrapPlugins( + packageRoot: string, + env: NodeJS.ProcessEnv = process.env, +): CachedBootstrapPlugins { return { - sortedIds: listBundledChannelPluginIds(), + sortedIds: listBundledChannelPluginIdsForRoot(packageRoot, env), byId: new Map(), secretsById: new Map(), missingIds: new Set(), }; } -function getBootstrapPlugins(): CachedBootstrapPlugins { - cachedBootstrapPlugins ??= buildBootstrapPlugins(); - return cachedBootstrapPlugins; +function getBootstrapPlugins( + packageRoot = resolveBundledChannelPackageRoot(), + env: NodeJS.ProcessEnv = process.env, +): CachedBootstrapPlugins { + const cached = cachedBootstrapPluginsByRoot.get(packageRoot); + if (cached) { + return cached; + } + const created = buildBootstrapPlugins(packageRoot, env); + cachedBootstrapPluginsByRoot.set(packageRoot, created); + return created; } export function listBootstrapChannelPluginIds(): readonly string[] { @@ -99,7 +111,7 @@ export function getBootstrapChannelPlugin(id: ChannelId): ChannelPlugin | undefi if (!resolvedId) { return undefined; } - const registry = getBootstrapPlugins(); + const registry = getBootstrapPlugins(resolveBundledChannelPackageRoot()); const cached = registry.byId.get(resolvedId); if (cached) { return cached; @@ -126,7 +138,7 @@ export function getBootstrapChannelSecrets(id: ChannelId): ChannelPlugin["secret if (!resolvedId) { return undefined; } - const registry = getBootstrapPlugins(); + const registry = getBootstrapPlugins(resolveBundledChannelPackageRoot()); const cached = registry.secretsById.get(resolvedId); if (cached) { return cached; @@ -142,5 +154,5 @@ export function getBootstrapChannelSecrets(id: ChannelId): ChannelPlugin["secret } export function clearBootstrapChannelPluginCache(): void { - cachedBootstrapPlugins = null; + cachedBootstrapPluginsByRoot.clear(); } diff --git a/src/channels/plugins/bundled-ids.ts b/src/channels/plugins/bundled-ids.ts index 7edbf2ded49..1cecd28aa74 100644 --- a/src/channels/plugins/bundled-ids.ts +++ b/src/channels/plugins/bundled-ids.ts @@ -1,10 +1,23 @@ import { listChannelCatalogEntries } from "../../plugins/channel-catalog-registry.js"; +import { resolveBundledChannelPackageRoot } from "./bundled-root.js"; -let bundledChannelPluginIds: string[] | null = null; +const bundledChannelPluginIdsByRoot = new Map(); -export function listBundledChannelPluginIds(): string[] { - bundledChannelPluginIds ??= listChannelCatalogEntries({ origin: "bundled" }) +export function listBundledChannelPluginIdsForRoot( + packageRoot: string, + env: NodeJS.ProcessEnv = process.env, +): string[] { + const cached = bundledChannelPluginIdsByRoot.get(packageRoot); + if (cached) { + return [...cached]; + } + const loaded = listChannelCatalogEntries({ origin: "bundled", env }) .map((entry) => entry.pluginId) .toSorted((left, right) => left.localeCompare(right)); - return [...bundledChannelPluginIds]; + bundledChannelPluginIdsByRoot.set(packageRoot, loaded); + return [...loaded]; +} + +export function listBundledChannelPluginIds(): string[] { + return listBundledChannelPluginIdsForRoot(resolveBundledChannelPackageRoot()); } diff --git a/src/channels/plugins/bundled-root-caches.test.ts b/src/channels/plugins/bundled-root-caches.test.ts new file mode 100644 index 00000000000..e348566919c --- /dev/null +++ b/src/channels/plugins/bundled-root-caches.test.ts @@ -0,0 +1,129 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { importFreshModule } from "../../../test/helpers/import-fresh.ts"; + +const tempDirs: string[] = []; +const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + +function makeBundledRoot(prefix: string): { root: string; pluginsDir: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(root); + const pluginsDir = path.join(root, "dist", "extensions"); + fs.mkdirSync(pluginsDir, { recursive: true }); + return { root, pluginsDir }; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + if (originalBundledPluginsDir === undefined) { + delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + } else { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledPluginsDir; + } + vi.resetModules(); + vi.doUnmock("../../plugins/channel-catalog-registry.js"); + vi.doUnmock("./bundled.js"); + vi.doUnmock("./bundled-ids.js"); +}); + +describe("bundled root-aware caches", () => { + it("partitions bundled channel ids by active bundled root without re-importing", async () => { + const rootA = makeBundledRoot("openclaw-bundled-ids-a-"); + const rootB = makeBundledRoot("openclaw-bundled-ids-b-"); + + vi.doMock("../../plugins/channel-catalog-registry.js", () => ({ + listChannelCatalogEntries: (params?: { env?: NodeJS.ProcessEnv }) => { + const activeRoot = params?.env?.OPENCLAW_BUNDLED_PLUGINS_DIR; + if (activeRoot === rootA.pluginsDir) { + return [{ pluginId: "alpha" }]; + } + if (activeRoot === rootB.pluginsDir) { + return [{ pluginId: "beta" }]; + } + return []; + }, + })); + + const bundledIds = await importFreshModule( + import.meta.url, + "./bundled-ids.js?scope=root-aware-id-cache", + ); + + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = rootA.pluginsDir; + expect(bundledIds.listBundledChannelPluginIds()).toEqual(["alpha"]); + + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = rootB.pluginsDir; + expect(bundledIds.listBundledChannelPluginIds()).toEqual(["beta"]); + }); + + it("partitions bootstrap plugin caches by active bundled root without re-importing", async () => { + const rootA = makeBundledRoot("openclaw-bootstrap-a-"); + const rootB = makeBundledRoot("openclaw-bootstrap-b-"); + + vi.doMock("./bundled-ids.js", () => ({ + listBundledChannelPluginIdsForRoot: (packageRoot: string) => { + if (packageRoot === rootA.root) { + return ["alpha"]; + } + if (packageRoot === rootB.root) { + return ["beta"]; + } + return []; + }, + })); + + vi.doMock("./bundled.js", () => ({ + getBundledChannelPlugin: (id: string) => ({ + id, + meta: { id, label: `runtime-${id}` }, + capabilities: {}, + config: {}, + }), + getBundledChannelSetupPlugin: (id: string) => { + const activeRoot = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + const suffix = + activeRoot === rootA.pluginsDir ? "A" : activeRoot === rootB.pluginsDir ? "B" : "unknown"; + return { + id, + meta: { id, label: `setup-${suffix}` }, + capabilities: {}, + config: {}, + }; + }, + getBundledChannelSecrets: (id: string) => ({ + secretTargetRegistryEntries: [{ id: `runtime-${id}`, targetType: "channel" }], + }), + getBundledChannelSetupSecrets: (id: string) => { + const activeRoot = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + const suffix = + activeRoot === rootA.pluginsDir ? "A" : activeRoot === rootB.pluginsDir ? "B" : "unknown"; + return { + secretTargetRegistryEntries: [{ id: `setup-${id}-${suffix}`, targetType: "channel" }], + }; + }, + })); + + const bootstrapRegistry = await importFreshModule( + import.meta.url, + "./bootstrap-registry.js?scope=root-aware-bootstrap-cache", + ); + + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = rootA.pluginsDir; + expect(bootstrapRegistry.listBootstrapChannelPluginIds()).toEqual(["alpha"]); + expect(bootstrapRegistry.getBootstrapChannelPlugin("alpha")?.meta.label).toBe("setup-A"); + expect( + bootstrapRegistry.getBootstrapChannelSecrets("alpha")?.secretTargetRegistryEntries?.[0]?.id, + ).toBe("setup-alpha-A"); + + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = rootB.pluginsDir; + expect(bootstrapRegistry.listBootstrapChannelPluginIds()).toEqual(["beta"]); + expect(bootstrapRegistry.getBootstrapChannelPlugin("beta")?.meta.label).toBe("setup-B"); + expect( + bootstrapRegistry.getBootstrapChannelSecrets("beta")?.secretTargetRegistryEntries?.[0]?.id, + ).toBe("setup-beta-B"); + }); +}); diff --git a/src/channels/plugins/bundled-root.ts b/src/channels/plugins/bundled-root.ts new file mode 100644 index 00000000000..5267b1d2b5b --- /dev/null +++ b/src/channels/plugins/bundled-root.ts @@ -0,0 +1,35 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js"; +import { resolveBundledPluginsDir } from "../../plugins/bundled-dir.js"; + +const OPENCLAW_PACKAGE_ROOT = + resolveOpenClawPackageRootSync({ + argv1: process.argv[1], + cwd: process.cwd(), + moduleUrl: import.meta.url.startsWith("file:") ? import.meta.url : undefined, + }) ?? + (import.meta.url.startsWith("file:") + ? path.resolve(fileURLToPath(new URL("../../..", import.meta.url))) + : process.cwd()); + +export function derivePackageRootFromBundledPluginsDir(pluginsDir: string): string { + const resolvedDir = path.resolve(pluginsDir); + if (path.basename(resolvedDir) !== "extensions") { + return resolvedDir; + } + const parentDir = path.dirname(resolvedDir); + const parentBase = path.basename(parentDir); + if (parentBase === "dist" || parentBase === "dist-runtime") { + return path.dirname(parentDir); + } + return parentDir; +} + +export function resolveBundledChannelPackageRoot(env: NodeJS.ProcessEnv = process.env): string { + const bundledPluginsDir = resolveBundledPluginsDir(env); + if (bundledPluginsDir) { + return derivePackageRootFromBundledPluginsDir(bundledPluginsDir); + } + return OPENCLAW_PACKAGE_ROOT; +} diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index b576eea65c4..0c36203fa4d 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -1,16 +1,14 @@ import path from "node:path"; -import { fileURLToPath } from "node:url"; import { formatErrorMessage } from "../../infra/errors.js"; -import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { listBundledChannelPluginMetadata, resolveBundledChannelGeneratedPath, type BundledChannelPluginMetadata, } from "../../plugins/bundled-channel-runtime.js"; -import { resolveBundledPluginsDir } from "../../plugins/bundled-dir.js"; import { unwrapDefaultModuleExport } from "../../plugins/module-export.js"; import type { PluginRuntime } from "../../plugins/runtime/types.js"; +import { resolveBundledChannelPackageRoot } from "./bundled-root.js"; import { isJavaScriptModulePath, loadChannelPluginModule } from "./module-loader.js"; import type { ChannelPlugin } from "./types.plugin.js"; import type { ChannelId } from "./types.public.js"; @@ -54,36 +52,6 @@ type BundledChannelCacheContext = { }; const log = createSubsystemLogger("channels"); -const OPENCLAW_PACKAGE_ROOT = - resolveOpenClawPackageRootSync({ - argv1: process.argv[1], - cwd: process.cwd(), - moduleUrl: import.meta.url.startsWith("file:") ? import.meta.url : undefined, - }) ?? - (import.meta.url.startsWith("file:") - ? path.resolve(fileURLToPath(new URL("../../..", import.meta.url))) - : process.cwd()); - -function derivePackageRootFromBundledPluginsDir(pluginsDir: string): string { - const resolvedDir = path.resolve(pluginsDir); - if (path.basename(resolvedDir) !== "extensions") { - return resolvedDir; - } - const parentDir = path.dirname(resolvedDir); - const parentBase = path.basename(parentDir); - if (parentBase === "dist" || parentBase === "dist-runtime") { - return path.dirname(parentDir); - } - return parentDir; -} - -function resolveBundledChannelPackageRoot(): string { - const bundledPluginsDir = resolveBundledPluginsDir(process.env); - if (bundledPluginsDir) { - return derivePackageRootFromBundledPluginsDir(bundledPluginsDir); - } - return OPENCLAW_PACKAGE_ROOT; -} function resolveChannelPluginModuleEntry( moduleExport: unknown,