feat(plugins): prefer persisted registry reads

This commit is contained in:
Vincent Koc
2026-04-25 05:09:02 -07:00
parent a7de722f4f
commit 521e75dea0
4 changed files with 196 additions and 7 deletions

View File

@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
@@ -35,6 +36,15 @@ export async function readJsonFile<T>(filePath: string): Promise<T | null> {
}
}
export function readJsonFileSync<T>(filePath: string): T | null {
try {
const raw = readFileSync(filePath, "utf8");
return JSON.parse(raw) as T;
} catch {
return null;
}
}
export async function writeJsonAtomic(
filePath: string,
value: unknown,

View File

@@ -1,7 +1,7 @@
import path from "node:path";
import { z } from "zod";
import { resolveStateDir } from "../config/paths.js";
import { readJsonFile, writeJsonAtomic } from "../infra/json-files.js";
import { readJsonFile, readJsonFileSync, writeJsonAtomic } from "../infra/json-files.js";
import { safeParseWithSchema } from "../utils/zod-parse.js";
import {
diffInstalledPluginIndexInvalidationReasons,
@@ -117,6 +117,13 @@ export async function readPersistedInstalledPluginIndex(
return parseInstalledPluginIndex(parsed);
}
export function readPersistedInstalledPluginIndexSync(
options: InstalledPluginIndexStoreOptions = {},
): InstalledPluginIndex | null {
const parsed = readJsonFileSync<unknown>(resolveInstalledPluginIndexStorePath(options));
return parseInstalledPluginIndex(parsed);
}
export async function writePersistedInstalledPluginIndex(
index: InstalledPluginIndex,
options: InstalledPluginIndexStoreOptions = {},

View File

@@ -2,13 +2,17 @@ import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { PluginCandidate } from "./discovery.js";
import { writePersistedInstalledPluginIndex } from "./installed-plugin-index-store.js";
import type { InstalledPluginIndex } from "./installed-plugin-index.js";
import {
DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV,
getPluginRecord,
inspectPluginRegistry,
isPluginEnabled,
listPluginContributionIds,
listPluginRecords,
loadPluginRegistrySnapshot,
loadPluginRegistrySnapshotWithMetadata,
refreshPluginRegistry,
resolveChannelOwners,
resolveCliBackendOwners,
@@ -85,6 +89,39 @@ function createCandidate(rootDir: string): PluginCandidate {
};
}
function createIndex(pluginId = "demo"): InstalledPluginIndex {
return {
version: 1,
hostContractVersion: "2026.4.25",
compatRegistryVersion: "compat-v1",
migrationVersion: 1,
policyHash: "policy-v1",
generatedAtMs: 1777118400000,
plugins: [
{
pluginId,
manifestPath: `/plugins/${pluginId}/openclaw.plugin.json`,
manifestHash: "manifest-hash",
rootDir: `/plugins/${pluginId}`,
origin: "global",
enabled: true,
contributions: {
providers: [pluginId],
channels: [],
channelConfigs: [],
setupProviders: [],
cliBackends: [],
modelCatalogProviders: [],
commandAliases: [],
contracts: [],
},
compat: [],
},
],
diagnostics: [],
};
}
describe("plugin registry facade", () => {
it("resolves cold plugin records and contribution owners without loading runtime", () => {
const rootDir = makeTempDir();
@@ -92,6 +129,7 @@ describe("plugin registry facade", () => {
const index = loadPluginRegistrySnapshot({
candidates: [candidate],
env: hermeticEnv(),
preferPersisted: false,
});
expect(listPluginRecords({ index }).map((plugin) => plugin.pluginId)).toEqual(["demo"]);
@@ -129,6 +167,7 @@ describe("plugin registry facade", () => {
},
},
env: hermeticEnv(),
preferPersisted: false,
});
expect(getPluginRecord({ index, pluginId: "demo" })).toMatchObject({
@@ -151,6 +190,66 @@ describe("plugin registry facade", () => {
).toEqual(["demo"]);
});
it("reads the persisted registry before deriving from discovered candidates", async () => {
const stateDir = makeTempDir();
const rootDir = makeTempDir();
const candidate = createCandidate(rootDir);
await writePersistedInstalledPluginIndex(createIndex("persisted"), { stateDir });
const result = loadPluginRegistrySnapshotWithMetadata({
stateDir,
candidates: [candidate],
env: hermeticEnv(),
});
expect(result.source).toBe("persisted");
expect(result.diagnostics).toEqual([]);
expect(listPluginRecords({ index: result.snapshot }).map((plugin) => plugin.pluginId)).toEqual([
"persisted",
]);
});
it("falls back to the derived registry when the persisted registry is missing", () => {
const stateDir = makeTempDir();
const rootDir = makeTempDir();
const candidate = createCandidate(rootDir);
const result = loadPluginRegistrySnapshotWithMetadata({
stateDir,
candidates: [candidate],
env: hermeticEnv(),
});
expect(result.source).toBe("derived");
expect(result.diagnostics).toEqual([
expect.objectContaining({ code: "persisted-registry-missing" }),
]);
expect(listPluginRecords({ index: result.snapshot }).map((plugin) => plugin.pluginId)).toEqual([
"demo",
]);
});
it("falls back to the derived registry when persisted reads are disabled", async () => {
const stateDir = makeTempDir();
const rootDir = makeTempDir();
const candidate = createCandidate(rootDir);
await writePersistedInstalledPluginIndex(createIndex("persisted"), { stateDir });
const result = loadPluginRegistrySnapshotWithMetadata({
stateDir,
candidates: [candidate],
env: hermeticEnv({ [DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV]: "1" }),
});
expect(result.source).toBe("derived");
expect(result.diagnostics).toEqual([
expect.objectContaining({ code: "persisted-registry-disabled" }),
]);
expect(listPluginRecords({ index: result.snapshot }).map((plugin) => plugin.pluginId)).toEqual([
"demo",
]);
});
it("exposes explicit persisted registry inspect and refresh operations", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "plugins", "demo");

View File

@@ -1,7 +1,8 @@
import { normalizeProviderId } from "../agents/provider-id.js";
import type {
InstalledPluginIndexStoreInspection,
InstalledPluginIndexStoreOptions,
import {
readPersistedInstalledPluginIndexSync,
type InstalledPluginIndexStoreInspection,
type InstalledPluginIndexStoreOptions,
} from "./installed-plugin-index-store.js";
import {
getInstalledPluginRecord,
@@ -20,11 +21,31 @@ import {
export type PluginRegistrySnapshot = InstalledPluginIndex;
export type PluginRegistryRecord = InstalledPluginIndexRecord;
export type PluginRegistryInspection = InstalledPluginIndexStoreInspection;
export type PluginRegistrySnapshotSource = "provided" | "persisted" | "derived";
export type PluginRegistrySnapshotDiagnosticCode =
| "persisted-registry-disabled"
| "persisted-registry-missing";
export type LoadPluginRegistryParams = LoadInstalledPluginIndexParams & {
index?: PluginRegistrySnapshot;
export type PluginRegistrySnapshotDiagnostic = {
level: "info" | "warn";
code: PluginRegistrySnapshotDiagnosticCode;
message: string;
};
export type PluginRegistrySnapshotResult = {
snapshot: PluginRegistrySnapshot;
source: PluginRegistrySnapshotSource;
diagnostics: readonly PluginRegistrySnapshotDiagnostic[];
};
export const DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV = "OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY";
export type LoadPluginRegistryParams = LoadInstalledPluginIndexParams &
InstalledPluginIndexStoreOptions & {
index?: PluginRegistrySnapshot;
preferPersisted?: boolean;
};
export type PluginRegistryContributionOptions = LoadPluginRegistryParams & {
includeDisabled?: boolean;
};
@@ -62,8 +83,60 @@ function normalizeContributionId(value: string): string {
return value.trim();
}
function hasEnvFlag(env: NodeJS.ProcessEnv, name: string): boolean {
const value = env[name]?.trim().toLowerCase();
return Boolean(value && value !== "0" && value !== "false" && value !== "no");
}
export function loadPluginRegistrySnapshotWithMetadata(
params: LoadPluginRegistryParams = {},
): PluginRegistrySnapshotResult {
if (params.index) {
return {
snapshot: params.index,
source: "provided",
diagnostics: [],
};
}
const env = params.env ?? process.env;
const diagnostics: PluginRegistrySnapshotDiagnostic[] = [];
const disabledByCaller = params.preferPersisted === false;
const disabledByEnv = hasEnvFlag(env, DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV);
const persistedReadsEnabled = !disabledByCaller && !disabledByEnv;
if (persistedReadsEnabled) {
const persisted = readPersistedInstalledPluginIndexSync(params);
if (persisted) {
return {
snapshot: persisted,
source: "persisted",
diagnostics,
};
}
diagnostics.push({
level: "info",
code: "persisted-registry-missing",
message: "Persisted plugin registry is missing or invalid; using derived plugin index.",
});
} else {
diagnostics.push({
level: "warn",
code: "persisted-registry-disabled",
message: disabledByEnv
? `${DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV} is set; using legacy derived plugin index.`
: "Persisted plugin registry reads are disabled by the caller; using derived plugin index.",
});
}
return {
snapshot: loadInstalledPluginIndex(params),
source: "derived",
diagnostics,
};
}
function resolveSnapshot(params: LoadPluginRegistryParams = {}): PluginRegistrySnapshot {
return params.index ?? loadInstalledPluginIndex(params);
return loadPluginRegistrySnapshotWithMetadata(params).snapshot;
}
export function loadPluginRegistrySnapshot(