mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:20:42 +00:00
feat(plugins): prefer persisted registry reads
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user