mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-24 10:29:33 +00:00
fix(deadcode): move update check state to sqlite
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user