// Update method tests cover update.run/status, restart sentinel metadata, // managed-service handoff, restart scheduling, and delivery context preservation. import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ConfigFileSnapshot, OpenClawConfig } from "../../config/types.openclaw.js"; import type { RestartSentinelPayload } from "../../infra/restart-sentinel.js"; import type { RespawnSupervisor } from "../../infra/supervisor-markers.js"; import type { UpdateInstallSurface, UpdateRunResult } from "../../infra/update-runner.js"; import { withEnvAsync } from "../../test-utils/env.js"; // Capture the sentinel payload written during update.run let capturedPayload: RestartSentinelPayload | undefined; const runGatewayUpdateMock = vi.fn<() => Promise>(); const resolveUpdateInstallSurfaceMock = vi.fn<() => Promise>(async () => ({ kind: "git", mode: "git", root: "/tmp/openclaw", packageRoot: "/tmp/openclaw", })); const getLatestUpdateRestartSentinelMock = vi.fn<() => RestartSentinelPayload | null>(() => null); const refreshLatestUpdateRestartSentinelMock = vi.fn<() => Promise>( async () => null, ); const recordLatestUpdateRestartSentinelMock = vi.fn(); const isRestartEnabledMock = vi.fn(() => true); const readPackageVersionMock = vi.fn(async () => "1.0.0"); const detectRespawnSupervisorMock = vi.fn<() => RespawnSupervisor | null>(() => null); const normalizeUpdateChannelMock = vi.fn((): "stable" | "beta" | "dev" | null => null); const readConfigFileSnapshotMock = vi.fn<() => Promise>(); const startManagedServiceUpdateHandoffMock = vi.fn(async () => ({ status: "started" as const, pid: 12345, command: "openclaw update --yes --timeout 1800", logPath: "/tmp/openclaw-update-run-handoff/handoff.log", })); const scheduleGatewaySigusr1RestartMock = vi.fn(() => ({ scheduled: true })); type PostCoreFinalizeOutcome = Awaited< ReturnType< typeof import("../../infra/update-post-core-finalize.js").runPostCoreFinalizeAfterGatewayUpdate > >; const runPostCoreFinalizeAfterGatewayUpdateMock = vi.fn<() => Promise>( async () => ({ status: "skipped", reason: "not-git-update" }), ); type UpdateRunPayload = { ok: boolean; result?: { status?: string; reason?: string; mode?: string }; handoff?: { status?: string; command?: string; message?: string }; sentinel?: { persisted?: boolean }; restart?: unknown; }; vi.mock("../../config/config.js", () => ({ getRuntimeConfig: () => ({ update: {} }), readConfigFileSnapshot: readConfigFileSnapshotMock, })); vi.mock("../../config/commands.flags.js", () => ({ isRestartEnabled: isRestartEnabledMock, })); vi.mock("../../config/sessions.js", () => ({ extractDeliveryInfo: (sessionKey: string | undefined) => { if (!sessionKey) { return { deliveryContext: undefined, threadId: undefined }; } // Simulate a threaded Slack session if (sessionKey.includes(":thread:")) { return { deliveryContext: { channel: "slack", to: "slack:C0123ABC", accountId: "workspace-1" }, threadId: "1234567890.123456", }; } return { deliveryContext: { channel: "webchat", to: "webchat:user-123", accountId: "default" }, threadId: undefined, }; }, })); vi.mock("../../infra/openclaw-root.js", async () => { const actual = await vi.importActual( "../../infra/openclaw-root.js", ); return { ...actual, resolveOpenClawPackageRoot: async () => "/tmp/openclaw", }; }); vi.mock("../../infra/restart-sentinel.js", async () => { const actual = await vi.importActual("../../infra/restart-sentinel.js"); return { ...(actual as Record), writeRestartSentinel: async (payload: RestartSentinelPayload) => { capturedPayload = payload; }, }; }); vi.mock("../../infra/restart.js", () => ({ scheduleGatewaySigusr1Restart: scheduleGatewaySigusr1RestartMock, })); vi.mock("../../infra/package-json.js", () => ({ readPackageVersion: readPackageVersionMock, })); vi.mock("../../infra/supervisor-markers.js", () => ({ detectRespawnSupervisor: detectRespawnSupervisorMock, })); vi.mock("../../infra/update-channels.js", () => ({ normalizeUpdateChannel: normalizeUpdateChannelMock, })); vi.mock("../../infra/update-runner.js", () => ({ resolveUpdateInstallSurface: resolveUpdateInstallSurfaceMock, runGatewayUpdate: runGatewayUpdateMock, })); // Keep the real `foldPostCoreFinalizeIntoResult` so the restart-gate behavior on // finalize failure is exercised; only stub the subprocess-spawning finalizer. vi.mock("../../infra/update-post-core-finalize.js", async () => { const actual = await vi.importActual( "../../infra/update-post-core-finalize.js", ); return { ...actual, runPostCoreFinalizeAfterGatewayUpdate: runPostCoreFinalizeAfterGatewayUpdateMock, }; }); vi.mock("../../../packages/gateway-protocol/src/index.js", () => ({ validateUpdateStatusParams: () => true, validateUpdateRunParams: () => true, })); vi.mock("../server-restart-sentinel.js", () => ({ getLatestUpdateRestartSentinel: getLatestUpdateRestartSentinelMock, recordLatestUpdateRestartSentinel: recordLatestUpdateRestartSentinelMock, refreshLatestUpdateRestartSentinel: refreshLatestUpdateRestartSentinelMock, })); vi.mock("./restart-request.js", () => ({ parseRestartRequestParams: (params: Record) => ({ sessionKey: params.sessionKey, note: params.note, continuationMessage: params.continuationMessage, restartDelayMs: params.restartDelayMs, }), })); vi.mock("../../infra/update-managed-service-handoff.js", () => ({ startManagedServiceUpdateHandoff: startManagedServiceUpdateHandoffMock, formatManagedServiceUpdateCommand: (params?: { timeoutMs?: number; channel?: "stable" | "beta" | "dev"; }) => params?.timeoutMs ? `openclaw update --yes --timeout ${Math.ceil(params.timeoutMs / 1000)}` : "openclaw update --yes", buildManagedServiceHandoffUnavailableMessage: (command: string) => [ "OpenClaw updates cannot safely run inside the live gateway process without a managed-service handoff.", `Run \`${command}\` from a shell outside the gateway service, or restart/update from the host UI.`, ].join("\n"), })); vi.mock("./validation.js", () => ({ assertValidParams: () => true, })); beforeEach(() => { capturedPayload = undefined; isRestartEnabledMock.mockReset(); isRestartEnabledMock.mockReturnValue(true); readPackageVersionMock.mockClear(); readPackageVersionMock.mockResolvedValue("1.0.0"); normalizeUpdateChannelMock.mockReset(); normalizeUpdateChannelMock.mockReturnValue(null); readConfigFileSnapshotMock.mockReset(); readConfigFileSnapshotMock.mockResolvedValue({ path: "/tmp/openclaw.json", exists: true, raw: "{}", parsed: {}, resolved: {} as OpenClawConfig, sourceConfig: {} as OpenClawConfig, valid: true, config: {} as OpenClawConfig, runtimeConfig: {} as OpenClawConfig, issues: [], warnings: [], legacyIssues: [], }); detectRespawnSupervisorMock.mockReset(); detectRespawnSupervisorMock.mockReturnValue(null); runGatewayUpdateMock.mockClear(); runGatewayUpdateMock.mockResolvedValue({ status: "ok", mode: "npm", after: { version: "2.0.0" }, steps: [], durationMs: 100, }); resolveUpdateInstallSurfaceMock.mockClear(); resolveUpdateInstallSurfaceMock.mockResolvedValue({ kind: "git", mode: "git", root: "/tmp/openclaw", packageRoot: "/tmp/openclaw", }); getLatestUpdateRestartSentinelMock.mockClear(); refreshLatestUpdateRestartSentinelMock.mockClear(); refreshLatestUpdateRestartSentinelMock.mockResolvedValue(null); recordLatestUpdateRestartSentinelMock.mockClear(); startManagedServiceUpdateHandoffMock.mockClear(); scheduleGatewaySigusr1RestartMock.mockClear(); scheduleGatewaySigusr1RestartMock.mockReturnValue({ scheduled: true }); runPostCoreFinalizeAfterGatewayUpdateMock.mockClear(); runPostCoreFinalizeAfterGatewayUpdateMock.mockResolvedValue({ status: "skipped", reason: "not-git-update", }); }); async function invokeUpdateRun( params: Record, respond?: (ok: boolean, response?: unknown) => void, ) { const { updateHandlers } = await import("./update.js"); const onRespond = respond ?? (() => {}); await updateHandlers["update.run"]({ params, respond: onRespond as never, context: { getRuntimeConfig: () => ({ update: {} }) }, } as never); } async function captureUpdateRunPayload( params: Record = {}, ): Promise { let payload: UpdateRunPayload | undefined; await invokeUpdateRun(params, (_ok: boolean, response: unknown) => { payload = response as UpdateRunPayload; }); return payload; } function readCapturedPayload(): RestartSentinelPayload { if (!capturedPayload) { throw new Error("expected restart sentinel payload"); } return capturedPayload; } function firstMockCall( mock: { mock: { calls: Array } }, label: string, ): readonly unknown[] { const call = mock.mock.calls[0]; if (!call) { throw new Error(`expected ${label} call`); } return call; } async function withProcessEnv( updates: Record, run: () => Promise, ): Promise { return await withEnvAsync(updates, run); } function mockGlobalInstallSurface() { resolveUpdateInstallSurfaceMock.mockResolvedValueOnce({ kind: "global", mode: "npm", root: "/tmp/openclaw-global", packageRoot: "/tmp/openclaw-global", }); } function mockGitInstallSurface(root: string) { resolveUpdateInstallSurfaceMock.mockResolvedValueOnce({ kind: "git", mode: "git", root, packageRoot: root, }); } describe("update.run sentinel deliveryContext", () => { it("includes deliveryContext in sentinel payload when sessionKey is provided", async () => { capturedPayload = undefined; let responded = false; await invokeUpdateRun({ sessionKey: "agent:main:webchat:dm:user-123" }, () => { responded = true; }); expect(responded).toBe(true); const payload = readCapturedPayload(); expect(payload.deliveryContext).toEqual({ channel: "webchat", to: "webchat:user-123", accountId: "default", }); expect(payload.continuation).toBeUndefined(); }); it("omits deliveryContext when no sessionKey is provided", async () => { capturedPayload = undefined; await invokeUpdateRun({}); const payload = readCapturedPayload(); expect(payload.deliveryContext).toBeUndefined(); expect(payload.threadId).toBeUndefined(); expect(payload.continuation).toBeUndefined(); }); it("includes threadId in sentinel payload for threaded sessions", async () => { capturedPayload = undefined; await invokeUpdateRun({ sessionKey: "agent:main:slack:dm:C0123ABC:thread:1234567890.123456" }); const payload = readCapturedPayload(); expect(payload.deliveryContext).toEqual({ channel: "slack", to: "slack:C0123ABC", accountId: "workspace-1", }); expect(payload.threadId).toBe("1234567890.123456"); expect(payload.continuation).toBeUndefined(); }); it("uses an explicit continuationMessage in successful update sentinels", async () => { capturedPayload = undefined; await invokeUpdateRun({ sessionKey: "agent:main:webchat:dm:user-123", continuationMessage: "Check the running version and finish the update report.", }); expect(readCapturedPayload().continuation).toEqual({ kind: "agentTurn", message: "Check the running version and finish the update report.", }); }); }); describe("update.run timeout normalization", () => { it("enforces a 1000ms minimum timeout for tiny values", async () => { await invokeUpdateRun({ timeoutMs: 1 }); expect(runGatewayUpdateMock).toHaveBeenCalledTimes(1); const [updateParams] = firstMockCall(runGatewayUpdateMock, "gateway update") as [ { timeoutMs?: number }, ]; expect(updateParams?.timeoutMs).toBe(1000); }); }); describe("update.run restart scheduling", () => { it("schedules restart when update succeeds", async () => { const payload = await captureUpdateRunPayload(); expect(scheduleGatewaySigusr1RestartMock).toHaveBeenCalledTimes(1); expect(payload?.ok).toBe(true); expect(payload?.restart).toEqual({ scheduled: true }); }); it("skips restart when update fails", async () => { runGatewayUpdateMock.mockResolvedValueOnce({ status: "error", mode: "git", reason: "build-failed", steps: [], durationMs: 100, }); const payload = await captureUpdateRunPayload({ sessionKey: "agent:main:webchat:dm:user-123", continuationMessage: "This should not run after a failed update.", }); expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled(); expect(payload?.ok).toBe(false); expect(payload?.restart).toBeNull(); expect(capturedPayload?.continuation).toBeUndefined(); }); it.each([ { status: "skipped" as const, reason: "dirty" }, { status: "skipped" as const, reason: "not-git-install" }, { status: "skipped" as const, reason: "restart-disabled" }, { status: "error" as const, reason: "deps-install-failed" }, { status: "error" as const, reason: "build-failed" }, { status: "error" as const, reason: "global-install-failed" }, ])("returns ok=false for $status:$reason", async ({ status, reason }) => { runGatewayUpdateMock.mockResolvedValueOnce({ status, mode: "git", reason, steps: [], durationMs: 100, }); const payload = await captureUpdateRunPayload(); expect(payload?.ok).toBe(false); expect(payload?.result?.status).toBe(status); expect(payload?.result?.reason).toBe(reason); }); it("hands managed package updates to the CLI path instead of running them in-process", async () => { detectRespawnSupervisorMock.mockReturnValueOnce("launchd"); mockGlobalInstallSurface(); const payload = await withProcessEnv({ OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.gateway" }, () => captureUpdateRunPayload(), ); expect(runGatewayUpdateMock).not.toHaveBeenCalled(); expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledTimes(1); expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledWith( expect.objectContaining({ root: "/tmp/openclaw", handoffId: expect.any(String), supervisor: "launchd", meta: expect.objectContaining({ handoffId: expect.any(String), }), }), ); const [handoffParams] = firstMockCall( startManagedServiceUpdateHandoffMock, "managed handoff", ) as [{ handoffId?: string; meta?: { handoffId?: string } }]; expect(handoffParams.meta?.handoffId).toBe(handoffParams.handoffId); expect(scheduleGatewaySigusr1RestartMock).toHaveBeenCalledTimes(1); const [restartParams] = firstMockCall( scheduleGatewaySigusr1RestartMock, "gateway restart schedule", ) as [{ delayMs?: number; reason?: string; skipCooldown?: boolean; skipDeferral?: boolean }]; expect(restartParams?.reason).toBe("update.run"); expect(restartParams?.skipCooldown).toBe(true); expect(restartParams?.skipDeferral).toBe(true); expect(payload?.ok).toBe(true); expect(payload?.result?.status).toBe("skipped"); expect(payload?.result?.reason).toBe("managed-service-handoff-started"); expect( (payload as { handoff?: { status?: string; command?: string } } | undefined)?.handoff, ).toEqual({ status: "started", pid: 12345, command: "openclaw update --yes --timeout 1800", }); expect(payload?.sentinel?.persisted).toBe(true); const sentinel = readCapturedPayload(); expect(sentinel.kind).toBe("update"); expect(sentinel.status).toBe("skipped"); expect(sentinel.stats).toEqual( expect.objectContaining({ handoffId: handoffParams.handoffId, reason: "managed-service-handoff-started", }), ); expect(recordLatestUpdateRestartSentinelMock).toHaveBeenCalledWith( expect.objectContaining({ kind: "update", status: "skipped", stats: expect.objectContaining({ reason: "managed-service-handoff-started", }), }), ); }); it("keeps a startup grace before restarting after systemd handoff spawn", async () => { detectRespawnSupervisorMock.mockReturnValueOnce("systemd"); mockGlobalInstallSurface(); await withProcessEnv({ OPENCLAW_SYSTEMD_UNIT: "openclaw-gateway.service" }, () => invokeUpdateRun({ restartDelayMs: 0 }), ); expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledWith( expect.objectContaining({ supervisor: "systemd", restartDelayMs: 0, }), ); expect(scheduleGatewaySigusr1RestartMock).toHaveBeenCalledWith( expect.objectContaining({ delayMs: 2000, reason: "update.run", skipCooldown: true, skipDeferral: true, }), ); }); it("starts managed package handoff when the gateway cwd is unavailable", async () => { detectRespawnSupervisorMock.mockReturnValueOnce("launchd"); mockGlobalInstallSurface(); const cwdSpy = vi.spyOn(process, "cwd").mockImplementation(() => { throw Object.assign(new Error("uv_cwd"), { code: "ENOENT", syscall: "uv_cwd" }); }); try { await withProcessEnv({ OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.gateway" }, () => invokeUpdateRun({}), ); } finally { cwdSpy.mockRestore(); } expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledTimes(1); expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledWith( expect.objectContaining({ root: "/tmp/openclaw", }), ); }); it("hands supervised git/dev updates to the CLI path instead of rebuilding live dist in-process", async () => { detectRespawnSupervisorMock.mockReturnValueOnce("launchd"); mockGitInstallSurface("/tmp/openclaw-git"); const payload = await withProcessEnv({ OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.gateway" }, () => captureUpdateRunPayload(), ); expect(runGatewayUpdateMock).not.toHaveBeenCalled(); expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledTimes(1); expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledWith( expect.objectContaining({ root: "/tmp/openclaw", handoffId: expect.any(String), supervisor: "launchd", meta: expect.objectContaining({ handoffId: expect.any(String), }), }), ); expect(scheduleGatewaySigusr1RestartMock).toHaveBeenCalledTimes(1); expect(payload?.ok).toBe(true); expect(payload?.result?.status).toBe("skipped"); expect(payload?.result?.reason).toBe("managed-service-handoff-started"); expect(payload?.result?.mode).toBe("git"); expect(payload?.handoff).toEqual({ status: "started", pid: 12345, command: "openclaw update --yes --timeout 1800", }); expect(readCapturedPayload().status).toBe("skipped"); }); it("does not pass the stored stable channel to supervised git handoff CLI", async () => { normalizeUpdateChannelMock.mockReturnValueOnce("stable"); detectRespawnSupervisorMock.mockReturnValueOnce("launchd"); mockGitInstallSurface("/tmp/openclaw-git"); const payload = await withProcessEnv({ OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.gateway" }, () => captureUpdateRunPayload(), ); expect(runGatewayUpdateMock).not.toHaveBeenCalled(); expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledTimes(1); const [handoffParams] = firstMockCall( startManagedServiceUpdateHandoffMock, "managed handoff", ) as [{ channel?: string }]; expect(handoffParams).not.toHaveProperty("channel"); expect(payload?.handoff?.command).not.toContain("--channel"); }); it("keeps unsupervised git/dev updates on the in-process gateway update path", async () => { runGatewayUpdateMock.mockResolvedValueOnce({ status: "ok", mode: "git", after: { version: "2.0.0" }, steps: [], durationMs: 100, }); mockGitInstallSurface("/tmp/openclaw-git"); const payload = await captureUpdateRunPayload(); expect(runGatewayUpdateMock).toHaveBeenCalledTimes(1); expect(startManagedServiceUpdateHandoffMock).not.toHaveBeenCalled(); expect(payload?.ok).toBe(true); expect(payload?.result?.status).toBe("ok"); expect(payload?.result?.mode).toBe("git"); expect(payload?.handoff).toBeUndefined(); expect(readCapturedPayload().status).toBe("ok"); }); it("hands systemd-supervised git/dev updates to handoff from the durable unit identity", async () => { detectRespawnSupervisorMock.mockReturnValueOnce("systemd"); mockGitInstallSurface("/tmp/openclaw-git"); const payload = await withProcessEnv( { OPENCLAW_SYSTEMD_UNIT: "openclaw-gateway.service", INVOCATION_ID: "8a77e69a8f604bf0b7984879b9f17a7c", }, () => captureUpdateRunPayload(), ); expect(runGatewayUpdateMock).not.toHaveBeenCalled(); expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledTimes(1); expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledWith( expect.objectContaining({ root: "/tmp/openclaw", supervisor: "systemd", }), ); expect(payload?.ok).toBe(true); expect(payload?.result?.status).toBe("skipped"); expect(payload?.result?.reason).toBe("managed-service-handoff-started"); expect(payload?.result?.mode).toBe("git"); expect(payload?.handoff?.status).toBe("started"); }); it("does not hand off systemd-supervised git/dev updates from generic systemd markers alone", async () => { detectRespawnSupervisorMock.mockReturnValueOnce("systemd"); mockGitInstallSurface("/tmp/openclaw-git"); const payload = await withProcessEnv( { OPENCLAW_SYSTEMD_UNIT: undefined, INVOCATION_ID: "8a77e69a8f604bf0b7984879b9f17a7c", }, () => captureUpdateRunPayload(), ); expect(runGatewayUpdateMock).not.toHaveBeenCalled(); expect(startManagedServiceUpdateHandoffMock).not.toHaveBeenCalled(); expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled(); expect(payload?.ok).toBe(false); expect(payload?.restart).toBeNull(); expect(payload?.result?.status).toBe("skipped"); expect(payload?.result?.reason).toBe("managed-service-handoff-unavailable"); expect(payload?.result?.mode).toBe("git"); expect(payload?.handoff?.status).toBe("unavailable"); }); it("returns a safe command when package updates cannot be handed off", async () => { mockGlobalInstallSurface(); const payload = await captureUpdateRunPayload({ timeoutMs: 1_800_000 }); expect(runGatewayUpdateMock).not.toHaveBeenCalled(); expect(startManagedServiceUpdateHandoffMock).not.toHaveBeenCalled(); expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled(); expect(payload?.ok).toBe(false); expect(payload?.restart).toBeNull(); expect(payload?.result?.status).toBe("skipped"); expect(payload?.result?.reason).toBe("managed-service-handoff-unavailable"); expect(payload?.handoff).toEqual({ status: "unavailable", command: "openclaw update --yes --timeout 1800", message: "OpenClaw updates cannot safely run inside the live gateway process without a managed-service handoff.\n" + "Run `openclaw update --yes --timeout 1800` from a shell outside the gateway service, or restart/update from the host UI.", }); }); it("blocks global package installs when the gateway cannot restart afterward", async () => { isRestartEnabledMock.mockReturnValue(false); detectRespawnSupervisorMock.mockReturnValue(null); mockGlobalInstallSurface(); const payload = await captureUpdateRunPayload(); expect(runGatewayUpdateMock).not.toHaveBeenCalled(); expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled(); expect(payload?.ok).toBe(false); expect(payload?.result?.status).toBe("skipped"); expect(payload?.result?.reason).toBe("restart-unavailable"); expect(payload?.result?.mode).toBe("npm"); }); }); describe("update.run post-core plugin finalize", () => { function mockGitOkUpdate(root: string) { runGatewayUpdateMock.mockResolvedValueOnce({ status: "ok", mode: "git", root, after: { version: "2026.6.1" }, steps: [], durationMs: 100, }); mockGitInstallSurface(root); } it("resumes official plugin convergence after a git/source core update", async () => { runPostCoreFinalizeAfterGatewayUpdateMock.mockResolvedValueOnce({ status: "ok", entrypoint: "/tmp/openclaw-git/dist/index.mjs", }); mockGitOkUpdate("/tmp/openclaw-git"); const payload = await captureUpdateRunPayload(); expect(runPostCoreFinalizeAfterGatewayUpdateMock).toHaveBeenCalledTimes(1); const [finalizeParams] = firstMockCall( runPostCoreFinalizeAfterGatewayUpdateMock, "post-core finalize", ) as [{ result?: UpdateRunResult }]; expect(finalizeParams.result?.mode).toBe("git"); expect(finalizeParams.result?.status).toBe("ok"); // Convergence succeeded, so the gateway is allowed to restart onto the new core. expect(scheduleGatewaySigusr1RestartMock).toHaveBeenCalledTimes(1); expect(payload?.ok).toBe(true); expect(payload?.result?.status).toBe("ok"); }); it("carries the pre-doctor source config into the git finalizer", async () => { const preUpdateConfig = { channels: { whatsapp: { enabled: true, }, }, } as OpenClawConfig; readConfigFileSnapshotMock.mockResolvedValueOnce({ path: "/tmp/openclaw.json", exists: true, raw: JSON.stringify(preUpdateConfig), parsed: preUpdateConfig, resolved: preUpdateConfig, sourceConfig: preUpdateConfig, valid: true, config: preUpdateConfig, runtimeConfig: preUpdateConfig, issues: [], warnings: [], legacyIssues: [], }); runPostCoreFinalizeAfterGatewayUpdateMock.mockResolvedValueOnce({ status: "ok", entrypoint: "/tmp/openclaw-git/dist/index.mjs", }); mockGitOkUpdate("/tmp/openclaw-git"); await captureUpdateRunPayload(); const [finalizeParams] = firstMockCall( runPostCoreFinalizeAfterGatewayUpdateMock, "post-core finalize", ) as [{ preUpdateConfig?: { sourceConfig?: OpenClawConfig; authoredConfig?: OpenClawConfig } }]; expect(finalizeParams.preUpdateConfig).toEqual({ sourceConfig: preUpdateConfig, authoredConfig: preUpdateConfig, }); }); it("blocks the restart when post-core plugin finalize fails", async () => { runPostCoreFinalizeAfterGatewayUpdateMock.mockResolvedValueOnce({ status: "error", reason: "nonzero-exit", entrypoint: "/tmp/openclaw-git/dist/index.mjs", exitCode: 1, message: "convergence failed", }); mockGitOkUpdate("/tmp/openclaw-git"); const payload = await captureUpdateRunPayload(); // Restarting onto the new core with unreconciled plugins is the bug we avoid. expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled(); expect(payload?.ok).toBe(false); expect(payload?.result?.status).toBe("error"); expect(payload?.result?.reason).toBe("post-core-plugin-finalize-failed"); expect(readCapturedPayload().status).toBe("error"); }); it("does not run finalize on the managed-service handoff path", async () => { detectRespawnSupervisorMock.mockReturnValueOnce("launchd"); mockGlobalInstallSurface(); await withProcessEnv({ OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.gateway" }, () => captureUpdateRunPayload(), ); expect(runGatewayUpdateMock).not.toHaveBeenCalled(); expect(runPostCoreFinalizeAfterGatewayUpdateMock).not.toHaveBeenCalled(); expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledTimes(1); }); }); describe("update.status", () => { it("refreshes the latest update sentinel before responding", async () => { getLatestUpdateRestartSentinelMock.mockReturnValueOnce({ kind: "update", status: "skipped", ts: 1, stats: { reason: "restart-health-pending", }, }); refreshLatestUpdateRestartSentinelMock.mockResolvedValueOnce({ kind: "update", status: "ok", ts: 2, stats: { after: { version: "2.0.0" }, }, }); const { updateHandlers } = await import("./update.js"); const respond = vi.fn(); await updateHandlers["update.status"]({ params: {}, respond, } as never); expect(respond).toHaveBeenCalledTimes(1); const [ok, response] = firstMockCall(respond, "update status response") as [ boolean, { sentinel?: { kind?: string; status?: string } } | undefined, ]; expect(ok).toBe(true); expect(refreshLatestUpdateRestartSentinelMock).toHaveBeenCalledTimes(1); expect(response?.sentinel?.kind).toBe("update"); expect(response?.sentinel?.status).toBe("ok"); }); it("falls back to the cached update sentinel when refresh fails", async () => { refreshLatestUpdateRestartSentinelMock.mockRejectedValueOnce(new Error("read failed")); getLatestUpdateRestartSentinelMock.mockReturnValueOnce({ kind: "update", status: "skipped", ts: 1, stats: { reason: "restart-health-pending", }, }); const warn = vi.fn(); const { updateHandlers } = await import("./update.js"); const respond = vi.fn(); await updateHandlers["update.status"]({ params: {}, respond, context: { logGateway: { warn } }, } as never); expect(warn).toHaveBeenCalledWith("update.status sentinel refresh failed: read failed"); const [, response] = firstMockCall(respond, "update status response") as [ boolean, { sentinel?: { kind?: string; status?: string } } | undefined, ]; expect(response?.sentinel?.kind).toBe("update"); expect(response?.sentinel?.status).toBe("skipped"); }); });