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>
This commit is contained in:
Vyctor H. Brzezowski
2026-08-18 05:31:21 -03:00
committed by GitHub
parent bed37ec377
commit e349d520a2
27 changed files with 607 additions and 15 deletions
@@ -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"
}
+4
View File
@@ -1567,6 +1567,10 @@
"source": "Troubleshooting",
"target": "故障排查"
},
{
"source": "Update troubleshooting",
"target": "更新故障排查"
},
{
"source": "Webhooks",
"target": "Webhooks"
+1
View File
@@ -1088,6 +1088,7 @@
"pages": [
"install/backups",
"install/updating",
"install/update-troubleshooting",
{
"group": "Migrating",
"pages": [
+87
View File
@@ -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.
+4
View File
@@ -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,
@@ -67,6 +67,7 @@ function createPairingShell(params: {
updateAvailable: null,
updateRunning: false,
updateStatusBanner: null,
recordedUpdateAttempt: null,
controlUiRefreshRequired: false,
};
const context = {
@@ -99,6 +99,7 @@ describe("OpenClaw shell dock suppression", () => {
updateAvailable: null,
updateRunning: false,
updateStatusBanner: null,
recordedUpdateAttempt: null,
controlUiRefreshRequired: false,
approvalQueue: [],
approvalBusy: false,
+3
View File
@@ -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`<div class="connection-action-block" role="status" aria-live="polite">
+2
View File
@@ -28,6 +28,7 @@ export function renderFloatingUpdateCard(params: {
refreshRequired: boolean;
onRefresh: () => void;
onHoldUpdate?: () => Promise<boolean>;
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)}
></openclaw-sidebar-update-card>`;
}
+3 -1
View File
@@ -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;
@@ -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<RequestFn>((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({
+39 -2
View File
@@ -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
+46
View File
@@ -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();
+84 -7
View File
@@ -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<GatewayBrowserClient, "request">,
timeoutMs: number,
request: { refreshCheckout?: true } = {},
onError?: (error: unknown) => void,
): Promise<UpdateRestartStatusResponse | null> {
try {
return await client.request<UpdateRestartStatusResponse>("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) }
: {}),
+1
View File
@@ -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<boolean> = async () => false;
@property({ attribute: false }) onReviewUpdate: () => void = () => undefined;
@property({ attribute: false }) onOpenApprovals?: () => void;
@property({ attribute: false }) onRetryConnect?: () => void;
@property({ attribute: false }) onOpenNewSession?: (
+1
View File
@@ -572,6 +572,7 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
.refreshRequired=${this.refreshRequired}
.onRefresh=${this.onRefresh}
.onHoldUpdate=${this.onHoldUpdate}
.onReviewUpdate=${this.onReviewUpdate}
></openclaw-sidebar-update-card>
<openclaw-lobster-pet
.seed=${lobsterPetSeed(this.sessionKey)}
+2
View File
@@ -51,6 +51,7 @@ type SettingsSidebarProps = {
refreshRequired: boolean;
onRefresh: () => void;
onHoldUpdate?: () => Promise<boolean>;
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)}
></openclaw-sidebar-update-card>
<footer class="settings-sidebar__footer">
${props.offline
@@ -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<boolean>;
statusBanner: ApplicationStatusBanner | null;
onReviewUpdate: () => void;
updateComplete: Promise<boolean>;
};
@@ -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<HTMLButtonElement>(".sidebar-update-card__review")?.click();
expect(onReviewUpdate).toHaveBeenCalledOnce();
});
it("renders nothing for a dismissed version and channel", async () => {
localStorage.setItem(
DISMISS_KEY,
+10
View File
@@ -73,6 +73,7 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
@property({ attribute: false }) refreshRequired = false;
@property({ attribute: false }) onRefresh: () => void = () => undefined;
@property({ attribute: false }) onHoldUpdate: () => Promise<boolean> = 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}
</button>
`}
${statusBanner
? html`<button
class="sidebar-update-card__review"
type="button"
@click=${this.onReviewUpdate}
>
${t("updates.reviewUpdate")}
</button>`
: nothing}
</div>
`;
}
+56
View File
@@ -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);
},
);
});
});
+7
View File
@@ -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",
+15
View File
@@ -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.",
+6 -1
View File
@@ -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();
+43
View File
@@ -29,9 +29,11 @@ function createProps(overrides: Partial<UpdatesViewProps> = {}): 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<UpdatesViewProps> = {}): 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<HTMLButtonElement>("button")?.click();
recovery.querySelectorAll<HTMLButtonElement>("button")[1]?.click();
expect(onCheckStatus).toHaveBeenCalledOnce();
expect(onUpdateNow).toHaveBeenCalledOnce();
expect(
container.querySelector<HTMLAnchorElement>("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 })),
+108 -1
View File
@@ -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<boolean>;
onCheckStatus: () => Promise<void>;
};
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`<code>${attempt.reason}</code>`, { mono: true }),
})
: nothing,
attempt?.failure
? renderSettingsRow({
title: t("updates.page.failedStep"),
stacked: true,
control: html`<details class="updates-attempt-details">
<summary>${t("updates.page.viewDetails")}</summary>
<div><code>${attempt.failure.step}</code></div>
<pre>${attempt.failure.detail}</pre>
</details>`,
})
: nothing,
renderSettingsRow({
title: t("updates.page.recoveryActions"),
control: html`<div class="updates-status-control">
<button
class="btn btn--sm"
type="button"
title=${props.canCheckStatus ? "" : t("updates.adminRequired")}
?disabled=${!props.canCheckStatus || props.updateBusy}
@click=${() => void props.onCheckStatus()}
>
${t("updates.page.checkStatus")}
</button>
<button
class="btn btn--sm primary"
type="button"
title=${canRetry ? "" : t("updates.adminRequired")}
?disabled=${!canRetry}
@click=${props.onUpdateNow}
>
${t("updates.page.retryUpdate")}
</button>
</div>`,
}),
renderSettingsRow({
title: t("updates.page.cliFallback"),
stacked: true,
control: html`<details class="updates-attempt-details">
<summary>${t("updates.page.showCliFallback")}</summary>
<pre><code>openclaw update status --json
openclaw update</code></pre>
</details>`,
}),
]);
}
function readUpdatesSettings(
configObject: Record<string, unknown>,
schedule: UpdateScheduleState | null,
@@ -311,6 +412,7 @@ export function renderUpdates(props: UpdatesViewProps): TemplateResult {
? html`<div class="callout warning" role="note">${t("updates.adminRequired")}</div>`
: 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`<p class="settings-page__hint">
<a href="https://docs.openclaw.ai/install/update-troubleshooting" target="_blank"
>${t("updates.page.troubleshoot")}</a
>
</p>`,
],
{ intro: t("updates.page.intro") },
)}
+17
View File
@@ -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);
+20
View File
@@ -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;