From 7259cb5c771bd4c55ca78edcaa0123eba3f80bff Mon Sep 17 00:00:00 2001 From: "Jason (Json)" <263060202+fuller-stack-dev@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:03:57 -0600 Subject: [PATCH] fix(update): continue after package doctor warnings (#91586) * fix(update): continue after package doctor warnings * fix(update): type advisory step rendering * fix(update): preserve advisory doctor step state * fix(update): share advisory doctor state * fix(update): keep timed-out doctor failures blocking * fix(update): require explicit doctor advisory result * fix(update): reject malformed doctor advisory results * fix(update): bound doctor advisory diagnostics * fix(update): keep doctor advisory restart-neutral * fix(update): protect doctor advisory IPC * fix(update): scope doctor advisories to converging updater * fix(update): scope doctor advisories to deferred repairs * fix(update): secure doctor advisory IPC --------- Co-authored-by: Peter Steinberger --- src/cli/update-cli.test.ts | 168 ++++++++++++++++++ src/cli/update-cli/progress.ts | 41 ++++- src/cli/update-cli/shared.ts | 6 + src/cli/update-cli/update-command.ts | 50 ++++-- .../missing-configured-plugin-install.test.ts | 6 + .../missing-configured-plugin-install.ts | 10 +- ...release-configured-plugin-installs.test.ts | 66 ++++++- .../release-configured-plugin-installs.ts | 28 +++ src/flows/doctor-health-contributions.ts | 7 +- src/flows/doctor-health.ts | 19 +- src/infra/package-update-steps.test.ts | 78 ++++++++ src/infra/package-update-steps.ts | 73 +++++++- src/infra/update-doctor-result.test.ts | 90 ++++++++++ src/infra/update-doctor-result.ts | 129 ++++++++++++++ src/infra/update-global.ts | 9 +- src/infra/update-runner.ts | 32 +++- 16 files changed, 779 insertions(+), 33 deletions(-) create mode 100644 src/infra/update-doctor-result.test.ts create mode 100644 src/infra/update-doctor-result.ts diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 9d38f93a2bd4..4c431b9fbaf5 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -12,6 +12,12 @@ import type { PluginInstallRecord } from "../config/types.plugins.js"; import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../daemon/constants.js"; import { writePackageDistInventory } from "../infra/package-dist-inventory.js"; import { isBetaTag } from "../infra/update-channels.js"; +import { + createDeferredConfiguredPluginRepairDoctorResult, + UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE, + UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH_ENV, + writeUpdatePostInstallDoctorResult, +} from "../infra/update-doctor-result.js"; import type { UpdateRunResult } from "../infra/update-runner.js"; import { withEnvAsync } from "../test-utils/env.js"; import { VERSION } from "../version.js"; @@ -2633,6 +2639,9 @@ describe("update-cli", () => { ); await fs.writeFile(entryPath, "export {};\n", "utf-8"); await writePackageDistInventory(pkgRoot); + readPackageVersion.mockImplementation(async (packageRoot: string) => + packageRoot === pkgRoot ? "2026.4.21" : "1.0.0", + ); pathExists.mockImplementation(async (candidate: string) => { try { await fs.access(candidate); @@ -2672,6 +2681,165 @@ describe("update-cli", () => { ).toBe("1"); }); + it("continues package post-core work for explicit post-update doctor advisories", async () => { + const tempDir = await createTrackedTempDir("openclaw-update-package-doctor-warning-"); + const nodeModules = path.join(tempDir, "node_modules"); + const pkgRoot = path.join(nodeModules, "openclaw"); + const entryPath = path.join(pkgRoot, "dist", "index.js"); + mockPackageInstallStatus(pkgRoot); + await fs.mkdir(path.dirname(entryPath), { recursive: true }); + await fs.writeFile( + path.join(pkgRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.4.21" }), + "utf-8", + ); + await fs.writeFile(entryPath, "export {};\n", "utf-8"); + await writePackageDistInventory(pkgRoot); + pathExists.mockImplementation(async (candidate: string) => { + try { + await fs.access(candidate); + return true; + } catch { + return false; + } + }); + vi.mocked(runCommandWithTimeout).mockImplementation(async (argv, options) => { + if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { + return { + stdout: `${nodeModules}\n`, + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + } + if (Array.isArray(argv) && argv[1] === entryPath && argv[2] === "doctor") { + const env = options && typeof options !== "number" ? options.env : undefined; + const resultPath = env?.[UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH_ENV]; + if (!resultPath) { + throw new Error("missing doctor result path"); + } + await writeUpdatePostInstallDoctorResult({ + resultPath, + result: createDeferredConfiguredPluginRepairDoctorResult([ + "deferred configured plugin repair", + ]), + }); + return { + stdout: "", + stderr: "doctor deferred configured plugin repair", + code: UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE, + signal: null, + killed: false, + termination: "exit", + }; + } + return { + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + }); + + await updateCommand({ yes: true, restart: false, json: true }); + + const doctorCall = doctorCommandCall(); + expect(doctorCall?.[0].slice(1)).toEqual([entryPath, "doctor", "--non-interactive", "--fix"]); + const postCoreCall = spawnCall(); + expect(postCoreCall?.[0]).toMatch(/node/); + expect(postCoreCall?.[1]).toEqual([entryPath, "update", "--json", "--no-restart", "--yes"]); + expect(postCoreCall?.[2]?.env?.OPENCLAW_UPDATE_POST_CORE).toBe("1"); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1); + const jsonOutput = lastWriteJsonCall() as UpdateRunResult | undefined; + const doctorStep = jsonOutput?.steps.find((step) => step.name === "openclaw doctor"); + expect(jsonOutput?.status).toBe("ok"); + expect(doctorStep?.exitCode).toBe(UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE); + expect(doctorStep?.advisory).toEqual({ + kind: "package-post-install-doctor", + message: expect.stringContaining("recoverable update-time repair warning"), + }); + expect(doctorStep?.advisory?.message).not.toContain("gateway restart"); + expect(doctorStep?.stderrTail).toContain("doctor deferred configured plugin repair"); + expect(doctorStep?.stderrTail).toContain("deferred configured plugin repair"); + }); + + it("fails package updates when the post-update doctor is killed after verification", async () => { + const tempDir = await createTrackedTempDir("openclaw-update-package-doctor-timeout-"); + const nodeModules = path.join(tempDir, "node_modules"); + const pkgRoot = path.join(nodeModules, "openclaw"); + const entryPath = path.join(pkgRoot, "dist", "index.js"); + mockPackageInstallStatus(pkgRoot); + await fs.mkdir(path.dirname(entryPath), { recursive: true }); + await fs.writeFile( + path.join(pkgRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.4.21" }), + "utf-8", + ); + await fs.writeFile(entryPath, "export {};\n", "utf-8"); + await writePackageDistInventory(pkgRoot); + pathExists.mockImplementation(async (candidate: string) => { + try { + await fs.access(candidate); + return true; + } catch { + return false; + } + }); + vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { + if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { + return { + stdout: `${nodeModules}\n`, + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + } + if (Array.isArray(argv) && argv[1] === entryPath && argv[2] === "doctor") { + return { + stdout: "", + stderr: "doctor timed out", + code: 124, + signal: null, + killed: true, + termination: "timeout", + }; + } + return { + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + }); + + await updateCommand({ yes: true, restart: false, json: true }); + + const doctorCall = doctorCommandCall(); + expect(doctorCall?.[0].slice(1)).toEqual([entryPath, "doctor", "--non-interactive", "--fix"]); + expect(spawn).not.toHaveBeenCalled(); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + const jsonOutput = lastWriteJsonCall() as UpdateRunResult | undefined; + const doctorStep = jsonOutput?.steps.find((step) => step.name === "openclaw doctor"); + expect(doctorStep?.exitCode).toBe(124); + expect(doctorStep?.advisory).toBeUndefined(); + expect(doctorStep?.termination).toBe("timeout"); + expect( + vi + .mocked(defaultRuntime.log) + .mock.calls.map((call) => String(call[0])) + .join("\n"), + ).not.toContain("Post-install doctor failed after the package install was verified"); + }); + it("runs package post-update doctor from the verified package root after a staged swap", async () => { const tempDir = await createTrackedTempDir("openclaw-update-staged-doctor-"); const nodeModules = path.join(tempDir, "lib", "node_modules"); diff --git a/src/cli/update-cli/progress.ts b/src/cli/update-cli/progress.ts index 4a8b76159b62..de3232158a42 100644 --- a/src/cli/update-cli/progress.ts +++ b/src/cli/update-cli/progress.ts @@ -5,6 +5,7 @@ import { theme } from "../../../packages/terminal-core/src/theme.js"; import { formatDurationPrecise } from "../../infra/format-time/format-duration.ts"; import type { UpdateRunResult, + UpdateStepAdvisory, UpdateStepInfo, UpdateStepProgress, } from "../../infra/update-runner.js"; @@ -37,10 +38,14 @@ const STEP_LABELS: Record = { "global install": "Installing global package", }; -function getStepLabel(step: UpdateStepInfo): string { +function getStepLabel(step: Pick): string { return STEP_LABELS[step.name] ?? step.name; } +function isAdvisoryStep(step: { advisory?: UpdateStepAdvisory }): boolean { + return step.advisory !== undefined; +} + /** Convert updater failure reasons and stderr tails into operator-facing recovery hints. */ export function inferUpdateFailureHints(result: UpdateRunResult): string[] { if (result.status !== "error") { @@ -138,12 +143,19 @@ export function createUpdateProgress(enabled: boolean): ProgressController { const label = getStepLabel(step); const duration = theme.muted(`(${formatDurationPrecise(step.durationMs)})`); - const icon = step.exitCode === 0 ? theme.success("\u2713") : theme.error("\u2717"); + const icon = formatStepStatus(step); currentSpinner.stop(`${icon} ${label} ${duration}`); currentSpinner = null; - if (step.exitCode !== 0 && step.stderrTail) { + if (isAdvisoryStep(step) && step.stderrTail) { + const lines = step.stderrTail.split("\n").slice(-10); + for (const line of lines) { + if (line.trim()) { + defaultRuntime.log(` ${theme.warn(line)}`); + } + } + } else if (step.exitCode !== 0 && step.stderrTail) { const lines = step.stderrTail.split("\n").slice(-10); for (const line of lines) { if (line.trim()) { @@ -165,11 +177,17 @@ export function createUpdateProgress(enabled: boolean): ProgressController { }; } -function formatStepStatus(exitCode: number | null): string { - if (exitCode === 0) { +function formatStepStatus(step: { + exitCode: number | null; + advisory?: UpdateStepAdvisory; +}): string { + if (isAdvisoryStep(step)) { + return theme.warn("!"); + } + if (step.exitCode === 0) { return theme.success("\u2713"); } - if (exitCode === null) { + if (step.exitCode === null) { return theme.warn("?"); } return theme.error("\u2717"); @@ -213,11 +231,18 @@ export function printResult(result: UpdateRunResult, opts: PrintResultOptions): defaultRuntime.log(""); defaultRuntime.log(theme.heading("Steps:")); for (const step of result.steps) { - const status = formatStepStatus(step.exitCode); + const status = formatStepStatus(step); const duration = theme.muted(`(${formatDurationPrecise(step.durationMs)})`); defaultRuntime.log(` ${status} ${step.name} ${duration}`); - if (step.exitCode !== 0 && step.stderrTail) { + if (isAdvisoryStep(step) && step.stderrTail) { + const lines = step.stderrTail.split("\n").slice(0, 5); + for (const line of lines) { + if (line.trim()) { + defaultRuntime.log(` ${theme.warn(line)}`); + } + } + } else if (step.exitCode !== 0 && step.stderrTail) { const lines = step.stderrTail.split("\n").slice(0, 5); for (const line of lines) { if (line.trim()) { diff --git a/src/cli/update-cli/shared.ts b/src/cli/update-cli/shared.ts index 34c7ffc741de..935668afd047 100644 --- a/src/cli/update-cli/shared.ts +++ b/src/cli/update-cli/shared.ts @@ -206,6 +206,9 @@ export async function runUpdateStep(params: { durationMs, exitCode: res.code, stderrTail, + signal: res.signal, + killed: res.killed, + termination: res.termination, }); return { @@ -216,6 +219,9 @@ export async function runUpdateStep(params: { exitCode: res.code, stdoutTail: trimLogTail(res.stdout, MAX_LOG_CHARS), stderrTail, + signal: res.signal, + killed: res.killed, + termination: res.termination, }; } diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 526bc58c6f97..f2d410d7564b 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -52,7 +52,10 @@ import { import { createLowDiskSpaceWarning } from "../../infra/disk-space.js"; import { pathExists } from "../../infra/fs-safe.js"; import { readJsonIfExists, writeJson } from "../../infra/json-files.js"; -import { runGlobalPackageUpdateSteps } from "../../infra/package-update-steps.js"; +import { + markPackagePostInstallDoctorAdvisory, + runGlobalPackageUpdateSteps, +} from "../../infra/package-update-steps.js"; import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js"; import { getSelfAndAncestorPidsSync } from "../../infra/restart-stale-pids.js"; import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js"; @@ -75,6 +78,11 @@ import { writeControlPlaneUpdateRestartSentinel, type ControlPlaneUpdateSentinelMetaFile, } from "../../infra/update-control-plane-sentinel.js"; +import { + consumeUpdatePostInstallDoctorResult, + createUpdatePostInstallDoctorResultPath, + UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH_ENV, +} from "../../infra/update-doctor-result.js"; import { canResolveRegistryVersionForPackageTarget, createGlobalInstallEnv, @@ -1558,15 +1566,24 @@ async function runPackageInstallUpdate(params: { if (entryPath) { await createUpdateConfigSnapshot(); const candidateHostVersion = await readPackageVersion(verifiedPackageRoot); - return await runUpdateStep({ + const doctorResultPath = createUpdatePostInstallDoctorResultPath(); + const doctorArgv = [ + params.nodeRunner ?? resolveNodeRunner(), + entryPath, + "doctor", + "--non-interactive", + "--fix", + ]; + const doctorProgressInfo = { name: `${CLI_NAME} doctor`, - argv: [ - params.nodeRunner ?? resolveNodeRunner(), - entryPath, - "doctor", - "--non-interactive", - "--fix", - ], + command: doctorArgv.join(" "), + index: 0, + total: 0, + }; + params.progress?.onStepStart?.(doctorProgressInfo); + const doctorStep = await runUpdateStep({ + name: `${CLI_NAME} doctor`, + argv: doctorArgv, cwd: verifiedPackageRoot, env: { ...resolvePostInstallDoctorEnv({ @@ -1576,13 +1593,26 @@ async function runPackageInstallUpdate(params: { OPENCLAW_UPDATE_IN_PROGRESS: "1", [UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]: "1", [UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1", + [UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH_ENV]: doctorResultPath, ...(candidateHostVersion === null ? {} : { OPENCLAW_COMPATIBILITY_HOST_VERSION: candidateHostVersion }), }, timeoutMs: params.timeoutMs, - progress: params.progress, }); + const doctorResult = await consumeUpdatePostInstallDoctorResult(doctorResultPath); + const completedDoctorStep = markPackagePostInstallDoctorAdvisory(doctorStep, doctorResult); + params.progress?.onStepComplete?.({ + ...doctorProgressInfo, + durationMs: completedDoctorStep.durationMs, + exitCode: completedDoctorStep.exitCode, + stderrTail: completedDoctorStep.stderrTail, + signal: completedDoctorStep.signal, + killed: completedDoctorStep.killed, + termination: completedDoctorStep.termination, + advisory: completedDoctorStep.advisory, + }); + return completedDoctorStep; } return null; }, diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index 8090857e97ac..f3040e37fd03 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -1097,6 +1097,9 @@ describe("repairMissingConfiguredPluginInstalls", () => { 'Skipped package-manager repair for configured plugin "discord" during package update; rerun "openclaw doctor --fix" after the update completes.', ], warnings: [], + deferredRepairDetails: [ + 'Skipped package-manager repair for configured plugin "discord" during package update; rerun "openclaw doctor --fix" after the update completes.', + ], records, }); }); @@ -1463,6 +1466,9 @@ describe("repairMissingConfiguredPluginInstalls", () => { 'Skipped package-manager repair for configured plugin "discord" during package update; rerun "openclaw doctor --fix" after the update completes.', ], warnings: [], + deferredRepairDetails: [ + 'Skipped package-manager repair for configured plugin "discord" during package update; rerun "openclaw doctor --fix" after the update completes.', + ], records, }); }); diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index f51f19a7114a..762d634cfef1 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -1190,6 +1190,8 @@ export type RepairMissingPluginInstallsResult = { changes: string[]; /** User-facing warnings for failed or skipped plugin install repairs. */ warnings: string[]; + /** User-facing details for repairs explicitly deferred until post-core convergence. */ + deferredRepairDetails?: string[]; /** Plugin ids whose install repair failed and should be preserved from cleanup passes. */ failedPluginIds?: string[]; /** @@ -1337,6 +1339,7 @@ async function repairMissingPluginInstalls(params: { const officialReplacementPluginIds = new Set(officialReplacementInstallCandidates.keys()); const changes: string[] = []; const warnings: string[] = []; + const deferredRepairDetails: string[] = []; const failedPluginIds = new Set(); const deferredPluginIds = new Set(); const preferNpmInstalls = isLegacyPackageUpdateDoctorPass(env); @@ -1369,9 +1372,9 @@ async function repairMissingPluginInstalls(params: { if (!record || !isInstalledRecordMissingOnDisk(record, env)) { continue; } - changes.push( - `Skipped package-manager repair for configured plugin "${pluginId}" during package update; rerun "openclaw doctor --fix" after the update completes.`, - ); + const detail = `Skipped package-manager repair for configured plugin "${pluginId}" during package update; rerun "openclaw doctor --fix" after the update completes.`; + changes.push(detail); + deferredRepairDetails.push(detail); } } @@ -1546,6 +1549,7 @@ async function repairMissingPluginInstalls(params: { return { changes, warnings, + ...(deferredRepairDetails.length > 0 ? { deferredRepairDetails } : {}), ...(failedPluginIds.size > 0 ? { failedPluginIds: [...failedPluginIds].toSorted((left, right) => diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts index 8839f35ff2ba..aa5735e3a915 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts @@ -392,6 +392,9 @@ describe("configured plugin install release step", () => { 'Skipped package-manager repair for configured plugin "codex" during package update; rerun "openclaw doctor --fix" after the update completes.', ], warnings: [], + deferredRepairDetails: [ + 'Skipped package-manager repair for configured plugin "codex" during package update; rerun "openclaw doctor --fix" after the update completes.', + ], }); const { maybeRunConfiguredPluginInstallReleaseStep } = @@ -426,13 +429,60 @@ describe("configured plugin install release step", () => { warnings: [], completed: false, touchedConfig: false, + postInstallDoctorResult: { + status: "advisory", + advisory: expect.objectContaining({ + kind: "package-post-install-doctor", + reason: "deferred-configured-plugin-repair", + details: [ + 'Skipped package-manager repair for configured plugin "codex" during package update; rerun "openclaw doctor --fix" after the update completes.', + ], + }), + }, + }); + }); + + it("does not report an advisory for update-time repair changes that were not deferred", async () => { + mocks.repairMissingPluginInstallsForIds.mockResolvedValue({ + changes: ['Removed stale managed install record for bundled plugin "matrix".'], + warnings: [], + }); + + const { maybeRunConfiguredPluginInstallReleaseStep } = + await import("./release-configured-plugin-installs.js"); + const result = await maybeRunConfiguredPluginInstallReleaseStep({ + cfg: { + plugins: { + entries: { + matrix: { enabled: true }, + }, + }, + }, + currentVersion: "2026.5.2-beta.1", + touchedVersion: "2026.5.1", + env: { + OPENCLAW_UPDATE_IN_PROGRESS: "1", + OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR: "1", + }, + }); + + expect(result).toEqual({ + changes: ['Removed stale managed install record for bundled plugin "matrix".'], + warnings: [], + completed: false, + touchedConfig: false, }); }); it("defers package-manager plugin release completion for writable legacy parents", async () => { mocks.repairMissingPluginInstallsForIds.mockResolvedValue({ - changes: ['Installed missing configured plugin "discord".'], + changes: [ + 'Skipped package-manager repair for configured plugin "discord" during package update; rerun "openclaw doctor --fix" after the update completes.', + ], warnings: [], + deferredRepairDetails: [ + 'Skipped package-manager repair for configured plugin "discord" during package update; rerun "openclaw doctor --fix" after the update completes.', + ], }); const { maybeRunConfiguredPluginInstallReleaseStep } = @@ -458,10 +508,22 @@ describe("configured plugin install release step", () => { OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE: "1", }); expect(result).toEqual({ - changes: ['Installed missing configured plugin "discord".'], + changes: [ + 'Skipped package-manager repair for configured plugin "discord" during package update; rerun "openclaw doctor --fix" after the update completes.', + ], warnings: [], completed: false, touchedConfig: false, + postInstallDoctorResult: { + status: "advisory", + advisory: expect.objectContaining({ + kind: "package-post-install-doctor", + reason: "deferred-configured-plugin-repair", + details: [ + 'Skipped package-manager repair for configured plugin "discord" during package update; rerun "openclaw doctor --fix" after the update completes.', + ], + }), + }, }); }); diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.ts b/src/commands/doctor/shared/release-configured-plugin-installs.ts index 9ea7029f4b7d..76f36a4d370c 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.ts @@ -8,6 +8,10 @@ import { isChannelConfigured } from "../../../config/channel-configured.js"; import { detectPluginAutoEnableCandidates } from "../../../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { compareOpenClawVersions } from "../../../config/version.js"; +import { + createDeferredConfiguredPluginRepairDoctorResult, + type UpdatePostInstallDoctorResult, +} from "../../../infra/update-doctor-result.js"; import { getOfficialExternalPluginCatalogEntry } from "../../../plugins/official-external-plugin-catalog.js"; import { resolveProviderInstallCatalogEntries } from "../../../plugins/provider-install-catalog.js"; import { resolveWebSearchInstallCatalogEntry } from "../../../plugins/web-search-install-catalog.js"; @@ -341,6 +345,7 @@ export async function maybeRunConfiguredPluginInstallReleaseStep(params: { warnings: string[]; completed: boolean; touchedConfig: boolean; + postInstallDoctorResult?: UpdatePostInstallDoctorResult; }> { const env = params.env ?? process.env; const updateInProgress = shouldDeferConfiguredPluginInstallRepair(env); @@ -360,11 +365,17 @@ export async function maybeRunConfiguredPluginInstallReleaseStep(params: { blockedPluginIds: collectBlockedPluginIds(params.cfg), env, }); + const postInstallDoctorResult = createPostInstallDoctorResultForDeferredRepair({ + updateInProgress, + details: repaired.deferredRepairDetails ?? [], + warnings: repaired.warnings, + }); return { changes: repaired.changes, warnings: repaired.warnings, completed: repaired.warnings.length === 0, touchedConfig: false, + ...(postInstallDoctorResult ? { postInstallDoctorResult } : {}), }; } if (configured.pluginIds.length === 0 && configured.channelIds.length === 0) { @@ -378,10 +389,27 @@ export async function maybeRunConfiguredPluginInstallReleaseStep(params: { env, }); const completed = repaired.warnings.length === 0 && !updateInProgress; + const postInstallDoctorResult = createPostInstallDoctorResultForDeferredRepair({ + updateInProgress, + details: repaired.deferredRepairDetails ?? [], + warnings: repaired.warnings, + }); return { changes: repaired.changes, warnings: repaired.warnings, completed, touchedConfig: completed, + ...(postInstallDoctorResult ? { postInstallDoctorResult } : {}), }; } + +function createPostInstallDoctorResultForDeferredRepair(params: { + updateInProgress: boolean; + details: readonly string[]; + warnings: readonly string[]; +}): UpdatePostInstallDoctorResult | undefined { + if (!params.updateInProgress || params.warnings.length > 0 || params.details.length === 0) { + return undefined; + } + return createDeferredConfiguredPluginRepairDoctorResult(params.details); +} diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 4dcc7434c08f..78a1aa2b60c6 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -8,6 +8,7 @@ import { } from "../commands/doctor/shared/update-phase.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { buildGatewayConnectionDetails } from "../gateway/call.js"; +import type { UpdatePostInstallDoctorResult } from "../infra/update-doctor-result.js"; import type { RuntimeEnv } from "../runtime.js"; import type { HealthFinding } from "./health-checks.js"; import type { FlowContribution } from "./types.js"; @@ -29,7 +30,7 @@ type DoctorConfigResult = { preservedLegacyRootKeys?: readonly string[]; }; -type DoctorHealthFlowContext = { +export type DoctorHealthFlowContext = { runtime: RuntimeEnv; options: DoctorOptions; prompter: DoctorPrompter; @@ -45,6 +46,7 @@ type DoctorHealthFlowContext = { gatewayHealthSkipped?: boolean; gatewayStatus?: import("../commands/status.types.js").StatusSummary; gatewayMemoryProbe?: Awaited>; + postInstallDoctorResult?: UpdatePostInstallDoctorResult; }; type DoctorHealthContribution = FlowContribution & { @@ -424,6 +426,9 @@ async function runReleaseConfiguredPluginInstallsHealth( env: ctx.env ?? process.env, touchedVersion: ctx.configResult.sourceLastTouchedVersion ?? ctx.cfg.meta?.lastTouchedVersion, }); + if (result.postInstallDoctorResult) { + ctx.postInstallDoctorResult = result.postInstallDoctorResult; + } if (result.changes.length > 0) { note(result.changes.join("\n"), "Doctor changes"); } diff --git a/src/flows/doctor-health.ts b/src/flows/doctor-health.ts index 20daacb47fa5..198de8cdd3d2 100644 --- a/src/flows/doctor-health.ts +++ b/src/flows/doctor-health.ts @@ -3,6 +3,7 @@ import { intro as clackIntro, outro as clackOutro } from "@clack/prompts"; import { stylePromptTitle } from "../../packages/terminal-core/src/prompt-style.js"; import type { DoctorOptions } from "../commands/doctor-prompter.js"; import type { RuntimeEnv } from "../runtime.js"; +import type { DoctorHealthFlowContext } from "./doctor-health-contributions.js"; // Interactive doctor entrypoint; lazy imports keep normal CLI startup light. const intro = (message: string) => clackIntro(stylePromptTitle(message) ?? message); @@ -68,7 +69,7 @@ export async function doctorCommand(runtime?: RuntimeEnv, options: DoctorOptions prompter, }); const { CONFIG_PATH } = await loadConfigModule(); - const ctx = { + const ctx: DoctorHealthFlowContext = { runtime: effectiveRuntime, options, prompter, @@ -80,6 +81,22 @@ export async function doctorCommand(runtime?: RuntimeEnv, options: DoctorOptions }; const { runDoctorHealthContributions } = await import("./doctor-health-contributions.js"); await runDoctorHealthContributions(ctx); + if (ctx.postInstallDoctorResult) { + const { + UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE, + UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH_ENV, + writeUpdatePostInstallDoctorResult, + } = await import("../infra/update-doctor-result.js"); + const resultPath = process.env[UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH_ENV]?.trim(); + if (resultPath) { + await writeUpdatePostInstallDoctorResult({ + resultPath, + result: ctx.postInstallDoctorResult, + }); + effectiveRuntime.exit(UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE); + return; + } + } outro("Doctor complete."); } diff --git a/src/infra/package-update-steps.test.ts b/src/infra/package-update-steps.test.ts index b72ac0e85d01..1532ad1a7688 100644 --- a/src/infra/package-update-steps.test.ts +++ b/src/infra/package-update-steps.test.ts @@ -5,9 +5,14 @@ import { describe, expect, it, vi } from "vitest"; import { withTempDir } from "../test-helpers/temp-dir.js"; import { writePackageDistInventory } from "./package-dist-inventory.js"; import { + markPackagePostInstallDoctorAdvisory, runGlobalPackageUpdateSteps, type PackageUpdateStepResult, } from "./package-update-steps.js"; +import { + createDeferredConfiguredPluginRepairDoctorResult, + UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE, +} from "./update-doctor-result.js"; import { resolveNpmGlobalPrefixLayoutFromPrefix, type CommandRunner, @@ -74,6 +79,79 @@ function createRootRunner(globalRoot: string): CommandRunner { }; } +describe("markPackagePostInstallDoctorAdvisory", () => { + it("marks only explicit post-install doctor advisory exits", () => { + const step = markPackagePostInstallDoctorAdvisory( + { + exitCode: UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE, + stderrTail: "doctor deferred repair", + signal: null, + killed: false, + termination: "exit" as const, + }, + createDeferredConfiguredPluginRepairDoctorResult(["deferred configured plugin repair"]), + ); + + expect(step.advisory).toEqual({ + kind: "package-post-install-doctor", + message: expect.stringContaining("recoverable update-time repair warning"), + }); + expect(step.stderrTail).toContain("doctor deferred repair"); + expect(step.stderrTail).toContain("deferred configured plugin repair"); + }); + + it("keeps advisory diagnostics bounded after appending deferred repair details", () => { + const step = markPackagePostInstallDoctorAdvisory( + { + exitCode: UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE, + stderrTail: "doctor deferred repair", + signal: null, + killed: false, + termination: "exit" as const, + }, + createDeferredConfiguredPluginRepairDoctorResult([ + `deferred configured plugin repair ${"x".repeat(10_000)}`, + ]), + ); + + expect(step.stderrTail).toHaveLength(8_001); + expect(step.stderrTail).toMatch(/^…/u); + expect(step.stderrTail).toContain("recoverable update-time repair warning"); + }); + + it("does not mark unknown nonzero doctor exits as advisory", () => { + const step = markPackagePostInstallDoctorAdvisory( + { + exitCode: 1, + stderrTail: "doctor refused migration", + signal: null, + killed: false, + termination: "exit" as const, + }, + null, + ); + + expect(step.advisory).toBeUndefined(); + expect(step.stderrTail).toBe("doctor refused migration"); + }); + + it("does not mark timed-out doctor exits as advisory when they report a code", () => { + const step = markPackagePostInstallDoctorAdvisory( + { + exitCode: 124, + stderrTail: "doctor timed out", + signal: null, + killed: true, + termination: "timeout" as const, + }, + createDeferredConfiguredPluginRepairDoctorResult(["deferred configured plugin repair"]), + ); + + expect(step.advisory).toBeUndefined(); + expect(step.stderrTail).toBe("doctor timed out"); + }); +}); + describe("runGlobalPackageUpdateSteps", () => { it("installs npm updates into a clean staged prefix before swapping the global package", async () => { await withTempDir({ prefix: "openclaw-package-update-staged-" }, async (base) => { diff --git a/src/infra/package-update-steps.ts b/src/infra/package-update-steps.ts index 103b6ac62447..61dac9efa5fa 100644 --- a/src/infra/package-update-steps.ts +++ b/src/infra/package-update-steps.ts @@ -5,6 +5,14 @@ import path from "node:path"; import { pathExists } from "./fs-safe.js"; import { readPackageVersion } from "./package-json.js"; import { movePathWithCopyFallback } from "./replace-file.js"; +import { trimLogTail } from "./restart-sentinel.js"; +import { + PACKAGE_POST_INSTALL_DOCTOR_ADVISORY, + UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE, + type PackageUpdateStepAdvisory, + type UpdatePostInstallDoctorResult, +} from "./update-doctor-result.js"; +export type { PackageUpdateStepAdvisory } from "./update-doctor-result.js"; import { collectInstalledGlobalPackageErrors, globalInstallArgs, @@ -33,6 +41,10 @@ export type PackageUpdateStepResult = { exitCode: number | null; stdoutTail?: string | null; stderrTail?: string | null; + signal?: NodeJS.Signals | null; + killed?: boolean; + termination?: "exit" | "timeout" | "no-output-timeout" | "signal"; + advisory?: PackageUpdateStepAdvisory; }; type PackageUpdateStepRunner = (params: { @@ -65,6 +77,60 @@ function formatError(err: unknown): string { return err instanceof Error ? err.message : String(err); } +function isBlockingPackageUpdateStep(step: PackageUpdateStepResult): boolean { + return step.exitCode !== 0 && step.advisory === undefined; +} + +function isNormalProcessExit(step: { + signal?: NodeJS.Signals | null; + killed?: boolean; + termination?: "exit" | "timeout" | "no-output-timeout" | "signal"; +}): boolean { + return ( + step.termination !== "timeout" && + step.termination !== "no-output-timeout" && + step.termination !== "signal" && + step.killed !== true && + (step.signal === undefined || step.signal === null) + ); +} + +export function markPackagePostInstallDoctorAdvisory< + T extends { + exitCode: number | null; + stderrTail?: string | null; + signal?: NodeJS.Signals | null; + killed?: boolean; + termination?: "exit" | "timeout" | "no-output-timeout" | "signal"; + advisory?: PackageUpdateStepAdvisory; + }, +>( + step: T, + result: UpdatePostInstallDoctorResult | null, +): T & { + advisory?: PackageUpdateStepAdvisory; +} { + if ( + step.exitCode !== UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE || + result?.status !== "advisory" || + !isNormalProcessExit(step) + ) { + return step; + } + const advisoryTail = [ + step.stderrTail, + ...result.advisory.details, + PACKAGE_POST_INSTALL_DOCTOR_ADVISORY.message, + ] + .filter((line): line is string => Boolean(line?.trim())) + .join("\n"); + return { + ...step, + advisory: PACKAGE_POST_INSTALL_DOCTOR_ADVISORY, + stderrTail: trimLogTail(advisoryTail) ?? step.stderrTail, + }; +} + async function removePathBestEffort(targetPath: string): Promise { try { await fs.rm(targetPath, { @@ -682,10 +748,9 @@ export async function runGlobalPackageUpdateSteps(params: { } } - const failedStep = - finalInstallStep.exitCode !== 0 - ? finalInstallStep - : (steps.find((step) => step !== updateStep && step.exitCode !== 0) ?? null); + const failedStep = isBlockingPackageUpdateStep(finalInstallStep) + ? finalInstallStep + : (steps.find((step) => step !== updateStep && isBlockingPackageUpdateStep(step)) ?? null); return { steps, diff --git a/src/infra/update-doctor-result.test.ts b/src/infra/update-doctor-result.test.ts new file mode 100644 index 000000000000..931e0e21bf6b --- /dev/null +++ b/src/infra/update-doctor-result.test.ts @@ -0,0 +1,90 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js"; +import { + consumeUpdatePostInstallDoctorResult, + createDeferredConfiguredPluginRepairDoctorResult, + createUpdatePostInstallDoctorResultPath, + writeUpdatePostInstallDoctorResult, +} from "./update-doctor-result.js"; + +const resultPaths: string[] = []; + +afterEach(async () => { + await Promise.all(resultPaths.splice(0).map((resultPath) => fs.rm(resultPath, { force: true }))); +}); + +describe("post-install doctor result IPC", () => { + it("round-trips typed advisory results and consumes the file", async () => { + const resultPath = createUpdatePostInstallDoctorResultPath(); + resultPaths.push(resultPath); + const result = createDeferredConfiguredPluginRepairDoctorResult([ + "deferred configured plugin repair", + ]); + + await writeUpdatePostInstallDoctorResult({ resultPath, result }); + + await expect(consumeUpdatePostInstallDoctorResult(resultPath)).resolves.toEqual(result); + await expect(fs.access(resultPath)).rejects.toThrow(); + }); + + it("rejects result paths outside the secure OpenClaw temp root", async () => { + const tempRoot = resolvePreferredOpenClawTmpDir(); + const resultPath = path.join( + `${tempRoot}-outside`, + `openclaw-update-doctor-${process.pid}-00000000-0000-4000-8000-000000000000.json`, + ); + + await expect( + writeUpdatePostInstallDoctorResult({ + resultPath, + result: createDeferredConfiguredPluginRepairDoctorResult(["deferred repair"]), + }), + ).rejects.toThrow("Unsafe post-install doctor result path"); + await expect(fs.access(resultPath)).rejects.toThrow(); + }); + + it("accepts newer child advisory copy and normalizes it to the parent copy", async () => { + const resultPath = createUpdatePostInstallDoctorResultPath(); + resultPaths.push(resultPath); + await fs.writeFile( + resultPath, + JSON.stringify({ + status: "advisory", + advisory: { + kind: "package-post-install-doctor", + reason: "deferred-configured-plugin-repair", + message: "newer child advisory wording", + details: ["deferred repair"], + }, + }), + { encoding: "utf8", mode: 0o600, flag: "wx" }, + ); + + await expect(consumeUpdatePostInstallDoctorResult(resultPath)).resolves.toEqual( + createDeferredConfiguredPluginRepairDoctorResult(["deferred repair"]), + ); + }); + + it("rejects malformed advisory payloads and consumes the file", async () => { + const resultPath = createUpdatePostInstallDoctorResultPath(); + resultPaths.push(resultPath); + await fs.writeFile( + resultPath, + JSON.stringify({ + status: "advisory", + advisory: { + kind: "package-post-install-doctor", + reason: "deferred-configured-plugin-repair", + message: "forged advisory", + details: [], + }, + }), + { encoding: "utf8", mode: 0o600, flag: "wx" }, + ); + + await expect(consumeUpdatePostInstallDoctorResult(resultPath)).resolves.toBeNull(); + await expect(fs.access(resultPath)).rejects.toThrow(); + }); +}); diff --git a/src/infra/update-doctor-result.ts b/src/infra/update-doctor-result.ts new file mode 100644 index 000000000000..eb2fd2d96e9a --- /dev/null +++ b/src/infra/update-doctor-result.ts @@ -0,0 +1,129 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js"; + +// IPC contract between package update parents and the post-install doctor child. +export const UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH_ENV = + "OPENCLAW_UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH"; +export const UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE = 86; +const UPDATE_POST_INSTALL_DOCTOR_RESULT_FILENAME_RE = + /^openclaw-update-doctor-\d+-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.json$/iu; + +export type PackageUpdateStepAdvisory = { + kind: "package-post-install-doctor"; + message: string; +}; + +export const PACKAGE_POST_INSTALL_DOCTOR_ADVISORY: PackageUpdateStepAdvisory = { + kind: "package-post-install-doctor", + message: + "Post-install doctor reported a recoverable update-time repair warning after the package install was verified; continuing with post-core plugin convergence.", +}; + +export type UpdatePostInstallDoctorResult = { + status: "advisory"; + advisory: PackageUpdateStepAdvisory & { + reason: "deferred-configured-plugin-repair"; + details: string[]; + }; +}; + +export function createUpdatePostInstallDoctorResultPath(): string { + return path.join( + resolvePreferredOpenClawTmpDir(), + `openclaw-update-doctor-${process.pid}-${randomUUID()}.json`, + ); +} + +function resolveSafeUpdatePostInstallDoctorResultPath(resultPath: string): string { + const tempRoot = path.resolve(resolvePreferredOpenClawTmpDir()); + const resolvedPath = path.resolve(resultPath); + if ( + path.dirname(resolvedPath) !== tempRoot || + !UPDATE_POST_INSTALL_DOCTOR_RESULT_FILENAME_RE.test(path.basename(resolvedPath)) + ) { + throw new Error("Unsafe post-install doctor result path"); + } + return resolvedPath; +} + +export function createDeferredConfiguredPluginRepairDoctorResult( + details: readonly string[], +): UpdatePostInstallDoctorResult { + return { + status: "advisory", + advisory: { + ...PACKAGE_POST_INSTALL_DOCTOR_ADVISORY, + reason: "deferred-configured-plugin-repair", + details: details.filter((line) => line.trim()), + }, + }; +} + +export async function writeUpdatePostInstallDoctorResult(params: { + resultPath: string; + result: UpdatePostInstallDoctorResult; +}): Promise { + const resultPath = resolveSafeUpdatePostInstallDoctorResultPath(params.resultPath); + // Advisory details can contain config-derived IDs; pre-existing paths must fail closed. + await fs.writeFile(resultPath, `${JSON.stringify(params.result)}\n`, { + encoding: "utf8", + mode: 0o600, + flag: "wx", + }); +} + +export async function consumeUpdatePostInstallDoctorResult( + resultPath: string, +): Promise { + let safeResultPath: string; + try { + safeResultPath = resolveSafeUpdatePostInstallDoctorResultPath(resultPath); + } catch { + return null; + } + try { + const raw = await fs.readFile(safeResultPath, "utf8"); + return parseUpdatePostInstallDoctorResult(JSON.parse(raw)); + } catch { + return null; + } finally { + await fs.rm(safeResultPath, { force: true }).catch(() => {}); + } +} + +function parseUpdatePostInstallDoctorResult(value: unknown): UpdatePostInstallDoctorResult | null { + if (!value || typeof value !== "object") { + return null; + } + const record = value as Record; + if (record.status !== "advisory") { + return null; + } + const advisory = record.advisory; + if (!advisory || typeof advisory !== "object") { + return null; + } + const advisoryRecord = advisory as Record; + const details = advisoryRecord.details; + if ( + advisoryRecord.kind !== "package-post-install-doctor" || + advisoryRecord.reason !== "deferred-configured-plugin-repair" || + typeof advisoryRecord.message !== "string" || + !Array.isArray(details) || + details.length === 0 || + !details.every((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + ) { + return null; + } + return { + status: "advisory", + advisory: { + kind: "package-post-install-doctor", + reason: "deferred-configured-plugin-repair", + message: PACKAGE_POST_INSTALL_DOCTOR_ADVISORY.message, + details, + }, + }; +} diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index dfcf3eb06634..ac22b7b48b54 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -27,7 +27,14 @@ export type GlobalInstallManager = "npm" | "pnpm" | "bun"; export type CommandRunner = ( argv: string[], options: { timeoutMs: number; cwd?: string; env?: NodeJS.ProcessEnv }, -) => Promise<{ stdout: string; stderr: string; code: number | null }>; +) => Promise<{ + stdout: string; + stderr: string; + code: number | null; + signal?: NodeJS.Signals | null; + killed?: boolean; + termination?: "exit" | "timeout" | "no-output-timeout" | "signal"; +}>; type ResolvedGlobalInstallCommand = { manager: GlobalInstallManager; diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 801966cc1fef..6ac3d245f35e 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -14,7 +14,10 @@ import { } from "./control-ui-assets.js"; import { readPackageName, readPackageVersion } from "./package-json.js"; import { normalizePackageTagInput } from "./package-tag.js"; -import { runGlobalPackageUpdateSteps } from "./package-update-steps.js"; +import { + runGlobalPackageUpdateSteps, + type PackageUpdateStepAdvisory, +} from "./package-update-steps.js"; import { trimLogTail } from "./restart-sentinel.js"; import { resolveStableNodePath } from "./stable-node-path.js"; import { @@ -42,6 +45,8 @@ import { type UpdatePackageManagerFailureReason, } from "./update-package-manager.js"; +export type UpdateStepAdvisory = PackageUpdateStepAdvisory; + export type UpdateStepResult = { name: string; command: string; @@ -50,6 +55,10 @@ export type UpdateStepResult = { exitCode: number | null; stdoutTail?: string | null; stderrTail?: string | null; + signal?: NodeJS.Signals | null; + killed?: boolean; + termination?: "exit" | "timeout" | "no-output-timeout" | "signal"; + advisory?: UpdateStepAdvisory; }; export type UpdateRunResult = { @@ -113,7 +122,14 @@ export type UpdateRunResult = { type CommandRunner = ( argv: string[], options: CommandOptions, -) => Promise<{ stdout: string; stderr: string; code: number | null }>; +) => Promise<{ + stdout: string; + stderr: string; + code: number | null; + signal?: NodeJS.Signals | null; + killed?: boolean; + termination?: "exit" | "timeout" | "no-output-timeout" | "signal"; +}>; export type UpdateStepInfo = { name: string; @@ -126,6 +142,10 @@ export type UpdateStepCompletion = UpdateStepInfo & { durationMs: number; exitCode: number | null; stderrTail?: string | null; + signal?: NodeJS.Signals | null; + killed?: boolean; + termination?: "exit" | "timeout" | "no-output-timeout" | "signal"; + advisory?: UpdateStepAdvisory; }; export type UpdateStepProgress = { @@ -427,6 +447,9 @@ async function runStep(opts: RunStepOptions): Promise { durationMs, exitCode: result.code, stderrTail, + signal: result.signal, + killed: result.killed, + termination: result.termination, }); return { @@ -437,6 +460,9 @@ async function runStep(opts: RunStepOptions): Promise { exitCode: result.code, stdoutTail: trimLogTail(result.stdout, MAX_LOG_CHARS), stderrTail: trimLogTail(result.stderr, MAX_LOG_CHARS), + signal: result.signal, + killed: result.killed, + termination: result.termination, }; } @@ -688,7 +714,7 @@ async function buildUpdateCommandRunner( ...options, env: mergeCommandEnvironments(defaultCommandEnv, options.env), }); - return { stdout: res.stdout, stderr: res.stderr, code: res.code }; + return res; }, }; }