From 12f82270cf07db0e37a504e218237e2f58ea6371 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 24 May 2026 02:47:12 +0100 Subject: [PATCH] perf: cache stable gateway metadata --- CHANGELOG.md | 1 + .../telegram/src/bot-message-dispatch.ts | 66 ++++---- src/channels/plugins/bundled.ts | 40 ++++- src/plugins/channel-catalog-registry.test.ts | 73 +++++++++ src/plugins/channel-catalog-registry.ts | 35 ++-- src/plugins/discovery.ts | 5 + .../installed-plugin-index-record-reader.ts | 80 +++++++++- .../installed-plugin-index-records.test.ts | 151 ++++++++++++++++++ src/plugins/installed-plugin-index-records.ts | 2 + src/plugins/installed-plugin-index-store.ts | 3 + 10 files changed, 400 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cbceed0a4b..402d29ed6f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Gateway/perf: reuse process-stable channel catalog reads, avoid repeated bundled-channel boundary checks, and rotate gateway watch CPU profiles so benchmark runs do not accumulate unbounded artifacts. +- Gateway/perf: cache stable install-record, channel-catalog, bundled-channel, and Telegram session-store metadata during process-local hot paths to reduce repeated JSON and manifest reads. - Gateway/perf: reuse immutable plugin metadata snapshots across startup, config, model, channel, setup, and secret metadata readers so hot paths avoid repeated plugin file stats and manifest registry reloads. - Talk/realtime: let WebUI and Discord voice callers ask for active OpenClaw run status, cancel, steer, or queue follow-up work while a consult is still running. (#84231) Thanks @Solvely-Colin. - Gateway/perf: lazy-load startup-idle plugin work, core gateway method handlers, and the embedded ACPX runtime so Gateway health and ready signals no longer wait on unused handler trees or ACPX probes. diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 0d647c039cc..8945dcc79b0 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -235,23 +235,48 @@ type DispatchTelegramMessageParams = { type TelegramReasoningLevel = "off" | "on" | "stream"; type TelegramTranscriptMirrorPayload = { text?: string; mediaUrls?: string[] }; +type TelegramSessionStore = ReturnType; +type FreshTelegramSessionStoreLoader = ((agentId: string) => { + storePath: string; + store: TelegramSessionStore; +}) & { + clear: () => void; +}; + +function createFreshTelegramSessionStoreLoader(params: { + cfg: OpenClawConfig; + telegramDeps: TelegramBotDeps; +}): FreshTelegramSessionStoreLoader { + const storesByPath = new Map(); + const load = ((agentId: string) => { + const storePath = params.telegramDeps.resolveStorePath(params.cfg.session?.store, { agentId }); + const cachedStore = storesByPath.get(storePath); + if (cachedStore) { + return { storePath, store: cachedStore }; + } + const store = (params.telegramDeps.loadSessionStore ?? loadSessionStore)(storePath, { + skipCache: true, + }); + storesByPath.set(storePath, store); + return { storePath, store }; + }) as FreshTelegramSessionStoreLoader; + load.clear = () => storesByPath.clear(); + return load; +} function resolveTelegramReasoningLevel(params: { cfg: OpenClawConfig; sessionKey?: string; agentId: string; - telegramDeps: TelegramBotDeps; + loadFreshSessionStore: FreshTelegramSessionStoreLoader; }): TelegramReasoningLevel { - const { cfg, sessionKey, agentId, telegramDeps } = params; + const { cfg, sessionKey, agentId } = params; const configDefault = resolveTelegramConfigReasoningDefault(cfg, agentId); if (!sessionKey) { return configDefault; } try { - const storePath = telegramDeps.resolveStorePath(cfg.session?.store, { agentId }); - const store = (telegramDeps.loadSessionStore ?? loadSessionStore)(storePath, { - skipCache: true, - }); + const { store } = params.loadFreshSessionStore(agentId); const entry = resolveSessionStoreEntry({ store, sessionKey }).existing; const level = entry?.reasoningLevel; if (level === "on" || level === "stream" || level === "off") { @@ -285,19 +310,14 @@ async function mirrorTelegramAssistantReplyToTranscript(params: { cfg: OpenClawConfig; route: TelegramMessageContext["route"]; sessionKey: string; - telegramDeps: TelegramBotDeps; + loadFreshSessionStore: FreshTelegramSessionStoreLoader; payload: TelegramTranscriptMirrorPayload; }) { const text = resolveTelegramMirroredTranscriptText(params.payload); if (!text) { return; } - const storePath = params.telegramDeps.resolveStorePath(params.cfg.session?.store, { - agentId: params.route.agentId, - }); - const store = (params.telegramDeps.loadSessionStore ?? loadSessionStore)(storePath, { - skipCache: true, - }); + const { storePath, store } = params.loadFreshSessionStore(params.route.agentId); const sessionEntry = resolveSessionStoreEntry({ store, sessionKey: params.sessionKey, @@ -384,6 +404,7 @@ export const dispatchTelegramMessage = async ({ const dispatchStartedAt = Date.now(); const telegramDeps = injectedTelegramDeps ?? (await import("./bot-deps.js")).defaultTelegramBotDeps; + const loadFreshSessionStore = createFreshTelegramSessionStoreLoader({ cfg, telegramDeps }); const { ctxPayload, msg, @@ -499,7 +520,7 @@ export const dispatchTelegramMessage = async ({ cfg, sessionKey: ctxPayload.SessionKey, agentId: route.agentId, - telegramDeps, + loadFreshSessionStore, }); const forceBlockStreamingForReasoning = resolvedReasoningLevel === "on"; const streamReasoningDraft = resolvedReasoningLevel === "stream"; @@ -960,12 +981,7 @@ export const dispatchTelegramMessage = async ({ return undefined; } try { - const storePath = telegramDeps.resolveStorePath(cfg.session?.store, { - agentId: route.agentId, - }); - const store = (telegramDeps.loadSessionStore ?? loadSessionStore)(storePath, { - skipCache: true, - }); + const { storePath, store } = loadFreshSessionStore(route.agentId); const sessionEntry = resolveSessionStoreEntry({ store, sessionKey, @@ -1020,7 +1036,7 @@ export const dispatchTelegramMessage = async ({ cfg, route, sessionKey, - telegramDeps, + loadFreshSessionStore, payload, }); } @@ -1285,12 +1301,7 @@ export const dispatchTelegramMessage = async ({ if (isDmTopic) { try { - const storePath = telegramDeps.resolveStorePath(cfg.session?.store, { - agentId: route.agentId, - }); - const store = (telegramDeps.loadSessionStore ?? loadSessionStore)(storePath, { - skipCache: true, - }); + const { store } = loadFreshSessionStore(route.agentId); const sessionKey = ctxPayload.SessionKey; if (sessionKey) { const entry = resolveSessionStoreEntry({ store, sessionKey }).existing; @@ -1302,6 +1313,7 @@ export const dispatchTelegramMessage = async ({ logVerbose(`auto-topic-label: session store error: ${formatErrorMessage(err)}`); } } + loadFreshSessionStore.clear(); if (statusReactionController && !isRoomEvent) { void statusReactionController.setThinking(); diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 5c0490dd24b..d8e58e004eb 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -90,6 +90,8 @@ type BundledChannelLoadContext = { ChannelId, NonNullable | null >; + metadataById: Map; + metadataLoaded: boolean; }; const log = createSubsystemLogger("channels"); @@ -373,6 +375,8 @@ function createBundledChannelLoadContext(): BundledChannelLoadContext { lazySecretsById: new Map(), lazySetupSecretsById: new Map(), lazyAccountInspectorsById: new Map(), + metadataById: new Map(), + metadataLoaded: false, }; } @@ -502,19 +506,39 @@ export function hasBundledChannelPackageSetupFeature( id: ChannelId, feature: BundledChannelPackageSetupFeature, ): boolean { - const rootScope = resolveBundledChannelRootScope(); + const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); return ( - resolveBundledChannelMetadata(id, rootScope)?.packageManifest?.setupFeatures?.[feature] === true + resolveBundledChannelMetadata(id, rootScope, loadContext)?.packageManifest?.setupFeatures?.[ + feature + ] === true ); } function resolveBundledChannelMetadata( id: ChannelId, rootScope: BundledChannelRootScope, + loadContext: BundledChannelLoadContext, ): BundledChannelPluginMetadata | undefined { - return listBundledChannelMetadata(rootScope).find( - (metadata) => metadata.manifest.id === id || metadata.manifest.channels?.includes(id), - ); + if (loadContext.metadataById.has(id)) { + return loadContext.metadataById.get(id) ?? undefined; + } + if (loadContext.metadataLoaded) { + loadContext.metadataById.set(id, null); + return undefined; + } + for (const metadata of listBundledChannelMetadata(rootScope)) { + const ids = new Set([metadata.manifest.id, ...(metadata.manifest.channels ?? [])]); + for (const metadataId of ids) { + loadContext.metadataById.set(metadataId, metadata); + } + } + loadContext.metadataLoaded = true; + const metadata = loadContext.metadataById.get(id); + if (metadata) { + return metadata; + } + loadContext.metadataById.set(id, null); + return undefined; } function getLazyGeneratedBundledChannelEntryForRoot( @@ -529,7 +553,7 @@ function getLazyGeneratedBundledChannelEntryForRoot( if (previous === null) { return null; } - const metadata = resolveBundledChannelMetadata(id, rootScope); + const metadata = resolveBundledChannelMetadata(id, rootScope, loadContext); if (!metadata) { loadContext.lazyEntriesById.set(id, null); return null; @@ -577,7 +601,7 @@ function getLazyGeneratedBundledChannelSetupEntryForRoot( if (loadContext.lazySetupEntriesById.has(id)) { return loadContext.lazySetupEntriesById.get(id) ?? null; } - const metadata = resolveBundledChannelMetadata(id, rootScope); + const metadata = resolveBundledChannelMetadata(id, rootScope, loadContext); if (!metadata) { loadContext.lazySetupEntriesById.set(id, null); return null; @@ -615,7 +639,7 @@ function getBundledChannelPluginForRoot( } loadContext.pluginLoadInProgressIds.add(id); try { - const metadata = resolveBundledChannelMetadata(id, rootScope); + const metadata = resolveBundledChannelMetadata(id, rootScope, loadContext); const plugin = entry.loadChannelPlugin() as ChannelPlugin | undefined; if (!plugin) { loadContext.lazyPluginsById.set(id, null); diff --git a/src/plugins/channel-catalog-registry.test.ts b/src/plugins/channel-catalog-registry.test.ts index 6d130f88405..c1ed0fb5ea8 100644 --- a/src/plugins/channel-catalog-registry.test.ts +++ b/src/plugins/channel-catalog-registry.test.ts @@ -65,6 +65,30 @@ function firstDiscoverOptions(discoverSpy: ReturnType): Record; } +function createChannelCandidate(params: { + idHint?: string; + pluginId?: string; + bundledPluginId?: string; + origin?: PluginCandidate["origin"]; +}): PluginCandidate { + return { + idHint: params.idHint ?? "hint-plugin", + source: "/tmp/openclaw-test-plugin/index.js", + rootDir: "/tmp/openclaw-test-plugin", + origin: params.origin ?? "global", + packageName: "@vendor/openclaw-test-plugin", + packageManifest: { + ...(params.pluginId ? { plugin: { id: params.pluginId } } : {}), + channel: { + id: "test-channel", + name: "Test Channel", + description: "Test channel", + }, + }, + ...(params.bundledPluginId ? { bundledManifestId: params.bundledPluginId } : {}), + } as PluginCandidate; +} + describe("listChannelCatalogEntries", () => { it("forwards lazily loaded install records to discovery when origin is unspecified", async () => { const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({}); @@ -134,4 +158,53 @@ describe("listChannelCatalogEntries", () => { expect(discoverSpy).toHaveBeenCalledTimes(1); expect(firstDiscoverOptions(discoverSpy)).not.toHaveProperty("installRecords"); }); + + it("uses discovered package metadata for channel plugin ids", async () => { + const { module, loadRecordsSpy } = await loadWithMocks({}); + + expect( + module.listChannelCatalogEntries({ + installRecords: {}, + discovery: { + candidates: [createChannelCandidate({ pluginId: "package-plugin" })], + diagnostics: [], + }, + }), + ).toStrictEqual([ + { + pluginId: "package-plugin", + origin: "global", + packageName: "@vendor/openclaw-test-plugin", + workspaceDir: undefined, + rootDir: "/tmp/openclaw-test-plugin", + channel: { + id: "test-channel", + name: "Test Channel", + description: "Test channel", + }, + }, + ]); + expect(loadRecordsSpy).not.toHaveBeenCalled(); + }); + + it("prefers bundled manifest ids over package id hints", async () => { + const { module } = await loadWithMocks({}); + + expect( + module.listChannelCatalogEntries({ + installRecords: {}, + discovery: { + candidates: [ + createChannelCandidate({ + idHint: "hint-plugin", + pluginId: "package-plugin", + bundledPluginId: "bundled-plugin", + origin: "bundled", + }), + ], + diagnostics: [], + }, + })[0]?.pluginId, + ).toBe("bundled-plugin"); + }); }); diff --git a/src/plugins/channel-catalog-registry.ts b/src/plugins/channel-catalog-registry.ts index e32dacc3bd3..9ad969d0abc 100644 --- a/src/plugins/channel-catalog-registry.ts +++ b/src/plugins/channel-catalog-registry.ts @@ -1,12 +1,7 @@ import type { PluginInstallRecord } from "../config/types.plugins.js"; import { discoverOpenClawPlugins, type PluginDiscoveryResult } from "./discovery.js"; -import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js"; import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js"; -import { - loadPluginManifest, - type PluginPackageChannel, - type PluginPackageInstall, -} from "./manifest.js"; +import type { PluginPackageChannel, PluginPackageInstall } from "./manifest.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; export type PluginChannelCatalogEntry = { @@ -50,20 +45,13 @@ export function listChannelCatalogEntries( if (!channel?.id) { return []; } - const manifest = loadPluginManifest( - candidate.rootDir, - shouldRejectHardlinkedPluginFiles({ - origin: candidate.origin, - rootDir: candidate.rootDir, - env: params.env, - }), - ); - if (!manifest.ok) { + const pluginId = resolveChannelCatalogPluginId(candidate); + if (!pluginId) { return []; } return [ { - pluginId: manifest.manifest.id, + pluginId, origin: candidate.origin, packageName: candidate.packageName, workspaceDir: candidate.workspaceDir, @@ -77,6 +65,21 @@ export function listChannelCatalogEntries( }); } +function resolveOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function resolveChannelCatalogPluginId( + candidate: PluginDiscoveryResult["candidates"][number], +): string | undefined { + return ( + resolveOptionalString(candidate.bundledManifest?.id) ?? + resolveOptionalString(candidate.bundledManifestId) ?? + resolveOptionalString(candidate.packageManifest?.plugin?.id) ?? + resolveOptionalString(candidate.idHint) + ); +} + function resolveInstallRecords(params: { origin?: PluginOrigin; env?: NodeJS.ProcessEnv; diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 5829fb0cd74..b2429ef3832 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -71,6 +71,7 @@ export type PluginCandidate = { packageManifest?: OpenClawPackageManifest; packageDependencies?: PluginDependencySpecMap; packageOptionalDependencies?: PluginDependencySpecMap; + bundledManifestId?: string; bundledManifest?: PluginManifest; bundledManifestPath?: string; rawPackageManifest?: PackageManifest; @@ -628,6 +629,7 @@ function addCandidate(params: { workspaceDir?: string; manifest?: PackageManifest | null; packageDir?: string; + bundledManifestId?: string; bundledManifest?: PluginManifest; bundledManifestPath?: string; realpathCache: Map; @@ -675,6 +677,7 @@ function addCandidate(params: { packageDependencies: packageDependencies.dependencies, packageOptionalDependencies: packageDependencies.optionalDependencies, rawPackageManifest: manifest ?? undefined, + bundledManifestId: params.bundledManifestId, bundledManifest: params.bundledManifest, bundledManifestPath: params.bundledManifestPath, }); @@ -731,6 +734,8 @@ function discoverBundleInRoot(params: { workspaceDir: params.workspaceDir, manifest: params.manifest, packageDir: params.rootDir, + bundledManifestId: bundleManifest.manifest.id, + bundledManifestPath: bundleManifest.manifestPath, realpathCache: params.realpathCache, }); return "added"; diff --git a/src/plugins/installed-plugin-index-record-reader.ts b/src/plugins/installed-plugin-index-record-reader.ts index 7b172a74fdb..ba2a0c2f76e 100644 --- a/src/plugins/installed-plugin-index-record-reader.ts +++ b/src/plugins/installed-plugin-index-record-reader.ts @@ -80,6 +80,12 @@ function readManifestPluginId(packageDir: string): string | undefined { return id || undefined; } +function resolveRecoveredManagedNpmRoot(options: InstalledPluginIndexStoreOptions = {}): string { + return path.resolve( + options.stateDir ? path.join(options.stateDir, "npm") : resolveDefaultPluginNpmDir(options.env), + ); +} + function resolveRecoveredManagedNpmPluginId(params: { packageName: string; packageDir: string; @@ -99,9 +105,7 @@ function resolveRecoveredManagedNpmPluginId(params: { function buildRecoveredManagedNpmInstallRecords( options: InstalledPluginIndexStoreOptions = {}, ): Record { - const npmRoot = options.stateDir - ? path.join(options.stateDir, "npm") - : resolveDefaultPluginNpmDir(options.env); + const npmRoot = resolveRecoveredManagedNpmRoot(options); const rootManifest = readJsonObjectFileSync(path.join(npmRoot, "package.json")); const dependencies = readStringRecord(rootManifest?.dependencies); const records: Record = {}; @@ -229,24 +233,90 @@ export function readPersistedInstalledPluginIndexInstallRecordsSync( return extractPluginInstallRecordsFromPersistedInstalledPluginIndex(parsed); } +type InstallRecordsCacheEntry = { + records: Record; + signature: string; +}; + +const installRecordsCache = new Map(); + +function readFileSignature(filePath: string): string { + try { + const stat = fs.statSync(filePath); + return `${stat.mtimeMs}:${stat.size}`; + } catch { + return "missing"; + } +} + +function resolveInstallRecordsCacheKey(options: InstalledPluginIndexStoreOptions): string { + return [ + path.resolve(resolveInstalledPluginIndexStorePath(options)), + resolveRecoveredManagedNpmRoot(options), + ].join("\0"); +} + +function resolveManagedNpmInstallSignature(options: InstalledPluginIndexStoreOptions): string { + const npmRoot = resolveRecoveredManagedNpmRoot(options); + const rootManifestPath = path.join(npmRoot, "package.json"); + const rootManifest = readJsonObjectFileSync(rootManifestPath); + const dependencies = readStringRecord(rootManifest?.dependencies); + const packageSignatures = Object.keys(dependencies).map((packageName) => { + const packageDir = path.join(npmRoot, "node_modules", packageName); + return [ + packageName, + readFileSignature(path.join(packageDir, "package.json")), + readFileSignature(path.join(packageDir, "openclaw.plugin.json")), + ].join(":"); + }); + return [readFileSignature(rootManifestPath), ...packageSignatures].join("\0"); +} + +function resolveInstallRecordsCacheSignature(options: InstalledPluginIndexStoreOptions): string { + return [ + readFileSignature(path.resolve(resolveInstalledPluginIndexStorePath(options))), + resolveManagedNpmInstallSignature(options), + ].join("\0"); +} + +export function clearLoadInstalledPluginIndexInstallRecordsCache(): void { + installRecordsCache.clear(); +} + export async function loadInstalledPluginIndexInstallRecords( params: InstalledPluginIndexStoreOptions = {}, ): Promise> { - return cloneInstallRecords( + const cacheKey = resolveInstallRecordsCacheKey(params); + const signature = resolveInstallRecordsCacheSignature(params); + const cached = installRecordsCache.get(cacheKey); + if (cached?.signature === signature) { + return cloneInstallRecords(cached.records); + } + const records = cloneInstallRecords( mergeRecoveredManagedNpmInstallRecords( await readPersistedInstalledPluginIndexInstallRecords(params), params, ), ); + installRecordsCache.set(cacheKey, { records, signature }); + return cloneInstallRecords(records); } export function loadInstalledPluginIndexInstallRecordsSync( params: InstalledPluginIndexStoreOptions = {}, ): Record { - return cloneInstallRecords( + const cacheKey = resolveInstallRecordsCacheKey(params); + const signature = resolveInstallRecordsCacheSignature(params); + const cached = installRecordsCache.get(cacheKey); + if (cached?.signature === signature) { + return cloneInstallRecords(cached.records); + } + const records = cloneInstallRecords( mergeRecoveredManagedNpmInstallRecords( readPersistedInstalledPluginIndexInstallRecordsSync(params), params, ), ); + installRecordsCache.set(cacheKey, { records, signature }); + return cloneInstallRecords(records); } diff --git a/src/plugins/installed-plugin-index-records.test.ts b/src/plugins/installed-plugin-index-records.test.ts index d044565d14d..bc65ba1b86a 100644 --- a/src/plugins/installed-plugin-index-records.test.ts +++ b/src/plugins/installed-plugin-index-records.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from "vitest"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import type { PluginCandidate } from "./discovery.js"; import { + clearLoadInstalledPluginIndexInstallRecordsCache, loadInstalledPluginIndexInstallRecords, loadInstalledPluginIndexInstallRecordsSync, readPersistedInstalledPluginIndexInstallRecords, @@ -13,6 +14,7 @@ import { resolveInstalledPluginIndexRecordsStorePath, withoutPluginInstallRecords, writePersistedInstalledPluginIndexInstallRecords, + writePersistedInstalledPluginIndexInstallRecordsSync, } from "./installed-plugin-index-records.js"; import { writeManagedNpmPlugin } from "./test-helpers/managed-npm-plugin.js"; @@ -57,6 +59,7 @@ function expectRecordFields(record: unknown, expected: Record) } afterEach(() => { + clearLoadInstalledPluginIndexInstallRecordsCache(); for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } @@ -170,6 +173,111 @@ describe("plugin index install records store", () => { }); }); + it("returns cloned cached records", async () => { + const stateDir = makeStateDir(); + const candidate = createPluginCandidate(stateDir, "cached"); + await writePersistedInstalledPluginIndexInstallRecords( + { + cached: { + source: "npm", + spec: "cached@1.0.0", + }, + }, + { stateDir, candidates: [candidate] }, + ); + + const first = loadInstalledPluginIndexInstallRecordsSync({ stateDir }); + first.cached.spec = "mutated@1.0.0"; + + expect(loadInstalledPluginIndexInstallRecordsSync({ stateDir })).toEqual({ + cached: { + source: "npm", + spec: "cached@1.0.0", + }, + }); + }); + + it("invalidates cached records when the persisted index is rewritten", () => { + const stateDir = makeStateDir(); + const first = createPluginCandidate(stateDir, "first"); + writePersistedInstalledPluginIndexInstallRecordsSync( + { + first: { + source: "npm", + spec: "first@1.0.0", + }, + }, + { stateDir, candidates: [first] }, + ); + expect(loadInstalledPluginIndexInstallRecordsSync({ stateDir })).toEqual({ + first: { + source: "npm", + spec: "first@1.0.0", + }, + }); + + const second = createPluginCandidate(stateDir, "second"); + writePersistedInstalledPluginIndexInstallRecordsSync( + { + second: { + source: "npm", + spec: "second@1.0.0", + }, + }, + { stateDir, candidates: [second] }, + ); + + expect(loadInstalledPluginIndexInstallRecordsSync({ stateDir })).toEqual({ + second: { + source: "npm", + spec: "second@1.0.0", + }, + }); + }); + + it("reloads cached records after an external index write", () => { + const stateDir = makeStateDir(); + const candidate = createPluginCandidate(stateDir, "external"); + writePersistedInstalledPluginIndexInstallRecordsSync( + { + external: { + source: "npm", + spec: "external@1.0.0", + }, + }, + { stateDir, candidates: [candidate] }, + ); + expect(loadInstalledPluginIndexInstallRecordsSync({ stateDir })).toEqual({ + external: { + source: "npm", + spec: "external@1.0.0", + }, + }); + + const indexPath = resolveInstalledPluginIndexRecordsStorePath({ stateDir }); + const persisted = JSON.parse(fs.readFileSync(indexPath, "utf8")) as Record; + fs.writeFileSync( + indexPath, + JSON.stringify({ + ...persisted, + installRecords: { + external: { + source: "npm", + spec: "external-plugin@2.0.0", + }, + }, + }), + "utf8", + ); + + expect(loadInstalledPluginIndexInstallRecordsSync({ stateDir })).toEqual({ + external: { + source: "npm", + spec: "external-plugin@2.0.0", + }, + }); + }); + it("reads legacy persisted records when the plugin index has no plugin list", async () => { const stateDir = makeStateDir(); const indexPath = resolveInstalledPluginIndexRecordsStorePath({ stateDir }); @@ -319,6 +427,49 @@ describe("plugin index install records store", () => { }); }); + it("reloads recovered managed npm records after package manifest changes", () => { + const stateDir = makeStateDir(); + const codexDir = writeManagedNpmPlugin({ + stateDir, + packageName: "@openclaw/codex", + pluginId: "codex", + version: "2026.5.18-beta.1", + }); + const indexPath = resolveInstalledPluginIndexRecordsStorePath({ stateDir }); + fs.mkdirSync(path.dirname(indexPath), { recursive: true }); + fs.writeFileSync(indexPath, JSON.stringify({ installRecords: {}, plugins: [] }), "utf8"); + + expectRecordFields(loadInstalledPluginIndexInstallRecordsSync({ stateDir }).codex, { + source: "npm", + spec: "@openclaw/codex@2026.5.18-beta.1", + installPath: codexDir, + version: "2026.5.18-beta.1", + }); + + const packagePath = path.join(codexDir, "package.json"); + const packageManifest = JSON.parse(fs.readFileSync(packagePath, "utf8")) as Record< + string, + unknown + >; + fs.writeFileSync( + packagePath, + JSON.stringify({ + ...packageManifest, + version: "2026.5.19-beta.1", + }), + "utf8", + ); + + expectRecordFields(loadInstalledPluginIndexInstallRecordsSync({ stateDir }).codex, { + source: "npm", + spec: "@openclaw/codex@2026.5.18-beta.1", + installPath: codexDir, + version: "2026.5.19-beta.1", + resolvedVersion: "2026.5.19-beta.1", + resolvedSpec: "@openclaw/codex@2026.5.19-beta.1", + }); + }); + it("preserves git install resolution fields in persisted records", async () => { const stateDir = makeStateDir(); const candidate = createPluginCandidate(stateDir, "git-demo"); diff --git a/src/plugins/installed-plugin-index-records.ts b/src/plugins/installed-plugin-index-records.ts index 9140a5f8107..3e395baa2c2 100644 --- a/src/plugins/installed-plugin-index-records.ts +++ b/src/plugins/installed-plugin-index-records.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { + clearLoadInstalledPluginIndexInstallRecordsCache, loadInstalledPluginIndexInstallRecords, loadInstalledPluginIndexInstallRecordsSync, readPersistedInstalledPluginIndexInstallRecords, @@ -15,6 +16,7 @@ import { type RefreshInstalledPluginIndexParams } from "./installed-plugin-index import { recordPluginInstall, type PluginInstallUpdate } from "./installs.js"; export { + clearLoadInstalledPluginIndexInstallRecordsCache, loadInstalledPluginIndexInstallRecords, loadInstalledPluginIndexInstallRecordsSync, readPersistedInstalledPluginIndexInstallRecords, diff --git a/src/plugins/installed-plugin-index-store.ts b/src/plugins/installed-plugin-index-store.ts index 3c3f9707956..87f652e114c 100644 --- a/src/plugins/installed-plugin-index-store.ts +++ b/src/plugins/installed-plugin-index-store.ts @@ -9,6 +9,7 @@ import { clearCurrentPluginMetadataSnapshotState } from "./current-plugin-metada import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js"; import { hashJson } from "./installed-plugin-index-hash.js"; import { resolveCompatRegistryVersion } from "./installed-plugin-index-policy.js"; +import { clearLoadInstalledPluginIndexInstallRecordsCache } from "./installed-plugin-index-record-reader.js"; import { resolveInstalledPluginIndexStorePath, type InstalledPluginIndexStoreOptions, @@ -187,6 +188,7 @@ export async function writePersistedInstalledPluginIndex( }, ); clearCurrentPluginMetadataSnapshotState(); + clearLoadInstalledPluginIndexInstallRecordsCache(); return filePath; } @@ -197,6 +199,7 @@ export function writePersistedInstalledPluginIndexSync( const filePath = resolveInstalledPluginIndexStorePath(options); saveJsonFile(filePath, { ...index, warning: INSTALLED_PLUGIN_INDEX_WARNING }); clearCurrentPluginMetadataSnapshotState(); + clearLoadInstalledPluginIndexInstallRecordsCache(); return filePath; }