mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:10:44 +00:00
fix(plugins): cache discovery registration snapshots
Co-authored-by: junpei.o <14040213+livingghost@users.noreply.github.com> Co-authored-by: Yoshiaki Okuyama <okuyam2y@gmail.com> Co-authored-by: Shion Eria <shioneria@foxmail.com> Co-authored-by: Billy Shih <1472300+bbshih@users.noreply.github.com>
This commit is contained in:
@@ -11,7 +11,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Agents/failover: stop body-less HTTP 400/422 proxy failures from defaulting to `"format"` classification, so embedded retries surface the opaque provider failure instead of falling into a compaction loop. Fixes #66462. (#67024) Thanks @altaywtf and @HongzhuLiu.
|
||||
|
||||
- Plugins/loader: use cached discovery-mode snapshot loads for read-only plugin capability lookups, keep snapshot caches isolated from active Gateway registries, and make same-plugin channel/HTTP route re-registration idempotent so repeated snapshot or hot-reload paths no longer rerun full plugin side effects or accumulate duplicate surfaces. Fixes #51781, #52031, #54181, and #57514. Thanks @livingghost, @okuyam2y, @ShionEria, and @bbshih.
|
||||
## 2026.4.24
|
||||
|
||||
### Breaking
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
b4fb88ca434fb92a38bb068cc0b1863b1f22bcde2ce21499c3077ea7e8460775 plugin-sdk-api-baseline.json
|
||||
0f373c8820c0cd17b13dddf520dd286d9dec85234eb0a7f94dac07432572ede7 plugin-sdk-api-baseline.jsonl
|
||||
eb5c790aaa54be7b1380eb5a162db50dd314e052aedb5e608290092c33d999f2 plugin-sdk-api-baseline.json
|
||||
0d2fd80f69e0c3488b6bdbbbb035b08ab108637790d1f30b8e4f84c71c5bc8e2 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -366,6 +366,21 @@ activation. The loader still falls back to `activate(api)` for older plugins,
|
||||
but bundled plugins and new external plugins should treat `register` as the
|
||||
public contract.
|
||||
|
||||
`api.registrationMode` tells a plugin why its entry is being loaded:
|
||||
|
||||
| Mode | Meaning |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `full` | Runtime activation. Register tools, hooks, services, commands, routes, and other live side effects. |
|
||||
| `discovery` | Read-only capability discovery. Register providers and metadata, but skip expensive live side effects. |
|
||||
| `setup-only` | Channel setup metadata loading through a lightweight setup entry. |
|
||||
| `setup-runtime` | Channel setup loading that also needs the runtime entry. |
|
||||
| `cli-metadata` | CLI command metadata collection only. |
|
||||
|
||||
Plugin entries that open sockets, databases, background workers, or long-lived
|
||||
clients should guard those side effects with `api.registrationMode === "full"`.
|
||||
Discovery loads are cached separately from activating loads and do not replace
|
||||
the running Gateway registry.
|
||||
|
||||
Common registration methods:
|
||||
|
||||
| Method | What it registers |
|
||||
|
||||
@@ -3415,13 +3415,107 @@ module.exports = { id: "throws-after-import", register() {} };`,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when activate:false is used without cache:false", () => {
|
||||
expect(() => loadOpenClawPlugins({ activate: false })).toThrow(
|
||||
"activate:false requires cache:false",
|
||||
);
|
||||
expect(() => loadOpenClawPlugins({ activate: false, cache: true })).toThrow(
|
||||
"activate:false requires cache:false",
|
||||
);
|
||||
it("uses discovery registration mode for non-activating loads", () => {
|
||||
useNoBundledPlugins();
|
||||
const marker = "__openclawDiscoveryModeTest";
|
||||
const plugin = writePlugin({
|
||||
id: "discovery-mode-test",
|
||||
filename: "discovery-mode-test.cjs",
|
||||
body: `module.exports = {
|
||||
id: "discovery-mode-test",
|
||||
register(api) {
|
||||
globalThis.${marker} = globalThis.${marker} || [];
|
||||
globalThis.${marker}.push(api.registrationMode);
|
||||
api.registerProvider({ id: "discovery-provider", label: "Discovery Provider", auth: [] });
|
||||
api.registerTool({
|
||||
name: "discovery_tool",
|
||||
description: "Discovery tool",
|
||||
parameters: {},
|
||||
execute: async () => ({ content: [{ type: "text", text: "ok" }] }),
|
||||
});
|
||||
},
|
||||
};`,
|
||||
});
|
||||
const config = {
|
||||
plugins: {
|
||||
load: { paths: [plugin.file] },
|
||||
allow: ["discovery-mode-test"],
|
||||
},
|
||||
};
|
||||
|
||||
const snapshot = loadOpenClawPlugins({
|
||||
activate: false,
|
||||
cache: false,
|
||||
workspaceDir: plugin.dir,
|
||||
config,
|
||||
});
|
||||
expect((globalThis as Record<string, unknown>)[marker]).toEqual(["discovery"]);
|
||||
expect(snapshot.providers.map((entry) => entry.provider.id)).toEqual(["discovery-provider"]);
|
||||
expect(snapshot.tools.flatMap((entry) => entry.names)).toContain("discovery_tool");
|
||||
|
||||
loadOpenClawPlugins({
|
||||
cache: false,
|
||||
workspaceDir: plugin.dir,
|
||||
config,
|
||||
});
|
||||
expect((globalThis as Record<string, unknown>)[marker]).toEqual(["discovery", "full"]);
|
||||
delete (globalThis as Record<string, unknown>)[marker];
|
||||
});
|
||||
|
||||
it("caches non-activating snapshots without restoring global side effects", () => {
|
||||
useNoBundledPlugins();
|
||||
clearPluginCommands();
|
||||
const marker = "__openclawSnapshotCacheRegisterCount";
|
||||
const plugin = writePlugin({
|
||||
id: "snapshot-cache",
|
||||
filename: "snapshot-cache.cjs",
|
||||
body: `module.exports = {
|
||||
id: "snapshot-cache",
|
||||
register(api) {
|
||||
globalThis.${marker} = (globalThis.${marker} || 0) + 1;
|
||||
api.registerCommand({
|
||||
name: "snapshot-command",
|
||||
description: "Snapshot command",
|
||||
handler: async () => ({ text: "ok" }),
|
||||
});
|
||||
},
|
||||
};`,
|
||||
});
|
||||
const options = {
|
||||
activate: false,
|
||||
workspaceDir: plugin.dir,
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [plugin.file] },
|
||||
allow: ["snapshot-cache"],
|
||||
},
|
||||
},
|
||||
onlyPluginIds: ["snapshot-cache"],
|
||||
};
|
||||
|
||||
const first = loadOpenClawPlugins(options);
|
||||
const second = loadOpenClawPlugins(options);
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect((globalThis as Record<string, unknown>)[marker]).toBe(1);
|
||||
expect(first.commands.map((entry) => entry.command.name)).toEqual(["snapshot-command"]);
|
||||
expect(getPluginCommandSpecs()).toEqual([]);
|
||||
|
||||
const active = loadOpenClawPlugins({
|
||||
workspaceDir: plugin.dir,
|
||||
config: options.config,
|
||||
onlyPluginIds: ["snapshot-cache"],
|
||||
});
|
||||
expect(active).not.toBe(first);
|
||||
expect((globalThis as Record<string, unknown>)[marker]).toBe(2);
|
||||
expect(getPluginCommandSpecs()).toEqual([
|
||||
{
|
||||
name: "snapshot-command",
|
||||
description: "Snapshot command",
|
||||
acceptsArgs: false,
|
||||
},
|
||||
]);
|
||||
delete (globalThis as Record<string, unknown>)[marker];
|
||||
});
|
||||
|
||||
it("re-initializes global hook runner when serving registry from cache", () => {
|
||||
@@ -4061,7 +4155,7 @@ module.exports = { id: "throws-after-import", register() {} };`,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "rejects duplicate channel ids during plugin registration",
|
||||
label: "updates duplicate channel ids during same-plugin registration",
|
||||
pluginId: "channel-dup",
|
||||
body: `module.exports = { id: "channel-dup", register(api) {
|
||||
api.registerChannel({
|
||||
@@ -4103,11 +4197,9 @@ module.exports = { id: "throws-after-import", register() {} };`,
|
||||
} };`,
|
||||
assert: (registry: ReturnType<typeof loadOpenClawPlugins>) => {
|
||||
expect(registry.channels.filter((entry) => entry.plugin.id === "demo")).toHaveLength(1);
|
||||
expectRegistryErrorDiagnostic({
|
||||
registry,
|
||||
pluginId: "channel-dup",
|
||||
message: "channel already registered: demo (channel-dup)",
|
||||
});
|
||||
expect(
|
||||
registry.channels.find((entry) => entry.plugin.id === "demo")?.plugin.meta?.label,
|
||||
).toBe("Demo Duplicate");
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -4417,14 +4509,14 @@ module.exports = { id: "throws-after-import", register() {} };`,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "same plugin can replace its own route",
|
||||
label: "same plugin can implicitly replace its own route",
|
||||
buildPlugins: () => [
|
||||
writePlugin({
|
||||
id: "http-route-replace-self",
|
||||
filename: "http-route-replace-self.cjs",
|
||||
body: `module.exports = { id: "http-route-replace-self", register(api) {
|
||||
api.registerHttpRoute({ path: "/demo", auth: "plugin", handler: async () => false });
|
||||
api.registerHttpRoute({ path: "/demo", auth: "plugin", replaceExisting: true, handler: async () => true });
|
||||
api.registerHttpRoute({ path: "/demo", auth: "plugin", handler: async () => true });
|
||||
} };`,
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -66,7 +66,7 @@ import {
|
||||
restorePluginInteractiveHandlers,
|
||||
} from "./interactive-registry.js";
|
||||
import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js";
|
||||
import { loadPluginManifestRegistry } from "./manifest-registry.js";
|
||||
import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js";
|
||||
import type { PluginBundleFormat, PluginDiagnostic, PluginFormat } from "./manifest-types.js";
|
||||
import type { PluginManifestContracts } from "./manifest.js";
|
||||
import {
|
||||
@@ -124,6 +124,7 @@ import type {
|
||||
OpenClawPluginDefinition,
|
||||
OpenClawPluginModule,
|
||||
PluginLogger,
|
||||
PluginRegistrationMode,
|
||||
} from "./types.js";
|
||||
|
||||
export type PluginLoadResult = PluginRegistry;
|
||||
@@ -808,6 +809,7 @@ function buildCacheKey(params: {
|
||||
runtimeSubagentMode?: "default" | "explicit" | "gateway-bindable";
|
||||
pluginSdkResolution?: PluginSdkResolutionPreference;
|
||||
coreGatewayMethodNames?: string[];
|
||||
activate?: boolean;
|
||||
}): string {
|
||||
const { roots, loadPaths } = resolvePluginCacheInputs({
|
||||
workspaceDir: params.workspaceDir,
|
||||
@@ -845,12 +847,13 @@ function buildCacheKey(params: {
|
||||
params.installBundledRuntimeDeps === false ? "skip-runtime-deps" : "install-runtime-deps";
|
||||
const runtimeSubagentMode = params.runtimeSubagentMode ?? "default";
|
||||
const gatewayMethodsKey = JSON.stringify(params.coreGatewayMethodNames ?? []);
|
||||
const activationMode = params.activate === false ? "snapshot" : "active";
|
||||
return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({
|
||||
...params.plugins,
|
||||
installs,
|
||||
loadPaths,
|
||||
activationMetadataKey: params.activationMetadataKey ?? "",
|
||||
})}::${scopeKey}::${setupOnlyKey}::${setupOnlyModeKey}::${setupOnlyRequirementKey}::${startupChannelMode}::${moduleLoadMode}::${bundledRuntimeDepsMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`;
|
||||
})}::${scopeKey}::${setupOnlyKey}::${setupOnlyModeKey}::${setupOnlyRequirementKey}::${startupChannelMode}::${moduleLoadMode}::${bundledRuntimeDepsMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}::${activationMode}`;
|
||||
}
|
||||
|
||||
function matchesScopedPluginRequest(params: {
|
||||
@@ -933,6 +936,87 @@ function hasExplicitCompatibilityInputs(options: PluginLoadOptions): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
type PluginRegistrationPlan = {
|
||||
/** Public compatibility label passed to plugin register(api). */
|
||||
mode: PluginRegistrationMode;
|
||||
/** Load a setup entry instead of the normal runtime entry. */
|
||||
loadSetupEntry: boolean;
|
||||
/** Setup flow also needs the runtime channel entry for runtime setters/plugin shape. */
|
||||
loadSetupRuntimeEntry: boolean;
|
||||
/** Apply runtime capability policy such as memory-slot selection. */
|
||||
runRuntimeCapabilityPolicy: boolean;
|
||||
/** Register metadata that only belongs to live activation, not discovery snapshots. */
|
||||
runFullActivationOnlyRegistrations: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert loader intent into explicit behavior flags.
|
||||
*
|
||||
* Registration modes are plugin-facing labels; this plan is the internal source
|
||||
* of truth for which entrypoint to load and which activation-only policies run.
|
||||
*/
|
||||
function resolvePluginRegistrationPlan(params: {
|
||||
canLoadScopedSetupOnlyChannelPlugin: boolean;
|
||||
scopedSetupOnlyChannelPluginRequested: boolean;
|
||||
requireSetupEntryForSetupOnlyChannelPlugins: boolean;
|
||||
enableStateEnabled: boolean;
|
||||
shouldLoadModules: boolean;
|
||||
validateOnly: boolean;
|
||||
shouldActivate: boolean;
|
||||
manifestRecord: PluginManifestRecord;
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
preferSetupRuntimeForChannelPlugins: boolean;
|
||||
}): PluginRegistrationPlan | null {
|
||||
if (params.canLoadScopedSetupOnlyChannelPlugin) {
|
||||
return {
|
||||
mode: "setup-only",
|
||||
loadSetupEntry: true,
|
||||
loadSetupRuntimeEntry: false,
|
||||
runRuntimeCapabilityPolicy: false,
|
||||
runFullActivationOnlyRegistrations: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
params.scopedSetupOnlyChannelPluginRequested &&
|
||||
params.requireSetupEntryForSetupOnlyChannelPlugins
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (!params.enableStateEnabled) {
|
||||
return null;
|
||||
}
|
||||
const loadSetupRuntimeEntry =
|
||||
params.shouldLoadModules &&
|
||||
!params.validateOnly &&
|
||||
shouldLoadChannelPluginInSetupRuntime({
|
||||
manifestChannels: params.manifestRecord.channels,
|
||||
setupSource: params.manifestRecord.setupSource,
|
||||
startupDeferConfiguredChannelFullLoadUntilAfterListen:
|
||||
params.manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen,
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins,
|
||||
});
|
||||
if (loadSetupRuntimeEntry) {
|
||||
return {
|
||||
mode: "setup-runtime",
|
||||
loadSetupEntry: true,
|
||||
loadSetupRuntimeEntry: true,
|
||||
runRuntimeCapabilityPolicy: false,
|
||||
runFullActivationOnlyRegistrations: false,
|
||||
};
|
||||
}
|
||||
const mode = params.shouldActivate ? "full" : "discovery";
|
||||
return {
|
||||
mode,
|
||||
loadSetupEntry: false,
|
||||
loadSetupRuntimeEntry: false,
|
||||
runRuntimeCapabilityPolicy: true,
|
||||
runFullActivationOnlyRegistrations: mode === "full",
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
|
||||
const env = options.env ?? process.env;
|
||||
const cfg = applyTestPluginDefaults(options.config ?? {}, env);
|
||||
@@ -976,6 +1060,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
|
||||
runtimeSubagentMode,
|
||||
pluginSdkResolution: options.pluginSdkResolution,
|
||||
coreGatewayMethodNames,
|
||||
activate: options.activate,
|
||||
});
|
||||
return {
|
||||
env,
|
||||
@@ -1053,6 +1138,15 @@ function getCompatibleActivePluginRegistry(
|
||||
if (loadContext.cacheKey === activeCacheKey) {
|
||||
return activeRegistry;
|
||||
}
|
||||
if (!loadContext.shouldActivate) {
|
||||
const activatingCacheKey = resolvePluginLoadCacheContext({
|
||||
...options,
|
||||
activate: true,
|
||||
}).cacheKey;
|
||||
if (activatingCacheKey === activeCacheKey) {
|
||||
return activeRegistry;
|
||||
}
|
||||
}
|
||||
if (
|
||||
loadContext.runtimeSubagentMode === "default" &&
|
||||
getActivePluginRuntimeSubagentMode() === "gateway-bindable"
|
||||
@@ -1067,6 +1161,19 @@ function getCompatibleActivePluginRegistry(
|
||||
if (gatewayBindableCacheKey === activeCacheKey) {
|
||||
return activeRegistry;
|
||||
}
|
||||
if (!loadContext.shouldActivate) {
|
||||
const activatingGatewayBindableCacheKey = resolvePluginLoadCacheContext({
|
||||
...options,
|
||||
activate: true,
|
||||
runtimeOptions: {
|
||||
...options.runtimeOptions,
|
||||
allowGatewaySubagentBinding: true,
|
||||
},
|
||||
}).cacheKey;
|
||||
if (activatingGatewayBindableCacheKey === activeCacheKey) {
|
||||
return activeRegistry;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -1851,13 +1958,6 @@ function activatePluginRegistry(
|
||||
}
|
||||
|
||||
export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegistry {
|
||||
// Snapshot (non-activating) loads must disable the cache to avoid storing a registry
|
||||
// whose commands were never globally registered.
|
||||
if (options.activate === false && options.cache !== false) {
|
||||
throw new Error(
|
||||
"loadOpenClawPlugins: activate:false requires cache:false to prevent command registry divergence",
|
||||
);
|
||||
}
|
||||
const {
|
||||
env,
|
||||
cfg,
|
||||
@@ -1882,21 +1982,21 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
if (cacheEnabled) {
|
||||
const cached = getCachedPluginRegistry(cacheKey);
|
||||
if (cached) {
|
||||
restoreRegisteredAgentHarnesses(cached.agentHarnesses);
|
||||
restorePluginCommands(cached.commands ?? []);
|
||||
restoreRegisteredCompactionProviders(cached.compactionProviders);
|
||||
restoreDetachedTaskLifecycleRuntimeRegistration(cached.detachedTaskRuntimeRegistration);
|
||||
restorePluginInteractiveHandlers(cached.interactiveHandlers ?? []);
|
||||
restoreRegisteredMemoryEmbeddingProviders(cached.memoryEmbeddingProviders);
|
||||
restoreMemoryPluginState({
|
||||
capability: cached.memoryCapability,
|
||||
corpusSupplements: cached.memoryCorpusSupplements,
|
||||
promptBuilder: cached.memoryPromptBuilder,
|
||||
promptSupplements: cached.memoryPromptSupplements,
|
||||
flushPlanResolver: cached.memoryFlushPlanResolver,
|
||||
runtime: cached.memoryRuntime,
|
||||
});
|
||||
if (shouldActivate) {
|
||||
restoreRegisteredAgentHarnesses(cached.agentHarnesses);
|
||||
restorePluginCommands(cached.commands ?? []);
|
||||
restoreRegisteredCompactionProviders(cached.compactionProviders);
|
||||
restoreDetachedTaskLifecycleRuntimeRegistration(cached.detachedTaskRuntimeRegistration);
|
||||
restorePluginInteractiveHandlers(cached.interactiveHandlers ?? []);
|
||||
restoreRegisteredMemoryEmbeddingProviders(cached.memoryEmbeddingProviders);
|
||||
restoreMemoryPluginState({
|
||||
capability: cached.memoryCapability,
|
||||
corpusSupplements: cached.memoryCorpusSupplements,
|
||||
promptBuilder: cached.memoryPromptBuilder,
|
||||
promptSupplements: cached.memoryPromptSupplements,
|
||||
flushPlanResolver: cached.memoryFlushPlanResolver,
|
||||
runtime: cached.memoryRuntime,
|
||||
});
|
||||
activatePluginRegistry(
|
||||
cached.registry,
|
||||
cacheKey,
|
||||
@@ -2178,33 +2278,27 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
const scopedSetupOnlyChannelPluginRequested =
|
||||
includeSetupOnlyChannelPlugins &&
|
||||
!validateOnly &&
|
||||
onlyPluginIdSet &&
|
||||
Boolean(onlyPluginIdSet) &&
|
||||
manifestRecord.channels.length > 0 &&
|
||||
(!enableState.enabled || forceSetupOnlyChannelPlugins);
|
||||
const canLoadScopedSetupOnlyChannelPlugin =
|
||||
scopedSetupOnlyChannelPluginRequested &&
|
||||
(!requireSetupEntryForSetupOnlyChannelPlugins || Boolean(manifestRecord.setupSource));
|
||||
const registrationMode = canLoadScopedSetupOnlyChannelPlugin
|
||||
? "setup-only"
|
||||
: scopedSetupOnlyChannelPluginRequested && requireSetupEntryForSetupOnlyChannelPlugins
|
||||
? null
|
||||
: enableState.enabled
|
||||
? shouldLoadModules &&
|
||||
!validateOnly &&
|
||||
shouldLoadChannelPluginInSetupRuntime({
|
||||
manifestChannels: manifestRecord.channels,
|
||||
setupSource: manifestRecord.setupSource,
|
||||
startupDeferConfiguredChannelFullLoadUntilAfterListen:
|
||||
manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen,
|
||||
cfg,
|
||||
env,
|
||||
preferSetupRuntimeForChannelPlugins,
|
||||
})
|
||||
? "setup-runtime"
|
||||
: "full"
|
||||
: null;
|
||||
const registrationPlan = resolvePluginRegistrationPlan({
|
||||
canLoadScopedSetupOnlyChannelPlugin,
|
||||
scopedSetupOnlyChannelPluginRequested,
|
||||
requireSetupEntryForSetupOnlyChannelPlugins,
|
||||
enableStateEnabled: enableState.enabled,
|
||||
shouldLoadModules,
|
||||
validateOnly,
|
||||
shouldActivate,
|
||||
manifestRecord,
|
||||
cfg,
|
||||
env,
|
||||
preferSetupRuntimeForChannelPlugins,
|
||||
});
|
||||
|
||||
if (!registrationMode) {
|
||||
if (!registrationPlan) {
|
||||
record.status = "disabled";
|
||||
record.error = enableState.reason;
|
||||
markPluginActivationDisabled(record, enableState.reason);
|
||||
@@ -2212,6 +2306,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
continue;
|
||||
}
|
||||
const registrationMode = registrationPlan.mode;
|
||||
if (!enableState.enabled) {
|
||||
record.status = "disabled";
|
||||
record.error = enableState.reason;
|
||||
@@ -2340,7 +2435,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
// Exception: the dreaming engine (memory-core by default) must load alongside the
|
||||
// selected memory slot plugin so dreaming can run even when lancedb holds the slot.
|
||||
if (
|
||||
registrationMode === "full" &&
|
||||
registrationPlan.runRuntimeCapabilityPolicy &&
|
||||
candidate.origin === "bundled" &&
|
||||
hasKind(manifestRecord.kind, "memory")
|
||||
) {
|
||||
@@ -2368,7 +2463,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!shouldLoadModules && registrationMode === "full") {
|
||||
if (!shouldLoadModules && registrationPlan.runRuntimeCapabilityPolicy) {
|
||||
const memoryDecision = resolveMemorySlotDecision({
|
||||
id: record.id,
|
||||
kind: record.kind,
|
||||
@@ -2414,8 +2509,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
}
|
||||
|
||||
const loadSource =
|
||||
(registrationMode === "setup-only" || registrationMode === "setup-runtime") &&
|
||||
runtimeSetupSource
|
||||
registrationPlan.loadSetupEntry && runtimeSetupSource
|
||||
? runtimeSetupSource
|
||||
: runtimeCandidateSource;
|
||||
const moduleLoadSource = resolveCanonicalDistRuntimeSource(loadSource);
|
||||
@@ -2461,10 +2555,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
(registrationMode === "setup-only" || registrationMode === "setup-runtime") &&
|
||||
manifestRecord.setupSource
|
||||
) {
|
||||
if (registrationPlan.loadSetupEntry && manifestRecord.setupSource) {
|
||||
const setupRegistration = resolveSetupChannelRegistration(mod, {
|
||||
installRuntimeDeps:
|
||||
shouldInstallBundledRuntimeDeps &&
|
||||
@@ -2507,7 +2598,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
let mergedSetupRegistration = setupRegistration;
|
||||
let runtimeSetterApplied = false;
|
||||
if (
|
||||
registrationMode === "setup-runtime" &&
|
||||
registrationPlan.loadSetupRuntimeEntry &&
|
||||
setupRegistration.usesBundledSetupContract &&
|
||||
runtimeCandidateSource !== safeSource
|
||||
) {
|
||||
@@ -2685,7 +2776,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
memorySlotMatched = true;
|
||||
}
|
||||
|
||||
if (registrationMode === "full") {
|
||||
if (registrationPlan.runRuntimeCapabilityPolicy) {
|
||||
if (pluginId !== dreamingEngineId) {
|
||||
const memoryDecision = resolveMemorySlotDecision({
|
||||
id: record.id,
|
||||
@@ -2711,7 +2802,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
}
|
||||
}
|
||||
|
||||
if (registrationMode === "full") {
|
||||
if (registrationPlan.runFullActivationOnlyRegistrations) {
|
||||
if (definition?.reload) {
|
||||
registerReload(record, definition.reload);
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ function resolveRuntimeProviderPluginLoadState(
|
||||
{
|
||||
onlyPluginIds: providerPluginIds,
|
||||
pluginSdkResolution: params.pluginSdkResolution,
|
||||
cache: params.cache ?? false,
|
||||
cache: params.cache ?? true,
|
||||
activate: params.activate ?? false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -164,7 +164,7 @@ function expectLastRuntimeRegistryLoad(params?: {
|
||||
}) {
|
||||
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cache: false,
|
||||
cache: true,
|
||||
activate: false,
|
||||
...(params?.env ? { env: params.env } : {}),
|
||||
...(params?.onlyPluginIds !== undefined ? { onlyPluginIds: params.onlyPluginIds } : {}),
|
||||
@@ -401,7 +401,7 @@ describe("resolvePluginProviders", () => {
|
||||
expect.objectContaining({
|
||||
workspaceDir: "/workspace/explicit",
|
||||
env,
|
||||
cache: false,
|
||||
cache: true,
|
||||
activate: false,
|
||||
}),
|
||||
);
|
||||
@@ -764,7 +764,7 @@ describe("resolvePluginProviders", () => {
|
||||
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceDir: "/workspace/runtime",
|
||||
cache: false,
|
||||
cache: true,
|
||||
activate: false,
|
||||
}),
|
||||
);
|
||||
@@ -790,7 +790,7 @@ describe("resolvePluginProviders", () => {
|
||||
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceDir: "/workspace/runtime",
|
||||
cache: false,
|
||||
cache: true,
|
||||
activate: false,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -196,6 +196,27 @@ const activePluginHookRegistrations = resolveGlobalSingleton<
|
||||
type HookRegistration = { event: string; handler: Parameters<typeof registerInternalHook>[1] };
|
||||
type HookRollbackEntry = { name: string; previousRegistrations: HookRegistration[] };
|
||||
|
||||
type PluginRegistrationCapabilities = {
|
||||
/** Broad registry writes that discovery and live activation both need. */
|
||||
capabilityHandlers: boolean;
|
||||
/** Runtime channel registration is suppressed for setup-only metadata loads. */
|
||||
runtimeChannel: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keep mode decoding centralized. PluginRegistrationMode is the public label;
|
||||
* registry code should consume these booleans instead of duplicating string
|
||||
* checks across individual registration handlers.
|
||||
*/
|
||||
function resolvePluginRegistrationCapabilities(
|
||||
mode: PluginRegistrationMode,
|
||||
): PluginRegistrationCapabilities {
|
||||
return {
|
||||
capabilityHandlers: mode === "full" || mode === "discovery",
|
||||
runtimeChannel: mode !== "setup-only",
|
||||
};
|
||||
}
|
||||
|
||||
export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
const coreGatewayMethods = new Set(Object.keys(registryParams.coreGatewayHandlers ?? {}));
|
||||
@@ -621,7 +642,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
if (!params.replaceExisting) {
|
||||
if (!params.replaceExisting && existing.pluginId !== record.id) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
@@ -671,6 +692,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
registration: OpenClawPluginChannelRegistration | ChannelPlugin,
|
||||
mode: PluginRegistrationMode = "full",
|
||||
) => {
|
||||
const registrationCapabilities = resolvePluginRegistrationCapabilities(mode);
|
||||
const normalized =
|
||||
typeof (registration as OpenClawPluginChannelRegistration).plugin === "object"
|
||||
? (registration as OpenClawPluginChannelRegistration)
|
||||
@@ -686,7 +708,22 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
}
|
||||
const id = plugin.id;
|
||||
const existingRuntime = registry.channels.find((entry) => entry.plugin.id === id);
|
||||
if (mode !== "setup-only" && existingRuntime) {
|
||||
if (registrationCapabilities.runtimeChannel && existingRuntime) {
|
||||
if (existingRuntime.pluginId === record.id) {
|
||||
existingRuntime.plugin = plugin;
|
||||
existingRuntime.pluginName = record.name;
|
||||
existingRuntime.source = record.source;
|
||||
existingRuntime.rootDir = record.rootDir;
|
||||
const existingSetup = registry.channelSetups.find((entry) => entry.plugin.id === id);
|
||||
if (existingSetup) {
|
||||
existingSetup.plugin = plugin;
|
||||
existingSetup.pluginName = record.name;
|
||||
existingSetup.source = record.source;
|
||||
existingSetup.enabled = record.enabled;
|
||||
existingSetup.rootDir = record.rootDir;
|
||||
}
|
||||
return;
|
||||
}
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
@@ -697,6 +734,14 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
}
|
||||
const existingSetup = registry.channelSetups.find((entry) => entry.plugin.id === id);
|
||||
if (existingSetup) {
|
||||
if (existingSetup.pluginId === record.id) {
|
||||
existingSetup.plugin = plugin;
|
||||
existingSetup.pluginName = record.name;
|
||||
existingSetup.source = record.source;
|
||||
existingSetup.enabled = record.enabled;
|
||||
existingSetup.rootDir = record.rootDir;
|
||||
return;
|
||||
}
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
@@ -714,7 +759,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
enabled: record.enabled,
|
||||
rootDir: record.rootDir,
|
||||
});
|
||||
if (mode === "setup-only") {
|
||||
if (!registrationCapabilities.runtimeChannel) {
|
||||
return;
|
||||
}
|
||||
registry.channels.push({
|
||||
@@ -1412,6 +1457,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
},
|
||||
): OpenClawPluginApi => {
|
||||
const registrationMode = params.registrationMode ?? "full";
|
||||
const registrationCapabilities = resolvePluginRegistrationCapabilities(registrationMode);
|
||||
return buildPluginApi({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
@@ -1426,7 +1472,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
logger: normalizeLogger(registryParams.logger),
|
||||
resolvePath: (input: string) => resolveUserPath(input),
|
||||
handlers: {
|
||||
...(registrationMode === "full"
|
||||
...(registrationCapabilities.capabilityHandlers
|
||||
? {
|
||||
registerTool: (tool, opts) => registerTool(record, tool, opts),
|
||||
registerHook: (events, handler, opts) =>
|
||||
|
||||
@@ -2000,7 +2000,25 @@ export type OpenClawPluginDefinition = {
|
||||
|
||||
export type OpenClawPluginModule = OpenClawPluginDefinition | ((api: OpenClawPluginApi) => void);
|
||||
|
||||
export type PluginRegistrationMode = "full" | "setup-only" | "setup-runtime" | "cli-metadata";
|
||||
/**
|
||||
* Public label exposed to plugin `register(api)` calls.
|
||||
*
|
||||
* Keep this as a compatibility signal for plugin authors. Loader internals
|
||||
* should derive explicit capability booleans from the mode instead of branching
|
||||
* on raw strings throughout the code path.
|
||||
*
|
||||
* - `full`: live runtime activation; long-lived side effects may start.
|
||||
* - `discovery`: read-only capability discovery; skip sockets/workers/clients.
|
||||
* - `setup-only`: lightweight channel setup entry only.
|
||||
* - `setup-runtime`: setup flow that also needs the runtime channel entry.
|
||||
* - `cli-metadata`: CLI command metadata collection.
|
||||
*/
|
||||
export type PluginRegistrationMode =
|
||||
| "full"
|
||||
| "discovery"
|
||||
| "setup-only"
|
||||
| "setup-runtime"
|
||||
| "cli-metadata";
|
||||
|
||||
export type PluginConfigMigration = (config: OpenClawConfig) =>
|
||||
| {
|
||||
|
||||
Reference in New Issue
Block a user