diff --git a/src/extension-host/cutover-inventory.md b/src/extension-host/cutover-inventory.md index bd124962807..e8454e5fe23 100644 --- a/src/extension-host/cutover-inventory.md +++ b/src/extension-host/cutover-inventory.md @@ -43,6 +43,7 @@ This is an implementation checklist, not a future-design spec. | Loader post-import planning and register execution | `src/plugins/loader.ts` | `src/extension-host/loader-register.ts` | `partial` | Definition application, post-import validation planning, and `register(...)` execution now delegate through host-owned loader-register helpers while preserving current plugin behavior. | | Loader per-candidate orchestration | `src/plugins/loader.ts` | `src/extension-host/loader-flow.ts` | `partial` | The per-candidate load flow now runs through a host-owned orchestrator that composes planning, import, runtime validation, register execution, and record-state helpers. | | Loader top-level load orchestration | `src/plugins/loader.ts` | `src/extension-host/loader-orchestrator.ts` | `partial` | Cache hits, runtime creation, discovery, manifest loading, candidate ordering, candidate processing, and finalization now route through a host-owned loader orchestrator while `src/plugins/loader.ts` remains the compatibility facade. | +| Loader preflight and cache-hit setup | mixed inside `src/plugins/loader.ts` and `src/extension-host/loader-orchestrator.ts` | `src/extension-host/loader-preflight.ts` | `partial` | Test-default application, config normalization, cache-key construction, cache-hit activation, and command-clear preflight now delegate through a host-owned loader-preflight helper. | | Loader execution setup composition | mixed inside `src/plugins/loader.ts` and `src/extension-host/loader-orchestrator.ts` | `src/extension-host/loader-execution.ts` | `partial` | Runtime creation, registry creation, bootstrap setup, module-loader creation, and session creation now delegate through a host-owned loader-execution helper. | | Loader discovery and manifest bootstrap | mixed inside `src/plugins/loader.ts` and `src/extension-host/loader-orchestrator.ts` | `src/extension-host/loader-bootstrap.ts` | `partial` | Discovery, manifest loading, manifest diagnostics, discovery-policy logging, provenance building, and candidate ordering now delegate through a host-owned loader-bootstrap helper. | | Loader mutable activation state session | local variables in `src/extension-host/loader-orchestrator.ts` | `src/extension-host/loader-session.ts` | `partial` | Seen-id tracking, memory-slot selection state, and finalization inputs now live in a host-owned loader session instead of being spread across top-level loader variables. | diff --git a/src/extension-host/loader-orchestrator.ts b/src/extension-host/loader-orchestrator.ts index 9329686e597..ea5c3380251 100644 --- a/src/extension-host/loader-orchestrator.ts +++ b/src/extension-host/loader-orchestrator.ts @@ -1,19 +1,17 @@ import type { OpenClawConfig } from "../config/config.js"; import { activateExtensionHostRegistry } from "../extension-host/activation.js"; import { - buildExtensionHostRegistryCacheKey, clearExtensionHostRegistryCache, - getCachedExtensionHostRegistry, setCachedExtensionHostRegistry, } from "../extension-host/loader-cache.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { clearPluginCommands } from "../plugins/commands.js"; -import { applyTestPluginDefaults, normalizePluginsConfig } from "../plugins/config-state.js"; import type { PluginRegistry } from "../plugins/registry.js"; import { createPluginRuntime, type CreatePluginRuntimeOptions } from "../plugins/runtime/index.js"; import type { PluginLogger } from "../plugins/types.js"; import { prepareExtensionHostLoaderExecution } from "./loader-execution.js"; +import { prepareExtensionHostLoaderPreflight } from "./loader-preflight.js"; import { runExtensionHostLoaderSession } from "./loader-run.js"; export type ExtensionHostPluginLoadOptions = { @@ -39,39 +37,23 @@ export function clearExtensionHostLoaderState(): void { export function loadExtensionHostPluginRegistry( options: ExtensionHostPluginLoadOptions = {}, ): PluginRegistry { - const env = options.env ?? process.env; - // Test env: default-disable plugins unless explicitly configured. - // This keeps unit/gateway suites fast and avoids loading heavyweight plugin deps by accident. - const cfg = applyTestPluginDefaults(options.config ?? {}, env); - const logger = options.logger ?? defaultLogger(); - const validateOnly = options.mode === "validate"; - const normalized = normalizePluginsConfig(cfg.plugins); - const cacheKey = buildExtensionHostRegistryCacheKey({ - workspaceDir: options.workspaceDir, - plugins: normalized, - installs: cfg.plugins?.installs, - env, + const preflight = prepareExtensionHostLoaderPreflight({ + options, + createDefaultLogger: defaultLogger, + clearPluginCommands, }); - const cacheEnabled = options.cache !== false; - if (cacheEnabled) { - const cached = getCachedExtensionHostRegistry(cacheKey); - if (cached) { - activateExtensionHostRegistry(cached, cacheKey); - return cached; - } + if (preflight.cacheHit) { + return preflight.registry; } - // Clear previously registered plugin commands before reloading. - clearPluginCommands(); - const execution = prepareExtensionHostLoaderExecution({ - config: cfg, + config: preflight.config, workspaceDir: options.workspaceDir, - env, + env: preflight.env, cache: options.cache, - cacheKey, - normalizedConfig: normalized, - logger, + cacheKey: preflight.cacheKey, + normalizedConfig: preflight.normalizedConfig, + logger: preflight.logger, coreGatewayHandlers: options.coreGatewayHandlers as Record, runtimeOptions: options.runtimeOptions, warningCache: openAllowlistWarningCache, @@ -84,9 +66,9 @@ export function loadExtensionHostPluginRegistry( session: execution.session, orderedCandidates: execution.orderedCandidates, manifestByRoot: execution.manifestByRoot, - normalizedConfig: normalized, - rootConfig: cfg, - validateOnly, + normalizedConfig: preflight.normalizedConfig, + rootConfig: preflight.config, + validateOnly: preflight.validateOnly, createApi: execution.createApi, loadModule: execution.loadModule, }); diff --git a/src/extension-host/loader-preflight.test.ts b/src/extension-host/loader-preflight.test.ts new file mode 100644 index 00000000000..99f2d56905d --- /dev/null +++ b/src/extension-host/loader-preflight.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; +import { prepareExtensionHostLoaderPreflight } from "./loader-preflight.js"; + +describe("extension host loader preflight", () => { + it("returns a cache hit without clearing commands", () => { + const registry = { plugins: [] } as never; + const clearPluginCommands = vi.fn(); + const activateRegistry = vi.fn(); + + const result = prepareExtensionHostLoaderPreflight({ + options: { + env: { TEST: "1" }, + }, + createDefaultLogger: vi.fn(() => ({ info() {}, warn() {}, error() {} })) as never, + clearPluginCommands, + applyTestDefaults: vi.fn((config) => config) as never, + normalizeConfig: vi.fn(() => ({ installs: [], entries: {}, slots: {} })) as never, + buildCacheKey: vi.fn(() => "cache-key") as never, + getCachedRegistry: vi.fn(() => registry) as never, + activateRegistry: activateRegistry as never, + }); + + expect(result).toEqual({ + cacheHit: true, + registry, + }); + expect(activateRegistry).toHaveBeenCalledWith(registry, "cache-key"); + expect(clearPluginCommands).not.toHaveBeenCalled(); + }); + + it("normalizes inputs and clears commands on a cache miss", () => { + const clearPluginCommands = vi.fn(); + const logger = { info() {}, warn() {}, error() {} }; + + const result = prepareExtensionHostLoaderPreflight({ + options: { + config: { plugins: { enabled: true } }, + workspaceDir: "/workspace", + env: { TEST: "1" }, + mode: "validate", + }, + createDefaultLogger: vi.fn(() => logger) as never, + clearPluginCommands, + applyTestDefaults: vi.fn((config) => ({ + ...config, + plugins: { ...config.plugins, allow: ["demo"] }, + })) as never, + normalizeConfig: vi.fn(() => ({ + enabled: true, + allow: ["demo"], + loadPaths: [], + entries: {}, + slots: {}, + })) as never, + buildCacheKey: vi.fn(() => "cache-key") as never, + getCachedRegistry: vi.fn(() => null) as never, + activateRegistry: vi.fn() as never, + }); + + expect(result).toMatchObject({ + cacheHit: false, + env: { TEST: "1" }, + logger, + validateOnly: true, + cacheKey: "cache-key", + normalizedConfig: { + allow: ["demo"], + }, + }); + expect(clearPluginCommands).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/extension-host/loader-preflight.ts b/src/extension-host/loader-preflight.ts new file mode 100644 index 00000000000..dbd16d87212 --- /dev/null +++ b/src/extension-host/loader-preflight.ts @@ -0,0 +1,75 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { applyTestPluginDefaults, normalizePluginsConfig } from "../plugins/config-state.js"; +import type { PluginLogger } from "../plugins/types.js"; +import { activateExtensionHostRegistry } from "./activation.js"; +import { + buildExtensionHostRegistryCacheKey, + getCachedExtensionHostRegistry, +} from "./loader-cache.js"; + +export type ExtensionHostPluginLoadMode = "full" | "validate"; + +export type ExtensionHostLoaderPreflightOptions = { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + logger?: PluginLogger; + cache?: boolean; + mode?: ExtensionHostPluginLoadMode; +}; + +export function prepareExtensionHostLoaderPreflight(params: { + options: ExtensionHostLoaderPreflightOptions; + createDefaultLogger: () => PluginLogger; + clearPluginCommands: () => void; + applyTestDefaults?: typeof applyTestPluginDefaults; + normalizeConfig?: typeof normalizePluginsConfig; + buildCacheKey?: typeof buildExtensionHostRegistryCacheKey; + getCachedRegistry?: typeof getCachedExtensionHostRegistry; + activateRegistry?: typeof activateExtensionHostRegistry; +}) { + const applyTestDefaults = params.applyTestDefaults ?? applyTestPluginDefaults; + const normalizeConfig = params.normalizeConfig ?? normalizePluginsConfig; + const buildCacheKey = params.buildCacheKey ?? buildExtensionHostRegistryCacheKey; + const getCachedRegistry = params.getCachedRegistry ?? getCachedExtensionHostRegistry; + const activateRegistry = params.activateRegistry ?? activateExtensionHostRegistry; + + const env = params.options.env ?? process.env; + // Test env: default-disable plugins unless explicitly configured. + // This keeps unit/gateway suites fast and avoids loading heavyweight plugin deps by accident. + const config = applyTestDefaults(params.options.config ?? {}, env); + const logger = params.options.logger ?? params.createDefaultLogger(); + const validateOnly = params.options.mode === "validate"; + const normalizedConfig = normalizeConfig(config.plugins); + const cacheKey = buildCacheKey({ + workspaceDir: params.options.workspaceDir, + plugins: normalizedConfig, + installs: config.plugins?.installs, + env, + }); + const cacheEnabled = params.options.cache !== false; + + if (cacheEnabled) { + const cachedRegistry = getCachedRegistry(cacheKey); + if (cachedRegistry) { + activateRegistry(cachedRegistry, cacheKey); + return { + cacheHit: true as const, + registry: cachedRegistry, + }; + } + } + + // Clear previously registered plugin commands before reloading. + params.clearPluginCommands(); + + return { + cacheHit: false as const, + env, + config, + logger, + validateOnly, + normalizedConfig, + cacheKey, + }; +}