feat(plugins): inspect persisted plugin index state

This commit is contained in:
Vincent Koc
2026-04-25 01:09:43 -07:00
parent 74a384d887
commit b001b8c947
2 changed files with 108 additions and 1 deletions

View File

@@ -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");

View File

@@ -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<InstalledPluginIndexStoreInspection> {
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<InstalledPluginIndex> {