From 49ae3b65a57e9fc41bb16afdf2b811b7ff06ee1d Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Mar 2026 15:09:01 +0000 Subject: [PATCH] Plugins: extract loader bootstrap --- src/extension-host/cutover-inventory.md | 1 + src/extension-host/loader-bootstrap.test.ts | 77 ++++++++++++++ src/extension-host/loader-bootstrap.ts | 107 ++++++++++++++++++++ src/extension-host/loader-orchestrator.ts | 59 +++-------- 4 files changed, 198 insertions(+), 46 deletions(-) create mode 100644 src/extension-host/loader-bootstrap.test.ts create mode 100644 src/extension-host/loader-bootstrap.ts diff --git a/src/extension-host/cutover-inventory.md b/src/extension-host/cutover-inventory.md index 4ae7d92b4dc..ec26f0680fb 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 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. | | Loader activation policy outcomes | open-coded in `src/extension-host/loader-flow.ts` | `src/extension-host/loader-activation-policy.ts` | `partial` | Duplicate precedence, config enablement, and early memory-slot gating now resolve through explicit host-owned activation-policy outcomes instead of remaining as inline loader decisions. | | Loader record-state transitions | `src/plugins/loader.ts` | `src/extension-host/loader-state.ts` | `partial` | The loader now enforces an explicit lifecycle transition model (`prepared -> imported -> validated -> registered -> ready`, plus terminal `disabled` and `error`) while still mapping back to compatibility `PluginRecord.status` values. | diff --git a/src/extension-host/loader-bootstrap.test.ts b/src/extension-host/loader-bootstrap.test.ts new file mode 100644 index 00000000000..a838b932640 --- /dev/null +++ b/src/extension-host/loader-bootstrap.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import { bootstrapExtensionHostPluginLoad } from "./loader-bootstrap.js"; + +describe("extension host loader bootstrap", () => { + it("pushes manifest diagnostics, logs discovery warnings, and orders candidates", () => { + const warnings: string[] = []; + const registry = createEmptyPluginRegistry(); + + const result = bootstrapExtensionHostPluginLoad({ + config: {}, + env: process.env, + cacheKey: "cache-key", + normalizedConfig: { + enabled: true, + allow: [], + loadPaths: [], + entries: {}, + slots: {}, + }, + warningCache: new Set(), + logger: { + info: () => {}, + warn: (message) => warnings.push(message), + error: () => {}, + }, + registry, + discoverPlugins: () => ({ + candidates: [ + { + idHint: "b", + source: "/plugins/b.ts", + rootDir: "/plugins/b", + origin: "workspace", + }, + { + idHint: "a", + source: "/plugins/a.ts", + rootDir: "/plugins/a", + origin: "workspace", + }, + ], + diagnostics: [], + }), + loadManifestRegistry: () => ({ + diagnostics: [{ level: "warn", message: "manifest warning" }], + plugins: [ + { + id: "a", + rootDir: "/plugins/a", + source: "/plugins/a.ts", + origin: "workspace", + } as never, + { + id: "b", + rootDir: "/plugins/b", + source: "/plugins/b.ts", + origin: "workspace", + } as never, + ], + }), + resolveDiscoveryPolicy: () => ({ + warningMessages: ["open allowlist warning"], + }), + buildProvenanceIndex: () => ({ + loadPathMatcher: { exact: new Set(), dirs: [] }, + installRules: new Map(), + }), + compareDuplicateCandidateOrder: ({ left, right }) => left.idHint.localeCompare(right.idHint), + }); + + expect(registry.diagnostics).toEqual([{ level: "warn", message: "manifest warning" }]); + expect(warnings).toEqual(["open allowlist warning"]); + expect(result.orderedCandidates.map((candidate) => candidate.idHint)).toEqual(["a", "b"]); + expect(result.manifestByRoot.get("/plugins/a")?.id).toBe("a"); + }); +}); diff --git a/src/extension-host/loader-bootstrap.ts b/src/extension-host/loader-bootstrap.ts new file mode 100644 index 00000000000..6009e427b1b --- /dev/null +++ b/src/extension-host/loader-bootstrap.ts @@ -0,0 +1,107 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { NormalizedPluginsConfig } from "../plugins/config-state.js"; +import { discoverOpenClawPlugins, type PluginCandidate } from "../plugins/discovery.js"; +import { + loadPluginManifestRegistry, + type PluginManifestRecord, + type PluginManifestRegistry, +} from "../plugins/manifest-registry.js"; +import type { PluginRegistry } from "../plugins/registry.js"; +import type { PluginLogger } from "../plugins/types.js"; +import { resolveExtensionHostDiscoveryPolicy } from "./loader-discovery-policy.js"; +import { + buildExtensionHostProvenanceIndex, + compareExtensionHostDuplicateCandidateOrder, + pushExtensionHostDiagnostics, +} from "./loader-policy.js"; +import type { ExtensionHostProvenanceIndex } from "./loader-provenance.js"; + +export function bootstrapExtensionHostPluginLoad(params: { + config: OpenClawConfig; + workspaceDir?: string; + env: NodeJS.ProcessEnv; + cache?: boolean; + cacheKey: string; + normalizedConfig: NormalizedPluginsConfig; + warningCache: Set; + logger: PluginLogger; + registry: PluginRegistry; + discoverPlugins?: typeof discoverOpenClawPlugins; + loadManifestRegistry?: typeof loadPluginManifestRegistry; + pushDiagnostics?: typeof pushExtensionHostDiagnostics; + resolveDiscoveryPolicy?: typeof resolveExtensionHostDiscoveryPolicy; + buildProvenanceIndex?: typeof buildExtensionHostProvenanceIndex; + compareDuplicateCandidateOrder?: typeof compareExtensionHostDuplicateCandidateOrder; +}): { + manifestByRoot: Map; + orderedCandidates: PluginCandidate[]; + provenance: ExtensionHostProvenanceIndex; + manifestRegistry: PluginManifestRegistry; +} { + const discoverPlugins = params.discoverPlugins ?? discoverOpenClawPlugins; + const loadManifestRegistry = params.loadManifestRegistry ?? loadPluginManifestRegistry; + const pushDiagnostics = params.pushDiagnostics ?? pushExtensionHostDiagnostics; + const resolveDiscoveryPolicy = + params.resolveDiscoveryPolicy ?? resolveExtensionHostDiscoveryPolicy; + const buildProvenanceIndex = params.buildProvenanceIndex ?? buildExtensionHostProvenanceIndex; + const compareDuplicateCandidateOrder = + params.compareDuplicateCandidateOrder ?? compareExtensionHostDuplicateCandidateOrder; + + const discovery = discoverPlugins({ + workspaceDir: params.workspaceDir, + extraPaths: params.normalizedConfig.loadPaths, + cache: params.cache, + env: params.env, + }); + const manifestRegistry = loadManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + cache: params.cache, + env: params.env, + candidates: discovery.candidates, + diagnostics: discovery.diagnostics, + }); + + pushDiagnostics(params.registry.diagnostics, manifestRegistry.diagnostics); + + const discoveryPolicy = resolveDiscoveryPolicy({ + pluginsEnabled: params.normalizedConfig.enabled, + allow: params.normalizedConfig.allow, + warningCacheKey: params.cacheKey, + warningCache: params.warningCache, + discoverablePlugins: manifestRegistry.plugins.map((plugin) => ({ + id: plugin.id, + source: plugin.source, + origin: plugin.origin, + })), + }); + for (const warning of discoveryPolicy.warningMessages) { + params.logger.warn(warning); + } + + const provenance = buildProvenanceIndex({ + config: params.config, + normalizedLoadPaths: params.normalizedConfig.loadPaths, + env: params.env, + }); + + const manifestByRoot = new Map( + manifestRegistry.plugins.map((record) => [record.rootDir, record]), + ); + const orderedCandidates = [...discovery.candidates].toSorted((left, right) => { + return compareDuplicateCandidateOrder({ + left, + right, + manifestByRoot, + provenance, + env: params.env, + }); + }); + + return { + manifestByRoot, + orderedCandidates, + provenance, + manifestRegistry, + }; +} diff --git a/src/extension-host/loader-orchestrator.ts b/src/extension-host/loader-orchestrator.ts index 46f0154e063..5dd80a6e512 100644 --- a/src/extension-host/loader-orchestrator.ts +++ b/src/extension-host/loader-orchestrator.ts @@ -15,11 +15,10 @@ 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 { discoverOpenClawPlugins } from "../plugins/discovery.js"; -import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { createPluginRegistry, type PluginRegistry } from "../plugins/registry.js"; import { createPluginRuntime, type CreatePluginRuntimeOptions } from "../plugins/runtime/index.js"; import type { PluginLogger } from "../plugins/types.js"; +import { bootstrapExtensionHostPluginLoad } from "./loader-bootstrap.js"; import { resolveExtensionHostDiscoveryPolicy } from "./loader-discovery-policy.js"; import { createExtensionHostModuleLoader } from "./loader-module-loader.js"; import { createExtensionHostLazyRuntime } from "./loader-runtime-proxy.js"; @@ -87,61 +86,29 @@ export function loadExtensionHostPluginRegistry( coreGatewayHandlers: options.coreGatewayHandlers as Record, }); - const discovery = discoverOpenClawPlugins({ - workspaceDir: options.workspaceDir, - extraPaths: normalized.loadPaths, - cache: options.cache, - env, - }); - const manifestRegistry = loadPluginManifestRegistry({ + const bootstrap = bootstrapExtensionHostPluginLoad({ config: cfg, workspaceDir: options.workspaceDir, - cache: options.cache, env, - candidates: discovery.candidates, - diagnostics: discovery.diagnostics, - }); - pushExtensionHostDiagnostics(registry.diagnostics, manifestRegistry.diagnostics); - const discoveryPolicy = resolveExtensionHostDiscoveryPolicy({ - pluginsEnabled: normalized.enabled, - allow: normalized.allow, warningCacheKey: cacheKey, warningCache: openAllowlistWarningCache, - discoverablePlugins: manifestRegistry.plugins.map((plugin) => ({ - id: plugin.id, - source: plugin.source, - origin: plugin.origin, - })), - }); - for (const warning of discoveryPolicy.warningMessages) { - logger.warn(warning); - } - const provenance = buildExtensionHostProvenanceIndex({ - config: cfg, - normalizedLoadPaths: normalized.loadPaths, - env, + cache: options.cache, + normalizedConfig: normalized, + logger, + registry, + pushDiagnostics: pushExtensionHostDiagnostics, + resolveDiscoveryPolicy: resolveExtensionHostDiscoveryPolicy, + buildProvenanceIndex: buildExtensionHostProvenanceIndex, + compareDuplicateCandidateOrder: compareExtensionHostDuplicateCandidateOrder, }); const loadModule = createExtensionHostModuleLoader(); - const manifestByRoot = new Map( - manifestRegistry.plugins.map((record) => [record.rootDir, record]), - ); - const orderedCandidates = [...discovery.candidates].toSorted((left, right) => { - return compareExtensionHostDuplicateCandidateOrder({ - left, - right, - manifestByRoot, - provenance, - env, - }); - }); - const session = createExtensionHostLoaderSession({ registry, logger, env, - provenance, + provenance: bootstrap.provenance, cacheEnabled, cacheKey, memorySlot: normalized.slots.memory, @@ -149,8 +116,8 @@ export function loadExtensionHostPluginRegistry( activateRegistry: activateExtensionHostRegistry, }); - for (const candidate of orderedCandidates) { - const manifestRecord = manifestByRoot.get(candidate.rootDir); + for (const candidate of bootstrap.orderedCandidates) { + const manifestRecord = bootstrap.manifestByRoot.get(candidate.rootDir); if (!manifestRecord) { continue; }