From e7ba2e1f6b1fd9325dd48ce10f79d8e4a48c6240 Mon Sep 17 00:00:00 2001 From: "Vyctor H. Brzezowski" Date: Tue, 11 Aug 2026 13:49:12 -0300 Subject: [PATCH] fix(ui): give the Gateway update a visible, truthful outcome (#121686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): give the Gateway update a visible, truthful outcome Confirming an update in the Control UI could end in nothing the operator could see. The sidebar card only knew `updateRunning`, which clears as soon as `update.run` answers, so a dev-channel handoff — git pull, install, full build, restart — spent minutes back on the stale "N commits behind" call to action. A verified install then published a null banner, so success was never announced at all, and the reload that a verified install triggers would have destroyed any in-memory notice anyway. The lifecycle was already recorded; nothing rendered it. Feed the sidebar the reconciliation fact the settings page already uses, narrate the wait in the shell callout that survives the restart, and record the verified result so it outlives the reload it causes. Failures now name their cause. The updater already stores the failing step and its captured output in the restart sentinel and `update.status` returns it whole, so a disk-full install reads "The update failed at install: ENOSPC: no space left on device" instead of a reason slug plus "see the gateway logs". Missing handoff-reason guidance is filled in, and the duplicate post-restart banner resolver is folded into the shared one. Also fixes a callout clipped by the fixed shell chrome controls, and the "Available 246 commits behind" double framing in the confirmation. * fix(ui): report the Gateway update in the dialog that starts it Follow-up to the first pass: the shell-top callout was the wrong surface for this. The dialog the operator clicked is now the one that reports the whole update, and the ambient surfaces are the fallback for anyone who leaves it. Confirming no longer closes the dialog. It becomes stateful — the danger button turns into a disabled "Updating…" with a spinner, and the body narrates the phases in place: installing, then "The Gateway is restarting. This page disconnects and reconnects on its own." The dialog is mounted on document.body, outside the shell, so losing the connection cannot unmount the only surface still reporting. A request the Gateway never accepts stops spinning after a bounded grace and says so. A failure lands in the dialog with its recorded cause and next step. Success still arrives after the reload a verified install triggers, so it stays a toast — no dialog exists by then. The shell-top status callout is gone. The same outcome now renders beside the sidebar update card that started the update, and stays hidden while the dialog owns the report so the two surfaces never duplicate each other. Splits the wire-payload readers out of update-overlay-helpers into update-schedule-dto and moves update.run answer classification next to them, keeping both owners under the file-size budget. * fix(ui): keep the update dialog open through the install in E2E and use a defined warn token * refactor(ui): wire the update progress stream into the floating card and drop unused video capture * fix(ui): report the current update, not the previous failure The dialog subscribed to progress before starting the update. Producers replay their current snapshot synchronously on subscribe, so a retry still carrying the previous attempt's failure banner painted that old error as this update's outcome. Start the request first, and treat a failure present on the subscribe emit as the previous attempt's: an accepted run clears the banner before its first await, so a failure that survives means the request was refused, which the accept timer already reports truthfully. * fix(ui): keep update helpers internal * chore(perf): raise startup-JS budget to 350 KiB Approved by maintainer Vyctor on 2026-08-11. Trigger: after #121734, main measured 326650 B—6 B below the prior 319 KiB hard ceiling. Current main subsequently moved the ceiling to 320 KiB; this records the authorized final 350 KiB decision for #121686. * fix(ui): announce update success without session storage Show the verified install identity directly when the current document does not reload, so denied session storage cannot suppress the operator-visible outcome. * test(ui): isolate update toast in overlay tests The Node-only overlay suite now reaches the direct non-reload success toast. Mock the DOM-owned toast boundary so reconciliation assertions can complete without a browser document. --- .../control-ui-startup-budget-baseline.json | 4 +- scripts/check-control-ui-performance.mts | 6 +- ui/src/app/app-host-native-shell.test.ts | 4 +- ui/src/app/app-shell-view.ts | 44 ++- ui/src/app/navigation-surface.browser.test.ts | 2 +- ui/src/app/navigation-surface.ts | 9 +- ui/src/app/overlays-update-race.test.ts | 4 +- ui/src/app/overlays.test.ts | 3 +- ui/src/app/overlays.ts | 66 ++-- .../app/update-confirmation.runtime.test.ts | 162 +++++++++ ui/src/app/update-confirmation.runtime.ts | 203 +++++++++-- ui/src/app/update-confirmation.ts | 15 + ui/src/app/update-overlay-helpers.test.ts | 47 ++- ui/src/app/update-overlay-helpers.ts | 340 +++++------------- ui/src/app/update-schedule-dto.ts | 227 ++++++++++++ ui/src/app/update-success-notice.test.ts | 37 ++ ui/src/app/update-success-notice.ts | 66 ++++ ui/src/build-info.ts | 6 +- ui/src/components/app-sidebar-base.ts | 8 +- ui/src/components/app-sidebar.ts | 4 +- ui/src/components/settings-sidebar.test.ts | 22 +- ui/src/components/settings-sidebar.ts | 10 +- ui/src/components/sidebar-update-card.test.ts | 47 ++- ui/src/components/sidebar-update-card.ts | 155 +++++--- ui/src/e2e/update-coalesced.e2e.test.ts | 27 +- ui/src/e2e/update-confirmation.e2e.test.ts | 4 +- ui/src/e2e/update-lifecycle.e2e.test.ts | 220 ++++++++++++ ui/src/i18n/locales/en.ts | 23 +- ui/src/lib/toast.ts | 15 + ui/src/pages/config/config-page.test.ts | 3 + ui/src/pages/config/config-page.ts | 23 +- ui/src/styles/components.css | 24 ++ ui/src/styles/layout.css | 44 +++ ui/src/test-helpers/app-sidebar.ts | 2 +- 34 files changed, 1440 insertions(+), 436 deletions(-) create mode 100644 ui/src/app/update-schedule-dto.ts create mode 100644 ui/src/app/update-success-notice.test.ts create mode 100644 ui/src/app/update-success-notice.ts create mode 100644 ui/src/e2e/update-lifecycle.e2e.test.ts diff --git a/config/control-ui-startup-budget-baseline.json b/config/control-ui-startup-budget-baseline.json index 70f06055da29..29abbc45fd42 100644 --- a/config/control-ui-startup-budget-baseline.json +++ b/config/control-ui-startup-budget-baseline.json @@ -1,5 +1,5 @@ { - "startupJsGzipBytes": 326657, - "reason": "chat header project lead-in (#121734); CI-measured merge-ref bytes", + "startupJsGzipBytes": 328285, + "reason": "Gateway update outcome (#121686); Testbox-measured exact-head bytes", "updatedAt": "2026-08-11" } diff --git a/scripts/check-control-ui-performance.mts b/scripts/check-control-ui-performance.mts index 1b9056c68a57..c62f78e6cc1f 100644 --- a/scripts/check-control-ui-performance.mts +++ b/scripts/check-control-ui-performance.mts @@ -28,9 +28,9 @@ const CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES = 1024; const controlUiPerformanceBudgets = { startupJsRequests: 18, startupCssRequests: 1, - // 320 KiB maintainer-approved 2026-08 after the chat header project lead-in; - // CI measured 326657 B, one byte above the prior 319 KiB cap. - startupJsGzipBytes: 320 * KIB, + // 350 KiB maintainer-approved by Vyctor 2026-08-11 for #121686; + // #121734 left main 6 B below the prior 319 KiB hard ceiling. + startupJsGzipBytes: 350 * KIB, // 45 KiB CSS ceilings maintainer-approved 2026-07 alongside the interleaved // sidebar zone styling; headroom over the ~36.5 KiB post-diet baseline. startupCssGzipBytes: 45 * KIB, diff --git a/ui/src/app/app-host-native-shell.test.ts b/ui/src/app/app-host-native-shell.test.ts index aa13cae3f8af..d5ade20da8a2 100644 --- a/ui/src/app/app-host-native-shell.test.ts +++ b/ui/src/app/app-host-native-shell.test.ts @@ -397,7 +397,7 @@ describe("OpenClaw shell update affordance", () => { latestVersion: "2026.7.2", channel: "stable" as const, }, - updateRunning: false, + updateBusy: false, canUpdate: true, onUpdate: vi.fn(), refreshRequired: false, @@ -487,7 +487,7 @@ describe("OpenClaw shell update affordance", () => { const shared = { onboarding: true, updateAvailable: null, - updateRunning: false, + updateBusy: false, onUpdate: vi.fn(), refreshRequired: true, onRefresh: vi.fn(), diff --git a/ui/src/app/app-shell-view.ts b/ui/src/app/app-shell-view.ts index 2230d4d8bc36..010a19c4a794 100644 --- a/ui/src/app/app-shell-view.ts +++ b/ui/src/app/app-shell-view.ts @@ -38,6 +38,7 @@ import { loadSettings, normalizeCatalogOpenTarget, } from "./settings.ts"; +import type { UpdateProgress } from "./update-confirmation.ts"; const EMPTY_OUTBOX_COUNT_FOR_SESSION = () => 0; const EMPTY_SESSION_HAS_DRAFT = () => false; @@ -122,6 +123,29 @@ export function renderApplicationShell(host: ShellViewHost) { : EMPTY_SESSION_HAS_DRAFT; const navigationSnapshot = context.navigation.snapshot; const overlaySnapshot = context.overlays.snapshot; + // The install keeps running after `update.run` answers, so the reconciliation + // — not the request — decides how long the update surfaces stay busy. + const updateBusy = overlaySnapshot.updateRunning || overlaySnapshot.updateReconciliationPending; + // The update dialog outlives this render and the connection, so it reads live + // snapshots rather than the values captured here. + const watchUpdateProgress = (listener: (progress: UpdateProgress) => void) => { + const emit = () => { + const update = context.overlays.snapshot; + const banner = update.updateStatusBanner; + listener({ + busy: update.updateRunning || update.updateReconciliationPending, + connected: context.gateway.snapshot.phase === "connected", + failure: banner && banner.tone !== "info" ? banner.text : null, + }); + }; + const stopOverlays = context.overlays.subscribe(emit); + const stopGateway = context.gateway.subscribe(emit); + emit(); + return () => { + stopOverlays(); + stopGateway(); + }; + }; const terminalAvailable = isTerminalAvailable( gatewaySnapshot, context.config.current.terminalEnabled ?? false, @@ -232,7 +256,9 @@ export function renderApplicationShell(host: ShellViewHost) { updateAvailable: navigationSurfaceHidden ? null : overlaySnapshot.updateAvailable, updateSchedule: navigationSurfaceHidden ? null : overlaySnapshot.updateSchedule, heldUpdateCampaignId: overlaySnapshot.heldUpdateCampaignId, - updateRunning: overlaySnapshot.updateRunning, + updateBusy, + updateStatusBanner: overlaySnapshot.updateStatusBanner, + watchUpdateProgress, canUpdate, canHoldUpdate, onUpdate: () => void context.overlays.runUpdate(), @@ -267,7 +293,9 @@ export function renderApplicationShell(host: ShellViewHost) { updateAvailable: navigationSurfaceHidden ? null : overlaySnapshot.updateAvailable, updateSchedule: navigationSurfaceHidden ? null : overlaySnapshot.updateSchedule, heldUpdateCampaignId: overlaySnapshot.heldUpdateCampaignId, - updateRunning: overlaySnapshot.updateRunning, + updateBusy, + updateStatusBanner: overlaySnapshot.updateStatusBanner, + watchUpdateProgress, canUpdate, canHoldUpdate, onUpdate: () => void context.overlays.runUpdate(), @@ -294,8 +322,7 @@ export function renderApplicationShell(host: ShellViewHost) { runtimeConfig.configLoading || runtimeConfig.configSaving || (runtimeConfig.configFormDirty && runtimeConfig.configFormMode === "raw") || - overlaySnapshot.updateRunning || - overlaySnapshot.updateReconciliationPending, + updateBusy, onRetry: () => void context.runtimeConfig.save(), onReload: () => void context.runtimeConfig.discardDraft(), onApply: () => void context.runtimeConfig.apply(), @@ -450,18 +477,15 @@ export function renderApplicationShell(host: ShellViewHost) { }} >` : nothing} - ${renderFloatingUpdateCard({ navigationSurfaceHidden, onboarding, updateAvailable: overlaySnapshot.updateAvailable, updateSchedule: overlaySnapshot.updateSchedule, heldUpdateCampaignId: overlaySnapshot.heldUpdateCampaignId, - updateRunning: overlaySnapshot.updateRunning, + updateBusy, + statusBanner: overlaySnapshot.updateStatusBanner, + watchUpdateProgress, canUpdate, canHoldUpdate, onUpdate: () => void context.overlays.runUpdate(), diff --git a/ui/src/app/navigation-surface.browser.test.ts b/ui/src/app/navigation-surface.browser.test.ts index 8ce583812628..1f5637c75727 100644 --- a/ui/src/app/navigation-surface.browser.test.ts +++ b/ui/src/app/navigation-surface.browser.test.ts @@ -52,7 +52,7 @@ describe.skipIf(!hasBrowserLayout)("navigation surface browser layout", () => { navigationSurfaceHidden: true, onboarding: false, updateAvailable: null, - updateRunning: false, + updateBusy: false, onUpdate: () => undefined, refreshRequired: true, onRefresh: () => undefined, diff --git a/ui/src/app/navigation-surface.ts b/ui/src/app/navigation-surface.ts index 3609a8353886..925be1bd8980 100644 --- a/ui/src/app/navigation-surface.ts +++ b/ui/src/app/navigation-surface.ts @@ -1,5 +1,6 @@ import { html, nothing } from "lit"; import type { ApplicationContext } from "./context.ts"; +import type { UpdateProgress } from "./update-confirmation.ts"; export function navigationSurfaceIsHidden(params: { onboarding: boolean; @@ -18,7 +19,9 @@ export function renderFloatingUpdateCard(params: { updateAvailable: ApplicationContext["overlays"]["snapshot"]["updateAvailable"]; updateSchedule?: ApplicationContext["overlays"]["snapshot"]["updateSchedule"]; heldUpdateCampaignId?: string | null; - updateRunning: boolean; + updateBusy: boolean; + statusBanner?: ApplicationContext["overlays"]["snapshot"]["updateStatusBanner"]; + watchUpdateProgress?: (listener: (progress: UpdateProgress) => void) => () => void; canUpdate?: boolean; canHoldUpdate?: boolean; onUpdate: () => void; @@ -36,7 +39,9 @@ export function renderFloatingUpdateCard(params: { .updateAvailable=${params.updateAvailable} .updateSchedule=${params.updateSchedule ?? null} .heldUpdateCampaignId=${params.heldUpdateCampaignId ?? null} - .updateRunning=${params.updateRunning} + .updateBusy=${params.updateBusy} + .statusBanner=${params.statusBanner ?? null} + .watchUpdateProgress=${params.watchUpdateProgress} .canUpdate=${params.canUpdate ?? false} .canHoldUpdate=${params.canHoldUpdate ?? false} .onUpdate=${params.onUpdate} diff --git a/ui/src/app/overlays-update-race.test.ts b/ui/src/app/overlays-update-race.test.ts index 4d716f4e3a24..701d0205b1ef 100644 --- a/ui/src/app/overlays-update-race.test.ts +++ b/ui/src/app/overlays-update-race.test.ts @@ -11,10 +11,12 @@ import { type RequestFn, } from "./overlays-access.test-support.ts"; import { createApplicationOverlays } from "./overlays.ts"; -import { UPDATE_HANDOFF_STARTED_REASON } from "./update-overlay-helpers.ts"; + +vi.mock("../lib/toast.ts", () => ({ showToast: vi.fn() })); const UNKNOWN_OUTCOME_TEXT = "The update request may have been accepted, but the Gateway did not report a final result after reconnect. Run `openclaw update status` before retrying."; +const UPDATE_HANDOFF_STARTED_REASON = "managed-service-handoff-started"; function installUpdateTranslations() { const translations: Record = { diff --git a/ui/src/app/overlays.test.ts b/ui/src/app/overlays.test.ts index 47b89616f621..1285615ae35a 100644 --- a/ui/src/app/overlays.test.ts +++ b/ui/src/app/overlays.test.ts @@ -13,13 +13,13 @@ import { type RequestFn, } from "./overlays-access.test-support.ts"; import { createApplicationOverlays } from "./overlays.ts"; -import { UPDATE_HANDOFF_STARTED_REASON } from "./update-overlay-helpers.ts"; vi.mock("../build-info.ts", () => ({ controlUiVersionDiffersFrom: (gatewayVersion: string | undefined) => Boolean(gatewayVersion?.trim() && gatewayVersion.trim() !== "1.0.0"), reloadControlUiIfStale: vi.fn(), })); +vi.mock("../lib/toast.ts", () => ({ showToast: vi.fn() })); const { peekStoredDeviceIdentityIdMock } = vi.hoisted(() => ({ peekStoredDeviceIdentityIdMock: vi.fn((): string | null => "browser-1"), })); @@ -29,6 +29,7 @@ vi.mock("../lib/nodes/index.ts", () => ({ const HANDOFF_POLL_MS = 1_000; const RESTART_VERIFICATION_TIMEOUT_MS = 10_000; +const UPDATE_HANDOFF_STARTED_REASON = "managed-service-handoff-started"; function installUpdateTranslations() { const translations: Record = { diff --git a/ui/src/app/overlays.ts b/ui/src/app/overlays.ts index fe147feceaa0..2237609ba964 100644 --- a/ui/src/app/overlays.ts +++ b/ui/src/app/overlays.ts @@ -4,7 +4,7 @@ import { } from "../../../src/gateway/events.js"; import type { GatewayEventFrame } from "../api/gateway.ts"; import type { UpdateAvailable, UpdateHoldResult, UpdateScheduleState } from "../api/types.ts"; -import { controlUiVersionDiffersFrom, reloadControlUiIfStale } from "../build-info.ts"; +import { controlUiVersionDiffersFrom } from "../build-info.ts"; import { t } from "../i18n/index.ts"; import { closeDevicePairSetup as closeDevicePairSetupState, @@ -40,24 +40,30 @@ import { readOverlayOperatorAccessTransition, } from "./overlays-access.ts"; import { + classifyUpdateRunResponse, createPendingUpdateReconciliation, createUpdateCampaignStatusPoller, createUpdateStatusRefresher, createUpdateVerificationController, projectUpdateStatusResponse, - readUpdateAvailable, - readUpdateAvailableValue, - readUpdateSchedule, - readUpdateScheduleValue, resolveExpectedUpdateSha, resolveUnknownUpdateOutcomeBanner, resolveUpdateStatusBanner, - UPDATE_HANDOFF_STARTED_REASON, type ApplicationStatusBanner, type PendingUpdateReconciliation, type UpdateRestartStatusResponse, type UpdateRunResponse, } from "./update-overlay-helpers.ts"; +import { + readUpdateAvailable, + readUpdateAvailableValue, + readUpdateSchedule, + readUpdateScheduleValue, +} from "./update-schedule-dto.ts"; +import { + announceRecordedUpdateSuccess, + announceVerifiedUpdateInstall, +} from "./update-success-notice.ts"; type ApplicationOverlaySnapshot = { updateAvailable: UpdateAvailable | null; @@ -236,7 +242,7 @@ export function createApplicationOverlays( getHello: () => gateway.snapshot.hello, publish, publishBanner: publishUpdateBanner, - onVerifiedInstall: reloadControlUiIfStale, + onVerifiedInstall: announceVerifiedUpdateInstall, }); const applyUpdateStatusResponse = (response: UpdateRestartStatusResponse) => { snapshot = { @@ -439,6 +445,8 @@ export function createApplicationOverlays( } }); synchronizeGateway(gateway.snapshot); + // A reload started by the previous document's verified install lands here. + announceRecordedUpdateSuccess(); return { get snapshot() { @@ -491,42 +499,22 @@ export function createApplicationOverlays( ) { return; } - const status = response.result?.status ?? (response.ok === true ? "ok" : "error"); - const expectedVersion = - response.result?.after?.version?.trim() || pendingUpdate.expectedVersion; - const expectedSha = response.result?.after?.sha?.trim() || pendingUpdate.expectedSha; - if ( - response.ok === true && - status === "skipped" && - response.result?.reason === UPDATE_HANDOFF_STARTED_REASON && - response.handoff?.status === "started" - ) { - pendingUpdate = { expectedVersion, expectedSha, kind: "handoff" }; - return; - } - if (response.ok === true && status === "ok") { - pendingUpdate = { expectedVersion, expectedSha, kind: "restart" }; - if (response.restart?.coalesced === true) { - snapshot = { - ...snapshot, - updateStatusBanner: { - tone: "info", - text: t("updates.coalescedRestart"), - }, - }; + const accepted = classifyUpdateRunResponse(response, pendingUpdate); + if (accepted) { + pendingUpdate = accepted.pending; + if (accepted.banner) { + snapshot = { ...snapshot, updateStatusBanner: accepted.banner }; } return; } pendingUpdate = null; - if (response.ok !== true || status !== "ok") { - snapshot = { - ...snapshot, - updateStatusBanner: resolveUpdateStatusBanner({ - status, - reason: response.result?.reason, - }), - }; - } + snapshot = { + ...snapshot, + updateStatusBanner: resolveUpdateStatusBanner({ + status: response.result?.status ?? "error", + reason: response.result?.reason, + }), + }; } catch (error) { if ( disposed || diff --git a/ui/src/app/update-confirmation.runtime.test.ts b/ui/src/app/update-confirmation.runtime.test.ts index 0f544a0b61a8..957cbc96b37e 100644 --- a/ui/src/app/update-confirmation.runtime.test.ts +++ b/ui/src/app/update-confirmation.runtime.test.ts @@ -4,6 +4,29 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts"; import { getRenderedModalDialog, installDialogPolyfill } from "../test-helpers/modal-dialog.ts"; import { confirmAndStartUpdateRuntime } from "./update-confirmation.runtime.ts"; +import type { UpdateProgress } from "./update-confirmation.ts"; + +/** Drives the dialog the way the shell does: one live lifecycle stream. */ +function createProgressStream() { + let emit: ((progress: UpdateProgress) => void) | null = null; + let stopped = false; + return { + get stopped() { + return stopped; + }, + watchUpdateProgress: (listener: (progress: UpdateProgress) => void) => { + emit = listener; + listener({ busy: false, connected: true, failure: null }); + return () => { + stopped = true; + }; + }, + async push(progress: UpdateProgress) { + emit?.(progress); + await Promise.resolve(); + }, + }; +} const UPDATE_AVAILABLE: UpdateAvailable = { channel: "stable", @@ -39,10 +62,14 @@ function startUpdate( updateAvailable?: UpdateAvailable | null; updateSchedule?: UpdateScheduleState | null; viaNativeApp?: boolean; + watchUpdateProgress?: (listener: (progress: UpdateProgress) => void) => () => void; } = {}, ) { const startGatewayUpdate = vi.fn(); const settled = confirmAndStartUpdateRuntime({ + ...(overrides.watchUpdateProgress + ? { watchUpdateProgress: overrides.watchUpdateProgress } + : {}), startGatewayUpdate: overrides.startGatewayUpdate ?? startGatewayUpdate, updateAvailable: overrides.updateAvailable === undefined ? UPDATE_AVAILABLE : overrides.updateAvailable, @@ -107,6 +134,22 @@ it("shows the git target when no package version is available", async () => { await settled; }); +it("states a git distance once instead of labelling it as an available version", async () => { + const { settled } = startUpdate({ + updateAvailable: { channel: "dev", currentVersion: "2026.8.1", latestVersion: "2026.8.1" }, + updateSchedule: { + target: { commitsBehind: 246, kind: "git" }, + } as unknown as UpdateScheduleState, + }); + const { modal } = await getRenderedModalDialog(document.body); + + expect(modal.textContent).toContain("Installed v2026.8.1 · 246 commits behind"); + expect(modal.textContent).not.toContain("Available 246"); + + findButton("Cancel").click(); + await settled; +}); + it("keeps a repeated request from stacking a second confirmation or update", async () => { const first = startUpdate(); const second = startUpdate(); @@ -120,3 +163,122 @@ it("keeps a repeated request from stacking a second confirmation or update", asy await first.settled; expect(first.startGatewayUpdate).toHaveBeenCalledOnce(); }); + +it("keeps the dialog open and narrates the install, the restart, and the failure", async () => { + const stream = createProgressStream(); + const { settled, startGatewayUpdate } = startUpdate({ + watchUpdateProgress: stream.watchUpdateProgress, + }); + const { modal } = await getRenderedModalDialog(document.body); + + findButton("Update and restart").click(); + await Promise.resolve(); + expect(startGatewayUpdate).toHaveBeenCalledOnce(); + const updating = findButton("Updating…"); + expect(updating.disabled).toBe(true); + expect(modal.textContent).toContain("Installing the update on the Gateway"); + + // The Gateway goes away mid-install; the dialog is mounted outside the shell + // precisely so it can keep reporting through the disconnect. + await stream.push({ busy: true, connected: false, failure: null }); + expect(modal.textContent).toContain("The Gateway is restarting"); + expect(document.body.querySelector("openclaw-modal-dialog")).not.toBeNull(); + + await stream.push({ + busy: false, + connected: true, + failure: "The update failed at install: ENOSPC: no space left on device, write.", + }); + expect(modal.textContent).toContain("ENOSPC: no space left on device"); + findButton("Close").click(); + await settled; + expect(stream.stopped).toBe(true); +}); + +it("closes itself once a watched update finishes without a failure", async () => { + const stream = createProgressStream(); + const { settled } = startUpdate({ watchUpdateProgress: stream.watchUpdateProgress }); + await getRenderedModalDialog(document.body); + + findButton("Update and restart").click(); + await Promise.resolve(); + await stream.push({ busy: true, connected: true, failure: null }); + await stream.push({ busy: false, connected: true, failure: null }); + + await settled; + expect(document.body.querySelector("openclaw-modal-dialog")).toBeNull(); +}); + +/** + * Retry after a failure: the shell keeps the previous attempt's banner until an + * accepted run clears it, and producers replay the current snapshot as their + * subscribe-time emit. `accepted: false` models `overlays.runUpdate` refusing + * the request (disconnected, already running, no admin), which leaves the + * banner in place. + */ +function createRetryStream(options: { accepted: boolean }) { + let progress: UpdateProgress = { + busy: false, + connected: true, + failure: "The update failed at install: ENOSPC: no space left on device, write.", + }; + let emit: ((next: UpdateProgress) => void) | null = null; + return { + startGatewayUpdate: () => { + if (!options.accepted) { + return; + } + progress = { busy: true, connected: true, failure: null }; + emit?.(progress); + }, + watchUpdateProgress: (listener: (next: UpdateProgress) => void) => { + emit = listener; + listener(progress); + return () => {}; + }, + }; +} + +it("reports a refused retry as unanswered rather than as the old failure", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const stream = createRetryStream({ accepted: false }); + const { settled } = startUpdate({ + startGatewayUpdate: stream.startGatewayUpdate, + watchUpdateProgress: stream.watchUpdateProgress, + }); + const { modal } = await getRenderedModalDialog(document.body); + + findButton("Update and restart").click(); + await Promise.resolve(); + // The refused request must not inherit the previous error as its outcome. + expect(modal.textContent).not.toContain("ENOSPC"); + + await vi.advanceTimersByTimeAsync(5_000); + expect(modal.textContent).toContain("The update request went unanswered"); + findButton("Close").click(); + await settled; + } finally { + vi.useRealTimers(); + } +}); + +it("reports a request the Gateway never accepted instead of spinning forever", async () => { + // Auto-advancing keeps the modal's own animation frames running while the + // grace deadline is fast-forwarded. + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const stream = createProgressStream(); + const { settled } = startUpdate({ watchUpdateProgress: stream.watchUpdateProgress }); + const { modal } = await getRenderedModalDialog(document.body); + + findButton("Update and restart").click(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(modal.textContent).toContain("The update request went unanswered"); + findButton("Close").click(); + await settled; + } finally { + vi.useRealTimers(); + } +}); diff --git a/ui/src/app/update-confirmation.runtime.ts b/ui/src/app/update-confirmation.runtime.ts index 007d7a3acc66..ef13d4b31fa7 100644 --- a/ui/src/app/update-confirmation.runtime.ts +++ b/ui/src/app/update-confirmation.runtime.ts @@ -1,14 +1,31 @@ -// Implementation of the Control UI's disruptive-update confirmation gate. It -// stays behind the `update-confirmation.ts` lazy boundary because nothing here -// runs until an operator clicks an update affordance, and the startup bundle -// has no room for a dialog nobody has opened yet. +// Implementation of the Control UI's disruptive-update dialog. It stays behind +// the `update-confirmation.ts` lazy boundary because nothing here runs until an +// operator clicks an update affordance, and the startup bundle has no room for +// a dialog nobody has opened yet. +// +// The dialog is the operator's primary surface for the whole update: it opens +// as a confirmation, becomes a progress report on confirm, and reports a +// failure in place. It is mounted on `document.body`, outside the shell, so the +// Gateway restart that tears down the connection cannot unmount it. +import { html, nothing, render } from "lit"; import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts"; -import { showConfirmDialog } from "../components/confirm-dialog.ts"; import { t } from "../i18n/index.ts"; +import "../components/modal-dialog.ts"; import { postNativeUpdate } from "./native-link-routing.ts"; -import type { ConfirmAndStartUpdateParams } from "./update-confirmation.ts"; +import type { ConfirmAndStartUpdateParams, UpdateProgress } from "./update-confirmation.ts"; import { formatUpdateTargetLabel } from "./update-overlay-helpers.ts"; +/** Bounds the wait for the request to be accepted before calling it a no-start. */ +const UPDATE_ACCEPT_GRACE_MS = 4_000; +const UPDATE_DIALOG_OPEN_CLASS = "update-dialog-open"; + +type DialogPhase = + | { kind: "confirm" } + | { kind: "working"; connected: boolean } + | { kind: "failed"; message: string }; + +let updateDialogActive = false; + function formatInstalledAndAvailable( updateAvailable: UpdateAvailable | null, updateSchedule: UpdateScheduleState | null, @@ -19,14 +36,38 @@ function formatInstalledAndAvailable( : null; const available = formatUpdateTargetLabel(updateSchedule, updateAvailable); if (installed && available) { - return t("updates.confirm.versions", { available, installed }); + // A commit count already reads as a distance, so "Available 246 commits + // behind" would double the framing; only a version needs the label. + const behind = + updateSchedule?.target?.kind === "git" || updateAvailable?.commitsBehind !== undefined; + return t(behind ? "updates.confirm.versionsBehind" : "updates.confirm.versions", { + available, + installed, + }); } return installed ?? available ?? undefined; } +function workingMessage(connected: boolean): string { + // The restart is the loud part of the wait; name it while it is happening + // instead of leaving the operator to interpret a frozen page. + return connected ? t("updates.dialog.installing") : t("updates.dialog.restarting"); +} + export async function confirmAndStartUpdateRuntime( params: ConfirmAndStartUpdateParams, ): Promise { + // Native confirms block reentrancy; refuse a second request rather than + // stacking a dialog over an update that is already being reported. + if (updateDialogActive) { + return; + } + updateDialogActive = true; + const host = document.createElement("div"); + document.body.append(host); + // One surface owns the outcome at a time: the ambient copy stays hidden while + // the dialog that started this update is still reporting it. + document.body.classList.add(UPDATE_DIALOG_OPEN_CLASS); const route = params.viaNativeApp ? { confirmLabel: t("updates.confirm.macAction"), @@ -38,18 +79,140 @@ export async function confirmAndStartUpdateRuntime( message: t("updates.confirm.message"), title: t("chat.sidebar.updateGateway"), }; - const confirmed = await showConfirmDialog({ - title: route.title, - // The impact sentence is shared so both routes state the same consequence. - message: `${route.message} ${t("updates.confirm.impact")}`, - details: formatInstalledAndAvailable(params.updateAvailable, params.updateSchedule), - confirmLabel: route.confirmLabel, + const details = formatInstalledAndAvailable(params.updateAvailable, params.updateSchedule); + await new Promise((resolve) => { + let phase: DialogPhase = { kind: "confirm" }; + let settled = false; + let stopWatching: (() => void) | undefined; + let acceptTimer: ReturnType | undefined; + let sawBusy = false; + + const finish = () => { + if (settled) { + return; + } + settled = true; + stopWatching?.(); + if (acceptTimer !== undefined) { + globalThis.clearTimeout(acceptTimer); + } + render(nothing, host); + host.remove(); + document.body.classList.remove(UPDATE_DIALOG_OPEN_CLASS); + updateDialogActive = false; + resolve(); + }; + + const draw = () => { + if (settled) { + return; + } + const current = phase; + const working = current.kind === "working"; + const failed = current.kind === "failed"; + const body = + current.kind === "failed" + ? current.message + : current.kind === "working" + ? workingMessage(current.connected) + : `${route.message} ${t("updates.confirm.impact")}`; + render( + html` + +
+
+
+
${route.title}
+
${body}
+
+
+ ${details && !failed + ? html`
${details}
` + : nothing} +
+ ${failed + ? html`` + : html` + + + `} +
+
+
+ `, + host, + ); + }; + + function confirm() { + if (phase.kind !== "confirm") { + return; + } + if (params.viaNativeApp && postNativeUpdate()) { + finish(); + return; + } + const watch = params.watchUpdateProgress; + if (!watch) { + params.startGatewayUpdate(); + finish(); + return; + } + phase = { kind: "working", connected: true }; + draw(); + // Start before subscribing: an accepted run clears the retained banner + // synchronously, before its first await. Producers then emit that fresh + // snapshot as the subscribe-time emit, so a failure still present on it + // belongs to the previous attempt and is not this update's outcome — + // a refused request is reported by the accept timer below instead. + params.startGatewayUpdate(); + let retainedEmit = true; + stopWatching = watch((progress: UpdateProgress) => { + const staleFailure = retainedEmit; + retainedEmit = false; + if (settled || phase.kind === "confirm") { + return; + } + if (progress.failure && !staleFailure) { + phase = { kind: "failed", message: progress.failure }; + draw(); + return; + } + if (progress.busy) { + sawBusy = true; + } else if (sawBusy) { + // Finished without a failure: the outcome is a toast, or a reload + // that replays it. Nothing left for the dialog to say. + finish(); + return; + } + phase = { kind: "working", connected: progress.connected }; + draw(); + }); + acceptTimer = globalThis.setTimeout(() => { + if (settled || sawBusy || phase.kind !== "working") { + return; + } + phase = { kind: "failed", message: t("updates.dialog.notStarted") }; + draw(); + }, UPDATE_ACCEPT_GRACE_MS); + } + + draw(); }); - if (!confirmed) { - return; - } - if (params.viaNativeApp && postNativeUpdate()) { - return; - } - params.startGatewayUpdate(); } diff --git a/ui/src/app/update-confirmation.ts b/ui/src/app/update-confirmation.ts index 65754e279ae1..13494c702919 100644 --- a/ui/src/app/update-confirmation.ts +++ b/ui/src/app/update-confirmation.ts @@ -5,6 +5,15 @@ // operator has not opened. import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts"; +/** What the dialog needs to narrate an install it cannot observe directly. */ +export type UpdateProgress = { + /** The install is accepted and unfinished, across the restart. */ + busy: boolean; + connected: boolean; + /** Set once the update produced a definitive failure. */ + failure: string | null; +}; + export type ConfirmAndStartUpdateParams = { updateAvailable: UpdateAvailable | null; updateSchedule: UpdateScheduleState | null; @@ -15,6 +24,12 @@ export type ConfirmAndStartUpdateParams = { */ viaNativeApp: boolean; startGatewayUpdate: () => void; + /** + * Streams the update lifecycle so the dialog can stay open and report it. + * A surface that cannot supply one closes on confirm instead of holding a + * dialog it can never update; the ambient surfaces narrate from there. + */ + watchUpdateProgress?: (listener: (progress: UpdateProgress) => void) => () => void; }; export async function confirmAndStartUpdate(params: ConfirmAndStartUpdateParams): Promise { diff --git a/ui/src/app/update-overlay-helpers.test.ts b/ui/src/app/update-overlay-helpers.test.ts index c3941157ba6e..5346515a1265 100644 --- a/ui/src/app/update-overlay-helpers.test.ts +++ b/ui/src/app/update-overlay-helpers.test.ts @@ -10,14 +10,15 @@ import type { import { createUpdateVerificationController, formatUpdateCampaignLabel, - readUpdateAvailable, - readUpdateSchedule, resolveUpdateStatusBanner, } from "./update-overlay-helpers.ts"; +import { readUpdateAvailable, readUpdateSchedule } from "./update-schedule-dto.ts"; const translations: Record = { "updates.status": "Update {status}: {reason}. {guidance}", "updates.failureReasons.dirty": "Commit or stash changes, then retry.", + "updates.failureReasons.depsInstallFailed": + "Dependency install failed. Fix the install error and retry.", "updates.failureReasons.default": "See the gateway logs for the exact failure and retry once the cause is fixed.", "updates.verificationFailed": @@ -28,9 +29,9 @@ const translations: Record = { "Update finished, but the running install does not match the expected revision. Expected {expected}, running {actual}.", "updates.outcomeUnknown": "The update outcome is unknown.", "common.unknown": "Unknown", - "updates.postRestart.restartUnhealthy": - "The replacement process never became healthy and the previous process stayed up.", - "updates.postRestart.default": "Check the gateway logs for the replacement failure.", + "updates.failureReasons.restartUnhealthy": + "The replacement process never became healthy. The previous process stayed up so you can recover.", + "updates.failedAtStep": "The update failed at {step}: {cause}.", "updates.handoffTimeout": "Update handoff started, but completion was not reported after reconnect. Run `openclaw update status` for the final result.", "updates.campaign.countdown": "Updating in {time}", @@ -226,6 +227,38 @@ describe("update status localization", () => { }); }); + it("names the recorded cause instead of the reason slug when a step failed", async () => { + installTranslations(); + + await expect( + verifyUpdate({ + pending: { kind: "handoff", expectedVersion: "2.0.0", expectedSha: null }, + response: { + sentinel: { + kind: "update", + status: "error", + stats: { + reason: "deps-install-failed", + steps: [ + { name: "fetch", log: { exitCode: 0, stderrTail: "done" } }, + { + name: "install", + log: { + exitCode: 1, + stderrTail: "Progress: resolved 1\nENOSPC: no space left on device, write", + }, + }, + ], + }, + }, + }, + }), + ).resolves.toEqual({ + tone: "danger", + text: "The update failed at install: ENOSPC: no space left on device, write. Dependency install failed. Fix the install error and retry.", + }); + }); + it("preserves unknown status details inside localized fallback guidance", () => { const translate = installTranslations(); @@ -326,7 +359,7 @@ describe("update status localization", () => { }), ).resolves.toEqual({ tone: "danger", - text: "Update error: restart-unhealthy. The replacement process never became healthy and the previous process stayed up.", + text: "Update error: restart-unhealthy. The replacement process never became healthy. The previous process stayed up so you can recover.", }); await expect( verifyUpdate({ @@ -341,7 +374,7 @@ describe("update status localization", () => { }), ).resolves.toEqual({ tone: "danger", - text: "Update error: supervisor-exited. Check the gateway logs for the replacement failure.", + text: "Update error: supervisor-exited. See the gateway logs for the exact failure and retry once the cause is fixed.", }); await expect( verifyUpdate({ diff --git a/ui/src/app/update-overlay-helpers.ts b/ui/src/app/update-overlay-helpers.ts index bd9e1da3c932..7718a8616ddf 100644 --- a/ui/src/app/update-overlay-helpers.ts +++ b/ui/src/app/update-overlay-helpers.ts @@ -1,14 +1,14 @@ -import { isRecord } from "@openclaw/normalization-core/record-coerce"; import type { GatewayBrowserClient, GatewayHelloOk } from "../api/gateway.ts"; import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts"; import { t } from "../i18n/index.ts"; +import { readUpdateAvailableValue, readUpdateScheduleValue } from "./update-schedule-dto.ts"; export type ApplicationStatusBanner = { tone: "danger" | "warn" | "info"; text: string; }; -export const UPDATE_HANDOFF_STARTED_REASON = "managed-service-handoff-started"; +const UPDATE_HANDOFF_STARTED_REASON = "managed-service-handoff-started"; const UPDATE_RESTART_HEALTH_PENDING_REASON = "restart-health-pending"; const UPDATE_RESTART_VERIFICATION_POLL_MS = 250; const UPDATE_RESTART_VERIFICATION_TIMEOUT_MS = 10_000; @@ -37,6 +37,24 @@ const UPDATE_FAILURE_REASON_KEYS: Record = { "managed-service-handoff-already-running": "updates.failureReasons.managedServiceHandoffAlreadyRunning", "doctor-failed": "updates.failureReasons.doctorFailed", + // The detached helper owns these; its output never reaches the gateway log, + // so the default "see the gateway logs" guidance would send operators nowhere. + "managed-service-handoff-failed": "updates.failureReasons.managedServiceHandoffFailed", + "managed-service-handoff-spawn-failed": "updates.failureReasons.managedServiceHandoffSpawnFailed", + "managed-service-handoff-helper-failed": "updates.failureReasons.managedServiceHandoffFailed", + "managed-service-handoff-parent-timeout": + "updates.failureReasons.managedServiceHandoffParentTimeout", +}; +// One line is enough to name the cause; the full tail belongs in the CLI. +const MAX_UPDATE_FAILURE_CAUSE_CHARS = 180; + +type UpdateSentinelStep = { + name?: string | null; + log?: { + stdoutTail?: string | null; + stderrTail?: string | null; + exitCode?: number | null; + } | null; }; export type UpdateRestartStatusResponse = { @@ -46,12 +64,42 @@ export type UpdateRestartStatusResponse = { stats?: { reason?: string | null; after?: { sha?: string | null; version?: string | null } | null; + steps?: UpdateSentinelStep[] | null; } | null; } | null; updateAvailable?: UpdateAvailable | null; schedule?: UpdateScheduleState; }; +type UpdateFailureCause = { step: string; detail: string }; + +function lastLogLine(tail: string | null | undefined): string | null { + const lines = (tail ?? "") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + const last = lines.at(-1); + return last ? last.slice(0, MAX_UPDATE_FAILURE_CAUSE_CHARS) : null; +} + +/** + * The updater records why it stopped — the failing step plus its captured + * output — in the restart sentinel. Read that recorded fact instead of making + * the operator reconstruct a disk-full or build failure from a reason slug. + */ +function readUpdateFailureCause( + sentinel: UpdateRestartStatusResponse["sentinel"], +): UpdateFailureCause | null { + const steps = sentinel?.stats?.steps; + // The run stops at its first failure, so the last non-zero exit is the cause. + const failed = Array.isArray(steps) + ? steps.findLast((step) => typeof step?.log?.exitCode === "number" && step.log.exitCode !== 0) + : undefined; + const detail = lastLogLine(failed?.log?.stderrTail) ?? lastLogLine(failed?.log?.stdoutTail); + const step = failed?.name?.trim(); + return step && detail ? { step, detail } : null; +} + export type UpdateRunResponse = { ok?: boolean; result?: { @@ -95,6 +143,38 @@ export function createUpdateStatusRefresher(params: { }; } +/** + * Reads what an `update.run` answer means for reconciliation. The RPC answers + * long before a managed handoff finishes, so an accepted request yields the + * pending record to verify after the restart, not an outcome. + */ +export function classifyUpdateRunResponse( + response: UpdateRunResponse, + pending: PendingUpdateReconciliation, +): { pending: PendingUpdateReconciliation; banner: ApplicationStatusBanner | null } | null { + const status = response.result?.status ?? (response.ok === true ? "ok" : "error"); + const expectedVersion = response.result?.after?.version?.trim() || pending.expectedVersion; + const expectedSha = response.result?.after?.sha?.trim() || pending.expectedSha; + if ( + response.ok === true && + status === "skipped" && + response.result?.reason === UPDATE_HANDOFF_STARTED_REASON && + response.handoff?.status === "started" + ) { + return { pending: { expectedVersion, expectedSha, kind: "handoff" }, banner: null }; + } + if (response.ok === true && status === "ok") { + return { + pending: { expectedVersion, expectedSha, kind: "restart" }, + banner: + response.restart?.coalesced === true + ? { tone: "info", text: t("updates.coalescedRestart") } + : null, + }; + } + return null; +} + export function resolveExpectedUpdateSha( schedule: UpdateScheduleState | null, updateAvailable: UpdateAvailable | null, @@ -204,7 +284,13 @@ export function createUpdateVerificationController(params: { } if (sentinel?.kind === "update" && sentinel.status && sentinel.status !== "ok") { params.clearPending(); - params.publishBanner(resolvePostRestartUpdateBanner(sentinel.stats?.reason)); + params.publishBanner( + resolveUpdateStatusBanner({ + status: "error", + ...(sentinel.stats?.reason ? { reason: sentinel.stats.reason } : {}), + cause: readUpdateFailureCause(sentinel), + }), + ); return; } const actualVersion = sentinel?.stats?.after?.version?.trim() || null; @@ -325,227 +411,6 @@ function resolveUpdateVerificationWindow( }; } -export function readUpdateAvailable(hello: GatewayHelloOk | null): UpdateAvailable | null { - const snapshot = hello?.snapshot; - if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { - return null; - } - const update = (snapshot as { updateAvailable?: unknown }).updateAvailable; - return readUpdateAvailableValue(update); -} - -export function readUpdateAvailableValue(update: unknown): UpdateAvailable | null { - if (!isRecord(update)) { - return null; - } - const rawCommits = update.commits; - const commits = - Array.isArray(rawCommits) && - rawCommits.length <= 5 && - rawCommits.every( - (commit): commit is { sha: string; subject: string } => - isRecord(commit) && - typeof commit.sha === "string" && - commit.sha.length > 0 && - typeof commit.subject === "string" && - commit.subject.length <= 120, - ) - ? rawCommits.map((commit) => ({ sha: commit.sha, subject: commit.subject })) - : undefined; - return typeof update.currentVersion === "string" && - typeof update.latestVersion === "string" && - typeof update.channel === "string" - ? { - currentVersion: update.currentVersion, - latestVersion: update.latestVersion, - channel: update.channel, - ...(typeof update.currentSha === "string" ? { currentSha: update.currentSha } : {}), - ...(typeof update.upstreamRef === "string" ? { upstreamRef: update.upstreamRef } : {}), - ...(typeof update.upstreamSha === "string" ? { upstreamSha: update.upstreamSha } : {}), - ...(Number.isInteger(update.commitsBehind) && Number(update.commitsBehind) >= 0 - ? { commitsBehind: Number(update.commitsBehind) } - : {}), - ...(commits ? { commits } : {}), - } - : null; -} - -function readScheduleTarget(value: unknown): UpdateScheduleState["target"] | null { - if (!isRecord(value)) { - return null; - } - if (value.kind === "package" && typeof value.version === "string") { - return { kind: "package", version: value.version }; - } - if ( - value.kind === "git" && - typeof value.upstreamRef === "string" && - typeof value.upstreamSha === "string" && - Number.isInteger(value.commitsBehind) && - Number(value.commitsBehind) >= 0 - ) { - return { - kind: "git", - upstreamRef: value.upstreamRef, - upstreamSha: value.upstreamSha, - commitsBehind: Number(value.commitsBehind), - }; - } - return null; -} - -function readGitInstallMetadata(value: Record): { - currentSha?: string; - commitAtMs?: number; - installedAtMs?: number; -} | null { - if ( - (value.currentSha !== undefined && - (typeof value.currentSha !== "string" || value.currentSha.length === 0)) || - (value.commitAtMs !== undefined && - (!Number.isInteger(value.commitAtMs) || Number(value.commitAtMs) < 0)) || - (value.installedAtMs !== undefined && - (!Number.isInteger(value.installedAtMs) || Number(value.installedAtMs) < 0)) - ) { - return null; - } - return { - ...(typeof value.currentSha === "string" ? { currentSha: value.currentSha } : {}), - ...(value.commitAtMs === undefined ? {} : { commitAtMs: Number(value.commitAtMs) }), - ...(value.installedAtMs === undefined ? {} : { installedAtMs: Number(value.installedAtMs) }), - }; -} - -function readGitUpdateStatus( - value: unknown, -): NonNullable["git"]> | null { - if (!isRecord(value)) { - return null; - } - const metadata = readGitInstallMetadata(value); - if (!metadata) { - return null; - } - if (value.status === "current") { - return { ...metadata, status: "current" }; - } - if ( - value.status === "behind" && - Number.isInteger(value.commitsBehind) && - Number(value.commitsBehind) > 0 - ) { - return { ...metadata, status: "behind", commitsBehind: Number(value.commitsBehind) }; - } - if ( - value.status === "ahead" && - Number.isInteger(value.commitsAhead) && - Number(value.commitsAhead) > 0 - ) { - return { ...metadata, status: "ahead", commitsAhead: Number(value.commitsAhead) }; - } - if ( - value.status === "diverged" && - Number.isInteger(value.commitsAhead) && - Number(value.commitsAhead) > 0 && - Number.isInteger(value.commitsBehind) && - Number(value.commitsBehind) > 0 - ) { - return { - ...metadata, - status: "diverged", - commitsAhead: Number(value.commitsAhead), - commitsBehind: Number(value.commitsBehind), - }; - } - if ( - value.status === "unavailable" && - (value.reason === "fetch-failed" || - value.reason === "no-upstream" || - value.reason === "no-upstream-sha" || - value.reason === "comparison-failed" || - value.reason === "git-unavailable") - ) { - return { ...metadata, status: "unavailable", reason: value.reason }; - } - return null; -} - -function readScheduleCampaign(value: unknown): UpdateScheduleState["campaign"] | null { - if ( - !isRecord(value) || - typeof value.id !== "string" || - (value.state !== "waiting-for-idle" && - value.state !== "countdown" && - value.state !== "applying") || - !Number.isInteger(value.announcedAtMs) || - Number(value.announcedAtMs) < 0 || - !Number.isInteger(value.forceAtMs) || - Number(value.forceAtMs) < 0 || - !Number.isInteger(value.updatedAtMs) || - Number(value.updatedAtMs) < 0 || - (value.applyAtMs !== undefined && - (!Number.isInteger(value.applyAtMs) || Number(value.applyAtMs) < 0)) || - (value.holdUntilMs !== undefined && - (!Number.isInteger(value.holdUntilMs) || Number(value.holdUntilMs) < 0)) - ) { - return null; - } - return { - id: value.id, - state: value.state, - announcedAtMs: Number(value.announcedAtMs), - ...(value.applyAtMs === undefined ? {} : { applyAtMs: Number(value.applyAtMs) }), - ...(value.holdUntilMs === undefined ? {} : { holdUntilMs: Number(value.holdUntilMs) }), - forceAtMs: Number(value.forceAtMs), - updatedAtMs: Number(value.updatedAtMs), - }; -} - -export function readUpdateScheduleValue(value: unknown): UpdateScheduleState | null { - if ( - !isRecord(value) || - typeof value.channel !== "string" || - typeof value.autoEnabled !== "boolean" - ) { - return null; - } - const rawInstall = isRecord(value.install) ? value.install : null; - const rawInstallKind = rawInstall?.kind; - const installKind = - rawInstallKind === "package" || rawInstallKind === "git" || rawInstallKind === "unknown" - ? rawInstallKind - : undefined; - if (value.install !== undefined && installKind === undefined) { - return null; - } - const gitStatus = rawInstall?.git === undefined ? undefined : readGitUpdateStatus(rawInstall.git); - if (rawInstall?.git !== undefined && !gitStatus) { - return null; - } - const target = value.target === undefined ? undefined : readScheduleTarget(value.target); - const campaign = value.campaign === undefined ? undefined : readScheduleCampaign(value.campaign); - if ((value.target !== undefined && !target) || (value.campaign !== undefined && !campaign)) { - return null; - } - return { - channel: value.channel, - autoEnabled: value.autoEnabled, - ...(installKind - ? { install: { kind: installKind, ...(gitStatus ? { git: gitStatus } : {}) } } - : {}), - ...(target ? { target } : {}), - ...(campaign ? { campaign } : {}), - }; -} - -export function readUpdateSchedule(hello: GatewayHelloOk | null): UpdateScheduleState | null { - const snapshot = hello?.snapshot; - if (!isRecord(snapshot)) { - return null; - } - return readUpdateScheduleValue(snapshot.updateSchedule); -} - export function projectUpdateStatusResponse( response: UpdateRestartStatusResponse, current: { @@ -570,6 +435,7 @@ export function projectUpdateStatusResponse( : resolveUpdateStatusBanner({ status: sentinel.status, reason: sentinel.stats?.reason ?? undefined, + cause: readUpdateFailureCause(sentinel), }) : current.updateStatusBanner, ...(Object.hasOwn(response, "updateAvailable") @@ -638,13 +504,19 @@ export function formatUpdateTargetLabel( export function resolveUpdateStatusBanner(params: { status?: string; reason?: string; + cause?: UpdateFailureCause | null; }): ApplicationStatusBanner { const status = (params.status ?? "error").trim() || "error"; const reason = (params.reason ?? "unexpected-error").trim() || "unexpected-error"; const guidance = t(UPDATE_FAILURE_REASON_KEYS[reason] ?? "updates.failureReasons.default"); + const cause = params.cause; return { tone: status === "skipped" ? "warn" : "danger", - text: t("updates.status", { status, reason, guidance }), + // A recorded cause names what actually broke; the reason slug only names + // which step owned it. + text: cause + ? `${t("updates.failedAtStep", { step: cause.step, cause: cause.detail })} ${guidance}` + : t("updates.status", { status, reason, guidance }), }; } @@ -670,24 +542,6 @@ function resolveUpdateVerificationBanner(params: { }; } -function resolvePostRestartUpdateBanner( - reason: string | null | undefined, -): ApplicationStatusBanner { - const normalizedReason = reason?.trim() || "restart-unhealthy"; - const guidanceKey = - normalizedReason === "restart-unhealthy" - ? "updates.postRestart.restartUnhealthy" - : "updates.postRestart.default"; - return { - tone: "danger", - text: t("updates.status", { - status: "error", - reason: normalizedReason, - guidance: t(guidanceKey), - }), - }; -} - function resolvePendingUpdateHandoffTimeoutBanner(): ApplicationStatusBanner { return { tone: "danger", diff --git a/ui/src/app/update-schedule-dto.ts b/ui/src/app/update-schedule-dto.ts new file mode 100644 index 000000000000..c45404862ee2 --- /dev/null +++ b/ui/src/app/update-schedule-dto.ts @@ -0,0 +1,227 @@ +// Normalizes the Gateway's update-availability and update-schedule payloads into +// the shapes the Control UI renders. These readers are the trust boundary for +// wire data, so they stay separate from the lifecycle controllers that consume them. +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import type { GatewayHelloOk } from "../api/gateway.ts"; +import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts"; + +export function readUpdateAvailable(hello: GatewayHelloOk | null): UpdateAvailable | null { + const snapshot = hello?.snapshot; + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { + return null; + } + const update = (snapshot as { updateAvailable?: unknown }).updateAvailable; + return readUpdateAvailableValue(update); +} + +export function readUpdateAvailableValue(update: unknown): UpdateAvailable | null { + if (!isRecord(update)) { + return null; + } + const rawCommits = update.commits; + const commits = + Array.isArray(rawCommits) && + rawCommits.length <= 5 && + rawCommits.every( + (commit): commit is { sha: string; subject: string } => + isRecord(commit) && + typeof commit.sha === "string" && + commit.sha.length > 0 && + typeof commit.subject === "string" && + commit.subject.length <= 120, + ) + ? rawCommits.map((commit) => ({ sha: commit.sha, subject: commit.subject })) + : undefined; + return typeof update.currentVersion === "string" && + typeof update.latestVersion === "string" && + typeof update.channel === "string" + ? { + currentVersion: update.currentVersion, + latestVersion: update.latestVersion, + channel: update.channel, + ...(typeof update.currentSha === "string" ? { currentSha: update.currentSha } : {}), + ...(typeof update.upstreamRef === "string" ? { upstreamRef: update.upstreamRef } : {}), + ...(typeof update.upstreamSha === "string" ? { upstreamSha: update.upstreamSha } : {}), + ...(Number.isInteger(update.commitsBehind) && Number(update.commitsBehind) >= 0 + ? { commitsBehind: Number(update.commitsBehind) } + : {}), + ...(commits ? { commits } : {}), + } + : null; +} + +function readScheduleTarget(value: unknown): UpdateScheduleState["target"] | null { + if (!isRecord(value)) { + return null; + } + if (value.kind === "package" && typeof value.version === "string") { + return { kind: "package", version: value.version }; + } + if ( + value.kind === "git" && + typeof value.upstreamRef === "string" && + typeof value.upstreamSha === "string" && + Number.isInteger(value.commitsBehind) && + Number(value.commitsBehind) >= 0 + ) { + return { + kind: "git", + upstreamRef: value.upstreamRef, + upstreamSha: value.upstreamSha, + commitsBehind: Number(value.commitsBehind), + }; + } + return null; +} + +function readGitInstallMetadata(value: Record): { + currentSha?: string; + commitAtMs?: number; + installedAtMs?: number; +} | null { + if ( + (value.currentSha !== undefined && + (typeof value.currentSha !== "string" || value.currentSha.length === 0)) || + (value.commitAtMs !== undefined && + (!Number.isInteger(value.commitAtMs) || Number(value.commitAtMs) < 0)) || + (value.installedAtMs !== undefined && + (!Number.isInteger(value.installedAtMs) || Number(value.installedAtMs) < 0)) + ) { + return null; + } + return { + ...(typeof value.currentSha === "string" ? { currentSha: value.currentSha } : {}), + ...(value.commitAtMs === undefined ? {} : { commitAtMs: Number(value.commitAtMs) }), + ...(value.installedAtMs === undefined ? {} : { installedAtMs: Number(value.installedAtMs) }), + }; +} + +function readGitUpdateStatus( + value: unknown, +): NonNullable["git"]> | null { + if (!isRecord(value)) { + return null; + } + const metadata = readGitInstallMetadata(value); + if (!metadata) { + return null; + } + if (value.status === "current") { + return { ...metadata, status: "current" }; + } + if ( + value.status === "behind" && + Number.isInteger(value.commitsBehind) && + Number(value.commitsBehind) > 0 + ) { + return { ...metadata, status: "behind", commitsBehind: Number(value.commitsBehind) }; + } + if ( + value.status === "ahead" && + Number.isInteger(value.commitsAhead) && + Number(value.commitsAhead) > 0 + ) { + return { ...metadata, status: "ahead", commitsAhead: Number(value.commitsAhead) }; + } + if ( + value.status === "diverged" && + Number.isInteger(value.commitsAhead) && + Number(value.commitsAhead) > 0 && + Number.isInteger(value.commitsBehind) && + Number(value.commitsBehind) > 0 + ) { + return { + ...metadata, + status: "diverged", + commitsAhead: Number(value.commitsAhead), + commitsBehind: Number(value.commitsBehind), + }; + } + if ( + value.status === "unavailable" && + (value.reason === "fetch-failed" || + value.reason === "no-upstream" || + value.reason === "no-upstream-sha" || + value.reason === "comparison-failed" || + value.reason === "git-unavailable") + ) { + return { ...metadata, status: "unavailable", reason: value.reason }; + } + return null; +} + +function readScheduleCampaign(value: unknown): UpdateScheduleState["campaign"] | null { + if ( + !isRecord(value) || + typeof value.id !== "string" || + (value.state !== "waiting-for-idle" && + value.state !== "countdown" && + value.state !== "applying") || + !Number.isInteger(value.announcedAtMs) || + Number(value.announcedAtMs) < 0 || + !Number.isInteger(value.forceAtMs) || + Number(value.forceAtMs) < 0 || + !Number.isInteger(value.updatedAtMs) || + Number(value.updatedAtMs) < 0 || + (value.applyAtMs !== undefined && + (!Number.isInteger(value.applyAtMs) || Number(value.applyAtMs) < 0)) || + (value.holdUntilMs !== undefined && + (!Number.isInteger(value.holdUntilMs) || Number(value.holdUntilMs) < 0)) + ) { + return null; + } + return { + id: value.id, + state: value.state, + announcedAtMs: Number(value.announcedAtMs), + ...(value.applyAtMs === undefined ? {} : { applyAtMs: Number(value.applyAtMs) }), + ...(value.holdUntilMs === undefined ? {} : { holdUntilMs: Number(value.holdUntilMs) }), + forceAtMs: Number(value.forceAtMs), + updatedAtMs: Number(value.updatedAtMs), + }; +} + +export function readUpdateScheduleValue(value: unknown): UpdateScheduleState | null { + if ( + !isRecord(value) || + typeof value.channel !== "string" || + typeof value.autoEnabled !== "boolean" + ) { + return null; + } + const rawInstall = isRecord(value.install) ? value.install : null; + const rawInstallKind = rawInstall?.kind; + const installKind = + rawInstallKind === "package" || rawInstallKind === "git" || rawInstallKind === "unknown" + ? rawInstallKind + : undefined; + if (value.install !== undefined && installKind === undefined) { + return null; + } + const gitStatus = rawInstall?.git === undefined ? undefined : readGitUpdateStatus(rawInstall.git); + if (rawInstall?.git !== undefined && !gitStatus) { + return null; + } + const target = value.target === undefined ? undefined : readScheduleTarget(value.target); + const campaign = value.campaign === undefined ? undefined : readScheduleCampaign(value.campaign); + if ((value.target !== undefined && !target) || (value.campaign !== undefined && !campaign)) { + return null; + } + return { + channel: value.channel, + autoEnabled: value.autoEnabled, + ...(installKind + ? { install: { kind: installKind, ...(gitStatus ? { git: gitStatus } : {}) } } + : {}), + ...(target ? { target } : {}), + ...(campaign ? { campaign } : {}), + }; +} + +export function readUpdateSchedule(hello: GatewayHelloOk | null): UpdateScheduleState | null { + const snapshot = hello?.snapshot; + if (!isRecord(snapshot)) { + return null; + } + return readUpdateScheduleValue(snapshot.updateSchedule); +} diff --git a/ui/src/app/update-success-notice.test.ts b/ui/src/app/update-success-notice.test.ts new file mode 100644 index 000000000000..176be6b2f10b --- /dev/null +++ b/ui/src/app/update-success-notice.test.ts @@ -0,0 +1,37 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getSafeSessionStorageMock, reloadControlUiIfStaleMock, showToastMock } = vi.hoisted(() => ({ + getSafeSessionStorageMock: vi.fn(), + reloadControlUiIfStaleMock: vi.fn(), + showToastMock: vi.fn(), +})); + +vi.mock("../build-info.ts", () => ({ + reloadControlUiIfStale: reloadControlUiIfStaleMock, +})); +vi.mock("../i18n/index.ts", () => ({ + t: (_key: string, params?: Record) => `Gateway updated · now on ${params?.sha}.`, +})); +vi.mock("../lib/toast.ts", () => ({ showToast: showToastMock })); +vi.mock("../local-storage.ts", () => ({ + getSafeSessionStorage: getSafeSessionStorageMock, +})); + +import { announceVerifiedUpdateInstall } from "./update-success-notice.ts"; + +describe("update success notice", () => { + beforeEach(() => { + vi.clearAllMocks(); + getSafeSessionStorageMock.mockReturnValue(null); + reloadControlUiIfStaleMock.mockReturnValue(false); + }); + + it("announces a non-reloading success when session storage is unavailable", () => { + announceVerifiedUpdateInstall({ version: "2026.8.11", sha: "abcdef1234567890" }); + + expect(showToastMock).toHaveBeenCalledWith({ + message: "Gateway updated · now on abcdef1.", + }); + }); +}); diff --git a/ui/src/app/update-success-notice.ts b/ui/src/app/update-success-notice.ts new file mode 100644 index 000000000000..9e87f1b3f41f --- /dev/null +++ b/ui/src/app/update-success-notice.ts @@ -0,0 +1,66 @@ +// A verified install whose bundle differs from this document reloads the page +// (`reloadControlUiIfStale`), which destroys any in-memory outcome. Record the +// result before that navigation so the reloaded document still tells the +// operator the update finished instead of coming back silently. +import { reloadControlUiIfStale } from "../build-info.ts"; +import { t } from "../i18n/index.ts"; +import { showToast } from "../lib/toast.ts"; +import { getSafeSessionStorage } from "../local-storage.ts"; + +const UPDATE_SUCCESS_NOTICE_KEY = "openclaw:control-ui:update-succeeded:v1"; + +type UpdateInstallIdentity = { version: string | null; sha: string | null }; + +function formatUpdateSuccess(identity: UpdateInstallIdentity): string { + // A git install keeps its version across commits, so the commit is the only + // fact that actually changed; package installs report no commit at all. + const sha = identity.sha?.trim(); + if (sha) { + return t("updates.succeededCommit", { sha: sha.slice(0, 7) }); + } + const version = identity.version?.trim(); + return version ? t("updates.succeededVersion", { version }) : t("updates.succeeded"); +} + +function takeRecordedUpdateSuccess(): string | null { + try { + const storage = getSafeSessionStorage(); + const raw = storage?.getItem(UPDATE_SUCCESS_NOTICE_KEY) ?? null; + storage?.removeItem(UPDATE_SUCCESS_NOTICE_KEY); + return raw; + } catch { + return null; + } +} + +/** Records the outcome, then presents it here unless a reload will present it. */ +export function announceVerifiedUpdateInstall(identity: UpdateInstallIdentity): void { + try { + getSafeSessionStorage()?.setItem(UPDATE_SUCCESS_NOTICE_KEY, JSON.stringify(identity)); + } catch { + // Storage is best effort; a document that stays put still announces below. + } + if (!reloadControlUiIfStale(identity)) { + takeRecordedUpdateSuccess(); + showToast({ message: formatUpdateSuccess(identity) }); + } +} + +/** Presents a recorded install outcome once, then forgets it. */ +export function announceRecordedUpdateSuccess(): void { + const raw = takeRecordedUpdateSuccess(); + if (!raw) { + return; + } + let identity: UpdateInstallIdentity; + try { + const parsed = JSON.parse(raw) as Partial; + identity = { + version: typeof parsed.version === "string" ? parsed.version : null, + sha: typeof parsed.sha === "string" ? parsed.sha : null, + }; + } catch { + return; + } + showToast({ message: formatUpdateSuccess(identity) }); +} diff --git a/ui/src/build-info.ts b/ui/src/build-info.ts index cce13bbb7288..cd85d59e7c56 100644 --- a/ui/src/build-info.ts +++ b/ui/src/build-info.ts @@ -13,16 +13,20 @@ declare global { export const CONTROL_UI_BUILD_INFO = globalThis.OPENCLAW_CONTROL_UI_BUILD_INFO ?? normalizeControlUiBuildInfo(undefined); +/** Reports whether the reload was started, so callers can tell an outcome they + * still have to present from one the reloaded document will present instead. */ export function reloadControlUiIfStale(identity: { version: string | null; sha: string | null; -}): void { +}): boolean { if ( typeof window !== "undefined" && controlUiVersionDiffersFrom(identity.version ?? undefined, identity.sha ?? undefined) ) { window.location.reload(); + return true; } + return false; } export function controlUiVersionDiffersFrom( diff --git a/ui/src/components/app-sidebar-base.ts b/ui/src/components/app-sidebar-base.ts index fd796625b025..edf5e6ad0585 100644 --- a/ui/src/components/app-sidebar-base.ts +++ b/ui/src/components/app-sidebar-base.ts @@ -11,6 +11,8 @@ import { } from "../app/context.ts"; import type { CatalogOpenTarget } from "../app/settings.ts"; import type { ThemeMode } from "../app/theme.ts"; +import type { UpdateProgress } from "../app/update-confirmation.ts"; +import type { ApplicationStatusBanner } from "../app/update-overlay-helpers.ts"; import { readSessionMethodAccess, type SessionMethodAccess } from "../lib/session-method-access.ts"; import { prepareSessionNavigationHandoff } from "../lib/sessions/navigation-handoff.ts"; import { SESSION_NAVIGATION_KEY_PARAM } from "../lib/sessions/route-navigation.ts"; @@ -50,7 +52,11 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement { @property({ attribute: false }) updateAvailable: UpdateAvailable | null = null; @property({ attribute: false }) updateSchedule: UpdateScheduleState | null = null; @property({ attribute: false }) heldUpdateCampaignId: string | null = null; - @property({ attribute: false }) updateRunning = false; + @property({ attribute: false }) updateBusy = false; + @property({ attribute: false }) updateStatusBanner: ApplicationStatusBanner | null = null; + @property({ attribute: false }) watchUpdateProgress: + | ((listener: (progress: UpdateProgress) => void) => () => void) + | undefined = undefined; @property({ attribute: false }) canUpdate = false; @property({ attribute: false }) canHoldUpdate = false; @property({ attribute: false }) onUpdate: () => void = () => undefined; diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index f3395fa349f0..da3f1edf904c 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -535,7 +535,9 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi .updateAvailable=${this.updateAvailable} .updateSchedule=${this.updateSchedule} .heldUpdateCampaignId=${this.heldUpdateCampaignId} - .updateRunning=${this.updateRunning} + .updateBusy=${this.updateBusy} + .statusBanner=${this.updateStatusBanner} + .watchUpdateProgress=${this.watchUpdateProgress} .canUpdate=${this.canUpdate} .canHoldUpdate=${this.canHoldUpdate} .onUpdate=${this.onUpdate} diff --git a/ui/src/components/settings-sidebar.test.ts b/ui/src/components/settings-sidebar.test.ts index 1bb99cd7de38..4ac6381fb342 100644 --- a/ui/src/components/settings-sidebar.test.ts +++ b/ui/src/components/settings-sidebar.test.ts @@ -47,7 +47,7 @@ describe("settings sidebar search", () => { lastError: null, gatewayVersion: "", updateAvailable: null, - updateRunning: false, + updateBusy: false, onUpdate: vi.fn(), ...inactiveRefresh, searchQuery: "", @@ -79,7 +79,7 @@ describe("settings sidebar search", () => { lastError: null, gatewayVersion: "", updateAvailable: null, - updateRunning: false, + updateBusy: false, onUpdate: vi.fn(), ...inactiveRefresh, searchQuery: "", @@ -110,7 +110,7 @@ describe("settings sidebar search", () => { lastError: null, gatewayVersion: "", updateAvailable: null, - updateRunning: false, + updateBusy: false, onUpdate: vi.fn(), ...inactiveRefresh, searchQuery: "cp", @@ -149,7 +149,7 @@ describe("settings sidebar search", () => { lastError: null, gatewayVersion: "", updateAvailable: null, - updateRunning: false, + updateBusy: false, onUpdate: vi.fn(), ...inactiveRefresh, searchQuery: "mcp", @@ -205,7 +205,7 @@ describe("settings sidebar search", () => { lastError: null, gatewayVersion: "", updateAvailable: null, - updateRunning: false, + updateBusy: false, onUpdate: vi.fn(), ...inactiveRefresh, searchQuery: "infrastructure", @@ -254,7 +254,7 @@ describe("settings sidebar search", () => { lastError: null, gatewayVersion: "", updateAvailable: null, - updateRunning: false, + updateBusy: false, onUpdate: vi.fn(), ...inactiveRefresh, searchQuery: "agent defaults", @@ -286,7 +286,7 @@ describe("settings sidebar search", () => { lastError: null, gatewayVersion: "", updateAvailable: null, - updateRunning: false, + updateBusy: false, onUpdate: vi.fn(), ...inactiveRefresh, searchQuery: "backend", @@ -332,7 +332,7 @@ describe("settings sidebar search", () => { lastError: null, gatewayVersion: "", updateAvailable: null, - updateRunning: false, + updateBusy: false, onUpdate: vi.fn(), ...inactiveRefresh, searchQuery, @@ -415,7 +415,7 @@ describe("settings sidebar search", () => { lastError: null, gatewayVersion: "", updateAvailable: null, - updateRunning: false, + updateBusy: false, onUpdate: vi.fn(), ...inactiveRefresh, searchQuery: "", @@ -453,7 +453,7 @@ describe("settings sidebar search", () => { latestVersion: "2.0.0", channel: "stable", }, - updateRunning: false, + updateBusy: false, canUpdate: true, onUpdate, refreshRequired: true, @@ -504,7 +504,7 @@ describe("settings sidebar search", () => { lastError, gatewayVersion: "1.0.0", updateAvailable: null, - updateRunning: false, + updateBusy: false, onUpdate: vi.fn(), ...inactiveRefresh, searchQuery: "", diff --git a/ui/src/components/settings-sidebar.ts b/ui/src/components/settings-sidebar.ts index c510829d7359..515913a44768 100644 --- a/ui/src/components/settings-sidebar.ts +++ b/ui/src/components/settings-sidebar.ts @@ -16,6 +16,8 @@ import { } from "../app-navigation.ts"; import { pathForRoute, type RouteId } from "../app-route-paths.ts"; import type { ApplicationNavigationOptions } from "../app/context.ts"; +import type { UpdateProgress } from "../app/update-confirmation.ts"; +import type { ApplicationStatusBanner } from "../app/update-overlay-helpers.ts"; import { t } from "../i18n/index.ts"; import { shouldHandleNavigationClick } from "../lib/navigation-click.ts"; import { normalizeLowercaseStringOrEmpty } from "../lib/string-coerce.ts"; @@ -40,7 +42,9 @@ type SettingsSidebarProps = { updateAvailable: UpdateAvailable | null; updateSchedule?: UpdateScheduleState | null; heldUpdateCampaignId?: string | null; - updateRunning: boolean; + updateBusy: boolean; + updateStatusBanner?: ApplicationStatusBanner | null; + watchUpdateProgress?: (listener: (progress: UpdateProgress) => void) => () => void; canUpdate?: boolean; canHoldUpdate?: boolean; onUpdate: () => void; @@ -312,7 +316,9 @@ export function renderSettingsSidebar(props: SettingsSidebarProps) { .updateAvailable=${props.updateAvailable} .updateSchedule=${props.updateSchedule ?? null} .heldUpdateCampaignId=${props.heldUpdateCampaignId ?? null} - .updateRunning=${props.updateRunning} + .updateBusy=${props.updateBusy} + .statusBanner=${props.updateStatusBanner ?? null} + .watchUpdateProgress=${props.watchUpdateProgress} .canUpdate=${props.canUpdate ?? false} .canHoldUpdate=${props.canHoldUpdate ?? false} .onUpdate=${props.onUpdate} diff --git a/ui/src/components/sidebar-update-card.test.ts b/ui/src/components/sidebar-update-card.test.ts index 4f0594290099..89a18c151329 100644 --- a/ui/src/components/sidebar-update-card.test.ts +++ b/ui/src/components/sidebar-update-card.test.ts @@ -35,7 +35,7 @@ type SidebarUpdateCardElement = HTMLElement & { updateAvailable: UpdateAvailable | null; updateSchedule: UpdateScheduleState | null; heldUpdateCampaignId: string | null; - updateRunning: boolean; + updateBusy: boolean; canUpdate: boolean; canHoldUpdate: boolean; onUpdate: () => void; @@ -303,11 +303,11 @@ describe("SidebarUpdateCard", () => { window.dispatchEvent(new CustomEvent(NATIVE_UPDATE_DECLINED_EVENT)); expect(onUpdate).toHaveBeenCalledOnce(); - element.updateRunning = true; + element.updateBusy = true; window.dispatchEvent(new CustomEvent(NATIVE_UPDATE_DECLINED_EVENT)); expect(onUpdate).toHaveBeenCalledOnce(); - element.updateRunning = false; + element.updateBusy = false; element.updateAvailable = null; window.dispatchEvent(new CustomEvent(NATIVE_UPDATE_DECLINED_EVENT)); expect(onUpdate).toHaveBeenCalledOnce(); @@ -376,18 +376,37 @@ describe("SidebarUpdateCard", () => { expect(postMessage).not.toHaveBeenCalled(); }); - it("disables the action while updating", async () => { - const element = await mount({ - currentVersion: "1.0.0", - latestVersion: "2.0.0", - channel: "stable", - }); - element.updateRunning = true; - await element.updateComplete; + it("narrates the whole update, including after the Gateway drops its metadata", async () => { + const element = await mount( + { currentVersion: "1.0.0", latestVersion: "1.0.0", channel: "dev", commitsBehind: 246 }, + { + channel: "dev", + autoEnabled: false, + target: { + kind: "git", + upstreamRef: "origin/main", + upstreamSha: "abc1234def", + commitsBehind: 246, + }, + }, + ); + expect(element.textContent).toContain("246 commits behind"); + element.updateBusy = true; + await element.updateComplete; const action = element.querySelector(".sidebar-update-card__action"); expect(action?.disabled).toBe(true); - expect(action?.textContent).toContain("Updating…"); + expect(action?.textContent).toContain("Updating Gateway…"); + // The stale call to action must not survive into the install. + expect(element.textContent).not.toContain("246 commits behind"); + expect(element.querySelector(".sidebar-update-card__dismiss")).toBeNull(); + + // The restarting Gateway takes its update metadata with it; the card is the + // operator's only remaining sign that an install is still running. + element.updateAvailable = null; + element.updateSchedule = null; + await element.updateComplete; + expect(element.textContent).toContain("Updating Gateway…"); }); it("renders a quiet live countdown, hides dismissal, and stops ticking on disconnect", async () => { @@ -425,10 +444,10 @@ describe("SidebarUpdateCard", () => { "Hold 1 h", ); - element.updateRunning = true; + element.updateBusy = true; await element.updateComplete; expect(element.querySelector(".sidebar-update-card__hold")).toBeNull(); - element.updateRunning = false; + element.updateBusy = false; await element.updateComplete; expect(element.querySelector(".sidebar-update-card__hold")).not.toBeNull(); diff --git a/ui/src/components/sidebar-update-card.ts b/ui/src/components/sidebar-update-card.ts index 66054bb8a143..d4e16667517a 100644 --- a/ui/src/components/sidebar-update-card.ts +++ b/ui/src/components/sidebar-update-card.ts @@ -6,10 +6,11 @@ import { NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT, NATIVE_UPDATE_DECLINED_EVENT, } from "../app/native-link-routing.ts"; -import { confirmAndStartUpdate } from "../app/update-confirmation.ts"; +import { confirmAndStartUpdate, type UpdateProgress } from "../app/update-confirmation.ts"; import { formatUpdateCampaignLabel, formatUpdateTargetLabel, + type ApplicationStatusBanner, } from "../app/update-overlay-helpers.ts"; import { t } from "../i18n/index.ts"; import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts"; @@ -61,7 +62,11 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement { @property({ attribute: false }) updateAvailable: UpdateAvailable | null = null; @property({ attribute: false }) updateSchedule: UpdateScheduleState | null = null; @property({ attribute: false }) heldUpdateCampaignId: string | null = null; - @property({ attribute: false }) updateRunning = false; + @property({ attribute: false }) updateBusy = false; + @property({ attribute: false }) statusBanner: ApplicationStatusBanner | null = null; + @property({ attribute: false }) watchUpdateProgress: + | ((listener: (progress: UpdateProgress) => void) => () => void) + | undefined = undefined; @property({ attribute: false }) canUpdate = false; @property({ attribute: false }) canHoldUpdate = false; @property({ attribute: false }) onUpdate: () => void = () => undefined; @@ -91,7 +96,7 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement { this.nativeUpdateAvailable = false; if ( (this.updateAvailable || this.updateSchedule?.campaign) && - !this.updateRunning && + !this.updateBusy && this.canUpdate && !this.refreshRequired ) { @@ -130,12 +135,27 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement { } } + private renderStatus() { + const statusBanner = this.statusBanner; + // The Gateway recorded this outcome; unlike the client's own update + // metadata it stays true even when this client is stale. + return statusBanner + ? html`` + : nothing; + } + override render() { // A stale client cannot trust its own update metadata, so refresh takes precedence // over any available update it may still report. if (this.refreshRequired) { return html` - ${campaign || !update + ${text} + + ${showHold && campaign + ? html` + + ` + : nothing} + ` + : nothing} + ${campaign || busy || !update || statusBanner ? nothing : html`