From eb00d499d16feea600fceef92d575fa30f005649 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 21 Jun 2026 20:57:36 +0800 Subject: [PATCH] fix(deadcode): move update check state to sqlite --- src/commands/doctor.e2e-harness.ts | 4 + src/infra/state-migrations.test.ts | 72 +++++++++- src/infra/state-migrations.ts | 202 +++++++++++++++++++++++++++++ src/infra/update-startup.test.ts | 164 ++++++++++++++++------- src/infra/update-startup.ts | 96 +++++++++++--- 5 files changed, 470 insertions(+), 68 deletions(-) diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index 17529b4ee85..ed30f7efe45 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -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", diff --git a/src/infra/state-migrations.test.ts b/src/infra/state-migrations.test.ts index 1b952c1a87e..1de4e429fc6 100644 --- a/src/infra/state-migrations.test.ts +++ b/src/infra/state-migrations.test.ts @@ -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; + async function expectMissingPath(targetPath: string): Promise { let statError: NodeJS.ErrnoException | undefined; try { @@ -101,6 +108,30 @@ async function expectMissingPath(targetPath: string): Promise { } 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(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"); diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index fb00b23254d..0af15b5d43d 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -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; type SqliteBindRow = Record; 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, 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) : {}; + 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(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, diff --git a/src/infra/update-startup.test.ts b/src/infra/update-startup.test.ts index 3b4162cdc29..5d2b931f8c7 100644 --- a/src/infra/update-startup.test.ts +++ b/src/infra/update-startup.test.ts @@ -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; +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(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(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")); }); diff --git a/src/infra/update-startup.ts b/src/infra/update-startup.ts index 2d0c5a48a8c..4f592484f27 100644 --- a/src/infra/update-startup.ts +++ b/src/infra/update-startup.ts @@ -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; + 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 { - 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 { - await writeJson(statePath, state); +async function readState(): Promise { + const database = openOpenClawStateDatabase(); + const stateDb = getNodeSqliteKysely(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 { + const updatedAtMs = Date.now(); + runOpenClawStateWriteTransaction(({ db }) => { + const stateDb = getNodeSqliteKysely(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,