Plugins: extract loader register flow

This commit is contained in:
Gustavo Madeira Santana 2026-03-15 11:34:19 +00:00
parent d8af1eceaf
commit 3a122c95fa
No known key found for this signature in database
4 changed files with 374 additions and 59 deletions

View File

@ -35,6 +35,7 @@ This is an implementation checklist, not a future-design spec.
| 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 module-export, config-validation, and memory-slot decisions | `src/plugins/loader.ts` | `src/extension-host/loader-runtime.ts` | `partial` | Module export resolution, export-metadata application, config validation, and early or final memory-slot decisions now delegate through host-owned loader-runtime helpers. |
| 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 record-state transitions | `src/plugins/loader.ts` | `src/extension-host/loader-state.ts` | `partial` | Disabled, error, and appended plugin-record state transitions now delegate through host-owned loader-state helpers; a real lifecycle state machine still does not exist. |
| Channel lookup | `src/channels/plugins/index.ts`, `src/channels/plugins/registry-loader.ts`, `src/channels/registry.ts` | extension-host-backed registries plus kernel channel contracts | `partial` | Readers now consume the host-owned active registry, but writes still originate from plugin registration. |
| Dock lookup | `src/channels/dock.ts` | host-owned static descriptors | `partial` | Runtime lookup now uses the host boundary; dock ownership itself has not moved yet. |
@ -81,14 +82,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, policy, runtime decisions, and record-state transitions
- loader compatibility, initial candidate planning, policy, runtime decisions, post-import register flow, and record-state transitions
## 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. Move the remaining loader orchestration into `src/extension-host/*`, especially per-plugin import and registration flow, enablement completion, and lifecycle-state transitions.
2. Move the remaining loader orchestration into `src/extension-host/*`, especially entry-path opening and import flow, enablement completion, and lifecycle-state transitions.
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,143 @@
import { describe, expect, it } from "vitest";
import type { PluginDiagnostic } from "../plugins/types.js";
import { createExtensionHostPluginRecord } from "./loader-policy.js";
import {
planExtensionHostLoadedPlugin,
runExtensionHostPluginRegister,
} from "./loader-register.js";
describe("extension host loader register", () => {
it("returns a register plan for valid loaded plugins", () => {
const record = createExtensionHostPluginRecord({
id: "demo",
source: "/plugins/demo.js",
origin: "workspace",
enabled: true,
configSchema: true,
});
const diagnostics: PluginDiagnostic[] = [];
const plan = planExtensionHostLoadedPlugin({
record,
manifestRecord: {
configSchema: {
type: "object",
properties: {
enabled: { type: "boolean" },
},
additionalProperties: false,
},
},
definition: {
id: "demo",
},
register: () => {},
diagnostics,
selectedMemoryPluginId: null,
entryConfig: { enabled: true },
validateOnly: false,
});
expect(plan).toMatchObject({
kind: "register",
pluginConfig: { enabled: true },
selectedMemoryPluginId: null,
});
});
it("returns invalid-config plans with the normalized message", () => {
const record = createExtensionHostPluginRecord({
id: "demo",
source: "/plugins/demo.js",
origin: "workspace",
enabled: true,
configSchema: true,
});
const plan = planExtensionHostLoadedPlugin({
record,
manifestRecord: {
configSchema: {
type: "object",
properties: {
enabled: { type: "boolean" },
},
additionalProperties: false,
},
},
diagnostics: [],
selectedMemoryPluginId: null,
entryConfig: { nope: true },
validateOnly: false,
});
expect(plan.kind).toBe("invalid-config");
expect(plan.message).toContain("invalid config:");
});
it("returns missing-register plans when validation passes but no register function exists", () => {
const record = createExtensionHostPluginRecord({
id: "demo",
source: "/plugins/demo.js",
origin: "workspace",
enabled: true,
configSchema: true,
});
expect(
planExtensionHostLoadedPlugin({
record,
manifestRecord: {
configSchema: {
type: "object",
},
},
diagnostics: [],
selectedMemoryPluginId: null,
validateOnly: false,
}),
).toMatchObject({
kind: "missing-register",
message: "plugin export missing register/activate",
});
});
it("runs register through the provided api factory and records async warnings", () => {
const record = createExtensionHostPluginRecord({
id: "demo",
source: "/plugins/demo.js",
origin: "workspace",
enabled: true,
configSchema: true,
});
const diagnostics: PluginDiagnostic[] = [];
let apiSeen = false;
const result = runExtensionHostPluginRegister({
register: async (api) => {
apiSeen = api.id === "demo";
},
createApi: (pluginRecord, options) =>
({
id: pluginRecord.id,
name: pluginRecord.name,
source: pluginRecord.source,
config: options.config,
pluginConfig: options.pluginConfig,
}) as never,
record,
config: {},
pluginConfig: { enabled: true },
diagnostics,
});
expect(result).toEqual({ ok: true });
expect(apiSeen).toBe(true);
expect(diagnostics).toContainEqual({
level: "warn",
pluginId: "demo",
source: "/plugins/demo.js",
message: "plugin register returned a promise; async registration is ignored",
});
});
});

