Plugins: add loader activation session

This commit is contained in:
Gustavo Madeira Santana 2026-03-15 14:46:18 +00:00
parent 5de733fa80
commit 5b26ce7252
No known key found for this signature in database
4 changed files with 302 additions and 32 deletions

View File

@ -40,6 +40,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 mutable activation state session | local variables in `src/plugins/loader.ts` and `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 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. |
| Loader final cache, warning, and activation finalization | `src/plugins/loader.ts` | `src/extension-host/loader-finalize.ts` | `partial` | Cache writes, untracked-extension warnings, final memory-slot warnings, readiness promotion, and registry activation now delegate through a host-owned loader-finalize helper; broader host lifecycle and policy semantics are still pending. |
| 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. |
@ -87,14 +88,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, cache control, initial candidate planning, entry-path import, policy, runtime decisions, post-import register flow, per-candidate orchestration, top-level load orchestration, explicit loader lifecycle transitions, and final cache plus activation finalization
- loader compatibility, cache control, initial candidate planning, entry-path import, policy, runtime decisions, post-import register flow, per-candidate orchestration, top-level load orchestration, session-owned activation state, explicit loader lifecycle transitions, and final cache plus activation finalization
## 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. Extend the new loader lifecycle state machine into broader activation-state and policy ownership in `src/extension-host/*`.
2. Extend the new loader lifecycle state machine and session-owned activation state into broader activation-state and policy ownership in `src/extension-host/*`.
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

@ -7,8 +7,6 @@ import {
getCachedExtensionHostRegistry,
setCachedExtensionHostRegistry,
} from "../extension-host/loader-cache.js";
import { finalizeExtensionHostRegistryLoad } from "../extension-host/loader-finalize.js";
import { processExtensionHostPluginCandidate } from "../extension-host/loader-flow.js";
import {
buildExtensionHostProvenanceIndex,
compareExtensionHostDuplicateCandidateOrder,
@ -21,15 +19,16 @@ 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 PluginRecord,
type PluginRegistry,
} from "../plugins/registry.js";
import { createPluginRegistry, type PluginRegistry } from "../plugins/registry.js";
import { createPluginRuntime, type CreatePluginRuntimeOptions } from "../plugins/runtime/index.js";
import type { PluginRuntime } from "../plugins/runtime/types.js";
import type { OpenClawPluginModule, PluginLogger } from "../plugins/types.js";
import { resolvePluginSdkAlias, resolvePluginSdkScopedAliasMap } from "./loader-compat.js";
import {
createExtensionHostLoaderSession,
finalizeExtensionHostLoaderSession,
processExtensionHostLoaderSessionCandidate,
} from "./loader-session.js";
export type ExtensionHostPluginLoadOptions = {
config?: OpenClawConfig;
@ -187,43 +186,34 @@ export function loadExtensionHostPluginRegistry(
});
});
const seenIds = new Map<string, PluginRecord["origin"]>();
const memorySlot = normalized.slots.memory;
let selectedMemoryPluginId: string | null = null;
let memorySlotMatched = false;
const session = createExtensionHostLoaderSession({
registry,
logger,
env,
provenance,
cacheEnabled,
cacheKey,
memorySlot: normalized.slots.memory,
setCachedRegistry: setCachedExtensionHostRegistry,
activateRegistry: activateExtensionHostRegistry,
});
for (const candidate of orderedCandidates) {
const manifestRecord = manifestByRoot.get(candidate.rootDir);
if (!manifestRecord) {
continue;
}
const processed = processExtensionHostPluginCandidate({
processExtensionHostLoaderSessionCandidate({
session,
candidate,
manifestRecord,
normalizedConfig: normalized,
rootConfig: cfg,
validateOnly,
logger,
registry,
seenIds,
selectedMemoryPluginId,
createApi,
loadModule: (safeSource) => getJiti()(safeSource) as OpenClawPluginModule,
});
selectedMemoryPluginId = processed.selectedMemoryPluginId;
memorySlotMatched ||= processed.memorySlotMatched;
}
return finalizeExtensionHostRegistryLoad({
registry,
memorySlot,
memorySlotMatched,
provenance,
logger,
env,
cacheEnabled,
cacheKey,
setCachedRegistry: setCachedExtensionHostRegistry,
activateRegistry: activateExtensionHostRegistry,
});
return finalizeExtensionHostLoaderSession(session);
}

View File

@ -0,0 +1,164 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { normalizePluginsConfig } from "../plugins/config-state.js";
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
import type { PluginRegistry } from "../plugins/registry.js";
import {
createExtensionHostLoaderSession,
finalizeExtensionHostLoaderSession,
processExtensionHostLoaderSessionCandidate,
} from "./loader-session.js";
const tempDirs: string[] = [];
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
});
function createTempPluginFixture() {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-loader-session-"));
tempDirs.push(rootDir);
const entryPath = path.join(rootDir, "index.js");
fs.writeFileSync(entryPath, "export default { id: 'demo', register() {} }");
return { rootDir, entryPath };
}
function createManifestRecord(rootDir: string, entryPath: string): PluginManifestRecord {
return {
id: "demo",
name: "Demo",
description: "Demo plugin",
version: "1.0.0",
kind: "memory",
channels: [],
providers: [],
skills: [],
origin: "bundled",
rootDir,
source: entryPath,
manifestPath: path.join(rootDir, "openclaw.plugin.json"),
schemaCacheKey: "demo-schema",
configSchema: {
type: "object",
properties: {},
},
resolvedExtension: {
id: "demo",
source: entryPath,
origin: "bundled",
rootDir,
static: {
package: {},
config: {},
setup: {},
},
runtime: {
kind: "memory",
contributions: [],
},
policy: {},
},
};
}
function createRegistry(): PluginRegistry {
return {
plugins: [],
tools: [],
hooks: [],
typedHooks: [],
channels: [],
providers: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],
services: [],
commands: [],
diagnostics: [],
};
}
describe("extension host loader session", () => {
it("owns mutable activation state for memory-slot selection", () => {
const { rootDir, entryPath } = createTempPluginFixture();
const session = createExtensionHostLoaderSession({
registry: createRegistry(),
logger: {
info: () => {},
warn: () => {},
error: () => {},
},
env: process.env,
provenance: {
loadPathMatcher: { exact: new Set(), dirs: [] },
installRules: new Map(),
},
cacheEnabled: false,
cacheKey: "cache-key",
memorySlot: "demo",
setCachedRegistry: () => {},
activateRegistry: () => {},
});
processExtensionHostLoaderSessionCandidate({
session,
candidate: {
source: entryPath,
rootDir,
packageDir: rootDir,
origin: "bundled",
},
manifestRecord: createManifestRecord(rootDir, entryPath),
normalizedConfig: normalizePluginsConfig({
slots: {
memory: "demo",
},
}),
rootConfig: {},
validateOnly: true,
createApi: () => ({}) as never,
loadModule: () =>
({
default: {
id: "demo",
register: () => {},
},
}) as never,
});
expect(session.selectedMemoryPluginId).toBe("demo");
expect(session.memorySlotMatched).toBe(true);
expect(session.registry.plugins[0]?.lifecycleState).toBe("validated");
});
it("finalizes the session through the shared finalizer", () => {
const session = createExtensionHostLoaderSession({
registry: createRegistry(),
logger: {
info: () => {},
warn: () => {},
error: () => {},
},
env: process.env,
provenance: {
loadPathMatcher: { exact: new Set(), dirs: [] },
installRules: new Map(),
},
cacheEnabled: false,
cacheKey: "cache-key",
setCachedRegistry: () => {},
activateRegistry: () => {},
});
const result = finalizeExtensionHostLoaderSession(session);
expect(result).toBe(session.registry);
});
});

View File

@ -0,0 +1,115 @@
import type { OpenClawConfig } from "../config/config.js";
import type { PluginCandidate } from "../plugins/discovery.js";
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
import type { PluginRecord, PluginRegistry } from "../plugins/registry.js";
import type { OpenClawPluginApi, OpenClawPluginModule, PluginLogger } from "../plugins/types.js";
import { finalizeExtensionHostRegistryLoad } from "./loader-finalize.js";
import { processExtensionHostPluginCandidate } from "./loader-flow.js";
import type { ExtensionHostProvenanceIndex } from "./loader-policy.js";
export type ExtensionHostLoaderSession = {
registry: PluginRegistry;
logger: PluginLogger;
env: NodeJS.ProcessEnv;
provenance: ExtensionHostProvenanceIndex;
cacheEnabled: boolean;
cacheKey: string;
memorySlot?: string | null;
seenIds: Map<string, PluginRecord["origin"]>;
selectedMemoryPluginId: string | null;
memorySlotMatched: boolean;
setCachedRegistry: (cacheKey: string, registry: PluginRegistry) => void;
activateRegistry: (registry: PluginRegistry, cacheKey: string) => void;
};
export function createExtensionHostLoaderSession(params: {
registry: PluginRegistry;
logger: PluginLogger;
env: NodeJS.ProcessEnv;
provenance: ExtensionHostProvenanceIndex;
cacheEnabled: boolean;
cacheKey: string;
memorySlot?: string | null;
setCachedRegistry: (cacheKey: string, registry: PluginRegistry) => void;
activateRegistry: (registry: PluginRegistry, cacheKey: string) => void;
}): ExtensionHostLoaderSession {
return {
registry: params.registry,
logger: params.logger,
env: params.env,
provenance: params.provenance,
cacheEnabled: params.cacheEnabled,
cacheKey: params.cacheKey,
memorySlot: params.memorySlot,
seenIds: new Map(),
selectedMemoryPluginId: null,
memorySlotMatched: false,
setCachedRegistry: params.setCachedRegistry,
activateRegistry: params.activateRegistry,
};
}
export function processExtensionHostLoaderSessionCandidate(params: {
session: ExtensionHostLoaderSession;
candidate: PluginCandidate;
manifestRecord: PluginManifestRecord;
normalizedConfig: {
entries: Record<
string,
{
enabled?: boolean;
hooks?: {
allowPromptInjection?: boolean;
};
config?: unknown;
}
>;
slots: {
memory?: string | null;
};
};
rootConfig: OpenClawConfig;
validateOnly: boolean;
createApi: (
record: PluginRecord,
options: {
config: OpenClawConfig;
pluginConfig?: Record<string, unknown>;
hookPolicy?: { allowPromptInjection?: boolean };
},
) => OpenClawPluginApi;
loadModule: (safeSource: string) => OpenClawPluginModule;
}): void {
const processed = processExtensionHostPluginCandidate({
candidate: params.candidate,
manifestRecord: params.manifestRecord,
normalizedConfig: params.normalizedConfig,
rootConfig: params.rootConfig,
validateOnly: params.validateOnly,
logger: params.session.logger,
registry: params.session.registry,
seenIds: params.session.seenIds,
selectedMemoryPluginId: params.session.selectedMemoryPluginId,
createApi: params.createApi,
loadModule: params.loadModule,
});
params.session.selectedMemoryPluginId = processed.selectedMemoryPluginId;
params.session.memorySlotMatched ||= processed.memorySlotMatched;
}
export function finalizeExtensionHostLoaderSession(
session: ExtensionHostLoaderSession,
): PluginRegistry {
return finalizeExtensionHostRegistryLoad({
registry: session.registry,
memorySlot: session.memorySlot,
memorySlotMatched: session.memorySlotMatched,
provenance: session.provenance,
logger: session.logger,
env: session.env,
cacheEnabled: session.cacheEnabled,
cacheKey: session.cacheKey,
setCachedRegistry: session.setCachedRegistry,
activateRegistry: session.activateRegistry,
});
}