From 03656c8f26d46b2f2e6526872f240365688467cf Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Mar 2026 11:54:43 +0000 Subject: [PATCH] Plugins: extract loader cache control --- src/extension-host/cutover-inventory.md | 5 +- src/extension-host/loader-cache.test.ts | 119 ++++++++++++++++++++++++ src/extension-host/loader-cache.ts | 72 ++++++++++++++ src/plugins/loader.ts | 28 +++--- 4 files changed, 207 insertions(+), 17 deletions(-) create mode 100644 src/extension-host/loader-cache.test.ts create mode 100644 src/extension-host/loader-cache.ts diff --git a/src/extension-host/cutover-inventory.md b/src/extension-host/cutover-inventory.md index 6bc15191b2b..80b2428f676 100644 --- a/src/extension-host/cutover-inventory.md +++ b/src/extension-host/cutover-inventory.md @@ -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. diff --git a/src/extension-host/loader-cache.test.ts b/src/extension-host/loader-cache.test.ts new file mode 100644 index 00000000000..f9e103b6557 --- /dev/null +++ b/src/extension-host/loader-cache.test.ts @@ -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); + }); +}); diff --git a/src/extension-host/loader-cache.ts b/src/extension-host/loader-cache.ts new file mode 100644 index 00000000000..1649fbc8ff5 --- /dev/null +++ b/src/extension-host/loader-cache.ts @@ -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(); + +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; + 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, + })}`; +} diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 74dde8c7667..dcdac79271d 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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(); const openAllowlistWarningCache = new Set(); 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, }); }