From e349d520a28677e62ac1bbabe26db654f32299cf Mon Sep 17 00:00:00 2001 From: "Vyctor H. Brzezowski" Date: Tue, 18 Aug 2026 05:31:21 -0300 Subject: [PATCH] feat(ui): add typed update recovery actions (#125098) * feat(ui): add typed update recovery actions * fix(ui): surface update status refresh failures * fix(ui): keep update failure type internal * fix(ui): publish update failure facts atomically * fix(ui): validate update recovery startup budget --------- Co-authored-by: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com> --- .../control-ui-startup-budget-baseline.json | 6 +- docs/.i18n/glossary.zh-CN.json | 4 + docs/docs.json | 1 + docs/install/update-troubleshooting.md | 87 ++++++++++++++ docs/install/updating.md | 4 + ui/src/app/app-host-pairing-access.test.ts | 1 + ui/src/app/app-host.dock-suppression.test.ts | 1 + ui/src/app/app-shell-view.ts | 3 + ui/src/app/navigation-surface.ts | 2 + ui/src/app/overlays-types.ts | 4 +- ui/src/app/overlays-update-campaign.test.ts | 28 +++++ ui/src/app/overlays.ts | 41 ++++++- ui/src/app/update-overlay-helpers.test.ts | 46 ++++++++ ui/src/app/update-overlay-helpers.ts | 91 +++++++++++++-- ui/src/components/app-sidebar-base.ts | 1 + ui/src/components/app-sidebar.ts | 1 + ui/src/components/settings-sidebar.ts | 2 + ui/src/components/sidebar-update-card.test.ts | 15 +++ ui/src/components/sidebar-update-card.ts | 10 ++ ui/src/e2e/updates-settings.e2e.test.ts | 56 +++++++++ ui/src/i18n/.i18n/raw-copy-baseline.json | 7 ++ ui/src/i18n/locales/en.ts | 15 +++ ui/src/pages/config/config-page.ts | 7 +- ui/src/pages/config/updates.test.ts | 43 +++++++ ui/src/pages/config/updates.ts | 109 +++++++++++++++++- ui/src/styles/layout.css | 17 +++ ui/src/styles/settings.css | 20 ++++ 27 files changed, 607 insertions(+), 15 deletions(-) create mode 100644 docs/install/update-troubleshooting.md diff --git a/config/control-ui-startup-budget-baseline.json b/config/control-ui-startup-budget-baseline.json index b99053951129..3327b1d3a967 100644 --- a/config/control-ui-startup-budget-baseline.json +++ b/config/control-ui-startup-budget-baseline.json @@ -1,5 +1,5 @@ { - "startupJsGzipBytes": 340994, - "reason": "Ask OpenClaw companion: shell chrome toggle, palette action, scope-aware gating", - "updatedAt": "2026-08-17" + "startupJsGzipBytes": 343289, + "reason": "Typed update recovery state, failure projection, and sidebar review routing", + "updatedAt": "2026-08-18" } diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index 0a367f597469..1e55b1991109 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -1567,6 +1567,10 @@ "source": "Troubleshooting", "target": "故障排查" }, + { + "source": "Update troubleshooting", + "target": "更新故障排查" + }, { "source": "Webhooks", "target": "Webhooks" diff --git a/docs/docs.json b/docs/docs.json index 17111756f650..943a53dba523 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1088,6 +1088,7 @@ "pages": [ "install/backups", "install/updating", + "install/update-troubleshooting", { "group": "Migrating", "pages": [ diff --git a/docs/install/update-troubleshooting.md b/docs/install/update-troubleshooting.md new file mode 100644 index 000000000000..8eeab3fff4df --- /dev/null +++ b/docs/install/update-troubleshooting.md @@ -0,0 +1,87 @@ +--- +summary: "Recover from failed OpenClaw updates in the Control UI or CLI" +read_when: + - An OpenClaw update failed + - The Gateway did not report a final update result +title: "Update troubleshooting" +--- + +Start in **Control UI → Settings → Updates**. The page reads the latest recorded +update attempt from the connected Gateway and shows its time, target, reason +code, failing step, and bounded diagnostic detail. + +Control UI remediation uses typed product actions only. It leads with an +authenticated Gateway or native action when the connected UI has the required +capability and scope, preserves confirmations for disruptive operations, and +keeps terminal commands as secondary host-side fallbacks. It never parses +localized guidance or executes an arbitrary command string. + +## Recover in the Control UI + +1. Select **Check status** when the Gateway restarted, disconnected, or did not + report a final result. This reads `update.status`; it does not start another + update. Recovery controls stay disabled while the check is pending, and a + rejected request appears as an error on the page. +2. Open **View details** and address the recorded failing step. Diagnostic text + is bounded and redacted for display; use Gateway logs when more context is + required. +3. Select **Retry update** only after the cause is resolved. The Control UI uses + the normal confirmed update flow and states that running sessions are + interrupted while the Gateway restarts. + +The controls require a connected Gateway, support for the corresponding typed +Gateway method, and administrator scope. When those conditions are not met, use +the CLI fallback on the Gateway host. + +## Reason codes + +- `dirty`, `no-upstream`: repair the source checkout before retrying. +- `deps-install-failed`, `build-failed`, `ui-build-failed`: inspect the failing + step, fix the dependency or build error, then retry. +- `global-install-failed`: retry after checking package-manager ownership and + permissions. Re-run the installer if the package install is incomplete. +- `doctor-failed`: run Doctor on the Gateway host, resolve its findings, then + retry. +- `restart-disabled`, `restart-unavailable`: restore a supported supervisor or + enable Gateway restarts before retrying. +- `restart-unhealthy`, `restart-revision-mismatch`, + `restart-revision-unavailable`: inspect Gateway service health and its install + root before retrying. +- `managed-service-handoff-*`: check status first. If the handoff stopped, use + the CLI on the Gateway host to preserve the full diagnostic output. + +Unknown reason codes remain visible. Check the Gateway logs before retrying. + +## CLI fallback + +Run these commands on the Gateway host, not on the computer that merely has the +Control UI open: + +```bash +openclaw update status --json +openclaw doctor --non-interactive +openclaw update +``` + +Use `openclaw update --dry-run` to preview a new attempt. If a package update +failed after installation began, follow the installer recovery steps in +[Updating](/install/updating#alternative-re-run-the-installer). + +## Rollback boundary + +Do not restore state as the first response to an update failure. First reinstall +known-good code while preserving current state. Restore a verified pre-update +state snapshot only when older code cannot read the current config or database. +See [Rollback](/install/updating#rollback). + +## Support diagnostics + +Collect the following without posting credentials, raw config, or unredacted +process output: + +- OpenClaw version and install type; +- update timestamp, target, phase, and reason code from Settings → Updates; +- the bounded failure detail shown by **View details**; +- `openclaw update status --json`; +- `openclaw gateway status --deep --json`; +- relevant redacted Gateway log lines. diff --git a/docs/install/updating.md b/docs/install/updating.md index cc286ff0ce45..5f26c33cd4f0 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -280,6 +280,10 @@ Off by default. Enable it in `~/.openclaw/openclaw.json`: You can also choose the update channel and enable automatic updates from **Settings → Updates** (`/settings/updates`) in the Control UI. +Recorded failures on that page include typed **Check status** and **Retry +update** actions when the connected Gateway supports them. See [Update +troubleshooting](/install/update-troubleshooting) for reason codes, guided +recovery, CLI fallbacks, and diagnostics to collect. For a `dev` git install, opening this page refreshes the tracked upstream and shows whether the checkout is current, ahead, diverged, unavailable, or a specific number of commits behind. It also shows exact and relative build, diff --git a/ui/src/app/app-host-pairing-access.test.ts b/ui/src/app/app-host-pairing-access.test.ts index 1947c5e20e16..313e8d99c0ef 100644 --- a/ui/src/app/app-host-pairing-access.test.ts +++ b/ui/src/app/app-host-pairing-access.test.ts @@ -67,6 +67,7 @@ function createPairingShell(params: { updateAvailable: null, updateRunning: false, updateStatusBanner: null, + recordedUpdateAttempt: null, controlUiRefreshRequired: false, }; const context = { diff --git a/ui/src/app/app-host.dock-suppression.test.ts b/ui/src/app/app-host.dock-suppression.test.ts index 37ae62dd3136..15324a198003 100644 --- a/ui/src/app/app-host.dock-suppression.test.ts +++ b/ui/src/app/app-host.dock-suppression.test.ts @@ -99,6 +99,7 @@ describe("OpenClaw shell dock suppression", () => { updateAvailable: null, updateRunning: false, updateStatusBanner: null, + recordedUpdateAttempt: null, controlUiRefreshRequired: false, approvalQueue: [], approvalBusy: false, diff --git a/ui/src/app/app-shell-view.ts b/ui/src/app/app-shell-view.ts index 5773e80d588e..b52ba33eb938 100644 --- a/ui/src/app/app-shell-view.ts +++ b/ui/src/app/app-shell-view.ts @@ -398,6 +398,7 @@ export function renderApplicationShell(host: ShellViewHost) { refreshRequired: navigationSurfaceHidden ? false : overlaySnapshot.controlUiRefreshRequired, onRefresh: () => host.refreshControlUi(), onHoldUpdate: () => context.overlays.holdUpdate(), + onReviewUpdate: () => host.navigate("updates"), onOpenApprovals: () => host.openApprovals(), onRetryConnect: () => context.gateway.connect(), onOpenNewSession: openNewSession, @@ -434,6 +435,7 @@ export function renderApplicationShell(host: ShellViewHost) { refreshRequired: navigationSurfaceHidden ? false : overlaySnapshot.controlUiRefreshRequired, onRefresh: () => host.refreshControlUi(), onHoldUpdate: () => context.overlays.holdUpdate(), + onReviewUpdate: () => host.navigate("updates"), searchQuery: host.settingsSearchQuery, searchBlockMatches: settingsSearchBlocks, onExit: () => host.exitSettings(), @@ -621,6 +623,7 @@ export function renderApplicationShell(host: ShellViewHost) { refreshRequired: overlaySnapshot.controlUiRefreshRequired, onRefresh: () => host.refreshControlUi(), onHoldUpdate: () => context.overlays.holdUpdate(), + onReviewUpdate: () => host.navigate("updates"), })} ${pageActionsBlocked && gatewaySnapshot.phase !== "reload-required" ? html`
diff --git a/ui/src/app/navigation-surface.ts b/ui/src/app/navigation-surface.ts index 925be1bd8980..261a554a49dc 100644 --- a/ui/src/app/navigation-surface.ts +++ b/ui/src/app/navigation-surface.ts @@ -28,6 +28,7 @@ export function renderFloatingUpdateCard(params: { refreshRequired: boolean; onRefresh: () => void; onHoldUpdate?: () => Promise; + onReviewUpdate?: () => void; }) { // A stale client must always have a visible refresh action, including during // onboarding, even though update-available actions stay hidden there. @@ -48,5 +49,6 @@ export function renderFloatingUpdateCard(params: { .refreshRequired=${params.refreshRequired} .onRefresh=${params.onRefresh} .onHoldUpdate=${params.onHoldUpdate ?? (async () => false)} + .onReviewUpdate=${params.onReviewUpdate ?? (() => undefined)} >`; } diff --git a/ui/src/app/overlays-types.ts b/ui/src/app/overlays-types.ts index 4a856cbb3ef7..a1427a0318fc 100644 --- a/ui/src/app/overlays-types.ts +++ b/ui/src/app/overlays-types.ts @@ -1,15 +1,17 @@ import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts"; import type { DevicePairSetupAccess, DevicePairSetupLifecycle } from "../lib/device-pair-setup.ts"; import type { ExecApprovalDecision, ExecApprovalRequest } from "./exec-approval.ts"; -import type { ApplicationStatusBanner } from "./update-overlay-helpers.ts"; +import type { ApplicationStatusBanner, RecordedUpdateAttempt } from "./update-overlay-helpers.ts"; export type ApplicationOverlaySnapshot = { updateAvailable: UpdateAvailable | null; updateSchedule: UpdateScheduleState | null; heldUpdateCampaignId: string | null; updateRunning: boolean; + updateStatusRefreshing: boolean; updateReconciliationPending: boolean; updateStatusBanner: ApplicationStatusBanner | null; + recordedUpdateAttempt: RecordedUpdateAttempt | null; controlUiRefreshRequired: boolean; approvalQueue: readonly ExecApprovalRequest[]; approvalBusy: boolean; diff --git a/ui/src/app/overlays-update-campaign.test.ts b/ui/src/app/overlays-update-campaign.test.ts index c779ca46ebff..9701247d3f11 100644 --- a/ui/src/app/overlays-update-campaign.test.ts +++ b/ui/src/app/overlays-update-campaign.test.ts @@ -4,6 +4,7 @@ import type { ApplicationGatewaySnapshot } from "./gateway.ts"; import { client, createGatewayHarness, + deferred, flushMicrotasks, type RequestFn, } from "./overlays-access.test-support.ts"; @@ -49,6 +50,33 @@ describe("application update campaign overlays", () => { overlays.dispose(); }); + it("publishes pending and error state when a manual status refresh fails", async () => { + const updateStatus = deferred(); + const request = vi.fn((method) => + method === "update.status" ? updateStatus.promise : Promise.resolve({}), + ); + const harness = createGatewayHarness(client(request)); + harness.update({ + hello: { + auth: { role: "operator", scopes: ["operator.admin"] }, + } as ApplicationGatewaySnapshot["hello"], + }); + const overlays = createApplicationOverlays(harness.gateway); + + const refresh = overlays.refreshUpdateStatus(); + expect(overlays.snapshot.updateStatusRefreshing).toBe(true); + + updateStatus.reject(new Error("Gateway unavailable")); + await refresh; + + expect(overlays.snapshot.updateStatusRefreshing).toBe(false); + expect(overlays.snapshot.updateStatusBanner).toEqual({ + tone: "danger", + text: expect.stringContaining("Gateway unavailable"), + }); + overlays.dispose(); + }); + it("hydrates campaign state from hello and update.available events", () => { const harness = createGatewayHarness(client(async () => ({}))); harness.update({ diff --git a/ui/src/app/overlays.ts b/ui/src/app/overlays.ts index 1c8cb6eecd2b..1b14b942e0f8 100644 --- a/ui/src/app/overlays.ts +++ b/ui/src/app/overlays.ts @@ -81,8 +81,10 @@ export function createApplicationOverlays( updateSchedule: null, heldUpdateCampaignId: null, updateRunning: false, + updateStatusRefreshing: false, updateReconciliationPending: false, updateStatusBanner: null, + recordedUpdateAttempt: null, controlUiRefreshRequired: false, approvalQueue: [], approvalBusy: false, @@ -177,6 +179,12 @@ export function createApplicationOverlays( snapshot = { ...snapshot, updateStatusBanner }; publish(); }; + const publishRecordedUpdateAttempt = ( + recordedUpdateAttempt: ApplicationOverlaySnapshot["recordedUpdateAttempt"], + ) => { + snapshot = { ...snapshot, recordedUpdateAttempt }; + publish(); + }; const heldCampaignId = (schedule: UpdateScheduleState | null) => schedule?.campaign?.holdUntilMs !== undefined ? schedule.campaign.id @@ -190,6 +198,13 @@ export function createApplicationOverlays( getHello: () => gateway.snapshot.hello, publish, publishBanner: publishUpdateBanner, + publishRecordedAttempt: publishRecordedUpdateAttempt, + publishRecordedFailure: ({ attempt, banner }) => { + // Both facts terminate the same reconciliation. Publishing either first + // exposes a false success or a failure without its recorded cause. + snapshot = { ...snapshot, recordedUpdateAttempt: attempt, updateStatusBanner: banner }; + publish(); + }, onVerifiedInstall: announceVerifiedUpdateInstall, }); const applyUpdateStatusResponse = (response: UpdateRestartStatusResponse) => { @@ -197,6 +212,7 @@ export function createApplicationOverlays( ...snapshot, ...projectUpdateStatusResponse(response, { updateStatusBanner: snapshot.updateStatusBanner, + recordedUpdateAttempt: snapshot.recordedUpdateAttempt, heldUpdateCampaignId: snapshot.heldUpdateCampaignId, }), }; @@ -215,7 +231,17 @@ export function createApplicationOverlays( getEpoch: () => connectedEpoch, canRefresh: () => operatorAccess.canAdmin, isCurrent: (client, epoch) => epoch === connectedEpoch && isCurrentClient(client), + onRefreshing: (updateStatusRefreshing) => { + snapshot = { ...snapshot, updateStatusRefreshing }; + publish(); + }, onStatus: applyUpdateStatusResponse, + onError: (error) => { + publishUpdateBanner({ + tone: "danger", + text: t("updates.error", { error: formatUiError(error) }), + }); + }, }); const synchronizeGateway = (next: ApplicationGateway["snapshot"]) => { @@ -250,7 +276,12 @@ export function createApplicationOverlays( ? resolveUnknownUpdateOutcomeBanner() : snapshot.updateStatusBanner; pendingUpdate = null; - snapshot = { ...snapshot, updateRunning: false, updateStatusBanner }; + snapshot = { + ...snapshot, + updateRunning: false, + updateStatusRefreshing: false, + updateStatusBanner, + }; } } if (accessTransition.pairingChanged) { @@ -289,6 +320,7 @@ export function createApplicationOverlays( updateAvailable: null, updateSchedule: null, updateRunning: false, + updateStatusRefreshing: false, }; updateCampaignPoller.stop(); if (next.phase === "reload-required") { @@ -438,7 +470,12 @@ export function createApplicationOverlays( return; } const generation = ++updateRunGeneration; - snapshot = { ...snapshot, updateRunning: true, updateStatusBanner: null }; + snapshot = { + ...snapshot, + updateRunning: true, + updateStatusBanner: null, + recordedUpdateAttempt: null, + }; publish(); try { // updateRunning above suspends NEW config writes (bootstrap syncs it diff --git a/ui/src/app/update-overlay-helpers.test.ts b/ui/src/app/update-overlay-helpers.test.ts index 2d0a5889aede..57c20dc62e62 100644 --- a/ui/src/app/update-overlay-helpers.test.ts +++ b/ui/src/app/update-overlay-helpers.test.ts @@ -15,6 +15,7 @@ import type { import { createUpdateVerificationController, formatUpdateCampaignLabel, + projectUpdateStatusResponse, resolveUpdateStatusBanner, } from "./update-overlay-helpers.ts"; import { @@ -88,6 +89,9 @@ async function verifyUpdate(params: { publishBanner: (value) => { banner = value; }, + publishRecordedFailure: ({ banner: value }) => { + banner = value; + }, ...(params.onVerifiedInstall ? { onVerifiedInstall: params.onVerifiedInstall } : {}), }); @@ -412,6 +416,48 @@ describe("update schedule hydration", () => { }); describe("update status localization", () => { + it("projects the recorded update attempt without inferring from localized text", () => { + installTranslations(); + const projected = projectUpdateStatusResponse( + { + sentinel: { + kind: "update", + status: "error", + ts: 123, + stats: { + mode: "git", + reason: "build-failed", + before: { sha: "before" }, + after: { sha: "after" }, + steps: [ + { + name: "build", + log: { exitCode: 1, stderrTail: "first line\nType check failed" }, + }, + ], + }, + }, + }, + { + updateStatusBanner: null, + recordedUpdateAttempt: null, + heldUpdateCampaignId: null, + }, + ); + + expect(projected.recordedUpdateAttempt).toEqual({ + timestampMs: 123, + status: "error", + reason: "build-failed", + installKind: "git", + installedVersion: null, + installedSha: "before", + targetVersion: null, + targetSha: "after", + failure: { step: "build", detail: "Type check failed" }, + }); + }); + it("localizes known update failure guidance", () => { const translate = installTranslations(); diff --git a/ui/src/app/update-overlay-helpers.ts b/ui/src/app/update-overlay-helpers.ts index a34f4a5044b0..4b822ecafe57 100644 --- a/ui/src/app/update-overlay-helpers.ts +++ b/ui/src/app/update-overlay-helpers.ts @@ -10,6 +10,18 @@ export type ApplicationStatusBanner = { text: string; }; +export type RecordedUpdateAttempt = { + timestampMs: number; + status: string; + reason: string; + installKind: string | null; + installedVersion: string | null; + installedSha: string | null; + targetVersion: string | null; + targetSha: string | null; + failure: UpdateFailureCause | null; +}; + 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; @@ -63,8 +75,11 @@ export type UpdateRestartStatusResponse = { sentinel?: { kind?: string; status?: string; + ts?: number; stats?: { + mode?: string | null; reason?: string | null; + before?: { sha?: string | null; version?: string | null } | null; after?: { sha?: string | null; version?: string | null } | null; steps?: UpdateSentinelStep[] | null; } | null; @@ -75,6 +90,32 @@ export type UpdateRestartStatusResponse = { type UpdateFailureCause = { step: string; detail: string }; +function readRecordedUpdateAttempt( + sentinel: UpdateRestartStatusResponse["sentinel"], +): RecordedUpdateAttempt | null { + if ( + sentinel?.kind !== "update" || + !sentinel.status || + sentinel.status === "ok" || + isPendingUpdateHandoffSentinel(sentinel) || + typeof sentinel.ts !== "number" + ) { + return null; + } + const stats = sentinel.stats; + return { + timestampMs: sentinel.ts, + status: sentinel.status, + reason: stats?.reason?.trim() || "unexpected-error", + installKind: stats?.mode?.trim() || null, + installedVersion: stats?.before?.version?.trim() || null, + installedSha: stats?.before?.sha?.trim() || null, + targetVersion: stats?.after?.version?.trim() || null, + targetSha: stats?.after?.sha?.trim() || null, + failure: readUpdateFailureCause(sentinel), + }; +} + function lastLogLine(tail: string | null | undefined): string | null { const lines = (tail ?? "") .split("\n") @@ -118,12 +159,14 @@ async function requestUpdateRestartStatus( client: Pick, timeoutMs: number, request: { refreshCheckout?: true } = {}, + onError?: (error: unknown) => void, ): Promise { try { return await client.request("update.status", request, { timeoutMs, }); - } catch { + } catch (error) { + onError?.(error); return null; } } @@ -133,17 +176,38 @@ export function createUpdateStatusRefresher(params: { getEpoch: () => number; canRefresh: () => boolean; isCurrent: (client: GatewayBrowserClient, epoch: number) => boolean; + onRefreshing: (refreshing: boolean) => void; onStatus: (response: UpdateRestartStatusResponse) => void; + onError: (error: unknown) => void; }) { + let generation = 0; return async () => { const client = params.getClient(); const epoch = params.getEpoch(); if (!client || !params.canRefresh()) { return; } - const response = await requestUpdateRestartStatus(client, 5_000, { refreshCheckout: true }); - if (response && params.isCurrent(client, epoch)) { - params.onStatus(response); + const operationGeneration = ++generation; + const isCurrent = () => operationGeneration === generation && params.isCurrent(client, epoch); + params.onRefreshing(true); + try { + const response = await requestUpdateRestartStatus( + client, + 5_000, + { refreshCheckout: true }, + (error) => { + if (isCurrent()) { + params.onError(error); + } + }, + ); + if (response && isCurrent()) { + params.onStatus(response); + } + } finally { + if (isCurrent()) { + params.onRefreshing(false); + } } }; } @@ -225,6 +289,11 @@ export function createUpdateVerificationController(params: { getHello: () => GatewayHelloOk | null; publish: () => void; publishBanner: (banner: ApplicationStatusBanner | null) => void; + publishRecordedAttempt?: (attempt: RecordedUpdateAttempt | null) => void; + publishRecordedFailure: (params: { + attempt: RecordedUpdateAttempt | null; + banner: ApplicationStatusBanner; + }) => void; onVerifiedInstall?: (identity: { version: string | null; sha: string | null }) => void; }) { let generation = 0; @@ -289,13 +358,14 @@ export function createUpdateVerificationController(params: { } if (sentinel?.kind === "update" && sentinel.status && sentinel.status !== "ok") { params.clearPending(); - params.publishBanner( - resolveUpdateStatusBanner({ + params.publishRecordedFailure({ + attempt: readRecordedUpdateAttempt(sentinel), + banner: resolveUpdateStatusBanner({ status: "error", ...(sentinel.stats?.reason ? { reason: sentinel.stats.reason } : {}), cause: readUpdateFailureCause(sentinel), }), - ); + }); return; } const actualVersion = sentinel?.stats?.after?.version?.trim() || null; @@ -308,6 +378,7 @@ export function createUpdateVerificationController(params: { const hasActualIdentity = actualVersion !== null || actualSha !== null; if (versionMatches && shaMatches && (hasActualIdentity || !hasExpectedIdentity)) { params.clearPending(); + params.publishRecordedAttempt?.(null); params.onVerifiedInstall?.({ version: actualVersion, sha: actualSha }); params.publishBanner(null); return; @@ -420,10 +491,12 @@ export function projectUpdateStatusResponse( response: UpdateRestartStatusResponse, current: { updateStatusBanner: ApplicationStatusBanner | null; + recordedUpdateAttempt: RecordedUpdateAttempt | null; heldUpdateCampaignId: string | null; }, ): { updateStatusBanner: ApplicationStatusBanner | null; + recordedUpdateAttempt: RecordedUpdateAttempt | null; updateAvailable?: UpdateAvailable | null; updateSchedule?: UpdateScheduleState | null; heldUpdateCampaignId?: string | null; @@ -443,6 +516,10 @@ export function projectUpdateStatusResponse( cause: readUpdateFailureCause(sentinel), }) : current.updateStatusBanner, + recordedUpdateAttempt: + sentinel?.kind === "update" && sentinel.status + ? readRecordedUpdateAttempt(sentinel) + : current.recordedUpdateAttempt, ...(Object.hasOwn(response, "updateAvailable") ? { updateAvailable: readUpdateAvailableValue(response.updateAvailable) } : {}), diff --git a/ui/src/components/app-sidebar-base.ts b/ui/src/components/app-sidebar-base.ts index 5bfa565b33d1..e6e05569a280 100644 --- a/ui/src/components/app-sidebar-base.ts +++ b/ui/src/components/app-sidebar-base.ts @@ -63,6 +63,7 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement { @property({ attribute: false }) refreshRequired = false; @property({ attribute: false }) onRefresh: () => void = () => undefined; @property({ attribute: false }) onHoldUpdate: () => Promise = async () => false; + @property({ attribute: false }) onReviewUpdate: () => void = () => undefined; @property({ attribute: false }) onOpenApprovals?: () => void; @property({ attribute: false }) onRetryConnect?: () => void; @property({ attribute: false }) onOpenNewSession?: ( diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index cb08177381da..aec6b7151737 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -572,6 +572,7 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi .refreshRequired=${this.refreshRequired} .onRefresh=${this.onRefresh} .onHoldUpdate=${this.onHoldUpdate} + .onReviewUpdate=${this.onReviewUpdate} > void; onHoldUpdate?: () => Promise; + onReviewUpdate?: () => void; searchQuery: string; searchBlockMatches?: readonly SettingsSearchBlock[]; onExit: () => void; @@ -325,6 +326,7 @@ export function renderSettingsSidebar(props: SettingsSidebarProps) { .refreshRequired=${props.refreshRequired} .onRefresh=${props.onRefresh} .onHoldUpdate=${props.onHoldUpdate ?? (async () => false)} + .onReviewUpdate=${props.onReviewUpdate ?? (() => undefined)} >
${props.offline diff --git a/ui/src/components/sidebar-update-card.test.ts b/ui/src/components/sidebar-update-card.test.ts index d2f691410b79..8dc760cd0c97 100644 --- a/ui/src/components/sidebar-update-card.test.ts +++ b/ui/src/components/sidebar-update-card.test.ts @@ -6,6 +6,7 @@ import { NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT, NATIVE_UPDATE_DECLINED_EVENT, } from "../app/native-link-routing.ts"; +import type { ApplicationStatusBanner } from "../app/update-overlay-helpers.ts"; import { answerConfirmDialog, cancelOpenModalDialogs, @@ -37,6 +38,8 @@ type SidebarUpdateCardElement = HTMLElement & { refreshRequired: boolean; onRefresh: () => void; onHoldUpdate: () => Promise; + statusBanner: ApplicationStatusBanner | null; + onReviewUpdate: () => void; updateComplete: Promise; }; @@ -205,6 +208,18 @@ describe("SidebarUpdateCard", () => { }, ); + it("routes a recorded failure to update settings when availability is gone", async () => { + const element = await mount(null); + const onReviewUpdate = vi.fn(); + element.statusBanner = { tone: "danger", text: "Update failed" }; + element.onReviewUpdate = onReviewUpdate; + await element.updateComplete; + + expect(element.textContent).toContain("Update failed"); + element.querySelector(".sidebar-update-card__review")?.click(); + expect(onReviewUpdate).toHaveBeenCalledOnce(); + }); + it("renders nothing for a dismissed version and channel", async () => { localStorage.setItem( DISMISS_KEY, diff --git a/ui/src/components/sidebar-update-card.ts b/ui/src/components/sidebar-update-card.ts index d4e16667517a..eace6575f9c9 100644 --- a/ui/src/components/sidebar-update-card.ts +++ b/ui/src/components/sidebar-update-card.ts @@ -73,6 +73,7 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement { @property({ attribute: false }) refreshRequired = false; @property({ attribute: false }) onRefresh: () => void = () => undefined; @property({ attribute: false }) onHoldUpdate: () => Promise = async () => false; + @property({ attribute: false }) onReviewUpdate: () => void = () => undefined; @state() private dismissedUpdateKey: string | null = null; @state() private holdingCampaignId: string | null = null; @state() private nativeUpdateAvailable = hasNativeUpdateBridge(); @@ -297,6 +298,15 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement { ${icons.x} `} + ${statusBanner + ? html`` + : nothing}
`; } diff --git a/ui/src/e2e/updates-settings.e2e.test.ts b/ui/src/e2e/updates-settings.e2e.test.ts index 69476f386e9c..18441727f730 100644 --- a/ui/src/e2e/updates-settings.e2e.test.ts +++ b/ui/src/e2e/updates-settings.e2e.test.ts @@ -157,4 +157,60 @@ suite.define(() => { }, ); }); + + it("keeps a failed manual status check visible", async () => { + await suite.withPage( + { + colorScheme: "dark", + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }, + async ({ page }) => { + const config = { update: { auto: { enabled: false }, channel: "stable" } }; + const gateway = await installMockGateway(page, { + featureMethods: ["config.get", "update.run", "update.status"], + methodResponses: { + "config.get": { + config, + hash: "updates-status-error-1", + issues: [], + raw: JSON.stringify(config), + runtimeConfig: config, + valid: true, + }, + "update.status": { + sentinel: { + kind: "update", + status: "error", + ts: Date.now(), + stats: { mode: "package", reason: "build-failed" }, + }, + }, + }, + operatorScopes: ["operator.read", "operator.admin"], + }); + + expect((await page.goto(`${suite.server.baseUrl}settings/updates`))?.status()).toBe(200); + await gateway.waitForRequest("update.status"); + const checkStatus = page.getByRole("button", { name: "Check status", exact: true }); + await checkStatus.waitFor(); + + await gateway.deferNext("update.status"); + await checkStatus.click(); + await expect.poll(async () => (await gateway.getRequests("update.status")).length).toBe(2); + expect(await checkStatus.isDisabled()).toBe(true); + + await gateway.rejectDeferred("update.status", { + code: "UNAVAILABLE", + message: "Gateway status is temporarily unavailable", + }); + await page + .locator("#config-section-update .settings-status") + .filter({ hasText: "Gateway status is temporarily unavailable" }) + .waitFor(); + expect(await checkStatus.isDisabled()).toBe(false); + }, + ); + }); }); diff --git a/ui/src/i18n/.i18n/raw-copy-baseline.json b/ui/src/i18n/.i18n/raw-copy-baseline.json index a0343e0e196b..4cd56d38110e 100644 --- a/ui/src/i18n/.i18n/raw-copy-baseline.json +++ b/ui/src/i18n/.i18n/raw-copy-baseline.json @@ -379,6 +379,13 @@ "path": "ui/src/pages/config/mcp.ts", "text": "openclaw mcp status --verbose" }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/updates.ts", + "text": "openclaw update status --json openclaw update" + }, { "count": 1, "kind": "html-attribute", diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index dc08b2b7171e..c9beeef057cb 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -415,6 +415,7 @@ export const en: TranslationMap = { applying: "Applying update…", }, holdOneHour: "Hold 1 h", + reviewUpdate: "Review update", confirm: { message: "Installs the available update on the connected Gateway and restarts it.", macMessage: @@ -486,6 +487,20 @@ export const en: TranslationMap = { gitComparisonFailed: "Could not compare this checkout with its tracked upstream", updateNow: "Update now", updateNowDescription: "Install the available update and restart the Gateway.", + latestAttempt: "Latest update attempt", + attemptedAt: "Attempted", + attemptTarget: "Target", + installedIdentity: "Installed", + attemptInstallKind: "Attempt install type", + attemptReason: "Reason code", + failedStep: "Failure details", + viewDetails: "View details", + recoveryActions: "Recovery", + checkStatus: "Check status", + retryUpdate: "Retry update", + troubleshoot: "Troubleshoot updates", + cliFallback: "CLI fallback", + showCliFallback: "Show terminal commands", }, coalescedRestart: "Update installed. A gateway restart is already in progress; status will refresh after it reconnects.", diff --git a/ui/src/pages/config/config-page.ts b/ui/src/pages/config/config-page.ts index d6e38661fd6a..6276bee80791 100644 --- a/ui/src/pages/config/config-page.ts +++ b/ui/src/pages/config/config-page.ts @@ -1052,7 +1052,9 @@ export class ConfigPage extends OpenClawLightDomElement { private isUpdateBusy(): boolean { const update = this.context.overlays.snapshot; - return update.updateRunning || update.updateReconciliationPending; + return ( + update.updateRunning || update.updateStatusRefreshing || update.updateReconciliationPending + ); } // The update dialog outlives this page and the connection, so it reads live @@ -1108,9 +1110,11 @@ export class ConfigPage extends OpenClawLightDomElement { heldUpdateCampaignId: overlaySnapshot.heldUpdateCampaignId, updateAvailable: overlaySnapshot.updateAvailable, statusBanner: overlaySnapshot.updateStatusBanner, + recordedAttempt: overlaySnapshot.recordedUpdateAttempt, configBusy: this.isCuratedConfigMutationDisabled(), canAdmin, canUpdate: canCallGatewayMethod(gatewaySnapshot, "update.run", "operator.admin"), + canCheckStatus: canCallGatewayMethod(gatewaySnapshot, "update.status", "operator.admin"), canHoldUpdate: canCallGatewayMethod(gatewaySnapshot, "update.hold", "operator.admin"), updateBusy: this.isUpdateBusy(), onChannelChange: (channel) => runtimeConfig.patchForm(["update", "channel"], channel), @@ -1127,6 +1131,7 @@ export class ConfigPage extends OpenClawLightDomElement { viaNativeApp: false, }), onHoldUpdate: () => this.context.overlays.holdUpdate(), + onCheckStatus: () => this.context.overlays.refreshUpdateStatus(), }); } const includeSections = this.includeSections(); diff --git a/ui/src/pages/config/updates.test.ts b/ui/src/pages/config/updates.test.ts index ddf94a0e5994..2f4715c0f4e6 100644 --- a/ui/src/pages/config/updates.test.ts +++ b/ui/src/pages/config/updates.test.ts @@ -29,9 +29,11 @@ function createProps(overrides: Partial = {}): UpdatesViewProp channel: "stable", }, statusBanner: null, + recordedAttempt: null, configBusy: false, canAdmin: true, canUpdate: true, + canCheckStatus: true, canHoldUpdate: true, updateBusy: false, nowMs: 1_000, @@ -39,6 +41,7 @@ function createProps(overrides: Partial = {}): UpdatesViewProp onAutomaticUpdatesChange: vi.fn(), onUpdateNow: vi.fn(), onHoldUpdate: vi.fn(async () => true), + onCheckStatus: vi.fn(async () => undefined), ...overrides, }; } @@ -453,6 +456,46 @@ describe("renderUpdates", () => { expect(row("Status").querySelector(".settings-status--danger")).not.toBeNull(); }); + it("renders the recorded failure details and typed recovery actions", () => { + const onUpdateNow = vi.fn(); + const onCheckStatus = vi.fn(async () => undefined); + render( + renderUpdates( + createProps({ + recordedAttempt: { + timestampMs: 500, + status: "error", + reason: "build-failed", + installKind: "git", + installedVersion: null, + installedSha: "0123456789abcdef", + targetVersion: null, + targetSha: null, + failure: { step: "build", detail: "Type check failed" }, + }, + onUpdateNow, + onCheckStatus, + }), + ), + container, + ); + + expect(row("Reason code").textContent).toContain("build-failed"); + expect(row("Target").textContent).toContain("v2026.8.2"); + expect(row("Installed").textContent).toContain("0123456789ab"); + expect(row("Attempt install type").textContent).toContain("git"); + expect(row("Failure details").textContent).toContain("Type check failed"); + const recovery = row("Recovery"); + recovery.querySelector("button")?.click(); + recovery.querySelectorAll("button")[1]?.click(); + expect(onCheckStatus).toHaveBeenCalledOnce(); + expect(onUpdateNow).toHaveBeenCalledOnce(); + expect( + container.querySelector("a[href*='update-troubleshooting']"), + ).not.toBeNull(); + expect(row("CLI fallback").textContent).toContain("openclaw update status --json"); + }); + it("keeps read-only facts visible while locking controls for non-admins", () => { render( renderUpdates(createProps({ canAdmin: false, canUpdate: false, configBusy: true })), diff --git a/ui/src/pages/config/updates.ts b/ui/src/pages/config/updates.ts index 5bb931636543..44dfa3d9e5f5 100644 --- a/ui/src/pages/config/updates.ts +++ b/ui/src/pages/config/updates.ts @@ -4,7 +4,10 @@ import { parseDateStringTimestampMs } from "@openclaw/normalization-core/number- import { asNullableRecord as asConfigRecord } from "@openclaw/normalization-core/record-coerce"; import { html, nothing, type TemplateResult } from "lit"; import type { UpdateAvailable, UpdateScheduleState } from "../../api/types.ts"; -import type { ApplicationStatusBanner } from "../../app/update-overlay-helpers.ts"; +import type { + ApplicationStatusBanner, + RecordedUpdateAttempt, +} from "../../app/update-overlay-helpers.ts"; import { formatUpdateCampaignLabel, formatUpdateTargetLabel, @@ -34,9 +37,11 @@ type UpdatesViewProps = { heldUpdateCampaignId: string | null; updateAvailable: UpdateAvailable | null; statusBanner: ApplicationStatusBanner | null; + recordedAttempt: RecordedUpdateAttempt | null; configBusy: boolean; canAdmin: boolean; canUpdate: boolean; + canCheckStatus: boolean; canHoldUpdate: boolean; updateBusy: boolean; nowMs?: number; @@ -44,8 +49,104 @@ type UpdatesViewProps = { onAutomaticUpdatesChange: (enabled: boolean) => void; onUpdateNow: () => void; onHoldUpdate: () => Promise; + onCheckStatus: () => Promise; }; +function formatAttemptIdentity(version: string | null, sha: string | null): string { + return version ? `v${version}` : sha ? sha.slice(0, 12) : t("common.unknown"); +} + +function renderRecordedAttempt(props: UpdatesViewProps) { + const attempt = props.recordedAttempt; + if (!attempt && !props.statusBanner) { + return nothing; + } + const canRetry = props.canUpdate && !props.updateBusy; + const recordedTarget = attempt + ? formatAttemptIdentity(attempt.targetVersion, attempt.targetSha) + : null; + const target = + recordedTarget && recordedTarget !== t("common.unknown") + ? recordedTarget + : (formatUpdateTargetLabel(props.schedule, props.updateAvailable) ?? t("common.unknown")); + return renderSettingsSection({ title: t("updates.page.latestAttempt") }, [ + attempt + ? renderSettingsRow({ + title: t("updates.page.attemptedAt"), + control: renderTimestamp(attempt.timestampMs, props.nowMs), + }) + : nothing, + renderSettingsRow({ + title: t("updates.page.attemptTarget"), + control: renderSettingsValue(target, { mono: true }), + }), + attempt + ? renderSettingsRow({ + title: t("updates.page.installedIdentity"), + control: renderSettingsValue( + formatAttemptIdentity(attempt.installedVersion, attempt.installedSha), + { mono: true }, + ), + }) + : nothing, + attempt?.installKind + ? renderSettingsRow({ + title: t("updates.page.attemptInstallKind"), + control: renderSettingsValue(attempt.installKind), + }) + : nothing, + attempt + ? renderSettingsRow({ + title: t("updates.page.attemptReason"), + control: renderSettingsValue(html`${attempt.reason}`, { mono: true }), + }) + : nothing, + attempt?.failure + ? renderSettingsRow({ + title: t("updates.page.failedStep"), + stacked: true, + control: html`
+ ${t("updates.page.viewDetails")} +
${attempt.failure.step}
+
${attempt.failure.detail}
+
`, + }) + : nothing, + renderSettingsRow({ + title: t("updates.page.recoveryActions"), + control: html`
+ + +
`, + }), + renderSettingsRow({ + title: t("updates.page.cliFallback"), + stacked: true, + control: html`
+ ${t("updates.page.showCliFallback")} +
openclaw update status --json
+openclaw update
+
`, + }), + ]); +} + function readUpdatesSettings( configObject: Record, schedule: UpdateScheduleState | null, @@ -311,6 +412,7 @@ export function renderUpdates(props: UpdatesViewProps): TemplateResult { ? html`
${t("updates.adminRequired")}
` : nothing, renderBuildFacts(props), + renderRecordedAttempt(props), renderSettingsSection({ title: t("updates.page.policyTitle") }, policyRows), renderSettingsSection({ title: t("updates.page.statusTitle") }, [ renderSettingsRow({ @@ -351,6 +453,11 @@ export function renderUpdates(props: UpdatesViewProps): TemplateResult { `, }), ]), + html`

+ ${t("updates.page.troubleshoot")} +

`, ], { intro: t("updates.page.intro") }, )} diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 8b768eb496cf..6c263010777e 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -1285,6 +1285,23 @@ body.update-dialog-open .sidebar-update-card__status { margin-top: 6px; } +.sidebar-update-card__review { + width: 100%; + margin-top: 6px; + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-elevated); + color: var(--text); + cursor: var(--cursor-action); + font-size: var(--control-ui-text-xs); + font-weight: 600; +} + +.sidebar-update-card__review:hover { + background: var(--bg-hover); +} + .sidebar-update-card__status--danger { border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); background: color-mix(in srgb, var(--danger) 10%, transparent); diff --git a/ui/src/styles/settings.css b/ui/src/styles/settings.css index b645b99fd119..2cae46b338f0 100644 --- a/ui/src/styles/settings.css +++ b/ui/src/styles/settings.css @@ -595,6 +595,26 @@ textarea.settings-input { gap: var(--space-3); } +.updates-attempt-details { + width: 100%; +} + +.updates-attempt-details summary { + cursor: var(--cursor-action); + font-weight: 600; +} + +.updates-attempt-details pre { + margin: var(--space-2) 0 0; + padding: var(--space-3); + overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-elevated); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + .updates-commit-list { width: 100%; overflow: hidden;