diff --git a/src/plugins/capability-provider-runtime.ts b/src/plugins/capability-provider-runtime.ts index 67e8a67eace..de0daaabcce 100644 --- a/src/plugins/capability-provider-runtime.ts +++ b/src/plugins/capability-provider-runtime.ts @@ -5,15 +5,15 @@ import { withBundledPluginEnablementCompat, withBundledPluginVitestCompat, } from "./bundled-compat.js"; -import { - resolveConfigScopedRuntimeCacheValue, - type ConfigScopedRuntimeCache, -} from "./config-scoped-runtime-cache.js"; import { resolvePluginRegistryLoadCacheKey, resolveRuntimePluginRegistry, type PluginLoadOptions, } from "./loader.js"; +import { + resolveConfigScopedRuntimeCacheValue, + type ConfigScopedRuntimeCache, +} from "./plugin-cache-primitives.js"; import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js"; import type { PluginRegistry } from "./registry-types.js"; diff --git a/src/plugins/config-scoped-runtime-cache.ts b/src/plugins/config-scoped-runtime-cache.ts deleted file mode 100644 index 5b5dfc69792..00000000000 --- a/src/plugins/config-scoped-runtime-cache.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { - resolveConfigScopedRuntimeCacheValue, - type ConfigScopedRuntimeCache, -} from "./plugin-cache-primitives.js"; diff --git a/src/plugins/current-plugin-metadata-snapshot.ts b/src/plugins/current-plugin-metadata-snapshot.ts index 5a291fecdbe..bbe8053f5c2 100644 --- a/src/plugins/current-plugin-metadata-snapshot.ts +++ b/src/plugins/current-plugin-metadata-snapshot.ts @@ -5,9 +5,21 @@ import { setCurrentPluginMetadataSnapshotState, } from "./current-plugin-metadata-state.js"; import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js"; -import { resolvePluginMetadataSnapshotConfigFingerprint } from "./plugin-metadata-config-fingerprint.js"; +import { + resolvePluginControlPlaneFingerprint, + type ResolvePluginControlPlaneContextParams, +} from "./plugin-control-plane-context.js"; import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js"; -export { resolvePluginMetadataSnapshotConfigFingerprint } from "./plugin-metadata-config-fingerprint.js"; + +export function resolvePluginMetadataSnapshotConfigFingerprint( + config?: OpenClawConfig, + options: Omit = {}, +): string { + return resolvePluginControlPlaneFingerprint({ + config, + ...options, + }); +} // Single-slot Gateway-owned handoff. Replace or clear it at lifecycle boundaries; // never accumulate historical metadata snapshots here. diff --git a/src/plugins/config-scoped-runtime-cache.test.ts b/src/plugins/plugin-cache-primitives.test.ts similarity index 58% rename from src/plugins/config-scoped-runtime-cache.test.ts rename to src/plugins/plugin-cache-primitives.test.ts index 523a011fe61..3f8e4245422 100644 --- a/src/plugins/config-scoped-runtime-cache.test.ts +++ b/src/plugins/plugin-cache-primitives.test.ts @@ -1,9 +1,50 @@ import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { + PluginLruCache, resolveConfigScopedRuntimeCacheValue, type ConfigScopedRuntimeCache, -} from "./config-scoped-runtime-cache.js"; +} from "./plugin-cache-primitives.js"; + +describe("PluginLruCache", () => { + it("evicts the least recently used entry", () => { + const cache = new PluginLruCache(2); + + cache.set("", "empty"); + cache.set("a", "alpha"); + cache.set("b", "bravo"); + expect(cache.get("a")).toBe("alpha"); + + cache.set("c", "charlie"); + + expect(cache.get("b")).toBeUndefined(); + expect(cache.get("a")).toBe("alpha"); + expect(cache.get("c")).toBe("charlie"); + }); + + it("returns hit state for cached null values", () => { + const cache = new PluginLruCache(2); + + cache.set("missing", null); + + expect(cache.getResult("missing")).toEqual({ hit: true, value: null }); + expect(cache.getResult("unknown")).toEqual({ hit: false }); + }); + + it("resizes and falls back to the default max entry count", () => { + const cache = new PluginLruCache(2); + + cache.setMaxEntriesForTest(1.9); + cache.set("a", "alpha"); + cache.set("b", "bravo"); + expect(cache.maxEntries).toBe(1); + expect(cache.size).toBe(1); + expect(cache.get("a")).toBeUndefined(); + + cache.setMaxEntriesForTest(); + expect(cache.maxEntries).toBe(2); + }); +}); describe("resolveConfigScopedRuntimeCacheValue", () => { it("caches values by config object and key", () => { diff --git a/src/plugins/plugin-lru-cache.test.ts b/src/plugins/plugin-lru-cache.test.ts deleted file mode 100644 index c4b003009ac..00000000000 --- a/src/plugins/plugin-lru-cache.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { PluginLruCache } from "./plugin-cache-primitives.js"; - -describe("PluginLruCache", () => { - it("evicts the least recently used entry", () => { - const cache = new PluginLruCache(2); - - cache.set("", "empty"); - cache.set("a", "alpha"); - cache.set("b", "bravo"); - expect(cache.get("a")).toBe("alpha"); - - cache.set("c", "charlie"); - - expect(cache.get("b")).toBeUndefined(); - expect(cache.get("a")).toBe("alpha"); - expect(cache.get("c")).toBe("charlie"); - }); - - it("returns hit state for cached null values", () => { - const cache = new PluginLruCache(2); - - cache.set("missing", null); - - expect(cache.getResult("missing")).toEqual({ hit: true, value: null }); - expect(cache.getResult("unknown")).toEqual({ hit: false }); - }); - - it("resizes and falls back to the default max entry count", () => { - const cache = new PluginLruCache(2); - - cache.setMaxEntriesForTest(1.9); - cache.set("a", "alpha"); - cache.set("b", "bravo"); - expect(cache.maxEntries).toBe(1); - expect(cache.size).toBe(1); - expect(cache.get("a")).toBeUndefined(); - - cache.setMaxEntriesForTest(); - expect(cache.maxEntries).toBe(2); - }); -}); diff --git a/src/plugins/plugin-metadata-config-fingerprint.ts b/src/plugins/plugin-metadata-config-fingerprint.ts deleted file mode 100644 index 9cd594c8844..00000000000 --- a/src/plugins/plugin-metadata-config-fingerprint.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import type { InstalledPluginIndex } from "./installed-plugin-index.js"; -import { resolvePluginControlPlaneFingerprint } from "./plugin-control-plane-context.js"; - -export { - fingerprintPluginControlPlaneContext, - fingerprintPluginDiscoveryContext, - resolvePluginControlPlaneContext, - resolvePluginControlPlaneFingerprint, - resolvePluginDiscoveryContext, - resolvePluginDiscoveryFingerprint, -} from "./plugin-control-plane-context.js"; - -export function resolvePluginMetadataSnapshotConfigFingerprint( - config: OpenClawConfig | undefined, - options: { - activationFingerprint?: string; - env?: NodeJS.ProcessEnv; - index?: InstalledPluginIndex; - inventoryFingerprint?: string; - policyHash?: string; - workspaceDir?: string; - } = {}, -): string { - return resolvePluginControlPlaneFingerprint({ - config, - activationFingerprint: options.activationFingerprint, - env: options.env, - index: options.index, - inventoryFingerprint: options.inventoryFingerprint, - policyHash: options.policyHash, - workspaceDir: options.workspaceDir, - }); -} diff --git a/src/plugins/plugin-metadata-snapshot.ts b/src/plugins/plugin-metadata-snapshot.ts index 86abb75ffa6..a55338b7810 100644 --- a/src/plugins/plugin-metadata-snapshot.ts +++ b/src/plugins/plugin-metadata-snapshot.ts @@ -7,7 +7,7 @@ import { resolveInstalledManifestRegistryIndexFingerprint, } from "./manifest-registry-installed.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; -import { resolvePluginMetadataSnapshotConfigFingerprint } from "./plugin-metadata-config-fingerprint.js"; +import { resolvePluginControlPlaneFingerprint } from "./plugin-control-plane-context.js"; import type { LoadPluginMetadataSnapshotParams, PluginMetadataSnapshot, @@ -23,6 +23,15 @@ export type { PluginMetadataSnapshotRegistryDiagnostic, } from "./plugin-metadata-snapshot.types.js"; +function resolvePluginMetadataSnapshotConfigFingerprint( + params: Pick & { + index?: InstalledPluginIndex; + policyHash?: string; + }, +): string { + return resolvePluginControlPlaneFingerprint(params); +} + function indexesMatch( left: InstalledPluginIndex | undefined, right: InstalledPluginIndex | undefined, @@ -51,7 +60,8 @@ export function isPluginMetadataSnapshotCompatible(params: { params.snapshot.policyHash === resolveInstalledPluginIndexPolicyHash(params.config) && (!params.snapshot.configFingerprint || params.snapshot.configFingerprint === - resolvePluginMetadataSnapshotConfigFingerprint(params.config, { + resolvePluginMetadataSnapshotConfigFingerprint({ + config: params.config, env, index: params.index ?? params.snapshot.index, policyHash: params.snapshot.policyHash, @@ -185,7 +195,8 @@ function loadPluginMetadataSnapshotImpl( return { policyHash: index.policyHash, - configFingerprint: resolvePluginMetadataSnapshotConfigFingerprint(params.config, { + configFingerprint: resolvePluginMetadataSnapshotConfigFingerprint({ + config: params.config, env: params.env, index, policyHash: index.policyHash, diff --git a/src/plugins/plugin-module-loader-cache.test.ts b/src/plugins/plugin-module-loader-cache.test.ts index e37f4134340..acc35634c00 100644 --- a/src/plugins/plugin-module-loader-cache.test.ts +++ b/src/plugins/plugin-module-loader-cache.test.ts @@ -26,6 +26,63 @@ async function loadCachedPluginModuleLoader(scope: string) { } describe("getCachedPluginModuleLoader", () => { + it("resolves deterministic cache entries for equivalent alias maps", async () => { + const { resolvePluginModuleLoaderCacheEntry } = await importFreshModule< + typeof import("./plugin-module-loader-cache.js") + >(import.meta.url, "./plugin-module-loader-cache.js?scope=cache-entry-alias-order"); + + const first = resolvePluginModuleLoaderCacheEntry({ + modulePath: "/repo/extensions/demo/index.ts", + importerUrl: "file:///repo/src/plugins/loader.ts", + loaderFilename: "/repo/src/plugins/loader.ts", + aliasMap: { + alpha: "/repo/alpha.js", + zeta: "/repo/zeta.js", + }, + tryNative: false, + }); + const second = resolvePluginModuleLoaderCacheEntry({ + modulePath: "/repo/extensions/demo/index.ts", + importerUrl: "file:///repo/src/plugins/loader.ts", + loaderFilename: "/repo/src/plugins/loader.ts", + aliasMap: { + zeta: "/repo/zeta.js", + alpha: "/repo/alpha.js", + }, + tryNative: false, + }); + + expect(second.cacheKey).toBe(first.cacheKey); + expect(second.scopedCacheKey).toBe(first.scopedCacheKey); + expect(first.loaderFilename).toBe("/repo/src/plugins/loader.ts"); + }); + + it("keeps explicit shared cache scope keys independent of loader options", async () => { + const { resolvePluginModuleLoaderCacheEntry } = await importFreshModule< + typeof import("./plugin-module-loader-cache.js") + >(import.meta.url, "./plugin-module-loader-cache.js?scope=cache-entry-shared-scope"); + + const first = resolvePluginModuleLoaderCacheEntry({ + modulePath: "/repo/dist/extensions/demo-a/api.js", + importerUrl: "file:///repo/src/plugins/public-surface-loader.ts", + loaderFilename: "/repo/src/plugins/public-surface-loader.ts", + aliasMap: { demo: "/repo/demo-a.js" }, + tryNative: true, + sharedCacheScopeKey: "bundled:native", + }); + const second = resolvePluginModuleLoaderCacheEntry({ + modulePath: "/repo/dist/extensions/demo-b/api.js", + importerUrl: "file:///repo/src/plugins/public-surface-loader.ts", + loaderFilename: "/repo/src/plugins/public-surface-loader.ts", + aliasMap: { demo: "/repo/demo-b.js" }, + tryNative: false, + sharedCacheScopeKey: "bundled:native", + }); + + expect(first.cacheKey).not.toBe(second.cacheKey); + expect(first.scopedCacheKey).toBe(second.scopedCacheKey); + }); + it("reuses cached loaders for the same module config and filename", async () => { const { createJiti, getCachedPluginModuleLoader } = await loadCachedPluginModuleLoader("cached-loader"); diff --git a/src/plugins/plugin-module-loader-cache.ts b/src/plugins/plugin-module-loader-cache.ts index c172b63661b..aac07b4ca82 100644 --- a/src/plugins/plugin-module-loader-cache.ts +++ b/src/plugins/plugin-module-loader-cache.ts @@ -15,6 +15,25 @@ export type PluginModuleLoaderCache = Pick< PluginLruCache, "clear" | "get" | "set" | "size" >; +export type ResolvePluginModuleLoaderCacheEntryParams = { + modulePath: string; + importerUrl: string; + argvEntry?: string; + preferBuiltDist?: boolean; + loaderFilename?: string; + aliasMap?: Record; + tryNative?: boolean; + pluginSdkResolution?: PluginSdkResolutionPreference; + cacheScopeKey?: string; + sharedCacheScopeKey?: string; +}; +export type PluginModuleLoaderCacheEntry = { + loaderFilename: string; + aliasMap: Record; + tryNative: boolean; + cacheKey: string; + scopedCacheKey: string; +}; const DEFAULT_PLUGIN_MODULE_LOADER_CACHE_ENTRIES = 128; @@ -24,34 +43,27 @@ export function createPluginModuleLoaderCache( return new PluginLruCache(maxEntries); } -export function getCachedPluginModuleLoader(params: { - cache: PluginModuleLoaderCache; - modulePath: string; - importerUrl: string; - argvEntry?: string; - preferBuiltDist?: boolean; - loaderFilename?: string; - createLoader?: PluginModuleLoaderFactory; - aliasMap?: Record; - tryNative?: boolean; - pluginSdkResolution?: PluginSdkResolutionPreference; - cacheScopeKey?: string; - sharedCacheScopeKey?: string; -}): PluginModuleLoader { +function resolveDefaultPluginModuleLoaderConfig( + params: ResolvePluginModuleLoaderCacheEntryParams, +): ReturnType { + return resolvePluginLoaderModuleConfig({ + modulePath: params.modulePath, + argv1: params.argvEntry ?? process.argv[1], + moduleUrl: params.importerUrl, + ...(params.preferBuiltDist ? { preferBuiltDist: true } : {}), + ...(params.pluginSdkResolution ? { pluginSdkResolution: params.pluginSdkResolution } : {}), + }); +} + +export function resolvePluginModuleLoaderCacheEntry( + params: ResolvePluginModuleLoaderCacheEntryParams, +): PluginModuleLoaderCacheEntry { const loaderFilename = toSafeImportPath(params.loaderFilename ?? params.modulePath); const hasAliasOverride = Boolean(params.aliasMap); const hasTryNativeOverride = typeof params.tryNative === "boolean"; const defaultConfig = hasAliasOverride || hasTryNativeOverride - ? resolvePluginLoaderModuleConfig({ - modulePath: params.modulePath, - argv1: params.argvEntry ?? process.argv[1], - moduleUrl: params.importerUrl, - ...(params.preferBuiltDist ? { preferBuiltDist: true } : {}), - ...(params.pluginSdkResolution - ? { pluginSdkResolution: params.pluginSdkResolution } - : {}), - }) + ? resolveDefaultPluginModuleLoaderConfig(params) : null; const canReuseDefaultCacheKey = defaultConfig !== null && @@ -63,13 +75,7 @@ export function getCachedPluginModuleLoader(params: { aliasMap: params.aliasMap ?? defaultConfig.aliasMap, cacheKey: canReuseDefaultCacheKey ? defaultConfig.cacheKey : undefined, } - : resolvePluginLoaderModuleConfig({ - modulePath: params.modulePath, - argv1: params.argvEntry ?? process.argv[1], - moduleUrl: params.importerUrl, - ...(params.preferBuiltDist ? { preferBuiltDist: true } : {}), - ...(params.pluginSdkResolution ? { pluginSdkResolution: params.pluginSdkResolution } : {}), - }); + : resolveDefaultPluginModuleLoaderConfig(params); const { tryNative, aliasMap } = resolved; const cacheKey = resolved.cacheKey ?? @@ -81,18 +87,29 @@ export function getCachedPluginModuleLoader(params: { params.sharedCacheScopeKey ?? (params.cacheScopeKey ? `${params.cacheScopeKey}::${cacheKey}` : cacheKey) }`; - const cached = params.cache.get(scopedCacheKey); - if (cached) { - return cached; - } + return { + loaderFilename, + aliasMap, + tryNative, + cacheKey, + scopedCacheKey, + }; +} + +function createLazySourceTransformLoader(params: { + loaderFilename: string; + aliasMap: Record; + tryNative: boolean; + createLoader?: PluginModuleLoaderFactory; +}): () => PluginModuleLoader { let loadWithSourceTransform: PluginModuleLoader | undefined; - const getLoadWithSourceTransform = (): PluginModuleLoader => { + return () => { if (loadWithSourceTransform) { return loadWithSourceTransform; } - const jitiLoader = (params.createLoader ?? createJiti)(loaderFilename, { - ...buildPluginLoaderJitiOptions(aliasMap), - tryNative, + const jitiLoader = (params.createLoader ?? createJiti)(params.loaderFilename, { + ...buildPluginLoaderJitiOptions(params.aliasMap), + tryNative: params.tryNative, }); loadWithSourceTransform = new Proxy(jitiLoader, { apply(target, thisArg, argArray) { @@ -108,18 +125,25 @@ export function getCachedPluginModuleLoader(params: { }); return loadWithSourceTransform; }; +} + +function createPluginModuleLoader(params: { + loaderFilename: string; + aliasMap: Record; + tryNative: boolean; + createLoader?: PluginModuleLoaderFactory; +}): PluginModuleLoader { + const getLoadWithSourceTransform = createLazySourceTransformLoader(params); // When the caller has explicitly opted out of native loading (for example // `bundled-capability-runtime` in Vitest+dist mode, which depends on // jiti's alias rewriting to surface a narrow SDK slice), route every // target through jiti so those alias rewrites still apply. - if (!tryNative) { - const loader = ((target: string, ...rest: unknown[]) => + if (!params.tryNative) { + return ((target: string, ...rest: unknown[]) => (getLoadWithSourceTransform() as (t: string, ...a: unknown[]) => unknown)( target, ...rest, )) as PluginModuleLoader; - params.cache.set(scopedCacheKey, loader); - return loader; } // Otherwise prefer native require() for already-compiled JS artifacts // (the bundled plugin public surfaces shipped in dist/). jiti's transform @@ -128,7 +152,7 @@ export function getCachedPluginModuleLoader(params: { // for TS / TSX sources and for the small set of require(esm) / // async-module fallbacks `tryNativeRequireJavaScriptModule` declines to // handle. - const loader = ((target: string, ...rest: unknown[]) => { + return ((target: string, ...rest: unknown[]) => { const native = tryNativeRequireJavaScriptModule(target, { allowWindows: true }); if (native.ok) { return native.moduleExport; @@ -138,7 +162,26 @@ export function getCachedPluginModuleLoader(params: { ...rest, ); }) as PluginModuleLoader; - params.cache.set(scopedCacheKey, loader); +} + +export function getCachedPluginModuleLoader( + params: ResolvePluginModuleLoaderCacheEntryParams & { + cache: PluginModuleLoaderCache; + createLoader?: PluginModuleLoaderFactory; + }, +): PluginModuleLoader { + const cacheEntry = resolvePluginModuleLoaderCacheEntry(params); + const cached = params.cache.get(cacheEntry.scopedCacheKey); + if (cached) { + return cached; + } + const loader = createPluginModuleLoader({ + loaderFilename: cacheEntry.loaderFilename, + aliasMap: cacheEntry.aliasMap, + tryNative: cacheEntry.tryNative, + ...(params.createLoader ? { createLoader: params.createLoader } : {}), + }); + params.cache.set(cacheEntry.scopedCacheKey, loader); return loader; } diff --git a/src/plugins/provider-hook-runtime.ts b/src/plugins/provider-hook-runtime.ts index 5f705052609..2a7586ea86f 100644 --- a/src/plugins/provider-hook-runtime.ts +++ b/src/plugins/provider-hook-runtime.ts @@ -4,7 +4,7 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { resolveConfigScopedRuntimeCacheValue, type ConfigScopedRuntimeCache, -} from "./config-scoped-runtime-cache.js"; +} from "./plugin-cache-primitives.js"; import { resolvePluginControlPlaneFingerprint } from "./plugin-control-plane-context.js"; import { resolveProviderConfigApiOwnerHint } from "./provider-config-owner.js"; import { isPluginProvidersLoadInFlight, resolvePluginProviders } from "./providers.runtime.js";