View File

@ -0,0 +1,186 @@
import type { OpenClawConfig } from "../config/config.js";
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
import type { PluginRecord } from "../plugins/registry.js";
import type {
OpenClawPluginApi,
OpenClawPluginDefinition,
OpenClawPluginHookOptions,
PluginDiagnostic,
} from "../plugins/types.js";
import {
applyExtensionHostDefinitionToRecord,
resolveExtensionHostMemoryDecision,
validateExtensionHostConfig,
} from "./loader-runtime.js";
export type ExtensionHostLoadedPluginPlan =
| {
kind: "disabled";
reason?: string;
memorySlotMatched: boolean;
selectedMemoryPluginId: string | null;
}
| {
kind: "invalid-config";
message: string;
errors: string[];
memorySlotMatched: boolean;
selectedMemoryPluginId: string | null;
}
| {
kind: "validate-only";
memorySlotMatched: boolean;
selectedMemoryPluginId: string | null;
}
| {
kind: "missing-register";
message: string;
memorySlotMatched: boolean;
selectedMemoryPluginId: string | null;
}
| {
kind: "register";
register: NonNullable<OpenClawPluginDefinition["register"]>;
pluginConfig?: Record<string, unknown>;
memorySlotMatched: boolean;
selectedMemoryPluginId: string | null;
}
| {
kind: "error";
message: string;
memorySlotMatched: boolean;
selectedMemoryPluginId: string | null;
};
export function planExtensionHostLoadedPlugin(params: {
record: PluginRecord;
manifestRecord: Pick<PluginManifestRecord, "configSchema" | "schemaCacheKey">;
definition?: OpenClawPluginDefinition;
register?: OpenClawPluginDefinition["register"];
diagnostics: PluginDiagnostic[];
memorySlot?: string | null;
selectedMemoryPluginId: string | null;
entryConfig?: unknown;
validateOnly: boolean;
}): ExtensionHostLoadedPluginPlan {
const definitionResult = applyExtensionHostDefinitionToRecord({
record: params.record,
definition: params.definition,
diagnostics: params.diagnostics,
});
const memorySlotMatched =
params.record.kind === "memory" && params.memorySlot === params.record.id;
if (!definitionResult.ok) {
return {
kind: "error",
message: definitionResult.message,
memorySlotMatched,
selectedMemoryPluginId: params.selectedMemoryPluginId,
};
}
const memoryDecision = resolveExtensionHostMemoryDecision({
recordId: params.record.id,
recordKind: params.record.kind,
memorySlot: params.memorySlot,
selectedMemoryPluginId: params.selectedMemoryPluginId,
});
const nextSelectedMemoryPluginId =
memoryDecision.selected && params.record.kind === "memory"
? params.record.id
: params.selectedMemoryPluginId;
if (!memoryDecision.enabled) {
return {
kind: "disabled",
reason: memoryDecision.reason,
memorySlotMatched,
selectedMemoryPluginId: nextSelectedMemoryPluginId,
};
}
const validatedConfig = validateExtensionHostConfig({
schema: params.manifestRecord.configSchema,
cacheKey: params.manifestRecord.schemaCacheKey,
value: params.entryConfig,
});
if (!validatedConfig.ok) {
return {
kind: "invalid-config",
message: `invalid config: ${validatedConfig.errors.join(", ")}`,
errors: validatedConfig.errors,
memorySlotMatched,
selectedMemoryPluginId: nextSelectedMemoryPluginId,
};
}
if (params.validateOnly) {
return {
kind: "validate-only",
memorySlotMatched,
selectedMemoryPluginId: nextSelectedMemoryPluginId,
};
}
if (typeof params.register !== "function") {
return {
kind: "missing-register",
message: "plugin export missing register/activate",
memorySlotMatched,
selectedMemoryPluginId: nextSelectedMemoryPluginId,
};
}
return {
kind: "register",
register: params.register,
pluginConfig: validatedConfig.value,
memorySlotMatched,
selectedMemoryPluginId: nextSelectedMemoryPluginId,
};
}
export function runExtensionHostPluginRegister(params: {
register: NonNullable<OpenClawPluginDefinition["register"]>;
createApi: (
record: PluginRecord,
options: {
config: OpenClawConfig;
pluginConfig?: Record<string, unknown>;
hookPolicy?: OpenClawPluginHookOptions;
},
) => OpenClawPluginApi;
record: PluginRecord;
config: OpenClawConfig;
pluginConfig?: Record<string, unknown>;
hookPolicy?: OpenClawPluginHookOptions;
diagnostics: PluginDiagnostic[];
}):
| {
ok: true;
}
| {
ok: false;
error: unknown;
} {
try {
const result = params.register(
params.createApi(params.record, {
config: params.config,
pluginConfig: params.pluginConfig,
hookPolicy: params.hookPolicy,
}),
);
if (result && typeof result === "object" && "then" in result) {
params.diagnostics.push({
level: "warn",
pluginId: params.record.id,
source: params.record.source,
message: "plugin register returned a promise; async registration is ignored",
});
}
return { ok: true };
} catch (error) {
return { ok: false, error };
}
}

