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
This commit is contained in:
Peter Steinberger
2026-08-08 17:21:42 -07:00
committed by GitHub
parent 173f57ad21
commit b4cedfd40e
28 changed files with 1302 additions and 164 deletions
+5
View File
@@ -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 |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -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", () => {
@@ -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(
@@ -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<import("../../infra/update-campaign.js").UpdateCampaignController["adopt"]>
>;
@@ -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", () => ({
+17 -1
View File
@@ -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 (
+38 -2
View File
@@ -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<RestartSentinelPayload["kind"]>([
"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<GatewayRestartSentinelDatabase>(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,
+96
View File
@@ -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,
+75 -16
View File
@@ -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<RestartSentinel | null> {
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<RestartSentinelPayload | null> {
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<boolean> {
try {
const database = openOpenClawStateDatabase({ env });
+29
View File
@@ -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");
+13 -2
View File
@@ -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,
+15 -1
View File
@@ -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({
+88 -7
View File
@@ -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 () => {
+135 -17
View File
@@ -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<NonNullable<UpdateScheduleState["install"]>["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<UpdateCheckResult["git"]>,
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<void> {
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,
});
@@ -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<RequestFn>(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({
+17 -13
View File
@@ -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<RequestFn>((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;
+4
View File
@@ -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.",
};
+37 -35
View File
@@ -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<void>;
runUpdate: () => Promise<void>;
holdUpdate: () => Promise<boolean>;
decideApproval: (decision: ExecApprovalDecision, approvalId?: string) => Promise<void>;
@@ -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<UpdateRunResponse>("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,
+67 -8
View File
@@ -24,6 +24,10 @@ const translations: Record<string, string> = {
"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<ApplicationStatusBanner | null | undefined> {
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",
+239 -53
View File
@@ -31,18 +31,21 @@ const UPDATE_FAILURE_REASON_KEYS: Record<string, string> = {
"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<typeof globalThis.setTimeout>;
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<string, unknown>): {
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<NonNullable<UpdateScheduleState["install"]>["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;
+3
View File
@@ -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();
+29 -2
View File
@@ -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),
);
}
+2 -1
View File
@@ -312,11 +312,12 @@ export function renderSettingsSegmented<T extends string>(props: {
export function renderSettingsStatus(props: {
kind: SettingsStatusKind;
label: unknown;
dot?: boolean;
}): TemplateResult {
const modifier = props.kind === "muted" ? "" : ` settings-status--${props.kind}`;
return html`
<span class="settings-status${modifier}">
<span class="settings-status__dot"></span>
${props.dot === false ? nothing : html`<span class="settings-status__dot"></span>`}
${props.label}
</span>
`;
+2 -2
View File
@@ -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,
},
+17
View File
@@ -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.",
+33
View File
@@ -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();
+24
View File
@@ -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<GatewayBrowserClient, Promise<void>>();
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,
+108 -1
View File
@@ -14,6 +14,8 @@ function createProps(overrides: Partial<UpdatesViewProps> = {}): 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(
+96 -3
View File
@@ -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<string, unknown>;
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`<time datetime=${new Date(timestampMs).toISOString()} title=${relative}
>${formatDateTimeMs(timestampMs, { dateStyle: "medium", timeStyle: "short" })}
<span class="muted">· ${relative}</span></time
>`,
);
}
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`<span role=${countdown ? "timer" : nothing} aria-live=${countdown ? "off" : nothing}
>${renderSettingsStatus({ kind, label })}</span
>${renderSettingsStatus({ kind, label, dot: false })}</span
>`;
}
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) {