diff --git a/src/state/openclaw-state-db-schema-repair.ts b/src/state/openclaw-state-db-schema-repair.ts index 50cd744d4ebe..851b9525a534 100644 --- a/src/state/openclaw-state-db-schema-repair.ts +++ b/src/state/openclaw-state-db-schema-repair.ts @@ -3,7 +3,9 @@ import type { DatabaseSync } from "node:sqlite"; import { openNodeSqliteDatabase } from "../infra/node-sqlite.js"; import { assertSqliteSchemaContains, + collectSqliteNamedIndexContract, collectSqliteSchemaIssues, + getCanonicalSqliteNamedIndexContracts, type SqliteSchemaCompatibility, } from "../infra/sqlite-schema-contract.js"; import { quoteSqliteIdentifier } from "../infra/sqlite-schema-sql.js"; @@ -81,7 +83,7 @@ CREATE INDEX idx_commitments_agent_sent ON commitments(agent_id, status, sent_at_ms, session_key); `; -const ADDITIVE_RETIRED_COMMITMENTS_SCHEMA_SQL = ` +const SHIPPED_RETIRED_COMMITMENTS_SCHEMA_SQL = ` CREATE TABLE commitments ( id TEXT NOT NULL PRIMARY KEY, agent_id TEXT NOT NULL, @@ -91,22 +93,22 @@ CREATE TABLE commitments ( recipient_id TEXT, thread_id TEXT, sender_id TEXT, - kind TEXT NOT NULL DEFAULT 'followup', - sensitivity TEXT NOT NULL DEFAULT 'normal', - source TEXT NOT NULL DEFAULT 'unknown', + kind TEXT NOT NULL, + sensitivity TEXT NOT NULL, + source TEXT NOT NULL, status TEXT NOT NULL, - reason TEXT NOT NULL DEFAULT '', - suggested_text TEXT NOT NULL DEFAULT '', - dedupe_key TEXT NOT NULL DEFAULT '', - confidence REAL NOT NULL DEFAULT 0, + reason TEXT NOT NULL, + suggested_text TEXT NOT NULL, + dedupe_key TEXT NOT NULL, + confidence REAL NOT NULL, due_earliest_ms INTEGER NOT NULL, due_latest_ms INTEGER NOT NULL, - due_timezone TEXT NOT NULL DEFAULT 'UTC', + due_timezone TEXT NOT NULL, source_message_id TEXT, source_run_id TEXT, - created_at_ms INTEGER NOT NULL DEFAULT 0, + created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL, - attempts INTEGER NOT NULL DEFAULT 0, + attempts INTEGER NOT NULL, last_attempt_at_ms INTEGER, sent_at_ms INTEGER, dismissed_at_ms INTEGER, @@ -118,12 +120,8 @@ CREATE INDEX idx_commitments_scope_due ON commitments(agent_id, session_key, status, due_earliest_ms, due_latest_ms); CREATE INDEX idx_commitments_status_due ON commitments(status, due_earliest_ms, due_latest_ms); -CREATE INDEX idx_commitments_agent_due - ON commitments(agent_id, status, due_earliest_ms, due_latest_ms, session_key); CREATE INDEX idx_commitments_scope_dedupe ON commitments(agent_id, session_key, channel, dedupe_key, status); -CREATE INDEX idx_commitments_agent_sent - ON commitments(agent_id, status, sent_at_ms, session_key); `; const RETIRED_COMMITMENTS_INDEX_NAMES = [ @@ -133,6 +131,11 @@ const RETIRED_COMMITMENTS_INDEX_NAMES = [ "idx_commitments_scope_due", "idx_commitments_status_due", ] as const; +const RETIRED_COMMITMENTS_INDEX_FINGERPRINTS = new Map( + getCanonicalSqliteNamedIndexContracts(RETIRED_COMMITMENTS_SCHEMA_SQL).map( + ({ fingerprint, name }) => [name, JSON.stringify(fingerprint)], + ), +); const RETIRED_COMMITMENTS_ADDITIVE_COLUMNS = [ "commitments.account_id", @@ -178,15 +181,9 @@ const RETIRED_COMMITMENTS_SCHEMA_COMPATIBILITY: SqliteSchemaCompatibility = { allowedMissingIndexes: RETIRED_COMMITMENTS_INDEX_NAMES, }; -const ADDITIVE_RETIRED_COMMITMENTS_SCHEMA_COMPATIBILITY: SqliteSchemaCompatibility = { - allowedMissingColumns: RETIRED_COMMITMENTS_ADDITIVE_COLUMNS, - allowedMissingIndexes: RETIRED_COMMITMENTS_INDEX_NAMES, -}; - function hasSupportedRetiredCommitmentsSchema( db: DatabaseSync, schemaSql: string, - expectedIndexNames: readonly string[], compatibility: SqliteSchemaCompatibility = {}, ): boolean { if (collectSqliteSchemaIssues(db, schemaSql, compatibility).length > 0) { @@ -202,9 +199,12 @@ function hasSupportedRetiredCommitmentsSchema( ORDER BY type, name`, ) .all() as Array<{ name: string; type: string }>; - const expectedIndexes = new Set(expectedIndexNames); return attachedObjects.every( - (object) => object.type === "index" && expectedIndexes.has(object.name), + (object) => + object.type === "index" && + RETIRED_COMMITMENTS_INDEX_NAMES.includes(object.name) && + JSON.stringify(collectSqliteNamedIndexContract(db, object.name)) === + RETIRED_COMMITMENTS_INDEX_FINGERPRINTS.get(object.name), ); } @@ -228,14 +228,12 @@ function hasRecognizedRetiredCommitmentsSchema(db: DatabaseSync): boolean { hasSupportedRetiredCommitmentsSchema( db, RETIRED_COMMITMENTS_SCHEMA_SQL, - RETIRED_COMMITMENTS_INDEX_NAMES, RETIRED_COMMITMENTS_SCHEMA_COMPATIBILITY, ) || hasSupportedRetiredCommitmentsSchema( db, - ADDITIVE_RETIRED_COMMITMENTS_SCHEMA_SQL, - RETIRED_COMMITMENTS_INDEX_NAMES, - ADDITIVE_RETIRED_COMMITMENTS_SCHEMA_COMPATIBILITY, + SHIPPED_RETIRED_COMMITMENTS_SCHEMA_SQL, + RETIRED_COMMITMENTS_SCHEMA_COMPATIBILITY, ) ); } diff --git a/src/state/openclaw-state-db.test.ts b/src/state/openclaw-state-db.test.ts index b3ef401050d4..7712e9ec665e 100644 --- a/src/state/openclaw-state-db.test.ts +++ b/src/state/openclaw-state-db.test.ts @@ -318,6 +318,63 @@ function seedAdditiveV6CommitmentSchema(database: DatabaseSync): void { markStateDatabaseAsV6(database); } +function seedV2026_7_1_2CommitmentSchema(database: DatabaseSync): void { + database.exec(` + CREATE TABLE commitments ( + id TEXT NOT NULL PRIMARY KEY, + agent_id TEXT NOT NULL, + session_key TEXT NOT NULL, + channel TEXT NOT NULL, + account_id TEXT, + recipient_id TEXT, + thread_id TEXT, + sender_id TEXT, + kind TEXT NOT NULL, + sensitivity TEXT NOT NULL, + source TEXT NOT NULL, + status TEXT NOT NULL, + reason TEXT NOT NULL, + suggested_text TEXT NOT NULL, + dedupe_key TEXT NOT NULL, + confidence REAL NOT NULL, + due_earliest_ms INTEGER NOT NULL, + due_latest_ms INTEGER NOT NULL, + due_timezone TEXT NOT NULL, + source_message_id TEXT, + source_run_id TEXT, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + attempts INTEGER NOT NULL, + last_attempt_at_ms INTEGER, + sent_at_ms INTEGER, + dismissed_at_ms INTEGER, + snoozed_until_ms INTEGER, + expired_at_ms INTEGER, + record_json TEXT NOT NULL + ); + CREATE INDEX idx_commitments_scope_due + ON commitments(agent_id, session_key, status, due_earliest_ms, due_latest_ms); + CREATE INDEX idx_commitments_status_due + ON commitments(status, due_earliest_ms, due_latest_ms); + CREATE INDEX idx_commitments_scope_dedupe + ON commitments(agent_id, session_key, channel, dedupe_key, status); + INSERT INTO commitments ( + id, agent_id, session_key, channel, kind, sensitivity, source, status, + reason, suggested_text, dedupe_key, confidence, due_earliest_ms, + due_latest_ms, due_timezone, created_at_ms, updated_at_ms, attempts, record_json + ) VALUES ( + 'released-commitment', 'main', 'agent:main:main', 'telegram', 'followup', + 'normal', 'message', 'pending', 'inert', 'follow up', 'released-dedupe', + 1.0, 10, 20, 'UTC', 1, 1, 0, '{}' + ); + INSERT INTO state_leases ( + scope, lease_key, owner, expires_at, heartbeat_at, payload_json, created_at, updated_at + ) VALUES ('test', 'released-preserved-lease', 'migration-test', 100, 50, '{}', 1, 2); + PRAGMA user_version = 1; + UPDATE schema_meta SET schema_version = 1 WHERE meta_key = 'primary'; + `); +} + function seedPartiallyAdditiveV6CommitmentSchema(database: DatabaseSync): void { database.exec(` CREATE TABLE commitments ( @@ -1583,6 +1640,79 @@ describe("openclaw state database", () => { }, ); + it.each(["runtime open", "doctor repair"] as const)( + "retires the shipped v2026.7.1-2 commitments layout through %s", + (migrationPath) => { + const stateDir = createTempStateDir(); + const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; + const databasePath = materializeCurrentStateDatabase(stateDir); + const { DatabaseSync } = requireNodeSqlite(); + const released = new DatabaseSync(databasePath); + seedV2026_7_1_2CommitmentSchema(released); + expect(readSqliteNumberPragma(released, "user_version")).toBe(1); + expect( + released.prepare("SELECT schema_version FROM schema_meta WHERE meta_key = 'primary'").get(), + ).toEqual({ schema_version: 1 }); + expect( + released.prepare("SELECT strict FROM pragma_table_list WHERE name = 'commitments'").get(), + ).toEqual({ strict: 0 }); + expect( + released + .prepare( + `SELECT name + FROM sqlite_schema + WHERE type = 'index' + AND tbl_name = 'commitments' + AND sql IS NOT NULL + ORDER BY name`, + ) + .all(), + ).toEqual([ + { name: "idx_commitments_scope_dedupe" }, + { name: "idx_commitments_scope_due" }, + { name: "idx_commitments_status_due" }, + ]); + released.close(); + + if (migrationPath === "doctor repair") { + const result = repairOpenClawStateDatabaseSchema(options); + expect(result.warnings).toEqual([]); + expect(result.changes).toContain("Retired shared state commitments table and indexes"); + } + const migrated = openOpenClawStateDatabase(options); + expect(readSqliteNumberPragma(migrated.db, "user_version")).toBe(7); + expect( + migrated.db + .prepare("SELECT schema_version FROM schema_meta WHERE meta_key = 'primary'") + .get(), + ).toEqual({ schema_version: 7 }); + for (const name of RETIRED_COMMITMENT_SCHEMA_OBJECTS) { + expect( + migrated.db.prepare("SELECT name FROM sqlite_schema WHERE name = ?").get(name), + ).toBeUndefined(); + } + expect( + migrated.db + .prepare( + `SELECT scope, lease_key, owner, expires_at, heartbeat_at, payload_json, + created_at, updated_at + FROM state_leases + WHERE scope = 'test' AND lease_key = 'released-preserved-lease'`, + ) + .get(), + ).toEqual({ + scope: "test", + lease_key: "released-preserved-lease", + owner: "migration-test", + expires_at: 100, + heartbeat_at: 50, + payload_json: "{}", + created_at: 1, + updated_at: 2, + }); + }, + ); + it.each(["runtime open", "doctor repair"] as const)( "retires a partially additive v6 commitments layout through %s", (migrationPath) => { @@ -1687,29 +1817,45 @@ describe("openclaw state database", () => { AFTER DELETE ON commitments BEGIN SELECT 1; END;`, type: "trigger", }, + { + label: "drifted optional agent-due index", + name: "idx_commitments_agent_due", + sql: `DROP INDEX idx_commitments_agent_due; + CREATE INDEX idx_commitments_agent_due + ON commitments(agent_id, status, session_key);`, + type: "index", + }, ])("preserves an $label on the final v6 commitments layout", ({ name, sql, type }) => { - const stateDir = createTempStateDir(); - const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; - const databasePath = materializeCurrentStateDatabase(stateDir); - const { DatabaseSync } = requireNodeSqlite(); - const customized = new DatabaseSync(databasePath); - seedV6CommitmentSchema(customized); - customized.exec(sql); - customized.close(); + for (const migrationPath of ["runtime open", "doctor repair"] as const) { + const stateDir = createTempStateDir(); + const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; + const databasePath = materializeCurrentStateDatabase(stateDir); + const { DatabaseSync } = requireNodeSqlite(); + const customized = new DatabaseSync(databasePath); + seedV6CommitmentSchema(customized); + customized.exec(sql); + customized.close(); - expect(() => openOpenClawStateDatabase(options)).toThrow(/commitments/u); + if (migrationPath === "doctor repair") { + const result = repairOpenClawStateDatabaseSchema(options); + expect(result.changes).toEqual([]); + expect(result.warnings.join("\n")).toMatch(/commitments/u); + } else { + expect(() => openOpenClawStateDatabase(options)).toThrow(/commitments/u); + } - const preserved = new DatabaseSync(databasePath, { readOnly: true }); - try { - expect(readSqliteNumberPragma(preserved, "user_version")).toBe(6); - expect( - preserved.prepare("SELECT name FROM sqlite_schema WHERE name = 'commitments'").get(), - ).toEqual({ name: "commitments" }); - expect( - preserved.prepare("SELECT type, tbl_name FROM sqlite_schema WHERE name = ?").get(name), - ).toEqual({ type, tbl_name: "commitments" }); - } finally { - preserved.close(); + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + try { + expect(readSqliteNumberPragma(preserved, "user_version")).toBe(6); + expect( + preserved.prepare("SELECT name FROM sqlite_schema WHERE name = 'commitments'").get(), + ).toEqual({ name: "commitments" }); + expect( + preserved.prepare("SELECT type, tbl_name FROM sqlite_schema WHERE name = ?").get(name), + ).toEqual({ type, tbl_name: "commitments" }); + } finally { + preserved.close(); + } } });