import path from "node:path"; import { resolveIsNixMode } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { getActiveDiagnosticsTimelineSpan, measureDiagnosticsTimelineSpanSync, } from "../infra/diagnostics-timeline.js"; import { resolveUserPath } from "../utils.js"; import { resolveCompatibilityHostVersion } from "../version.js"; import { resolveDefaultPluginNpmDir } from "./install-paths.js"; import { hashJson, safeFileSignature } from "./installed-plugin-index-hash.js"; import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js"; import { resolveInstalledPluginIndexStorePath } from "./installed-plugin-index-store-path.js"; import type { InstalledPluginIndex } from "./installed-plugin-index.js"; import { loadPluginManifestRegistryForInstalledIndex, resolveInstalledManifestRegistryIndexFingerprint, } from "./manifest-registry-installed.js"; import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js"; import { resolvePluginControlPlaneFingerprint } from "./plugin-control-plane-context.js"; import type { LoadPluginMetadataSnapshotParams, PluginMetadataSnapshot, PluginMetadataSnapshotOwnerMaps, } from "./plugin-metadata-snapshot.types.js"; import { createPluginRegistryIdNormalizer } from "./plugin-registry-id-normalizer.js"; import { loadPluginRegistrySnapshotWithMetadata, type PluginRegistrySnapshotSource, } from "./plugin-registry.js"; type PluginMetadataSnapshotMemo = { key: string; snapshot: PluginMetadataSnapshot; }; let pluginMetadataSnapshotMemo: PluginMetadataSnapshotMemo | undefined; export function clearLoadPluginMetadataSnapshotMemo(): void { pluginMetadataSnapshotMemo = undefined; } const MEMO_RELEVANT_ENV_KEYS = [ "APPDATA", "HOME", "OPENCLAW_BUNDLED_PLUGINS_DIR", "OPENCLAW_COMPATIBILITY_HOST_VERSION", "OPENCLAW_CONFIG_PATH", "OPENCLAW_DISABLE_BUNDLED_PLUGINS", "OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS", "OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY", "OPENCLAW_HOME", "OPENCLAW_NIX_MODE", "OPENCLAW_STATE_DIR", "USERPROFILE", "XDG_CONFIG_HOME", ] as const; export type { LoadPluginMetadataSnapshotParams, PluginMetadataManifestView, PluginMetadataRegistryView, PluginMetadataSnapshot, PluginMetadataSnapshotMetrics, PluginMetadataSnapshotOwnerMaps, PluginMetadataSnapshotRegistryDiagnostic, } from "./plugin-metadata-snapshot.types.js"; function fileFingerprint(filePath: string): unknown { const signature = safeFileSignature(filePath); if (!signature) { return [filePath, "missing"]; } return [filePath, signature.size, signature.mtimeMs, signature.ctimeMs]; } function pickMemoRelevantEnv(env: NodeJS.ProcessEnv): Record { return Object.fromEntries( MEMO_RELEVANT_ENV_KEYS.flatMap((key) => { const value = env[key]; return value === undefined ? [] : [[key, value]]; }), ); } function cloneOwnerMaps(owners: PluginMetadataSnapshotOwnerMaps): PluginMetadataSnapshotOwnerMaps { return { channels: new Map(owners.channels), channelConfigs: new Map(owners.channelConfigs), providers: new Map(owners.providers), modelCatalogProviders: new Map(owners.modelCatalogProviders), cliBackends: new Map(owners.cliBackends), setupProviders: new Map(owners.setupProviders), commandAliases: new Map(owners.commandAliases), contracts: new Map(owners.contracts), }; } function cloneSnapshotValue(value: T): T { return value && typeof value === "object" ? structuredClone(value) : value; } function clonePluginManifestRecord(plugin: PluginManifestRecord): PluginManifestRecord { return cloneSnapshotValue(plugin); } function clonePluginMetadataSnapshot(snapshot: PluginMetadataSnapshot): PluginMetadataSnapshot { const plugins = snapshot.plugins.map(clonePluginManifestRecord); const pluginsById = new Map(plugins.map((plugin) => [plugin.id, plugin])); const diagnostics = snapshot.diagnostics.map(cloneSnapshotValue); return { ...snapshot, index: { ...snapshot.index, installRecords: cloneSnapshotValue(snapshot.index.installRecords ?? {}), plugins: snapshot.index.plugins.map(cloneSnapshotValue), diagnostics: snapshot.index.diagnostics.map(cloneSnapshotValue), }, registryDiagnostics: snapshot.registryDiagnostics.map(cloneSnapshotValue), manifestRegistry: { ...snapshot.manifestRegistry, plugins, diagnostics, }, plugins, diagnostics, byPluginId: new Map( [...snapshot.byPluginId.entries()].map(([pluginId, plugin]) => [ pluginId, pluginsById.get(plugin.id) ?? clonePluginManifestRecord(plugin), ]), ), owners: cloneOwnerMaps(snapshot.owners), metrics: { ...snapshot.metrics }, }; } function resolvePersistedRegistryMemoFingerprint(params: { env: NodeJS.ProcessEnv; preferPersisted?: boolean; stateDir?: string; }): unknown { const indexPath = resolveInstalledPluginIndexStorePath({ env: params.env, ...(params.stateDir ? { stateDir: params.stateDir } : {}), }); const npmRoot = params.stateDir ? path.join(params.stateDir, "npm") : resolveDefaultPluginNpmDir(params.env); return { disabled: params.preferPersisted === false || params.env.OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY, index: fileFingerprint(indexPath), npmPackageJson: fileFingerprint(path.join(npmRoot, "package.json")), }; } function computePluginMetadataSnapshotMemoKey(params: LoadPluginMetadataSnapshotParams): string { const env = params.env ?? process.env; const indexFingerprint = params.index ? resolveInstalledManifestRegistryIndexFingerprint(params.index) : undefined; return hashJson({ controlPlane: resolvePluginControlPlaneFingerprint({ config: params.config, env, workspaceDir: params.workspaceDir, policyHash: resolveInstalledPluginIndexPolicyHash(params.config), ...(indexFingerprint ? { inventoryFingerprint: indexFingerprint } : {}), }), cwd: process.cwd(), env: pickMemoRelevantEnv(env), index: indexFingerprint ?? null, pathPolicy: { compatibilityHostVersion: resolveCompatibilityHostVersion(env), nixMode: resolveIsNixMode(env), }, preferPersisted: params.preferPersisted ?? null, registry: resolvePersistedRegistryMemoFingerprint({ env, ...(params.stateDir ? { stateDir: resolveUserPath(params.stateDir, env) } : {}), ...(params.preferPersisted !== undefined ? { preferPersisted: params.preferPersisted } : {}), }), stateDir: params.stateDir ? resolveUserPath(params.stateDir, env) : null, workspaceDir: params.workspaceDir ?? null, }); } function resolvePluginMetadataControlPlaneFingerprint( params: Pick & { index?: InstalledPluginIndex; policyHash?: string; }, ): string { return resolvePluginControlPlaneFingerprint(params); } function indexesMatch( left: InstalledPluginIndex | undefined, right: InstalledPluginIndex | undefined, ): boolean { if (!left || !right) { return true; } return ( resolveInstalledManifestRegistryIndexFingerprint(left) === resolveInstalledManifestRegistryIndexFingerprint(right) ); } function normalizeInstalledPluginIndex(index: InstalledPluginIndex): InstalledPluginIndex { return { version: index.version ?? 1, hostContractVersion: index.hostContractVersion ?? "", compatRegistryVersion: index.compatRegistryVersion ?? "", migrationVersion: index.migrationVersion ?? 1, policyHash: index.policyHash ?? "", generatedAtMs: index.generatedAtMs ?? 0, installRecords: index.installRecords ?? {}, plugins: index.plugins ?? [], diagnostics: index.diagnostics ?? [], ...(index.warning ? { warning: index.warning } : {}), ...(index.refreshReason ? { refreshReason: index.refreshReason } : {}), } as InstalledPluginIndex; } export function isPluginMetadataSnapshotCompatible(params: { snapshot: Pick< PluginMetadataSnapshot, "configFingerprint" | "index" | "policyHash" | "workspaceDir" >; config: OpenClawConfig; env?: NodeJS.ProcessEnv; workspaceDir?: string; index?: InstalledPluginIndex; }): boolean { const env = params.env ?? process.env; return ( params.snapshot.policyHash === resolveInstalledPluginIndexPolicyHash(params.config) && (!params.snapshot.configFingerprint || params.snapshot.configFingerprint === resolvePluginMetadataControlPlaneFingerprint({ config: params.config, env, index: params.index ?? params.snapshot.index, policyHash: params.snapshot.policyHash, workspaceDir: params.workspaceDir, })) && (params.snapshot.workspaceDir ?? "") === (params.workspaceDir ?? "") && indexesMatch(params.snapshot.index, params.index) ); } function appendOwner(owners: Map, ownedId: string, pluginId: string): void { const existing = owners.get(ownedId); if (existing) { existing.push(pluginId); return; } owners.set(ownedId, [pluginId]); } function freezeOwnerMap(owners: Map): ReadonlyMap { return new Map( [...owners.entries()].map(([ownedId, pluginIds]) => [ownedId, Object.freeze([...pluginIds])]), ); } function buildPluginMetadataOwnerMaps( plugins: readonly PluginManifestRecord[], ): PluginMetadataSnapshotOwnerMaps { const channels = new Map(); const channelConfigs = new Map(); const providers = new Map(); const modelCatalogProviders = new Map(); const cliBackends = new Map(); const setupProviders = new Map(); const commandAliases = new Map(); const contracts = new Map(); for (const plugin of plugins) { for (const channelId of plugin.channels ?? []) { appendOwner(channels, channelId, plugin.id); } for (const channelId of Object.keys(plugin.channelConfigs ?? {})) { appendOwner(channelConfigs, channelId, plugin.id); } for (const providerId of plugin.providers ?? []) { appendOwner(providers, providerId, plugin.id); } for (const providerId of Object.keys(plugin.modelCatalog?.providers ?? {})) { appendOwner(modelCatalogProviders, providerId, plugin.id); } for (const providerId of Object.keys(plugin.modelCatalog?.aliases ?? {})) { appendOwner(modelCatalogProviders, providerId, plugin.id); } for (const cliBackendId of plugin.cliBackends ?? []) { appendOwner(cliBackends, cliBackendId, plugin.id); } for (const cliBackendId of plugin.setup?.cliBackends ?? []) { appendOwner(cliBackends, cliBackendId, plugin.id); } for (const setupProvider of plugin.setup?.providers ?? []) { appendOwner(setupProviders, setupProvider.id, plugin.id); } for (const commandAlias of plugin.commandAliases ?? []) { appendOwner(commandAliases, commandAlias.name, plugin.id); } for (const [contract, values] of Object.entries(plugin.contracts ?? {})) { if (Array.isArray(values) && values.length > 0) { appendOwner(contracts, contract, plugin.id); } } } return { channels: freezeOwnerMap(channels), channelConfigs: freezeOwnerMap(channelConfigs), providers: freezeOwnerMap(providers), modelCatalogProviders: freezeOwnerMap(modelCatalogProviders), cliBackends: freezeOwnerMap(cliBackends), setupProviders: freezeOwnerMap(setupProviders), commandAliases: freezeOwnerMap(commandAliases), contracts: freezeOwnerMap(contracts), }; } export function listPluginOriginsFromMetadataSnapshot( snapshot: Pick, ): ReadonlyMap { return new Map(snapshot.plugins.map((record) => [record.id, record.origin])); } // Process-local memoization is keyed by stable registry/config/env inputs. It // intentionally does not watch arbitrary direct plugin file edits after a // persisted registry has been accepted; registry refreshes and process restarts // are the freshness boundaries for that broader edit flow. export function loadPluginMetadataSnapshot( params: LoadPluginMetadataSnapshotParams, ): PluginMetadataSnapshot { const activeTimelineSpan = getActiveDiagnosticsTimelineSpan(); const memoKey = computePluginMetadataSnapshotMemoKey(params); const memo = pluginMetadataSnapshotMemo; if (memo?.key === memoKey) { return measureDiagnosticsTimelineSpanSync( "plugins.metadata.scan", () => clonePluginMetadataSnapshot(memo.snapshot), { phase: activeTimelineSpan?.phase ?? "startup", config: params.config, env: params.env, attributes: { cacheHit: true, hasWorkspaceDir: params.workspaceDir !== undefined, hasInstalledIndex: params.index !== undefined, }, }, ); } const result = measureDiagnosticsTimelineSpanSync( "plugins.metadata.scan", () => loadPluginMetadataSnapshotImpl(params), { phase: activeTimelineSpan?.phase ?? "startup", config: params.config, env: params.env, attributes: { hasWorkspaceDir: params.workspaceDir !== undefined, hasInstalledIndex: params.index !== undefined, }, }, ); if (canMemoizePluginMetadataSnapshotResult(result)) { pluginMetadataSnapshotMemo = { key: memoKey, snapshot: clonePluginMetadataSnapshot(result.snapshot), }; } return result.snapshot; } function canMemoizePluginMetadataSnapshotResult(result: { registrySource: PluginRegistrySnapshotSource; snapshot: PluginMetadataSnapshot; }): boolean { if (result.snapshot.index.plugins.length === 0) { return false; } if (result.registrySource !== "derived") { return true; } return ( result.snapshot.registryDiagnostics.length > 0 && result.snapshot.registryDiagnostics.every( (diagnostic) => diagnostic.code === "persisted-registry-stale-policy", ) ); } function loadPluginMetadataSnapshotImpl(params: LoadPluginMetadataSnapshotParams): { snapshot: PluginMetadataSnapshot; registrySource: PluginRegistrySnapshotSource; } { const totalStartedAt = performance.now(); const registryStartedAt = performance.now(); const registryResult = loadPluginRegistrySnapshotWithMetadata({ config: params.config, workspaceDir: params.workspaceDir, ...(params.stateDir ? { stateDir: params.stateDir } : {}), env: params.env, ...(params.preferPersisted !== undefined ? { preferPersisted: params.preferPersisted } : {}), ...(params.index ? { index: params.index } : {}), }) ?? { source: "derived" as const, snapshot: { plugins: [] }, diagnostics: [], }; const registrySnapshotMs = performance.now() - registryStartedAt; const index = normalizeInstalledPluginIndex(registryResult.snapshot); const manifestStartedAt = performance.now(); const manifestRegistry = index.plugins.length === 0 ? loadPluginManifestRegistry({ config: params.config, workspaceDir: params.workspaceDir, env: params.env, diagnostics: [...index.diagnostics], installRecords: index.installRecords, }) : loadPluginManifestRegistryForInstalledIndex({ index, config: params.config, workspaceDir: params.workspaceDir, env: params.env, includeDisabled: true, }); const manifestRegistryMs = performance.now() - manifestStartedAt; const normalizePluginId = createPluginRegistryIdNormalizer(index, { manifestRegistry }); const byPluginId = new Map(manifestRegistry.plugins.map((plugin) => [plugin.id, plugin])); const ownerMapsStartedAt = performance.now(); const owners = buildPluginMetadataOwnerMaps(manifestRegistry.plugins); const ownerMapsMs = performance.now() - ownerMapsStartedAt; const totalMs = performance.now() - totalStartedAt; return { registrySource: registryResult.source, snapshot: { policyHash: index.policyHash, configFingerprint: resolvePluginMetadataControlPlaneFingerprint({ config: params.config, env: params.env, index, policyHash: index.policyHash, workspaceDir: params.workspaceDir, }), ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), index, registryDiagnostics: registryResult.diagnostics, manifestRegistry, plugins: manifestRegistry.plugins, diagnostics: manifestRegistry.diagnostics, byPluginId, normalizePluginId, owners, metrics: { registrySnapshotMs, manifestRegistryMs, ownerMapsMs, totalMs, indexPluginCount: index.plugins.length, manifestPluginCount: manifestRegistry.plugins.length, }, }, }; }