Plugins: extract loader cache control

This commit is contained in:
Gustavo Madeira Santana
2026-03-15 11:54:43 +00:00
parent 25fc09885e
commit 03656c8f26
4 changed files with 207 additions and 17 deletions

View File

@@ -32,6 +32,7 @@ This is an implementation checklist, not a future-design spec.
| Resolved static registry | flat rows in `src/plugins/manifest-registry.ts` | `src/extension-host/resolved-registry.ts` | `partial` | Manifest records now carry `resolvedExtension`; a host-owned resolved registry view exists for static consumers. |
| Manifest/package metadata loading | `src/plugins/manifest.ts`, `src/plugins/discovery.ts`, `src/plugins/install.ts` | `src/extension-host/schema.ts` and `src/extension-host/manifest-registry.ts` | `partial` | Package metadata parsing is routed through host schema helpers; legacy loader flow still supplies the source manifests. |
| Loader SDK alias compatibility | `src/plugins/loader.ts` | `src/extension-host/loader-compat.ts` | `partial` | Plugin-SDK alias candidate ordering, alias-file resolution, and scoped alias-map construction now live in host-owned loader compatibility helpers. |
| Loader cache key and registry cache control | `src/plugins/loader.ts` | `src/extension-host/loader-cache.ts` | `partial` | Cache-key construction, LRU registry cache reads and writes, and cache clearing now delegate through host-owned loader-cache helpers while preserving the current cache shape and cap. |
| Loader provenance and duplicate-order policy | `src/plugins/loader.ts` | `src/extension-host/loader-policy.ts` | `partial` | Plugin-record creation, duplicate precedence, provenance indexing, and allowlist/untracked warnings now live in host-owned loader-policy helpers. |
| Loader initial candidate planning and record creation | `src/plugins/loader.ts` | `src/extension-host/loader-records.ts` | `partial` | Duplicate detection, initial record creation, manifest metadata attachment, and first-pass enable-state planning now delegate through host-owned loader-records helpers. |
| Loader entry-path opening and module import | `src/plugins/loader.ts` | `src/extension-host/loader-import.ts` | `partial` | Boundary-checked entry opening and module import now delegate through host-owned loader-import helpers while preserving the current trusted in-process loading model. |
@@ -85,14 +86,14 @@ That pattern has been used for:
- active registry ownership
- normalized extension schema and resolved-extension records
- static consumers such as skills, validation, auto-enable, and config baseline generation
- loader compatibility, initial candidate planning, entry-path import, policy, runtime decisions, post-import register flow, per-candidate orchestration, record-state transitions with explicit compatibility lifecycle mapping, and final cache plus activation finalization
- loader compatibility, cache control, initial candidate planning, entry-path import, policy, runtime decisions, post-import register flow, per-candidate orchestration, record-state transitions with explicit compatibility lifecycle mapping, and final cache plus activation finalization
## Immediate Next Targets
These are the next lowest-risk cutover steps:
1. Replace remaining static-only manifest-registry injections with resolved-extension registry inputs where practical.
2. Grow the compatibility `lifecycleState` mapping into an explicit lifecycle state machine and move any remaining cache-control or policy orchestration into `src/extension-host/*`.
2. Grow the compatibility `lifecycleState` mapping into an explicit lifecycle state machine and move any remaining activation-state or policy orchestration into `src/extension-host/*`.
3. Introduce explicit host-owned registration surfaces for runtime writes, starting with the least-coupled registries.
4. Move minimal SDK compatibility and loader normalization into `src/extension-host/*` without breaking current `openclaw/plugin-sdk/*` loading.
5. Start the first pilot on `extensions/thread-ownership` only after the host-side registry and lifecycle seams are explicit.

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import type { PluginRegistry } from "../plugins/registry.js";
import {
buildExtensionHostRegistryCacheKey,
clearExtensionHostRegistryCache,
getCachedExtensionHostRegistry,
MAX_EXTENSION_HOST_REGISTRY_CACHE_ENTRIES,
setCachedExtensionHostRegistry,
} from "./loader-cache.js";
function createRegistry(id: string): PluginRegistry {
return {
plugins: [
{
id,
name: id,
source: `/plugins/${id}.js`,
origin: "workspace",
enabled: true,
status: "loaded",
lifecycleState: "registered",
toolNames: [],
hookNames: [],
channelIds: [],
providerIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
configSchema: false,
},
],
tools: [],
hooks: [],
typedHooks: [],
channels: [],
providers: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],
services: [],
commands: [],
diagnostics: [],
};
}
describe("extension host loader cache", () => {
it("normalizes install paths into the cache key", () => {
const env = { ...process.env, HOME: "/tmp/home" };
const first = buildExtensionHostRegistryCacheKey({
workspaceDir: "/workspace",
plugins: {
enabled: true,
allow: [],
loadPaths: ["~/plugins"],
entries: {},
slots: {},
},
installs: {
demo: {
installPath: "~/demo-install",
sourcePath: "~/demo-source",
},
},
env,
});
const second = buildExtensionHostRegistryCacheKey({
workspaceDir: "/workspace",
plugins: {
enabled: true,
allow: [],
loadPaths: ["/tmp/home/plugins"],
entries: {},
slots: {},
},
installs: {
demo: {
installPath: "/tmp/home/demo-install",
sourcePath: "/tmp/home/demo-source",
},
},
env,
});
expect(first).toBe(second);
});
it("evicts least recently used registries", () => {
clearExtensionHostRegistryCache();
for (let index = 0; index < MAX_EXTENSION_HOST_REGISTRY_CACHE_ENTRIES + 1; index += 1) {
setCachedExtensionHostRegistry(`cache-${index}`, createRegistry(`plugin-${index}`));
}
expect(getCachedExtensionHostRegistry("cache-0")).toBeUndefined();
expect(
getCachedExtensionHostRegistry(`cache-${MAX_EXTENSION_HOST_REGISTRY_CACHE_ENTRIES}`),
).toBeDefined();
});
it("refreshes cache insertion order on reads", () => {
clearExtensionHostRegistryCache();
for (let index = 0; index < MAX_EXTENSION_HOST_REGISTRY_CACHE_ENTRIES; index += 1) {
setCachedExtensionHostRegistry(`cache-${index}`, createRegistry(`plugin-${index}`));
}
const refreshed = getCachedExtensionHostRegistry("cache-0");
expect(refreshed).toBeDefined();
setCachedExtensionHostRegistry("cache-new", createRegistry("plugin-new"));
expect(getCachedExtensionHostRegistry("cache-1")).toBeUndefined();
expect(getCachedExtensionHostRegistry("cache-0")).toBe(refreshed);
});
});

