mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-17 05:20:47 +00:00
refactor(plugins): track activation compat hints
This commit is contained in:
@@ -9,10 +9,14 @@ vi.mock("./manifest-registry.js", () => ({
|
||||
}));
|
||||
|
||||
let resolveManifestActivationPluginIds: typeof import("./activation-planner.js").resolveManifestActivationPluginIds;
|
||||
let resolveManifestActivationPlan: typeof import("./activation-planner.js").resolveManifestActivationPlan;
|
||||
let PLUGIN_COMPAT_REASON: typeof import("./compat-reasons.js").PLUGIN_COMPAT_REASON;
|
||||
|
||||
describe("resolveManifestActivationPluginIds", () => {
|
||||
beforeAll(async () => {
|
||||
({ resolveManifestActivationPluginIds } = await import("./activation-planner.js"));
|
||||
({ resolveManifestActivationPluginIds, resolveManifestActivationPlan } =
|
||||
await import("./activation-planner.js"));
|
||||
({ PLUGIN_COMPAT_REASON } = await import("./compat-reasons.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -70,6 +74,20 @@ describe("resolveManifestActivationPluginIds", () => {
|
||||
},
|
||||
origin: "workspace",
|
||||
},
|
||||
{
|
||||
id: "legacy-activation-only",
|
||||
providers: [],
|
||||
activation: {
|
||||
onProviders: ["legacy-provider"],
|
||||
onChannels: ["legacy-channel"],
|
||||
onCapabilities: ["tool"],
|
||||
},
|
||||
channels: [],
|
||||
cliBackends: [],
|
||||
skills: [],
|
||||
hooks: [],
|
||||
origin: "workspace",
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
@@ -168,7 +186,7 @@ describe("resolveManifestActivationPluginIds", () => {
|
||||
capability: "tool",
|
||||
},
|
||||
}),
|
||||
).toEqual(["demo-channel"]);
|
||||
).toEqual(["demo-channel", "legacy-activation-only"]);
|
||||
|
||||
expect(
|
||||
resolveManifestActivationPluginIds({
|
||||
@@ -191,4 +209,96 @@ describe("resolveManifestActivationPluginIds", () => {
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports legacy activation field compat reasons without changing plugin-id resolution", () => {
|
||||
expect(
|
||||
resolveManifestActivationPlan({
|
||||
trigger: {
|
||||
kind: "command",
|
||||
command: "demo-tools",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
pluginIds: ["demo-channel"],
|
||||
entries: [
|
||||
{
|
||||
pluginId: "demo-channel",
|
||||
reasons: ["command:demo-tools"],
|
||||
compatReasons: [PLUGIN_COMPAT_REASON.legacyActivationField],
|
||||
},
|
||||
],
|
||||
compatReasons: {
|
||||
"demo-channel": [PLUGIN_COMPAT_REASON.legacyActivationField],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveManifestActivationPlan({
|
||||
trigger: {
|
||||
kind: "provider",
|
||||
provider: "legacy-provider",
|
||||
},
|
||||
}).compatReasons,
|
||||
).toEqual({
|
||||
"legacy-activation-only": [PLUGIN_COMPAT_REASON.legacyActivationField],
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveManifestActivationPluginIds({
|
||||
trigger: {
|
||||
kind: "provider",
|
||||
provider: "legacy-provider",
|
||||
},
|
||||
}),
|
||||
).toEqual(["legacy-activation-only"]);
|
||||
});
|
||||
|
||||
it("does not report compat reasons for stable ownership metadata", () => {
|
||||
expect(
|
||||
resolveManifestActivationPlan({
|
||||
trigger: {
|
||||
kind: "provider",
|
||||
provider: "openai",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
pluginIds: ["openai"],
|
||||
entries: [
|
||||
{
|
||||
pluginId: "openai",
|
||||
reasons: ["provider:openai"],
|
||||
compatReasons: [],
|
||||
},
|
||||
],
|
||||
compatReasons: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("reports legacy activation capability hints separately from stable capabilities", () => {
|
||||
expect(
|
||||
resolveManifestActivationPlan({
|
||||
trigger: {
|
||||
kind: "capability",
|
||||
capability: "tool",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
pluginIds: ["demo-channel", "legacy-activation-only"],
|
||||
entries: [
|
||||
{
|
||||
pluginId: "demo-channel",
|
||||
reasons: ["capability:tool"],
|
||||
compatReasons: [],
|
||||
},
|
||||
{
|
||||
pluginId: "legacy-activation-only",
|
||||
reasons: ["capability:tool"],
|
||||
compatReasons: [PLUGIN_COMPAT_REASON.legacyActivationField],
|
||||
},
|
||||
],
|
||||
compatReasons: {
|
||||
"legacy-activation-only": [PLUGIN_COMPAT_REASON.legacyActivationField],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { normalizeProviderId } from "../agents/provider-id.js";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
|
||||
import { PLUGIN_COMPAT_REASON, type PluginCompatReason } from "./compat-reasons.js";
|
||||
import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js";
|
||||
import type { PluginManifestActivationCapability } from "./manifest.js";
|
||||
import type { PluginOrigin } from "./plugin-origin.types.js";
|
||||
@@ -14,6 +15,18 @@ export type PluginActivationPlannerTrigger =
|
||||
| { kind: "route"; route: string }
|
||||
| { kind: "capability"; capability: PluginManifestActivationCapability };
|
||||
|
||||
export type PluginActivationPlanEntry = {
|
||||
pluginId: string;
|
||||
reasons: string[];
|
||||
compatReasons: PluginCompatReason[];
|
||||
};
|
||||
|
||||
export type PluginActivationPlan = {
|
||||
pluginIds: string[];
|
||||
entries: PluginActivationPlanEntry[];
|
||||
compatReasons: Record<string, PluginCompatReason[]>;
|
||||
};
|
||||
|
||||
export function resolveManifestActivationPluginIds(params: {
|
||||
trigger: PluginActivationPlannerTrigger;
|
||||
config?: OpenClawConfig;
|
||||
@@ -23,101 +36,218 @@ export function resolveManifestActivationPluginIds(params: {
|
||||
origin?: PluginOrigin;
|
||||
onlyPluginIds?: readonly string[];
|
||||
}): string[] {
|
||||
const onlyPluginIdSet = createPluginIdScopeSet(normalizePluginIdScope(params.onlyPluginIds));
|
||||
|
||||
return [
|
||||
...new Set(
|
||||
loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
cache: params.cache,
|
||||
})
|
||||
.plugins.filter(
|
||||
(plugin) =>
|
||||
(!params.origin || plugin.origin === params.origin) &&
|
||||
(!onlyPluginIdSet || onlyPluginIdSet.has(plugin.id)) &&
|
||||
matchesManifestActivationTrigger(plugin, params.trigger),
|
||||
)
|
||||
.map((plugin) => plugin.id),
|
||||
),
|
||||
].toSorted((left, right) => left.localeCompare(right));
|
||||
return resolveManifestActivationPlan(params).pluginIds;
|
||||
}
|
||||
|
||||
function matchesManifestActivationTrigger(
|
||||
export function resolveManifestActivationPlan(params: {
|
||||
trigger: PluginActivationPlannerTrigger;
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
cache?: boolean;
|
||||
origin?: PluginOrigin;
|
||||
onlyPluginIds?: readonly string[];
|
||||
}): PluginActivationPlan {
|
||||
const onlyPluginIdSet = createPluginIdScopeSet(normalizePluginIdScope(params.onlyPluginIds));
|
||||
|
||||
const entries = loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
cache: params.cache,
|
||||
})
|
||||
.plugins.flatMap((plugin): PluginActivationPlanEntry[] => {
|
||||
if (
|
||||
(params.origin && plugin.origin !== params.origin) ||
|
||||
(onlyPluginIdSet && !onlyPluginIdSet.has(plugin.id))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const match = matchManifestActivationTrigger(plugin, params.trigger);
|
||||
if (!match) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
pluginId: plugin.id,
|
||||
reasons: [match.reason],
|
||||
compatReasons: match.compatReason ? [match.compatReason] : [],
|
||||
},
|
||||
];
|
||||
})
|
||||
.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId));
|
||||
|
||||
return {
|
||||
pluginIds: entries.map((entry) => entry.pluginId),
|
||||
entries,
|
||||
compatReasons: Object.fromEntries(
|
||||
entries.flatMap((entry) =>
|
||||
entry.compatReasons.length > 0 ? [[entry.pluginId, entry.compatReasons]] : [],
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
type ManifestActivationMatch = {
|
||||
reason: string;
|
||||
compatReason?: PluginCompatReason;
|
||||
};
|
||||
|
||||
function matchManifestActivationTrigger(
|
||||
plugin: PluginManifestRecord,
|
||||
trigger: PluginActivationPlannerTrigger,
|
||||
): boolean {
|
||||
): ManifestActivationMatch | null {
|
||||
switch (trigger.kind) {
|
||||
case "command":
|
||||
return listActivationCommandIds(plugin).includes(normalizeCommandId(trigger.command));
|
||||
return matchActivationCommand(plugin, trigger.command);
|
||||
case "provider":
|
||||
return listActivationProviderIds(plugin).includes(normalizeProviderId(trigger.provider));
|
||||
return matchActivationProvider(plugin, trigger.provider);
|
||||
case "agentHarness":
|
||||
return listActivationAgentHarnessIds(plugin).includes(normalizeCommandId(trigger.runtime));
|
||||
return matchLegacyActivationList(
|
||||
listActivationAgentHarnessIds(plugin),
|
||||
normalizeCommandId(trigger.runtime),
|
||||
"agent-harness",
|
||||
);
|
||||
case "channel":
|
||||
return listActivationChannelIds(plugin).includes(normalizeCommandId(trigger.channel));
|
||||
return matchActivationChannel(plugin, trigger.channel);
|
||||
case "route":
|
||||
return listActivationRouteIds(plugin).includes(normalizeCommandId(trigger.route));
|
||||
return matchLegacyActivationList(
|
||||
listActivationRouteIds(plugin),
|
||||
normalizeCommandId(trigger.route),
|
||||
"route",
|
||||
);
|
||||
case "capability":
|
||||
return hasActivationCapability(plugin, trigger.capability);
|
||||
return matchActivationCapability(plugin, trigger.capability);
|
||||
}
|
||||
const unreachableTrigger: never = trigger;
|
||||
return unreachableTrigger;
|
||||
}
|
||||
|
||||
function matchLegacyActivationList(
|
||||
ids: readonly string[],
|
||||
normalizedId: string,
|
||||
reasonPrefix: string,
|
||||
): ManifestActivationMatch | null {
|
||||
if (!normalizedId || !ids.includes(normalizedId)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
reason: `${reasonPrefix}:${normalizedId}`,
|
||||
compatReason: PLUGIN_COMPAT_REASON.legacyActivationField,
|
||||
};
|
||||
}
|
||||
|
||||
function listActivationAgentHarnessIds(plugin: PluginManifestRecord): string[] {
|
||||
return [...(plugin.activation?.onAgentHarnesses ?? [])].map(normalizeCommandId).filter(Boolean);
|
||||
}
|
||||
|
||||
function listActivationCommandIds(plugin: PluginManifestRecord): string[] {
|
||||
return [
|
||||
...(plugin.activation?.onCommands ?? []),
|
||||
...(plugin.commandAliases ?? []).flatMap((alias) => alias.cliCommand ?? alias.name),
|
||||
]
|
||||
function matchActivationCommand(
|
||||
plugin: PluginManifestRecord,
|
||||
command: string,
|
||||
): ManifestActivationMatch | null {
|
||||
const normalizedCommand = normalizeCommandId(command);
|
||||
if (!normalizedCommand) {
|
||||
return null;
|
||||
}
|
||||
const commandAliases = (plugin.commandAliases ?? [])
|
||||
.flatMap((alias) => alias.cliCommand ?? alias.name)
|
||||
.map(normalizeCommandId)
|
||||
.filter(Boolean);
|
||||
if (commandAliases.includes(normalizedCommand)) {
|
||||
return { reason: `command:${normalizedCommand}` };
|
||||
}
|
||||
return matchLegacyActivationList(
|
||||
(plugin.activation?.onCommands ?? []).map(normalizeCommandId).filter(Boolean),
|
||||
normalizedCommand,
|
||||
"command",
|
||||
);
|
||||
}
|
||||
|
||||
function listActivationProviderIds(plugin: PluginManifestRecord): string[] {
|
||||
return [
|
||||
...(plugin.activation?.onProviders ?? []),
|
||||
function matchActivationProvider(
|
||||
plugin: PluginManifestRecord,
|
||||
provider: string,
|
||||
): ManifestActivationMatch | null {
|
||||
const normalizedProvider = normalizeProviderId(provider);
|
||||
if (!normalizedProvider) {
|
||||
return null;
|
||||
}
|
||||
const stableProviderIds = [
|
||||
...plugin.providers,
|
||||
...(plugin.setup?.providers?.map((provider) => provider.id) ?? []),
|
||||
...(plugin.setup?.providers?.map((entry) => entry.id) ?? []),
|
||||
]
|
||||
.map((value) => normalizeProviderId(value))
|
||||
.filter(Boolean);
|
||||
if (stableProviderIds.includes(normalizedProvider)) {
|
||||
return { reason: `provider:${normalizedProvider}` };
|
||||
}
|
||||
return matchLegacyActivationList(
|
||||
(plugin.activation?.onProviders ?? [])
|
||||
.map((value) => normalizeProviderId(value))
|
||||
.filter(Boolean),
|
||||
normalizedProvider,
|
||||
"provider",
|
||||
);
|
||||
}
|
||||
|
||||
function listActivationChannelIds(plugin: PluginManifestRecord): string[] {
|
||||
return [...(plugin.activation?.onChannels ?? []), ...plugin.channels]
|
||||
.map(normalizeCommandId)
|
||||
.filter(Boolean);
|
||||
function matchActivationChannel(
|
||||
plugin: PluginManifestRecord,
|
||||
channel: string,
|
||||
): ManifestActivationMatch | null {
|
||||
const normalizedChannel = normalizeCommandId(channel);
|
||||
if (!normalizedChannel) {
|
||||
return null;
|
||||
}
|
||||
const stableChannelIds = plugin.channels.map(normalizeCommandId).filter(Boolean);
|
||||
if (stableChannelIds.includes(normalizedChannel)) {
|
||||
return { reason: `channel:${normalizedChannel}` };
|
||||
}
|
||||
return matchLegacyActivationList(
|
||||
(plugin.activation?.onChannels ?? []).map(normalizeCommandId).filter(Boolean),
|
||||
normalizedChannel,
|
||||
"channel",
|
||||
);
|
||||
}
|
||||
|
||||
function listActivationRouteIds(plugin: PluginManifestRecord): string[] {
|
||||
return (plugin.activation?.onRoutes ?? []).map(normalizeCommandId).filter(Boolean);
|
||||
}
|
||||
|
||||
function hasActivationCapability(
|
||||
function matchActivationCapability(
|
||||
plugin: PluginManifestRecord,
|
||||
capability: PluginManifestActivationCapability,
|
||||
): boolean {
|
||||
if (plugin.activation?.onCapabilities?.includes(capability)) {
|
||||
return true;
|
||||
}
|
||||
): ManifestActivationMatch | null {
|
||||
switch (capability) {
|
||||
case "provider":
|
||||
return listActivationProviderIds(plugin).length > 0;
|
||||
case "provider": {
|
||||
const hasProviderOwnership =
|
||||
plugin.providers.length > 0 || (plugin.setup?.providers?.length ?? 0) > 0;
|
||||
if (hasProviderOwnership) {
|
||||
return { reason: "capability:provider" };
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "channel":
|
||||
return listActivationChannelIds(plugin).length > 0;
|
||||
if (plugin.channels.length > 0) {
|
||||
return { reason: "capability:channel" };
|
||||
}
|
||||
break;
|
||||
case "tool":
|
||||
return (plugin.contracts?.tools?.length ?? 0) > 0;
|
||||
if ((plugin.contracts?.tools?.length ?? 0) > 0) {
|
||||
return { reason: "capability:tool" };
|
||||
}
|
||||
break;
|
||||
case "hook":
|
||||
return plugin.hooks.length > 0;
|
||||
if (plugin.hooks.length > 0) {
|
||||
return { reason: "capability:hook" };
|
||||
}
|
||||
break;
|
||||
}
|
||||
const unreachableCapability: never = capability;
|
||||
return unreachableCapability;
|
||||
if (plugin.activation?.onCapabilities?.includes(capability)) {
|
||||
return {
|
||||
reason: `capability:${capability}`,
|
||||
compatReason: PLUGIN_COMPAT_REASON.legacyActivationField,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeCommandId(value: string | undefined): string {
|
||||
|
||||
10
src/plugins/compat-reasons.ts
Normal file
10
src/plugins/compat-reasons.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const PLUGIN_COMPAT_REASON = {
|
||||
legacyActivationField: "legacy-activation-field",
|
||||
legacySetupApi: "legacy-setup-api",
|
||||
legacyRootSdkImport: "legacy-root-sdk-import",
|
||||
legacyGlobalRegistry: "legacy-global-registry",
|
||||
legacyManifestOwnerFallback: "legacy-manifest-owner-fallback",
|
||||
legacyHookStage: "legacy-hook-stage",
|
||||
} as const;
|
||||
|
||||
export type PluginCompatReason = (typeof PLUGIN_COMPAT_REASON)[keyof typeof PLUGIN_COMPAT_REASON];
|
||||
@@ -57,19 +57,19 @@ export type PluginManifestActivationCapability = "provider" | "channel" | "tool"
|
||||
|
||||
export type PluginManifestActivation = {
|
||||
/**
|
||||
* Provider ids that should activate this plugin when explicitly requested.
|
||||
* This is metadata only; runtime loading still happens through the loader.
|
||||
* Legacy provider activation hints. Prefer top-level `providers` and setup
|
||||
* provider ownership for new manifests.
|
||||
*/
|
||||
onProviders?: string[];
|
||||
/** Agent harness runtime ids that should activate this plugin. */
|
||||
/** Legacy agent harness runtime activation hints. */
|
||||
onAgentHarnesses?: string[];
|
||||
/** Command ids that should activate this plugin. */
|
||||
/** Legacy command activation hints. Prefer command aliases or CLI descriptors. */
|
||||
onCommands?: string[];
|
||||
/** Channel ids that should activate this plugin. */
|
||||
/** Legacy channel activation hints. Prefer top-level `channels`. */
|
||||
onChannels?: string[];
|
||||
/** Route kinds that should activate this plugin. */
|
||||
/** Legacy route activation hints. */
|
||||
onRoutes?: string[];
|
||||
/** Cheap capability hints used by future activation planning. */
|
||||
/** Legacy broad capability hints. Do not add new uses. */
|
||||
onCapabilities?: PluginManifestActivationCapability[];
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user