refactor: store tui last sessions in sqlite table

This commit is contained in:
Peter Steinberger
2026-05-09 00:18:54 +01:00
parent b977741e60
commit f60b8868b0
8 changed files with 216 additions and 61 deletions

View File

@@ -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/<agentId>/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

View File

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

View File

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

View File

@@ -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", () => {

View File

@@ -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<string, unknown>;
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 {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<string, LastSessionRecord>;
const TUI_LAST_SESSION_KV_SCOPE = "tui:last-session";
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");
@@ -56,7 +60,7 @@ async function deleteStore(filePath: string): Promise<void> {
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<TuiLastSessionDatabase>(db);
}
function recordToRow(params: {
scopeKey: string;
record: LastSessionRecord;
}): Insertable<TuiLastSessionsTable> {
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<OpenClawStateJsonValue>(
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<string | null> {
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<TuiLastSessionRow>(
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<string>();
const kvOptions = stateKvOptionsForStateDir(params.stateDir);
for (const entry of listOpenClawStateKvJson<LastSessionRecord>(
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: {