refactor: isolate legacy update and tui imports

This commit is contained in:
Peter Steinberger
2026-05-09 02:44:52 +01:00
parent 099342a7ef
commit 180e37e06f
6 changed files with 213 additions and 122 deletions

View File

@@ -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 = {

View File

@@ -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<OpenClawStateJsonValue>(
UPDATE_CHECK_SCOPE,
UPDATE_CHECK_KEY,
state as unknown as OpenClawStateJsonValue,
sqliteOptionsForEnv(env),
);
}
async function readLegacyStateFile(filePath: string): Promise<UpdateCheckState> {
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<boolean> {
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 };
}

View File

@@ -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<UpdateCheckState> {
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<boolean> {
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;

View File

@@ -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<string, LastSessionRecord>;
export function resolveLegacyTuiLastSessionStatePath(stateDir = resolveStateDir()): string {
return path.join(stateDir, "tui", "last-session.json");
}
async function readStore(filePath: string): Promise<LastSessionStore> {
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<void> {
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<string, unknown>;
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<LastSessionStore> {
const filePath = resolveLegacyTuiLastSessionStatePath(params.stateDir);
return await readStore(filePath);
}
export async function legacyTuiLastSessionFileExists(
params: {
stateDir?: string;
} = {},
): Promise<boolean> {
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 };
}

View File

@@ -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";

View File

@@ -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<string, LastSessionRecord>;
type TuiLastSessionsTable = OpenClawStateKyselyDatabase["tui_last_sessions"];
type TuiLastSessionRow = Selectable<TuiLastSessionsTable>;
type TuiLastSessionDatabase = Pick<OpenClawStateKyselyDatabase, "tui_last_sessions">;
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<LastSessionStore> {
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<void> {
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<LastSessionStore> {
const filePath = resolveLegacyTuiLastSessionStatePath(params.stateDir);
return await readStore(filePath);
}
export async function legacyTuiLastSessionFileExists(
params: {
stateDir?: string;
} = {},
): Promise<boolean> {
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<boolean> {
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<string>;