diff --git a/docs/refactor/database-first.md b/docs/refactor/database-first.md index 4c5bf65e93c..93579261d1c 100644 --- a/docs/refactor/database-first.md +++ b/docs/refactor/database-first.md @@ -104,9 +104,9 @@ The branch already has a real shared SQLite base: `plugin_state_entries`, `plugin_blob_entries`, `transcript_files`, `capture_sessions`, `capture_events`, `capture_blobs`, `sandbox_registry_entries`, `cron_run_logs`, `cron_jobs`, `commitments`, - `delivery_queue_entries`, `current_conversation_bindings`, `task_runs`, - `task_delivery_state`, `flow_runs`, `subagent_runs`, `migration_runs`, and - `backup_runs`. + `delivery_queue_entries`, `current_conversation_bindings`, + `tui_last_sessions`, `task_runs`, `task_delivery_state`, `flow_runs`, + `subagent_runs`, `migration_runs`, and `backup_runs`. - `src/state/openclaw-agent-db.ts` opens `agents//agent/openclaw-agent.sqlite`, registers the database in the global DB, and owns agent-local session, transcript, VFS, artifact, and cache @@ -119,6 +119,9 @@ The branch already has a real shared SQLite base: - Current conversation bindings now live in typed shared `current_conversation_bindings` rows keyed by normalized conversation id and indexed by target session. The old KV scope is migration input only. +- TUI last-session restore pointers now live in typed shared + `tui_last_sessions` rows keyed by the hashed TUI connection/session scope. + The old JSON file and `tui:last-session` KV scope are migration input only. - `src/agents/filesystem/virtual-agent-fs.sqlite.ts` implements a SQLite VFS over the agent database `vfs_entries` table. - `src/agents/runtime-worker.entry.ts` creates per-run SQLite VFS, tool artifact, @@ -534,6 +537,7 @@ task_delivery_state(...) flow_runs(...) subagent_runs(run_id, child_session_key, requester_session_key, controller_session_key, created_at, ended_at, cleanup_handled, payload_json) current_conversation_bindings(binding_key, binding_id, channel, account_id, conversation_id, target_session_key, status, bound_at, expires_at, record_json) +tui_last_sessions(scope_key, session_key, updated_at) plugin_state_entries(plugin_id, namespace, entry_key, value_json, created_at, expires_at) plugin_blob_entries(plugin_id, namespace, entry_key, metadata_json, blob, created_at, expires_at) media_blobs(subdir, id, content_type, size_bytes, blob, created_at, updated_at) @@ -619,9 +623,8 @@ Move these into the global database: - Cron job definitions, schedule state, and run history now use shared SQLite; doctor imports/removes legacy `jobs.json`, `jobs-state.json`, and `cron/runs/*.jsonl` files -- Device identity/auth/bootstrap, pairing, push, update check, commitments, TUI - pointers, OpenRouter model cache, installed plugin index, and app-server - bindings +- Device identity/auth/bootstrap, pairing, push, update check, commitments, + OpenRouter model cache, installed plugin index, and app-server bindings - Device-pair notification subscribers and delivered-request markers now use the shared SQLite plugin-state table instead of `device-pair-notify.json`. - Voice-call call records now use the shared SQLite plugin-state table under the diff --git a/src/commands/doctor-sqlite-state.test.ts b/src/commands/doctor-sqlite-state.test.ts index 475717379b9..6ea6cefe852 100644 --- a/src/commands/doctor-sqlite-state.test.ts +++ b/src/commands/doctor-sqlite-state.test.ts @@ -22,9 +22,11 @@ import { import { readMemoryHostEvents } from "../memory-host-sdk/events.js"; import { loadNodeHostConfig } from "../node-host/config.js"; import { listChannelPairingRequests, readChannelAllowFromStore } from "../pairing/pairing-store.js"; +import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js"; import { readOpenClawStateKvJson } from "../state/openclaw-state-kv.js"; import { withEnvAsync } from "../test-utils/env.js"; import { withTempDir } from "../test-utils/temp-dir.js"; +import { readTuiLastSessionKey } from "../tui/tui-last-session.js"; const noteMock = vi.hoisted(() => vi.fn()); @@ -491,16 +493,17 @@ describe("maybeRepairLegacyRuntimeStateFiles", () => { await expect(fs.stat(path.join(legacyMediaDir, "legacy-media.txt"))).rejects.toMatchObject({ code: "ENOENT", }); - expect(readOpenClawStateKvJson("subagent_runs", "run-legacy", { env })).toMatchObject({ - childSessionKey: "agent:main:subagent:legacy", - }); + expect( + openOpenClawStateDatabase({ env }) + .db.prepare("SELECT child_session_key FROM subagent_runs WHERE run_id = ?") + .get("run-legacy"), + ).toEqual({ child_session_key: "agent:main:subagent:legacy" }); await expect(fs.stat(path.join(stateDir, "subagents", "runs.json"))).rejects.toMatchObject({ code: "ENOENT", }); - expect(readOpenClawStateKvJson("tui:last-session", "legacy-tui-scope", { env })).toEqual({ - sessionKey: "agent:main:tui-legacy", - updatedAt: 1000, - }); + await expect( + readTuiLastSessionKey({ scopeKey: "legacy-tui-scope", stateDir }), + ).resolves.toBe("agent:main:tui-legacy"); await expect( fs.stat(path.join(stateDir, "tui", "last-session.json")), ).rejects.toMatchObject({ code: "ENOENT" }); diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index d02e0b2deaf..97c35395595 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -344,6 +344,12 @@ export interface TranscriptFiles { session_id: string; } +export interface TuiLastSessions { + scope_key: string; + session_key: string; + updated_at: number; +} + export interface DB { acp_replay_events: AcpReplayEvents; acp_replay_sessions: AcpReplaySessions; @@ -371,4 +377,5 @@ export interface DB { task_delivery_state: TaskDeliveryState; task_runs: TaskRuns; transcript_files: TranscriptFiles; + tui_last_sessions: TuiLastSessions; } diff --git a/src/state/openclaw-state-db.test.ts b/src/state/openclaw-state-db.test.ts index e31543b1d1b..da87271a223 100644 --- a/src/state/openclaw-state-db.test.ts +++ b/src/state/openclaw-state-db.test.ts @@ -157,7 +157,7 @@ describe("openclaw state database", () => { expect(columns.some((column) => column.name === "sort_order")).toBe(true); expect(index?.sql).toContain("sort_order ASC"); - expect(version.user_version).toBe(21); + expect(version.user_version).toBe(22); }); it("migrates legacy cron runtime state from kv into cron job columns", () => { @@ -215,7 +215,7 @@ describe("openclaw state database", () => { .prepare("SELECT COUNT(*) AS count FROM kv WHERE scope = ?") .get("cron.jobs.state"), ).toEqual({ count: 0 }); - expect(database.db.prepare("PRAGMA user_version").get()).toEqual({ user_version: 21 }); + expect(database.db.prepare("PRAGMA user_version").get()).toEqual({ user_version: 22 }); }); it("migrates persisted subagent runs from kv into subagent run rows", () => { @@ -264,7 +264,7 @@ describe("openclaw state database", () => { expect( database.db.prepare("SELECT COUNT(*) AS count FROM kv WHERE scope = ?").get("subagent_runs"), ).toEqual({ count: 0 }); - expect(database.db.prepare("PRAGMA user_version").get()).toEqual({ user_version: 21 }); + expect(database.db.prepare("PRAGMA user_version").get()).toEqual({ user_version: 22 }); }); it("migrates current conversation bindings from kv into binding rows", () => { @@ -314,7 +314,55 @@ describe("openclaw state database", () => { .prepare("SELECT COUNT(*) AS count FROM kv WHERE scope = ?") .get("current-conversation-bindings"), ).toEqual({ count: 0 }); - expect(database.db.prepare("PRAGMA user_version").get()).toEqual({ user_version: 21 }); + expect(database.db.prepare("PRAGMA user_version").get()).toEqual({ user_version: 22 }); + }); + + it("migrates TUI last-session pointers from kv into dedicated rows", () => { + const stateDir = createTempStateDir(); + const dbPath = resolveOpenClawStateSqlitePath({ OPENCLAW_STATE_DIR: stateDir }); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + const sqlite = requireNodeSqlite(); + const oldDb = new sqlite.DatabaseSync(dbPath); + oldDb.exec(` + CREATE TABLE kv ( + scope TEXT NOT NULL, + key TEXT NOT NULL, + value_json TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (scope, key) + ); + INSERT INTO kv (scope, key, value_json, updated_at) + VALUES ( + 'tui:last-session', + 'scope-main', + '{"sessionKey":"agent:main:tui-legacy","updatedAt":1000}', + 1001 + ); + PRAGMA user_version = 21; + `); + oldDb.close(); + + const database = openOpenClawStateDatabase({ + env: { OPENCLAW_STATE_DIR: stateDir }, + }); + + expect( + database.db + .prepare( + "SELECT scope_key, session_key, updated_at FROM tui_last_sessions WHERE scope_key = ?", + ) + .get("scope-main"), + ).toEqual({ + scope_key: "scope-main", + session_key: "agent:main:tui-legacy", + updated_at: 1000, + }); + expect( + database.db + .prepare("SELECT COUNT(*) AS count FROM kv WHERE scope = ?") + .get("tui:last-session"), + ).toEqual({ count: 0 }); + expect(database.db.prepare("PRAGMA user_version").get()).toEqual({ user_version: 22 }); }); it("upgrades task delivery state with task-run cascade integrity", () => { diff --git a/src/state/openclaw-state-db.ts b/src/state/openclaw-state-db.ts index c630c096840..d1abf815773 100644 --- a/src/state/openclaw-state-db.ts +++ b/src/state/openclaw-state-db.ts @@ -17,7 +17,7 @@ import { } from "./openclaw-state-db.paths.js"; import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.generated.js"; -const OPENCLAW_STATE_SCHEMA_VERSION = 21; +const OPENCLAW_STATE_SCHEMA_VERSION = 22; export const OPENCLAW_SQLITE_BUSY_TIMEOUT_MS = 30_000; const OPENCLAW_STATE_DIR_MODE = 0o700; const OPENCLAW_STATE_FILE_MODE = 0o600; @@ -457,6 +457,49 @@ function migrateCurrentConversationBindingsFromKv(db: DatabaseSync): void { db.prepare("DELETE FROM kv WHERE scope = 'current-conversation-bindings'").run(); } +function migrateTuiLastSessionsFromKv(db: DatabaseSync): void { + const legacyRows = db + .prepare("SELECT key, value_json FROM kv WHERE scope = 'tui:last-session'") + .all() as Array<{ key?: unknown; value_json?: unknown }>; + if (legacyRows.length === 0) { + return; + } + + const insert = db.prepare(` + INSERT OR REPLACE INTO tui_last_sessions ( + scope_key, + session_key, + updated_at + ) + VALUES (?, ?, ?) + `); + + for (const row of legacyRows) { + if (typeof row.key !== "string" || typeof row.value_json !== "string") { + continue; + } + let parsed: unknown; + try { + parsed = JSON.parse(row.value_json); + } catch { + continue; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + continue; + } + const record = parsed as Record; + const scopeKey = row.key.trim(); + const sessionKey = readString(record.sessionKey); + const updatedAt = readFiniteNumber(record.updatedAt); + if (!scopeKey || !sessionKey || updatedAt === null) { + continue; + } + insert.run(scopeKey, sessionKey, updatedAt); + } + + db.prepare("DELETE FROM kv WHERE scope = 'tui:last-session'").run(); +} + function rebuildTaskDeliveryStateWithForeignKey(db: DatabaseSync): void { db.exec(` CREATE TABLE IF NOT EXISTS task_delivery_state_next ( @@ -539,6 +582,9 @@ function migrateStateSchema(db: DatabaseSync, fromVersion: number): void { if (fromVersion < 21) { migrateCurrentConversationBindingsFromKv(db); } + if (fromVersion < 22) { + migrateTuiLastSessionsFromKv(db); + } } function ensureSchema(db: DatabaseSync, pathname: string): void { diff --git a/src/state/openclaw-state-schema.generated.ts b/src/state/openclaw-state-schema.generated.ts index 7f5324c2ad4..9b7a2c95ca8 100644 --- a/src/state/openclaw-state-schema.generated.ts +++ b/src/state/openclaw-state-schema.generated.ts @@ -375,6 +375,15 @@ CREATE INDEX IF NOT EXISTS idx_current_conversation_bindings_target CREATE INDEX IF NOT EXISTS idx_current_conversation_bindings_expires ON current_conversation_bindings(expires_at, binding_key); +CREATE TABLE IF NOT EXISTS tui_last_sessions ( + scope_key TEXT NOT NULL PRIMARY KEY, + session_key TEXT NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_tui_last_sessions_session_key + ON tui_last_sessions(session_key, updated_at DESC, scope_key); + CREATE TABLE IF NOT EXISTS task_delivery_state ( task_id TEXT NOT NULL PRIMARY KEY, requester_origin_json TEXT, diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index 922014eba89..6e3da224c62 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -370,6 +370,15 @@ CREATE INDEX IF NOT EXISTS idx_current_conversation_bindings_target CREATE INDEX IF NOT EXISTS idx_current_conversation_bindings_expires ON current_conversation_bindings(expires_at, binding_key); +CREATE TABLE IF NOT EXISTS tui_last_sessions ( + scope_key TEXT NOT NULL PRIMARY KEY, + session_key TEXT NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_tui_last_sessions_session_key + ON tui_last_sessions(session_key, updated_at DESC, scope_key); + CREATE TABLE IF NOT EXISTS task_delivery_state ( task_id TEXT NOT NULL PRIMARY KEY, requester_origin_json TEXT, diff --git a/src/tui/tui-last-session.ts b/src/tui/tui-last-session.ts index a995b1e743e..e0ec36f841d 100644 --- a/src/tui/tui-last-session.ts +++ b/src/tui/tui-last-session.ts @@ -1,16 +1,18 @@ 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 { - deleteOpenClawStateKvJson, - listOpenClawStateKvJson, - readOpenClawStateKvJson, - writeOpenClawStateKvJson, - type OpenClawStateJsonValue, -} from "../state/openclaw-state-kv.js"; + type OpenClawStateDatabaseOptions, + openOpenClawStateDatabase, + runOpenClawStateWriteTransaction, +} from "../state/openclaw-state-db.js"; import type { TuiSessionList } from "./tui-backend.js"; import type { SessionScope } from "./tui-types.js"; @@ -20,7 +22,9 @@ type LastSessionRecord = { }; type LastSessionStore = Record; -const TUI_LAST_SESSION_KV_SCOPE = "tui:last-session"; +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"); @@ -56,7 +60,7 @@ async function deleteStore(filePath: string): Promise { await fs.rm(filePath, { force: true }); } -function stateKvOptionsForStateDir(stateDir?: string) { +function sqliteOptionsForStateDir(stateDir?: string): OpenClawStateDatabaseOptions { return stateDir ? { env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } } : {}; } @@ -73,17 +77,51 @@ function normalizeLastSessionRecord(value: unknown): LastSessionRecord | null { return { sessionKey, updatedAt }; } -function writeTuiLastSessionKv(params: { +function getTuiLastSessionKysely(db: DatabaseSync) { + return getNodeSqliteKysely(db); +} + +function recordToRow(params: { + scopeKey: string; + record: LastSessionRecord; +}): Insertable { + return { + scope_key: params.scopeKey, + session_key: params.record.sessionKey, + updated_at: params.record.updatedAt, + }; +} + +function rowToRecord(row: TuiLastSessionRow | undefined): LastSessionRecord | null { + if (!row) { + return null; + } + return normalizeLastSessionRecord({ + sessionKey: row.session_key, + updatedAt: row.updated_at, + }); +} + +function writeTuiLastSessionRow(params: { scopeKey: string; record: LastSessionRecord; stateDir?: string; }): void { - writeOpenClawStateKvJson( - TUI_LAST_SESSION_KV_SCOPE, - params.scopeKey, - params.record, - stateKvOptionsForStateDir(params.stateDir), - ); + runOpenClawStateWriteTransaction((stateDatabase) => { + const db = getTuiLastSessionKysely(stateDatabase.db); + executeSqliteQuerySync( + stateDatabase.db, + db + .insertInto("tui_last_sessions") + .values(recordToRow(params)) + .onConflict((conflict) => + conflict.column("scope_key").doUpdateSet({ + session_key: (eb) => eb.ref("excluded.session_key"), + updated_at: (eb) => eb.ref("excluded.updated_at"), + }), + ), + ); + }, sqliteOptionsForStateDir(params.stateDir)); } async function readLegacyTuiLastSessionStore(params: { @@ -123,7 +161,7 @@ export async function importLegacyTuiLastSessionStoreToSqlite( if (!record) { continue; } - writeTuiLastSessionKv({ + writeTuiLastSessionRow({ scopeKey, record, stateDir: params.stateDir, @@ -162,17 +200,13 @@ export async function readTuiLastSessionKey(params: { scopeKey: string; stateDir?: string; }): Promise { - const kvValue = readOpenClawStateKvJson( - TUI_LAST_SESSION_KV_SCOPE, - params.scopeKey, - stateKvOptionsForStateDir(params.stateDir), - ); - const kvRecord = normalizeLastSessionRecord(kvValue); - if (kvRecord) { - return kvRecord.sessionKey; - } - - return null; + const stateDatabase = openOpenClawStateDatabase(sqliteOptionsForStateDir(params.stateDir)); + const db = getTuiLastSessionKysely(stateDatabase.db); + const row = executeSqliteQuerySync( + stateDatabase.db, + db.selectFrom("tui_last_sessions").selectAll().where("scope_key", "=", params.scopeKey), + ).rows[0]; + return rowToRecord(row)?.sessionKey ?? null; } export async function writeTuiLastSessionKey(params: { @@ -188,7 +222,7 @@ export async function writeTuiLastSessionKey(params: { sessionKey, updatedAt: Date.now(), }; - writeTuiLastSessionKv({ + writeTuiLastSessionRow({ scopeKey: params.scopeKey, record, stateDir: params.stateDir, @@ -202,21 +236,17 @@ export async function clearTuiLastSessionPointers(params: { if (params.sessionKeys.size === 0) { return 0; } - const removedScopeKeys = new Set(); - const kvOptions = stateKvOptionsForStateDir(params.stateDir); - for (const entry of listOpenClawStateKvJson( - TUI_LAST_SESSION_KV_SCOPE, - kvOptions, - )) { - const record = normalizeLastSessionRecord(entry.value); - if (record && params.sessionKeys.has(record.sessionKey)) { - if (deleteOpenClawStateKvJson(TUI_LAST_SESSION_KV_SCOPE, entry.key, kvOptions)) { - removedScopeKeys.add(entry.key); - } - } - } - - return removedScopeKeys.size; + let removed = 0; + const sessionKeys = [...params.sessionKeys]; + runOpenClawStateWriteTransaction((stateDatabase) => { + const db = getTuiLastSessionKysely(stateDatabase.db); + const result = executeSqliteQuerySync( + stateDatabase.db, + db.deleteFrom("tui_last_sessions").where("session_key", "in", sessionKeys), + ); + removed = Number(result.numAffectedRows ?? 0n); + }, sqliteOptionsForStateDir(params.stateDir)); + return removed; } export function resolveRememberedTuiSessionKey(params: {