diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index c84d9f03ebcd..c5143c3d6823 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -28,6 +28,7 @@ import { loadInstalledPluginIndexInstallRecords, loadInstalledPluginIndexInstallRecordsSync, } from "../plugins/installed-plugin-index-records.js"; +import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js"; import { bumpSkillsSnapshotVersion } from "../skills/runtime/refresh-state.js"; import { createConfigAppliedRevisionTracker } from "./config-applied-revision.js"; import { diffConfigPaths, diffGatewayReloadPaths } from "./config-diff.js"; @@ -1249,6 +1250,7 @@ export function startGatewayConfigReloader(opts: { // The signal carries a metadata change while config bytes stay identical. // Clear both metadata and config-echo caches before scheduling the shared diff path. clearLoadInstalledPluginIndexInstallRecordsCache(); + clearPluginMetadataLifecycleCaches(); startupInternalWriteHash = null; lastAppliedWriteHash = null; scheduleExternalRefresh(); diff --git a/src/plugins/channel-catalog-registry.test.ts b/src/plugins/channel-catalog-registry.test.ts index 2b327d0d0378..cc68cb3ff2ce 100644 --- a/src/plugins/channel-catalog-registry.test.ts +++ b/src/plugins/channel-catalog-registry.test.ts @@ -34,6 +34,7 @@ async function loadWithMocks(params: { loadRecords?: (env: NodeJS.ProcessEnv | undefined) => Record; }): Promise<{ module: typeof import("./channel-catalog-registry.js"); + lifecycle: typeof import("./plugin-metadata-lifecycle.js"); discoverSpy: ReturnType; loadRecordsSpy: ReturnType; }> { @@ -51,7 +52,8 @@ async function loadWithMocks(params: { import.meta.url, `./channel-catalog-registry.js?case=${++loadCase}`, ); - return { module, discoverSpy, loadRecordsSpy }; + const lifecycle = await import("./plugin-metadata-lifecycle.js"); + return { module, lifecycle, discoverSpy, loadRecordsSpy }; } function firstDiscoverOptions(discoverSpy: ReturnType): Record { @@ -91,6 +93,55 @@ function createChannelCandidate(params: { } describe("listChannelCatalogEntries", () => { + it("reuses one discovery result for repeated calls in the same scope", async () => { + const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({}); + + module.listChannelCatalogEntries({ env: ENV }); + module.listChannelCatalogEntries({ env: ENV }); + + expect(discoverSpy).toHaveBeenCalledTimes(1); + expect(loadRecordsSpy).toHaveBeenCalledTimes(1); + }); + + it("clears the discovery memo through the plugin metadata lifecycle owner", async () => { + const { module, lifecycle, discoverSpy } = await loadWithMocks({}); + + module.listChannelCatalogEntries({ origin: "bundled", env: ENV }); + lifecycle.clearPluginMetadataLifecycleCaches(); + module.listChannelCatalogEntries({ origin: "bundled", env: ENV }); + + expect(discoverSpy).toHaveBeenCalledTimes(2); + }); + + it("does not share discovery across differing input scopes", async () => { + const { module, discoverSpy } = await loadWithMocks({}); + const installRecords = {}; + const baseline = { + env: ENV, + workspaceDir: "/tmp/workspace-a", + extraPaths: ["/tmp/plugins/a"], + installRecords, + }; + const otherInstallRecords: Record = {}; + + module.listChannelCatalogEntries(baseline); + module.listChannelCatalogEntries({ ...baseline, workspaceDir: "/tmp/workspace-b" }); + module.listChannelCatalogEntries({ + ...baseline, + env: { HOME: "/tmp/openclaw-other-home" }, + }); + module.listChannelCatalogEntries({ ...baseline, extraPaths: ["/tmp/plugins/b"] }); + module.listChannelCatalogEntries({ ...baseline, installRecords: otherInstallRecords }); + module.listChannelCatalogEntries({ ...baseline, installRecords: otherInstallRecords }); + otherInstallRecords.telegram = { + source: "npm", + spec: "@openclaw/telegram@1.0.0", + } as PluginInstallRecord; + module.listChannelCatalogEntries({ ...baseline, installRecords: otherInstallRecords }); + + expect(discoverSpy).toHaveBeenCalledTimes(6); + }); + it("forwards lazily loaded install records to discovery when origin is unspecified", async () => { const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({}); diff --git a/src/plugins/channel-catalog-registry.ts b/src/plugins/channel-catalog-registry.ts index 95afab9c981c..d59fb00e838c 100644 --- a/src/plugins/channel-catalog-registry.ts +++ b/src/plugins/channel-catalog-registry.ts @@ -1,9 +1,13 @@ // Maintains channel catalog entries advertised by plugins. import { normalizeOptionalString as resolveOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { resolveIsNixMode } from "../config/paths.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; +import { resolveCompatibilityHostVersion } from "../version.js"; import { discoverOpenClawPlugins, type PluginDiscoveryResult } from "./discovery.js"; import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js"; import type { PluginPackageChannel, PluginPackageInstall } from "./manifest.js"; +import { resolvePluginDiscoveryContext } from "./plugin-control-plane-context.js"; +import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; export type PluginChannelCatalogEntry = { @@ -16,31 +20,53 @@ export type PluginChannelCatalogEntry = { install?: PluginPackageInstall; }; +type ChannelCatalogParams = { + origin?: PluginOrigin; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + extraPaths?: string[]; + /** + * Optional override. When omitted and `origin !== "bundled"`, the persisted + * plugin install ledger is loaded synchronously so that npm-installed + * channels stored outside the discovery roots are visible to the catalog. + * Bundled-only callers skip the load to avoid the disk read. + */ + installRecords?: Record; + discovery?: PluginDiscoveryResult; +}; + +const defaultInstallRecordsIdentity = Symbol("default-install-records"); +const noInstallRecordsIdentity = Symbol("no-install-records"); + +type ChannelCatalogDiscoveryMemo = { + scopeKey: string; + installRecordsIdentity: + | Record + | typeof defaultInstallRecordsIdentity + | typeof noInstallRecordsIdentity; + installRecordsFingerprint?: string; + discovery: PluginDiscoveryResult; +}; + +let channelCatalogDiscoveryMemo: ChannelCatalogDiscoveryMemo | undefined; + +function clearChannelCatalogDiscoveryMemo(): void { + channelCatalogDiscoveryMemo = undefined; +} + +// Catalog discovery is process-stable. Same-process registry writes and Gateway +// refreshes clear this hook; external install/uninstall/doctor flows are restart-backed, +// so request-time ledger freshness polling is intentionally absent. +registerPluginMetadataProcessMemoLifecycleClear(clearChannelCatalogDiscoveryMemo); + export function listChannelCatalogEntries( - params: { - origin?: PluginOrigin; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; - extraPaths?: string[]; - /** - * Optional override. When omitted and `origin !== "bundled"`, the persisted - * plugin install ledger is loaded synchronously so that npm-installed - * channels stored outside the discovery roots are visible to the catalog. - * Bundled-only callers skip the load to avoid the disk read. - */ - installRecords?: Record; - discovery?: PluginDiscoveryResult; - } = {}, + params: ChannelCatalogParams = {}, ): PluginChannelCatalogEntry[] { - const installRecords = resolveInstallRecords(params); - const discovery = - params.discovery ?? - discoverOpenClawPlugins({ - workspaceDir: params.workspaceDir, - env: params.env, - extraPaths: params.extraPaths, - ...(installRecords && Object.keys(installRecords).length > 0 ? { installRecords } : {}), - }); + // Preserve the ledger-read behavior for callers supplying an exact discovery. + if (params.discovery) { + resolveInstallRecords(params); + } + const discovery = params.discovery ?? resolveMemoizedChannelCatalogDiscovery(params); return discovery.candidates.flatMap((candidate) => { if (params.origin && candidate.origin !== params.origin) { return []; @@ -69,6 +95,78 @@ export function listChannelCatalogEntries( }); } +function resolveMemoizedChannelCatalogDiscovery(params: ChannelCatalogParams) { + const installRecordsKey = resolveInstallRecordsKey(params); + const scopeKey = resolveChannelCatalogDiscoveryScopeKey(params); + if ( + installRecordsKey.cacheable && + channelCatalogDiscoveryMemo?.scopeKey === scopeKey && + channelCatalogDiscoveryMemo.installRecordsIdentity === installRecordsKey.identity && + channelCatalogDiscoveryMemo.installRecordsFingerprint === installRecordsKey.fingerprint + ) { + return channelCatalogDiscoveryMemo.discovery; + } + + const resolvedInstallRecords = resolveInstallRecords(params); + const discovery = discoverOpenClawPlugins({ + workspaceDir: params.workspaceDir, + env: params.env, + extraPaths: params.extraPaths, + ...(resolvedInstallRecords.installRecords && + Object.keys(resolvedInstallRecords.installRecords).length > 0 + ? { installRecords: resolvedInstallRecords.installRecords } + : {}), + }); + if (resolvedInstallRecords.cacheable && installRecordsKey.cacheable) { + channelCatalogDiscoveryMemo = { + scopeKey, + installRecordsIdentity: installRecordsKey.identity, + ...(installRecordsKey.fingerprint !== undefined + ? { installRecordsFingerprint: installRecordsKey.fingerprint } + : {}), + discovery, + }; + } + return discovery; +} + +function resolveChannelCatalogDiscoveryScopeKey(params: ChannelCatalogParams): string { + const env = params.env ?? process.env; + return JSON.stringify({ + workspaceDir: resolveOptionalString(params.workspaceDir) ?? null, + discovery: resolvePluginDiscoveryContext({ + workspaceDir: params.workspaceDir, + env, + loadPaths: params.extraPaths, + }), + compatibilityHostVersion: resolveCompatibilityHostVersion(env), + bundledSourceOverlaysDisabled: env.OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS ?? "", + nixMode: resolveIsNixMode(env), + }); +} + +function resolveInstallRecordsKey(params: ChannelCatalogParams): { + identity: ChannelCatalogDiscoveryMemo["installRecordsIdentity"]; + fingerprint?: string; + cacheable: boolean; +} { + if (params.installRecords) { + try { + const fingerprint = JSON.stringify(params.installRecords); + return fingerprint === undefined + ? { identity: params.installRecords, cacheable: false } + : { identity: params.installRecords, fingerprint, cacheable: true }; + } catch { + return { identity: params.installRecords, cacheable: false }; + } + } + return { + identity: + params.origin === "bundled" ? noInstallRecordsIdentity : defaultInstallRecordsIdentity, + cacheable: true, + }; +} + function resolveChannelCatalogPluginId( candidate: PluginDiscoveryResult["candidates"][number], ): string | undefined { @@ -80,20 +178,25 @@ function resolveChannelCatalogPluginId( ); } -function resolveInstallRecords(params: { - origin?: PluginOrigin; - env?: NodeJS.ProcessEnv; +function resolveInstallRecords(params: ChannelCatalogParams): { installRecords?: Record; -}): Record | undefined { + cacheable: boolean; +} { if (params.installRecords) { - return params.installRecords; + return { installRecords: params.installRecords, cacheable: true }; } if (params.origin === "bundled") { - return undefined; + return { cacheable: true }; } try { - return loadInstalledPluginIndexInstallRecordsSync(params.env ? { env: params.env } : {}); + return { + installRecords: loadInstalledPluginIndexInstallRecordsSync( + params.env ? { env: params.env } : {}, + ), + cacheable: true, + }; } catch { - return undefined; + // Retry transient ledger failures instead of memoizing an incomplete catalog. + return { cacheable: false }; } }