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:
Vincent Koc
2026-08-05 01:04:17 +08:00
committed by GitHub
parent bfba83956f
commit ef11eae39b
17 changed files with 1314 additions and 242 deletions
+147 -1
View File
@@ -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 () => {