fix(plugins): reuse shared discovery cache

This commit is contained in:
Ayaan Zaidi
2026-04-17 08:28:05 +05:30
parent c6af0437c9
commit 9da4d5f5df
2 changed files with 189 additions and 83 deletions

View File

@@ -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",

View File

@@ -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<string>,
): 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<string>();
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<string>();
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<string>();
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<string>();
mergeDiscoveryResult(result, scopedResult, seenSources);
mergeDiscoveryResult(result, sharedResult, seenSources);
return result;
}