refactor(cli): read-only database access for pure-read commands (#110732)

This commit is contained in:
Peter Steinberger
2026-07-18 15:42:28 +01:00
committed by GitHub
parent 593423ecc0
commit d8846a1dcb
20 changed files with 358 additions and 100 deletions
+4 -1
View File
@@ -62,7 +62,10 @@ const rawSqliteAllowPathGroups = {
"src/snapshot/local-repository.ts",
],
"agent auth profile read-only bootstrap": ["src/agents/auth-profiles/sqlite.ts"],
"read-only shared state database access": ["src/state/openclaw-state-db-readonly.ts"],
"read-only shared state database access": [
"src/state/openclaw-agent-db-readonly.ts",
"src/state/openclaw-state-db-readonly.ts",
],
"read-only schema preflight and integrity verification access": [
"src/state/openclaw-database-preflight.ts",
"src/state/openclaw-database-verify.worker.ts",
+1
View File
@@ -181,6 +181,7 @@ describe("registry race safety", () => {
await expect(readRegistry()).resolves.toEqual({ entries: [] });
await expect(readRegistryEntry("legacy-container")).resolves.toBeNull();
await expect(fs.access(SANDBOX_REGISTRY_PATH)).resolves.toBeUndefined();
await expectPathMissing(path.join(TEST_STATE_DIR, "state", "openclaw.sqlite"));
});
it("normalizes legacy registry entries after explicit migration", async () => {
+44 -27
View File
@@ -3,16 +3,17 @@
*
* Tracks runtime and browser containers in the shared state DB plus migration support for legacy registries.
*/
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import type { Insertable, Selectable, Updateable } from "kysely";
import { z } from "zod";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../../infra/kysely-sync.js";
import { withOpenClawStateDatabaseReadOnly } from "../../state/openclaw-state-db-readonly.js";
import { tableExists } from "../../state/openclaw-state-db-schema-helpers.js";
import type { DB as OpenClawStateKyselyDatabase } from "../../state/openclaw-state-db.generated.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
} from "../../state/openclaw-state-db.js";
import { runOpenClawStateWriteTransaction } from "../../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js";
import { safeParseJsonWithSchema } from "../../utils/zod-parse.js";
import { acquireSessionWriteLock } from "../session-write-lock.js";
import {
@@ -231,35 +232,51 @@ function rowToUpdate(row: SandboxRegistryInsert): SandboxRegistryUpdate {
}
function readRegistryRows(kind: SandboxRegistryKind): SandboxRegistryRow[] {
const { db } = openOpenClawStateDatabase();
const stateDb = getSandboxRegistryKysely(db);
return executeSqliteQuerySync(
db,
stateDb
.selectFrom("sandbox_registry_entries")
.selectAll()
.where("registry_kind", "=", kind)
.orderBy("container_name", "asc"),
).rows;
if (!fsSync.existsSync(resolveOpenClawStateSqlitePath(process.env))) {
return [];
}
// CLI reads must not join the Gateway's writable SQLite lifecycle (#101290).
return withOpenClawStateDatabaseReadOnly(({ db }) => {
if (!tableExists(db, "sandbox_registry_entries")) {
return [];
}
const stateDb = getSandboxRegistryKysely(db);
return executeSqliteQuerySync(
db,
stateDb
.selectFrom("sandbox_registry_entries")
.selectAll()
.where("registry_kind", "=", kind)
.orderBy("container_name", "asc"),
).rows;
});
}
function readRegistryRow(
kind: SandboxRegistryKind,
containerName: string,
): SandboxRegistryRow | null {
const { db } = openOpenClawStateDatabase();
const stateDb = getSandboxRegistryKysely(db);
return (
executeSqliteQuerySync(
db,
stateDb
.selectFrom("sandbox_registry_entries")
.selectAll()
.where("registry_kind", "=", kind)
.where("container_name", "=", containerName)
.limit(1),
).rows[0] ?? null
);
if (!fsSync.existsSync(resolveOpenClawStateSqlitePath(process.env))) {
return null;
}
// CLI reads must not join the Gateway's writable SQLite lifecycle (#101290).
return withOpenClawStateDatabaseReadOnly(({ db }) => {
if (!tableExists(db, "sandbox_registry_entries")) {
return null;
}
const stateDb = getSandboxRegistryKysely(db);
return (
executeSqliteQuerySync(
db,
stateDb
.selectFrom("sandbox_registry_entries")
.selectAll()
.where("registry_kind", "=", kind)
.where("container_name", "=", containerName)
.limit(1),
).rows[0] ?? null
);
});
}
function insertRegistryRowIfMissing(row: SandboxRegistryInsert): void {
+10 -10
View File
@@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({
exportTrajectoryForCommand: vi.fn(),
formatTrajectoryCommandExportSummary: vi.fn(),
getRuntimeConfig: vi.fn(),
loadSessionEntry: vi.fn(),
loadSessionEntryReadOnly: vi.fn(),
resolveStorePath: vi.fn(),
}));
@@ -19,7 +19,7 @@ vi.mock("../config/sessions/session-accessor.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/sessions/session-accessor.js")>();
return {
...actual,
loadSessionEntry: mocks.loadSessionEntry,
loadSessionEntryReadOnly: mocks.loadSessionEntryReadOnly,
};
});
@@ -49,7 +49,7 @@ describe("exportTrajectoryCommand", () => {
vi.clearAllMocks();
mocks.getRuntimeConfig.mockReturnValue({});
mocks.resolveStorePath.mockReturnValue("/tmp/openclaw/sessions.json");
mocks.loadSessionEntry.mockReturnValue(undefined);
mocks.loadSessionEntryReadOnly.mockReturnValue(undefined);
mocks.exportTrajectoryForCommand.mockResolvedValue({
outputDir: "/tmp/workspace/.openclaw/trajectory-exports/export",
displayPath: ".openclaw/trajectory-exports/export",
@@ -101,7 +101,7 @@ describe("exportTrajectoryCommand", () => {
expect(runtime.error).toHaveBeenCalledWith(
"Failed to decode trajectory export request: Encoded trajectory export request is invalid",
);
expect(mocks.loadSessionEntry).not.toHaveBeenCalled();
expect(mocks.loadSessionEntryReadOnly).not.toHaveBeenCalled();
expect(runtime.exit).toHaveBeenCalledWith(1);
},
);
@@ -127,7 +127,7 @@ describe("exportTrajectoryCommand", () => {
expect(mocks.resolveStorePath).toHaveBeenCalledWith("/tmp/direct-store.json", {
agentId: "main",
});
expect(mocks.loadSessionEntry).toHaveBeenCalledWith({
expect(mocks.loadSessionEntryReadOnly).toHaveBeenCalledWith({
agentId: "main",
sessionKey: "agent:main:telegram:direct:123",
storePath: "/tmp/direct-store.json",
@@ -158,7 +158,7 @@ describe("exportTrajectoryCommand", () => {
expect(mocks.getRuntimeConfig).not.toHaveBeenCalled();
expect(mocks.resolveStorePath).toHaveBeenCalledWith(store, { agentId: "work" });
expect(mocks.loadSessionEntry).toHaveBeenCalledWith({
expect(mocks.loadSessionEntryReadOnly).toHaveBeenCalledWith({
agentId: "work",
sessionKey: "agent:work:telegram:direct:123",
storePath: resolvedStore,
@@ -183,7 +183,7 @@ describe("exportTrajectoryCommand", () => {
"/tmp/openclaw/agents/{agentId}/sessions/sessions.json",
{ agentId: "work" },
);
expect(mocks.loadSessionEntry).toHaveBeenCalledWith({
expect(mocks.loadSessionEntryReadOnly).toHaveBeenCalledWith({
agentId: "work",
sessionKey: "agent:work:telegram:direct:123",
storePath: "/tmp/openclaw/agents/work/sessions/sessions.json",
@@ -200,7 +200,7 @@ describe("exportTrajectoryCommand", () => {
await exportTrajectoryCommand({ sessionKey: "agent:main:telegram:direct:123" }, runtime);
expect(mocks.resolveStorePath).toHaveBeenCalledWith(undefined, { agentId: "main" });
expect(mocks.loadSessionEntry).toHaveBeenCalledWith({
expect(mocks.loadSessionEntryReadOnly).toHaveBeenCalledWith({
agentId: "main",
sessionKey: "agent:main:telegram:direct:123",
storePath: "/tmp/openclaw/sessions.json",
@@ -218,7 +218,7 @@ describe("exportTrajectoryCommand", () => {
await exportTrajectoryCommand({ sessionKey: "agent:main:telegram:direct:123" }, runtime);
expect(mocks.resolveStorePath).toHaveBeenCalledWith("", { agentId: "main" });
expect(mocks.loadSessionEntry).toHaveBeenCalledWith({
expect(mocks.loadSessionEntryReadOnly).toHaveBeenCalledWith({
agentId: "main",
sessionKey: "agent:main:telegram:direct:123",
storePath: "/tmp/openclaw/sessions.json",
@@ -232,7 +232,7 @@ describe("exportTrajectoryCommand", () => {
it("exports SQLite marker sessions without probing a transcript JSONL file", async () => {
const runtime = createRuntime();
const sessionFile = "sqlite:main:session-1:/tmp/openclaw/sessions.json";
mocks.loadSessionEntry.mockReturnValue({
mocks.loadSessionEntryReadOnly.mockReturnValue({
sessionId: "session-1",
sessionFile,
updatedAt: 1,
+3 -2
View File
@@ -4,7 +4,7 @@ import { formatCliCommand } from "../cli/command-format.js";
import { getRuntimeConfig } from "../config/config.js";
import { resolveStorePath } from "../config/sessions/paths.js";
import {
loadSessionEntry,
loadSessionEntryReadOnly,
resolveSessionTranscriptReadTarget,
} from "../config/sessions/session-accessor.js";
import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js";
@@ -122,7 +122,8 @@ export async function exportTrajectoryCommand(
const storePath = resolvedOpts.store
? resolveStorePath(resolvedOpts.store, { agentId: targetAgentId })
: resolveStorePath(getRuntimeConfig().session?.store, { agentId: targetAgentId });
const entry = loadSessionEntry({
// CLI reads must not join the Gateway's writable SQLite lifecycle (#101290).
const entry = loadSessionEntryReadOnly({
agentId: targetAgentId,
sessionKey,
storePath,
+30
View File
@@ -5,6 +5,8 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { replaceSessionEntry } from "../config/sessions/session-accessor.js";
import type { SessionEntry } from "../config/sessions/types.js";
import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js";
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import { sandboxExplainCommand } from "./sandbox-explain.js";
const SANDBOX_EXPLAIN_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 30_000;
@@ -21,6 +23,34 @@ vi.mock("../config/config.js", async () => {
});
describe("sandbox explain command", () => {
it("reads a missing session without creating or registering an agent database", async () => {
await withOpenClawTestState({ label: "sandbox-explain-readonly" }, async (state) => {
const agentDatabasePath = state.statePath(
"agents",
"readonly",
"agent",
"openclaw-agent.sqlite",
);
mockCfg = {
agents: {
defaults: { sandbox: { mode: "off" } },
list: [{ id: "readonly", workspace: state.workspaceDir }],
},
session: { store: agentDatabasePath },
};
const stateDatabase = openOpenClawStateDatabase({ env: state.env });
await sandboxExplainCommand({ json: true, agent: "readonly" }, {
log: () => {},
error: () => {},
exit: () => {},
} as unknown as Parameters<typeof sandboxExplainCommand>[1]);
expect(stateDatabase.db.prepare("SELECT agent_id FROM agent_databases").all()).toEqual([]);
await expect(fs.stat(agentDatabasePath)).rejects.toMatchObject({ code: "ENOENT" });
});
});
it("prints JSON shape + fix-it keys", { timeout: SANDBOX_EXPLAIN_TEST_TIMEOUT_MS }, async () => {
mockCfg = {
agents: {
+3 -2
View File
@@ -30,7 +30,7 @@ import {
resolveStorePath,
type SessionEntry,
} from "../config/sessions.js";
import { loadSessionEntry } from "../config/sessions/session-accessor.js";
import { loadSessionEntryReadOnly } from "../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
buildAgentMainSessionKey,
@@ -187,7 +187,8 @@ export async function sandboxExplainCommand(
const storePath = resolveStorePath(cfg.session?.store, {
agentId: resolvedAgentId,
});
const sessionEntry = loadSessionEntry({
// CLI reads must not join the Gateway's writable SQLite lifecycle (#101290).
const sessionEntry = loadSessionEntryReadOnly({
agentId: resolvedAgentId,
sessionKey,
storePath,
@@ -13,6 +13,7 @@ import {
listSqliteSessionEntries,
loadExactSqliteSessionEntry,
loadSqliteSessionEntry,
loadSqliteSessionEntryReadOnly,
patchSqliteSessionEntry,
patchSqliteSessionEntryTarget,
readSqliteSessionUpdatedAt,
@@ -296,6 +297,11 @@ export function loadSessionEntry(scope: SessionAccessScope): SessionEntry | unde
return loadSqliteSessionEntry(scope);
}
/** Returns one session entry without joining the agent database writable lifecycle. */
export function loadSessionEntryReadOnly(scope: SessionAccessScope): SessionEntry | undefined {
return loadSqliteSessionEntryReadOnly(scope);
}
/**
* Returns only the row persisted under the exact key provided.
* Use this for authorization-sensitive routing where alias canonicalization
@@ -39,6 +39,8 @@ import type { SessionEntry } from "./types.js";
// Canonical owner for session_entries row selection, alias snapshots, and writes.
type OpenClawAgentDatabaseReader = Pick<OpenClawAgentDatabase, "db">;
type SessionEntryRow = Selectable<OpenClawAgentKyselyDatabase["session_entries"]>;
export type ResolvedSessionEntryRow = {
entry: SessionEntry;
@@ -82,7 +84,7 @@ export function createSqliteSessionIdentitySnapshot(
}
export function readSessionEntryRow(
database: OpenClawAgentDatabase,
database: OpenClawAgentDatabaseReader,
sessionKey: string,
): ResolvedSessionEntryRow | undefined {
const db = getSessionKysely(database.db);
@@ -157,7 +159,7 @@ export function assertSqliteSessionEntrySelectionUnchanged(
}
export function collectSessionEntryLookupKeys(
database: OpenClawAgentDatabase,
database: OpenClawAgentDatabaseReader,
sessionKey: string,
): string[] {
const trimmedKey = sessionKey.trim();
@@ -184,7 +186,7 @@ export function collectSessionEntryLookupKeys(
}
export function readExactSessionEntryRow(
database: OpenClawAgentDatabase,
database: OpenClawAgentDatabaseReader,
sessionKey: string,
): ResolvedSessionEntryRow | undefined {
const db = getSessionKysely(database.db);
@@ -3,6 +3,7 @@ import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
} from "../../infra/kysely-sync.js";
import { withOpenClawAgentDatabaseReadOnly } from "../../state/openclaw-agent-db-readonly.js";
import {
openOpenClawAgentDatabase,
resolveOpenClawAgentSqlitePath,
@@ -78,6 +79,18 @@ export function loadSqliteSessionEntry(scope: SessionAccessScope): SessionEntry
return readSessionEntryRow(database, resolved.sessionKey)?.entry;
}
/** Loads one session entry without opening its agent database writable. */
export function loadSqliteSessionEntryReadOnly(
scope: SessionAccessScope,
): SessionEntry | undefined {
const resolved = resolveSqliteScope(scope);
const result = withOpenClawAgentDatabaseReadOnly(
(database) => readSessionEntryRow(database, resolved.sessionKey)?.entry,
toDatabaseOptions(resolved),
);
return result.found ? result.value : undefined;
}
/** Loads one exact persisted-key entry from the additive SQLite session store. */
export function loadExactSqliteSessionEntry(
scope: SessionAccessScope,
@@ -5,6 +5,7 @@ export {
listSqliteSessionTranscriptInstances,
loadExactSqliteSessionEntry,
loadSqliteSessionEntry,
loadSqliteSessionEntryReadOnly,
patchSqliteSessionEntry,
patchSqliteSessionEntryTarget,
readSqliteSessionUpdatedAt,
+1
View File
@@ -114,6 +114,7 @@ export {
listSessionEntries,
loadExactSessionEntry,
loadSessionEntry,
loadSessionEntryReadOnly,
openSessionEntryReadView,
patchSessionEntry,
patchSessionEntryTarget,
+12
View File
@@ -1,3 +1,4 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
@@ -45,6 +46,17 @@ describe("fleet cell registry", () => {
};
}
it("returns empty reads without creating state on a fresh install", () => {
if (!root) {
throw new Error("test root not initialized");
}
const databasePath = path.join(root, "state", "openclaw.sqlite");
expect(listFleetCells(env)).toEqual([]);
expect(getFleetCell(env, "missing")).toBeUndefined();
expect(fs.existsSync(databasePath)).toBe(false);
});
it("persists, orders, updates, and deletes cells", () => {
const zulu = reserveFleetCell(env, {
...params("zulu", 19_250),
+38 -15
View File
@@ -1,4 +1,5 @@
import crypto from "node:crypto";
import fs from "node:fs";
import type { DatabaseSync } from "node:sqlite";
import type { Insertable, Selectable } from "kysely";
import {
@@ -6,11 +7,11 @@ import {
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../infra/kysely-sync.js";
import { withOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js";
import { tableExists } from "../state/openclaw-state-db-schema-helpers.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
} from "../state/openclaw-state-db.js";
import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
import { allocateHostPort } from "./cell-profile.js";
export type FleetCellRecord = {
@@ -86,24 +87,46 @@ function recordToRow(record: FleetCellRecord): Insertable<FleetCellsTable> {
}
export function listFleetCells(env: NodeJS.ProcessEnv = process.env): FleetCellRecord[] {
const db = openOpenClawStateDatabase({ env }).db;
const rows = executeSqliteQuerySync(
db,
kyselyFor(db).selectFrom("fleet_cells").selectAll().orderBy("tenant_id", "asc"),
).rows;
return rows.map(rowToRecord);
if (!fs.existsSync(resolveOpenClawStateSqlitePath(env))) {
return [];
}
// CLI reads must not join the Gateway's writable SQLite lifecycle (#101290).
return withOpenClawStateDatabaseReadOnly(
({ db }) => {
if (!tableExists(db, "fleet_cells")) {
return [];
}
const rows = executeSqliteQuerySync(
db,
kyselyFor(db).selectFrom("fleet_cells").selectAll().orderBy("tenant_id", "asc"),
).rows;
return rows.map(rowToRecord);
},
{ env },
);
}
export function getFleetCell(
env: NodeJS.ProcessEnv,
tenantId: string,
): FleetCellRecord | undefined {
const db = openOpenClawStateDatabase({ env }).db;
const row = executeSqliteQueryTakeFirstSync(
db,
kyselyFor(db).selectFrom("fleet_cells").selectAll().where("tenant_id", "=", tenantId),
if (!fs.existsSync(resolveOpenClawStateSqlitePath(env))) {
return undefined;
}
// CLI reads must not join the Gateway's writable SQLite lifecycle (#101290).
return withOpenClawStateDatabaseReadOnly(
({ db }) => {
if (!tableExists(db, "fleet_cells")) {
return undefined;
}
const row = executeSqliteQueryTakeFirstSync(
db,
kyselyFor(db).selectFrom("fleet_cells").selectAll().where("tenant_id", "=", tenantId),
);
return row ? rowToRecord(row) : undefined;
},
{ env },
);
return row ? rowToRecord(row) : undefined;
}
export function reserveFleetCell(
@@ -1,3 +1,4 @@
import fs from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import {
@@ -35,6 +36,7 @@ describe("onboarding recommendations store", () => {
const inventory = [{ label: "Chat", bundleId: "com.example.chat" }];
expect(readOnboardingRecommendations(database)).toBeNull();
expect(fs.existsSync(state.statePath("state", "openclaw.sqlite"))).toBe(false);
const written = writeOnboardingRecommendationsOffer({
inventory,
matches,
+32 -26
View File
@@ -6,9 +6,10 @@ import {
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../infra/kysely-sync.js";
import { withOpenClawStateDatabaseReadOnly } from "./openclaw-state-db-readonly.js";
import { tableExists } from "./openclaw-state-db-schema-helpers.js";
import type { DB as OpenClawStateKyselyDatabase } from "./openclaw-state-db.generated.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
type OpenClawStateDatabaseOptions,
} from "./openclaw-state-db.js";
@@ -80,31 +81,36 @@ export function readOnboardingRecommendations(
if (!existsSync(pathname)) {
return null;
}
const database = openOpenClawStateDatabase(options);
const db = getNodeSqliteKysely<OnboardingRecommendationsDatabase>(database.db);
const row = executeSqliteQueryTakeFirstSync(
database.db,
db
.selectFrom("onboarding_recommendations")
.select([
"inventory_hash",
"matches_json",
"offered_at_ms",
"accepted_at_ms",
"updated_at_ms",
])
.where("config_key", "=", ONBOARDING_RECOMMENDATIONS_KEY),
);
if (!row) {
return null;
}
return {
inventoryHash: row.inventory_hash,
matches: OnboardingRecommendationMatchesSchema.parse(JSON.parse(row.matches_json)),
offeredAt: row.offered_at_ms,
acceptedAt: row.accepted_at_ms,
updatedAt: row.updated_at_ms,
};
// CLI reads must not join the Gateway's writable SQLite lifecycle (#101290).
return withOpenClawStateDatabaseReadOnly(({ db: database }) => {
if (!tableExists(database, "onboarding_recommendations")) {
return null;
}
const db = getNodeSqliteKysely<OnboardingRecommendationsDatabase>(database);
const row = executeSqliteQueryTakeFirstSync(
database,
db
.selectFrom("onboarding_recommendations")
.select([
"inventory_hash",
"matches_json",
"offered_at_ms",
"accepted_at_ms",
"updated_at_ms",
])
.where("config_key", "=", ONBOARDING_RECOMMENDATIONS_KEY),
);
if (!row) {
return null;
}
return {
inventoryHash: row.inventory_hash,
matches: OnboardingRecommendationMatchesSchema.parse(JSON.parse(row.matches_json)),
offeredAt: row.offered_at_ms,
acceptedAt: row.accepted_at_ms,
updatedAt: row.updated_at_ms,
};
}, options);
}
export function writeOnboardingRecommendationsOffer(params: {
+65
View File
@@ -0,0 +1,65 @@
import fs from "node:fs";
import type { DatabaseSync } from "node:sqlite";
import { clearNodeSqliteKyselyCacheForDatabase } from "../infra/kysely-sync.js";
import { requireNodeSqlite } from "../infra/node-sqlite.js";
import { normalizeAgentId } from "../routing/session-key.js";
import type { OpenClawAgentDatabaseOptions } from "./openclaw-agent-db-contract.js";
import {
assertExistingAgentSchemaOwner,
assertSupportedAgentSchemaVersion,
readExistingAgentSchemaMeta,
} from "./openclaw-agent-db-schema-helpers.js";
import { resolveOpenClawAgentSqlitePath } from "./openclaw-agent-db.paths.js";
import { OPENCLAW_SQLITE_BUSY_TIMEOUT_MS } from "./openclaw-state-db.js";
type OpenClawAgentReadOnlyDatabase = {
agentId: string;
db: DatabaseSync;
path: string;
};
type OpenClawAgentDatabaseReadOnlyResult<T> =
| { found: true; value: T }
| { found: false; reason: "database-missing" | "schema-missing" | "table-missing" };
function isMissingTableError(error: unknown): boolean {
return (
error instanceof Error &&
(error as NodeJS.ErrnoException).code === "ERR_SQLITE_ERROR" &&
/\bno such table:/iu.test(error.message)
);
}
/** Read agent state without creating, registering, migrating, or joining its writable lifecycle. */
export function withOpenClawAgentDatabaseReadOnly<T>(
operation: (database: OpenClawAgentReadOnlyDatabase) => T,
options: OpenClawAgentDatabaseOptions,
): OpenClawAgentDatabaseReadOnlyResult<T> {
const agentId = normalizeAgentId(options.agentId);
const pathname = resolveOpenClawAgentSqlitePath({ ...options, agentId });
if (!fs.existsSync(pathname)) {
return { found: false, reason: "database-missing" };
}
const sqlite = requireNodeSqlite();
const db = new sqlite.DatabaseSync(pathname, { readOnly: true });
try {
db.exec(`PRAGMA busy_timeout = ${OPENCLAW_SQLITE_BUSY_TIMEOUT_MS};`);
assertSupportedAgentSchemaVersion(db, pathname);
const schemaMeta = readExistingAgentSchemaMeta(db);
if (!schemaMeta) {
return { found: false, reason: "schema-missing" };
}
assertExistingAgentSchemaOwner(schemaMeta, agentId, pathname);
try {
return { found: true, value: operation({ agentId, db, path: pathname }) };
} catch (error) {
if (isMissingTableError(error)) {
return { found: false, reason: "table-missing" };
}
throw error;
}
} finally {
clearNodeSqliteKyselyCacheForDatabase(db);
db.close();
}
}
+55
View File
@@ -14,6 +14,7 @@ import { requireNodeSqlite } from "../infra/node-sqlite.js";
import { listOpenFileDescriptorsForPath } from "../infra/open-file-descriptors.test-support.js";
import { readSqliteNumberPragma } from "../infra/sqlite-pragma.test-support.js";
import { VERSION } from "../version.js";
import { withOpenClawAgentDatabaseReadOnly } from "./openclaw-agent-db-readonly.js";
import type { DB as OpenClawAgentKyselyDatabase } from "./openclaw-agent-db.generated.js";
import {
assertOpenClawAgentDatabaseForMaintenance,
@@ -434,6 +435,60 @@ describe("openclaw agent database", () => {
);
});
it("returns typed not-found without creating a missing read-only database", () => {
const stateDir = createTempStateDir();
const options = {
agentId: "worker-1",
env: { OPENCLAW_STATE_DIR: stateDir },
};
const databasePath = resolveOpenClawAgentSqlitePath(options);
expect(withOpenClawAgentDatabaseReadOnly(() => "unused", options)).toEqual({
found: false,
reason: "database-missing",
});
expect(fs.existsSync(databasePath)).toBe(false);
});
it("refuses a newer schema from the read-only database helper", () => {
const stateDir = createTempStateDir();
const options = {
agentId: "worker-1",
env: { OPENCLAW_STATE_DIR: stateDir },
};
const databasePath = resolveOpenClawAgentSqlitePath(options);
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
const { DatabaseSync } = requireNodeSqlite();
const database = new DatabaseSync(databasePath);
database.exec(`PRAGMA user_version = ${OPENCLAW_AGENT_SCHEMA_VERSION + 1};`);
database.close();
expect(() => withOpenClawAgentDatabaseReadOnly(() => "unused", options)).toThrow(
`newer schema version ${OPENCLAW_AGENT_SCHEMA_VERSION + 1}`,
);
});
it("returns typed not-found when a read-only query targets a missing table", () => {
const stateDir = createTempStateDir();
const options = {
agentId: "worker-1",
env: { OPENCLAW_STATE_DIR: stateDir },
};
const databasePath = openOpenClawAgentDatabase(options).path;
closeOpenClawAgentDatabasesForTest();
const { DatabaseSync } = requireNodeSqlite();
const database = new DatabaseSync(databasePath);
database.exec("DROP TABLE session_entries;");
database.close();
expect(
withOpenClawAgentDatabaseReadOnly(
({ db }) => db.prepare("SELECT * FROM session_entries").all(),
options,
),
).toEqual({ found: false, reason: "table-missing" });
});
it("lists a missing registry without creating the shared state database", () => {
const stateDir = createTempStateDir();
const env = { OPENCLAW_STATE_DIR: stateDir };
+9
View File
@@ -26,6 +26,15 @@ afterEach(async () => {
});
describe("tui last session state", () => {
it("returns no remembered session without creating state on a fresh install", async () => {
const stateDir = await makeTempStateDir();
await expect(readTuiLastSessionKey({ scopeKey: "missing", stateDir })).resolves.toBeNull();
await expect(fs.stat(path.join(stateDir, "state", "openclaw.sqlite"))).rejects.toMatchObject({
code: "ENOENT",
});
});
it("persists the last session under a scoped hashed key", async () => {
const stateDir = await makeTempStateDir();
const scopeKey = buildTuiLastSessionScopeKey({
+24 -14
View File
@@ -1,16 +1,17 @@
// Stores and resolves the last TUI session per workspace.
import { createHash } from "node:crypto";
import fs from "node:fs";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../infra/kysely-sync.js";
import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
import { withOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js";
import { tableExists } from "../state/openclaw-state-db-schema-helpers.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
} from "../state/openclaw-state-db.js";
import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
import type { TuiSessionList } from "./tui-backend.js";
import type { SessionScope } from "./tui-types.js";
@@ -66,16 +67,25 @@ export async function readTuiLastSessionKey(params: {
scopeKey: string;
stateDir?: string;
}): Promise<string | null> {
const database = openOpenClawStateDatabase(stateDatabaseOptions(params.stateDir));
const row = executeSqliteQueryTakeFirstSync(
database.db,
getNodeSqliteKysely<TuiLastSessionDatabase>(database.db)
.selectFrom("tui_last_sessions")
.select("session_key")
.where("scope_key", "=", params.scopeKey),
);
const sessionKey = row?.session_key.trim() ?? "";
return sessionKey && !isHeartbeatSessionKey(sessionKey) ? sessionKey : null;
const options = stateDatabaseOptions(params.stateDir);
if (!fs.existsSync(resolveOpenClawStateSqlitePath(options.env))) {
return null;
}
// CLI reads must not join the Gateway's writable SQLite lifecycle (#101290).
return withOpenClawStateDatabaseReadOnly(({ db }) => {
if (!tableExists(db, "tui_last_sessions")) {
return null;
}
const row = executeSqliteQueryTakeFirstSync(
db,
getNodeSqliteKysely<TuiLastSessionDatabase>(db)
.selectFrom("tui_last_sessions")
.select("session_key")
.where("scope_key", "=", params.scopeKey),
);
const sessionKey = row?.session_key.trim() ?? "";
return sessionKey && !isHeartbeatSessionKey(sessionKey) ? sessionKey : null;
}, options);
}
/** Writes the remembered session key unless it is empty, unknown, or heartbeat-owned. */