refactor: unify plugin startup metadata planning

This commit is contained in:
Peter Steinberger
2026-05-02 06:35:59 +01:00
parent cd398a543d
commit fecac7e40a
10 changed files with 190 additions and 123 deletions

View File

@@ -43,11 +43,21 @@ vi.mock("../channels/config-presence.js", () => ({
hasMeaningfulChannelConfig,
}));
vi.mock("./manifest-registry-installed.js", () => ({
loadPluginManifestRegistryForInstalledIndex,
}));
vi.mock("./manifest-registry-installed.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./manifest-registry-installed.js")>();
return {
...actual,
loadPluginManifestRegistryForInstalledIndex,
};
});
vi.mock("./plugin-registry-snapshot.js", () => ({ loadPluginRegistrySnapshot }));
vi.mock("./plugin-registry-snapshot.js", () => ({
loadPluginRegistrySnapshot,
loadPluginRegistrySnapshotWithMetadata: (params: unknown) => ({
snapshot: loadPluginRegistrySnapshot(params),
diagnostics: [],
}),
}));
vi.mock("./plugin-registry-contributions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./plugin-registry-contributions.js")>();
@@ -62,6 +72,7 @@ import {
listConfiguredAnnounceChannelIdsForConfig,
listConfiguredChannelIdsForReadOnlyScope,
listExplicitConfiguredChannelIdsForConfig,
loadGatewayStartupPluginPlan,
resolveConfiguredChannelPresencePolicy,
resolveConfiguredDeferredChannelPluginIds,
resolveConfiguredChannelPluginIds,
@@ -854,6 +865,31 @@ describe("resolveGatewayStartupPluginIds", () => {
).toEqual([]);
});
it("loads channel, deferred, and startup plugin ids from one manifest registry", () => {
const registry = createManifestRegistryFixture();
const index = createInstalledPluginIndexFixture(registry);
loadPluginRegistrySnapshot.mockReset().mockReturnValue(index);
loadPluginManifestRegistryForInstalledIndex.mockReset().mockReturnValue(registry);
const plan = loadGatewayStartupPluginPlan({
config: {
channels: {
"demo-channel": {
token: "configured",
},
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {},
});
expect(plan.channelPluginIds).toContain("demo-channel");
expect(plan.pluginIds).toContain("demo-channel");
expect(plan.configuredDeferredChannelPluginIds).toEqual([]);
expect(loadPluginRegistrySnapshot).toHaveBeenCalledOnce();
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce();
});
it("does not treat explicitly disabled stale channel config as deferred startup intent", () => {
useManifestRegistryFixture(createManifestRegistryFixtureWithWorkspaceDemoChannel());

View File

@@ -17,6 +17,9 @@ export {
resolveChannelPluginIdsFromRegistry,
resolveConfiguredDeferredChannelPluginIds,
resolveConfiguredDeferredChannelPluginIdsFromRegistry,
loadGatewayStartupPluginPlan,
resolveGatewayStartupPluginIds,
resolveGatewayStartupPluginPlanFromRegistry,
resolveGatewayStartupPluginIdsFromRegistry,
type GatewayStartupPluginPlan,
} from "./gateway-startup-plugin-ids.js";

View File

@@ -11,7 +11,7 @@ import {
} from "./plugin-control-plane-context.js";
import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js";
export function resolvePluginMetadataSnapshotConfigFingerprint(
export function resolvePluginMetadataControlPlaneFingerprint(
config?: OpenClawConfig,
options: Omit<ResolvePluginControlPlaneContextParams, "config"> = {},
): string {
@@ -30,7 +30,7 @@ export function setCurrentPluginMetadataSnapshot(
setCurrentPluginMetadataSnapshotState(
snapshot,
snapshot
? resolvePluginMetadataSnapshotConfigFingerprint(options.config, {
? resolvePluginMetadataControlPlaneFingerprint(options.config, {
env: options.env,
index: snapshot.index,
policyHash: snapshot.policyHash,
@@ -63,15 +63,12 @@ export function getCurrentPluginMetadataSnapshot(
return undefined;
}
if (params.config) {
const requestedConfigFingerprint = resolvePluginMetadataSnapshotConfigFingerprint(
params.config,
{
env: params.env,
index: snapshot.index,
policyHash: snapshot.policyHash,
workspaceDir: params.workspaceDir,
},
);
const requestedConfigFingerprint = resolvePluginMetadataControlPlaneFingerprint(params.config, {
env: params.env,
index: snapshot.index,
policyHash: snapshot.policyHash,
workspaceDir: params.workspaceDir,
});
if (configFingerprint && configFingerprint !== requestedConfigFingerprint) {
return undefined;
}

View File

@@ -7,8 +7,8 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import {
listExplicitConfiguredChannelIdsForConfig,
loadGatewayStartupPluginPlan,
resolveConfiguredChannelPluginIds,
resolveGatewayStartupPluginIds,
} from "./channel-plugin-ids.js";
import { normalizePluginsConfig } from "./config-state.js";
import { passesManifestOwnerBasePolicy } from "./manifest-owner-policy.js";
@@ -155,12 +155,12 @@ export function resolveEffectivePluginIds(params: {
})) {
ids.add(pluginId);
}
for (const pluginId of resolveGatewayStartupPluginIds({
for (const pluginId of loadGatewayStartupPluginPlan({
config: effectiveConfig,
activationSourceConfig: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
})) {
}).pluginIds) {
ids.add(pluginId);
}
return [...ids].toSorted((left, right) => left.localeCompare(right));

View File

@@ -15,13 +15,23 @@ import { hasExplicitChannelConfig } from "./channel-presence-policy.js";
import { collectPluginConfigContractMatches } from "./config-contracts.js";
import { resolveEffectivePluginActivationState } from "./config-state.js";
import type { InstalledPluginIndexRecord } from "./installed-plugin-index.js";
import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js";
import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js";
import {
isPluginMetadataSnapshotCompatible,
loadPluginMetadataSnapshot,
type PluginMetadataSnapshot,
} from "./plugin-metadata-snapshot.js";
import {
createPluginRegistryIdNormalizer,
normalizePluginsConfigWithRegistry,
} from "./plugin-registry-contributions.js";
import { loadPluginRegistrySnapshot } from "./plugin-registry-snapshot.js";
import type { PluginRegistrySnapshot } from "./plugin-registry-snapshot.js";
export type GatewayStartupPluginPlan = {
channelPluginIds: readonly string[];
configuredDeferredChannelPluginIds: readonly string[];
pluginIds: readonly string[];
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
@@ -235,19 +245,7 @@ export function resolveChannelPluginIds(params: {
workspaceDir?: string;
env: NodeJS.ProcessEnv;
}): string[] {
const index = loadPluginRegistrySnapshot({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const manifestRegistry = loadPluginManifestRegistryForInstalledIndex({
index,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
includeDisabled: true,
});
return resolveChannelPluginIdsFromRegistry({ manifestRegistry });
return [...loadGatewayStartupPluginPlan(params).channelPluginIds];
}
export function resolveChannelPluginIdsFromRegistry(params: {
@@ -262,7 +260,7 @@ export function resolveChannelPluginIdsFromRegistry(params: {
export function resolveConfiguredDeferredChannelPluginIdsFromRegistry(params: {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
index: ReturnType<typeof loadPluginRegistrySnapshot>;
index: PluginRegistrySnapshot;
manifestRegistry: PluginManifestRegistry;
}): string[] {
const configuredChannelIds = new Set(listPotentialEnabledChannelIds(params.config, params.env));
@@ -302,33 +300,25 @@ export function resolveConfiguredDeferredChannelPluginIds(params: {
workspaceDir?: string;
env: NodeJS.ProcessEnv;
}): string[] {
const index = loadPluginRegistrySnapshot({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const manifestRegistry = loadPluginManifestRegistryForInstalledIndex({
index,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
includeDisabled: true,
});
return resolveConfiguredDeferredChannelPluginIdsFromRegistry({
config: params.config,
env: params.env,
index,
manifestRegistry,
});
return [...loadGatewayStartupPluginPlan(params).configuredDeferredChannelPluginIds];
}
export function resolveGatewayStartupPluginIdsFromRegistry(params: {
export function resolveGatewayStartupPluginPlanFromRegistry(params: {
config: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
env: NodeJS.ProcessEnv;
index: ReturnType<typeof loadPluginRegistrySnapshot>;
index: PluginRegistrySnapshot;
manifestRegistry: PluginManifestRegistry;
}): string[] {
}): GatewayStartupPluginPlan {
const channelPluginIds = resolveChannelPluginIdsFromRegistry({
manifestRegistry: params.manifestRegistry,
});
const configuredDeferredChannelPluginIds = resolveConfiguredDeferredChannelPluginIdsFromRegistry({
config: params.config,
env: params.env,
index: params.index,
manifestRegistry: params.manifestRegistry,
});
const configuredChannelIds = new Set(listPotentialEnabledChannelIds(params.config, params.env));
const pluginsConfig = normalizePluginsConfigWithRegistry(params.config.plugins, params.index, {
manifestRegistry: params.manifestRegistry,
@@ -358,7 +348,7 @@ export function resolveGatewayStartupPluginIdsFromRegistry(params: {
manifestRegistry: params.manifestRegistry,
}),
});
return params.index.plugins
const pluginIds = params.index.plugins
.filter((plugin) => {
const manifest = findManifestPlugin(manifestLookup, plugin.pluginId);
if (
@@ -427,6 +417,57 @@ export function resolveGatewayStartupPluginIdsFromRegistry(params: {
return activationState.source === "explicit" || activationState.source === "default";
})
.map((plugin) => plugin.pluginId);
return {
channelPluginIds,
configuredDeferredChannelPluginIds,
pluginIds,
};
}
export function resolveGatewayStartupPluginIdsFromRegistry(params: {
config: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
env: NodeJS.ProcessEnv;
index: PluginRegistrySnapshot;
manifestRegistry: PluginManifestRegistry;
}): string[] {
return [...resolveGatewayStartupPluginPlanFromRegistry(params).pluginIds];
}
export function loadGatewayStartupPluginPlan(params: {
config: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
workspaceDir?: string;
env: NodeJS.ProcessEnv;
index?: PluginRegistrySnapshot;
metadataSnapshot?: PluginMetadataSnapshot;
}): GatewayStartupPluginPlan {
const snapshotConfig = params.activationSourceConfig ?? params.config;
const metadataSnapshot =
params.metadataSnapshot &&
isPluginMetadataSnapshotCompatible({
snapshot: params.metadataSnapshot,
config: snapshotConfig,
env: params.env,
workspaceDir: params.workspaceDir,
index: params.index,
})
? params.metadataSnapshot
: loadPluginMetadataSnapshot({
config: snapshotConfig,
workspaceDir: params.workspaceDir,
env: params.env,
...(params.index ? { index: params.index } : {}),
});
return resolveGatewayStartupPluginPlanFromRegistry({
config: params.config,
...(params.activationSourceConfig !== undefined
? { activationSourceConfig: params.activationSourceConfig }
: {}),
env: params.env,
index: metadataSnapshot.index,
manifestRegistry: metadataSnapshot.manifestRegistry,
});
}
export function resolveGatewayStartupPluginIds(params: {
@@ -435,25 +476,5 @@ export function resolveGatewayStartupPluginIds(params: {
workspaceDir?: string;
env: NodeJS.ProcessEnv;
}): string[] {
const index = loadPluginRegistrySnapshot({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const manifestRegistry = loadPluginManifestRegistryForInstalledIndex({
index,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
includeDisabled: true,
});
return resolveGatewayStartupPluginIdsFromRegistry({
config: params.config,
...(params.activationSourceConfig !== undefined
? { activationSourceConfig: params.activationSourceConfig }
: {}),
env: params.env,
index,
manifestRegistry,
});
return [...loadGatewayStartupPluginPlan(params).pluginIds];
}

View File

@@ -1,8 +1,7 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
resolveChannelPluginIdsFromRegistry,
resolveConfiguredDeferredChannelPluginIdsFromRegistry,
resolveGatewayStartupPluginIdsFromRegistry,
resolveGatewayStartupPluginPlanFromRegistry,
type GatewayStartupPluginPlan,
} from "./channel-plugin-ids.js";
import { hashJson } from "./installed-plugin-index-hash.js";
import {
@@ -15,11 +14,7 @@ import type { PluginRegistrySnapshot } from "./plugin-registry-snapshot.js";
export type PluginLookUpTableOwnerMaps = PluginMetadataSnapshotOwnerMaps;
export type PluginLookUpTableStartupPlan = {
channelPluginIds: readonly string[];
configuredDeferredChannelPluginIds: readonly string[];
pluginIds: readonly string[];
};
export type PluginLookUpTableStartupPlan = GatewayStartupPluginPlan;
export type PluginLookUpTableMetrics = {
registrySnapshotMs: number;
@@ -72,14 +67,7 @@ export function loadPluginLookUpTable(params: LoadPluginLookUpTableParams): Plug
});
const { index, manifestRegistry } = metadataSnapshot;
const startupPlanStartedAt = performance.now();
const channelPluginIds = resolveChannelPluginIdsFromRegistry({ manifestRegistry });
const configuredDeferredChannelPluginIds = resolveConfiguredDeferredChannelPluginIdsFromRegistry({
config: params.config,
env: params.env,
index,
manifestRegistry,
});
const pluginIds = resolveGatewayStartupPluginIdsFromRegistry({
const startup = resolveGatewayStartupPluginPlanFromRegistry({
config: params.config,
...(params.activationSourceConfig !== undefined
? { activationSourceConfig: params.activationSourceConfig }
@@ -89,11 +77,6 @@ export function loadPluginLookUpTable(params: LoadPluginLookUpTableParams): Plug
manifestRegistry,
});
const startupPlanMs = performance.now() - startupPlanStartedAt;
const startup = {
channelPluginIds,
configuredDeferredChannelPluginIds,
pluginIds,
};
return {
...metadataSnapshot,
@@ -112,8 +95,8 @@ export function loadPluginLookUpTable(params: LoadPluginLookUpTableParams): Plug
...metadataSnapshot.metrics,
startupPlanMs,
totalMs: metadataSnapshot.metrics.totalMs + startupPlanMs,
startupPluginCount: pluginIds.length,
deferredChannelPluginCount: configuredDeferredChannelPluginIds.length,
startupPluginCount: startup.pluginIds.length,
deferredChannelPluginCount: startup.configuredDeferredChannelPluginIds.length,
},
};
}

View File

@@ -23,7 +23,7 @@ export type {
PluginMetadataSnapshotRegistryDiagnostic,
} from "./plugin-metadata-snapshot.types.js";
function resolvePluginMetadataSnapshotConfigFingerprint(
function resolvePluginMetadataControlPlaneFingerprint(
params: Pick<LoadPluginMetadataSnapshotParams, "config" | "env" | "workspaceDir"> & {
index?: InstalledPluginIndex;
policyHash?: string;
@@ -60,7 +60,7 @@ export function isPluginMetadataSnapshotCompatible(params: {
params.snapshot.policyHash === resolveInstalledPluginIndexPolicyHash(params.config) &&
(!params.snapshot.configFingerprint ||
params.snapshot.configFingerprint ===
resolvePluginMetadataSnapshotConfigFingerprint({
resolvePluginMetadataControlPlaneFingerprint({
config: params.config,
env,
index: params.index ?? params.snapshot.index,
@@ -195,7 +195,7 @@ function loadPluginMetadataSnapshotImpl(
return {
policyHash: index.policyHash,
configFingerprint: resolvePluginMetadataSnapshotConfigFingerprint({
configFingerprint: resolvePluginMetadataControlPlaneFingerprint({
config: params.config,
env: params.env,
index,

View File

@@ -13,6 +13,7 @@ import type {
PluginManifestRegistry,
} from "./manifest-registry.js";
import { isPackageIncludedInCoreBundle } from "./manifest.js";
import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js";
import type { PluginOrigin } from "./plugin-origin.types.js";
import {
createPluginRegistryIdNormalizer,
@@ -28,22 +29,10 @@ export {
type PluginRegistryIdNormalizerOptions,
} from "./plugin-registry-id-normalizer.js";
export type PluginLookUpTable = {
index: PluginRegistrySnapshot;
manifestRegistry: PluginManifestRegistry;
plugins: readonly PluginManifestRecord[];
normalizePluginId: (pluginId: string) => string;
owners: {
channels: ReadonlyMap<string, readonly string[]>;
channelConfigs: ReadonlyMap<string, readonly string[]>;
providers: ReadonlyMap<string, readonly string[]>;
modelCatalogProviders: ReadonlyMap<string, readonly string[]>;
cliBackends: ReadonlyMap<string, readonly string[]>;
setupProviders: ReadonlyMap<string, readonly string[]>;
commandAliases: ReadonlyMap<string, readonly string[]>;
contracts: ReadonlyMap<string, readonly string[]>;
};
};
export type PluginLookUpTable = Pick<
PluginMetadataSnapshot,
"index" | "manifestRegistry" | "plugins" | "normalizePluginId" | "owners"
>;
export type PluginRegistryContributionOptions = LoadPluginRegistryParams & {
includeDisabled?: boolean;

View File

@@ -153,6 +153,41 @@ describe("bundled plugin public surface loader", () => {
expect(createJiti).not.toHaveBeenCalled();
});
it("does not cache missing public artifact locations", async () => {
vi.doMock("./native-module-require.js", () => ({
tryNativeRequireJavaScriptModule: (modulePath: string) => ({
ok: true,
moduleExport: { marker: path.basename(path.dirname(modulePath)) },
}),
}));
vi.resetModules();
const publicSurfaceLoader = await importFreshModule<
typeof import("./public-surface-loader.js")
>(import.meta.url, "./public-surface-loader.js?scope=missing-location-retry");
const tempRoot = createTempDir();
const bundledPluginsDir = path.join(tempRoot, "dist");
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledPluginsDir;
expect(
publicSurfaceLoader.resolveBundledPluginPublicArtifactPath({
dirName: "demo",
artifactBasename: "api.js",
}),
).toBeNull();
const modulePath = path.join(bundledPluginsDir, "demo", "api.js");
fs.mkdirSync(path.dirname(modulePath), { recursive: true });
fs.writeFileSync(modulePath, 'export const marker = "demo";\n', "utf8");
expect(
publicSurfaceLoader.loadBundledPluginPublicArtifactModuleSync<{ marker: string }>({
dirName: "demo",
artifactBasename: "api.js",
}).marker,
).toBe("demo");
});
it("rejects public artifacts that change after boundary validation", async () => {
const createJiti = vi.fn(() => vi.fn(() => ({ marker: "should-not-load" })));
vi.doMock("jiti", () => ({

View File

@@ -25,7 +25,7 @@ const publicSurfaceLocationCache = new Map<
{
modulePath: string;
boundaryRoot: string;
} | null
}
>();
const moduleLoaders: PluginModuleLoaderCache = createPluginModuleLoaderCache();
@@ -84,11 +84,14 @@ function resolvePublicSurfaceLocation(params: {
artifactBasename: string;
}): { modulePath: string; boundaryRoot: string } | null {
const key = createResolutionKey(params);
if (publicSurfaceLocationCache.has(key)) {
return publicSurfaceLocationCache.get(key) ?? null;
const cached = publicSurfaceLocationCache.get(key);
if (cached) {
return cached;
}
const resolved = resolvePublicSurfaceLocationUncached(params);
publicSurfaceLocationCache.set(key, resolved);
if (resolved) {
publicSurfaceLocationCache.set(key, resolved);
}
return resolved;
}