From 9da4d5f5df5d0a9f9d462594e69e8891d3f06fc5 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 17 Apr 2026 08:28:05 +0530 Subject: [PATCH] fix(plugins): reuse shared discovery cache --- src/plugins/discovery.test.ts | 52 +++++++- src/plugins/discovery.ts | 220 +++++++++++++++++++++------------- 2 files changed, 189 insertions(+), 83 deletions(-) diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index a9a474aa524..53552746bbc 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { bundledDistPluginFile } from "../../test/helpers/bundled-plugin-paths.js"; import { clearPluginDiscoveryCache, discoverOpenClawPlugins } from "./discovery.js"; import { @@ -856,6 +856,56 @@ describe("discoverOpenClawPlugins", () => { expect(third.candidates.some((candidate) => candidate.idHint === "cached")).toBe(false); }); + it("reuses bundled and global discovery across workspace-specific cache misses", () => { + const stateDir = makeTempDir(); + const bundledDir = path.join(stateDir, "bundled"); + const globalExt = path.join(stateDir, "extensions"); + const workspaceA = path.join(stateDir, "workspace-a"); + const workspaceB = path.join(stateDir, "workspace-b"); + + createPackagePluginWithEntry({ + packageDir: path.join(bundledDir, "bundled-plugin"), + packageName: "@openclaw/bundled-plugin", + pluginId: "bundled-plugin", + }); + createPackagePluginWithEntry({ + packageDir: path.join(globalExt, "global-plugin"), + packageName: "@openclaw/global-plugin", + pluginId: "global-plugin", + }); + createPackagePluginWithEntry({ + packageDir: path.join(workspaceA, ".openclaw", "extensions", "workspace-a-plugin"), + packageName: "@openclaw/workspace-a-plugin", + pluginId: "workspace-a-plugin", + }); + createPackagePluginWithEntry({ + packageDir: path.join(workspaceB, ".openclaw", "extensions", "workspace-b-plugin"), + packageName: "@openclaw/workspace-b-plugin", + pluginId: "workspace-b-plugin", + }); + + const env = buildCachedDiscoveryEnv(stateDir, { + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, + }); + const readdirSync = vi.spyOn(fs, "readdirSync"); + const countSharedRootReads = () => + readdirSync.mock.calls.filter(([dir]) => dir === bundledDir || dir === globalExt).length; + + const first = discoverWithCachedEnv({ workspaceDir: workspaceA, env }); + expectCandidatePresence(first, { + present: ["bundled-plugin", "global-plugin", "workspace-a-plugin"], + absent: ["workspace-b-plugin"], + }); + expect(countSharedRootReads()).toBe(2); + + const second = discoverWithCachedEnv({ workspaceDir: workspaceB, env }); + expectCandidatePresence(second, { + present: ["bundled-plugin", "global-plugin", "workspace-b-plugin"], + absent: ["workspace-a-plugin"], + }); + expect(countSharedRootReads()).toBe(2); + }); + it.each([ { name: "does not reuse discovery results across env root changes", diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 505323488ea..17111b81858 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -90,7 +90,7 @@ function shouldUseDiscoveryCache(env: NodeJS.ProcessEnv): boolean { return resolveDiscoveryCacheMs(env) > 0; } -function buildDiscoveryCacheKey(params: { +function buildScopedDiscoveryCacheKey(params: { workspaceDir?: string; extraPaths?: string[]; ownershipUid?: number | null; @@ -102,10 +102,19 @@ function buildDiscoveryCacheKey(params: { env: params.env, }); const workspaceKey = roots.workspace ?? ""; + const ownershipUid = params.ownershipUid ?? currentUid(); + return `scoped::${workspaceKey}::${ownershipUid ?? "none"}::${JSON.stringify(loadPaths)}`; +} + +function buildSharedDiscoveryCacheKey(params: { + ownershipUid?: number | null; + env: NodeJS.ProcessEnv; +}): string { + const roots = resolvePluginSourceRoots({ env: params.env }); const configExtensionsRoot = roots.global ?? ""; const bundledRoot = roots.stock ?? ""; const ownershipUid = params.ownershipUid ?? currentUid(); - return `${workspaceKey}::${ownershipUid ?? "none"}::${configExtensionsRoot}::${bundledRoot}::${JSON.stringify(loadPaths)}`; + return `shared::${ownershipUid ?? "none"}::${configExtensionsRoot}::${bundledRoot}`; } function currentUid(overrideUid?: number | null): number | null { @@ -344,6 +353,49 @@ function resolvesToSameDirectory(left?: string, right?: string): boolean { return path.resolve(left) === path.resolve(right); } +function createDiscoveryResult(): PluginDiscoveryResult { + return { + candidates: [], + diagnostics: [], + }; +} + +function mergeDiscoveryResult( + target: PluginDiscoveryResult, + source: PluginDiscoveryResult, + seenSources: Set, +): void { + for (const candidate of source.candidates) { + const key = candidate.source; + if (seenSources.has(key)) { + continue; + } + seenSources.add(key); + target.candidates.push(candidate); + } + target.diagnostics.push(...source.diagnostics); +} + +function getCachedDiscoveryResult(params: { + cacheEnabled: boolean; + cacheKey: string; + env: NodeJS.ProcessEnv; + load: () => PluginDiscoveryResult; +}): PluginDiscoveryResult { + const ttl = resolveDiscoveryCacheMs(params.env); + if (params.cacheEnabled) { + const cached = discoveryCache.get(params.cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.result; + } + } + const result = params.load(); + if (params.cacheEnabled && ttl > 0) { + discoveryCache.set(params.cacheKey, { expiresAt: Date.now() + ttl, result }); + } + return result; +} + function readPackageManifest(dir: string, rejectHardlinks = true): PackageManifest | null { const manifestPath = path.join(dir, "package.json"); const opened = openBoundaryFileSync({ @@ -869,91 +921,95 @@ export function discoverOpenClawPlugins(params: { }): PluginDiscoveryResult { const env = params.env ?? process.env; const cacheEnabled = params.cache !== false && shouldUseDiscoveryCache(env); - const cacheKey = buildDiscoveryCacheKey({ - workspaceDir: params.workspaceDir, - extraPaths: params.extraPaths, - ownershipUid: params.ownershipUid, - env, - }); - if (cacheEnabled) { - const cached = discoveryCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - return cached.result; - } - } - - const candidates: PluginCandidate[] = []; - const diagnostics: PluginDiagnostic[] = []; - const seen = new Set(); const workspaceDir = normalizeOptionalString(params.workspaceDir); const workspaceRoot = workspaceDir ? resolveUserPath(workspaceDir, env) : undefined; const roots = resolvePluginSourceRoots({ workspaceDir: workspaceRoot, env }); - - const extra = params.extraPaths ?? []; - for (const extraPath of extra) { - if (typeof extraPath !== "string") { - continue; - } - const trimmed = extraPath.trim(); - if (!trimmed) { - continue; - } - discoverFromPath({ - rawPath: trimmed, - origin: "config", + const scopedResult = getCachedDiscoveryResult({ + cacheEnabled, + cacheKey: buildScopedDiscoveryCacheKey({ + workspaceDir: params.workspaceDir, + extraPaths: params.extraPaths, ownershipUid: params.ownershipUid, - workspaceDir, env, - candidates, - diagnostics, - seen, - }); - } - const workspaceMatchesBundledRoot = resolvesToSameDirectory(workspaceRoot, roots.stock); - - if (roots.workspace && workspaceRoot && !workspaceMatchesBundledRoot) { - // Keep workspace auto-discovery constrained to the OpenClaw extensions root. - // Recursively scanning the full workspace treats arbitrary project folders as - // plugin candidates and causes noisy "plugin manifest not found" validation failures. - discoverInDirectory({ - dir: roots.workspace, - origin: "workspace", - ownershipUid: params.ownershipUid, - workspaceDir: workspaceRoot, - candidates, - diagnostics, - seen, - }); - } - - if (roots.stock) { - discoverInDirectory({ - dir: roots.stock, - origin: "bundled", - ownershipUid: params.ownershipUid, - candidates, - diagnostics, - seen, - }); - } - - // Keep auto-discovered global extensions behind bundled plugins. - // Users can still intentionally override via plugins.load.paths (origin=config). - discoverInDirectory({ - dir: roots.global, - origin: "global", - ownershipUid: params.ownershipUid, - candidates, - diagnostics, - seen, + }), + env, + load: () => { + const result = createDiscoveryResult(); + const seen = new Set(); + const extra = params.extraPaths ?? []; + for (const extraPath of extra) { + if (typeof extraPath !== "string") { + continue; + } + const trimmed = extraPath.trim(); + if (!trimmed) { + continue; + } + discoverFromPath({ + rawPath: trimmed, + origin: "config", + ownershipUid: params.ownershipUid, + workspaceDir, + env, + candidates: result.candidates, + diagnostics: result.diagnostics, + seen, + }); + } + const workspaceMatchesBundledRoot = resolvesToSameDirectory(workspaceRoot, roots.stock); + if (roots.workspace && workspaceRoot && !workspaceMatchesBundledRoot) { + // Keep workspace auto-discovery constrained to the OpenClaw extensions root. + // Recursively scanning the full workspace treats arbitrary project folders as + // plugin candidates and causes noisy "plugin manifest not found" validation failures. + discoverInDirectory({ + dir: roots.workspace, + origin: "workspace", + ownershipUid: params.ownershipUid, + workspaceDir: workspaceRoot, + candidates: result.candidates, + diagnostics: result.diagnostics, + seen, + }); + } + return result; + }, }); - - const result = { candidates, diagnostics }; - if (cacheEnabled) { - const ttl = resolveDiscoveryCacheMs(env); - if (ttl > 0) { - discoveryCache.set(cacheKey, { expiresAt: Date.now() + ttl, result }); - } - } + const sharedResult = getCachedDiscoveryResult({ + cacheEnabled, + cacheKey: buildSharedDiscoveryCacheKey({ + ownershipUid: params.ownershipUid, + env, + }), + env, + load: () => { + const result = createDiscoveryResult(); + const seen = new Set(); + if (roots.stock) { + discoverInDirectory({ + dir: roots.stock, + origin: "bundled", + ownershipUid: params.ownershipUid, + candidates: result.candidates, + diagnostics: result.diagnostics, + seen, + }); + } + // Keep auto-discovered global extensions behind bundled plugins. + // Users can still intentionally override via plugins.load.paths (origin=config). + discoverInDirectory({ + dir: roots.global, + origin: "global", + ownershipUid: params.ownershipUid, + candidates: result.candidates, + diagnostics: result.diagnostics, + seen, + }); + return result; + }, + }); + const result = createDiscoveryResult(); + const seenSources = new Set(); + mergeDiscoveryResult(result, scopedResult, seenSources); + mergeDiscoveryResult(result, sharedResult, seenSources); return result; }