From 180e37e06f355ffb308b1bf138b505a1cd3d6bf4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 9 May 2026 02:44:52 +0100 Subject: [PATCH] refactor: isolate legacy update and tui imports --- src/commands/doctor-sqlite-state.ts | 4 +- src/infra/update-startup-legacy.ts | 89 ++++++++++++++++++++++++++ src/infra/update-startup.ts | 44 ------------- src/tui/tui-last-session-legacy.ts | 97 +++++++++++++++++++++++++++++ src/tui/tui-last-session.test.ts | 6 +- src/tui/tui-last-session.ts | 95 +++++++--------------------- 6 files changed, 213 insertions(+), 122 deletions(-) create mode 100644 src/infra/update-startup-legacy.ts create mode 100644 src/tui/tui-last-session-legacy.ts diff --git a/src/commands/doctor-sqlite-state.ts b/src/commands/doctor-sqlite-state.ts index 5a498fec410..912741d64c6 100644 --- a/src/commands/doctor-sqlite-state.ts +++ b/src/commands/doctor-sqlite-state.ts @@ -58,7 +58,7 @@ import { import { importLegacyUpdateCheckFileToSqlite, legacyUpdateCheckFileExists, -} from "../infra/update-startup.js"; +} from "../infra/update-startup-legacy.js"; import { importLegacyVoiceWakeConfigFileToSqlite, legacyVoiceWakeConfigFileExists, @@ -92,7 +92,7 @@ import { note } from "../terminal/note.js"; import { importLegacyTuiLastSessionStoreToSqlite, legacyTuiLastSessionFileExists, -} from "../tui/tui-last-session.js"; +} from "../tui/tui-last-session-legacy.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; type LegacyStateProbe = { diff --git a/src/infra/update-startup-legacy.ts b/src/infra/update-startup-legacy.ts new file mode 100644 index 00000000000..9885344710e --- /dev/null +++ b/src/infra/update-startup-legacy.ts @@ -0,0 +1,89 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolveStateDir } from "../config/paths.js"; +import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; +import { + writeOpenClawStateKvJson, + type OpenClawStateJsonValue, +} from "../state/openclaw-state-kv.js"; + +type UpdateCheckState = { + 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; +}; + +const UPDATE_CHECK_FILENAME = "update-check.json"; +const UPDATE_CHECK_SCOPE = "runtime.update-check"; +const UPDATE_CHECK_KEY = "state"; + +function sqliteOptionsForEnv(env: NodeJS.ProcessEnv): OpenClawStateDatabaseOptions { + return { env }; +} + +function resolveLegacyUpdateCheckPath(env: NodeJS.ProcessEnv = process.env): string { + return path.join(resolveStateDir(env), UPDATE_CHECK_FILENAME); +} + +function coerceUpdateCheckState(value: unknown): UpdateCheckState { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as UpdateCheckState) + : {}; +} + +function writeState(state: UpdateCheckState, env: NodeJS.ProcessEnv = process.env): void { + writeOpenClawStateKvJson( + UPDATE_CHECK_SCOPE, + UPDATE_CHECK_KEY, + state as unknown as OpenClawStateJsonValue, + sqliteOptionsForEnv(env), + ); +} + +async function readLegacyStateFile(filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, "utf-8"); + return coerceUpdateCheckState(JSON.parse(raw)); + } catch { + return {}; + } +} + +export async function legacyUpdateCheckFileExists( + env: NodeJS.ProcessEnv = process.env, +): Promise { + try { + await fs.access(resolveLegacyUpdateCheckPath(env)); + return true; + } catch { + return false; + } +} + +export async function importLegacyUpdateCheckFileToSqlite( + env: NodeJS.ProcessEnv = process.env, +): Promise<{ imported: boolean }> { + const filePath = resolveLegacyUpdateCheckPath(env); + try { + await fs.access(filePath); + } catch (error) { + if ((error as { code?: unknown })?.code === "ENOENT") { + return { imported: false }; + } + throw error; + } + const state = await readLegacyStateFile(filePath); + writeState(state, env); + await fs.rm(filePath, { force: true }).catch(() => undefined); + return { imported: true }; +} diff --git a/src/infra/update-startup.ts b/src/infra/update-startup.ts index 3eada234207..d5047ec641b 100644 --- a/src/infra/update-startup.ts +++ b/src/infra/update-startup.ts @@ -2,7 +2,6 @@ import { createHash, randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; 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 { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; @@ -65,7 +64,6 @@ export function resetUpdateAvailableStateForTest(): void { updateAvailableCache = null; } -const UPDATE_CHECK_FILENAME = "update-check.json"; const UPDATE_CHECK_SCOPE = "runtime.update-check"; const UPDATE_CHECK_KEY = "state"; const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; @@ -127,10 +125,6 @@ function sqliteOptionsForEnv(env: NodeJS.ProcessEnv): OpenClawStateDatabaseOptio return { env }; } -function resolveLegacyUpdateCheckPath(env: NodeJS.ProcessEnv = process.env): string { - return path.join(resolveStateDir(env), UPDATE_CHECK_FILENAME); -} - function coerceUpdateCheckState(value: unknown): UpdateCheckState { return value && typeof value === "object" && !Array.isArray(value) ? (value as UpdateCheckState) @@ -152,44 +146,6 @@ function writeState(state: UpdateCheckState, env: NodeJS.ProcessEnv = process.en ); } -async function readLegacyStateFile(filePath: string): Promise { - try { - const raw = await fs.readFile(filePath, "utf-8"); - return coerceUpdateCheckState(JSON.parse(raw)); - } catch { - return {}; - } -} - -export async function legacyUpdateCheckFileExists( - env: NodeJS.ProcessEnv = process.env, -): Promise { - try { - await fs.access(resolveLegacyUpdateCheckPath(env)); - return true; - } catch { - return false; - } -} - -export async function importLegacyUpdateCheckFileToSqlite( - env: NodeJS.ProcessEnv = process.env, -): Promise<{ imported: boolean }> { - const filePath = resolveLegacyUpdateCheckPath(env); - try { - await fs.access(filePath); - } catch (error) { - if ((error as { code?: unknown })?.code === "ENOENT") { - return { imported: false }; - } - throw error; - } - const state = await readLegacyStateFile(filePath); - writeState(state, env); - await fs.rm(filePath, { force: true }).catch(() => undefined); - return { imported: true }; -} - function sameUpdateAvailable(a: UpdateAvailable | null, b: UpdateAvailable | null): boolean { if (a === b) { return true; diff --git a/src/tui/tui-last-session-legacy.ts b/src/tui/tui-last-session-legacy.ts new file mode 100644 index 00000000000..7798743bb78 --- /dev/null +++ b/src/tui/tui-last-session-legacy.ts @@ -0,0 +1,97 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolveStateDir } from "../config/paths.js"; +import { privateFileStore } from "../infra/private-file-store.js"; +import { writeTuiLastSessionRecordForMigration } from "./tui-last-session.js"; + +type LastSessionRecord = { + sessionKey: string; + updatedAt: number; +}; + +type LastSessionStore = Record; + +export function resolveLegacyTuiLastSessionStatePath(stateDir = resolveStateDir()): string { + return path.join(stateDir, "tui", "last-session.json"); +} + +async function readStore(filePath: string): Promise { + try { + const parsed = await privateFileStore(path.dirname(filePath)).readJsonIfExists( + path.basename(filePath), + ); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as LastSessionStore) + : {}; + } catch { + return {}; + } +} + +async function deleteStore(filePath: string): Promise { + await fs.rm(filePath, { force: true }); +} + +function normalizeLastSessionRecord(value: unknown): LastSessionRecord | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const record = value as Record; + const sessionKey = typeof record.sessionKey === "string" ? record.sessionKey.trim() : ""; + const updatedAt = typeof record.updatedAt === "number" ? record.updatedAt : null; + if (!sessionKey || updatedAt === null || !Number.isFinite(updatedAt)) { + return null; + } + return { sessionKey, updatedAt }; +} + +async function readLegacyTuiLastSessionStore(params: { + stateDir?: string; +}): Promise { + const filePath = resolveLegacyTuiLastSessionStatePath(params.stateDir); + return await readStore(filePath); +} + +export async function legacyTuiLastSessionFileExists( + params: { + stateDir?: string; + } = {}, +): Promise { + try { + await fs.access(resolveLegacyTuiLastSessionStatePath(params.stateDir)); + return true; + } catch { + return false; + } +} + +export async function importLegacyTuiLastSessionStoreToSqlite( + params: { + stateDir?: string; + } = {}, +): Promise<{ imported: boolean; pointers: number }> { + const filePath = resolveLegacyTuiLastSessionStatePath(params.stateDir); + const exists = await legacyTuiLastSessionFileExists(params); + if (!exists) { + return { imported: false, pointers: 0 }; + } + const store = await readLegacyTuiLastSessionStore(params); + let pointers = 0; + for (const [scopeKey, value] of Object.entries(store)) { + const record = normalizeLastSessionRecord(value); + if (!record) { + continue; + } + const wrote = await writeTuiLastSessionRecordForMigration({ + scopeKey, + sessionKey: record.sessionKey, + updatedAt: record.updatedAt, + stateDir: params.stateDir, + }); + if (wrote) { + pointers += 1; + } + } + await deleteStore(filePath); + return { imported: true, pointers }; +} diff --git a/src/tui/tui-last-session.test.ts b/src/tui/tui-last-session.test.ts index 826b800c770..d84ec9c3343 100644 --- a/src/tui/tui-last-session.test.ts +++ b/src/tui/tui-last-session.test.ts @@ -3,14 +3,16 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { + importLegacyTuiLastSessionStoreToSqlite, + resolveLegacyTuiLastSessionStatePath, +} from "./tui-last-session-legacy.js"; import { buildTuiLastSessionScopeKey, clearTuiLastSessionPointers, - importLegacyTuiLastSessionStoreToSqlite, isHeartbeatLikeTuiSession, readTuiLastSessionKey, resolveRememberedTuiSessionKey, - resolveLegacyTuiLastSessionStatePath, writeTuiLastSessionKey, } from "./tui-last-session.js"; diff --git a/src/tui/tui-last-session.ts b/src/tui/tui-last-session.ts index 8b8dd42a7fc..4960c5e11c6 100644 --- a/src/tui/tui-last-session.ts +++ b/src/tui/tui-last-session.ts @@ -1,11 +1,7 @@ import { createHash } from "node:crypto"; -import fs from "node:fs/promises"; -import path from "node:path"; import type { DatabaseSync } from "node:sqlite"; import type { Insertable, Selectable } from "kysely"; -import { resolveStateDir } from "../config/paths.js"; import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js"; -import { privateFileStore } from "../infra/private-file-store.js"; import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; import { @@ -21,15 +17,10 @@ type LastSessionRecord = { updatedAt: number; }; -type LastSessionStore = Record; type TuiLastSessionsTable = OpenClawStateKyselyDatabase["tui_last_sessions"]; type TuiLastSessionRow = Selectable; type TuiLastSessionDatabase = Pick; -export function resolveLegacyTuiLastSessionStatePath(stateDir = resolveStateDir()): string { - return path.join(stateDir, "tui", "last-session.json"); -} - export function buildTuiLastSessionScopeKey(params: { connectionUrl: string; agentId: string; @@ -43,23 +34,6 @@ export function buildTuiLastSessionScopeKey(params: { .slice(0, 32); } -async function readStore(filePath: string): Promise { - try { - const parsed = await privateFileStore(path.dirname(filePath)).readJsonIfExists( - path.basename(filePath), - ); - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as LastSessionStore) - : {}; - } catch { - return {}; - } -} - -async function deleteStore(filePath: string): Promise { - await fs.rm(filePath, { force: true }); -} - function sqliteOptionsForStateDir(stateDir?: string): OpenClawStateDatabaseOptions { return stateDir ? { env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } } : {}; } @@ -124,54 +98,6 @@ function writeTuiLastSessionRow(params: { }, sqliteOptionsForStateDir(params.stateDir)); } -async function readLegacyTuiLastSessionStore(params: { - stateDir?: string; -}): Promise { - const filePath = resolveLegacyTuiLastSessionStatePath(params.stateDir); - return await readStore(filePath); -} - -export async function legacyTuiLastSessionFileExists( - params: { - stateDir?: string; - } = {}, -): Promise { - try { - await fs.access(resolveLegacyTuiLastSessionStatePath(params.stateDir)); - return true; - } catch { - return false; - } -} - -export async function importLegacyTuiLastSessionStoreToSqlite( - params: { - stateDir?: string; - } = {}, -): Promise<{ imported: boolean; pointers: number }> { - const filePath = resolveLegacyTuiLastSessionStatePath(params.stateDir); - const exists = await legacyTuiLastSessionFileExists(params); - if (!exists) { - return { imported: false, pointers: 0 }; - } - const store = await readLegacyTuiLastSessionStore(params); - let pointers = 0; - for (const [scopeKey, value] of Object.entries(store)) { - const record = normalizeLastSessionRecord(value); - if (!record) { - continue; - } - writeTuiLastSessionRow({ - scopeKey, - record, - stateDir: params.stateDir, - }); - pointers += 1; - } - await deleteStore(filePath); - return { imported: true, pointers }; -} - function normalizeMarker(value: unknown): string { return typeof value === "string" ? value.trim().toLowerCase() : ""; } @@ -229,6 +155,27 @@ export async function writeTuiLastSessionKey(params: { }); } +export async function writeTuiLastSessionRecordForMigration(params: { + scopeKey: string; + sessionKey: string; + updatedAt: number; + stateDir?: string; +}): Promise { + const record = normalizeLastSessionRecord({ + sessionKey: params.sessionKey, + updatedAt: params.updatedAt, + }); + if (!record || isHeartbeatSessionKey(record.sessionKey)) { + return false; + } + writeTuiLastSessionRow({ + scopeKey: params.scopeKey, + record, + stateDir: params.stateDir, + }); + return true; +} + export async function clearTuiLastSessionPointers(params: { stateDir?: string; sessionKeys: ReadonlySet;