perf: reuse plugin metadata snapshots (#85843)

* perf: reuse plugin metadata snapshots

* test: update plugin metadata snapshot mocks
This commit is contained in:
Peter Steinberger
2026-05-23 23:34:19 +01:00
committed by GitHub
parent 45fbf2d81a
commit 4314674054
32 changed files with 405 additions and 486 deletions

View File

@@ -23,7 +23,7 @@ import type { InstalledPluginIndexRecord } from "./installed-plugin-index.js";
import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js";
import {
isPluginMetadataSnapshotCompatible,
loadPluginMetadataSnapshot,
resolvePluginMetadataSnapshot,
type PluginMetadataSnapshot,
} from "./plugin-metadata-snapshot.js";
import {
@@ -938,10 +938,11 @@ export function loadGatewayStartupPluginPlan(params: {
index: params.index,
})
? params.metadataSnapshot
: loadPluginMetadataSnapshot({
: resolvePluginMetadataSnapshot({
config: snapshotConfig,
workspaceDir: params.workspaceDir,
env: params.env,
allowWorkspaceScopedCurrent: params.workspaceDir === undefined,
...(params.index ? { index: params.index } : {}),
});
return resolveGatewayStartupPluginPlanFromRegistry({

View File

@@ -1,8 +1,7 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
import { isInstalledPluginEnabled } from "./installed-plugin-index.js";
import type { PluginManifestContractListKey, PluginManifestRecord } from "./manifest-registry.js";
import { loadPluginMetadataSnapshot } from "./plugin-metadata-snapshot.js";
import { resolvePluginMetadataSnapshot } from "./plugin-metadata-snapshot.js";
import type {
PluginMetadataManifestView,
PluginMetadataRegistryView,
@@ -98,18 +97,10 @@ export function loadManifestMetadataSnapshot(params: {
}): PluginMetadataSnapshot {
const config = params.config ?? {};
const env = params.env ?? process.env;
const current = getCurrentPluginMetadataSnapshot({
config,
env,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
...(params.workspaceDir === undefined ? { allowWorkspaceScopedSnapshot: true } : {}),
});
if (current) {
return current;
}
return loadPluginMetadataSnapshot({
return resolvePluginMetadataSnapshot({
config,
env,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
allowWorkspaceScopedCurrent: params.workspaceDir === undefined,
});
}

View File

@@ -1,10 +1,9 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
import type { PluginManifestRecord } from "./manifest-registry.js";
import type { PluginManifestModelIdNormalizationProvider } from "./manifest.js";
import {
loadPluginMetadataSnapshot,
resolvePluginMetadataSnapshot,
type PluginMetadataSnapshot,
} from "./plugin-metadata-snapshot.js";
import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js";
@@ -43,21 +42,14 @@ function resolveMetadataSnapshotForPolicies(
} {
const env = params.env ?? process.env;
const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState();
const current = getCurrentPluginMetadataSnapshot({
config: params.config,
env,
workspaceDir,
});
if (current) {
return { snapshot: current, cacheable: true };
}
return {
snapshot: loadPluginMetadataSnapshot({
snapshot: resolvePluginMetadataSnapshot({
config: params.config ?? {},
env,
workspaceDir,
allowWorkspaceScopedCurrent: true,
}),
cacheable: false,
cacheable: true,
};
}

View File

@@ -6,7 +6,7 @@ import {
import { hashJson } from "./installed-plugin-index-hash.js";
import {
isPluginMetadataSnapshotCompatible,
loadPluginMetadataSnapshot,
resolvePluginMetadataSnapshot,
type PluginMetadataSnapshot,
type PluginMetadataSnapshotOwnerMaps,
} from "./plugin-metadata-snapshot.js";
@@ -59,10 +59,11 @@ export function loadPluginLookUpTable(params: LoadPluginLookUpTableParams): Plug
index: params.index,
})
? params.metadataSnapshot
: loadPluginMetadataSnapshot({
: resolvePluginMetadataSnapshot({
config: requestedSnapshotConfig,
workspaceDir: params.workspaceDir,
env: params.env,
allowWorkspaceScopedCurrent: params.workspaceDir === undefined,
...(params.index ? { index: params.index } : {}),
});
const { index, manifestRegistry } = metadataSnapshot;

View File

@@ -2,11 +2,17 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clearCurrentPluginMetadataSnapshot,
setCurrentPluginMetadataSnapshot,
} from "./current-plugin-metadata-snapshot.js";
import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js";
import type { InstalledPluginIndex } from "./installed-plugin-index.js";
import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js";
import {
clearLoadPluginMetadataSnapshotMemo,
loadPluginMetadataSnapshot,
resolvePluginMetadataSnapshot,
} from "./plugin-metadata-snapshot.js";
const loadPluginRegistrySnapshotWithMetadata = vi.hoisted(() => vi.fn());
@@ -222,6 +228,7 @@ describe("loadPluginMetadataSnapshot process memo", () => {
afterEach(() => {
clearLoadPluginMetadataSnapshotMemo();
clearCurrentPluginMetadataSnapshot();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
@@ -254,6 +261,130 @@ describe("loadPluginMetadataSnapshot process memo", () => {
expect(second.byPluginId.get("demo")).toBe(second.plugins[0]);
});
it("keeps hot persisted snapshots for alternating config callers", () => {
const stateDir = tempStateDir();
touchPersistedIndex(stateDir);
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "persisted",
snapshot: makeIndex(),
diagnostics: [],
});
loadPluginMetadataSnapshot({
config: { plugins: { allow: ["demo"] } },
env: {},
stateDir,
});
loadPluginMetadataSnapshot({
config: { plugins: { allow: ["other"] } },
env: {},
stateDir,
});
loadPluginMetadataSnapshot({
config: { plugins: { allow: ["demo"] } },
env: {},
stateDir,
});
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledTimes(2);
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledTimes(2);
});
it("reuses workspace-scoped current snapshots when the caller opts in", () => {
const index = makeIndex();
index.policyHash = resolveInstalledPluginIndexPolicyHash({});
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "runtime",
snapshot: index,
diagnostics: [],
});
const snapshot = loadPluginMetadataSnapshot({
config: {},
env: {},
index,
workspaceDir: "/workspace/a",
});
setCurrentPluginMetadataSnapshot(snapshot, {
config: {},
env: {},
workspaceDir: "/workspace/a",
});
loadPluginRegistrySnapshotWithMetadata.mockClear();
loadPluginManifestRegistryForInstalledIndex.mockClear();
expect(
resolvePluginMetadataSnapshot({
config: {},
env: {},
allowWorkspaceScopedCurrent: true,
}),
).toBe(snapshot);
expect(loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled();
expect(loadPluginManifestRegistryForInstalledIndex).not.toHaveBeenCalled();
});
it("reuses compatible current snapshots without reloading metadata", () => {
const sourceConfig = { plugins: { allow: ["demo"] } };
const compatibleConfig = { plugins: { entries: { demo: { enabled: true } } } };
const index = makeIndex();
index.policyHash = resolveInstalledPluginIndexPolicyHash(sourceConfig);
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "runtime",
snapshot: index,
diagnostics: [],
});
const snapshot = loadPluginMetadataSnapshot({
config: sourceConfig,
env: {},
index,
workspaceDir: "/workspace/a",
});
setCurrentPluginMetadataSnapshot(snapshot, {
config: sourceConfig,
compatibleConfigs: [compatibleConfig],
env: {},
workspaceDir: "/workspace/a",
});
loadPluginRegistrySnapshotWithMetadata.mockClear();
loadPluginManifestRegistryForInstalledIndex.mockClear();
expect(
resolvePluginMetadataSnapshot({
config: compatibleConfig,
env: {},
workspaceDir: "/workspace/a",
}),
).toBe(snapshot);
expect(loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled();
expect(loadPluginManifestRegistryForInstalledIndex).not.toHaveBeenCalled();
});
it("does not scan persisted registry files when the caller provides an index", () => {
const stateDir = tempStateDir();
writePersistedIndex({ pluginId: "demo", stateDir });
const index = makeIndex();
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "provided",
snapshot: index,
diagnostics: [],
});
loadPluginMetadataSnapshot({ config: {}, env: {}, index, stateDir });
const statSpy = vi.spyOn(fs, "statSync");
const readSpy = vi.spyOn(fs, "readFileSync");
try {
loadPluginMetadataSnapshot({ config: {}, env: {}, index, stateDir });
} finally {
statSpy.mockRestore();
readSpy.mockRestore();
}
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce();
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce();
expect(statSpy).not.toHaveBeenCalled();
expect(readSpy).not.toHaveBeenCalled();
});
it("does not memoize policy-stale derived snapshots", () => {
const stateDir = tempStateDir();
touchPersistedIndex(stateDir);
@@ -366,7 +497,7 @@ describe("loadPluginMetadataSnapshot process memo", () => {
["source", "index.js", "source"],
["setup source", "setup.js", "setupSource"],
["package manifest", "package.json", "packageJsonPath"],
])("refreshes when persisted plugin %s changes in the same process", (_, fileName, field) => {
])("requires reload before persisted plugin %s edits are visible", (_, fileName, field) => {
const stateDir = tempStateDir();
const filePath = path.join(stateDir, "extensions", "demo", fileName);
writePersistedIndex({ [field]: filePath, pluginId: "demo", stateDir });
@@ -380,8 +511,8 @@ describe("loadPluginMetadataSnapshot process memo", () => {
writeJson(filePath, { id: "demo", version: "0.2.0" });
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledTimes(2);
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledTimes(2);
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce();
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce();
});
it.each([
@@ -398,7 +529,7 @@ describe("loadPluginMetadataSnapshot process memo", () => {
(homeDir: string) => path.join(homeDir, "tracked-plugin", "package.json"),
],
])(
"refreshes when home-relative install record %s changes",
"requires reload before home-relative install record %s changes are visible",
(_, recordPath, record, targetPath) => {
const stateDir = tempStateDir();
const homeDir = path.join(stateDir, "home");
@@ -415,12 +546,12 @@ describe("loadPluginMetadataSnapshot process memo", () => {
writeJson(filePath, { version: "1.0.1000" });
loadPluginMetadataSnapshot({ config: {}, env: { HOME: homeDir }, stateDir });
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledTimes(2);
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledTimes(2);
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce();
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce();
},
);
it("does not reuse home-relative install record watches across env changes", () => {
it("does not reuse home-relative install record memo state across env changes", () => {
const stateDir = tempStateDir();
const firstHomeDir = path.join(stateDir, "first-home");
const secondHomeDir = path.join(stateDir, "second-home");
@@ -442,11 +573,11 @@ describe("loadPluginMetadataSnapshot process memo", () => {
writeJson(secondPackageJsonPath, { version: "1.0.1000" });
loadPluginMetadataSnapshot({ config: {}, env: { HOME: secondHomeDir }, stateDir });
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledTimes(3);
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledTimes(3);
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledTimes(2);
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledTimes(2);
});
it("refreshes when recovered managed npm package metadata changes", () => {
it("requires reload before recovered managed npm package metadata changes are visible", () => {
const stateDir = tempStateDir();
writeRecoverableNpmPlugin({
packageName: "recovered-plugin",
@@ -470,11 +601,11 @@ describe("loadPluginMetadataSnapshot process memo", () => {
});
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledTimes(2);
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledTimes(2);
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce();
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce();
});
it("refreshes when a declared recovered managed npm package appears", () => {
it("requires reload before a declared recovered managed npm package appears", () => {
const stateDir = tempStateDir();
writeJson(path.join(stateDir, "npm", "package.json"), {
dependencies: {
@@ -497,11 +628,11 @@ describe("loadPluginMetadataSnapshot process memo", () => {
});
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledTimes(2);
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledTimes(2);
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce();
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce();
});
it("refreshes when an in-root package manifest symlink target changes", () => {
it("requires reload before an in-root package manifest symlink target change is visible", () => {
const stateDir = tempStateDir();
const pluginDir = path.join(stateDir, "extensions", "demo");
const packageJsonPath = path.join(pluginDir, "package.json");
@@ -520,8 +651,8 @@ describe("loadPluginMetadataSnapshot process memo", () => {
writeJson(outsidePackageJsonPath, { name: "outside", version: "1.0.1" });
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledTimes(2);
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledTimes(2);
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce();
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce();
});
it("does not fingerprint persisted plugin paths outside the plugin root", () => {

View File

@@ -8,10 +8,10 @@ import {
} from "../infra/diagnostics-timeline.js";
import { resolveUserPath } from "../utils.js";
import { resolveCompatibilityHostVersion } from "../version.js";
import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
import { resolveDefaultPluginNpmDir } from "./install-paths.js";
import { hashJson } from "./installed-plugin-index-hash.js";
import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js";
import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js";
import { resolveInstalledPluginIndexStorePath } from "./installed-plugin-index-store-path.js";
import type { InstalledPluginIndex } from "./installed-plugin-index.js";
import {
@@ -24,6 +24,7 @@ import type {
LoadPluginMetadataSnapshotParams,
PluginMetadataSnapshot,
PluginMetadataSnapshotOwnerMaps,
ResolvePluginMetadataSnapshotParams,
} from "./plugin-metadata-snapshot.types.js";
import { createPluginRegistryIdNormalizer } from "./plugin-registry-id-normalizer.js";
import {
@@ -41,14 +42,14 @@ type PersistedRegistryMemoState = {
contextHash: string;
fastHash: string;
fingerprint: unknown;
watchedFilesHash: string;
watchedFiles: readonly string[];
};
let pluginMetadataSnapshotMemo: PluginMetadataSnapshotMemo | undefined;
const MAX_PLUGIN_METADATA_SNAPSHOT_MEMOS = 8;
let pluginMetadataSnapshotMemos: PluginMetadataSnapshotMemo[] = [];
export function clearLoadPluginMetadataSnapshotMemo(): void {
pluginMetadataSnapshotMemo = undefined;
pluginMetadataSnapshotMemos = [];
}
const MEMO_RELEVANT_ENV_KEYS = [
@@ -74,6 +75,7 @@ export type {
PluginMetadataSnapshotMetrics,
PluginMetadataSnapshotOwnerMaps,
PluginMetadataSnapshotRegistryDiagnostic,
ResolvePluginMetadataSnapshotParams,
} from "./plugin-metadata-snapshot.types.js";
function fileFingerprint(filePath: string): unknown {
@@ -99,10 +101,6 @@ function readJsonObject(filePath: string): Record<string, unknown> | undefined {
}
}
function normalizeString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function stableMemoValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(stableMemoValue);
@@ -117,162 +115,6 @@ function stableMemoValue(value: unknown): unknown {
);
}
function isPathInsideOrEqual(childPath: string, parentPath: string): boolean {
const relative = path.relative(parentPath, childPath);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function tryRealpath(filePath: string): string | null {
try {
return fs.realpathSync(filePath);
} catch {
return null;
}
}
function resolvePluginFilePath(
pluginDir: string,
filePath: string | undefined,
options: { allowSymlinkOutsideRoot?: boolean } = {},
):
| { status: "ok"; path: string }
| { status: "outside-root"; path: string }
| { status: "missing-root"; path: string } {
if (!filePath) {
return { status: "missing-root", path: "" };
}
const rootDir = path.resolve(pluginDir);
const resolved = path.isAbsolute(filePath)
? path.resolve(filePath)
: path.resolve(rootDir, filePath);
if (!isPathInsideOrEqual(resolved, rootDir)) {
return { status: "outside-root", path: resolved };
}
const rootRealPath = tryRealpath(rootDir);
const targetRealPath = tryRealpath(resolved);
if (
rootRealPath &&
targetRealPath &&
!isPathInsideOrEqual(targetRealPath, rootRealPath) &&
!options.allowSymlinkOutsideRoot
) {
return { status: "outside-root", path: resolved };
}
return { status: "ok", path: resolved };
}
function persistedPluginFileFingerprint(
rootDir: string | undefined,
filePath: string | undefined,
options: { allowSymlinkOutsideRoot?: boolean; watchedFiles?: Set<string> } = {},
): unknown {
if (!filePath) {
return null;
}
if (!rootDir) {
return [filePath, "missing-root"];
}
const resolved = resolvePluginFilePath(rootDir, filePath, {
allowSymlinkOutsideRoot: options.allowSymlinkOutsideRoot,
});
if (resolved.status !== "ok") {
return [filePath, resolved.status];
}
options.watchedFiles?.add(resolved.path);
return fileFingerprint(resolved.path);
}
function watchedFileFingerprint(filePath: string | undefined, watchedFiles: Set<string>): unknown {
if (!filePath) {
return null;
}
watchedFiles.add(filePath);
return fileFingerprint(filePath);
}
function resolveInstallRecordPath(value: unknown, env: NodeJS.ProcessEnv): string | undefined {
const normalized = normalizeString(value);
return normalized ? resolveUserPath(normalized, env) : undefined;
}
function installRecordPathFingerprints(
env: NodeJS.ProcessEnv,
records: unknown,
watchedFiles: Set<string>,
): readonly unknown[] {
if (!isRecord(records)) {
return [];
}
return Object.entries(records)
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([pluginId, rawRecord]) => {
if (!isRecord(rawRecord)) {
return [pluginId, rawRecord];
}
const installPath = normalizeString(rawRecord.installPath);
const sourcePath = normalizeString(rawRecord.sourcePath);
const resolvedInstallPath = resolveInstallRecordPath(rawRecord.installPath, env);
const resolvedSourcePath = resolveInstallRecordPath(rawRecord.sourcePath, env);
return [
pluginId,
installPath,
sourcePath,
watchedFileFingerprint(
resolvedInstallPath ? path.join(resolvedInstallPath, "package.json") : undefined,
watchedFiles,
),
watchedFileFingerprint(
resolvedInstallPath ? path.join(resolvedInstallPath, "openclaw.plugin.json") : undefined,
watchedFiles,
),
watchedFileFingerprint(resolvedSourcePath, watchedFiles),
watchedFileFingerprint(
resolvedSourcePath ? path.join(resolvedSourcePath, "package.json") : undefined,
watchedFiles,
),
watchedFileFingerprint(
resolvedSourcePath ? path.join(resolvedSourcePath, "openclaw.plugin.json") : undefined,
watchedFiles,
),
];
});
}
function managedNpmDependencyMetadataFingerprints(
npmRoot: string,
watchedFiles: Set<string>,
): readonly unknown[] {
const rootManifest = readJsonObject(path.join(npmRoot, "package.json"));
const dependencies = isRecord(rootManifest?.dependencies) ? rootManifest.dependencies : {};
const nodeModulesRoot = path.join(npmRoot, "node_modules");
return Object.entries(dependencies)
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([packageName, rawSpec]) => {
const dependencySpec = normalizeString(rawSpec);
if (!dependencySpec) {
return [packageName, rawSpec];
}
const packageDir = path.resolve(nodeModulesRoot, packageName);
if (!isPathInsideOrEqual(packageDir, path.resolve(nodeModulesRoot))) {
return [packageName, dependencySpec, "outside-node-modules"];
}
return [
packageName,
dependencySpec,
watchedFileFingerprint(path.join(packageDir, "package.json"), watchedFiles),
watchedFileFingerprint(path.join(packageDir, "openclaw.plugin.json"), watchedFiles),
];
});
}
function resolveRecordPackageJsonPath(record: Record<string, unknown>): string | undefined {
const packageJson = record.packageJson;
if (!isRecord(packageJson)) {
return undefined;
}
return normalizeString(packageJson.path);
}
function pickMemoRelevantEnv(env: NodeJS.ProcessEnv): Record<string, string> {
return Object.fromEntries(
MEMO_RELEVANT_ENV_KEYS.flatMap((key) => {
@@ -376,10 +218,6 @@ function resolvePersistedRegistryMemoContextHash(params: {
});
}
function hashWatchedFiles(watchedFiles: readonly string[]): string {
return hashJson(watchedFiles.map((filePath) => fileFingerprint(filePath)));
}
function resolvePersistedRegistryMemoState(params: {
env: NodeJS.ProcessEnv;
index?: InstalledPluginIndex;
@@ -397,100 +235,20 @@ function resolvePersistedRegistryMemoState(params: {
contextHash,
fastHash,
fingerprint: fastFingerprint,
watchedFiles: [],
watchedFilesHash: hashJson([]),
};
}
const indexPath = resolveInstalledPluginIndexStorePath({
env: params.env,
...(params.stateDir ? { stateDir: params.stateDir } : {}),
});
const npmRoot = params.stateDir
? path.join(params.stateDir, "npm")
: resolveDefaultPluginNpmDir(params.env);
const index = params.index ?? readJsonObject(indexPath);
const plugins = Array.isArray(index?.plugins) ? index.plugins : [];
const diagnostics = Array.isArray(index?.diagnostics) ? index.diagnostics : [];
const pluginRootById = new Map<string, string>();
const watchedFiles = new Set<string>();
for (const rawPlugin of plugins) {
if (!isRecord(rawPlugin)) {
continue;
}
const pluginId = normalizeString(rawPlugin.pluginId);
const rootDir = normalizeString(rawPlugin.rootDir);
if (pluginId && rootDir) {
pluginRootById.set(pluginId, rootDir);
}
}
const installRecords =
params.index?.installRecords ??
loadInstalledPluginIndexInstallRecordsSync({
env: params.env,
...(params.stateDir ? { stateDir: params.stateDir } : {}),
});
const watchedPlugins = plugins.map((rawPlugin) => {
if (!isRecord(rawPlugin)) {
return rawPlugin;
}
const rootDir = normalizeString(rawPlugin.rootDir);
const manifestPath = normalizeString(rawPlugin.manifestPath);
const packageJsonPath = resolveRecordPackageJsonPath(rawPlugin);
const source = normalizeString(rawPlugin.source);
const setupSource = normalizeString(rawPlugin.setupSource);
return [
normalizeString(rawPlugin.pluginId),
rootDir,
rootDir ? fileFingerprint(rootDir) : null,
manifestPath,
persistedPluginFileFingerprint(rootDir, manifestPath, { watchedFiles }),
source,
persistedPluginFileFingerprint(rootDir, source, { watchedFiles }),
setupSource,
persistedPluginFileFingerprint(rootDir, setupSource, { watchedFiles }),
packageJsonPath,
persistedPluginFileFingerprint(rootDir, packageJsonPath, {
allowSymlinkOutsideRoot: true,
watchedFiles,
}),
];
});
const watchedDiagnostics = diagnostics.map((rawDiagnostic) => {
if (!isRecord(rawDiagnostic)) {
return rawDiagnostic;
}
const pluginId = normalizeString(rawDiagnostic.pluginId);
const source = normalizeString(rawDiagnostic.source);
return [
pluginId,
source,
persistedPluginFileFingerprint(pluginId ? pluginRootById.get(pluginId) : undefined, source, {
watchedFiles,
}),
];
});
const installRecordFiles = installRecordPathFingerprints(
params.env,
installRecords,
watchedFiles,
);
const managedNpmDependencyFiles = managedNpmDependencyMetadataFingerprints(npmRoot, watchedFiles);
const watchedFilesList = [...watchedFiles].toSorted();
return {
contextHash,
fastHash,
fingerprint: {
...fastFingerprint,
indexHash: hashJson(stableMemoValue(index) ?? null),
installRecords: hashJson(stableMemoValue(installRecords)),
installRecordFiles,
managedNpmDependencyFiles,
npmPackageJson: fileFingerprint(path.join(npmRoot, "package.json")),
plugins: watchedPlugins,
diagnostics: watchedDiagnostics,
},
watchedFiles: watchedFilesList,
watchedFilesHash: hashWatchedFiles(watchedFilesList),
};
}
@@ -500,7 +258,7 @@ function resolvePersistedRegistryMemoStateForLookup(
preferPersisted?: boolean;
stateDir?: string;
},
memo: PluginMetadataSnapshotMemo | undefined,
memos: readonly PluginMetadataSnapshotMemo[],
): PersistedRegistryMemoState {
const fastFingerprint = resolvePersistedRegistryFastMemoFingerprint(params);
const fastHash = hashJson(fastFingerprint);
@@ -508,18 +266,53 @@ function resolvePersistedRegistryMemoStateForLookup(
...params,
fastFingerprint,
});
const registryState = memo?.registryState;
if (
registryState &&
registryState.contextHash === contextHash &&
registryState.fastHash === fastHash &&
hashWatchedFiles(registryState.watchedFiles) === registryState.watchedFilesHash
) {
return registryState;
for (const memo of memos) {
const registryState = memo.registryState;
if (
registryState &&
registryState.contextHash === contextHash &&
registryState.fastHash === fastHash
) {
// Plugin files are immutable for a running gateway; plugin edits require
// an explicit reload/restart, so hot lookups only validate the registry envelope.
return registryState;
}
}
return resolvePersistedRegistryMemoState(params);
}
function resolveProvidedIndexMemoState(index: InstalledPluginIndex): PersistedRegistryMemoState {
const fingerprint = {
providedIndex: resolveInstalledManifestRegistryIndexFingerprint(index),
};
const fingerprintHash = hashJson(fingerprint);
return {
contextHash: fingerprintHash,
fastHash: fingerprintHash,
fingerprint,
};
}
function findPluginMetadataSnapshotMemo(key: string): PluginMetadataSnapshotMemo | undefined {
const index = pluginMetadataSnapshotMemos.findIndex((memo) => memo.key === key);
if (index === -1) {
return undefined;
}
const [memo] = pluginMetadataSnapshotMemos.splice(index, 1);
if (!memo) {
return undefined;
}
pluginMetadataSnapshotMemos.unshift(memo);
return memo;
}
function rememberPluginMetadataSnapshotMemo(memo: PluginMetadataSnapshotMemo): void {
pluginMetadataSnapshotMemos = [
memo,
...pluginMetadataSnapshotMemos.filter((existing) => existing.key !== memo.key),
].slice(0, MAX_PLUGIN_METADATA_SNAPSHOT_MEMOS);
}
function computePluginMetadataSnapshotMemoKey(params: {
params: LoadPluginMetadataSnapshotParams;
registryState: PersistedRegistryMemoState;
@@ -702,17 +495,21 @@ export function loadPluginMetadataSnapshot(
params: LoadPluginMetadataSnapshotParams,
): PluginMetadataSnapshot {
const activeTimelineSpan = getActiveDiagnosticsTimelineSpan();
const memo = pluginMetadataSnapshotMemo;
const env = params.env ?? process.env;
const registryState = resolvePersistedRegistryMemoStateForLookup(
{
env,
...(params.stateDir ? { stateDir: resolveUserPath(params.stateDir, env) } : {}),
...(params.preferPersisted !== undefined ? { preferPersisted: params.preferPersisted } : {}),
},
memo,
);
const registryState = params.index
? resolveProvidedIndexMemoState(params.index)
: resolvePersistedRegistryMemoStateForLookup(
{
env,
...(params.stateDir ? { stateDir: resolveUserPath(params.stateDir, env) } : {}),
...(params.preferPersisted !== undefined
? { preferPersisted: params.preferPersisted }
: {}),
},
pluginMetadataSnapshotMemos,
);
const memoKey = computePluginMetadataSnapshotMemoKey({ params, registryState });
const memo = findPluginMetadataSnapshotMemo(memoKey);
if (memo?.key === memoKey) {
return measureDiagnosticsTimelineSpanSync(
"plugins.metadata.scan",
@@ -755,11 +552,11 @@ export function loadPluginMetadataSnapshot(
: {}),
})
: registryState;
pluginMetadataSnapshotMemo = {
rememberPluginMetadataSnapshotMemo({
key: computePluginMetadataSnapshotMemoKey({ params, registryState: cachedRegistryState }),
registryState: cachedRegistryState,
snapshot: clonePluginMetadataSnapshot(result.snapshot),
};
});
}
return result.snapshot;
}
@@ -771,6 +568,45 @@ function canMemoizePluginMetadataSnapshotResult(result: {
return result.registrySource !== "derived" && result.snapshot.index.plugins.length > 0;
}
export function resolvePluginMetadataSnapshot(
params: ResolvePluginMetadataSnapshotParams,
): PluginMetadataSnapshot {
const canUseCurrentSnapshot =
params.allowCurrent !== false &&
params.stateDir === undefined &&
params.preferPersisted !== false;
if (canUseCurrentSnapshot) {
const current = getCurrentPluginMetadataSnapshot({
config: params.config,
env: params.env,
...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}),
...(params.allowWorkspaceScopedCurrent === true
? { allowWorkspaceScopedSnapshot: true }
: {}),
});
if (!current) {
return loadPluginMetadataSnapshot(params);
}
if (!params.index) {
return current;
}
if (
isPluginMetadataSnapshotCompatible({
snapshot: current,
config: params.config,
env: params.env,
workspaceDir:
params.workspaceDir ??
(params.allowWorkspaceScopedCurrent === true ? current.workspaceDir : undefined),
index: params.index,
})
) {
return current;
}
}
return loadPluginMetadataSnapshot(params);
}
function loadPluginMetadataSnapshotImpl(params: LoadPluginMetadataSnapshotParams): {
snapshot: PluginMetadataSnapshot;
registrySource: PluginRegistrySnapshotSource;

View File

@@ -62,3 +62,8 @@ export type LoadPluginMetadataSnapshotParams = {
index?: InstalledPluginIndex;
preferPersisted?: boolean;
};
export type ResolvePluginMetadataSnapshotParams = LoadPluginMetadataSnapshotParams & {
allowCurrent?: boolean;
allowWorkspaceScopedCurrent?: boolean;
};

View File

@@ -45,6 +45,7 @@ vi.mock("../../agents/agent-scope.js", () => ({
vi.mock("../plugin-metadata-snapshot.js", () => ({
loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock,
resolvePluginMetadataSnapshot: loadPluginMetadataSnapshotMock,
}));
vi.mock("../current-plugin-metadata-snapshot.js", () => ({
@@ -121,6 +122,7 @@ describe("resolvePluginRuntimeLoadContext", () => {
installRecords: {},
});
expect(loadPluginMetadataSnapshotMock).toHaveBeenCalledWith({
allowWorkspaceScopedCurrent: true,
config: rawConfig,
env,
workspaceDir: "/resolved-workspace",

View File

@@ -7,14 +7,13 @@ import { createSubsystemLogger } from "../../logging.js";
import { resolvePluginActivationSourceConfig } from "../activation-source-config.js";
import {
clearCurrentPluginMetadataSnapshot,
getCurrentPluginMetadataSnapshot,
isReusableCurrentPluginMetadataSnapshot,
setCurrentPluginMetadataSnapshot,
} from "../current-plugin-metadata-snapshot.js";
import { extractPluginInstallRecordsFromInstalledPluginIndex } from "../installed-plugin-index-install-records.js";
import type { PluginLoadOptions } from "../loader.js";
import type { PluginManifestRegistry } from "../manifest-registry.js";
import { loadPluginMetadataSnapshot } from "../plugin-metadata-snapshot.js";
import { resolvePluginMetadataSnapshot } from "../plugin-metadata-snapshot.js";
import type { PluginLogger } from "../types.js";
const log = createSubsystemLogger("plugins");
@@ -70,16 +69,12 @@ export function resolvePluginRuntimeLoadContext(
options?.workspaceDir ?? resolveAgentWorkspaceDir(rawConfig, resolveDefaultAgentId(rawConfig));
const metadataSnapshot = options?.manifestRegistry
? undefined
: (getCurrentPluginMetadataSnapshot({
: resolvePluginMetadataSnapshot({
config: rawConfig,
env,
workspaceDir: rawWorkspaceDir,
}) ??
loadPluginMetadataSnapshot({
config: rawConfig,
env,
workspaceDir: rawWorkspaceDir,
}));
allowWorkspaceScopedCurrent: true,
});
const manifestRegistry = options?.manifestRegistry ?? metadataSnapshot?.manifestRegistry;
const installRecords = metadataSnapshot
? extractPluginInstallRecordsFromInstalledPluginIndex(metadataSnapshot.index)

View File

@@ -22,9 +22,23 @@ vi.mock("./manifest-registry-installed.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./manifest-registry-installed.js")>()),
loadPluginManifestRegistryForInstalledIndex: loadPluginManifestRegistryForInstalledIndexMock,
}));
vi.mock("./plugin-metadata-snapshot.js", () => ({
loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock,
}));
vi.mock("./plugin-metadata-snapshot.js", async () => {
const current = await import("./current-plugin-metadata-snapshot.js");
return {
loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock,
resolvePluginMetadataSnapshot: (
params: Parameters<typeof current.getCurrentPluginMetadataSnapshot>[0] & {
allowWorkspaceScopedCurrent?: boolean;
},
) =>
current.getCurrentPluginMetadataSnapshot({
config: params.config,
env: params.env,
workspaceDir: params.workspaceDir,
allowWorkspaceScopedSnapshot: params.allowWorkspaceScopedCurrent,
}) ?? loadPluginMetadataSnapshotMock(params),
};
});
afterEach(() => {
clearCurrentPluginMetadataSnapshot();

View File

@@ -1,10 +1,9 @@
import { createRequire } from "node:module";
import { normalizeProviderId } from "../agents/provider-id.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
import { isInstalledPluginEnabled } from "./installed-plugin-index.js";
import {
loadPluginMetadataSnapshot,
resolvePluginMetadataSnapshot,
type PluginMetadataSnapshot,
} from "./plugin-metadata-snapshot.js";
import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js";
@@ -57,21 +56,19 @@ function resolveMetadataSnapshotForSetupCliBackends(
} {
const env = params.env ?? process.env;
const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState();
const current = getCurrentPluginMetadataSnapshot({
config: params.config,
const snapshot = resolvePluginMetadataSnapshot({
config: params.config ?? {},
env,
workspaceDir,
...(workspaceDir !== undefined
? {
workspaceDir,
allowWorkspaceScopedCurrent: true,
}
: {}),
});
if (current) {
return { snapshot: current, cacheable: true };
}
return {
snapshot: loadPluginMetadataSnapshot({
config: params.config ?? {},
env,
workspaceDir,
}),
cacheable: false,
snapshot,
cacheable: true,
};
}