mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(cli): read-only database access for pure-read commands (#110732)
This commit is contained in:
committed by
GitHub
parent
593423ecc0
commit
d8846a1dcb
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -114,6 +114,7 @@ export {
|
||||
listSessionEntries,
|
||||
loadExactSessionEntry,
|
||||
loadSessionEntry,
|
||||
loadSessionEntryReadOnly,
|
||||
openSessionEntryReadView,
|
||||
patchSessionEntry,
|
||||
patchSessionEntryTarget,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user