diff --git a/src/plugins/installed-plugin-index-store.test.ts b/src/plugins/installed-plugin-index-store.test.ts index 62d61f56a1c..56b946265f8 100644 --- a/src/plugins/installed-plugin-index-store.test.ts +++ b/src/plugins/installed-plugin-index-store.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { PluginCandidate } from "./discovery.js"; import { + inspectPersistedInstalledPluginIndex, readPersistedInstalledPluginIndex, refreshPersistedInstalledPluginIndex, resolveInstalledPluginIndexStorePath, @@ -111,6 +112,76 @@ describe("installed plugin index persistence", () => { await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toBeNull(); }); + it("inspects missing, fresh, and stale persisted index state without loading runtime", async () => { + const stateDir = makeTempDir(); + const pluginDir = path.join(stateDir, "plugins", "demo"); + fs.mkdirSync(pluginDir, { recursive: true }); + const candidate = createCandidate(pluginDir); + const env = { + OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", + OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", + OPENCLAW_VERSION: "2026.4.25", + VITEST: "true", + }; + + await expect( + inspectPersistedInstalledPluginIndex({ stateDir, candidates: [candidate], env }), + ).resolves.toMatchObject({ + state: "missing", + refreshReasons: ["missing"], + persisted: null, + current: { + plugins: [expect.objectContaining({ pluginId: "demo" })], + }, + }); + + const current = await refreshPersistedInstalledPluginIndex({ + reason: "manual", + stateDir, + candidates: [candidate], + env, + }); + + await expect( + inspectPersistedInstalledPluginIndex({ stateDir, candidates: [candidate], env }), + ).resolves.toMatchObject({ + state: "fresh", + refreshReasons: [], + persisted: current, + current: { + plugins: [expect.objectContaining({ pluginId: "demo" })], + }, + }); + + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "demo", + name: "Demo", + configSchema: { type: "object" }, + providers: ["demo", "demo-next"], + }), + "utf8", + ); + + await expect( + inspectPersistedInstalledPluginIndex({ stateDir, candidates: [candidate], env }), + ).resolves.toMatchObject({ + state: "stale", + refreshReasons: ["stale-manifest"], + persisted: current, + current: { + plugins: [ + expect.objectContaining({ + pluginId: "demo", + contributions: expect.objectContaining({ providers: ["demo", "demo-next"] }), + }), + ], + }, + }); + }); + it("refreshes and persists a rebuilt index without loading plugin runtime", async () => { const stateDir = makeTempDir(); const pluginDir = path.join(stateDir, "plugins", "demo"); diff --git a/src/plugins/installed-plugin-index-store.ts b/src/plugins/installed-plugin-index-store.ts index 5d73f76d236..0bfc602f60a 100644 --- a/src/plugins/installed-plugin-index-store.ts +++ b/src/plugins/installed-plugin-index-store.ts @@ -4,13 +4,17 @@ import { resolveStateDir } from "../config/paths.js"; import { readJsonFile, writeJsonAtomic } from "../infra/json-files.js"; import { safeParseWithSchema } from "../utils/zod-parse.js"; import { + diffInstalledPluginIndexInvalidationReasons, INSTALLED_PLUGIN_INDEX_VERSION, + loadInstalledPluginIndex, refreshInstalledPluginIndex, type InstalledPluginIndex, + type InstalledPluginIndexRefreshReason, + type LoadInstalledPluginIndexParams, type RefreshInstalledPluginIndexParams, } from "./installed-plugin-index.js"; -const INSTALLED_PLUGIN_INDEX_STORE_PATH = path.join("plugins", "installed-index.json"); +export const INSTALLED_PLUGIN_INDEX_STORE_PATH = path.join("plugins", "installed-index.json"); export type InstalledPluginIndexStoreOptions = { env?: NodeJS.ProcessEnv; @@ -18,6 +22,15 @@ export type InstalledPluginIndexStoreOptions = { filePath?: string; }; +export type InstalledPluginIndexStoreState = "missing" | "fresh" | "stale"; + +export type InstalledPluginIndexStoreInspection = { + state: InstalledPluginIndexStoreState; + refreshReasons: readonly InstalledPluginIndexRefreshReason[]; + persisted: InstalledPluginIndex | null; + current: InstalledPluginIndex; +}; + const ContributionArraySchema = z.array(z.string()); const InstalledPluginIndexContributionsSchema = z @@ -109,6 +122,29 @@ export async function writePersistedInstalledPluginIndex( return filePath; } +export async function inspectPersistedInstalledPluginIndex( + params: LoadInstalledPluginIndexParams & InstalledPluginIndexStoreOptions = {}, +): Promise { + const persisted = await readPersistedInstalledPluginIndex(params); + const current = loadInstalledPluginIndex(params); + if (!persisted) { + return { + state: "missing", + refreshReasons: ["missing"], + persisted: null, + current, + }; + } + + const refreshReasons = diffInstalledPluginIndexInvalidationReasons(persisted, current); + return { + state: refreshReasons.length > 0 ? "stale" : "fresh", + refreshReasons, + persisted, + current, + }; +} + export async function refreshPersistedInstalledPluginIndex( params: RefreshInstalledPluginIndexParams & InstalledPluginIndexStoreOptions, ): Promise {