diff --git a/apps/macos/Tests/OpenClawIPCTests/PortGuardianRecordStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/PortGuardianRecordStoreTests.swift index e71a1b8dd61c..3b296d3ffd74 100644 --- a/apps/macos/Tests/OpenClawIPCTests/PortGuardianRecordStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/PortGuardianRecordStoreTests.swift @@ -532,7 +532,7 @@ struct PortGuardianRecordStoreTests { let fixture = try Self.fixture() defer { fixture.cleanup() } - for version in [4, 5] { + for version in [4, 5, 6] { let databaseURL = fixture.root.appendingPathComponent("supported-v\(version).sqlite") try Self.seedVersionedPortGuardianDatabase(databaseURL, schemaVersion: version) let store = try PortGuardianRecordStore(databaseURL: databaseURL) @@ -544,7 +544,7 @@ struct PortGuardianRecordStoreTests { #expect(try store.records() == [record]) } - for version in [6, 99] { + for version in [7, 99] { let databaseURL = fixture.root.appendingPathComponent("newer-v\(version).sqlite") try Self.seedVersionedPortGuardianDatabase(databaseURL, schemaVersion: version) #expect(throws: PortGuardianStoreError.self) { @@ -592,7 +592,7 @@ struct PortGuardianRecordStoreTests { try store.upsert(existing) try JSONEncoder().encode([existing]).write(to: fixture.legacyURL, options: [.atomic]) - try Self.execute(fixture.databaseURL, "PRAGMA user_version = 6") + try Self.execute(fixture.databaseURL, "PRAGMA user_version = 7") #expect(throws: PortGuardianStoreError.self) { try store.upsert(Self.record(pid: 4243, port: 18790, timestamp: 43)) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift b/apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift index d5025e467b5e..a9784b4b92d4 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift @@ -35,7 +35,7 @@ public enum OpenClawNativeStateSQLiteValueType: Equatable, Sendable { /// One recursive connection lock serializes transactions and statement access. public final class OpenClawNativeStateSQLite: @unchecked Sendable { // Keep aligned with OPENCLAW_STATE_SCHEMA_VERSION. Native clients never upgrade this database. - private static let maximumSupportedSchemaVersion: Int64 = 5 + private static let maximumSupportedSchemaVersion: Int64 = 6 private static let defaultBusyTimeoutMilliseconds: Int32 = 5000 private struct SchemaObject: Hashable { diff --git a/package.json b/package.json index 079efe9c28ed..1a8502368838 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,8 @@ "version": "2026.7.2", "openclaw": { "schemaVersions": { - "state": 5, - "agent": 14 + "state": 6, + "agent": 15 } }, "description": "Multi-channel AI gateway with extensible messaging integrations", diff --git a/src/agents/worktrees/registry.test.ts b/src/agents/worktrees/registry.test.ts index 2645e1bfcd3a..6761db9ce0a0 100644 --- a/src/agents/worktrees/registry.test.ts +++ b/src/agents/worktrees/registry.test.ts @@ -130,7 +130,11 @@ describe("managed worktree registry", () => { closeOpenClawStateDatabaseForTest(); const { DatabaseSync } = requireNodeSqlite(); const legacy = new DatabaseSync(databasePath); - legacy.exec("ALTER TABLE worktrees DROP COLUMN provisioned_paths_json"); + legacy.exec(` + ALTER TABLE worktrees DROP COLUMN provisioned_paths_json; + PRAGMA user_version = 5; + UPDATE schema_meta SET schema_version = 5 WHERE meta_key = 'primary'; + `); legacy.close(); expect(getRegistryWorktreeProvisionedPaths(env, "missing")).toBeUndefined(); diff --git a/src/boards/board-store.parity.test.ts b/src/boards/board-store.parity.test.ts index 6a7eef43ecda..c382bde84274 100644 --- a/src/boards/board-store.parity.test.ts +++ b/src/boards/board-store.parity.test.ts @@ -510,7 +510,7 @@ describe("SqliteBoardStore persistence", () => { expect(store.readWidgetMcpApp(sessionKey, "legacy-app")).toBeUndefined(); }); - it("lazily creates board tables for an existing v14 database", () => { + it("migrates board tables into an existing v14 database", () => { const stateDir = tempDirs.make("openclaw-board-lazy-schema-"); const env = { OPENCLAW_STATE_DIR: stateDir }; const sessionKey = "agent:main:board"; @@ -535,13 +535,12 @@ describe("SqliteBoardStore persistence", () => { reopened.db .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'board_tabs'") .get(), - ).toBeUndefined(); + ).toEqual({ name: "board_tabs" }); const store = new SqliteBoardStore({ resolveSession: () => ({ agentId: "main", sessionKey }), env, }); - // Reads before any write must see "no boards", not "no such table". expect(store.getSnapshot(sessionKey)).toMatchObject({ revision: 0, tabs: [], widgets: [] }); expect(store.readWidgetHtml(sessionKey, "status")).toBeUndefined(); expect(store.listSessionsWithBoards()).toEqual([]); @@ -579,7 +578,7 @@ describe("SqliteBoardStore persistence", () => { ).toEqual({ name: "idx_agent_board_widgets_tab_position" }); }); - it("upgrades the unreleased v13 board constraint before storing plugin widgets", () => { + it("upgrades the v14 board constraint before storing plugin widgets", () => { const stateDir = tempDirs.make("openclaw-board-plugin-kind-schema-"); const env = { OPENCLAW_STATE_DIR: stateDir }; const sessionKey = "agent:main:board"; @@ -622,6 +621,8 @@ describe("SqliteBoardStore persistence", () => { ON board_widgets(session_key, tab_id, position); COMMIT; PRAGMA foreign_keys = ON; + PRAGMA user_version = 14; + UPDATE schema_meta SET schema_version = 14 WHERE meta_key = 'primary'; `); closeOpenClawAgentDatabasesForTest(); diff --git a/src/config/sessions/session-sharing-store.test.ts b/src/config/sessions/session-sharing-store.test.ts index 5d4a897ecae8..6ffaefb3286a 100644 --- a/src/config/sessions/session-sharing-store.test.ts +++ b/src/config/sessions/session-sharing-store.test.ts @@ -20,7 +20,7 @@ import { afterEach(() => closeOpenClawAgentDatabasesForTest()); describe("session sharing store", () => { - it("lazily ensures the additive membership table and keeps deterministic rows", async () => { + it("keeps deterministic membership rows", async () => { await withTempDir({ prefix: "openclaw-session-sharing-" }, async (dir) => { const env = { ...process.env, OPENCLAW_STATE_DIR: dir }; const scope = { agentId: "main", env, sessionKey: "agent:main:main" }; @@ -30,13 +30,6 @@ describe("session sharing store", () => { visibility: "shared", }); expect(loadSessionEntry(scope)?.visibility).toBe("shared"); - const database = openOpenClawAgentDatabase({ agentId: "main", env }); - database.db.exec("DROP TABLE session_members;"); - expect( - database.db - .prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'session_members'") - .get(), - ).toBeUndefined(); expect(listSessionMembers(scope)).toEqual([]); expect( @@ -67,6 +60,23 @@ describe("session sharing store", () => { }); }); + it("does not recreate a missing canonical membership table", async () => { + await withTempDir({ prefix: "openclaw-session-sharing-missing-" }, async (dir) => { + const env = { ...process.env, OPENCLAW_STATE_DIR: dir }; + const scope = { agentId: "main", env, sessionKey: "agent:main:main" }; + await upsertSessionEntry(scope, { sessionId: "session-main", updatedAt: 1 }); + const database = openOpenClawAgentDatabase({ agentId: "main", env }); + database.db.exec("DROP TABLE session_members;"); + + expect(() => listSessionMembers(scope)).toThrow(/no such table: session_members/); + expect( + database.db + .prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'session_members'") + .get(), + ).toBeUndefined(); + }); + }); + it("refuses member writes whose expected session instance no longer matches", async () => { await withTempDir({ prefix: "openclaw-session-sharing-instance-" }, async (dir) => { const env = { ...process.env, OPENCLAW_STATE_DIR: dir }; diff --git a/src/config/sessions/session-sharing-store.ts b/src/config/sessions/session-sharing-store.ts index 5f1d90727d92..259bc7ba177f 100644 --- a/src/config/sessions/session-sharing-store.ts +++ b/src/config/sessions/session-sharing-store.ts @@ -1,4 +1,3 @@ -import type { DatabaseSync } from "node:sqlite"; import { executeSqliteQuerySync, executeSqliteQueryTakeFirstSync, @@ -11,7 +10,6 @@ import { type OpenClawAgentDatabase, type OpenClawAgentDatabaseOptions, } from "../../state/openclaw-agent-db.js"; -import { ensureOpenClawAgentSessionSharingSchemaInTransaction } from "../../state/openclaw-agent-session-sharing-schema.js"; import type { SessionAccessScope } from "./session-accessor.sqlite-contract.js"; import { resolveSqliteScope, toDatabaseOptions } from "./session-accessor.sqlite-scope.js"; @@ -23,31 +21,18 @@ type SessionMember = { addedAt: number; }; -const ensuredDatabases = new WeakSet(); const SESSION_MEMBERSHIP_QUERY_CHUNK_SIZE = 400; function resolveDatabaseOptions(scope: SessionAccessScope): OpenClawAgentDatabaseOptions { return toDatabaseOptions(resolveSqliteScope(scope)); } -function ensureSessionSharingSchema(options: OpenClawAgentDatabaseOptions): OpenClawAgentDatabase { - const database = openOpenClawAgentDatabase(options); - if (ensuredDatabases.has(database.db)) { - return database; - } - runOpenClawAgentWriteTransaction((transactionDatabase) => { - ensureOpenClawAgentSessionSharingSchemaInTransaction(transactionDatabase.db); - }, options); - ensuredDatabases.add(database.db); - return database; -} - function getSessionMemberKysely(database: OpenClawAgentDatabase) { return getNodeSqliteKysely(database.db); } export function listSessionMembers(scope: SessionAccessScope): SessionMember[] { - const database = ensureSessionSharingSchema(resolveDatabaseOptions(scope)); + const database = openOpenClawAgentDatabase(resolveDatabaseOptions(scope)); const db = getSessionMemberKysely(database); return executeSqliteQuerySync( database.db, @@ -73,7 +58,7 @@ export function listSessionMembershipKeys( if (!normalizedIdentityId || normalizedSessionKeys.length === 0) { return new Set(); } - const database = ensureSessionSharingSchema(resolveDatabaseOptions(scope)); + const database = openOpenClawAgentDatabase(resolveDatabaseOptions(scope)); const db = getSessionMemberKysely(database); const memberships = new Set(); for ( @@ -102,7 +87,7 @@ export function isSessionMember(scope: SessionAccessScope, identityId: string): if (!normalizedIdentityId) { return false; } - const database = ensureSessionSharingSchema(resolveDatabaseOptions(scope)); + const database = openOpenClawAgentDatabase(resolveDatabaseOptions(scope)); const db = getSessionMemberKysely(database); return Boolean( executeSqliteQueryTakeFirstSync( @@ -160,7 +145,6 @@ export function addSessionMember( throw new Error("session member identity and actor are required"); } const options = resolveDatabaseOptions(scope); - ensureSessionSharingSchema(options); const addedAt = params.addedAt ?? Date.now(); const inserted = runOpenClawAgentWriteTransaction((database) => { assertAuthorizedSessionInstance( @@ -197,7 +181,6 @@ export function removeSessionMember( return null; } const options = resolveDatabaseOptions(scope); - ensureSessionSharingSchema(options); return runOpenClawAgentWriteTransaction((database) => { assertAuthorizedSessionInstance( database, diff --git a/src/config/sessions/session-suggestion-store.test.ts b/src/config/sessions/session-suggestion-store.test.ts index aa28e0e66d5c..7bd60922a2e8 100644 --- a/src/config/sessions/session-suggestion-store.test.ts +++ b/src/config/sessions/session-suggestion-store.test.ts @@ -40,13 +40,11 @@ function resolvePendingSuggestion(params: { afterEach(() => closeOpenClawAgentDatabasesForTest()); describe("session suggestion store", () => { - it("lazily ensures deterministic rows and resolves only pending suggestions", async () => { + it("keeps deterministic rows and resolves only pending suggestions", async () => { await withTempDir({ prefix: "openclaw-session-suggestions-" }, async (dir) => { const env = { ...process.env, OPENCLAW_STATE_DIR: dir }; const scope = { agentId: "main", env, sessionKey: "agent:main:main" }; await upsertSessionEntry(scope, { sessionId: "session-a", updatedAt: 1 }); - const database = openOpenClawAgentDatabase({ agentId: "main", env }); - database.db.exec("DROP TABLE session_suggestions;"); expect(listSessionSuggestions(scope)).toEqual([]); addSessionSuggestion(scope, { @@ -91,6 +89,25 @@ describe("session suggestion store", () => { }); }); + it("does not recreate a missing canonical suggestions table", async () => { + await withTempDir({ prefix: "openclaw-session-suggestions-missing-" }, async (dir) => { + const env = { ...process.env, OPENCLAW_STATE_DIR: dir }; + const scope = { agentId: "main", env, sessionKey: "agent:main:main" }; + await upsertSessionEntry(scope, { sessionId: "session-a", updatedAt: 1 }); + const database = openOpenClawAgentDatabase({ agentId: "main", env }); + database.db.exec("DROP TABLE session_suggestions;"); + + expect(() => listSessionSuggestions(scope)).toThrow(/no such table: session_suggestions/); + expect( + database.db + .prepare( + "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'session_suggestions'", + ) + .get(), + ).toBeUndefined(); + }); + }); + it("binds writes to the session instance and clears rows on replacement", async () => { await withTempDir({ prefix: "openclaw-session-suggestions-reset-" }, async (dir) => { const env = { ...process.env, OPENCLAW_STATE_DIR: dir }; diff --git a/src/config/sessions/session-suggestion-store.ts b/src/config/sessions/session-suggestion-store.ts index 33bb11dc14e1..6494721df438 100644 --- a/src/config/sessions/session-suggestion-store.ts +++ b/src/config/sessions/session-suggestion-store.ts @@ -1,5 +1,4 @@ import { randomUUID } from "node:crypto"; -import type { DatabaseSync } from "node:sqlite"; import { executeSqliteQuerySync, executeSqliteQueryTakeFirstSync, @@ -12,7 +11,6 @@ import { type OpenClawAgentDatabase, type OpenClawAgentDatabaseOptions, } from "../../state/openclaw-agent-db.js"; -import { ensureOpenClawAgentSessionSharingSchemaInTransaction } from "../../state/openclaw-agent-session-sharing-schema.js"; import { SessionWorkStartInvalidatedError } from "./lifecycle.js"; import type { SessionAccessScope } from "./session-accessor.sqlite-contract.js"; import { resolveSqliteScope, toDatabaseOptions } from "./session-accessor.sqlite-scope.js"; @@ -31,7 +29,6 @@ export type StoredSessionSuggestion = { state: StoredSessionSuggestionState; }; -const ensuredDatabases = new WeakSet(); const MAX_PENDING_SESSION_SUGGESTIONS_PER_AUTHOR = 20; const MAX_PENDING_SESSION_SUGGESTIONS_PER_SESSION = 100; const MAX_RETAINED_RESOLVED_SESSION_SUGGESTIONS = 200; @@ -41,18 +38,6 @@ function resolveDatabaseOptions(scope: SessionAccessScope): OpenClawAgentDatabas return toDatabaseOptions(resolveSqliteScope(scope)); } -function ensureSuggestionSchema(options: OpenClawAgentDatabaseOptions): OpenClawAgentDatabase { - const database = openOpenClawAgentDatabase(options); - if (ensuredDatabases.has(database.db)) { - return database; - } - runOpenClawAgentWriteTransaction((transactionDatabase) => { - ensureOpenClawAgentSessionSharingSchemaInTransaction(transactionDatabase.db); - }, options); - ensuredDatabases.add(database.db); - return database; -} - function suggestionDb(database: OpenClawAgentDatabase) { return getNodeSqliteKysely(database.db); } @@ -154,7 +139,6 @@ export function addSessionSuggestion( throw new Error("suggestion author and text are required"); } const options = resolveDatabaseOptions(scope); - ensureSuggestionSchema(options); const sessionKey = resolveSqliteScope(scope).sessionKey; const suggestion: StoredSessionSuggestion = { id: params.id ?? randomUUID(), @@ -209,7 +193,7 @@ export function listSessionSuggestions( params: { authorId?: string; pendingOnly?: boolean } = {}, ): StoredSessionSuggestion[] { const options = resolveDatabaseOptions(scope); - const database = ensureSuggestionSchema(options); + const database = openOpenClawAgentDatabase(options); const sessionKey = resolveSqliteScope(scope).sessionKey; let query = suggestionDb(database) .selectFrom("session_suggestions") @@ -243,7 +227,6 @@ export function claimSessionSuggestionDispatch( }, ): SessionSuggestionDispatchClaim | null { const options = resolveDatabaseOptions(scope); - ensureSuggestionSchema(options); const sessionKey = resolveSqliteScope(scope).sessionKey; return runOpenClawAgentWriteTransaction((database) => { assertSessionInstance(database, sessionKey, params.expectedSessionId); @@ -308,7 +291,6 @@ export function releaseSessionSuggestionDispatch( params: { id: string; token: string; expectedSessionId?: string }, ): boolean { const options = resolveDatabaseOptions(scope); - ensureSuggestionSchema(options); const sessionKey = resolveSqliteScope(scope).sessionKey; return runOpenClawAgentWriteTransaction((database) => { assertSessionInstance(database, sessionKey, params.expectedSessionId); @@ -336,7 +318,6 @@ export function finalizeSessionSuggestionClaim( }, ): StoredSessionSuggestion | null { const options = resolveDatabaseOptions(scope); - ensureSuggestionSchema(options); const sessionKey = resolveSqliteScope(scope).sessionKey; return runOpenClawAgentWriteTransaction((database) => { assertSessionInstance(database, sessionKey, params.expectedSessionId); diff --git a/src/infra/sqlite-schema-contract.ts b/src/infra/sqlite-schema-contract.ts index e0ba573fe42d..10b63cad1db0 100644 --- a/src/infra/sqlite-schema-contract.ts +++ b/src/infra/sqlite-schema-contract.ts @@ -179,16 +179,42 @@ export function assertSqliteSchemaContains( } if (mismatches.length > 0) { - const shown = mismatches.slice(0, 8); - if (mismatches.length > shown.length) { - shown.push(`${mismatches.length - shown.length} additional mismatch(es)`); - } - throw new Error( - `SQLite schema is incomplete or noncanonical for ${databaseLabel}: ${shown.join("; ")}`, - ); + throwSqliteSchemaMismatches(databaseLabel, mismatches); } } +/** Require stable canonical tables before a version-specific additive migration. */ +export function assertSqliteSchemaTablesPresent( + database: DatabaseSync, + databaseLabel: string, + schemaSql: string, + options: { allowedMissingTables?: readonly string[] } = {}, +): void { + const allowedMissingTables = new Set(options.allowedMissingTables ?? []); + const missingTables = getCanonicalSqliteTableNames(schemaSql) + .filter((tableName) => !allowedMissingTables.has(tableName)) + .filter( + (tableName) => + !database + .prepare("SELECT 1 FROM main.sqlite_schema WHERE type = 'table' AND name = ? LIMIT 1") + .get(tableName), + ) + .map((tableName) => `missing table ${tableName}`); + if (missingTables.length > 0) { + throwSqliteSchemaMismatches(databaseLabel, missingTables); + } +} + +function throwSqliteSchemaMismatches(databaseLabel: string, mismatches: string[]): never { + const shown = mismatches.slice(0, 8); + if (mismatches.length > shown.length) { + shown.push(`${mismatches.length - shown.length} additional mismatch(es)`); + } + throw new Error( + `SQLite schema is incomplete or noncanonical for ${databaseLabel}: ${shown.join("; ")}`, + ); +} + /** Return every explicit named index owned by one committed schema. */ export function getCanonicalSqliteNamedIndexContracts( schemaSql: string, diff --git a/src/node-host/config.test.ts b/src/node-host/config.test.ts index 9b25a08e2597..37f3d346edc0 100644 --- a/src/node-host/config.test.ts +++ b/src/node-host/config.test.ts @@ -210,7 +210,11 @@ describe("node-host SQLite config", () => { it("adds the gateway context-path column to an existing state database", async () => { const { env } = makeTestEnv(); const database = openOpenClawStateDatabase({ env }); - database.db.exec("ALTER TABLE node_host_config DROP COLUMN gateway_context_path"); + database.db.exec(` + ALTER TABLE node_host_config DROP COLUMN gateway_context_path; + PRAGMA user_version = 5; + UPDATE schema_meta SET schema_version = 5 WHERE meta_key = 'primary'; + `); closeOpenClawStateDatabaseForTest(); const configured = await configureNodeHost({ diff --git a/src/state/openclaw-agent-board-schema.ts b/src/state/openclaw-agent-board-schema.ts index 876aef4ed310..0f9a758f8b20 100644 --- a/src/state/openclaw-agent-board-schema.ts +++ b/src/state/openclaw-agent-board-schema.ts @@ -26,6 +26,7 @@ function splitBoardSchema(sql: string): { board: string; withoutBoard: string } const boardSchema = splitBoardSchema(OPENCLAW_AGENT_SCHEMA_SQL); const OPENCLAW_AGENT_BOARD_SCHEMA_SQL = boardSchema.board; +export const AGENT_V14_BOARD_SCHEMA_SQL = OPENCLAW_AGENT_BOARD_SCHEMA_SQL; export const OPENCLAW_AGENT_SCHEMA_WITHOUT_BOARD_SQL = boardSchema.withoutBoard; function canonicalBoardWidgetsCreateSql(): string { @@ -59,10 +60,7 @@ function normalizeBoardWidgetsCreateSql(sql: string): string { .trim(); } -/** - * Repairs the unreleased v13 board table shape without advancing the agent DB version. - * Delete this same-version bridge when the lazy board schema folds into the next natural bump. - */ +/** Repair the v14 board constraint inside the caller's schema/write transaction. */ export function ensureOpenClawAgentBoardSchemaInTransaction(db: DatabaseSync): void { if (!db.isTransaction) { throw new Error("board schema ensure requires an active transaction"); diff --git a/src/state/openclaw-agent-db-contract.ts b/src/state/openclaw-agent-db-contract.ts index 4691196cc76a..dd6c6674aa55 100644 --- a/src/state/openclaw-agent-db-contract.ts +++ b/src/state/openclaw-agent-db-contract.ts @@ -2,6 +2,7 @@ import type { DatabaseSync } from "node:sqlite"; import type { SqliteWalMaintenance } from "../infra/sqlite-wal.js"; import type { OpenClawStateDatabaseOptions } from "./openclaw-state-db.js"; +// v15 makes board and session-sharing tables part of the canonical agent schema. // v14 = logical session nodes, generation windows, and node-owned artifact FKs. // v13 = one durable rewrite watermark per raw session transcript. // v12 = session-owned ACP parent-stream events. @@ -15,7 +16,7 @@ import type { OpenClawStateDatabaseOptions } from "./openclaw-state-db.js"; // The v4 session/transcript flip and main's v2 memory-identity // change is folded in structure-gated migrations, so v2 main DBs and // pre-merge v4 flip DBs both converge on this schema. -export const OPENCLAW_AGENT_SCHEMA_VERSION = 14; +export const OPENCLAW_AGENT_SCHEMA_VERSION = 15; /** Open per-agent SQLite database handle plus lifecycle maintenance. */ export type OpenClawAgentDatabase = { diff --git a/src/state/openclaw-agent-db-maintenance.ts b/src/state/openclaw-agent-db-maintenance.ts index ee53e25063b7..704103d8a2f7 100644 --- a/src/state/openclaw-agent-db-maintenance.ts +++ b/src/state/openclaw-agent-db-maintenance.ts @@ -1,15 +1,7 @@ import type { DatabaseSync } from "node:sqlite"; -import { - MEMORY_INDEX_SOURCES_TABLE, - MEMORY_PATH_FTS_TRIGGER_DEFINITIONS, -} from "../../packages/memory-host-sdk/src/host/memory-schema.js"; import { clearNodeSqliteKyselyCacheForDatabase } from "../infra/kysely-sync.js"; import { openNodeSqliteDatabase } from "../infra/node-sqlite.js"; import { repairCanonicalSqliteIndexes } from "../infra/sqlite-index-schema.js"; -import { - assertSqliteSchemaContains, - type SqliteSchemaCompatibility, -} from "../infra/sqlite-schema-contract.js"; import { createNewerSqliteSchemaVersionError, readSqliteUserVersion, @@ -18,6 +10,7 @@ import { normalizeAgentId } from "../routing/session-key.js"; import { OPENCLAW_AGENT_SCHEMA_VERSION } from "./openclaw-agent-db-contract.js"; import { assertExistingAgentSchemaOwner, + assertOpenClawAgentSchemaContains, assertSupportedAgentSchemaVersion, readExistingAgentSchemaMeta, } from "./openclaw-agent-db-schema-helpers.js"; @@ -25,18 +18,6 @@ import { ensureOpenClawAgentDatabaseSchema } from "./openclaw-agent-db-schema.js import { OPENCLAW_AGENT_SCHEMA_SQL } from "./openclaw-agent-schema.generated.js"; import { OPENCLAW_SQLITE_BUSY_TIMEOUT_MS } from "./openclaw-state-db.js"; -const OPENCLAW_AGENT_MAINTENANCE_SCHEMA_COMPATIBILITY = { - allowedColumnDefinitions: { - "conversations.delivery_target": ["delivery_target TEXT NOT NULL DEFAULT ''"], - }, - optionalCanonicalTriggerGroups: [ - { - tableName: MEMORY_INDEX_SOURCES_TABLE, - triggers: MEMORY_PATH_FTS_TRIGGER_DEFINITIONS, - }, - ], -} satisfies SqliteSchemaCompatibility; - /** Require exact agent ownership without requiring the latest schema. */ export function assertOpenClawAgentDatabaseOwner( database: DatabaseSync, @@ -84,12 +65,7 @@ export function assertOpenClawAgentDatabaseForMaintenance( `OpenClaw agent database ${options.pathname} metadata schema version ${metadata.schemaVersion ?? "invalid"} does not match ${OPENCLAW_AGENT_SCHEMA_VERSION}; run openclaw doctor --fix before compacting it.`, ); } - assertSqliteSchemaContains( - database, - options.pathname, - OPENCLAW_AGENT_SCHEMA_SQL, - OPENCLAW_AGENT_MAINTENANCE_SCHEMA_COMPATIBILITY, - ); + assertOpenClawAgentSchemaContains(database, options.pathname, OPENCLAW_AGENT_SCHEMA_SQL); } /** Upgrade or repair a supported owned schema before strict offline maintenance. */ diff --git a/src/state/openclaw-agent-db-schema-helpers.ts b/src/state/openclaw-agent-db-schema-helpers.ts index 3b3c1646b203..75e67178fbea 100644 --- a/src/state/openclaw-agent-db-schema-helpers.ts +++ b/src/state/openclaw-agent-db-schema-helpers.ts @@ -1,10 +1,31 @@ import type { DatabaseSync } from "node:sqlite"; +import { + MEMORY_INDEX_SOURCES_TABLE, + MEMORY_PATH_FTS_TRIGGER_DEFINITIONS, +} from "../../packages/memory-host-sdk/src/host/memory-schema.js"; +import { repairCanonicalSqliteIndexes } from "../infra/sqlite-index-schema.js"; +import { + assertSqliteSchemaContains, + assertSqliteSchemaTablesPresent, + getCanonicalSqliteTableNames, + type SqliteSchemaCompatibility, +} from "../infra/sqlite-schema-contract.js"; import { createNewerSqliteSchemaVersionError, readSqliteUserVersion, } from "../infra/sqlite-user-version.js"; import { normalizeAgentId } from "../routing/session-key.js"; +import { + AGENT_V14_BOARD_SCHEMA_SQL, + ensureOpenClawAgentBoardSchemaInTransaction, +} from "./openclaw-agent-board-schema.js"; import { OPENCLAW_AGENT_SCHEMA_VERSION } from "./openclaw-agent-db-contract.js"; +import { OPENCLAW_AGENT_SCHEMA_SQL } from "./openclaw-agent-schema.generated.js"; +import { + AGENT_V14_ADDITIVE_SCHEMA_SQL, + AGENT_V14_CORE_SCHEMA_SQL, + AGENT_V14_SESSION_SHARING_SCHEMA_SQL, +} from "./openclaw-agent-session-sharing-schema.js"; type ExistingAgentSchemaMeta = { agentId: string | null; @@ -12,6 +33,115 @@ type ExistingAgentSchemaMeta = { schemaVersion: number | null; }; +const OPENCLAW_AGENT_SCHEMA_COMPATIBILITY = { + allowedColumnDefinitions: { + "conversations.delivery_target": ["delivery_target TEXT NOT NULL DEFAULT ''"], + }, + optionalCanonicalTriggerGroups: [ + { + tableName: MEMORY_INDEX_SOURCES_TABLE, + triggers: MEMORY_PATH_FTS_TRIGGER_DEFINITIONS, + }, + ], +} satisfies SqliteSchemaCompatibility; + +export function assertOpenClawAgentSchemaContains( + database: DatabaseSync, + pathname: string, + schemaSql: string, +): void { + assertSqliteSchemaContains(database, pathname, schemaSql, OPENCLAW_AGENT_SCHEMA_COMPATIBILITY); +} + +export function assertOpenClawAgentCurrentRuntimeSchema( + database: DatabaseSync, + options: { agentId: string; pathname: string }, +): void { + const agentId = normalizeAgentId(options.agentId); + const metadata = readExistingAgentSchemaMeta(database); + if (!metadata) { + throw new Error( + `OpenClaw agent database ${options.pathname} has no schema ownership metadata.`, + ); + } + assertExistingAgentSchemaOwner(metadata, agentId, options.pathname); + if (metadata.schemaVersion !== OPENCLAW_AGENT_SCHEMA_VERSION) { + throw new Error( + `OpenClaw agent database ${options.pathname} metadata schema version ${metadata.schemaVersion ?? "invalid"} does not match ${OPENCLAW_AGENT_SCHEMA_VERSION}; run openclaw doctor --fix before using it.`, + ); + } + assertOpenClawAgentSchemaContains(database, options.pathname, OPENCLAW_AGENT_SCHEMA_SQL); +} + +function hasAnyCanonicalTable(database: DatabaseSync, schemaSql: string): boolean { + const tableNames = getCanonicalSqliteTableNames(schemaSql); + const placeholders = tableNames.map(() => "?").join(", "); + return Boolean( + database + .prepare( + `SELECT 1 FROM main.sqlite_schema + WHERE type = 'table' AND name IN (${placeholders}) + LIMIT 1`, + ) + .get(...tableNames), + ); +} + +function repairAndAssertAgentSchemaGroup( + database: DatabaseSync, + pathname: string, + schemaSql: string, +): void { + repairCanonicalSqliteIndexes(database, pathname, schemaSql, { + verifyPhysicalIntegrity: false, + }); + assertOpenClawAgentSchemaContains(database, pathname, schemaSql); +} + +export function repairAndAssertOpenClawAgentV14SchemaForMigration( + database: DatabaseSync, + options: { agentId: string; pathname: string }, +): void { + const userVersion = readSqliteUserVersion(database); + if (userVersion !== 14) { + throw new Error( + `OpenClaw agent database ${options.pathname} uses schema version ${userVersion}; expected 14 before migrating it.`, + ); + } + const agentId = normalizeAgentId(options.agentId); + const metadata = readExistingAgentSchemaMeta(database); + if (!metadata) { + throw new Error( + `OpenClaw agent database ${options.pathname} has no schema ownership metadata.`, + ); + } + assertExistingAgentSchemaOwner(metadata, agentId, options.pathname); + if (metadata.schemaVersion !== 14) { + throw new Error( + `OpenClaw agent database ${options.pathname} metadata schema version ${metadata.schemaVersion ?? "invalid"} does not match 14; repair the ownership metadata before migrating it.`, + ); + } + + // v14 always owned the core schema. Board and collaboration groups were + // lazy, but a partially present group still has to be complete and canonical. + repairAndAssertAgentSchemaGroup(database, options.pathname, AGENT_V14_CORE_SCHEMA_SQL); + if (hasAnyCanonicalTable(database, AGENT_V14_SESSION_SHARING_SCHEMA_SQL)) { + repairAndAssertAgentSchemaGroup( + database, + options.pathname, + AGENT_V14_SESSION_SHARING_SCHEMA_SQL, + ); + } + if (hasAnyCanonicalTable(database, AGENT_V14_ADDITIVE_SCHEMA_SQL)) { + repairAndAssertAgentSchemaGroup(database, options.pathname, AGENT_V14_ADDITIVE_SCHEMA_SQL); + } + if (hasAnyCanonicalTable(database, AGENT_V14_BOARD_SCHEMA_SQL)) { + assertSqliteSchemaTablesPresent(database, options.pathname, AGENT_V14_BOARD_SCHEMA_SQL); + ensureOpenClawAgentBoardSchemaInTransaction(database); + repairAndAssertAgentSchemaGroup(database, options.pathname, AGENT_V14_BOARD_SCHEMA_SQL); + } +} + export function assertSupportedAgentSchemaVersion(db: DatabaseSync, pathname: string): void { const userVersion = readSqliteUserVersion(db); if (userVersion > OPENCLAW_AGENT_SCHEMA_VERSION) { diff --git a/src/state/openclaw-agent-db-schema.ts b/src/state/openclaw-agent-db-schema.ts index 2ab511dc1a44..9c453507250e 100644 --- a/src/state/openclaw-agent-db-schema.ts +++ b/src/state/openclaw-agent-db-schema.ts @@ -10,6 +10,7 @@ import { configureSqlitePreSchemaPragmas } from "../infra/sqlite-wal.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { VERSION } from "../version.js"; +import { ensureOpenClawAgentBoardSchemaInTransaction } from "./openclaw-agent-board-schema.js"; import { OPENCLAW_AGENT_SCHEMA_VERSION, type OpenClawAgentDatabaseOptions, @@ -18,8 +19,10 @@ import { ensureOpenClawAgentDatabasePermissions } from "./openclaw-agent-db-perm import { registerOpenClawAgentDatabase } from "./openclaw-agent-db-registry.js"; import { assertExistingAgentSchemaOwner, + assertOpenClawAgentCurrentRuntimeSchema, assertSupportedAgentSchemaVersion, readExistingAgentSchemaMeta, + repairAndAssertOpenClawAgentV14SchemaForMigration, } from "./openclaw-agent-db-schema-helpers.js"; import { backfillSessionConversations, @@ -36,7 +39,6 @@ import { import type { DB as OpenClawAgentKyselyDatabase } from "./openclaw-agent-db.generated.js"; import { resolveOpenClawAgentSqlitePath } from "./openclaw-agent-db.paths.js"; import { OPENCLAW_AGENT_SCHEMA_SQL } from "./openclaw-agent-schema.generated.js"; -import { AGENT_SCHEMA_WITHOUT_LAZY_SURFACES_SQL } from "./openclaw-agent-session-sharing-schema.js"; import { OPENCLAW_SQLITE_BUSY_TIMEOUT_MS } from "./openclaw-state-db.js"; type OpenClawAgentMetadataDatabase = Pick; @@ -447,6 +449,7 @@ function backfillOpenClawAgentSchema(db: DatabaseSync, previousVersion: number): export function assertAgentDatabaseIntegrityBeforeMutation( database: DatabaseSync, + agentId: string, pathname: string, ): void { database.exec(`PRAGMA busy_timeout = ${OPENCLAW_SQLITE_BUSY_TIMEOUT_MS};`); @@ -464,6 +467,8 @@ export function assertAgentDatabaseIntegrityBeforeMutation( toVersion: OPENCLAW_AGENT_SCHEMA_VERSION, }); } + // Named indexes are repairable; the full schema assertion below must run + // after this repair while still rejecting table and constraint drift. const rebuiltIndexes = userVersion === OPENCLAW_AGENT_SCHEMA_VERSION ? repairCanonicalSqliteIndexes(database, pathname, OPENCLAW_AGENT_SCHEMA_SQL, { @@ -474,6 +479,9 @@ export function assertAgentDatabaseIntegrityBeforeMutation( // Every physical open proves the full file before schema mutation or exposure. assertSqliteIntegrity(database, pathname); } + if (userVersion === OPENCLAW_AGENT_SCHEMA_VERSION) { + assertOpenClawAgentCurrentRuntimeSchema(database, { agentId, pathname }); + } } function ensureAgentSchema(db: DatabaseSync, agentId: string, pathname: string): void { @@ -490,6 +498,16 @@ function ensureAgentSchema(db: DatabaseSync, agentId: string, pathname: string): assertExistingAgentSchemaOwner(readExistingAgentSchemaMeta(db), agentId, pathname); assertSupportedAgentSchemaVersion(db, pathname); const previousVersion = readSqliteUserVersion(db); + if (previousVersion === OPENCLAW_AGENT_SCHEMA_VERSION) { + // Repeat index repair before the transactional schema assertion so a + // concurrent opener cannot turn repairable drift into a hard refusal. + repairCanonicalSqliteIndexes(db, pathname, OPENCLAW_AGENT_SCHEMA_SQL, { + verifyPhysicalIntegrity: false, + }); + assertOpenClawAgentCurrentRuntimeSchema(db, { agentId, pathname }); + } else if (previousVersion === 14) { + repairAndAssertOpenClawAgentV14SchemaForMigration(db, { agentId, pathname }); + } // Two legacy memory shapes exist: the flip lineage's source_kind schema // (derived cache — dropped for rebuild) and main's path/source-keyed // schema (migrated in place by the identity migration). Both helpers are @@ -506,11 +524,10 @@ function ensureAgentSchema(db: DatabaseSync, agentId: string, pathname: string): } backfillSessionEntryProvenance(db, previousVersion); migrateSessionNodesAndWindows(db, previousVersion); - const schemaSql = - previousVersion === OPENCLAW_AGENT_SCHEMA_VERSION - ? AGENT_SCHEMA_WITHOUT_LAZY_SURFACES_SQL - : OPENCLAW_AGENT_SCHEMA_SQL; - db.exec(schemaSql); + db.exec(OPENCLAW_AGENT_SCHEMA_SQL); + if (previousVersion < OPENCLAW_AGENT_SCHEMA_VERSION) { + ensureOpenClawAgentBoardSchemaInTransaction(db); + } migrateSessionTranscriptGenerations(db, previousVersion); migrateSessionTranscriptActiveProjection(db, previousVersion); if (previousVersion < 11) { @@ -518,7 +535,7 @@ function ensureAgentSchema(db: DatabaseSync, agentId: string, pathname: string): databaseLabel: pathname, }); } - repairCanonicalSqliteIndexes(db, pathname, schemaSql, { + repairCanonicalSqliteIndexes(db, pathname, OPENCLAW_AGENT_SCHEMA_SQL, { verifyPhysicalIntegrity: false, }); const kysely = getNodeSqliteKysely(db); @@ -547,6 +564,7 @@ function ensureAgentSchema(db: DatabaseSync, agentId: string, pathname: string): }), ), ); + assertOpenClawAgentCurrentRuntimeSchema(db, { agentId, pathname }); }); } finally { db.exec("PRAGMA foreign_keys = ON;"); @@ -565,7 +583,7 @@ export function ensureOpenClawAgentDatabaseSchema( db.exec(`PRAGMA busy_timeout = ${OPENCLAW_SQLITE_BUSY_TIMEOUT_MS};`); assertSupportedAgentSchemaVersion(db, pathname); assertExistingAgentSchemaOwner(readExistingAgentSchemaMeta(db), agentId, pathname); - assertAgentDatabaseIntegrityBeforeMutation(db, pathname); + assertAgentDatabaseIntegrityBeforeMutation(db, agentId, pathname); configureSqlitePreSchemaPragmas(db, { busyTimeoutMs: OPENCLAW_SQLITE_BUSY_TIMEOUT_MS, }); diff --git a/src/state/openclaw-agent-db.test.ts b/src/state/openclaw-agent-db.test.ts index e7f59fc77a4b..99ac6e899eb1 100644 --- a/src/state/openclaw-agent-db.test.ts +++ b/src/state/openclaw-agent-db.test.ts @@ -932,7 +932,7 @@ describe("openclaw agent database", () => { }); it("opens a v13 database that already contains additive board storage", () => { - expect(OPENCLAW_AGENT_SCHEMA_VERSION).toBe(14); + expect(OPENCLAW_AGENT_SCHEMA_VERSION).toBe(15); const stateDir = createTempStateDir(); const env = { OPENCLAW_STATE_DIR: stateDir }; const opened = openOpenClawAgentDatabase({ agentId: "worker-1", env }); @@ -1164,7 +1164,7 @@ describe("openclaw agent database", () => { }); it("keeps additive heartbeat repair while upgrading schema version 12", () => { - expect(OPENCLAW_AGENT_SCHEMA_VERSION).toBe(14); + expect(OPENCLAW_AGENT_SCHEMA_VERSION).toBe(15); const stateDir = createTempStateDir(); const env = { OPENCLAW_STATE_DIR: stateDir }; const opened = openOpenClawAgentDatabase({ agentId: "worker-1", env }); @@ -2433,6 +2433,170 @@ describe("openclaw agent database", () => { ).toEqual([{ key: "key-a" }]); }); + it("rejects a missing current-schema table instead of recreating it empty", () => { + const stateDir = createTempStateDir(); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const databasePath = openOpenClawAgentDatabase({ agentId: "worker-1", env }).path; + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + + const { DatabaseSync } = requireNodeSqlite(); + const drifted = new DatabaseSync(databasePath); + drifted.exec("DROP TABLE auth_profile_store;"); + drifted.close(); + + expect(() => openOpenClawAgentDatabase({ agentId: "worker-1", env })).toThrow( + /missing table auth_profile_store/iu, + ); + + const after = new DatabaseSync(databasePath, { readOnly: true }); + try { + expect( + after + .prepare( + "SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'auth_profile_store'", + ) + .get(), + ).toBeUndefined(); + } finally { + after.close(); + } + }); + + it("rejects a missing stable v14 table before the v15 migration", () => { + const stateDir = createTempStateDir(); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const databasePath = openOpenClawAgentDatabase({ agentId: "worker-1", env }).path; + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + + const { DatabaseSync } = requireNodeSqlite(); + const damaged = new DatabaseSync(databasePath); + damaged.exec(` + DROP TABLE auth_profile_store; + PRAGMA user_version = 14; + UPDATE schema_meta SET schema_version = 14 WHERE meta_key = 'primary'; + `); + damaged.close(); + + expect(() => openOpenClawAgentDatabase({ agentId: "worker-1", env })).toThrow( + /missing table auth_profile_store/iu, + ); + + const after = new DatabaseSync(databasePath, { readOnly: true }); + try { + expect( + after + .prepare( + "SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'auth_profile_store'", + ) + .get(), + ).toBeUndefined(); + } finally { + after.close(); + } + }); + + it("adds post-v14 suggestion tables during the v15 migration", () => { + const stateDir = createTempStateDir(); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const databasePath = openOpenClawAgentDatabase({ agentId: "worker-1", env }).path; + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + + const { DatabaseSync } = requireNodeSqlite(); + const legacy = new DatabaseSync(databasePath); + legacy.exec(` + DROP TABLE session_suggestions; + PRAGMA user_version = 14; + UPDATE schema_meta SET schema_version = 14 WHERE meta_key = 'primary'; + `); + legacy.close(); + + const migrated = openOpenClawAgentDatabase({ agentId: "worker-1", env }); + expect( + migrated.db + .prepare( + "SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'session_suggestions'", + ) + .get(), + ).toEqual({ name: "session_suggestions" }); + }); + + it("rejects an inline unique constraint hidden behind a SQLite autoindex", () => { + const stateDir = createTempStateDir(); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const databasePath = openOpenClawAgentDatabase({ agentId: "worker-1", env }).path; + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + + const { DatabaseSync } = requireNodeSqlite(); + const drifted = new DatabaseSync(databasePath); + drifted.exec(` + DROP TABLE cache_entries; + CREATE TABLE cache_entries ( + scope TEXT NOT NULL UNIQUE, + key TEXT NOT NULL, + value_json TEXT, + blob BLOB, + expires_at INTEGER, + updated_at INTEGER NOT NULL, + PRIMARY KEY (scope, key) + ) STRICT; + CREATE INDEX idx_agent_cache_expiry + ON cache_entries(scope, expires_at, key) + WHERE expires_at IS NOT NULL; + CREATE INDEX idx_agent_cache_updated + ON cache_entries(scope, updated_at DESC, key); + `); + drifted.close(); + + expect(() => openOpenClawAgentDatabase({ agentId: "worker-1", env })).toThrow( + /unexpected unique index on cache_entries/iu, + ); + }); + + it("rejects primary-key collation drift in a current-schema table", () => { + const stateDir = createTempStateDir(); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const databasePath = openOpenClawAgentDatabase({ agentId: "worker-1", env }).path; + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + + const { DatabaseSync } = requireNodeSqlite(); + const drifted = new DatabaseSync(databasePath); + drifted.exec(` + DROP TABLE auth_profile_store; + CREATE TABLE auth_profile_store ( + store_key TEXT COLLATE NOCASE NOT NULL PRIMARY KEY, + store_json TEXT NOT NULL, + updated_at INTEGER NOT NULL + ) STRICT; + `); + drifted.close(); + + expect(() => openOpenClawAgentDatabase({ agentId: "worker-1", env })).toThrow( + /column definitions differ for auth_profile_store/iu, + ); + }); + + it("rejects a partially missing lazy board schema", () => { + const stateDir = createTempStateDir(); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const databasePath = openOpenClawAgentDatabase({ agentId: "worker-1", env }).path; + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + + const { DatabaseSync } = requireNodeSqlite(); + const drifted = new DatabaseSync(databasePath); + drifted.exec("DROP TABLE board_widgets;"); + drifted.close(); + + expect(() => openOpenClawAgentDatabase({ agentId: "worker-1", env })).toThrow( + /missing table board_widgets/iu, + ); + }); + it("rejects same-name transcript index drift when duplicate rows block repair", () => { const stateDir = createTempStateDir(); const env = { OPENCLAW_STATE_DIR: stateDir }; diff --git a/src/state/openclaw-agent-db.ts b/src/state/openclaw-agent-db.ts index 161f4a73711c..3c94da041cb6 100644 --- a/src/state/openclaw-agent-db.ts +++ b/src/state/openclaw-agent-db.ts @@ -311,7 +311,7 @@ export function openOpenClawAgentDatabase( } // Integrity is not process-stable: the file can be damaged while evicted. // This guard is read-only (no busy waits), so every physical open pays it. - assertAgentDatabaseIntegrityBeforeMutation(db, pathname); + assertAgentDatabaseIntegrityBeforeMutation(db, agentId, pathname); configureSqlitePreSchemaPragmas(db, { busyTimeoutMs: OPENCLAW_SQLITE_BUSY_TIMEOUT_MS, }); diff --git a/src/state/openclaw-agent-session-sharing-schema.ts b/src/state/openclaw-agent-session-sharing-schema.ts index c6113ddde7f0..60585085ec62 100644 --- a/src/state/openclaw-agent-session-sharing-schema.ts +++ b/src/state/openclaw-agent-session-sharing-schema.ts @@ -1,8 +1,8 @@ -import type { DatabaseSync } from "node:sqlite"; import { OPENCLAW_AGENT_SCHEMA_WITHOUT_BOARD_SQL } from "./openclaw-agent-board-schema.js"; const SHARING_SCHEMA_START = "CREATE TABLE IF NOT EXISTS session_members ("; const SHARING_SCHEMA_END = "CREATE TABLE IF NOT EXISTS heartbeat_outcomes ("; +const SUGGESTIONS_SCHEMA_START = "CREATE TABLE IF NOT EXISTS session_suggestions ("; function splitSessionSharingSchema(sql: string): { sharing: string; withoutSharing: string } { const start = sql.indexOf(SHARING_SCHEMA_START); @@ -17,13 +17,14 @@ function splitSessionSharingSchema(sql: string): { sharing: string; withoutShari } const sessionSharingSchema = splitSessionSharingSchema(OPENCLAW_AGENT_SCHEMA_WITHOUT_BOARD_SQL); - -export const AGENT_SCHEMA_WITHOUT_LAZY_SURFACES_SQL = sessionSharingSchema.withoutSharing; - -/** Adds the phase-2 collaboration table on first use without a schema-version bump. */ -export function ensureOpenClawAgentSessionSharingSchemaInTransaction(db: DatabaseSync): void { - if (!db.isTransaction) { - throw new Error("session sharing schema ensure requires an active transaction"); - } - db.exec(sessionSharingSchema.sharing); // sqlite-allow-raw -- Canonical DDL for an additive lazy table. +const sessionSuggestionsStart = sessionSharingSchema.sharing.indexOf(SUGGESTIONS_SCHEMA_START); +if (sessionSuggestionsStart === -1) { + throw new Error("OpenClaw agent session-suggestions schema marker is missing."); } +export const AGENT_V14_SESSION_SHARING_SCHEMA_SQL = sessionSharingSchema.sharing.slice( + 0, + sessionSuggestionsStart, +); +export const AGENT_V14_ADDITIVE_SCHEMA_SQL = + sessionSharingSchema.sharing.slice(sessionSuggestionsStart); +export const AGENT_V14_CORE_SCHEMA_SQL = sessionSharingSchema.withoutSharing; diff --git a/src/state/openclaw-state-db-contract.ts b/src/state/openclaw-state-db-contract.ts index c88d06fae801..1b1068fc4e25 100644 --- a/src/state/openclaw-state-db-contract.ts +++ b/src/state/openclaw-state-db-contract.ts @@ -1,8 +1,9 @@ import type { DatabaseSync } from "node:sqlite"; import type { SqliteWalMaintenance } from "../infra/sqlite-wal.js"; +// v6 makes every committed shared-state table part of the canonical runtime schema. // v5 records durable cloud-worker result refs on pending workspace fences. -export const OPENCLAW_STATE_SCHEMA_VERSION = 5; +export const OPENCLAW_STATE_SCHEMA_VERSION = 6; export const OPENCLAW_STATE_STRICT_SCHEMA_VERSION = 3; /** Maximum time one synchronous SQLite call may wait for a lock. */ export const OPENCLAW_SQLITE_BUSY_TIMEOUT_MS = 5_000; diff --git a/src/state/openclaw-state-db-maintenance.ts b/src/state/openclaw-state-db-maintenance.ts index 4149a5103f0f..db333df091cd 100644 --- a/src/state/openclaw-state-db-maintenance.ts +++ b/src/state/openclaw-state-db-maintenance.ts @@ -2,6 +2,7 @@ import path from "node:path"; import type { DatabaseSync } from "node:sqlite"; import { assertSqliteSchemaContains, + assertSqliteSchemaTablesPresent, type SqliteSchemaCompatibility, } from "../infra/sqlite-schema-contract.js"; import { @@ -29,6 +30,10 @@ const OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY = { "commitments.sensitivity": ["sensitivity TEXT NOT NULL DEFAULT 'normal'"], "commitments.source": ["source TEXT NOT NULL DEFAULT 'unknown'"], "commitments.suggested_text": ["suggested_text TEXT NOT NULL DEFAULT ''"], + "claw_package_refs.package_integrity": [ + "package_integrity TEXT NOT NULL DEFAULT 'sha256:0000000000000000000000000000000000000000000000000000000000000000'", + ], + "claw_package_refs.updated_at_ms": ["updated_at_ms INTEGER NOT NULL DEFAULT 0"], "cron_jobs.created_at_ms": ["created_at_ms INTEGER NOT NULL DEFAULT 0"], "cron_jobs.enabled": ["enabled INTEGER NOT NULL DEFAULT 1"], "cron_jobs.name": ["name TEXT NOT NULL DEFAULT ''"], @@ -42,9 +47,29 @@ const OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY = { "current_conversation_bindings.target_agent_id": [ "target_agent_id TEXT NOT NULL DEFAULT 'main'", ], + "operator_approvals.resolution_ref": ["resolution_ref TEXT"], }, } satisfies SqliteSchemaCompatibility; +const STATE_V5_ADDITIVE_TABLES = [ + "agent_database_leases", + "agent_deletion_journal", + "claw_cron_refs", + "claw_installs", + "claw_mcp_server_refs", + "claw_package_refs", + "claw_workspace_files", + "config_machine_state", + "cron_job_scratch", + "meeting_transcript_sessions", + "meeting_transcript_summaries", + "meeting_transcript_utterances", + "outbound_media_provenance", + "worker_environment_credentials", + "worker_transcript_commit_heads", + "worker_transcript_commits", +] as const; + /** Open shared SQLite database handle plus WAL maintenance lifecycle. */ export function createOpenClawDatabaseVerificationError( @@ -133,6 +158,33 @@ export function assertOpenClawStateDatabaseForMaintenance( ); } +/** Require every stable v5 table before the v6 additive migration can run. */ +export function assertOpenClawStateDatabaseV5ForMigration( + database: DatabaseSync, + options: { pathname: string }, +): void { + const userVersion = readSqliteUserVersion(database); + if (userVersion !== 5) { + throw new Error( + `OpenClaw state database ${options.pathname} uses schema version ${userVersion}; expected 5 before migrating it.`, + ); + } + assertOpenClawStateDatabaseOwner(database, options); + const metadata = database + .prepare("SELECT schema_version FROM schema_meta WHERE meta_key = 'primary' LIMIT 1") + .get() as { schema_version?: unknown } | undefined; + if (metadata?.schema_version !== 5) { + const schemaVersion = + typeof metadata?.schema_version === "number" ? metadata.schema_version : "invalid"; + throw new Error( + `OpenClaw state database ${options.pathname} metadata schema version ${schemaVersion} does not match 5; repair the ownership metadata before migrating it.`, + ); + } + assertSqliteSchemaTablesPresent(database, options.pathname, OPENCLAW_STATE_SCHEMA_SQL, { + allowedMissingTables: STATE_V5_ADDITIVE_TABLES, + }); +} + export function resolveDatabasePath(options: OpenClawStateDatabaseOptions = {}): string { return path.resolve(options.path ?? resolveOpenClawStateSqlitePath(options.env ?? process.env)); } diff --git a/src/state/openclaw-state-db.test.ts b/src/state/openclaw-state-db.test.ts index f6c8096bd42b..a6b5cd02a24e 100644 --- a/src/state/openclaw-state-db.test.ts +++ b/src/state/openclaw-state-db.test.ts @@ -139,6 +139,13 @@ function replaceManagedImageRecordsWithLegacyTable( const LEGACY_SESSION_WATCH_SCHEMA_VERSION = 3; const LEGACY_AMBIENT_WATCH_PREFIX = "ambient-group-watch:"; +function markStateDatabaseAsV5(database: DatabaseSync): void { + database.exec(` + PRAGMA user_version = 5; + UPDATE schema_meta SET schema_version = 5 WHERE meta_key = 'primary'; + `); +} + function seedLegacySessionWatchCursorSchema(stateDir: string): { ambientTarget: string; bomTarget: string; @@ -1791,6 +1798,112 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're ).toEqual([{ task_id: "task-index-repair" }]); }); + it("rejects a missing current-schema table instead of recreating it empty", () => { + const stateDir = createTempStateDir(); + const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; + const databasePath = openOpenClawStateDatabase(options).path; + closeOpenClawStateDatabaseForTest(); + + const { DatabaseSync } = requireNodeSqlite(); + const drifted = new DatabaseSync(databasePath); + drifted.exec("DROP TABLE auth_profile_stores;"); + drifted.close(); + + expect(() => openOpenClawStateDatabase(options)).toThrow(/missing table auth_profile_stores/iu); + + const after = new DatabaseSync(databasePath, { readOnly: true }); + try { + expect( + after + .prepare( + "SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'auth_profile_stores'", + ) + .get(), + ).toBeUndefined(); + } finally { + after.close(); + } + }); + + it("rejects a missing stable v5 table before the v6 migration", () => { + const stateDir = createTempStateDir(); + const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; + const databasePath = openOpenClawStateDatabase(options).path; + closeOpenClawStateDatabaseForTest(); + + const { DatabaseSync } = requireNodeSqlite(); + const damaged = new DatabaseSync(databasePath); + damaged.exec("DROP TABLE auth_profile_stores;"); + markStateDatabaseAsV5(damaged); + damaged.close(); + + expect(() => openOpenClawStateDatabase(options)).toThrow(/missing table auth_profile_stores/iu); + + const after = new DatabaseSync(databasePath, { readOnly: true }); + try { + expect( + after + .prepare( + "SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'auth_profile_stores'", + ) + .get(), + ).toBeUndefined(); + } finally { + after.close(); + } + }); + + it("rejects an inline unique constraint hidden behind a SQLite autoindex", () => { + const stateDir = createTempStateDir(); + const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; + const databasePath = openOpenClawStateDatabase(options).path; + closeOpenClawStateDatabaseForTest(); + + const { DatabaseSync } = requireNodeSqlite(); + const drifted = new DatabaseSync(databasePath); + drifted.exec(` + DROP TABLE diagnostic_events; + CREATE TABLE diagnostic_events ( + scope TEXT NOT NULL UNIQUE, + event_key TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + sequence INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (scope, event_key) + ) STRICT; + CREATE INDEX idx_diagnostic_events_scope_sequence + ON diagnostic_events(scope, sequence, event_key); + `); + drifted.close(); + + expect(() => openOpenClawStateDatabase(options)).toThrow( + /unexpected unique index on diagnostic_events/iu, + ); + }); + + it("rejects primary-key collation drift in a current-schema table", () => { + const stateDir = createTempStateDir(); + const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; + const databasePath = openOpenClawStateDatabase(options).path; + closeOpenClawStateDatabaseForTest(); + + const { DatabaseSync } = requireNodeSqlite(); + const drifted = new DatabaseSync(databasePath); + drifted.exec(` + DROP TABLE auth_profile_stores; + CREATE TABLE auth_profile_stores ( + store_key TEXT COLLATE NOCASE NOT NULL PRIMARY KEY, + store_json TEXT NOT NULL, + updated_at INTEGER NOT NULL + ) STRICT; + `); + drifted.close(); + + expect(() => openOpenClawStateDatabase(options)).toThrow( + /column definitions differ for auth_profile_stores/iu, + ); + }); + it("migrates the released audit ledger to message-compatible attribution exactly once", () => { const stateDir = createTempStateDir(); const databasePath = createLegacyAuditStateDatabase(stateDir); @@ -2630,6 +2743,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're const { DatabaseSync } = requireNodeSqlite(); const legacyDb = new DatabaseSync(databasePath); legacyDb.exec("ALTER TABLE gateway_boot_lifecycle DROP COLUMN startup_reason"); + markStateDatabaseAsV5(legacyDb); legacyDb.close(); const reopened = openOpenClawStateDatabase({ @@ -2676,6 +2790,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're const { DatabaseSync } = requireNodeSqlite(); const legacyDb = new DatabaseSync(databasePath); legacyDb.exec("ALTER TABLE claw_package_refs DROP COLUMN updated_at_ms"); + markStateDatabaseAsV5(legacyDb); legacyDb.close(); const reopened = openOpenClawStateDatabase({ @@ -2705,6 +2820,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're ALTER TABLE worker_environments DROP COLUMN teardown_terminal_state; ALTER TABLE worker_environments DROP COLUMN ssh_host_key; `); + markStateDatabaseAsV5(legacyDb); legacyDb.close(); const reopened = openOpenClawStateDatabase({ @@ -2774,6 +2890,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're DROP TABLE worker_transcript_commits; DROP TABLE worker_transcript_commit_heads; `); + markStateDatabaseAsV5(legacyDb); legacyDb.close(); const reopened = openOpenClawStateDatabase({ @@ -2840,6 +2957,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're DROP INDEX idx_operator_approvals_resolution_ref; ALTER TABLE operator_approvals DROP COLUMN resolution_ref; `); + markStateDatabaseAsV5(legacyDb); legacyDb.close(); const reopened = openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: stateDir } }); @@ -3018,6 +3136,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're ('alpha', 'tie-second', '{}', 10), ('beta', 'only', '{}', 30); `); + markStateDatabaseAsV5(legacyDb); legacyDb.close(); const reopened = openOpenClawStateDatabase(options); @@ -3058,6 +3177,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're const { DatabaseSync } = requireNodeSqlite(); const legacyDb = new DatabaseSync(databasePath); legacyDb.exec("ALTER TABLE apns_registrations DROP COLUMN relay_origin"); + markStateDatabaseAsV5(legacyDb); legacyDb.close(); const reopened = openOpenClawStateDatabase(options); @@ -3205,6 +3325,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're 110, 110, ); + markStateDatabaseAsV5(legacyDb); legacyDb.close(); const reopened = openOpenClawStateDatabase({ @@ -3372,6 +3493,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're ALTER TABLE official_external_plugin_catalog_snapshots DROP COLUMN trust_threshold; ALTER TABLE official_external_plugin_catalog_snapshots DROP COLUMN trust_verified_at; `); + markStateDatabaseAsV5(legacyDb); legacyDb.close(); const reopened = openOpenClawStateDatabase({ @@ -3404,6 +3526,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're const { DatabaseSync } = requireNodeSqlite(); const legacyDb = new DatabaseSync(databasePath); legacyDb.exec("ALTER TABLE task_runs DROP COLUMN detail_json"); + markStateDatabaseAsV5(legacyDb); legacyDb.close(); const reopened = openOpenClawStateDatabase({ @@ -3466,6 +3589,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're 100, 100, ); + markStateDatabaseAsV5(legacyDb); legacyDb.close(); expect(() => diff --git a/src/state/openclaw-state-db.ts b/src/state/openclaw-state-db.ts index bb83f7189219..e3de63c5197f 100644 --- a/src/state/openclaw-state-db.ts +++ b/src/state/openclaw-state-db.ts @@ -47,6 +47,7 @@ import { } from "./openclaw-state-db-contract.js"; import { assertOpenClawStateDatabaseForMaintenance, + assertOpenClawStateDatabaseV5ForMigration, assertSupportedSchemaVersion, createOpenClawDatabaseVerificationError, resolveDatabasePath, @@ -257,6 +258,15 @@ function ensureSchema(db: DatabaseSync, pathname: string): void { () => { assertSupportedSchemaVersion(db, pathname); const previousVersion = readSqliteUserVersion(db); + if (previousVersion === OPENCLAW_STATE_SCHEMA_VERSION) { + repairCanonicalSqliteIndexes(db, pathname, OPENCLAW_STATE_SCHEMA_SQL, { + verifyPhysicalIntegrity: false, + }); + assertCanonicalStateSchemaShape(db, pathname); + assertOpenClawStateDatabaseForMaintenance(db, { pathname }); + } else if (previousVersion === 5) { + assertOpenClawStateDatabaseV5ForMigration(db, { pathname }); + } dropLegacyStateTables(db); ensureAdditiveStateColumns(db); sessionWatchMigration.migrateSessionWatchCursorProvenance(db); @@ -296,6 +306,7 @@ function ensureSchema(db: DatabaseSync, pathname: string): void { }), ), ); + assertOpenClawStateDatabaseForMaintenance(db, { pathname }); }, { busyTimeoutMs: OPENCLAW_SQLITE_BUSY_TIMEOUT_MS, diff --git a/test/scripts/check-native-state-schema-version.test.ts b/test/scripts/check-native-state-schema-version.test.ts index 560fd46fcd46..89d2e2774940 100644 --- a/test/scripts/check-native-state-schema-version.test.ts +++ b/test/scripts/check-native-state-schema-version.test.ts @@ -6,15 +6,15 @@ import { describe("native state schema version guard", () => { it("keeps the checked-in Swift and TypeScript contracts aligned", () => { - expect(checkNativeStateSchemaVersion()).toBe(5); + expect(checkNativeStateSchemaVersion()).toBe(6); }); it("fails when a deliberate Swift fixture drifts behind TypeScript", () => { expect(() => compareNativeStateSchemaVersions({ - swiftSource: "private static let maximumSupportedSchemaVersion: Int64 = 4\n", - typescriptSource: "export const OPENCLAW_STATE_SCHEMA_VERSION = 5;\n", + swiftSource: "private static let maximumSupportedSchemaVersion: Int64 = 5\n", + typescriptSource: "export const OPENCLAW_STATE_SCHEMA_VERSION = 6;\n", }), - ).toThrow("Native state schema version drift: Swift supports 4, TypeScript owns 5"); + ).toThrow("Native state schema version drift: Swift supports 5, TypeScript owns 6"); }); });