mirror of https://github.com/openclaw/openclaw.git
Plugins: extract loader register flow
This commit is contained in:
parent
d8af1eceaf
commit
3a122c95fa
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue