fix(deadcode): move update check state to sqlite

This commit is contained in:
Vincent Koc
2026-06-21 20:57:36 +08:00
parent 8a7906c716
commit eb00d499d1
5 changed files with 470 additions and 68 deletions

View File

@@ -247,6 +247,10 @@ function createLegacyStateMigrationDetectionResult(params?: {
routingPath: "/tmp/state/settings/voicewake-routing.json",
hasLegacy: false,
},
updateCheck: {
sourcePath: "/tmp/state/update-check.json",
hasLegacy: false,
},
execApprovals: {
sourcePath: "/tmp/state/exec-approvals.legacy.json",
targetPath: "/tmp/state/exec-approvals.json",

View File

@@ -5,8 +5,13 @@ import path from "node:path";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resolveChannelAllowFromPath } from "../pairing/pairing-store.js";
import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "../state/openclaw-state-db.js";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely } from "./kysely-sync.js";
import {
autoMigrateLegacyState,
detectLegacyStateMigrations,
@@ -87,6 +92,8 @@ vi.mock("../channels/plugins/bundled.js", () => {
const tempDirs = createTrackedTempDirs();
type UpdateCheckStateDatabase = Pick<OpenClawStateKyselyDatabase, "update_check_state">;
async function expectMissingPath(targetPath: string): Promise<void> {
let statError: NodeJS.ErrnoException | undefined;
try {
@@ -101,6 +108,30 @@ async function expectMissingPath(targetPath: string): Promise<void> {
}
const createTempDir = () => tempDirs.make("openclaw-state-migrations-test-");
function readUpdateCheckState(env: NodeJS.ProcessEnv):
| {
last_checked_at: string | null;
last_available_version: string | null;
last_available_tag: string | null;
auto_install_id: string | null;
}
| undefined {
const { db } = openOpenClawStateDatabase({ env });
const stateDb = getNodeSqliteKysely<UpdateCheckStateDatabase>(db);
return executeSqliteQueryTakeFirstSync(
db,
stateDb
.selectFrom("update_check_state")
.select([
"last_checked_at",
"last_available_version",
"last_available_tag",
"auto_install_id",
])
.where("state_key", "=", "default"),
);
}
function createConfig(): OpenClawConfig {
return {
agents: {
@@ -179,6 +210,7 @@ async function createLegacyStateFixture(params?: { includePreKey?: boolean }) {
afterEach(async () => {
vi.useRealTimers();
closeOpenClawStateDatabaseForTest();
await tempDirs.cleanup();
});
@@ -518,6 +550,44 @@ describe("state migrations", () => {
await expectMissingPath(path.join(settingsDir, "voicewake.json"));
});
it("migrates legacy update-check JSON into shared SQLite state", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
const cfg = createConfig();
const sourcePath = path.join(stateDir, "update-check.json");
await fs.mkdir(stateDir, { recursive: true });
await fs.writeFile(
sourcePath,
JSON.stringify({
lastCheckedAt: "2026-01-17T09:30:00.000Z",
lastAvailableVersion: "2.0.0",
lastAvailableTag: "latest",
autoInstallId: "install-1",
}),
"utf8",
);
const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
expect(detected.updateCheck.hasLegacy).toBe(true);
expect(detected.preview).toContain(
"- Update-check state: legacy JSON file → shared SQLite state",
);
const result = await runLegacyStateMigrations({ detected, config: cfg });
expect(result.warnings).toStrictEqual([]);
expect(result.changes).toContain("Migrated update-check state → shared SQLite state");
expect(readUpdateCheckState(env)).toMatchObject({
last_checked_at: "2026-01-17T09:30:00.000Z",
last_available_version: "2.0.0",
last_available_tag: "latest",
auto_install_id: "install-1",
});
await expectMissingPath(sourcePath);
await expect(fs.readFile(`${sourcePath}.migrated`, "utf8")).resolves.toContain("2.0.0");
});
it("keeps legacy delivery queue files when shared SQLite already has a conflicting row", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");

View File

@@ -145,6 +145,10 @@ export type LegacyStateDetection = {
routingPath: string;
hasLegacy: boolean;
};
updateCheck: {
sourcePath: string;
hasLegacy: boolean;
};
execApprovals: {
sourcePath: string;
targetPath: string;
@@ -187,6 +191,7 @@ type LegacyVoiceWakeImportDatabase = Pick<
OpenClawStateKyselyDatabase,
"voicewake_routing_config" | "voicewake_routing_routes" | "voicewake_triggers"
>;
type LegacyUpdateCheckImportDatabase = Pick<OpenClawStateKyselyDatabase, "update_check_state">;
type SqliteBindRow = Record<string, SQLInputValue>;
type DetectedPluginDoctorStateMigrationPlan = {
@@ -1794,6 +1799,172 @@ function migrateLegacyVoiceWakeSettings(params: {
return { changes, warnings };
}
const UPDATE_CHECK_STATE_KEY = "default";
type LegacyUpdateCheckState = {
lastCheckedAt?: string;
lastNotifiedVersion?: string;
lastNotifiedTag?: string;
lastAvailableVersion?: string;
lastAvailableTag?: string;
autoInstallId?: string;
autoFirstSeenVersion?: string;
autoFirstSeenTag?: string;
autoFirstSeenAt?: string;
autoLastAttemptVersion?: string;
autoLastAttemptAt?: string;
autoLastSuccessVersion?: string;
autoLastSuccessAt?: string;
};
function resolveLegacyUpdateCheckPath(stateDir: string): string {
return path.join(stateDir, "update-check.json");
}
function optionalLegacyString(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key];
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}
function normalizeLegacyUpdateCheckState(input: unknown): LegacyUpdateCheckState {
const record = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
return {
lastCheckedAt: optionalLegacyString(record, "lastCheckedAt"),
lastNotifiedVersion: optionalLegacyString(record, "lastNotifiedVersion"),
lastNotifiedTag: optionalLegacyString(record, "lastNotifiedTag"),
lastAvailableVersion: optionalLegacyString(record, "lastAvailableVersion"),
lastAvailableTag: optionalLegacyString(record, "lastAvailableTag"),
autoInstallId: optionalLegacyString(record, "autoInstallId"),
autoFirstSeenVersion: optionalLegacyString(record, "autoFirstSeenVersion"),
autoFirstSeenTag: optionalLegacyString(record, "autoFirstSeenTag"),
autoFirstSeenAt: optionalLegacyString(record, "autoFirstSeenAt"),
autoLastAttemptVersion: optionalLegacyString(record, "autoLastAttemptVersion"),
autoLastAttemptAt: optionalLegacyString(record, "autoLastAttemptAt"),
autoLastSuccessVersion: optionalLegacyString(record, "autoLastSuccessVersion"),
autoLastSuccessAt: optionalLegacyString(record, "autoLastSuccessAt"),
};
}
function legacyUpdateCheckStateMatches(
row: {
last_checked_at: string | null;
last_notified_version: string | null;
last_notified_tag: string | null;
last_available_version: string | null;
last_available_tag: string | null;
auto_install_id: string | null;
auto_first_seen_version: string | null;
auto_first_seen_tag: string | null;
auto_first_seen_at: string | null;
auto_last_attempt_version: string | null;
auto_last_attempt_at: string | null;
auto_last_success_version: string | null;
auto_last_success_at: string | null;
},
state: LegacyUpdateCheckState,
): boolean {
return (
(state.lastCheckedAt ?? null) === row.last_checked_at &&
(state.lastNotifiedVersion ?? null) === row.last_notified_version &&
(state.lastNotifiedTag ?? null) === row.last_notified_tag &&
(state.lastAvailableVersion ?? null) === row.last_available_version &&
(state.lastAvailableTag ?? null) === row.last_available_tag &&
(state.autoInstallId ?? null) === row.auto_install_id &&
(state.autoFirstSeenVersion ?? null) === row.auto_first_seen_version &&
(state.autoFirstSeenTag ?? null) === row.auto_first_seen_tag &&
(state.autoFirstSeenAt ?? null) === row.auto_first_seen_at &&
(state.autoLastAttemptVersion ?? null) === row.auto_last_attempt_version &&
(state.autoLastAttemptAt ?? null) === row.auto_last_attempt_at &&
(state.autoLastSuccessVersion ?? null) === row.auto_last_success_version &&
(state.autoLastSuccessAt ?? null) === row.auto_last_success_at
);
}
function migrateLegacyUpdateCheckState(params: {
detected: LegacyStateDetection["updateCheck"];
stateDir: string;
}): { changes: string[]; warnings: string[] } {
const changes: string[] = [];
const warnings: string[] = [];
if (!fileExists(params.detected.sourcePath)) {
return { changes, warnings };
}
let state: LegacyUpdateCheckState;
try {
state = normalizeLegacyUpdateCheckState(readLegacyJsonObject(params.detected.sourcePath));
} catch (err) {
warnings.push(
`Failed reading legacy update-check state ${params.detected.sourcePath}: ${String(err)}`,
);
return { changes, warnings };
}
let imported = false;
let shouldArchive = false;
try {
runOpenClawStateWriteTransaction(
({ db }) => {
const stateDb = getNodeSqliteKysely<LegacyUpdateCheckImportDatabase>(db);
const existing = executeSqliteQueryTakeFirstSync(
db,
stateDb
.selectFrom("update_check_state")
.selectAll()
.where("state_key", "=", UPDATE_CHECK_STATE_KEY),
);
if (existing) {
if (legacyUpdateCheckStateMatches(existing, state)) {
shouldArchive = true;
} else {
warnings.push(
`Left legacy update-check state in place because shared SQLite state already differs: ${params.detected.sourcePath}`,
);
}
return;
}
executeSqliteQuerySync(
db,
stateDb.insertInto("update_check_state").values({
state_key: UPDATE_CHECK_STATE_KEY,
last_checked_at: state.lastCheckedAt ?? null,
last_notified_version: state.lastNotifiedVersion ?? null,
last_notified_tag: state.lastNotifiedTag ?? null,
last_available_version: state.lastAvailableVersion ?? null,
last_available_tag: state.lastAvailableTag ?? null,
auto_install_id: state.autoInstallId ?? null,
auto_first_seen_version: state.autoFirstSeenVersion ?? null,
auto_first_seen_tag: state.autoFirstSeenTag ?? null,
auto_first_seen_at: state.autoFirstSeenAt ?? null,
auto_last_attempt_version: state.autoLastAttemptVersion ?? null,
auto_last_attempt_at: state.autoLastAttemptAt ?? null,
auto_last_success_version: state.autoLastSuccessVersion ?? null,
auto_last_success_at: state.autoLastSuccessAt ?? null,
updated_at_ms: Date.now(),
}),
);
imported = true;
shouldArchive = true;
},
{ env: { ...process.env, OPENCLAW_STATE_DIR: params.stateDir } },
);
} catch (err) {
warnings.push(`Failed migrating legacy update-check state: ${String(err)}`);
}
if (imported) {
changes.push("Migrated update-check state → shared SQLite state");
}
if (shouldArchive) {
archiveLegacyImportSource({
sourcePath: params.detected.sourcePath,
label: "update-check state",
changes,
warnings,
});
}
return { changes, warnings };
}
async function migrateLegacyPluginStateSidecar(params: {
stateDir: string;
}): Promise<{ changes: string[]; warnings: string[] }> {
@@ -3218,6 +3389,10 @@ export async function detectLegacyStateMigrations(params: {
routingPath: resolveLegacyVoiceWakeRoutingPath(stateDir),
};
const hasVoiceWake = fileExists(voiceWake.triggersPath) || fileExists(voiceWake.routingPath);
const updateCheck = {
sourcePath: resolveLegacyUpdateCheckPath(stateDir),
};
const hasUpdateCheck = fileExists(updateCheck.sourcePath);
const channelPlans = await collectChannelLegacyStateMigrationPlans({
cfg: params.cfg,
env,
@@ -3282,6 +3457,9 @@ export async function detectLegacyStateMigrations(params: {
if (hasVoiceWake) {
preview.push("- Voice Wake settings: legacy JSON files → shared SQLite state");
}
if (hasUpdateCheck) {
preview.push("- Update-check state: legacy JSON file → shared SQLite state");
}
if (execApprovals.hasLegacy) {
preview.push(`- Exec approvals: ${execApprovals.sourcePath}${execApprovals.targetPath}`);
}
@@ -3345,6 +3523,10 @@ export async function detectLegacyStateMigrations(params: {
...voiceWake,
hasLegacy: hasVoiceWake,
},
updateCheck: {
...updateCheck,
hasLegacy: hasUpdateCheck,
},
execApprovals,
preview,
};
@@ -3867,6 +4049,10 @@ export async function runLegacyStateMigrations(params: {
detected: detected.voiceWake,
stateDir: detected.stateDir,
});
const updateCheck = migrateLegacyUpdateCheckState({
detected: detected.updateCheck,
stateDir: detected.stateDir,
});
const execApprovals = migrateLegacyExecApprovals(detected.execApprovals);
const preSessionChannelPlans = await runLegacyMigrationPlans(
detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"),
@@ -3898,6 +4084,7 @@ export async function runLegacyStateMigrations(params: {
...taskStateSidecars.changes,
...deliveryQueues.changes,
...voiceWake.changes,
...updateCheck.changes,
...execApprovals.changes,
...preSessionChannelPlans.changes,
...pluginPlans.changes,
@@ -3914,6 +4101,7 @@ export async function runLegacyStateMigrations(params: {
...taskStateSidecars.warnings,
...deliveryQueues.warnings,
...voiceWake.warnings,
...updateCheck.warnings,
...execApprovals.warnings,
...preSessionChannelPlans.warnings,
...pluginPlans.warnings,
@@ -4239,6 +4427,10 @@ export async function autoMigrateLegacyState(params: {
detected: detected.voiceWake,
stateDir: detected.stateDir,
});
const updateCheck = migrateLegacyUpdateCheckState({
detected: detected.updateCheck,
stateDir: detected.stateDir,
});
const execApprovals = migrateLegacyExecApprovals(detected.execApprovals);
const preSessionChannelPlans = await runLegacyMigrationPlans(
detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"),
@@ -4258,6 +4450,7 @@ export async function autoMigrateLegacyState(params: {
...taskStateSidecars.changes,
...deliveryQueues.changes,
...voiceWake.changes,
...updateCheck.changes,
...execApprovals.changes,
...preSessionChannelPlans.changes,
...pluginPlans.changes,
@@ -4273,6 +4466,7 @@ export async function autoMigrateLegacyState(params: {
...taskStateSidecars.warnings,
...deliveryQueues.warnings,
...voiceWake.warnings,
...updateCheck.warnings,
...execApprovals.warnings,
...preSessionChannelPlans.warnings,
...pluginPlans.warnings,
@@ -4290,6 +4484,7 @@ export async function autoMigrateLegacyState(params: {
taskStateSidecars.changes.length > 0 ||
deliveryQueues.changes.length > 0 ||
voiceWake.changes.length > 0 ||
updateCheck.changes.length > 0 ||
execApprovals.changes.length > 0 ||
preSessionChannelPlans.changes.length > 0 ||
pluginPlans.changes.length > 0,
@@ -4310,6 +4505,7 @@ export async function autoMigrateLegacyState(params: {
!detected.taskStateSidecars.hasLegacy &&
!detected.deliveryQueues.hasLegacy &&
!detected.voiceWake.hasLegacy &&
!detected.updateCheck.hasLegacy &&
!detected.execApprovals.hasLegacy
) {
const changes = [
@@ -4358,6 +4554,10 @@ export async function autoMigrateLegacyState(params: {
detected: detected.voiceWake,
stateDir: detected.stateDir,
});
const updateCheck = migrateLegacyUpdateCheckState({
detected: detected.updateCheck,
stateDir: detected.stateDir,
});
const execApprovals = migrateLegacyExecApprovals(detected.execApprovals);
const preSessionChannelPlans = await runLegacyMigrationPlans(
detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"),
@@ -4389,6 +4589,7 @@ export async function autoMigrateLegacyState(params: {
...taskStateSidecars.changes,
...deliveryQueues.changes,
...voiceWake.changes,
...updateCheck.changes,
...execApprovals.changes,
...preSessionChannelPlans.changes,
...pluginPlans.changes,
@@ -4408,6 +4609,7 @@ export async function autoMigrateLegacyState(params: {
...taskStateSidecars.warnings,
...deliveryQueues.warnings,
...voiceWake.warnings,
...updateCheck.warnings,
...execApprovals.warnings,
...preSessionChannelPlans.warnings,
...pluginPlans.warnings,

View File

@@ -3,10 +3,21 @@ import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { formatCliCommand } from "../cli/command-format.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
} from "../state/openclaw-state-db.js";
import {
createOpenClawTestState,
type OpenClawTestState,
} from "../test-utils/openclaw-test-state.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "./kysely-sync.js";
import type { UpdateCheckResult } from "./update-check.js";
const {
@@ -79,6 +90,29 @@ vi.mock("./update-managed-service-handoff.js", () => ({
startManagedServiceUpdateHandoff: startManagedServiceUpdateHandoffMock,
}));
const UPDATE_CHECK_STATE_KEY = "default";
type UpdateCheckStateDatabase = Pick<OpenClawStateKyselyDatabase, "update_check_state">;
type PersistedUpdateCheckState = {
lastCheckedAt?: string;
lastNotifiedVersion?: string;
lastNotifiedTag?: string;
lastAvailableVersion?: string;
lastAvailableTag?: string;
autoInstallId?: string;
autoFirstSeenVersion?: string;
autoFirstSeenTag?: string;
autoFirstSeenAt?: string;
autoLastAttemptVersion?: string;
autoLastAttemptAt?: string;
autoLastSuccessVersion?: string;
autoLastSuccessAt?: string;
};
function presentString(value: string | null): string | undefined {
return value ?? undefined;
}
describe("update-startup", () => {
let tempDir: string;
let testState: OpenClawTestState;
@@ -101,6 +135,66 @@ describe("update-startup", () => {
return call;
}
function readPersistedUpdateCheckState(): PersistedUpdateCheckState | null {
const { db } = openOpenClawStateDatabase();
const stateDb = getNodeSqliteKysely<UpdateCheckStateDatabase>(db);
const row = executeSqliteQueryTakeFirstSync(
db,
stateDb
.selectFrom("update_check_state")
.selectAll()
.where("state_key", "=", UPDATE_CHECK_STATE_KEY),
);
if (!row) {
return null;
}
return {
lastCheckedAt: presentString(row.last_checked_at),
lastNotifiedVersion: presentString(row.last_notified_version),
lastNotifiedTag: presentString(row.last_notified_tag),
lastAvailableVersion: presentString(row.last_available_version),
lastAvailableTag: presentString(row.last_available_tag),
autoInstallId: presentString(row.auto_install_id),
autoFirstSeenVersion: presentString(row.auto_first_seen_version),
autoFirstSeenTag: presentString(row.auto_first_seen_tag),
autoFirstSeenAt: presentString(row.auto_first_seen_at),
autoLastAttemptVersion: presentString(row.auto_last_attempt_version),
autoLastAttemptAt: presentString(row.auto_last_attempt_at),
autoLastSuccessVersion: presentString(row.auto_last_success_version),
autoLastSuccessAt: presentString(row.auto_last_success_at),
};
}
function writePersistedUpdateCheckState(state: PersistedUpdateCheckState): void {
runOpenClawStateWriteTransaction(({ db }) => {
const stateDb = getNodeSqliteKysely<UpdateCheckStateDatabase>(db);
executeSqliteQuerySync(
db,
stateDb.deleteFrom("update_check_state").where("state_key", "=", UPDATE_CHECK_STATE_KEY),
);
executeSqliteQuerySync(
db,
stateDb.insertInto("update_check_state").values({
state_key: UPDATE_CHECK_STATE_KEY,
last_checked_at: state.lastCheckedAt ?? null,
last_notified_version: state.lastNotifiedVersion ?? null,
last_notified_tag: state.lastNotifiedTag ?? null,
last_available_version: state.lastAvailableVersion ?? null,
last_available_tag: state.lastAvailableTag ?? null,
auto_install_id: state.autoInstallId ?? null,
auto_first_seen_version: state.autoFirstSeenVersion ?? null,
auto_first_seen_tag: state.autoFirstSeenTag ?? null,
auto_first_seen_at: state.autoFirstSeenAt ?? null,
auto_last_attempt_version: state.autoLastAttemptVersion ?? null,
auto_last_attempt_at: state.autoLastAttemptAt ?? null,
auto_last_success_version: state.autoLastSuccessVersion ?? null,
auto_last_success_at: state.autoLastSuccessAt ?? null,
updated_at_ms: Date.now(),
}),
);
});
}
beforeEach(async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-17T10:00:00Z"));
@@ -154,6 +248,7 @@ describe("update-startup", () => {
afterEach(async () => {
vi.useRealTimers();
closeOpenClawStateDatabaseForTest();
await testState.cleanup();
resetUpdateAvailableStateForTest();
});
@@ -190,13 +285,8 @@ describe("update-startup", () => {
allowInTests: true,
});
const statePath = path.join(tempDir, "update-check.json");
const parsed = JSON.parse(await fs.readFile(statePath, "utf-8")) as {
lastNotifiedVersion?: string;
lastNotifiedTag?: string;
lastAvailableVersion?: string;
lastAvailableTag?: string;
};
const parsed = readPersistedUpdateCheckState();
expect(parsed).not.toBeNull();
return { log, parsed };
}
@@ -277,9 +367,9 @@ describe("update-startup", () => {
expect(log.info).toHaveBeenCalledWith(
`update available (latest): v2.0.0 (current v1.0.0). Run: ${formatCliCommand("openclaw update")}`,
);
expect(parsed.lastNotifiedVersion).toBe("2.0.0");
expect(parsed.lastAvailableVersion).toBe("2.0.0");
expect(parsed.lastNotifiedTag).toBe("latest");
expect(parsed?.lastNotifiedVersion).toBe("2.0.0");
expect(parsed?.lastAvailableVersion).toBe("2.0.0");
expect(parsed?.lastNotifiedTag).toBe("latest");
});
it("falls back when the update-check clock is outside Date range", async () => {
@@ -293,28 +383,15 @@ describe("update-startup", () => {
allowInTests: true,
});
const statePath = path.join(tempDir, "update-check.json");
const parsed = JSON.parse(await fs.readFile(statePath, "utf-8")) as {
lastCheckedAt?: string;
lastAvailableVersion?: string;
};
expect(parsed.lastCheckedAt).toBe("1970-01-01T00:00:00.000Z");
expect(parsed.lastAvailableVersion).toBe("2.0.0");
const parsed = readPersistedUpdateCheckState();
expect(parsed?.lastCheckedAt).toBe("1970-01-01T00:00:00.000Z");
expect(parsed?.lastAvailableVersion).toBe("2.0.0");
});
it("does not throttle invalid update-check clocks against persisted state", async () => {
const statePath = path.join(tempDir, "update-check.json");
await fs.writeFile(
statePath,
JSON.stringify(
{
lastCheckedAt: "2026-01-17T09:30:00.000Z",
},
null,
2,
),
"utf-8",
);
writePersistedUpdateCheckState({
lastCheckedAt: "2026-01-17T09:30:00.000Z",
});
mockPackageUpdateStatus("latest", "2.0.0");
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
@@ -326,29 +403,17 @@ describe("update-startup", () => {
});
expect(checkUpdateStatus).toHaveBeenCalledTimes(1);
const parsed = JSON.parse(await fs.readFile(statePath, "utf-8")) as {
lastCheckedAt?: string;
lastAvailableVersion?: string;
};
expect(parsed.lastCheckedAt).toBe("1970-01-01T00:00:00.000Z");
expect(parsed.lastAvailableVersion).toBe("2.0.0");
const parsed = readPersistedUpdateCheckState();
expect(parsed?.lastCheckedAt).toBe("1970-01-01T00:00:00.000Z");
expect(parsed?.lastAvailableVersion).toBe("2.0.0");
});
it("hydrates cached update from persisted state during throttle window", async () => {
const statePath = path.join(tempDir, "update-check.json");
await fs.writeFile(
statePath,
JSON.stringify(
{
lastCheckedAt: new Date(Date.now()).toISOString(),
lastAvailableVersion: "2.0.0",
lastAvailableTag: "latest",
},
null,
2,
),
"utf-8",
);
writePersistedUpdateCheckState({
lastCheckedAt: new Date(Date.now()).toISOString(),
lastAvailableVersion: "2.0.0",
lastAvailableTag: "latest",
});
const onUpdateAvailableChange = vi.fn();
await runGatewayUpdateCheck({
@@ -409,6 +474,7 @@ describe("update-startup", () => {
});
expect(log.info).not.toHaveBeenCalled();
expect(readPersistedUpdateCheckState()).toBeNull();
await expectPathMissing(path.join(tempDir, "update-check.json"));
});

View File

@@ -9,12 +9,20 @@ import {
} from "@openclaw/normalization-core/number-coercion";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { formatCliCommand } from "../cli/command-format.js";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { runCommandWithTimeout } from "../process/exec.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
} from "../state/openclaw-state-db.js";
import { VERSION } from "../version.js";
import { isTruthyEnvValue } from "./env.js";
import { writeJson } from "./json-files.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "./kysely-sync.js";
import { resolveOpenClawPackageRoot } from "./openclaw-root.js";
import { scheduleGatewaySigusr1Restart } from "./restart.js";
import { detectRespawnSupervisor, type RespawnSupervisor } from "./supervisor-markers.js";
@@ -73,7 +81,7 @@ export function resetUpdateAvailableStateForTest(): void {
updateAvailableCache = null;
}
const UPDATE_CHECK_FILENAME = "update-check.json";
const UPDATE_CHECK_STATE_KEY = "default";
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
const ONE_HOUR_MS = 60 * 60 * 1000;
const AUTO_UPDATE_COMMAND_TIMEOUT_MS = 45 * 60 * 1000;
@@ -82,6 +90,8 @@ const AUTO_STABLE_JITTER_HOURS_DEFAULT = 12;
const AUTO_BETA_CHECK_INTERVAL_HOURS_DEFAULT = 1;
const MANAGED_AUTO_UPDATE_SYSTEMD_RESTART_GRACE_MS = 2000;
type UpdateCheckStateDatabase = Pick<OpenClawStateKyselyDatabase, "update_check_state">;
function shouldSkipCheck(allowInTests: boolean): boolean {
if (allowInTests) {
return false;
@@ -130,18 +140,69 @@ function resolveCheckIntervalMs(cfg: OpenClawConfig): number {
return UPDATE_CHECK_INTERVAL_MS;
}
async function readState(statePath: string): Promise<UpdateCheckState> {
try {
const raw = await fs.readFile(statePath, "utf-8");
const parsed = JSON.parse(raw) as UpdateCheckState;
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
function presentString(value: string | null): string | undefined {
return value ?? undefined;
}
async function writeState(statePath: string, state: UpdateCheckState): Promise<void> {
await writeJson(statePath, state);
async function readState(): Promise<UpdateCheckState> {
const database = openOpenClawStateDatabase();
const stateDb = getNodeSqliteKysely<UpdateCheckStateDatabase>(database.db);
const row = executeSqliteQueryTakeFirstSync(
database.db,
stateDb
.selectFrom("update_check_state")
.selectAll()
.where("state_key", "=", UPDATE_CHECK_STATE_KEY),
);
if (!row) {
return {};
}
return {
lastCheckedAt: presentString(row.last_checked_at),
lastNotifiedVersion: presentString(row.last_notified_version),
lastNotifiedTag: presentString(row.last_notified_tag),
lastAvailableVersion: presentString(row.last_available_version),
lastAvailableTag: presentString(row.last_available_tag),
autoInstallId: presentString(row.auto_install_id),
autoFirstSeenVersion: presentString(row.auto_first_seen_version),
autoFirstSeenTag: presentString(row.auto_first_seen_tag),
autoFirstSeenAt: presentString(row.auto_first_seen_at),
autoLastAttemptVersion: presentString(row.auto_last_attempt_version),
autoLastAttemptAt: presentString(row.auto_last_attempt_at),
autoLastSuccessVersion: presentString(row.auto_last_success_version),
autoLastSuccessAt: presentString(row.auto_last_success_at),
};
}
async function writeState(state: UpdateCheckState): Promise<void> {
const updatedAtMs = Date.now();
runOpenClawStateWriteTransaction(({ db }) => {
const stateDb = getNodeSqliteKysely<UpdateCheckStateDatabase>(db);
executeSqliteQuerySync(
db,
stateDb.deleteFrom("update_check_state").where("state_key", "=", UPDATE_CHECK_STATE_KEY),
);
executeSqliteQuerySync(
db,
stateDb.insertInto("update_check_state").values({
state_key: UPDATE_CHECK_STATE_KEY,
last_checked_at: state.lastCheckedAt ?? null,
last_notified_version: state.lastNotifiedVersion ?? null,
last_notified_tag: state.lastNotifiedTag ?? null,
last_available_version: state.lastAvailableVersion ?? null,
last_available_tag: state.lastAvailableTag ?? null,
auto_install_id: state.autoInstallId ?? null,
auto_first_seen_version: state.autoFirstSeenVersion ?? null,
auto_first_seen_tag: state.autoFirstSeenTag ?? null,
auto_first_seen_at: state.autoFirstSeenAt ?? null,
auto_last_attempt_version: state.autoLastAttemptVersion ?? null,
auto_last_attempt_at: state.autoLastAttemptAt ?? null,
auto_last_success_version: state.autoLastSuccessVersion ?? null,
auto_last_success_at: state.autoLastSuccessAt ?? null,
updated_at_ms: updatedAtMs,
}),
);
});
}
function sameUpdateAvailable(a: UpdateAvailable | null, b: UpdateAvailable | null): boolean {
@@ -417,8 +478,7 @@ export async function runGatewayUpdateCheck(params: {
return;
}
const statePath = path.join(resolveStateDir(), UPDATE_CHECK_FILENAME);
const state = await readState(statePath);
const state = await readState();
const rawNow = Date.now();
const now = resolveUpdateCheckNowMs(rawNow);
const rawNowIsValid = asDateTimestampMs(rawNow) !== undefined;
@@ -470,7 +530,7 @@ export async function runGatewayUpdateCheck(params: {
next: null,
onUpdateAvailableChange: params.onUpdateAvailableChange,
});
await writeState(statePath, nextState);
await writeState(nextState);
return;
}
@@ -478,7 +538,7 @@ export async function runGatewayUpdateCheck(params: {
const resolved = await resolveNpmChannelTag({ channel, timeoutMs: 2500 });
const tag = resolved.tag;
if (!resolved.version) {
await writeState(statePath, nextState);
await writeState(nextState);
return;
}
@@ -598,7 +658,7 @@ export async function runGatewayUpdateCheck(params: {
});
}
await writeState(statePath, nextState);
await writeState(nextState);
if (pendingAutoUpdateRestartDelayMs !== null) {
scheduleGatewaySigusr1Restart({
delayMs: pendingAutoUpdateRestartDelayMs,