mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
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:
committed by
GitHub
parent
173f57ad21
commit
b4cedfd40e
@@ -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", () => ({
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user