Plugins: extract loader bootstrap

This commit is contained in:
Gustavo Madeira Santana 2026-03-15 15:09:01 +00:00
parent 1817c6fcf6
commit 49ae3b65a5
No known key found for this signature in database
4 changed files with 198 additions and 46 deletions

View File

@ -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. |

View File

@ -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<string>(),
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");
});
});

View File

@ -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<string>;
logger: PluginLogger;
registry: PluginRegistry;
discoverPlugins?: typeof discoverOpenClawPlugins;
loadManifestRegistry?: typeof loadPluginManifestRegistry;
pushDiagnostics?: typeof pushExtensionHostDiagnostics;
resolveDiscoveryPolicy?: typeof resolveExtensionHostDiscoveryPolicy;
buildProvenanceIndex?: typeof buildExtensionHostProvenanceIndex;
compareDuplicateCandidateOrder?: typeof compareExtensionHostDuplicateCandidateOrder;
}): {
manifestByRoot: Map<string, PluginManifestRecord>;
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,
};
}

View File

@ -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<string, GatewayRequestHandler>,
});
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;
}