View File

@@ -0,0 +1,72 @@
import type { PluginInstallRecord } from "../config/types.plugins.js";
import type { NormalizedPluginsConfig } from "../plugins/config-state.js";
import type { PluginRegistry } from "../plugins/registry.js";
import { resolvePluginCacheInputs } from "../plugins/roots.js";
import { resolveUserPath } from "../utils.js";
export const MAX_EXTENSION_HOST_REGISTRY_CACHE_ENTRIES = 32;
const extensionHostRegistryCache = new Map<string, PluginRegistry>();
export function clearExtensionHostRegistryCache(): void {
extensionHostRegistryCache.clear();
}
export function getCachedExtensionHostRegistry(cacheKey: string): PluginRegistry | undefined {
const cached = extensionHostRegistryCache.get(cacheKey);
if (!cached) {
return undefined;
}
// Refresh insertion order so frequently reused registries survive eviction.
extensionHostRegistryCache.delete(cacheKey);
extensionHostRegistryCache.set(cacheKey, cached);
return cached;
}
export function setCachedExtensionHostRegistry(cacheKey: string, registry: PluginRegistry): void {
if (extensionHostRegistryCache.has(cacheKey)) {
extensionHostRegistryCache.delete(cacheKey);
}
extensionHostRegistryCache.set(cacheKey, registry);
while (extensionHostRegistryCache.size > MAX_EXTENSION_HOST_REGISTRY_CACHE_ENTRIES) {
const oldestKey = extensionHostRegistryCache.keys().next().value;
if (!oldestKey) {
break;
}
extensionHostRegistryCache.delete(oldestKey);
}
}
export function buildExtensionHostRegistryCacheKey(params: {
workspaceDir?: string;
plugins: NormalizedPluginsConfig;
installs?: Record<string, PluginInstallRecord>;
env: NodeJS.ProcessEnv;
}): string {
const { roots, loadPaths } = resolvePluginCacheInputs({
workspaceDir: params.workspaceDir,
loadPaths: params.plugins.loadPaths,
env: params.env,
});
const installs = Object.fromEntries(
Object.entries(params.installs ?? {}).map(([pluginId, install]) => [
pluginId,
{
...install,
installPath:
typeof install.installPath === "string"
? resolveUserPath(install.installPath, params.env)
: install.installPath,
sourcePath:
typeof install.sourcePath === "string"
? resolveUserPath(install.sourcePath, params.env)
: install.sourcePath,
},
]),
);
return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({
...params.plugins,
installs,
loadPaths,
})}`;
}

View File

@@ -1,7 +1,13 @@
import { createJiti } from "jiti";
import type { OpenClawConfig } from "../config/config.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { activateExtensionHostRegistry } from "../extension-host/activation.js";
import {
buildExtensionHostRegistryCacheKey,
clearExtensionHostRegistryCache,
getCachedExtensionHostRegistry,
MAX_EXTENSION_HOST_REGISTRY_CACHE_ENTRIES,
setCachedExtensionHostRegistry,
} from "../extension-host/loader-cache.js";
import {
listPluginSdkAliasCandidates,
listPluginSdkExportedSubpaths,
@@ -20,19 +26,13 @@ import {
} from "../extension-host/loader-policy.js";
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveUserPath } from "../utils.js";
import { clearPluginCommands } from "./commands.js";
import {
applyTestPluginDefaults,
normalizePluginsConfig,
type NormalizedPluginsConfig,
} from "./config-state.js";
import { applyTestPluginDefaults, normalizePluginsConfig } from "./config-state.js";
import { discoverOpenClawPlugins } from "./discovery.js";
import { initializeGlobalHookRunner } from "./hook-runner-global.js";
import { clearPluginInteractiveHandlers } from "./interactive.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
import { resolvePluginCacheInputs } from "./roots.js";
import { createPluginRuntime, type CreatePluginRuntimeOptions } from "./runtime/index.js";
import type { PluginRuntime } from "./runtime/types.js";
import { validateJsonSchemaValue } from "./schema-validator.js";
@@ -60,12 +60,10 @@ export type PluginLoadOptions = {
mode?: "full" | "validate";
};
const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 32;
const registryCache = new Map<string, PluginRegistry>();
const openAllowlistWarningCache = new Set<string>();
export function clearPluginLoaderCache(): void {
registryCache.clear();
clearExtensionHostRegistryCache();
openAllowlistWarningCache.clear();
}
@@ -216,7 +214,7 @@ export const __testing = {
resolveExtensionApiAlias,
resolvePluginSdkAliasCandidateOrder,
resolvePluginSdkAliasFile,
maxPluginRegistryCacheEntries: MAX_PLUGIN_REGISTRY_CACHE_ENTRIES,
maxPluginRegistryCacheEntries: MAX_EXTENSION_HOST_REGISTRY_CACHE_ENTRIES,
};
function getCachedPluginRegistry(cacheKey: string): PluginRegistry | undefined {
@@ -655,7 +653,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
const logger = options.logger ?? defaultLogger();
const validateOnly = options.mode === "validate";
const normalized = normalizePluginsConfig(cfg.plugins);
const cacheKey = buildCacheKey({
const cacheKey = buildExtensionHostRegistryCacheKey({
workspaceDir: options.workspaceDir,
plugins: normalized,
installs: cfg.plugins?.installs,
@@ -663,7 +661,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
});
const cacheEnabled = options.cache !== false;
if (cacheEnabled) {
const cached = getCachedPluginRegistry(cacheKey);
const cached = getCachedExtensionHostRegistry(cacheKey);
if (cached) {
activateExtensionHostRegistry(cached, cacheKey);
return cached;
@@ -980,7 +978,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
env,
cacheEnabled,
cacheKey,
setCachedRegistry: setCachedPluginRegistry,
setCachedRegistry: setCachedExtensionHostRegistry,
activateRegistry: activateExtensionHostRegistry,
});
}