View File

@ -22,11 +22,12 @@ import {
} from "../extension-host/loader-policy.js";
import { prepareExtensionHostPluginCandidate } from "../extension-host/loader-records.js";
import {
applyExtensionHostDefinitionToRecord,
planExtensionHostLoadedPlugin,
runExtensionHostPluginRegister,
} from "../extension-host/loader-register.js";
import {
resolveExtensionHostEarlyMemoryDecision,
resolveExtensionHostMemoryDecision,
resolveExtensionHostModuleExport,
validateExtensionHostConfig,
} from "../extension-host/loader-runtime.js";
import {
appendExtensionHostPluginRecord,
@ -394,29 +395,29 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
const definition = resolved.definition;
const register = resolved.register;
const definitionResult = applyExtensionHostDefinitionToRecord({
const loadedPlan = planExtensionHostLoadedPlugin({
record,
manifestRecord,
definition,
register,
diagnostics: registry.diagnostics,
});
if (!definitionResult.ok) {
pushPluginLoadError(definitionResult.message);
continue;
}
if (record.kind === "memory" && memorySlot === record.id) {
memorySlotMatched = true;
}
const memoryDecision = resolveExtensionHostMemoryDecision({
recordId: record.id,
recordKind: record.kind,
memorySlot,
selectedMemoryPluginId,
entryConfig: entry?.config,
validateOnly,
});
if (loadedPlan.memorySlotMatched) {
memorySlotMatched = true;
}
selectedMemoryPluginId = loadedPlan.selectedMemoryPluginId;
if (!memoryDecision.enabled) {
setExtensionHostPluginRecordDisabled(record, memoryDecision.reason);
if (loadedPlan.kind === "error") {
pushPluginLoadError(loadedPlan.message);
continue;
}
if (loadedPlan.kind === "disabled") {
setExtensionHostPluginRecordDisabled(record, loadedPlan.reason);
appendExtensionHostPluginRecord({
registry,
record,
@ -427,23 +428,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
continue;
}
if (memoryDecision.selected && record.kind === "memory") {
selectedMemoryPluginId = record.id;
}
const validatedConfig = validateExtensionHostConfig({
schema: manifestRecord.configSchema,
cacheKey: manifestRecord.schemaCacheKey,
value: entry?.config,
});
if (!validatedConfig.ok) {
logger.error(`[plugins] ${record.id} invalid config: ${validatedConfig.errors?.join(", ")}`);
pushPluginLoadError(`invalid config: ${validatedConfig.errors?.join(", ")}`);
if (loadedPlan.kind === "invalid-config") {
logger.error(`[plugins] ${record.id} ${loadedPlan.message}`);
pushPluginLoadError(loadedPlan.message);
continue;
}
if (validateOnly) {
if (loadedPlan.kind === "validate-only") {
appendExtensionHostPluginRecord({
registry,
record,
@ -454,36 +445,22 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
continue;
}
if (typeof register !== "function") {
if (loadedPlan.kind === "missing-register") {
logger.error(`[plugins] ${record.id} missing register/activate export`);
pushPluginLoadError("plugin export missing register/activate");
pushPluginLoadError(loadedPlan.message);
continue;
}
const api = createApi(record, {
const registerResult = runExtensionHostPluginRegister({
register: loadedPlan.register,
createApi,
record,
config: cfg,
pluginConfig: validatedConfig.value,
pluginConfig: loadedPlan.pluginConfig,
hookPolicy: entry?.hooks,
diagnostics: registry.diagnostics,
});
try {
const result = register(api);
if (result && typeof result.then === "function") {
registry.diagnostics.push({
level: "warn",
pluginId: record.id,
source: record.source,
message: "plugin register returned a promise; async registration is ignored",
});
}
appendExtensionHostPluginRecord({
registry,
record,
seenIds,
pluginId,
origin: candidate.origin,
});
} catch (err) {
if (!registerResult.ok) {
recordExtensionHostPluginError({
logger,
registry,
@ -491,11 +468,19 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
seenIds,
pluginId,
origin: candidate.origin,
error: err,
error: registerResult.error,
logPrefix: `[plugins] ${record.id} failed during register from ${record.source}: `,
diagnosticMessagePrefix: "plugin failed during register: ",
});
continue;
}
appendExtensionHostPluginRecord({
registry,
record,
seenIds,
pluginId,
origin: candidate.origin,
});
}
if (typeof memorySlot === "string" && !memorySlotMatched) {