mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix(plugins): preserve install index state across failures (#119228)
* fix(plugins): preserve install index policy config * fix(plugins): restore complete install index state * fix(plugins): fence install index rollback * fix(plugins): fence generic install index rollback * fix(plugins): keep lifecycle lease context private * test(cli): align plugin index rollback mocks * test(plugins): enforce index rollback receipts * fix(plugins): serialize install rollback with config commit * test(plugins): keep legacy index writer mock private
This commit is contained in:
@@ -6,6 +6,7 @@ import { getRuntimeConfig } from "../config/config.js";
|
||||
import type { HookInstallRecord } from "../config/types.hooks.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import type { InstalledPluginIndex } from "../plugins/installed-plugin-index.js";
|
||||
import type { CliMockOutputRuntime } from "./test-runtime-capture.js";
|
||||
|
||||
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
|
||||
@@ -27,6 +28,14 @@ type UpdateNpmInstalledPluginsFn =
|
||||
(typeof import("../plugins/update.js"))["updateNpmInstalledPlugins"];
|
||||
type UpdateNpmInstalledHookPacksFn =
|
||||
(typeof import("../hooks/update.js"))["updateNpmInstalledHookPacks"];
|
||||
type ReadPersistedInstalledPluginIndexFn =
|
||||
(typeof import("../plugins/installed-plugin-index-store.js"))["readPersistedInstalledPluginIndex"];
|
||||
type RestorePersistedInstalledPluginIndexIfCurrentFn =
|
||||
(typeof import("../plugins/installed-plugin-index-store.js"))["restorePersistedInstalledPluginIndexIfCurrent"];
|
||||
type WritePersistedInstalledPluginIndexInstallRecordsFn =
|
||||
(typeof import("../plugins/installed-plugin-index-records.js"))["writePersistedInstalledPluginIndexInstallRecords"];
|
||||
type WritePersistedInstalledPluginIndexInstallRecordsWithLeaseFn =
|
||||
(typeof import("../plugins/installed-plugin-index-records.js"))["writePersistedInstalledPluginIndexInstallRecordsWithLease"];
|
||||
type PluginInstallRecordMap = Record<string, PluginInstallRecord>;
|
||||
|
||||
function createEmptyUninstallActions() {
|
||||
@@ -45,6 +54,7 @@ function createEmptyUninstallActions() {
|
||||
|
||||
let mockInstalledPluginIndexInstallRecords: PluginInstallRecordMap = {};
|
||||
let mockHookInstallRecords: Record<string, HookInstallRecord> = {};
|
||||
let mockInstalledPluginIndexRevision = 0;
|
||||
|
||||
export function setHookInstallRecords(records: Record<string, HookInstallRecord>): void {
|
||||
mockHookInstallRecords = structuredClone(records);
|
||||
@@ -55,6 +65,24 @@ function clonePluginInstallRecords(records: PluginInstallRecordMap): PluginInsta
|
||||
return structuredClone(records);
|
||||
}
|
||||
|
||||
export function createTestInstalledPluginIndex(params: {
|
||||
policyHash: string;
|
||||
installRecords: PluginInstallRecordMap;
|
||||
}): InstalledPluginIndex {
|
||||
return {
|
||||
version: 1,
|
||||
hostContractVersion: "test",
|
||||
compatRegistryVersion: "test",
|
||||
migrationVersion: 1,
|
||||
policyHash: params.policyHash,
|
||||
generatedAtMs: 0,
|
||||
refreshReason: "source-changed",
|
||||
installRecords: clonePluginInstallRecords(params.installRecords),
|
||||
plugins: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Test helper preserves mock call and result types.
|
||||
function invokeMock<TArgs extends unknown[], TResult>(mock: unknown, ...args: TArgs): TResult {
|
||||
return (mock as (...args: TArgs) => TResult)(...args);
|
||||
@@ -78,13 +106,31 @@ export const recordPluginInstall: UnknownMock = vi.fn();
|
||||
const loadInstalledPluginIndexInstallRecords: AsyncUnknownMock = vi.fn(async () =>
|
||||
clonePluginInstallRecords(mockInstalledPluginIndexInstallRecords),
|
||||
);
|
||||
export const writePersistedInstalledPluginIndexInstallRecords: AsyncUnknownMock = vi.fn(
|
||||
async (records: unknown) => {
|
||||
const writePersistedInstalledPluginIndexInstallRecords: Mock<WritePersistedInstalledPluginIndexInstallRecordsFn> =
|
||||
vi.fn<WritePersistedInstalledPluginIndexInstallRecordsFn>(async (records) => {
|
||||
mockInstalledPluginIndexInstallRecords = clonePluginInstallRecords(records);
|
||||
return "/tmp/openclaw-state/openclaw.sqlite";
|
||||
});
|
||||
export const readPersistedInstalledPluginIndex: Mock<ReadPersistedInstalledPluginIndexFn> =
|
||||
vi.fn<ReadPersistedInstalledPluginIndexFn>(async () => null);
|
||||
export const writePersistedInstalledPluginIndexInstallRecordsWithLease: Mock<WritePersistedInstalledPluginIndexInstallRecordsWithLeaseFn> =
|
||||
vi.fn<WritePersistedInstalledPluginIndexInstallRecordsWithLeaseFn>(async (records) => {
|
||||
const previous = await readPersistedInstalledPluginIndex();
|
||||
mockInstalledPluginIndexInstallRecords = clonePluginInstallRecords(records);
|
||||
mockInstalledPluginIndexRevision += 1;
|
||||
return { previous, revision: mockInstalledPluginIndexRevision };
|
||||
});
|
||||
export const restorePersistedInstalledPluginIndexIfCurrent: Mock<RestorePersistedInstalledPluginIndexIfCurrentFn> =
|
||||
vi.fn<RestorePersistedInstalledPluginIndexIfCurrentFn>(async (index, expectedRevision) => {
|
||||
if (mockInstalledPluginIndexRevision !== expectedRevision) {
|
||||
return false;
|
||||
}
|
||||
mockInstalledPluginIndexInstallRecords = clonePluginInstallRecords(
|
||||
(records ?? {}) as PluginInstallRecordMap,
|
||||
(index?.installRecords ?? {}) as PluginInstallRecordMap,
|
||||
);
|
||||
},
|
||||
);
|
||||
mockInstalledPluginIndexRevision += 1;
|
||||
return true;
|
||||
});
|
||||
export const loadPluginManifestRegistry: UnknownMock = vi.fn();
|
||||
export const buildPluginSnapshotReport: UnknownMock = vi.fn();
|
||||
export const buildPluginRegistrySnapshotReport: UnknownMock = vi.fn();
|
||||
@@ -319,6 +365,11 @@ vi.mock("../plugins/installed-plugin-index-records.js", async (importOriginal) =
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
...args,
|
||||
)) as (...args: unknown[]) => unknown,
|
||||
writePersistedInstalledPluginIndexInstallRecordsWithLease: ((...args: unknown[]) =>
|
||||
invokeMock<unknown[], unknown>(
|
||||
writePersistedInstalledPluginIndexInstallRecordsWithLease,
|
||||
...args,
|
||||
)) as (...args: unknown[]) => unknown,
|
||||
recordPluginInstallInRecords: (
|
||||
records: Record<string, unknown>,
|
||||
update: { pluginId: string; installedAt?: string } & Record<string, unknown>,
|
||||
@@ -336,6 +387,22 @@ vi.mock("../plugins/installed-plugin-index-records.js", async (importOriginal) =
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../plugins/installed-plugin-index-store.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../plugins/installed-plugin-index-store.js")>();
|
||||
return {
|
||||
...actual,
|
||||
readPersistedInstalledPluginIndex: ((...args: unknown[]) =>
|
||||
invokeMock<unknown[], unknown>(readPersistedInstalledPluginIndex, ...args)) as (
|
||||
...args: unknown[]
|
||||
) => unknown,
|
||||
restorePersistedInstalledPluginIndexIfCurrent: ((...args: unknown[]) =>
|
||||
invokeMock<unknown[], unknown>(restorePersistedInstalledPluginIndexIfCurrent, ...args)) as (
|
||||
...args: unknown[]
|
||||
) => unknown,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../plugins/manifest-registry.js", () => ({
|
||||
loadPluginManifestRegistry: ((...args: unknown[]) =>
|
||||
invokeMock<unknown[], unknown>(loadPluginManifestRegistry, ...args)) as (
|
||||
@@ -751,8 +818,12 @@ export function resetPluginsCliTestState() {
|
||||
enablePluginInConfig.mockReset();
|
||||
recordPluginInstall.mockReset();
|
||||
mockInstalledPluginIndexInstallRecords = {};
|
||||
mockInstalledPluginIndexRevision = 0;
|
||||
loadInstalledPluginIndexInstallRecords.mockReset();
|
||||
writePersistedInstalledPluginIndexInstallRecords.mockReset();
|
||||
writePersistedInstalledPluginIndexInstallRecordsWithLease.mockReset();
|
||||
readPersistedInstalledPluginIndex.mockReset();
|
||||
restorePersistedInstalledPluginIndexIfCurrent.mockReset();
|
||||
loadPluginManifestRegistry.mockReset();
|
||||
buildPluginSnapshotReport.mockReset();
|
||||
buildPluginRegistrySnapshotReport.mockReset();
|
||||
@@ -837,11 +908,29 @@ export function resetPluginsCliTestState() {
|
||||
loadInstalledPluginIndexInstallRecords.mockImplementation(async () =>
|
||||
clonePluginInstallRecords(mockInstalledPluginIndexInstallRecords),
|
||||
);
|
||||
writePersistedInstalledPluginIndexInstallRecords.mockImplementation(async (records: unknown) => {
|
||||
mockInstalledPluginIndexInstallRecords = clonePluginInstallRecords(
|
||||
(records ?? {}) as PluginInstallRecordMap,
|
||||
);
|
||||
writePersistedInstalledPluginIndexInstallRecords.mockImplementation(async (records) => {
|
||||
mockInstalledPluginIndexInstallRecords = clonePluginInstallRecords(records);
|
||||
return "/tmp/openclaw-state/openclaw.sqlite";
|
||||
});
|
||||
readPersistedInstalledPluginIndex.mockResolvedValue(null);
|
||||
writePersistedInstalledPluginIndexInstallRecordsWithLease.mockImplementation(async (records) => {
|
||||
const previous = await readPersistedInstalledPluginIndex();
|
||||
mockInstalledPluginIndexInstallRecords = clonePluginInstallRecords(records);
|
||||
mockInstalledPluginIndexRevision += 1;
|
||||
return { previous, revision: mockInstalledPluginIndexRevision };
|
||||
});
|
||||
restorePersistedInstalledPluginIndexIfCurrent.mockImplementation(
|
||||
async (index, expectedRevision) => {
|
||||
if (mockInstalledPluginIndexRevision !== expectedRevision) {
|
||||
return false;
|
||||
}
|
||||
mockInstalledPluginIndexInstallRecords = clonePluginInstallRecords(
|
||||
(index?.installRecords ?? {}) as PluginInstallRecordMap,
|
||||
);
|
||||
mockInstalledPluginIndexRevision += 1;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
loadPluginManifestRegistry.mockReturnValue({
|
||||
plugins: [],
|
||||
diagnostics: [],
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
runtimeErrors,
|
||||
runtimeLogs,
|
||||
writeConfigFile,
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
writePersistedInstalledPluginIndexInstallRecordsWithLease,
|
||||
} from "./plugins-cli-test-helpers.js";
|
||||
|
||||
const CLI_STATE_ROOT = "/tmp/openclaw-state";
|
||||
@@ -430,10 +430,10 @@ function hookNpmInstallCall(callIndex = 0): PluginInstallCall {
|
||||
}
|
||||
|
||||
function persistedInstallRecords(callIndex = 0): Record<string, PersistedInstallRecord> {
|
||||
return mockCallArg(writePersistedInstalledPluginIndexInstallRecords, callIndex) as Record<
|
||||
string,
|
||||
PersistedInstallRecord
|
||||
>;
|
||||
return mockCallArg(
|
||||
writePersistedInstalledPluginIndexInstallRecordsWithLease,
|
||||
callIndex,
|
||||
) as Record<string, PersistedInstallRecord>;
|
||||
}
|
||||
|
||||
function persistedInstallRecord(pluginId: string, callIndex = 0): PersistedInstallRecord {
|
||||
|
||||
@@ -10,19 +10,22 @@ import {
|
||||
applyPluginUninstallDirectoryRemoval,
|
||||
buildPluginDiagnosticsReport,
|
||||
buildPluginSnapshotReport,
|
||||
createTestInstalledPluginIndex,
|
||||
loadConfig,
|
||||
planPluginUninstall,
|
||||
PromptInputClosedError,
|
||||
promptYesNo,
|
||||
readPersistedInstalledPluginIndex,
|
||||
refreshPluginRegistry,
|
||||
replaceConfigFile,
|
||||
resetPluginsCliTestState,
|
||||
restorePersistedInstalledPluginIndexIfCurrent,
|
||||
runPluginsCommand,
|
||||
runtimeErrors,
|
||||
runtimeLogs,
|
||||
setInstalledPluginIndexInstallRecords,
|
||||
writeConfigFile,
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
writePersistedInstalledPluginIndexInstallRecordsWithLease,
|
||||
} from "./plugins-cli-test-helpers.js";
|
||||
|
||||
const CLI_STATE_ROOT = "/tmp/openclaw-state";
|
||||
@@ -34,6 +37,17 @@ function expectRuntimeLogIncludes(fragment: string) {
|
||||
expect(runtimeLogs.join("\n")).toContain(fragment);
|
||||
}
|
||||
|
||||
function expectInstallRecordsWrittenWithLease(records: unknown, config: unknown) {
|
||||
expect(writePersistedInstalledPluginIndexInstallRecordsWithLease).toHaveBeenCalledWith(
|
||||
records,
|
||||
expect.objectContaining({
|
||||
config,
|
||||
filePath: expect.any(String),
|
||||
lease: expect.anything(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function expectLatestUninstallPlanParams(expected: {
|
||||
pluginId: string;
|
||||
deleteFiles: boolean;
|
||||
@@ -186,7 +200,7 @@ describe("plugins cli uninstall", () => {
|
||||
|
||||
expect(promptYesNo).not.toHaveBeenCalled();
|
||||
expectLatestUninstallPlanParams({ pluginId: "alpha", deleteFiles: false });
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({});
|
||||
expectInstallRecordsWrittenWithLease({}, { plugins: { entries: {} } });
|
||||
expect(writeConfigFile).toHaveBeenCalledWith({
|
||||
plugins: {
|
||||
entries: {},
|
||||
@@ -267,9 +281,16 @@ describe("plugins cli uninstall", () => {
|
||||
await runPluginsCommand(["plugins", "uninstall", "calendar", "--force", "--keep-files"]);
|
||||
|
||||
expectLatestUninstallPlanParams({ pluginId: "calendar", deleteFiles: false });
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
|
||||
"unrelated-plugin": { source: "npm", spec: "unrelated-plugin@1.0.0" },
|
||||
});
|
||||
expectInstallRecordsWrittenWithLease(
|
||||
{
|
||||
"unrelated-plugin": { source: "npm", spec: "unrelated-plugin@1.0.0" },
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
entries: { "unrelated-plugin": { enabled: true } },
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an ambiguous display name before planning or mutating installed plugins", async () => {
|
||||
@@ -304,7 +325,7 @@ describe("plugins cli uninstall", () => {
|
||||
expect(planPluginUninstall).not.toHaveBeenCalled();
|
||||
expect(promptYesNo).not.toHaveBeenCalled();
|
||||
expect(applyPluginUninstallDirectoryRemoval).not.toHaveBeenCalled();
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
|
||||
expect(writePersistedInstalledPluginIndexInstallRecordsWithLease).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(refreshPluginRegistry).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -368,7 +389,7 @@ describe("plugins cli uninstall", () => {
|
||||
|
||||
expectRuntimeLogIncludes('Warning: plugin "alpha" is referenced by Claw: @owner/audit-claw.');
|
||||
expectRuntimeLogIncludes("Uninstalling it may break those Claws");
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({});
|
||||
expectInstallRecordsWrittenWithLease({}, { plugins: { entries: {} } });
|
||||
} finally {
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
@@ -424,7 +445,7 @@ describe("plugins cli uninstall", () => {
|
||||
expect(runtimeErrors).toContain(
|
||||
"Error: plugins uninstall requires confirmation input. Re-run in an interactive TTY or pass --force.",
|
||||
);
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
|
||||
expect(writePersistedInstalledPluginIndexInstallRecordsWithLease).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(refreshPluginRegistry).not.toHaveBeenCalled();
|
||||
expect(applyPluginUninstallDirectoryRemoval).not.toHaveBeenCalled();
|
||||
@@ -452,9 +473,14 @@ describe("plugins cli uninstall", () => {
|
||||
installs: {},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const previousPersistedIndex = createTestInstalledPluginIndex({
|
||||
policyHash: "previous-policy",
|
||||
installRecords,
|
||||
});
|
||||
|
||||
loadConfig.mockReturnValue(baseConfig);
|
||||
setInstalledPluginIndexInstallRecords(installRecords);
|
||||
readPersistedInstalledPluginIndex.mockResolvedValue(previousPersistedIndex);
|
||||
buildPluginSnapshotReport.mockReturnValue({
|
||||
plugins: [{ id: "alpha", name: "alpha" }],
|
||||
diagnostics: [],
|
||||
@@ -480,10 +506,14 @@ describe("plugins cli uninstall", () => {
|
||||
runPluginsCommand(["plugins", "uninstall", "alpha", "--force", "--keep-files"]),
|
||||
).rejects.toThrow("config changed");
|
||||
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith(1, {});
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
installRecords,
|
||||
expectInstallRecordsWrittenWithLease({}, { plugins: { entries: {} } });
|
||||
expect(restorePersistedInstalledPluginIndexIfCurrent).toHaveBeenCalledWith(
|
||||
previousPersistedIndex,
|
||||
expect.any(Number),
|
||||
expect.objectContaining({
|
||||
filePath: expect.any(String),
|
||||
lease: expect.anything(),
|
||||
}),
|
||||
);
|
||||
expect(refreshPluginRegistry).not.toHaveBeenCalled();
|
||||
expect(applyPluginUninstallDirectoryRemoval).not.toHaveBeenCalled();
|
||||
@@ -613,7 +643,7 @@ describe("plugins cli uninstall", () => {
|
||||
installs: installRecords,
|
||||
},
|
||||
});
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
|
||||
expect(writePersistedInstalledPluginIndexInstallRecordsWithLease).not.toHaveBeenCalled();
|
||||
expect(refreshPluginRegistry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -762,7 +792,14 @@ describe("plugins cli uninstall", () => {
|
||||
await runPluginsCommand(["plugins", "uninstall", pluginId, "--force", "--keep-files"]);
|
||||
|
||||
expectLatestUninstallPlanParams({ pluginId, channelIds, deleteFiles: false });
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({});
|
||||
expectInstallRecordsWrittenWithLease(
|
||||
{},
|
||||
expect.objectContaining({
|
||||
channels: Object.fromEntries(
|
||||
Object.entries(channels).filter(([channelId]) => !channelIds.includes(channelId)),
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channels: Object.fromEntries(
|
||||
@@ -837,7 +874,7 @@ describe("plugins cli uninstall", () => {
|
||||
channelIds: undefined,
|
||||
deleteFiles: false,
|
||||
});
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({});
|
||||
expectInstallRecordsWrittenWithLease({}, nextConfig);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(nextConfig);
|
||||
expectRuntimeLogIncludes("channel config (channels.alpha)");
|
||||
expect(runtimeLogs.at(-2)).toContain('Uninstalled plugin "alpha"');
|
||||
|
||||
@@ -8,13 +8,16 @@ import { resolveRegistryUpdateChannel } from "../infra/update-channels.js";
|
||||
import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub-error-codes.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import {
|
||||
createTestInstalledPluginIndex,
|
||||
loadConfig,
|
||||
notifyGatewayPluginMetadataChanged,
|
||||
readConfigFileSnapshotForWrite,
|
||||
readPersistedInstalledPluginIndex,
|
||||
refreshPluginRegistry,
|
||||
registerPluginsCli,
|
||||
replaceConfigFile,
|
||||
resetPluginsCliTestState,
|
||||
restorePersistedInstalledPluginIndexIfCurrent,
|
||||
runPluginsCommand,
|
||||
runtimeErrors,
|
||||
runtimeLogs,
|
||||
@@ -23,7 +26,7 @@ import {
|
||||
updateNpmInstalledHookPacks,
|
||||
updateNpmInstalledPlugins,
|
||||
writeConfigFile,
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
writePersistedInstalledPluginIndexInstallRecordsWithLease,
|
||||
} from "./plugins-cli-test-helpers.js";
|
||||
|
||||
const ORIGINAL_OPENCLAW_NIX_MODE = process.env.OPENCLAW_NIX_MODE;
|
||||
@@ -81,6 +84,17 @@ function expectRestartNoticeLogged() {
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
function expectInstallRecordsWrittenWithLease(records: unknown, config: unknown) {
|
||||
expect(writePersistedInstalledPluginIndexInstallRecordsWithLease).toHaveBeenCalledWith(
|
||||
records,
|
||||
expect.objectContaining({
|
||||
config,
|
||||
filePath: expect.any(String),
|
||||
lease: expect.anything(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function expectSingleCallParams(mockFn: ReturnType<typeof vi.fn>) {
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
const params = mockFn.mock.calls[0]?.[0] as Record<string, unknown> | undefined;
|
||||
@@ -218,7 +232,7 @@ async function expectSkippedClawHubPluginUpdate(params: {
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "update", "demo"])).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
|
||||
expect(writePersistedInstalledPluginIndexInstallRecordsWithLease).not.toHaveBeenCalled();
|
||||
expect(runtimeLogs.at(-1)).toContain(params.expectedLog);
|
||||
}
|
||||
|
||||
@@ -301,7 +315,7 @@ describe("plugins cli update", () => {
|
||||
expect(acquireLease).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(replaceConfigFile).not.toHaveBeenCalled();
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
|
||||
expect(writePersistedInstalledPluginIndexInstallRecordsWithLease).not.toHaveBeenCalled();
|
||||
expect(refreshPluginRegistry).not.toHaveBeenCalled();
|
||||
expect(runtimeLogs).toContain("Would update alpha: 1.0.0 -> 1.1.0.");
|
||||
} finally {
|
||||
@@ -591,9 +605,7 @@ describe("plugins cli update", () => {
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
expect(updateNpmInstalledPlugins).toHaveBeenCalledOnce();
|
||||
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
|
||||
nextConfig.plugins?.installs,
|
||||
);
|
||||
expectInstallRecordsWrittenWithLease(nextConfig.plugins?.installs, cfg);
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -632,7 +644,7 @@ describe("plugins cli update", () => {
|
||||
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
expect(updateNpmInstalledPlugins).toHaveBeenCalledOnce();
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(pluginRecords);
|
||||
expectInstallRecordsWrittenWithLease(pluginRecords, cfg);
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -671,7 +683,7 @@ describe("plugins cli update", () => {
|
||||
await runPluginsCommand(["plugins", "update", "brave"]);
|
||||
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(nextRecords);
|
||||
expectInstallRecordsWrittenWithLease(nextRecords, sourceCfg);
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(replaceConfigFile).not.toHaveBeenCalled();
|
||||
expect(refreshPluginRegistry).toHaveBeenCalledWith({
|
||||
@@ -722,7 +734,11 @@ describe("plugins cli update", () => {
|
||||
|
||||
await runPluginsCommand(["plugins", "update", "brave"]);
|
||||
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(nextRecords);
|
||||
expectInstallRecordsWrittenWithLease(nextRecords, {
|
||||
plugins: {
|
||||
load: { paths: [nextInstallPath, customPath] },
|
||||
},
|
||||
});
|
||||
expect(replaceConfigFile).toHaveBeenCalledWith({
|
||||
nextConfig: {
|
||||
plugins: {
|
||||
@@ -783,18 +799,24 @@ describe("plugins cli update", () => {
|
||||
.mockResolvedValueOnce(initialSnapshot)
|
||||
.mockResolvedValueOnce(changedSnapshot);
|
||||
const { previousRecords, nextRecords } = primeBravePluginRecordUpdate(cfg);
|
||||
const previousPersistedIndex = createTestInstalledPluginIndex({
|
||||
policyHash: "previous-policy",
|
||||
installRecords: previousRecords,
|
||||
});
|
||||
readPersistedInstalledPluginIndex.mockResolvedValue(previousPersistedIndex);
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "update", "brave"])).rejects.toThrow(
|
||||
"config changed since last load",
|
||||
);
|
||||
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
nextRecords,
|
||||
);
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
previousRecords,
|
||||
expectInstallRecordsWrittenWithLease(nextRecords, cfg);
|
||||
expect(restorePersistedInstalledPluginIndexIfCurrent).toHaveBeenCalledWith(
|
||||
previousPersistedIndex,
|
||||
expect.any(Number),
|
||||
expect.objectContaining({
|
||||
filePath: expect.any(String),
|
||||
lease: expect.anything(),
|
||||
}),
|
||||
);
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(replaceConfigFile).not.toHaveBeenCalled();
|
||||
@@ -839,18 +861,24 @@ describe("plugins cli update", () => {
|
||||
.mockResolvedValueOnce(initialSnapshot)
|
||||
.mockResolvedValueOnce(changedSnapshot);
|
||||
const { previousRecords, nextRecords } = primeBravePluginRecordUpdate(cfg);
|
||||
const previousPersistedIndex = createTestInstalledPluginIndex({
|
||||
policyHash: "previous-policy",
|
||||
installRecords: previousRecords,
|
||||
});
|
||||
readPersistedInstalledPluginIndex.mockResolvedValue(previousPersistedIndex);
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "update", "brave"])).rejects.toThrow(
|
||||
"included config changed since last load",
|
||||
);
|
||||
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
nextRecords,
|
||||
);
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
previousRecords,
|
||||
expectInstallRecordsWrittenWithLease(nextRecords, cfg);
|
||||
expect(restorePersistedInstalledPluginIndexIfCurrent).toHaveBeenCalledWith(
|
||||
previousPersistedIndex,
|
||||
expect.any(Number),
|
||||
expect.objectContaining({
|
||||
filePath: expect.any(String),
|
||||
lease: expect.anything(),
|
||||
}),
|
||||
);
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(replaceConfigFile).not.toHaveBeenCalled();
|
||||
@@ -888,18 +916,24 @@ describe("plugins cli update", () => {
|
||||
.mockResolvedValueOnce(initialSnapshot)
|
||||
.mockResolvedValueOnce(invalidSnapshot);
|
||||
const { previousRecords, nextRecords } = primeBravePluginRecordUpdate(cfg);
|
||||
const previousPersistedIndex = createTestInstalledPluginIndex({
|
||||
policyHash: "previous-policy",
|
||||
installRecords: previousRecords,
|
||||
});
|
||||
readPersistedInstalledPluginIndex.mockResolvedValue(previousPersistedIndex);
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "update", "brave"])).rejects.toThrow(
|
||||
"invalid config for plugin brave",
|
||||
);
|
||||
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
nextRecords,
|
||||
);
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
previousRecords,
|
||||
expectInstallRecordsWrittenWithLease(nextRecords, cfg);
|
||||
expect(restorePersistedInstalledPluginIndexIfCurrent).toHaveBeenCalledWith(
|
||||
previousPersistedIndex,
|
||||
expect.any(Number),
|
||||
expect.objectContaining({
|
||||
filePath: expect.any(String),
|
||||
lease: expect.anything(),
|
||||
}),
|
||||
);
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(replaceConfigFile).not.toHaveBeenCalled();
|
||||
@@ -1420,9 +1454,7 @@ describe("plugins cli update", () => {
|
||||
expect(updateParams.config).toEqual(runtimeConfig);
|
||||
expect(updateParams.pluginIds).toEqual(["alpha"]);
|
||||
expect(updateParams.dryRun).toBe(false);
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
|
||||
nextConfig.plugins?.installs,
|
||||
);
|
||||
expectInstallRecordsWrittenWithLease(nextConfig.plugins?.installs, {});
|
||||
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).toHaveBeenCalledWith({});
|
||||
expect(replaceConfigFile).toHaveBeenCalledWith({
|
||||
@@ -1489,9 +1521,7 @@ describe("plugins cli update", () => {
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "update", "--all"])).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
|
||||
nextConfig.plugins?.installs,
|
||||
);
|
||||
expectInstallRecordsWrittenWithLease(nextConfig.plugins?.installs, {});
|
||||
expect(refreshPluginRegistry).toHaveBeenCalledWith({
|
||||
config: {},
|
||||
installRecords: nextConfig.plugins?.installs,
|
||||
|
||||
@@ -437,6 +437,7 @@ async function runPluginUpdateCommandUnlocked(params: RunPluginUpdateCommandPara
|
||||
await commitPluginInstallRecordsOnly({
|
||||
previousInstallRecords: persistedPluginInstallRecords,
|
||||
nextInstallRecords: nextPluginInstallRecords,
|
||||
nextConfig,
|
||||
verifyConfigFresh: async () => {
|
||||
await assertRecordsOnlyUpdateConfigFresh({
|
||||
baseHash: sourceSnapshot?.snapshot.hash,
|
||||
|
||||
+147
-1
@@ -67,6 +67,16 @@ const updateNpmInstalledPlugins = vi.fn();
|
||||
const loadInstalledPluginIndexInstallRecords = vi.fn(
|
||||
async (params: { config?: OpenClawConfig } = {}) => params.config?.plugins?.installs ?? {},
|
||||
);
|
||||
const readPersistedInstalledPluginIndex = vi.fn(async () => null);
|
||||
const restorePersistedInstalledPluginIndex = vi.fn(async () => undefined);
|
||||
const restorePersistedInstalledPluginIndexIfCurrent = vi.fn<
|
||||
typeof import("../plugins/installed-plugin-index-store.js").restorePersistedInstalledPluginIndexIfCurrent
|
||||
>(async () => true);
|
||||
const writePersistedInstalledPluginIndexInstallRecords = vi.fn(async () => undefined);
|
||||
const writePersistedInstalledPluginIndexInstallRecordsWithLease = vi.fn(async () => ({
|
||||
previous: null,
|
||||
revision: 1,
|
||||
}));
|
||||
const checkShellCompletionStatus = vi.fn();
|
||||
const ensureCompletionCacheExists = vi.fn();
|
||||
const installCompletion = vi.fn();
|
||||
@@ -292,7 +302,19 @@ vi.mock("../plugins/installed-plugin-index-records.js", async (importOriginal) =
|
||||
return {
|
||||
...actual,
|
||||
loadInstalledPluginIndexInstallRecords,
|
||||
writePersistedInstalledPluginIndexInstallRecords: vi.fn(async () => undefined),
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
writePersistedInstalledPluginIndexInstallRecordsWithLease,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../plugins/installed-plugin-index-store.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../plugins/installed-plugin-index-store.js")>();
|
||||
return {
|
||||
...actual,
|
||||
readPersistedInstalledPluginIndex,
|
||||
restorePersistedInstalledPluginIndex,
|
||||
restorePersistedInstalledPluginIndexIfCurrent,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1310,6 +1332,14 @@ describe("update-cli", () => {
|
||||
delete process.env[GATEWAY_SERVICE_RUNTIME_PID_ENV];
|
||||
restartHealthTestControl.snapshot = undefined;
|
||||
vi.clearAllMocks();
|
||||
readPersistedInstalledPluginIndex.mockResolvedValue(null);
|
||||
restorePersistedInstalledPluginIndex.mockResolvedValue(undefined);
|
||||
restorePersistedInstalledPluginIndexIfCurrent.mockResolvedValue(true);
|
||||
writePersistedInstalledPluginIndexInstallRecords.mockResolvedValue(undefined);
|
||||
writePersistedInstalledPluginIndexInstallRecordsWithLease.mockResolvedValue({
|
||||
previous: null,
|
||||
revision: 1,
|
||||
});
|
||||
resetRuntimeCapture();
|
||||
spawn.mockImplementation(() => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
@@ -1777,6 +1807,21 @@ describe("update-cli", () => {
|
||||
readPackageVersion.mockImplementation(async (pkgRoot: string) =>
|
||||
pkgRoot === root ? "0.0.1" : "2026.5.28",
|
||||
);
|
||||
const preUpdateConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
msteams: { enabled: false },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
|
||||
...baseSnapshot,
|
||||
parsed: preUpdateConfig,
|
||||
sourceConfig: preUpdateConfig,
|
||||
resolved: preUpdateConfig,
|
||||
config: preUpdateConfig,
|
||||
runtimeConfig: preUpdateConfig,
|
||||
});
|
||||
const pluginInstallRecords = {
|
||||
msteams: {
|
||||
source: "npm",
|
||||
@@ -1819,6 +1864,107 @@ describe("update-cli", () => {
|
||||
integrity: "sha512-newer",
|
||||
},
|
||||
});
|
||||
expect(writePersistedInstalledPluginIndexInstallRecordsWithLease).toHaveBeenCalledWith(
|
||||
capturedRecords,
|
||||
{
|
||||
config: preUpdateConfig,
|
||||
lease: expect.anything(),
|
||||
},
|
||||
);
|
||||
expect(restorePersistedInstalledPluginIndexIfCurrent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the exact plugin index revision when post-core handoff fails", async () => {
|
||||
const { root } = setupUpdatedRootRefresh();
|
||||
readPackageVersion.mockImplementation(async (pkgRoot: string) =>
|
||||
pkgRoot === root ? "0.0.1" : "2026.5.28",
|
||||
);
|
||||
const previousPersistedIndex = {
|
||||
policyHash: "previous-policy",
|
||||
installRecords: {
|
||||
msteams: {
|
||||
source: "npm",
|
||||
spec: "@openclaw/msteams",
|
||||
resolvedVersion: "1.0.0",
|
||||
},
|
||||
} satisfies Record<string, PluginInstallRecord>,
|
||||
};
|
||||
writePersistedInstalledPluginIndexInstallRecordsWithLease.mockResolvedValue({
|
||||
previous: previousPersistedIndex as never,
|
||||
revision: 17,
|
||||
});
|
||||
loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce(
|
||||
previousPersistedIndex.installRecords,
|
||||
);
|
||||
spawn.mockImplementationOnce(() => {
|
||||
throw new Error("post-core spawn failed");
|
||||
});
|
||||
|
||||
await expect(updateCommand({ yes: true, restart: false })).rejects.toThrow(
|
||||
"post-core spawn failed",
|
||||
);
|
||||
|
||||
expect(writePersistedInstalledPluginIndexInstallRecordsWithLease).toHaveBeenCalledTimes(1);
|
||||
expect(restorePersistedInstalledPluginIndexIfCurrent).toHaveBeenCalledWith(
|
||||
previousPersistedIndex,
|
||||
17,
|
||||
{ lease: expect.anything() },
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a child-committed plugin index when the post-core handoff is signaled", async () => {
|
||||
const { root } = setupUpdatedRootRefresh();
|
||||
readPackageVersion.mockImplementation(async (pkgRoot: string) =>
|
||||
pkgRoot === root ? "0.0.1" : "2026.5.28",
|
||||
);
|
||||
const previousPersistedIndex = {
|
||||
policyHash: "previous-policy",
|
||||
installRecords: {
|
||||
msteams: {
|
||||
source: "npm",
|
||||
spec: "@openclaw/msteams",
|
||||
resolvedVersion: "1.0.0",
|
||||
},
|
||||
} satisfies Record<string, PluginInstallRecord>,
|
||||
};
|
||||
let currentRevision = 17;
|
||||
writePersistedInstalledPluginIndexInstallRecordsWithLease.mockResolvedValue({
|
||||
previous: previousPersistedIndex as never,
|
||||
revision: currentRevision,
|
||||
});
|
||||
restorePersistedInstalledPluginIndexIfCurrent.mockImplementation(
|
||||
async (_index, expectedRevision) => {
|
||||
if (currentRevision !== expectedRevision) {
|
||||
return false;
|
||||
}
|
||||
currentRevision += 1;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce(
|
||||
previousPersistedIndex.installRecords,
|
||||
);
|
||||
spawn.mockImplementationOnce(() => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
once: EventEmitter["once"];
|
||||
};
|
||||
currentRevision = 18;
|
||||
queueMicrotask(() => {
|
||||
child.emit("exit", null, "SIGTERM");
|
||||
});
|
||||
return child;
|
||||
});
|
||||
|
||||
await expect(updateCommand({ yes: true, restart: false })).rejects.toThrow(
|
||||
"post-update process terminated by signal SIGTERM",
|
||||
);
|
||||
|
||||
expect(restorePersistedInstalledPluginIndexIfCurrent).toHaveBeenCalledWith(
|
||||
previousPersistedIndex,
|
||||
17,
|
||||
{ lease: expect.anything() },
|
||||
);
|
||||
expect(currentRevision).toBe(18);
|
||||
});
|
||||
|
||||
it("respawns into the updated git root before requested channel persistence", async () => {
|
||||
|
||||
@@ -43,8 +43,9 @@ import type { UpdateRunResult } from "../../infra/update-runner.js";
|
||||
import { getWindowsSystem32ExePath } from "../../infra/windows-install-roots.js";
|
||||
import {
|
||||
loadInstalledPluginIndexInstallRecords,
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
writePersistedInstalledPluginIndexInstallRecordsWithLease,
|
||||
} from "../../plugins/installed-plugin-index-records.js";
|
||||
import { restorePersistedInstalledPluginIndexIfCurrent } from "../../plugins/installed-plugin-index-store.js";
|
||||
import { withPluginLifecycleLease } from "../../plugins/plugin-lifecycle-lease.js";
|
||||
import { runExec } from "../../process/exec.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
@@ -513,10 +514,33 @@ export async function continuePostCoreUpdateInFreshProcess(params: {
|
||||
records: params.pluginInstallRecords,
|
||||
targetVersion: postCoreHostVersion,
|
||||
});
|
||||
let tentativePluginIndex:
|
||||
| Awaited<ReturnType<typeof writePersistedInstalledPluginIndexInstallRecordsWithLease>>
|
||||
| undefined;
|
||||
const restoreTentativePluginIndex = async () => {
|
||||
const tentative = tentativePluginIndex;
|
||||
if (!tentative) {
|
||||
return;
|
||||
}
|
||||
await withPluginLifecycleLease({}, async (lease) => {
|
||||
await restorePersistedInstalledPluginIndexIfCurrent(tentative.previous, tentative.revision, {
|
||||
lease,
|
||||
});
|
||||
});
|
||||
tentativePluginIndex = undefined;
|
||||
};
|
||||
|
||||
try {
|
||||
if (pluginInstallRecords && pluginInstallRecords !== params.pluginInstallRecords) {
|
||||
await writePersistedInstalledPluginIndexInstallRecords(pluginInstallRecords);
|
||||
await withPluginLifecycleLease({}, async (lease) => {
|
||||
tentativePluginIndex = await writePersistedInstalledPluginIndexInstallRecordsWithLease(
|
||||
pluginInstallRecords,
|
||||
{
|
||||
...(params.preUpdateConfig ? { config: params.preUpdateConfig.sourceConfig } : {}),
|
||||
lease,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
await writePostCorePluginInstallRecordsFile(installRecordsPath, pluginInstallRecords);
|
||||
await writePostCoreSourceConfigFile(sourceConfigPath, params.preUpdateConfig);
|
||||
@@ -608,9 +632,19 @@ export async function continuePostCoreUpdateInFreshProcess(params: {
|
||||
if (pluginUpdate) {
|
||||
return { resumed: true, pluginUpdate };
|
||||
}
|
||||
await restoreTentativePluginIndex();
|
||||
return { resumed: false, exitCode };
|
||||
}
|
||||
return { resumed: true, ...(pluginUpdate ? { pluginUpdate } : {}) };
|
||||
} catch (error) {
|
||||
try {
|
||||
await restoreTentativePluginIndex();
|
||||
} catch (rollbackError) {
|
||||
throw new Error("Post-core update failed and could not restore the previous plugin index", {
|
||||
cause: rollbackError,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await fs.rm(resultDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user