fix(update): preserve plugin warning context

This commit is contained in:
Vincent Koc
2026-05-05 18:32:09 -07:00
parent a3aa0a457f
commit 64ab50e42b
4 changed files with 185 additions and 5 deletions

View File

@@ -86,4 +86,10 @@ if [ "$update_status" -ne 0 ]; then
exit 0
fi
node scripts/e2e/lib/plugin-update/probe.mjs assert-corrupt-update /tmp/openclaw-update-corrupt-plugin.json demo-corrupt-plugin
if ! node scripts/e2e/lib/plugin-update/probe.mjs assert-corrupt-update /tmp/openclaw-update-corrupt-plugin.json demo-corrupt-plugin; then
echo "corrupt update JSON payload:" >&2
cat /tmp/openclaw-update-corrupt-plugin.json >&2 || true
echo "corrupt update stderr:" >&2
cat /tmp/openclaw-update-corrupt-plugin.err >&2 || true
exit 1
fi

View File

@@ -174,9 +174,12 @@ function assertCorruptPluginCleanOrRepaired(evidence) {
function assertCorruptPluginDetails(plugins, pluginId) {
const evidence = collectPluginEvidence(plugins, pluginId);
const outcome = evidence.outcome;
if (!outcome || outcome.status !== "error") {
if (
!outcome ||
(outcome.status !== "error" && !isCorruptPluginDisabledAfterUpdate(evidence, pluginId))
) {
throw new Error(
`expected error outcome for ${pluginId}, got ${JSON.stringify({
`expected error or disabled-after-failure outcome for ${pluginId}, got ${JSON.stringify({
outcomes: plugins.npm?.outcomes ?? [],
warnings: plugins.warnings ?? [],
sync: plugins.sync,
@@ -184,6 +187,9 @@ function assertCorruptPluginDetails(plugins, pluginId) {
})}`,
);
}
if (isCorruptPluginDisabledAfterUpdate(evidence, pluginId)) {
return;
}
const warning = evidence.warning;
if (!warning) {
throw new Error(

View File

@@ -689,6 +689,40 @@ describe("update-cli", () => {
expect(spawnEnv?.OPENCLAW_SERVICE_KIND).toBeUndefined();
});
it("passes pre-update plugin install records into the post-core update process", async () => {
setupUpdatedRootRefresh();
const pluginInstallRecords = {
demo: {
source: "npm",
spec: "@openclaw/demo@1.0.0",
installPath: "/tmp/openclaw-demo-plugin",
},
} as const;
let capturedRecords: unknown;
loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce(pluginInstallRecords);
spawn.mockImplementationOnce((_node, _argv, options) => {
const env = (options as { env?: NodeJS.ProcessEnv }).env;
const recordsPath = env?.OPENCLAW_UPDATE_POST_CORE_INSTALL_RECORDS_PATH;
if (!recordsPath) {
throw new Error("missing post-core install records path");
}
capturedRecords = JSON.parse(fsSync.readFileSync(recordsPath, "utf-8"));
const child = new EventEmitter() as EventEmitter & {
once: EventEmitter["once"];
};
queueMicrotask(() => {
child.emit("exit", 0, null);
});
return child;
});
await updateCommand({ yes: true, restart: false });
expect(capturedRecords).toEqual(pluginInstallRecords);
expect(syncPluginsForUpdateChannel).not.toHaveBeenCalled();
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
});
it("respawns into the updated git root before requested channel persistence", async () => {
const { entrypoints } = setupUpdatedRootRefresh({
gatewayUpdateImpl: async (root) =>
@@ -829,6 +863,48 @@ describe("update-cli", () => {
expect(spawn).not.toHaveBeenCalled();
});
it("post-core resume mode uses the parent install records snapshot for missing payload warnings", async () => {
const resultDir = createCaseDir("openclaw-post-core-records");
const recordsPath = path.join(resultDir, "plugin-install-records.json");
const installPath = path.join(resultDir, "demo-plugin");
await fs.mkdir(installPath, { recursive: true });
await fs.writeFile(
recordsPath,
`${JSON.stringify({
demo: {
source: "npm",
spec: "@openclaw/demo@1.0.0",
installPath,
},
})}\n`,
"utf-8",
);
pathExists.mockImplementation(async (candidate: string) => candidate === installPath);
await withEnvAsync(
{
OPENCLAW_UPDATE_POST_CORE: "1",
OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable",
OPENCLAW_UPDATE_POST_CORE_INSTALL_RECORDS_PATH: recordsPath,
},
async () => {
await updateCommand({ json: true, restart: false });
},
);
const jsonOutput = vi.mocked(defaultRuntime.writeJson).mock.calls.at(-1)?.[0] as
| UpdateRunResult
| undefined;
expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning");
expect(jsonOutput?.postUpdate?.plugins?.warnings?.[0]?.reason).toContain(
"package.json is missing",
);
const updateCall = updateNpmInstalledPlugins.mock.calls.at(-1)?.[0] as
| { skipIds?: Set<string> }
| undefined;
expect(updateCall?.skipIds?.has("demo")).toBe(true);
});
it("post-core resume mode persists the requested update channel with the updated process", async () => {
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
...baseSnapshot,
@@ -1175,6 +1251,40 @@ describe("update-cli", () => {
expect(logs).toContain("Run openclaw plugins inspect demo --runtime --json for details.");
});
it("marks disabled-after-failure plugin skips as post-update warnings", async () => {
updateNpmInstalledPlugins.mockResolvedValueOnce({
changed: true,
config: baseConfig,
outcomes: [
{
pluginId: "demo",
status: "skipped",
message:
'Disabled "demo" after plugin update failure; OpenClaw will continue without it. Failed to update demo: registry timeout',
},
],
});
vi.mocked(defaultRuntime.writeJson).mockClear();
await updateCommand({ json: true, restart: false });
const jsonOutput = vi.mocked(defaultRuntime.writeJson).mock.calls.at(-1)?.[0] as
| UpdateRunResult
| undefined;
expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning");
expect(jsonOutput?.postUpdate?.plugins?.warnings?.[0]).toMatchObject({
pluginId: "demo",
guidance: [
"Run openclaw doctor --fix to attempt automatic repair.",
"Run openclaw plugins inspect demo --runtime --json for details.",
],
});
expect(jsonOutput?.postUpdate?.plugins?.npm.outcomes[0]).toMatchObject({
pluginId: "demo",
status: "skipped",
});
});
it("fails unexpected post-core plugin sync exceptions", async () => {
syncPluginsForUpdateChannel.mockRejectedValueOnce(new Error("plugin sync invariant broke"));

View File

@@ -115,6 +115,7 @@ const POST_CORE_UPDATE_ENV = "OPENCLAW_UPDATE_POST_CORE";
const POST_CORE_UPDATE_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_CHANNEL";
const POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL";
const POST_CORE_UPDATE_RESULT_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_RESULT_PATH";
const POST_CORE_UPDATE_INSTALL_RECORDS_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_INSTALL_RECORDS_PATH";
const POST_CORE_UPDATE_RESULT_POLL_MS = 100;
const UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV =
"OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE";
@@ -181,6 +182,25 @@ function isTrackedPackageInstallRecord(record: PluginInstallRecord): boolean {
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizePluginInstallRecordMap(value: unknown): Record<string, PluginInstallRecord> {
if (!isRecord(value)) {
return {};
}
const records: Record<string, PluginInstallRecord> = {};
for (const [pluginId, record] of Object.entries(value).toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
if (isRecord(record) && typeof record.source === "string") {
records[pluginId] = structuredClone(record) as PluginInstallRecord;
}
}
return records;
}
export async function collectMissingPluginInstallPayloads(params: {
records: Record<string, PluginInstallRecord>;
config?: OpenClawConfig;
@@ -272,7 +292,7 @@ function createGuidedPostUpdatePluginOutcome(outcome: PluginUpdateOutcome): {
outcome: PluginUpdateOutcome;
warning?: PostUpdatePluginWarning;
} {
if (outcome.status !== "error") {
if (outcome.status !== "error" && !isDisabledAfterFailureOutcome(outcome)) {
return { outcome };
}
const warning = createPostUpdatePluginWarning({
@@ -288,6 +308,10 @@ function createGuidedPostUpdatePluginOutcome(outcome: PluginUpdateOutcome): {
};
}
function isDisabledAfterFailureOutcome(outcome: PluginUpdateOutcome): boolean {
return outcome.status === "skipped" && outcome.message.includes("after plugin update failure");
}
export function shouldPrepareUpdatedInstallRestart(params: {
updateMode: UpdateRunResult["mode"];
serviceInstalled: boolean;
@@ -1077,6 +1101,7 @@ async function updatePluginsAfterCoreUpdate(params: {
configSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
opts: UpdateCommandOptions;
timeoutMs: number;
pluginInstallRecords?: Record<string, PluginInstallRecord>;
}): Promise<PostCorePluginUpdateResult> {
if (!params.configSnapshot.valid) {
if (!params.opts.json) {
@@ -1116,7 +1141,8 @@ async function updatePluginsAfterCoreUpdate(params: {
}
const warnings: PostUpdatePluginWarning[] = [];
const pluginInstallRecords = await loadInstalledPluginIndexInstallRecords();
const pluginInstallRecords =
params.pluginInstallRecords ?? (await loadInstalledPluginIndexInstallRecords());
const syncConfig = withPluginInstallRecords(
params.configSnapshot.sourceConfig,
pluginInstallRecords,
@@ -1598,6 +1624,7 @@ async function runPostCorePluginUpdate(params: {
configSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
opts: UpdateCommandOptions;
timeoutMs: number;
pluginInstallRecords?: Record<string, PluginInstallRecord>;
}): Promise<PostCorePluginUpdateResult> {
return await updatePluginsAfterCoreUpdate({
root: params.root,
@@ -1605,6 +1632,7 @@ async function runPostCorePluginUpdate(params: {
configSnapshot: params.configSnapshot,
opts: params.opts,
timeoutMs: params.timeoutMs,
pluginInstallRecords: params.pluginInstallRecords,
});
}
@@ -1706,6 +1734,27 @@ async function writePostCorePluginUpdateResultFile(
await writeJson(filePath, result, { trailingNewline: true });
}
async function writePostCorePluginInstallRecordsFile(
filePath: string,
records: Record<string, PluginInstallRecord>,
): Promise<void> {
await fs.writeFile(filePath, `${JSON.stringify(records)}\n`, "utf-8");
}
async function readPostCorePluginInstallRecordsFile(
filePath: string | undefined,
): Promise<Record<string, PluginInstallRecord> | undefined> {
if (!filePath) {
return undefined;
}
try {
const parsed = JSON.parse(await fs.readFile(filePath, "utf-8")) as unknown;
return normalizePluginInstallRecordMap(parsed);
} catch {
return undefined;
}
}
async function readPostCorePluginUpdateResultFile(
filePath: string,
): Promise<PostCorePluginUpdateResult | undefined> {
@@ -1751,6 +1800,7 @@ async function continuePostCoreUpdateInFreshProcess(params: {
channel: "stable" | "beta" | "dev";
requestedChannel: "stable" | "beta" | "dev" | null;
opts: UpdateCommandOptions;
pluginInstallRecords: Record<string, PluginInstallRecord>;
}): Promise<{ resumed: boolean; pluginUpdate?: PostCorePluginUpdateResult }> {
const entryPath = await resolveGatewayInstallEntrypoint(params.root);
if (!entryPath) {
@@ -1772,8 +1822,10 @@ async function continuePostCoreUpdateInFreshProcess(params: {
}
const resultDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-post-core-"));
const resultPath = path.join(resultDir, "plugins.json");
const installRecordsPath = path.join(resultDir, "plugin-install-records.json");
try {
await writePostCorePluginInstallRecordsFile(installRecordsPath, params.pluginInstallRecords);
const child = spawn(resolveNodeRunner(), argv, {
stdio: "inherit",
env: {
@@ -1784,6 +1836,7 @@ async function continuePostCoreUpdateInFreshProcess(params: {
? { [POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV]: params.requestedChannel }
: {}),
[POST_CORE_UPDATE_RESULT_PATH_ENV]: resultPath,
[POST_CORE_UPDATE_INSTALL_RECORDS_PATH_ENV]: installRecordsPath,
},
});
@@ -1885,6 +1938,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
const postCoreUpdateChannel = process.env[POST_CORE_UPDATE_CHANNEL_ENV]?.trim();
const postCoreRequestedChannelInput =
process.env[POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV]?.trim() ?? "";
const postCoreInstallRecordsPath = process.env[POST_CORE_UPDATE_INSTALL_RECORDS_PATH_ENV];
const timeoutMs = parseTimeoutMsOrExit(opts.timeout);
const shouldRestart = opts.restart !== false;
@@ -1925,6 +1979,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
configSnapshot: postCoreConfigSnapshot,
opts,
timeoutMs: updateStepTimeoutMs,
pluginInstallRecords: await readPostCorePluginInstallRecordsFile(postCoreInstallRecordsPath),
});
if (process.env[POST_CORE_UPDATE_RESULT_PATH_ENV]) {
await writePostCorePluginUpdateResultFile(
@@ -2159,6 +2214,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
const { progress, stop } = createUpdateProgress(showProgress);
const startedAt = Date.now();
const preUpdatePluginInstallRecords = await loadInstalledPluginIndexInstallRecords();
let prePackageServiceStop: PrePackageServiceStop | undefined;
if (updateInstallKind === "package") {
@@ -2319,6 +2375,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
channel,
requestedChannel,
opts,
pluginInstallRecords: preUpdatePluginInstallRecords,
});
pluginsUpdatedInFreshProcess = freshProcessResult.resumed;
postCorePluginUpdate = freshProcessResult.pluginUpdate;
@@ -2337,6 +2394,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
configSnapshot: postUpdateConfigSnapshot,
opts,
timeoutMs: updateStepTimeoutMs,
pluginInstallRecords: preUpdatePluginInstallRecords,
});
}