From b4cedfd40e35adfb698d89d00e3e55b75821fabe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 8 Aug 2026 17:21:42 -0700 Subject: [PATCH] fix(ui): show dev checkout commit lag in Updates (#120769) * fix(ui): report dev checkout update status Show tracked-upstream commit lag and verified install/commit timestamps in Settings > Updates. Verify the post-restart Git revision before reporting success, and surface same-revision updates as an explicit no-op unless plugin convergence changed the install. * test(ui): expect authoritative update reconciliation --- docs/install/updating.md | 5 + .../src/schema/config.test.ts | 40 +++ .../gateway-protocol/src/schema/config.ts | 38 +++ src/gateway/server-methods/update.test.ts | 2 + src/gateway/server-methods/update.ts | 18 +- src/infra/restart-sentinel-store.ts | 40 ++- src/infra/restart-sentinel.test.ts | 96 ++++++ src/infra/restart-sentinel.ts | 91 +++++- src/infra/update-check.test.ts | 29 ++ src/infra/update-check.ts | 15 +- src/infra/update-restart-sentinel-payload.ts | 16 +- src/infra/update-startup.test.ts | 95 +++++- src/infra/update-startup.ts | 152 ++++++++- ui/src/app/overlays-update-campaign.test.ts | 33 ++ ui/src/app/overlays-update-race.test.ts | 30 +- ui/src/app/overlays.test.ts | 4 + ui/src/app/overlays.ts | 72 ++--- ui/src/app/update-overlay-helpers.test.ts | 75 ++++- ui/src/app/update-overlay-helpers.ts | 292 ++++++++++++++---- ui/src/build-info.test.ts | 3 + ui/src/build-info.ts | 31 +- ui/src/components/settings-ui.ts | 3 +- ui/src/e2e/update-coalesced.e2e.test.ts | 4 +- ui/src/i18n/locales/en.ts | 17 + ui/src/pages/config/config-page.test.ts | 33 ++ ui/src/pages/config/config-page.ts | 24 ++ ui/src/pages/config/updates.test.ts | 109 ++++++- ui/src/pages/config/updates.ts | 99 +++++- 28 files changed, 1302 insertions(+), 164 deletions(-) diff --git a/docs/install/updating.md b/docs/install/updating.md index 103cff8e30f6..86d54f989810 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -265,6 +265,11 @@ 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. +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, +verified install, and last-commit times. Existing checkouts show an unknown +install time until their next verified successful update. | Channel | Behavior | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/packages/gateway-protocol/src/schema/config.test.ts b/packages/gateway-protocol/src/schema/config.test.ts index 47103e3f362d..571165c6254c 100644 --- a/packages/gateway-protocol/src/schema/config.test.ts +++ b/packages/gateway-protocol/src/schema/config.test.ts @@ -107,6 +107,16 @@ describe("update protocol schemas", () => { schedule: { channel: "dev", autoEnabled: true, + install: { + kind: "git", + git: { + status: "behind", + currentSha: "1234567890", + commitAtMs: 1_754_640_000_000, + installedAtMs: 1_754_647_200_000, + commitsBehind: 3, + }, + }, target: { kind: "git", upstreamRef: "origin/main", @@ -127,6 +137,36 @@ describe("update protocol schemas", () => { }, }), ).toBe(false); + + for (const git of [ + { status: "current" }, + { status: "ahead", commitsAhead: 2 }, + { status: "diverged", commitsAhead: 1, commitsBehind: 3 }, + { status: "unavailable", reason: "fetch-failed" }, + ]) { + expect( + Value.Check(UpdateStatusResultSchema, { + sentinel: null, + updateAvailable: null, + schedule: { + channel: "dev", + autoEnabled: false, + install: { kind: "git", git }, + }, + }), + ).toBe(true); + } + expect( + Value.Check(UpdateStatusResultSchema, { + sentinel: null, + updateAvailable: null, + schedule: { + channel: "dev", + autoEnabled: false, + install: { kind: "git", git: { status: "behind", commitsBehind: 0 } }, + }, + }), + ).toBe(false); }); it("accepts optional bounded dev commit summaries", () => { diff --git a/packages/gateway-protocol/src/schema/config.ts b/packages/gateway-protocol/src/schema/config.ts index ced4435d8dbf..ddfa96fc1eb7 100644 --- a/packages/gateway-protocol/src/schema/config.ts +++ b/packages/gateway-protocol/src/schema/config.ts @@ -80,6 +80,43 @@ export const UpdateAvailableSchema = closedObject({ commits: Type.Optional(Type.Array(UpdateCommitSchema, { maxItems: 5 })), }); +const GitInstallMetadataProperties = { + currentSha: Type.Optional(NonEmptyString), + commitAtMs: Type.Optional(Type.Integer({ minimum: 0 })), + installedAtMs: Type.Optional(Type.Integer({ minimum: 0 })), +} as const; + +const GitUpdateStatusSchema = Type.Union([ + closedObject({ ...GitInstallMetadataProperties, status: Type.Literal("current") }), + closedObject({ + ...GitInstallMetadataProperties, + status: Type.Literal("behind"), + commitsBehind: Type.Integer({ minimum: 1 }), + }), + closedObject({ + ...GitInstallMetadataProperties, + status: Type.Literal("ahead"), + commitsAhead: Type.Integer({ minimum: 1 }), + }), + closedObject({ + ...GitInstallMetadataProperties, + status: Type.Literal("diverged"), + commitsAhead: Type.Integer({ minimum: 1 }), + commitsBehind: Type.Integer({ minimum: 1 }), + }), + closedObject({ + ...GitInstallMetadataProperties, + status: Type.Literal("unavailable"), + reason: Type.Union([ + Type.Literal("fetch-failed"), + Type.Literal("no-upstream"), + Type.Literal("no-upstream-sha"), + Type.Literal("comparison-failed"), + Type.Literal("git-unavailable"), + ]), + }), +]); + /** Authoritative automatic-update schedule and in-memory campaign state. */ export const UpdateScheduleStateSchema = closedObject({ channel: NonEmptyString, @@ -87,6 +124,7 @@ export const UpdateScheduleStateSchema = closedObject({ install: Type.Optional( closedObject({ kind: Type.Union([Type.Literal("package"), Type.Literal("git"), Type.Literal("unknown")]), + git: Type.Optional(GitUpdateStatusSchema), }), ), target: Type.Optional( diff --git a/src/gateway/server-methods/update.test.ts b/src/gateway/server-methods/update.test.ts index fbba02b35e2b..f877deaf74df 100644 --- a/src/gateway/server-methods/update.test.ts +++ b/src/gateway/server-methods/update.test.ts @@ -44,6 +44,7 @@ const getUpdateAvailableMock = vi.fn( const getUpdateScheduleMock = vi.fn< () => import("../../../packages/gateway-protocol/src/index.js").UpdateScheduleState | null >(() => null); +const refreshGatewayUpdateStatusMock = vi.fn(async () => {}); type UpdateCampaignAdoption = NonNullable< ReturnType >; @@ -159,6 +160,7 @@ vi.mock("../../infra/update-channels.js", () => ({ vi.mock("../../infra/update-startup.js", () => ({ getUpdateAvailable: getUpdateAvailableMock, getUpdateSchedule: getUpdateScheduleMock, + refreshGatewayUpdateStatus: refreshGatewayUpdateStatusMock, })); vi.mock("../../infra/update-campaign.js", () => ({ diff --git a/src/gateway/server-methods/update.ts b/src/gateway/server-methods/update.ts index f506cfd81c24..a9c64dcbf73d 100644 --- a/src/gateway/server-methods/update.ts +++ b/src/gateway/server-methods/update.ts @@ -42,10 +42,15 @@ import { } from "../../infra/update-post-core-finalize.js"; import { buildUpdateRestartSentinelPayload, + normalizeControlPlaneUpdateResult, type UpdateRestartSentinelMeta, } from "../../infra/update-restart-sentinel-payload.js"; import { resolveUpdateInstallSurface, runGatewayUpdate } from "../../infra/update-runner.js"; -import { getUpdateAvailable, getUpdateSchedule } from "../../infra/update-startup.js"; +import { + getUpdateAvailable, + getUpdateSchedule, + refreshGatewayUpdateStatus, +} from "../../infra/update-startup.js"; import { formatControlPlaneActor, resolveControlPlaneActor } from "../control-plane-audit.js"; import { getLatestUpdateRestartSentinel, @@ -144,6 +149,15 @@ export const updateHandlers: GatewayRequestHandlers = { ); sentinel = getLatestUpdateRestartSentinel(); } + if (context?.getRuntimeConfig) { + try { + await refreshGatewayUpdateStatus(context.getRuntimeConfig()); + } catch (err) { + context.logGateway?.warn( + `update.status checkout refresh failed: ${formatUpdateRunErrorMessage(err)}`, + ); + } + } const schedule = getUpdateSchedule(); const result = { sentinel, @@ -487,6 +501,8 @@ export const updateHandlers: GatewayRequestHandlers = { }; } + result = normalizeControlPlaneUpdateResult(result); + // A failed RPC owns the adopted campaign until it explicitly releases it; // only a started handoff may leave "applying" for the successor process. if ( diff --git a/src/infra/restart-sentinel-store.ts b/src/infra/restart-sentinel-store.ts index cde916d67d7e..2c3843e728c5 100644 --- a/src/infra/restart-sentinel-store.ts +++ b/src/infra/restart-sentinel-store.ts @@ -78,6 +78,7 @@ type RestartSentinelRowState = const RESTART_SENTINEL_KEY = "current"; const RESTART_SENTINEL_REVISION_FLOOR_KEY = "revision-floor"; +const UPDATE_INSTALL_RECEIPT_KEY = "latest-update-install"; const RESTART_SENTINEL_KINDS = new Set([ "config-apply", "config-auto-recovery", @@ -425,7 +426,10 @@ function decodeRestartSentinelRow(row: { return payload ? { version: 1, payload, revision: row.updated_at_ms } : null; } -export function readRestartSentinelRowSync(db: DatabaseSync): RestartSentinelRowState { +function readRestartSentinelRowForKeySync( + db: DatabaseSync, + sentinelKey: string, +): RestartSentinelRowState { const stateDb = getNodeSqliteKysely(db); const row = executeSqliteQueryTakeFirstSync( db, @@ -447,7 +451,7 @@ export function readRestartSentinelRowSync(db: DatabaseSync): RestartSentinelRow "stats_json", "updated_at_ms", ]) - .where("sentinel_key", "=", RESTART_SENTINEL_KEY), + .where("sentinel_key", "=", sentinelKey), ); if (!row) { return { kind: "missing" }; @@ -456,6 +460,15 @@ export function readRestartSentinelRowSync(db: DatabaseSync): RestartSentinelRow return sentinel ? { kind: "valid", sentinel } : { kind: "invalid", revision: row.updated_at_ms }; } +export function readRestartSentinelRowSync(db: DatabaseSync): RestartSentinelRowState { + return readRestartSentinelRowForKeySync(db, RESTART_SENTINEL_KEY); +} + +export function readUpdateInstallReceiptRowSync(db: DatabaseSync): RestartSentinel | null { + const current = readRestartSentinelRowForKeySync(db, UPDATE_INSTALL_RECEIPT_KEY); + return current.kind === "valid" ? current.sentinel : null; +} + function requireValidPayload(payload: RestartSentinelPayload): RestartSentinelPayload { const parsed = parseRestartSentinelPayload(payload); if (!parsed) { @@ -594,6 +607,29 @@ export function writeRestartSentinelRowSync( return { version: 1, payload, revision }; } +export function writeUpdateInstallReceiptRowSync( + db: DatabaseSync, + rawPayload: RestartSentinelPayload, +): RestartSentinel { + const payload = requireValidPayload(rawPayload); + if (payload.kind !== "update" || payload.status !== "ok") { + throw new TypeError("Update install receipt requires a successful update payload"); + } + const current = readRestartSentinelRowForKeySync(db, UPDATE_INSTALL_RECEIPT_KEY); + const currentRevision = + current.kind === "missing" + ? null + : current.kind === "valid" + ? current.sentinel.revision + : current.revision; + const revision = nextRevision(currentRevision); + upsertRestartSentinelRowSync( + db, + buildRestartSentinelRow(payload, revision, UPDATE_INSTALL_RECEIPT_KEY), + ); + return { version: 1, payload, revision }; +} + export function writeRestartSentinelRowIfRevisionSync( db: DatabaseSync, rawPayload: RestartSentinelPayload, diff --git a/src/infra/restart-sentinel.test.ts b/src/infra/restart-sentinel.test.ts index 1d8b387655bb..0704561f5678 100644 --- a/src/infra/restart-sentinel.test.ts +++ b/src/infra/restart-sentinel.test.ts @@ -52,6 +52,7 @@ import { hasRestartSentinel, markUpdateRestartSentinelFailure, readRestartSentinel, + readUpdateInstallReceipt, summarizeRestartSentinel, trimLogTail, writeRestartSentinel, @@ -540,6 +541,82 @@ describe("restart sentinel", () => { }); }); + it("persists the verified Git install receipt after restart", async () => { + await withRestartSentinelStateDir(async () => { + const ts = Date.now(); + await writeRestartSentinel({ + kind: "update", + status: "ok", + ts, + stats: { + mode: "git", + before: { sha: "aaaaaaaa" }, + after: { sha: "bbbbbbbb", version: "expected-version" }, + }, + }); + + await finalizeUpdateRestartSentinelRunningVersion( + "actual-version", + process.env, + "bbbbbbbb1234", + ); + await clearRestartSentinel(); + + await expect(readUpdateInstallReceipt()).resolves.toMatchObject({ + kind: "update", + status: "ok", + ts, + stats: { + mode: "git", + after: { sha: "bbbbbbbb", version: "actual-version" }, + }, + }); + }); + }); + + it("does not advance install time when a successful Git run keeps the same revision", async () => { + await withRestartSentinelStateDir(async () => { + await writeRestartSentinel({ + kind: "update", + status: "ok", + ts: Date.now(), + stats: { + mode: "git", + before: { sha: "aaaaaaaa" }, + after: { sha: "aaaaaaaa", version: "expected-version" }, + }, + }); + + await finalizeUpdateRestartSentinelRunningVersion("actual-version", process.env, "aaaaaaaa"); + + await expect(readUpdateInstallReceipt()).resolves.toBeNull(); + }); + }); + + it("rejects a restarted Git revision that does not match the update result", async () => { + await withRestartSentinelStateDir(async () => { + await writeRestartSentinel({ + kind: "update", + status: "ok", + ts: Date.now(), + stats: { + mode: "git", + after: { sha: "bbbbbbbb", version: "expected-version" }, + }, + }); + + await finalizeUpdateRestartSentinelRunningVersion("actual-version", process.env, "cccccccc"); + + await expect(readRestartSentinel()).resolves.toMatchObject({ + payload: { + status: "error", + stats: { reason: "restart-revision-mismatch" }, + }, + }); + await expect(readUpdateInstallReceipt()).resolves.toBeNull(); + }); + }); + it("marks update restart failures with a stable reason", async () => { await withRestartSentinelStateDir(async () => { const ts = Date.now(); @@ -635,6 +712,25 @@ describe("restart success continuation", () => { }); describe("control-plane update restart sentinel", () => { + it("reports a successful same-revision Git run as already current", () => { + const payload = buildUpdateRestartSentinelPayload({ + result: { + status: "ok", + mode: "git", + before: { sha: "aaaaaaaa" }, + after: { sha: "aaaaaaaa" }, + steps: [], + durationMs: 42, + }, + meta: {}, + nowMs: 1, + }); + + expect(payload.status).toBe("skipped"); + expect(payload.stats?.reason).toBe("already-current"); + expect(payload.continuation).toBeUndefined(); + }); + it("keeps restart-health-pending sentinels continuation-free until final success", () => { const result = { status: "ok" as const, diff --git a/src/infra/restart-sentinel.ts b/src/infra/restart-sentinel.ts index 4dec8810ab37..de3b7b3e249d 100644 --- a/src/infra/restart-sentinel.ts +++ b/src/infra/restart-sentinel.ts @@ -9,11 +9,14 @@ import { } from "../state/openclaw-state-db.js"; import { resolveRuntimeServiceVersion } from "../version.js"; import { formatErrorMessage } from "./errors.js"; +import { resolveCommitHash } from "./git-commit.js"; import { deleteRestartSentinelRowSync, readRestartSentinelRowSync, + readUpdateInstallReceiptRowSync, writeRestartSentinelRowIfRevisionSync, writeRestartSentinelRowSync, + writeUpdateInstallReceiptRowSync, type RestartSentinel, type RestartSentinelContinuation, type RestartSentinelPayload, @@ -70,26 +73,70 @@ async function rewriteRestartSentinel( ); } +function commitsMatch(expected: string, actual: string): boolean { + const normalizedExpected = expected.trim().toLowerCase(); + const normalizedActual = actual.trim().toLowerCase(); + return ( + normalizedExpected.length >= 7 && + normalizedActual.length >= 7 && + (normalizedExpected.startsWith(normalizedActual) || + normalizedActual.startsWith(normalizedExpected)) + ); +} + export async function finalizeUpdateRestartSentinelRunningVersion( version = resolveRuntimeServiceVersion(process.env), env: NodeJS.ProcessEnv = process.env, + commit = resolveCommitHash({ env, moduleUrl: import.meta.url }), ): Promise { - return await rewriteRestartSentinel((payload) => { - if (payload.kind !== "update") { - return null; - } - const stats = payload.stats ? { ...payload.stats } : {}; - const after = isPlainRecord(stats.after) ? { ...stats.after } : {}; - if (after.version === version) { - return null; - } - after.version = version; - stats.after = after; - return { - ...payload, - stats, - }; - }, env); + return runOpenClawStateWriteTransaction( + ({ db }) => { + const current = readRestartSentinelRowSync(db); + if (current.kind !== "valid" || current.sentinel.payload.kind !== "update") { + return null; + } + + const payload = cloneRestartSentinelPayload(current.sentinel.payload); + const stats = payload.stats ? { ...payload.stats } : {}; + const after = isPlainRecord(stats.after) ? { ...stats.after } : {}; + let changed = false; + if (after.version !== version) { + after.version = version; + changed = true; + } + + const before = isPlainRecord(stats.before) ? stats.before : {}; + const beforeSha = typeof before.sha === "string" ? before.sha.trim() : ""; + const expectedSha = typeof after.sha === "string" ? after.sha.trim() : ""; + const actualSha = commit?.trim() ?? ""; + const verifiesGitRevision = + stats.mode !== "git" || (expectedSha.length > 0 && commitsMatch(expectedSha, actualSha)); + const changedInstall = + stats.mode !== "git" || + (beforeSha.length > 0 && expectedSha.length > 0 && !commitsMatch(beforeSha, expectedSha)); + if (payload.status === "ok" && stats.mode === "git" && expectedSha && !verifiesGitRevision) { + payload.status = "error"; + stats.reason = actualSha ? "restart-revision-mismatch" : "restart-revision-unavailable"; + delete payload.continuation; + changed = true; + } + + stats.after = after; + payload.stats = stats; + const finalized = changed + ? writeRestartSentinelRowIfRevisionSync(db, payload, current.sentinel.revision) + : current.sentinel; + if (!finalized) { + return null; + } + if (payload.status === "ok" && verifiesGitRevision && changedInstall) { + writeUpdateInstallReceiptRowSync(db, payload); + } + return changed ? finalized : null; + }, + { env }, + { operationLabel: "restart-sentinel.finalize-running-install" }, + ); } export async function markUpdateRestartSentinelFailure( @@ -159,6 +206,18 @@ export async function readRestartSentinel( } } +export async function readUpdateInstallReceipt( + env: NodeJS.ProcessEnv = process.env, +): Promise { + try { + const database = openOpenClawStateDatabase({ env }); + return readUpdateInstallReceiptRowSync(database.db)?.payload ?? null; + } catch (err) { + sentinelLog.warn(`Failed to read update install receipt: ${formatErrorMessage(err)}`); + return null; + } +} + export async function hasRestartSentinel(env: NodeJS.ProcessEnv = process.env): Promise { try { const database = openOpenClawStateDatabase({ env }); diff --git a/src/infra/update-check.test.ts b/src/infra/update-check.test.ts index 23569ef9e0ac..ec291a15dd9d 100644 --- a/src/infra/update-check.test.ts +++ b/src/infra/update-check.test.ts @@ -627,6 +627,35 @@ describe("formatGitInstallLabel", () => { }); describe("checkUpdateStatus", () => { + it("does not treat stale remote refs as current when fetch fails", async () => { + await withTempDir({ prefix: "openclaw-update-check-fetch-failure-" }, async (base) => { + const remoteRoot = path.join(base, "remote"); + const localRoot = path.join(base, "local"); + await initGitRepo(remoteRoot); + await commitGit(remoteRoot, "initial"); + await runGit(base, "clone", "--quiet", remoteRoot, localRoot); + await runGit(localRoot, "remote", "set-url", "origin", path.join(base, "missing")); + const commitAtMs = + Number(await runGit(localRoot, "show", "-s", "--format=%ct", "HEAD")) * 1000; + + const status = await checkUpdateStatus({ + root: localRoot, + includeRegistry: false, + fetchGit: true, + timeoutMs: 5000, + }); + + expect(status.git).toMatchObject({ + upstream: "origin/main", + upstreamSha: null, + commitAtMs, + ahead: null, + behind: null, + fetchOk: false, + }); + }); + }); + it("does not report divergence for unrelated histories", async () => { await withTempDir({ prefix: "openclaw-update-check-unrelated-" }, async (base) => { const localRoot = path.join(base, "local"); diff --git a/src/infra/update-check.ts b/src/infra/update-check.ts index ee2f8042c2e1..00fa8253070c 100644 --- a/src/infra/update-check.ts +++ b/src/infra/update-check.ts @@ -25,6 +25,7 @@ type GitUpdateStatus = { branch: string | null; upstream: string | null; upstreamSha?: string | null; + commitAtMs?: number | null; dirty: boolean | null; ahead: number | null; behind: number | null; @@ -238,19 +239,23 @@ async function checkGitUpdateStatus(params: { branch: null, upstream: null, upstreamSha: null, + commitAtMs: null, dirty: null, ahead: null, behind: null, fetchOk: null, }; - const [branchRes, shaRes, tagRes, upstreamRes, dirtyRes] = await Promise.all([ + const [branchRes, shaRes, commitAtRes, tagRes, upstreamRes, dirtyRes] = await Promise.all([ runCommandWithTimeout(["git", "-C", root, "rev-parse", "--abbrev-ref", "HEAD"], { timeoutMs, }).catch(() => null), runCommandWithTimeout(["git", "-C", root, "rev-parse", "HEAD"], { timeoutMs, }).catch(() => null), + runCommandWithTimeout(["git", "-C", root, "show", "-s", "--format=%ct", "HEAD"], { + timeoutMs, + }).catch(() => null), runCommandWithTimeout(["git", "-C", root, "describe", "--tags", "--exact-match"], { timeoutMs, }).catch(() => null), @@ -270,6 +275,9 @@ async function checkGitUpdateStatus(params: { const branch = branchRes.stdout.trim() || null; const sha = shaRes && shaRes.code === 0 ? shaRes.stdout.trim() : null; + const commitAtSeconds = + commitAtRes?.code === 0 ? Number.parseInt(commitAtRes.stdout.trim(), 10) : Number.NaN; + const commitAtMs = Number.isSafeInteger(commitAtSeconds) ? commitAtSeconds * 1000 : null; const tag = tagRes && tagRes.code === 0 ? tagRes.stdout.trim() : null; @@ -283,11 +291,13 @@ async function checkGitUpdateStatus(params: { .catch(() => false) : null; + const canCompareUpstream = !params.fetch || fetchOk === true; + // Freeze the post-fetch upstream for both graph queries. Resolve via @{upstream} rather than // its display name so dashed remotes stay operands on older Git versions. Three-dot rev-list // still counts disconnected or truncated histories, so require a visible common ancestor. const upstreamCommitRes = - upstream && sha + canCompareUpstream && upstream && sha ? await runCommandWithTimeout( ["git", "-C", root, "rev-parse", "--verify", "@{upstream}^{commit}"], { timeoutMs }, @@ -330,6 +340,7 @@ async function checkGitUpdateStatus(params: { branch, upstream, upstreamSha: upstreamCommit, + commitAtMs, dirty, ahead: parsed?.ahead ?? null, behind: parsed?.behind ?? null, diff --git a/src/infra/update-restart-sentinel-payload.ts b/src/infra/update-restart-sentinel-payload.ts index 3929af5c9249..eaf88ca7a6e2 100644 --- a/src/infra/update-restart-sentinel-payload.ts +++ b/src/infra/update-restart-sentinel-payload.ts @@ -22,13 +22,27 @@ export type UpdateRestartSentinelMeta = { continuationMessage?: string | null; }; +export function normalizeControlPlaneUpdateResult(result: UpdateRunResult): UpdateRunResult { + const beforeSha = result.before?.sha?.trim(); + const afterSha = result.after?.sha?.trim(); + return result.status === "ok" && + result.mode === "git" && + result.postUpdate?.plugins?.changed !== true && + beforeSha && + afterSha && + beforeSha === afterSha + ? { ...result, status: "skipped", reason: "already-current" } + : result; +} + /** Build the restart sentinel payload written after update runs. */ export function buildUpdateRestartSentinelPayload(params: { result: UpdateRunResult; meta: UpdateRestartSentinelMeta; nowMs?: number; }): RestartSentinelPayload { - const { result, meta } = params; + const result = normalizeControlPlaneUpdateResult(params.result); + const { meta } = params; const continuation = result.status === "ok" ? buildRestartSuccessContinuation({ diff --git a/src/infra/update-startup.test.ts b/src/infra/update-startup.test.ts index 40d631ef93f0..736facf1a956 100644 --- a/src/infra/update-startup.test.ts +++ b/src/infra/update-startup.test.ts @@ -19,6 +19,7 @@ import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely, } from "./kysely-sync.js"; +import { writeUpdateInstallReceiptRowSync } from "./restart-sentinel-store.js"; import type { UpdateCheckResult } from "./update-check.js"; const { @@ -321,8 +322,12 @@ describe("update-startup", () => { function mockDevGitStatus(params?: { currentSha?: string; - upstreamSha?: string; + upstream?: string | null; + upstreamSha?: string | null; + commitAtMs?: number | null; + ahead?: number | null; behind?: number | null; + fetchOk?: boolean; }) { vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue("/opt/openclaw"); vi.mocked(checkUpdateStatus).mockResolvedValue({ @@ -334,12 +339,13 @@ describe("update-startup", () => { sha: params?.currentSha ?? "current-sha", tag: null, branch: "main", - upstream: "origin/main", - upstreamSha: params?.upstreamSha ?? "upstream-sha", + upstream: params?.upstream === undefined ? "origin/main" : params.upstream, + upstreamSha: params?.upstreamSha === undefined ? "upstream-sha" : params.upstreamSha, + commitAtMs: params?.commitAtMs ?? null, dirty: false, - ahead: 0, - behind: params?.behind ?? 2, - fetchOk: true, + ahead: params?.ahead === undefined ? 0 : params.ahead, + behind: params?.behind === undefined ? 2 : params.behind, + fetchOk: params?.fetchOk ?? true, }, } satisfies UpdateCheckResult); } @@ -1052,7 +1058,7 @@ describe("update-startup", () => { expect(getUpdateSchedule()).toMatchObject({ channel: "dev", autoEnabled: true, - install: { kind: "git" }, + install: { kind: "git", git: { status: "behind", commitsBehind: 2 } }, target: { kind: "git", upstreamRef: "origin/main", @@ -1132,6 +1138,81 @@ describe("update-startup", () => { expect(runCommandWithTimeout).not.toHaveBeenCalled(); expect(getUpdateAvailable()).toBeNull(); + expect(getUpdateSchedule()?.install).toEqual({ + kind: "git", + git: { currentSha: "current-sha", status: "current" }, + }); + }); + + it("reports commit and verified installation times for the current checkout", async () => { + const installedAtMs = Date.now() - 60 * 60 * 1000; + const commitAtMs = installedAtMs - 24 * 60 * 60 * 1000; + runOpenClawStateWriteTransaction(({ db }) => { + writeUpdateInstallReceiptRowSync(db, { + kind: "update", + status: "ok", + ts: installedAtMs, + stats: { + mode: "git", + after: { sha: "current-sha", version: "1.0.0" }, + }, + }); + }); + mockDevGitStatus({ behind: 0, commitAtMs }); + + await runGatewayUpdateCheck({ + cfg: { update: { channel: "dev" } }, + log: { info: vi.fn() }, + isNixMode: false, + allowInTests: true, + }); + + expect(getUpdateSchedule()?.install?.git).toEqual({ + status: "current", + currentSha: "current-sha", + commitAtMs, + installedAtMs, + }); + }); + + it.each([ + { + name: "failed fetch", + git: { fetchOk: false, ahead: null, behind: null }, + expected: { status: "unavailable", reason: "fetch-failed" }, + }, + { + name: "missing upstream", + git: { upstream: null, upstreamSha: null, ahead: null, behind: null }, + expected: { status: "unavailable", reason: "no-upstream" }, + }, + { + name: "incomparable history", + git: { ahead: null, behind: null }, + expected: { status: "unavailable", reason: "comparison-failed" }, + }, + { + name: "ahead checkout", + git: { ahead: 2, behind: 0 }, + expected: { status: "ahead", commitsAhead: 2 }, + }, + { + name: "diverged checkout", + git: { ahead: 1, behind: 3 }, + expected: { status: "diverged", commitsAhead: 1, commitsBehind: 3 }, + }, + ])("reports $name without fabricating current", async ({ git, expected }) => { + mockDevGitStatus(git); + + await runGatewayUpdateCheck({ + cfg: { update: { channel: "dev" } }, + log: { info: vi.fn() }, + isNixMode: false, + allowInTests: true, + }); + + expect(getUpdateSchedule()?.install?.git).toEqual({ currentSha: "current-sha", ...expected }); + expect(getUpdateSchedule()?.install?.git?.status).not.toBe("current"); }); it("resets a busy dev campaign and forces it at the deadline", async () => { diff --git a/src/infra/update-startup.ts b/src/infra/update-startup.ts index e2207502ff51..1a5a9ab99f28 100644 --- a/src/infra/update-startup.ts +++ b/src/infra/update-startup.ts @@ -37,6 +37,7 @@ import { getNodeSqliteKysely, } from "./kysely-sync.js"; import { resolveOpenClawPackageRoot } from "./openclaw-root.js"; +import { readUpdateInstallReceipt, type RestartSentinelPayload } from "./restart-sentinel.js"; import { resolveGatewayRestartDeferralTimeoutMs, scheduleGatewaySigusr1Restart, @@ -49,7 +50,12 @@ import { DEFAULT_PACKAGE_CHANNEL, type UpdateChannel, } from "./update-channels.js"; -import { compareSemverStrings, resolveNpmChannelTag, checkUpdateStatus } from "./update-check.js"; +import { + compareSemverStrings, + resolveNpmChannelTag, + checkUpdateStatus, + type UpdateCheckResult, +} from "./update-check.js"; import { CONTROL_PLANE_UPDATE_HANDOFF_STARTED_REASON } from "./update-control-plane-sentinel.js"; import { startManagedServiceUpdateHandoff } from "./update-managed-service-handoff.js"; @@ -588,13 +594,121 @@ async function resolveStartupInstallStatus(fetchGit: boolean) { argv1: process.argv[1], cwd: process.cwd(), }); - const status = await checkUpdateStatus({ - root, - timeoutMs: 2500, - fetchGit, - includeRegistry: false, - }); - return { root, status }; + const [status, installReceipt] = await Promise.all([ + checkUpdateStatus({ + root, + timeoutMs: 2500, + fetchGit, + includeRegistry: false, + }), + readUpdateInstallReceipt(), + ]); + return { root, status, installReceipt }; +} + +type GitScheduleStatus = NonNullable["git"]>; + +function gitCommitsMatch(left: string, right: string): boolean { + const normalizedLeft = left.trim().toLowerCase(); + const normalizedRight = right.trim().toLowerCase(); + return ( + normalizedLeft.length >= 7 && + normalizedRight.length >= 7 && + (normalizedLeft.startsWith(normalizedRight) || normalizedRight.startsWith(normalizedLeft)) + ); +} + +function resolveGitInstalledAtMs( + git: NonNullable, + installReceipt: RestartSentinelPayload | null, +): number | undefined { + const receiptSha = installReceipt?.stats?.after?.sha; + return installReceipt?.kind === "update" && + installReceipt.status === "ok" && + installReceipt.stats?.mode === "git" && + typeof receiptSha === "string" && + git.sha && + gitCommitsMatch(receiptSha, git.sha) + ? installReceipt.ts + : undefined; +} + +function resolveGitScheduleStatus( + update: UpdateCheckResult, + installReceipt: RestartSentinelPayload | null, +): GitScheduleStatus | undefined { + if (update.installKind !== "git") { + return undefined; + } + const git = update.git; + const installedAtMs = git ? resolveGitInstalledAtMs(git, installReceipt) : undefined; + const metadata = git + ? { + ...(git.sha ? { currentSha: git.sha } : {}), + ...(typeof git.commitAtMs === "number" ? { commitAtMs: git.commitAtMs } : {}), + ...(installedAtMs === undefined ? {} : { installedAtMs }), + } + : {}; + if (!git || git.error || !git.sha) { + return { ...metadata, status: "unavailable", reason: "git-unavailable" }; + } + if (git.fetchOk !== true) { + return { ...metadata, status: "unavailable", reason: "fetch-failed" }; + } + if (!git.upstream) { + return { ...metadata, status: "unavailable", reason: "no-upstream" }; + } + if (!git.upstreamSha) { + return { ...metadata, status: "unavailable", reason: "no-upstream-sha" }; + } + if (git.ahead === null || git.behind === null) { + return { ...metadata, status: "unavailable", reason: "comparison-failed" }; + } + if (git.ahead > 0 && git.behind > 0) { + return { + ...metadata, + status: "diverged", + commitsAhead: git.ahead, + commitsBehind: git.behind, + }; + } + if (git.behind > 0) { + return { ...metadata, status: "behind", commitsBehind: git.behind }; + } + if (git.ahead > 0) { + return { ...metadata, status: "ahead", commitsAhead: git.ahead }; + } + return { ...metadata, status: "current" }; +} + +function withInstallStatus( + schedule: UpdateScheduleState, + update: UpdateCheckResult, + includeGitStatus: boolean, + installReceipt: RestartSentinelPayload | null, +): UpdateScheduleState { + const git = includeGitStatus ? resolveGitScheduleStatus(update, installReceipt) : undefined; + return { + ...schedule, + install: { + kind: update.installKind, + ...(git ? { git } : {}), + }, + }; +} + +/** Refreshes the read-only Dev checkout comparison used by update.status. */ +export async function refreshGatewayUpdateStatus(cfg: OpenClawConfig): Promise { + const channel = normalizeUpdateChannel(cfg.update?.channel) ?? DEFAULT_PACKAGE_CHANNEL; + if (channel !== "dev") { + return; + } + const { status, installReceipt } = await resolveStartupInstallStatus(true); + const current = + updateScheduleCache?.channel === channel + ? updateScheduleCache + : { channel, autoEnabled: Boolean(cfg.update?.auto?.enabled) }; + setUpdateScheduleCache({ next: withInstallStatus(current, status, true, installReceipt) }); } async function resolveDevGitCommits(params: { @@ -808,10 +922,12 @@ export async function runGatewayUpdateCheck(params: { if (configuredChannel === "extended-stable" || configuredChannel === "dev") { installStatus = await resolveStartupInstallStatus(configuredChannel === "dev"); setUpdateScheduleCache({ - next: { - ...(updateScheduleCache ?? initialSchedule), - install: { kind: installStatus.status.installKind }, - }, + next: withInstallStatus( + updateScheduleCache ?? initialSchedule, + installStatus.status, + configuredChannel === "dev", + installStatus.installReceipt, + ), onUpdateScheduleChange: params.onUpdateScheduleChange, }); } @@ -895,12 +1011,14 @@ export async function runGatewayUpdateCheck(params: { } installStatus ??= await resolveStartupInstallStatus(false); - const { root, status } = installStatus; + const { root, status, installReceipt } = installStatus; setUpdateScheduleCache({ - next: { - ...(updateScheduleCache ?? initialSchedule), - install: { kind: status.installKind }, - }, + next: withInstallStatus( + updateScheduleCache ?? initialSchedule, + status, + isDevGit, + installReceipt, + ), onUpdateScheduleChange: params.onUpdateScheduleChange, }); diff --git a/ui/src/app/overlays-update-campaign.test.ts b/ui/src/app/overlays-update-campaign.test.ts index c1effca16b0e..68ef16a4cde4 100644 --- a/ui/src/app/overlays-update-campaign.test.ts +++ b/ui/src/app/overlays-update-campaign.test.ts @@ -12,6 +12,39 @@ import { createApplicationOverlays } from "./overlays.ts"; afterEach(() => vi.useRealTimers()); describe("application update campaign overlays", () => { + it("refreshes an explicit dev checkout comparison on demand", async () => { + const request = vi.fn(async (method) => + method === "update.status" + ? { + sentinel: null, + updateAvailable: null, + schedule: { + channel: "dev", + autoEnabled: false, + install: { kind: "git", git: { status: "behind", commitsBehind: 12 } }, + }, + } + : {}, + ); + const harness = createGatewayHarness(client(request)); + harness.update({ + hello: { + auth: { role: "operator", scopes: ["operator.admin"] }, + snapshot: { updateSchedule: { channel: "dev", autoEnabled: false } }, + } as ApplicationGatewaySnapshot["hello"], + }); + const overlays = createApplicationOverlays(harness.gateway); + + await overlays.refreshUpdateStatus(); + + expect(request).toHaveBeenCalledWith("update.status", {}, { timeoutMs: 5_000 }); + expect(overlays.snapshot.updateSchedule?.install?.git).toEqual({ + status: "behind", + commitsBehind: 12, + }); + 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-update-race.test.ts b/ui/src/app/overlays-update-race.test.ts index 57925556e34d..4d716f4e3a24 100644 --- a/ui/src/app/overlays-update-race.test.ts +++ b/ui/src/app/overlays-update-race.test.ts @@ -21,6 +21,9 @@ function installUpdateTranslations() { "updates.outcomeUnknown": UNKNOWN_OUTCOME_TEXT, "updates.verificationFailedWithVersions": "Update installed but running version did not change — restart may have been blocked. Expected v{expectedVersion}, running v{actualVersion}.", + "updates.verificationFailedWithIdentity": + "Update finished, but the running install does not match the expected revision. Expected {expected}, running {actual}.", + "common.unknown": "Unknown", }; return vi.spyOn(i18n, "t").mockImplementation((key, params) => { const template = translations[key] ?? key; @@ -37,7 +40,7 @@ afterEach(() => { }); describe("application update reconciliation races", () => { - it("does not accept a cached status when disconnect wins the update.run response race", async () => { + it("checks the authoritative sentinel when disconnect wins the update.run response race", async () => { installUpdateTranslations(); const updateRun = deferred<{ ok: boolean; @@ -89,15 +92,11 @@ describe("application update reconciliation races", () => { harness.update({ phase: "connected" }); await flushMicrotasks(); - expect(request).not.toHaveBeenCalledWith( - "update.status", - expect.anything(), - expect.anything(), - ); + expectUpdateStatusRequested(request); expect(overlays.snapshot.updateReconciliationPending).toBe(false); expect(overlays.snapshot.updateStatusBanner).toEqual({ tone: "danger", - text: UNKNOWN_OUTCOME_TEXT, + text: expect.stringContaining("Expected v2.0.0, running v1.0.0"), }); updateRun.resolve({ @@ -117,7 +116,7 @@ describe("application update reconciliation races", () => { } }); - it("accepts the replacement Gateway version as proof of an ambiguous update", async () => { + it("accepts the replacement Gateway sentinel as proof of an ambiguous update", async () => { installUpdateTranslations(); const updateRun = deferred(); const request = vi.fn((method) => { @@ -127,6 +126,15 @@ describe("application update reconciliation races", () => { if (method === "update.run") { return updateRun.promise; } + if (method === "update.status") { + return Promise.resolve({ + sentinel: { + kind: "update", + status: "ok", + stats: { after: { version: "2.0.0" } }, + }, + }); + } return Promise.resolve({}); }); const gatewayClient = client(request); @@ -159,11 +167,7 @@ describe("application update reconciliation races", () => { expect(overlays.snapshot.updateReconciliationPending).toBe(false); expect(overlays.snapshot.updateStatusBanner).toBeNull(); - expect(request).not.toHaveBeenCalledWith( - "update.status", - expect.anything(), - expect.anything(), - ); + expectUpdateStatusRequested(request); updateRun.resolve({}); await running; diff --git a/ui/src/app/overlays.test.ts b/ui/src/app/overlays.test.ts index a5e23e24593f..47b89616f621 100644 --- a/ui/src/app/overlays.test.ts +++ b/ui/src/app/overlays.test.ts @@ -18,6 +18,7 @@ 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(), })); const { peekStoredDeviceIdentityIdMock } = vi.hoisted(() => ({ peekStoredDeviceIdentityIdMock: vi.fn((): string | null => "browser-1"), @@ -38,6 +39,9 @@ function installUpdateTranslations() { "Another managed update is already running. Wait for it to complete, then refresh update status.", "updates.verificationFailedWithVersions": "Update installed but running version did not change — restart may have been blocked. Expected v{expectedVersion}, running v{actualVersion}.", + "updates.verificationFailedWithIdentity": + "Update finished, but the running install does not match the expected revision. Expected {expected}, running {actual}.", + "common.unknown": "Unknown", "updates.outcomeUnknown": "The update request may have been accepted, but the Gateway did not report a final result after reconnect. Run `openclaw update status` before retrying.", }; diff --git a/ui/src/app/overlays.ts b/ui/src/app/overlays.ts index 778e74b2d86c..fe147feceaa0 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 } from "../build-info.ts"; +import { controlUiVersionDiffersFrom, reloadControlUiIfStale } from "../build-info.ts"; import { t } from "../i18n/index.ts"; import { closeDevicePairSetup as closeDevicePairSetupState, @@ -40,18 +40,22 @@ import { readOverlayOperatorAccessTransition, } from "./overlays-access.ts"; import { + createPendingUpdateReconciliation, createUpdateCampaignStatusPoller, + createUpdateStatusRefresher, createUpdateVerificationController, - isPendingUpdateHandoffSentinel, + 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"; @@ -79,6 +83,7 @@ type ApplicationOverlaySnapshot = { export type ApplicationOverlays = { readonly snapshot: ApplicationOverlaySnapshot; subscribe: (listener: (snapshot: ApplicationOverlaySnapshot) => void) => () => void; + refreshUpdateStatus: () => Promise; runUpdate: () => Promise; holdUpdate: () => Promise; decideApproval: (decision: ExecApprovalDecision, approvalId?: string) => Promise; @@ -231,41 +236,32 @@ export function createApplicationOverlays( getHello: () => gateway.snapshot.hello, publish, publishBanner: publishUpdateBanner, + onVerifiedInstall: reloadControlUiIfStale, }); + const applyUpdateStatusResponse = (response: UpdateRestartStatusResponse) => { + snapshot = { + ...snapshot, + ...projectUpdateStatusResponse(response, { + updateStatusBanner: snapshot.updateStatusBanner, + heldUpdateCampaignId: snapshot.heldUpdateCampaignId, + }), + }; + publish(); + }; const updateCampaignPoller = createUpdateCampaignStatusPoller({ getClient: () => activeClient, getEpoch: () => connectedEpoch, canPoll: () => operatorAccess.canAdmin, getSchedule: () => snapshot.updateSchedule, isCurrent: (client, epoch) => epoch === connectedEpoch && isCurrentClient(client), - onStatus: (response) => { - const sentinel = response.sentinel; - const updateSchedule = Object.hasOwn(response, "schedule") - ? readUpdateScheduleValue(response.schedule) - : undefined; - snapshot = { - ...snapshot, - updateStatusBanner: - sentinel?.kind === "update" && sentinel.status - ? sentinel.status === "ok" || isPendingUpdateHandoffSentinel(sentinel) - ? null - : resolveUpdateStatusBanner({ - status: sentinel.status, - reason: sentinel.stats?.reason ?? undefined, - }) - : snapshot.updateStatusBanner, - ...(Object.hasOwn(response, "updateAvailable") - ? { updateAvailable: readUpdateAvailableValue(response.updateAvailable) } - : {}), - ...(updateSchedule !== undefined - ? { - updateSchedule, - heldUpdateCampaignId: heldCampaignId(updateSchedule), - } - : {}), - }; - publish(); - }, + onStatus: applyUpdateStatusResponse, + }); + const refreshUpdateStatus = createUpdateStatusRefresher({ + getClient: () => activeClient, + getEpoch: () => connectedEpoch, + canRefresh: () => operatorAccess.canAdmin, + isCurrent: (client, epoch) => epoch === connectedEpoch && isCurrentClient(client), + onStatus: applyUpdateStatusResponse, }); const synchronizeGateway = (next: ApplicationGateway["snapshot"]) => { @@ -452,6 +448,7 @@ export function createApplicationOverlays( listeners.add(listener); return () => listeners.delete(listener); }, + refreshUpdateStatus, async runUpdate() { const client = gateway.snapshot.client; if ( @@ -479,8 +476,11 @@ export function createApplicationOverlays( ) { return; } - const announcedVersion = snapshot.updateAvailable?.latestVersion?.trim() || null; - pendingUpdate = { expected: announcedVersion, kind: "ambiguous" }; + pendingUpdate = createPendingUpdateReconciliation( + "ambiguous", + snapshot.updateAvailable?.latestVersion?.trim() || null, + resolveExpectedUpdateSha(snapshot.updateSchedule, snapshot.updateAvailable), + ); publish(); const response = await client.request("update.run", {}); if ( @@ -492,18 +492,20 @@ export function createApplicationOverlays( return; } const status = response.result?.status ?? (response.ok === true ? "ok" : "error"); - const expectedVersion = response.result?.after?.version?.trim() || announcedVersion; + 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 = { expected: expectedVersion, kind: "handoff" }; + pendingUpdate = { expectedVersion, expectedSha, kind: "handoff" }; return; } if (response.ok === true && status === "ok") { - pendingUpdate = { expected: expectedVersion, kind: "restart" }; + pendingUpdate = { expectedVersion, expectedSha, kind: "restart" }; if (response.restart?.coalesced === true) { snapshot = { ...snapshot, diff --git a/ui/src/app/update-overlay-helpers.test.ts b/ui/src/app/update-overlay-helpers.test.ts index ff4b634f961a..c3941157ba6e 100644 --- a/ui/src/app/update-overlay-helpers.test.ts +++ b/ui/src/app/update-overlay-helpers.test.ts @@ -24,6 +24,10 @@ const translations: Record = { "Update installed but running version did not change — restart may have been blocked.", "updates.verificationFailedWithVersions": "Update installed but running version did not change — restart may have been blocked. Expected v{expectedVersion}, running v{actualVersion}.", + "updates.verificationFailedWithIdentity": + "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.", @@ -51,6 +55,7 @@ async function verifyUpdate(params: { response: unknown; hello?: GatewayHelloOk | null; advanceToMs?: number; + onVerifiedInstall?: (identity: { version: string | null; sha: string | null }) => void; }): Promise { vi.useFakeTimers(); vi.setSystemTime(0); @@ -72,6 +77,7 @@ async function verifyUpdate(params: { publishBanner: (value) => { banner = value; }, + ...(params.onVerifiedInstall ? { onVerifiedInstall: params.onVerifiedInstall } : {}), }); await controller.verify(client, 1); @@ -106,7 +112,16 @@ describe("update schedule hydration", () => { const updateSchedule = { channel: "dev", autoEnabled: true, - install: { kind: "git" }, + install: { + kind: "git", + git: { + status: "behind", + currentSha: "a".repeat(40), + commitAtMs: 1_000, + installedAtMs: 2_000, + commitsBehind: 3, + }, + }, target: { kind: "git", upstreamRef: "origin/main", @@ -226,7 +241,7 @@ describe("update status localization", () => { await expect( verifyUpdate({ - pending: { kind: "restart", expected: "2.0.0" }, + pending: { kind: "restart", expectedVersion: "2.0.0", expectedSha: null }, response: { sentinel: { kind: "update", @@ -237,17 +252,61 @@ describe("update status localization", () => { }), ).resolves.toEqual({ tone: "danger", - text: "Update installed but running version did not change — restart may have been blocked. Expected v2.0.0, running v1.9.0.", + text: "Update finished, but the running install does not match the expected revision. Expected v2.0.0, running v1.9.0.", }); await expect( verifyUpdate({ - pending: { kind: "restart", expected: "2.0.0" }, + pending: { kind: "restart", expectedVersion: "2.0.0", expectedSha: null }, response: null, advanceToMs: 10_000, }), ).resolves.toEqual({ tone: "danger", - text: "Update installed but running version did not change — restart may have been blocked.", + text: "Update finished, but the running install does not match the expected revision. Expected v2.0.0, running Unknown.", + }); + }); + + it("verifies the restarted Git revision before reporting success", async () => { + installTranslations(); + const onVerifiedInstall = vi.fn(); + + await expect( + verifyUpdate({ + pending: { + kind: "restart", + expectedVersion: "2.0.0", + expectedSha: "abcdef0123456789", + }, + response: { + sentinel: { + kind: "update", + status: "ok", + stats: { after: { version: "2.0.0", sha: "abcdef0" } }, + }, + }, + onVerifiedInstall, + }), + ).resolves.toBeNull(); + expect(onVerifiedInstall).toHaveBeenCalledWith({ version: "2.0.0", sha: "abcdef0" }); + + await expect( + verifyUpdate({ + pending: { + kind: "restart", + expectedVersion: "2.0.0", + expectedSha: "abcdef0123456789", + }, + response: { + sentinel: { + kind: "update", + status: "ok", + stats: { after: { version: "2.0.0", sha: "1234567" } }, + }, + }, + }), + ).resolves.toEqual({ + tone: "danger", + text: "Update finished, but the running install does not match the expected revision. Expected abcdef012345, running 1234567.", }); }); @@ -256,7 +315,7 @@ describe("update status localization", () => { await expect( verifyUpdate({ - pending: { kind: "restart", expected: "2.0.0" }, + pending: { kind: "restart", expectedVersion: "2.0.0", expectedSha: null }, response: { sentinel: { kind: "update", @@ -271,7 +330,7 @@ describe("update status localization", () => { }); await expect( verifyUpdate({ - pending: { kind: "restart", expected: "2.0.0" }, + pending: { kind: "restart", expectedVersion: "2.0.0", expectedSha: null }, response: { sentinel: { kind: "update", @@ -286,7 +345,7 @@ describe("update status localization", () => { }); await expect( verifyUpdate({ - pending: { kind: "handoff", expected: null }, + pending: { kind: "handoff", expectedVersion: null, expectedSha: null }, response: { sentinel: { kind: "update", diff --git a/ui/src/app/update-overlay-helpers.ts b/ui/src/app/update-overlay-helpers.ts index baef26365738..bd9e1da3c932 100644 --- a/ui/src/app/update-overlay-helpers.ts +++ b/ui/src/app/update-overlay-helpers.ts @@ -31,18 +31,21 @@ const UPDATE_FAILURE_REASON_KEYS: Record = { "restart-disabled": "updates.failureReasons.restartDisabled", "restart-unavailable": "updates.failureReasons.restartUnavailable", "restart-unhealthy": "updates.failureReasons.restartUnhealthy", + "restart-revision-mismatch": "updates.failureReasons.restartRevisionMismatch", + "restart-revision-unavailable": "updates.failureReasons.restartRevisionUnavailable", + "already-current": "updates.failureReasons.alreadyCurrent", "managed-service-handoff-already-running": "updates.failureReasons.managedServiceHandoffAlreadyRunning", "doctor-failed": "updates.failureReasons.doctorFailed", }; -type UpdateRestartStatusResponse = { +export type UpdateRestartStatusResponse = { sentinel?: { kind?: string; status?: string; stats?: { reason?: string | null; - after?: { version?: string | null } | null; + after?: { sha?: string | null; version?: string | null } | null; } | null; } | null; updateAvailable?: UpdateAvailable | null; @@ -54,7 +57,8 @@ export type UpdateRunResponse = { result?: { status?: string; reason?: string; - after?: { version?: string | null } | null; + before?: { sha?: string | null; version?: string | null } | null; + after?: { sha?: string | null; version?: string | null } | null; }; handoff?: { status?: string }; restart?: { coalesced?: boolean } | null; @@ -71,16 +75,64 @@ async function requestUpdateRestartStatus( } } +export function createUpdateStatusRefresher(params: { + getClient: () => GatewayBrowserClient | null; + getEpoch: () => number; + canRefresh: () => boolean; + isCurrent: (client: GatewayBrowserClient, epoch: number) => boolean; + onStatus: (response: UpdateRestartStatusResponse) => void; +}) { + return async () => { + const client = params.getClient(); + const epoch = params.getEpoch(); + if (!client || !params.canRefresh()) { + return; + } + const response = await requestUpdateRestartStatus(client, 5_000); + if (response && params.isCurrent(client, epoch)) { + params.onStatus(response); + } + }; +} + +export function resolveExpectedUpdateSha( + schedule: UpdateScheduleState | null, + updateAvailable: UpdateAvailable | null, +): string | null { + return schedule?.target?.kind === "git" + ? schedule.target.upstreamSha.trim() || null + : updateAvailable?.upstreamSha?.trim() || null; +} + export type PendingUpdateReconciliation = { - expected: string | null; + expectedVersion: string | null; + expectedSha: string | null; kind: "ambiguous" | "handoff" | "restart"; }; +export function createPendingUpdateReconciliation( + kind: PendingUpdateReconciliation["kind"], + expectedVersion: string | null, + expectedSha: string | null, +): PendingUpdateReconciliation { + return { expectedVersion, expectedSha, kind }; +} + type UpdateVerificationWait = { timer: ReturnType; resolve: (active: boolean) => void; }; +function commitsMatch(left: string, right: string): boolean { + const normalizedLeft = left.trim().toLowerCase(); + const normalizedRight = right.trim().toLowerCase(); + return ( + normalizedLeft.length >= 7 && + normalizedRight.length >= 7 && + (normalizedLeft.startsWith(normalizedRight) || normalizedRight.startsWith(normalizedLeft)) + ); +} + export function createUpdateVerificationController(params: { getPending: () => PendingUpdateReconciliation | null; clearPending: () => void; @@ -88,6 +140,7 @@ export function createUpdateVerificationController(params: { getHello: () => GatewayHelloOk | null; publish: () => void; publishBanner: (banner: ApplicationStatusBanner | null) => void; + onVerifiedInstall?: (identity: { version: string | null; sha: string | null }) => void; }) { let generation = 0; let wait: UpdateVerificationWait | null = null; @@ -122,15 +175,11 @@ export function createUpdateVerificationController(params: { if (!reconciliation) { return; } - const expectedVersion = reconciliation.expected?.trim() || null; - if (reconciliation.kind === "ambiguous") { - // Only the replacement Gateway version can prove a response-lost request; status is cached. - params.clearPending(); - params.publishBanner(resolveAmbiguousUpdateOutcomeBanner(expectedVersion, params.getHello())); - return; - } + const expectedVersion = reconciliation.expectedVersion?.trim() || null; + const expectedSha = reconciliation.expectedSha?.trim() || null; const isCurrent = () => currentGeneration === generation && params.isCurrent(client, epoch); - let { deadline, pollMs } = resolveUpdateVerificationWindow(reconciliation.kind); + const verificationKind = reconciliation.kind === "handoff" ? "handoff" : "restart"; + let { deadline, pollMs } = resolveUpdateVerificationWindow(verificationKind); while (isCurrent() && Date.now() < deadline) { const response = await requestUpdateRestartStatus(client, Math.max(0, deadline - Date.now())); if (!isCurrent()) { @@ -159,24 +208,35 @@ export function createUpdateVerificationController(params: { return; } const actualVersion = sentinel?.stats?.after?.version?.trim() || null; - if ( - sentinel?.kind === "update" && - sentinel.status === "ok" && - !actualVersion && - !expectedVersion - ) { - params.clearPending(); - params.publish(); - return; - } - if (sentinel?.kind === "update" && actualVersion) { - params.clearPending(); - params.publishBanner( - expectedVersion && actualVersion !== expectedVersion - ? resolveUpdateVerificationBanner({ expectedVersion, actualVersion }) - : null, - ); - return; + const actualSha = sentinel?.stats?.after?.sha?.trim() || null; + if (sentinel?.kind === "update" && sentinel.status === "ok") { + const versionMatches = !expectedVersion || actualVersion === expectedVersion; + const shaMatches = + !expectedSha || (actualSha !== null && commitsMatch(expectedSha, actualSha)); + const hasExpectedIdentity = expectedVersion !== null || expectedSha !== null; + const hasActualIdentity = actualVersion !== null || actualSha !== null; + if (versionMatches && shaMatches && (hasActualIdentity || !hasExpectedIdentity)) { + params.clearPending(); + params.onVerifiedInstall?.({ version: actualVersion, sha: actualSha }); + params.publishBanner(null); + return; + } + const versionMismatch = + expectedVersion !== null && actualVersion !== null && actualVersion !== expectedVersion; + const shaMismatch = + expectedSha !== null && actualSha !== null && !commitsMatch(expectedSha, actualSha); + if (versionMismatch || shaMismatch) { + params.clearPending(); + params.publishBanner( + resolveUpdateVerificationBanner({ + expectedVersion, + actualVersion, + expectedSha, + actualSha, + }), + ); + return; + } } const remainingMs = deadline - Date.now(); if (remainingMs <= 0) { @@ -192,11 +252,16 @@ export function createUpdateVerificationController(params: { const currentVersion = params.getHello()?.server?.version?.trim() || null; params.clearPending(); params.publishBanner( - expectedVersion && currentVersion !== expectedVersion - ? resolveUpdateVerificationBanner({ expectedVersion, actualVersion: currentVersion }) + expectedSha || (expectedVersion && currentVersion !== expectedVersion) + ? resolveUpdateVerificationBanner({ + expectedVersion, + actualVersion: currentVersion, + expectedSha, + actualSha: null, + }) : reconciliation.kind === "handoff" ? resolvePendingUpdateHandoffTimeoutBanner() - : null, + : resolveUnknownUpdateOutcomeBanner(), ); }; return { cancel, verify }; @@ -329,6 +394,82 @@ function readScheduleTarget(value: unknown): UpdateScheduleState["target"] | nul 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) || @@ -368,7 +509,8 @@ export function readUpdateScheduleValue(value: unknown): UpdateScheduleState | n ) { return null; } - const rawInstallKind = isRecord(value.install) ? value.install.kind : undefined; + const rawInstall = isRecord(value.install) ? value.install : null; + const rawInstallKind = rawInstall?.kind; const installKind = rawInstallKind === "package" || rawInstallKind === "git" || rawInstallKind === "unknown" ? rawInstallKind @@ -376,6 +518,10 @@ export function readUpdateScheduleValue(value: unknown): UpdateScheduleState | n 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)) { @@ -384,7 +530,9 @@ export function readUpdateScheduleValue(value: unknown): UpdateScheduleState | n return { channel: value.channel, autoEnabled: value.autoEnabled, - ...(installKind ? { install: { kind: installKind } } : {}), + ...(installKind + ? { install: { kind: installKind, ...(gitStatus ? { git: gitStatus } : {}) } } + : {}), ...(target ? { target } : {}), ...(campaign ? { campaign } : {}), }; @@ -398,6 +546,47 @@ export function readUpdateSchedule(hello: GatewayHelloOk | null): UpdateSchedule return readUpdateScheduleValue(snapshot.updateSchedule); } +export function projectUpdateStatusResponse( + response: UpdateRestartStatusResponse, + current: { + updateStatusBanner: ApplicationStatusBanner | null; + heldUpdateCampaignId: string | null; + }, +): { + updateStatusBanner: ApplicationStatusBanner | null; + updateAvailable?: UpdateAvailable | null; + updateSchedule?: UpdateScheduleState | null; + heldUpdateCampaignId?: string | null; +} { + const sentinel = response.sentinel; + const updateSchedule = Object.hasOwn(response, "schedule") + ? readUpdateScheduleValue(response.schedule) + : undefined; + return { + updateStatusBanner: + sentinel?.kind === "update" && sentinel.status + ? sentinel.status === "ok" || isPendingUpdateHandoffSentinel(sentinel) + ? null + : resolveUpdateStatusBanner({ + status: sentinel.status, + reason: sentinel.stats?.reason ?? undefined, + }) + : current.updateStatusBanner, + ...(Object.hasOwn(response, "updateAvailable") + ? { updateAvailable: readUpdateAvailableValue(response.updateAvailable) } + : {}), + ...(updateSchedule !== undefined + ? { + updateSchedule, + heldUpdateCampaignId: + updateSchedule?.campaign?.holdUntilMs !== undefined + ? updateSchedule.campaign.id + : current.heldUpdateCampaignId, + } + : {}), + }; +} + function formatUpdateCountdown(deadlineMs: number, nowMs = Date.now()): string { const totalSeconds = Math.max(0, Math.ceil((deadlineMs - nowMs) / 1_000)); const minutes = Math.floor(totalSeconds / 60); @@ -460,17 +649,24 @@ export function resolveUpdateStatusBanner(params: { } function resolveUpdateVerificationBanner(params: { - expectedVersion: string; + expectedVersion: string | null; actualVersion: string | null; + expectedSha: string | null; + actualSha: string | null; }): ApplicationStatusBanner { + const expected = params.expectedSha + ? params.expectedSha.slice(0, 12) + : params.expectedVersion + ? `v${params.expectedVersion}` + : t("common.unknown"); + const actual = params.actualSha + ? params.actualSha.slice(0, 12) + : params.actualVersion + ? `v${params.actualVersion}` + : t("common.unknown"); return { tone: "danger", - text: params.actualVersion - ? t("updates.verificationFailedWithVersions", { - expectedVersion: params.expectedVersion, - actualVersion: params.actualVersion, - }) - : t("updates.verificationFailed"), + text: t("updates.verificationFailedWithIdentity", { expected, actual }), }; } @@ -506,17 +702,7 @@ export function resolveUnknownUpdateOutcomeBanner(): ApplicationStatusBanner { }; } -function resolveAmbiguousUpdateOutcomeBanner( - expectedVersion: string | null, - hello: GatewayHelloOk | null, -): ApplicationStatusBanner | null { - const currentVersion = hello?.server?.version?.trim() || null; - return expectedVersion && currentVersion === expectedVersion - ? null - : resolveUnknownUpdateOutcomeBanner(); -} - -export function isPendingUpdateHandoffSentinel( +function isPendingUpdateHandoffSentinel( sentinel: UpdateRestartStatusResponse["sentinel"], ): boolean { const reason = sentinel?.stats?.reason; diff --git a/ui/src/build-info.test.ts b/ui/src/build-info.test.ts index 1ae56333e4cf..4747d773e729 100644 --- a/ui/src/build-info.test.ts +++ b/ui/src/build-info.test.ts @@ -7,6 +7,7 @@ describe("Control UI build info", () => { it("compares the normalized embedded version with the gateway", async () => { vi.stubGlobal("OPENCLAW_CONTROL_UI_BUILD_INFO", { version: "2026.7.19", + commit: COMMIT, buildId: "test", }); vi.resetModules(); @@ -15,6 +16,8 @@ describe("Control UI build info", () => { const { controlUiVersionDiffersFrom } = await import("./build-info.ts"); expect(controlUiVersionDiffersFrom(" 2026.7.19 ")).toBe(false); expect(controlUiVersionDiffersFrom("2026.7.20")).toBe(true); + expect(controlUiVersionDiffersFrom("2026.7.19", COMMIT.slice(0, 12))).toBe(false); + expect(controlUiVersionDiffersFrom("2026.7.19", "f".repeat(40))).toBe(true); expect(controlUiVersionDiffersFrom(undefined)).toBe(false); } finally { vi.unstubAllGlobals(); diff --git a/ui/src/build-info.ts b/ui/src/build-info.ts index 5445781e36d7..1d1ad58e64b8 100644 --- a/ui/src/build-info.ts +++ b/ui/src/build-info.ts @@ -14,10 +14,37 @@ const injectedBuildInfo = globalThis.OPENCLAW_CONTROL_UI_BUILD_INFO; export const CONTROL_UI_BUILD_INFO = normalizeControlUiBuildInfo(injectedBuildInfo); -export function controlUiVersionDiffersFrom(gatewayVersion: string | undefined): boolean { +export function reloadControlUiIfStale(identity: { + version: string | null; + sha: string | null; +}): void { + if ( + typeof window !== "undefined" && + controlUiVersionDiffersFrom(identity.version ?? undefined, identity.sha ?? undefined) + ) { + window.location.reload(); + } +} + +export function controlUiVersionDiffersFrom( + gatewayVersion: string | undefined, + gatewayCommit?: string, +): boolean { const controlUiVersion = CONTROL_UI_BUILD_INFO.version?.trim(); const normalizedGatewayVersion = gatewayVersion?.trim(); + if ( + controlUiVersion && + normalizedGatewayVersion && + controlUiVersion !== normalizedGatewayVersion + ) { + return true; + } + const controlUiCommit = CONTROL_UI_BUILD_INFO.commit?.trim().toLowerCase(); + const normalizedGatewayCommit = gatewayCommit?.trim().toLowerCase(); return Boolean( - controlUiVersion && normalizedGatewayVersion && controlUiVersion !== normalizedGatewayVersion, + controlUiCommit && + normalizedGatewayCommit && + !controlUiCommit.startsWith(normalizedGatewayCommit) && + !normalizedGatewayCommit.startsWith(controlUiCommit), ); } diff --git a/ui/src/components/settings-ui.ts b/ui/src/components/settings-ui.ts index eb66d30956f9..aa702edf548a 100644 --- a/ui/src/components/settings-ui.ts +++ b/ui/src/components/settings-ui.ts @@ -312,11 +312,12 @@ export function renderSettingsSegmented(props: { export function renderSettingsStatus(props: { kind: SettingsStatusKind; label: unknown; + dot?: boolean; }): TemplateResult { const modifier = props.kind === "muted" ? "" : ` settings-status--${props.kind}`; return html` - + ${props.dot === false ? nothing : html``} ${props.label} `; diff --git a/ui/src/e2e/update-coalesced.e2e.test.ts b/ui/src/e2e/update-coalesced.e2e.test.ts index 4d54e5af3d0e..57872b9801c7 100644 --- a/ui/src/e2e/update-coalesced.e2e.test.ts +++ b/ui/src/e2e/update-coalesced.e2e.test.ts @@ -155,8 +155,8 @@ suite.define(() => { }, { artifactName: "disconnect-first", - expectedStatusRequests: 0, - expectedText: "The update request may have been accepted", + expectedStatusRequests: 2, + expectedText: "Expected v2.0.0, running v1.0.0", name: "when disconnect arrives before the response", responseFirst: false, }, diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 82a37435da94..33845a8b3559 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -395,6 +395,10 @@ export const en: TranslationMap = { buildTitle: "Current build", gatewayVersion: "Gateway version", controlUiCommit: "Control UI commit", + builtAt: "Built", + installedAt: "Installed", + installedAtUnknown: "Unknown · recorded after the next successful update", + lastCommitAt: "Last commit", installKind: "Install type", policyTitle: "Update policy", channel: "Release channel", @@ -412,6 +416,12 @@ export const en: TranslationMap = { available: "Update available {target}", upToDate: "Up to date", statusUnavailable: "Update status unavailable", + gitCommitAhead: "{count} commit ahead of tracked upstream", + gitCommitsAhead: "{count} commits ahead of tracked upstream", + gitDiverged: "Diverged · {ahead} ahead, {behind} behind", + gitFetchFailed: "Could not fetch the tracked upstream", + gitNoUpstream: "No tracked upstream is configured", + gitComparisonFailed: "Could not compare this checkout with its tracked upstream", updateNow: "Update now", updateNowDescription: "Install the available update and restart the Gateway.", }, @@ -423,6 +433,8 @@ export const en: TranslationMap = { "Update installed but running version did not change — restart may have been blocked.", verificationFailedWithVersions: "Update installed but running version did not change — restart may have been blocked. Expected v{expectedVersion}, running v{actualVersion}.", + verificationFailedWithIdentity: + "Update finished, but the running install does not match the expected revision. Expected {expected}, running {actual}.", handoffTimeout: "Update handoff started, but completion was not reported after reconnect. Run `openclaw update status` for the final result.", outcomeUnknown: @@ -447,6 +459,11 @@ export const en: TranslationMap = { "This global install cannot be safely replaced while restarts are disabled and no supervisor is present.", restartUnhealthy: "The replacement process never became healthy. The previous process stayed up so you can recover.", + restartRevisionMismatch: + "The restarted Gateway is running a different revision. Check the service install root and retry.", + restartRevisionUnavailable: + "The restarted Gateway could not report its revision. Check the service install root and logs before retrying.", + alreadyCurrent: "This checkout is already at its tracked upstream revision.", managedServiceHandoffAlreadyRunning: "Another managed update is already running. Wait for it to complete, then refresh update status.", doctorFailed: "Doctor repair failed. Run `openclaw doctor --non-interactive` and retry.", diff --git a/ui/src/pages/config/config-page.test.ts b/ui/src/pages/config/config-page.test.ts index 83547632520d..caac7f5ea33a 100644 --- a/ui/src/pages/config/config-page.test.ts +++ b/ui/src/pages/config/config-page.test.ts @@ -534,6 +534,39 @@ describe("ConfigPage curated mutation eligibility", () => { }); describe("ConfigPage Updates integration", () => { + it("refreshes update status once when the page becomes active", () => { + const refreshUpdateStatus = vi.fn(async () => {}); + const page = new ConfigPage(); + const state = page as unknown as { + context: ApplicationContext; + syncUpdateStatusRefresh: () => void; + }; + state.context = { + gateway: { + snapshot: { + client: {}, + phase: "connected", + hello: { + auth: { role: "operator", scopes: ["operator.admin"] }, + features: { methods: ["update.status"] }, + }, + }, + }, + overlays: { refreshUpdateStatus }, + } as unknown as ApplicationContext; + + page.pageId = "updates"; + state.syncUpdateStatusRefresh(); + state.syncUpdateStatusRefresh(); + expect(refreshUpdateStatus).toHaveBeenCalledOnce(); + + page.pageId = "advanced"; + state.syncUpdateStatusRefresh(); + page.pageId = "updates"; + state.syncUpdateStatusRefresh(); + expect(refreshUpdateStatus).toHaveBeenCalledTimes(2); + }); + it("stages policy changes through patchForm and delegates Update now to overlays", () => { const patchForm = vi.fn(); const runUpdate = vi.fn(); diff --git a/ui/src/pages/config/config-page.ts b/ui/src/pages/config/config-page.ts index 514286bc27fd..5a2e0c8a5d56 100644 --- a/ui/src/pages/config/config-page.ts +++ b/ui/src/pages/config/config-page.ts @@ -292,6 +292,7 @@ export class ConfigPage extends OpenClawLightDomElement { private runtimeConfigSource: ApplicationContext["runtimeConfig"] | null = null; private systemInfoGatewaySource: ApplicationContext["gateway"] | null = null; private systemInfoClient: GatewayBrowserClient | null = null; + private updateStatusClient: GatewayBrowserClient | null = null; private sessionObserverModelsClient: GatewayBrowserClient | null = null; private readonly sessionObserverModelLoads = new WeakMap>(); private readonly systemInfoPolling = new PollController( @@ -404,6 +405,7 @@ export class ConfigPage extends OpenClawLightDomElement { this.resetConfigViewState(); this.systemInfoGatewaySource = null; this.systemInfoClient = null; + this.updateStatusClient = null; this.subscriptions.clear(); super.disconnectedCallback(); } @@ -423,6 +425,7 @@ export class ConfigPage extends OpenClawLightDomElement { this.invalidateSystemInfoRequest(); } this.syncSystemInfoPolling(); + this.syncUpdateStatusRefresh(); this.syncUpdateCountdownPolling(); this.scrollToPendingRouteTarget(); // Device labels stay hidden until the user grants media permission; each @@ -551,6 +554,23 @@ export class ConfigPage extends OpenClawLightDomElement { this.updateCountdownPolling.stop(); } + private syncUpdateStatusRefresh() { + const gateway = this.context.gateway.snapshot; + const client = + this.pageId === "updates" && + gateway.phase === "connected" && + canCallGatewayMethod(gateway, "update.status", "operator.admin") + ? gateway.client + : null; + if (client === this.updateStatusClient) { + return; + } + this.updateStatusClient = client; + if (client) { + void this.context.overlays.refreshUpdateStatus(); + } + } + private synchronizeRuntimeConfig(runtimeConfig: ApplicationContext["runtimeConfig"]) { if (runtimeConfig !== this.runtimeConfigSource) { if (this.runtimeConfigSource) { @@ -587,6 +607,7 @@ export class ConfigPage extends OpenClawLightDomElement { this.systemInfoGatewaySource = gateway; this.resetConfigViewState(); this.systemInfoClient = null; + this.updateStatusClient = null; this.systemInfo = null; this.systemInfoUnavailable = false; this.sessionObserverModelsClient = null; @@ -594,6 +615,7 @@ export class ConfigPage extends OpenClawLightDomElement { this.sessionObserverModelsUnavailable = false; } this.handleSystemInfoGatewaySnapshot(gateway.snapshot); + this.syncUpdateStatusRefresh(); } private resetConfigViewState() { @@ -969,6 +991,8 @@ export class ConfigPage extends OpenClawLightDomElement { gatewaySnapshot.hello?.server?.version ?? null, controlUiCommit: CONTROL_UI_BUILD_INFO.commit, + controlUiCommitAt: CONTROL_UI_BUILD_INFO.commitAt, + controlUiBuiltAt: CONTROL_UI_BUILD_INFO.builtAt, schedule: overlaySnapshot.updateSchedule, heldUpdateCampaignId: overlaySnapshot.heldUpdateCampaignId, updateAvailable: overlaySnapshot.updateAvailable, diff --git a/ui/src/pages/config/updates.test.ts b/ui/src/pages/config/updates.test.ts index 5f05fc83a006..ddf94a0e5994 100644 --- a/ui/src/pages/config/updates.test.ts +++ b/ui/src/pages/config/updates.test.ts @@ -14,6 +14,8 @@ function createProps(overrides: Partial = {}): UpdatesViewProp configObject: { update: { channel: "stable", auto: { enabled: false } } }, gatewayVersion: "2026.8.1", controlUiCommit: "0123456789abcdef0123456789abcdef01234567", + controlUiCommitAt: "1970-01-01T00:00:00.000Z", + controlUiBuiltAt: "1970-01-01T00:00:00.000Z", schedule: { channel: "stable", autoEnabled: false, @@ -80,6 +82,12 @@ describe("renderUpdates", () => { expect(row("Gateway version").textContent).toContain("2026.8.1"); expect(row("Control UI commit").textContent).toContain("0123456789ab"); + expect(row("Built").querySelector("time")?.getAttribute("datetime")).toBe( + "1970-01-01T00:00:00.000Z", + ); + expect(row("Last commit").querySelector("time")?.getAttribute("datetime")).toBe( + "1970-01-01T00:00:00.000Z", + ); expect(row("Install type").textContent).toContain("Package"); expect( [...container.querySelectorAll("wa-radio")].map((option) => option.textContent?.trim()), @@ -293,7 +301,7 @@ describe("renderUpdates", () => { schedule: { channel: "dev", autoEnabled: false, - install: { kind: "git" }, + install: { kind: "git", git: { status: "behind", commitsBehind: 2 } }, target: { kind: "git", upstreamRef: "origin/main", @@ -322,11 +330,110 @@ describe("renderUpdates", () => { expect(row("Commits").querySelectorAll("[role='listitem']")).toHaveLength(2); expect(row("Commits").textContent).toContain("b123456"); expect(row("Commits").textContent).toContain("Show dev commit details"); + expect(row("Status").textContent).toContain("Update available 2 commits behind"); + expect(row("Status").textContent).not.toContain("Up to date"); + expect(row("Status").querySelector(".settings-status__dot")).toBeNull(); render(renderUpdates(createProps()), container); expect(container.querySelector(".updates-commit-list")).toBeNull(); }); + it("shows truthful Git build, install, and commit ages", () => { + const installedAtMs = Date.parse("2026-08-08T12:00:00Z"); + const commitAtMs = Date.parse("2026-08-08T10:00:00Z"); + render( + renderUpdates( + createProps({ + configObject: { update: { channel: "dev" } }, + nowMs: Date.parse("2026-08-08T14:00:00Z"), + schedule: { + channel: "dev", + autoEnabled: false, + install: { + kind: "git", + git: { + status: "current", + currentSha: "a".repeat(40), + commitAtMs, + installedAtMs, + }, + }, + }, + updateAvailable: null, + }), + ), + container, + ); + + expect(row("Installed").querySelector("time")?.getAttribute("datetime")).toBe( + "2026-08-08T12:00:00.000Z", + ); + expect(row("Installed").textContent).toContain("2h ago"); + expect(row("Last commit").querySelector("time")?.getAttribute("datetime")).toBe( + "2026-08-08T10:00:00.000Z", + ); + expect(row("Last commit").textContent).toContain("4h ago"); + + render( + renderUpdates( + createProps({ + configObject: { update: { channel: "dev" } }, + schedule: { + channel: "dev", + autoEnabled: false, + install: { kind: "git", git: { status: "current" } }, + }, + updateAvailable: null, + }), + ), + container, + ); + expect(row("Installed").textContent).toContain( + "Unknown · recorded after the next successful update", + ); + }); + + it.each([ + { + name: "current", + git: { status: "current" } as const, + label: "Up to date", + }, + { + name: "ahead", + git: { status: "ahead", commitsAhead: 2 } as const, + label: "2 commits ahead of tracked upstream", + }, + { + name: "diverged", + git: { status: "diverged", commitsAhead: 1, commitsBehind: 3 } as const, + label: "Diverged · 1 ahead, 3 behind", + }, + { + name: "fetch unavailable", + git: { status: "unavailable", reason: "fetch-failed" } as const, + label: "Could not fetch the tracked upstream", + }, + ])("renders explicit $name git status without a dot", ({ git, label }) => { + render( + renderUpdates( + createProps({ + configObject: { update: { channel: "dev" } }, + schedule: { + channel: "dev", + autoEnabled: false, + install: { kind: "git", git }, + }, + updateAvailable: null, + }), + ), + container, + ); + + expect(row("Status").textContent).toContain(label); + expect(row("Status").querySelector(".settings-status__dot")).toBeNull(); + }); + it("surfaces the latest update failure ahead of passive availability", () => { render( renderUpdates( diff --git a/ui/src/pages/config/updates.ts b/ui/src/pages/config/updates.ts index 147e29ad1523..3dc4f1201ca0 100644 --- a/ui/src/pages/config/updates.ts +++ b/ui/src/pages/config/updates.ts @@ -19,6 +19,7 @@ import { renderSettingsValue, } from "../../components/settings-ui.ts"; import { t } from "../../i18n/index.ts"; +import { formatDateTimeMs, formatTimeAgo } from "../../lib/format.ts"; type UpdatesChannel = "stable" | "beta" | "dev" | "extended-stable"; @@ -26,6 +27,8 @@ type UpdatesViewProps = { configObject: Record; gatewayVersion: string | null; controlUiCommit: string | null; + controlUiCommitAt: string | null; + controlUiBuiltAt: string | null; schedule: UpdateScheduleState | null; heldUpdateCampaignId: string | null; updateAvailable: UpdateAvailable | null; @@ -67,8 +70,29 @@ function readUpdatesSettings( }; } +function parseTimestampMs(value: string | null): number | null { + if (!value) { + return null; + } + const timestampMs = Date.parse(value); + return Number.isFinite(timestampMs) ? timestampMs : null; +} + +function renderTimestamp(timestampMs: number, nowMs = Date.now()) { + const relative = formatTimeAgo(Math.max(0, nowMs - timestampMs)); + return renderSettingsValue( + html``, + ); +} + function renderBuildFacts(props: UpdatesViewProps) { const installKind = props.schedule?.install?.kind; + const git = props.schedule?.install?.git; + const builtAtMs = parseTimestampMs(props.controlUiBuiltAt); + const commitAtMs = git?.commitAtMs ?? parseTimestampMs(props.controlUiCommitAt); return renderSettingsSection({ title: t("updates.page.buildTitle") }, [ renderSettingsRow({ title: t("updates.page.gatewayVersion"), @@ -90,6 +114,27 @@ function renderBuildFacts(props: UpdatesViewProps) { { mono: true }, ), }), + builtAtMs === null + ? nothing + : renderSettingsRow({ + title: t("updates.page.builtAt"), + control: renderTimestamp(builtAtMs, props.nowMs), + }), + installKind === "git" + ? renderSettingsRow({ + title: t("updates.page.installedAt"), + control: + git?.installedAtMs === undefined + ? renderSettingsValue(t("updates.page.installedAtUnknown")) + : renderTimestamp(git.installedAtMs, props.nowMs), + }) + : nothing, + commitAtMs === null + ? nothing + : renderSettingsRow({ + title: t("updates.page.lastCommitAt"), + control: renderTimestamp(commitAtMs, props.nowMs), + }), installKind ? renderSettingsRow({ title: t("updates.page.installKind"), @@ -116,10 +161,44 @@ function renderScheduleStatus(props: UpdatesViewProps): TemplateResult { ? "warn" : "accent"; label = props.statusBanner.text; + } else if (props.schedule?.install?.kind === "git") { + const git = props.schedule.install.git; + if (!git) { + label = t("updates.page.statusUnavailable"); + } else if (git.status === "current") { + kind = "ok"; + label = t("updates.page.upToDate"); + } else if (git.status === "behind") { + kind = "accent"; + const lag = t( + git.commitsBehind === 1 ? "updates.target.commitBehind" : "updates.target.commitsBehind", + { count: String(git.commitsBehind) }, + ); + label = t("updates.page.available", { target: lag }); + } else if (git.status === "ahead") { + label = t( + git.commitsAhead === 1 ? "updates.page.gitCommitAhead" : "updates.page.gitCommitsAhead", + { count: String(git.commitsAhead) }, + ); + } else if (git.status === "diverged") { + kind = "warn"; + label = t("updates.page.gitDiverged", { + ahead: String(git.commitsAhead), + behind: String(git.commitsBehind), + }); + } else { + kind = "warn"; + label = + git.reason === "fetch-failed" + ? t("updates.page.gitFetchFailed") + : git.reason === "no-upstream" + ? t("updates.page.gitNoUpstream") + : t("updates.page.gitComparisonFailed"); + } } else if (target) { kind = "accent"; label = t("updates.page.available", { target }); - } else if (props.schedule) { + } else if (props.schedule?.install?.kind === "package") { kind = "ok"; label = t("updates.page.upToDate"); } else { @@ -127,14 +206,28 @@ function renderScheduleStatus(props: UpdatesViewProps): TemplateResult { } const countdown = campaign?.state === "waiting-for-idle" || campaign?.state === "countdown"; return html`${renderSettingsStatus({ kind, label })}${renderSettingsStatus({ kind, label, dot: false })}`; } function readGitCommits(props: UpdatesViewProps) { const update = props.updateAvailable; const gitUpdate = props.schedule?.target?.kind === "git" || Boolean(update?.currentSha); - return gitUpdate ? (update?.commits ?? []) : []; + const comparedBehind = props.schedule?.install?.git; + if ( + comparedBehind && + comparedBehind.status !== "behind" && + comparedBehind.status !== "diverged" + ) { + return []; + } + const comparedBehindCount = + comparedBehind?.status === "behind" || comparedBehind?.status === "diverged" + ? comparedBehind.commitsBehind + : undefined; + const commitsMatch = + comparedBehindCount === undefined || comparedBehindCount === update?.commitsBehind; + return gitUpdate && commitsMatch ? (update?.commits ?? []) : []; } function renderCommitList(props: UpdatesViewProps) {