mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 14:20:44 +00:00
fix(plugins): reuse shared discovery cache
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user