diff --git a/src/infra/sqlite-index-schema.ts b/src/infra/sqlite-index-schema.ts index 96ee36f90e33..8e829eab8de5 100644 --- a/src/infra/sqlite-index-schema.ts +++ b/src/infra/sqlite-index-schema.ts @@ -33,6 +33,8 @@ export function repairCanonicalSqliteIndexes( * canonical index. No other repair failure is deferred. */ allowMissingColumns?: boolean; + /** Keep index repair atomic with the caller's whole-schema validation. */ + validateAfterRepair?: () => void; verifyPhysicalIntegrity?: boolean; } = {}, ): string[] { @@ -107,6 +109,7 @@ export function repairCanonicalSqliteIndexes( assertSqliteTableIntegrity(db, databaseLabel, tableName); } assertSqliteIntegrity(db, databaseLabel); + options.validateAfterRepair?.(); db.exec(`RELEASE SAVEPOINT ${savepoint};`); } catch (error) { try { diff --git a/src/infra/state-migrations.test.ts b/src/infra/state-migrations.test.ts index e9204c9ca750..35b08b1bce4a 100644 --- a/src/infra/state-migrations.test.ts +++ b/src/infra/state-migrations.test.ts @@ -1977,6 +1977,16 @@ describe("state migrations", () => { expect(db.prepare("PRAGMA user_version").get()).toEqual({ user_version: OPENCLAW_STATE_SCHEMA_VERSION, }); + expect( + db + .prepare( + "SELECT role, schema_version FROM schema_meta WHERE meta_key = 'primary' LIMIT 1", + ) + .get(), + ).toEqual({ + role: "global", + schema_version: OPENCLAW_STATE_SCHEMA_VERSION, + }); } finally { db.close(); } diff --git a/src/state/openclaw-agent-db-maintenance.ts b/src/state/openclaw-agent-db-maintenance.ts index 704103d8a2f7..cee65ea3ad42 100644 --- a/src/state/openclaw-agent-db-maintenance.ts +++ b/src/state/openclaw-agent-db-maintenance.ts @@ -99,7 +99,19 @@ export function migrateOpenClawAgentDatabaseForMaintenance(options: { return; } if (hasCurrentVersion) { - repairCanonicalSqliteIndexes(database, options.pathname, OPENCLAW_AGENT_SCHEMA_SQL); + repairCanonicalSqliteIndexes(database, options.pathname, OPENCLAW_AGENT_SCHEMA_SQL, { + // The maintenance contract is the runtime owner/schema contract plus + // an exact user_version gate, so table drift rolls this savepoint back. + validateAfterRepair: () => + assertOpenClawAgentDatabaseForMaintenance(database, { + agentId, + pathname: options.pathname, + }), + }); + assertOpenClawAgentDatabaseForMaintenance(database, { + agentId, + pathname: options.pathname, + }); return; } ensureOpenClawAgentDatabaseSchema(database, { diff --git a/src/state/openclaw-agent-db-schema.ts b/src/state/openclaw-agent-db-schema.ts index 9c453507250e..9354b89a4ef3 100644 --- a/src/state/openclaw-agent-db-schema.ts +++ b/src/state/openclaw-agent-db-schema.ts @@ -473,6 +473,8 @@ export function assertAgentDatabaseIntegrityBeforeMutation( userVersion === OPENCLAW_AGENT_SCHEMA_VERSION ? repairCanonicalSqliteIndexes(database, pathname, OPENCLAW_AGENT_SCHEMA_SQL, { allowMissingColumns: true, + validateAfterRepair: () => + assertOpenClawAgentCurrentRuntimeSchema(database, { agentId, pathname }), }) : []; if (rebuiltIndexes.length === 0) { diff --git a/src/state/openclaw-agent-db.test.ts b/src/state/openclaw-agent-db.test.ts index fc2ea39d59e2..be178b5d5132 100644 --- a/src/state/openclaw-agent-db.test.ts +++ b/src/state/openclaw-agent-db.test.ts @@ -43,6 +43,7 @@ import { ensureOpenClawAgentDatabaseSchema, inspectOpenClawAgentDatabaseOwner, listOpenClawRegisteredAgentDatabases, + migrateOpenClawAgentDatabaseForMaintenance, OPENCLAW_AGENT_SCHEMA_VERSION, openOpenClawAgentDatabase, resolveOpenClawAgentSqlitePath, @@ -2847,6 +2848,12 @@ describe("openclaw agent database", () => { expect(() => openOpenClawAgentDatabase({ agentId: "worker-1", env })).toThrow( /missing table auth_profile_store/iu, ); + expect(() => + migrateOpenClawAgentDatabaseForMaintenance({ + agentId: "worker-1", + pathname: databasePath, + }), + ).toThrow(/missing table auth_profile_store/iu); const after = new DatabaseSync(databasePath, { readOnly: true }); try { @@ -2943,8 +2950,7 @@ describe("openclaw agent database", () => { PRIMARY KEY (scope, key) ) STRICT; CREATE INDEX idx_agent_cache_expiry - ON cache_entries(scope, expires_at, key) - WHERE expires_at IS NOT NULL; + ON cache_entries(key); CREATE INDEX idx_agent_cache_updated ON cache_entries(scope, updated_at DESC, key); `); @@ -2953,6 +2959,26 @@ describe("openclaw agent database", () => { expect(() => openOpenClawAgentDatabase({ agentId: "worker-1", env })).toThrow( /unexpected unique index on cache_entries/iu, ); + expect(() => + migrateOpenClawAgentDatabaseForMaintenance({ + agentId: "worker-1", + pathname: databasePath, + }), + ).toThrow(/unexpected unique index on cache_entries/iu); + const after = new DatabaseSync(databasePath, { readOnly: true }); + try { + expect( + after + .prepare( + "SELECT sql FROM sqlite_schema WHERE type = 'index' AND name = 'idx_agent_cache_expiry'", + ) + .get(), + ).toEqual({ + sql: "CREATE INDEX idx_agent_cache_expiry\n ON cache_entries(key)", + }); + } finally { + after.close(); + } }); it("rejects primary-key collation drift in a current-schema table", () => { diff --git a/src/state/openclaw-state-db-schema-repair.ts b/src/state/openclaw-state-db-schema-repair.ts index ac4f5e0cf1e9..d37104544c13 100644 --- a/src/state/openclaw-state-db-schema-repair.ts +++ b/src/state/openclaw-state-db-schema-repair.ts @@ -107,7 +107,10 @@ export function repairLegacyGatewayRestartHandoffsForStrictMigration(db: Databas `); } -export function markCurrentStateSchemaVersion(db: DatabaseSync): void { +export function markCurrentStateSchemaVersion( + db: DatabaseSync, + options: { createMetadataIfMissing?: boolean } = {}, +): void { // Pre-v2 databases can legitimately predate the audit table. Leave their // version untouched so normal open can create the complete v2 schema first. if (!tableExists(db, "audit_events")) { @@ -120,9 +123,24 @@ export function markCurrentStateSchemaVersion(db: DatabaseSync): void { tableHasColumn(db, "schema_meta", column), ) ) { + const now = Date.now(); + if (options.createMetadataIfMissing) { + // Recognized pre-metadata schemas may acquire the global owner row during + // doctor migration. Conflicting existing ownership is preserved so the + // final maintenance assertion rejects and rolls back the repair. + db.prepare( + `INSERT INTO schema_meta ( + meta_key, role, schema_version, agent_id, app_version, created_at, updated_at + ) VALUES ('primary', 'global', ?, NULL, NULL, ?, ?) + ON CONFLICT(meta_key) DO UPDATE SET + schema_version = excluded.schema_version, + updated_at = excluded.updated_at`, + ).run(OPENCLAW_STATE_SCHEMA_VERSION, now, now); + return; + } db.prepare( "UPDATE schema_meta SET schema_version = ?, updated_at = ? WHERE meta_key = 'primary'", - ).run(OPENCLAW_STATE_SCHEMA_VERSION, Date.now()); + ).run(OPENCLAW_STATE_SCHEMA_VERSION, now); } } diff --git a/src/state/openclaw-state-db.test.ts b/src/state/openclaw-state-db.test.ts index 917c85202539..13cfc3ad24ab 100644 --- a/src/state/openclaw-state-db.test.ts +++ b/src/state/openclaw-state-db.test.ts @@ -750,7 +750,11 @@ function runHotRollbackJournalRecoveryProbe(params: { moduleUrl: string; rootDir }; } -function expectNoncanonicalAuditSchemaRejected(stateDir: string, databasePath: string): void { +function expectNoncanonicalAuditSchemaRejected( + stateDir: string, + databasePath: string, + doctorWarning = "cannot be repaired automatically", +): void { const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; expect(detectOpenClawStateDatabaseSchemaMigrations(options)).toEqual([ { kind: "audit-events-v2", path: databasePath }, @@ -758,7 +762,7 @@ function expectNoncanonicalAuditSchemaRejected(stateDir: string, databasePath: s expect(() => openOpenClawStateDatabase(options)).toThrow(/noncanonical audit event schema/); expect(repairOpenClawStateDatabaseSchema(options)).toEqual({ changes: [], - warnings: [expect.stringContaining("cannot be repaired automatically")], + warnings: [expect.stringContaining(doctorWarning)], }); } @@ -1810,6 +1814,10 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're drifted.close(); expect(() => openOpenClawStateDatabase(options)).toThrow(/missing table auth_profile_stores/iu); + expect(repairOpenClawStateDatabaseSchema(options)).toEqual({ + changes: [], + warnings: [expect.stringContaining("missing table auth_profile_stores")], + }); const after = new DatabaseSync(databasePath, { readOnly: true }); try { @@ -1872,13 +1880,31 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're PRIMARY KEY (scope, event_key) ) STRICT; CREATE INDEX idx_diagnostic_events_scope_sequence - ON diagnostic_events(scope, sequence, event_key); + ON diagnostic_events(event_key); `); drifted.close(); expect(() => openOpenClawStateDatabase(options)).toThrow( /unexpected unique index on diagnostic_events/iu, ); + expect(repairOpenClawStateDatabaseSchema(options)).toEqual({ + changes: [], + warnings: [expect.stringContaining("unexpected unique index on diagnostic_events")], + }); + const after = new DatabaseSync(databasePath, { readOnly: true }); + try { + expect( + after + .prepare( + "SELECT sql FROM sqlite_schema WHERE type = 'index' AND name = 'idx_diagnostic_events_scope_sequence'", + ) + .get(), + ).toEqual({ + sql: "CREATE INDEX idx_diagnostic_events_scope_sequence\n ON diagnostic_events(event_key)", + }); + } finally { + after.close(); + } }); it("rejects primary-key collation drift in a current-schema table", () => { @@ -2093,6 +2119,42 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're } }); + it("does not claim a legacy audit database with conflicting ownership", () => { + const stateDir = createTempStateDir(); + const databasePath = createLegacyAuditStateDatabase(stateDir); + const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; + const { DatabaseSync } = requireNodeSqlite(); + const legacy = new DatabaseSync(databasePath); + legacy.exec("UPDATE schema_meta SET role = 'agent', agent_id = 'worker-1';"); + legacy.close(); + + expect(repairOpenClawStateDatabaseSchema(options)).toEqual({ + changes: [], + warnings: [expect.stringContaining("schema role agent; expected global")], + }); + + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + try { + expect(readSqliteNumberPragma(preserved, "user_version")).toBe(1); + expect( + preserved + .prepare( + "SELECT role, schema_version, agent_id FROM schema_meta WHERE meta_key = 'primary'", + ) + .get(), + ).toEqual({ role: "agent", schema_version: 1, agent_id: "worker-1" }); + expect( + preserved + .prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'auth_profile_stores'", + ) + .get(), + ).toBeUndefined(); + } finally { + preserved.close(); + } + }); + it("refuses an audit sequence high-water mark outside the supported cursor range", () => { const stateDir = createTempStateDir(); const databasePath = createLegacyAuditStateDatabase(stateDir); @@ -2264,7 +2326,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're malformed.exec("DROP TABLE audit_events"); malformed.close(); - expectNoncanonicalAuditSchemaRejected(stateDir, databasePath); + expectNoncanonicalAuditSchemaRejected(stateDir, databasePath, "missing table audit_events"); const preserved = new DatabaseSync(databasePath, { readOnly: true }); try { diff --git a/src/state/openclaw-state-db.ts b/src/state/openclaw-state-db.ts index e3de63c5197f..76c2c0895d4c 100644 --- a/src/state/openclaw-state-db.ts +++ b/src/state/openclaw-state-db.ts @@ -17,6 +17,7 @@ import { type SqliteIntegrityConfirmation, } from "../infra/sqlite-integrity.js"; import { prepareSqliteReadOnlyLocation } from "../infra/sqlite-readonly-location.js"; +import { assertSqliteSchemaTablesPresent } from "../infra/sqlite-schema-contract.js"; import { migrateSqliteSchemaToStrictInTransaction } from "../infra/sqlite-strict.js"; import { createSqliteTerminalOpenLatch } from "../infra/sqlite-terminal-open-latch.js"; import { @@ -148,22 +149,25 @@ export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabase const rebuiltIndexNames = new Set(); try { assertSupportedSchemaVersion(db, pathname); - if (readSqliteUserVersion(db) === OPENCLAW_STATE_SCHEMA_VERSION) { - for (const name of repairCanonicalSqliteIndexes(db, pathname, OPENCLAW_STATE_SCHEMA_SQL, { - allowMissingColumns: true, - })) { - rebuiltIndexNames.add(name); - } - } - if (rebuiltIndexNames.size === 0) { - assertSqliteIntegrity(db, pathname); - } db.exec("PRAGMA foreign_keys = OFF;"); const changes = runSqliteImmediateTransactionSync( db, () => { const applied: string[] = []; const previousVersion = readSqliteUserVersion(db); + if (previousVersion === OPENCLAW_STATE_SCHEMA_VERSION) { + for (const name of repairCanonicalSqliteIndexes(db, pathname, OPENCLAW_STATE_SCHEMA_SQL, { + allowMissingColumns: true, + })) { + rebuiltIndexNames.add(name); + } + // Current-schema doctor repair may normalize recognized columns or + // table options, but it must never recreate a missing table empty. + assertSqliteSchemaTablesPresent(db, pathname, OPENCLAW_STATE_SCHEMA_SQL); + } + if (rebuiltIndexNames.size === 0) { + assertSqliteIntegrity(db, pathname); + } dropLegacyStateTables(db); if (repairAgentDatabasesCompositePrimaryKey(db)) { applied.push(`Migrated shared state agent database registry primary key → agent_id,path`); @@ -205,7 +209,12 @@ export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabase rebuiltIndexNames.add(name); } } - markCurrentStateSchemaVersion(db); + markCurrentStateSchemaVersion(db, { + createMetadataIfMissing: previousVersion < OPENCLAW_STATE_SCHEMA_VERSION, + }); + if (readSqliteUserVersion(db) === OPENCLAW_STATE_SCHEMA_VERSION) { + assertCurrentStateRuntimeSchema(db, pathname); + } if (rebuiltIndexNames.size > 0) { applied.push(`Rebuilt canonical shared-state SQLite indexes (${rebuiltIndexNames.size})`); } @@ -262,8 +271,7 @@ function ensureSchema(db: DatabaseSync, pathname: string): void { repairCanonicalSqliteIndexes(db, pathname, OPENCLAW_STATE_SCHEMA_SQL, { verifyPhysicalIntegrity: false, }); - assertCanonicalStateSchemaShape(db, pathname); - assertOpenClawStateDatabaseForMaintenance(db, { pathname }); + assertCurrentStateRuntimeSchema(db, pathname); } else if (previousVersion === 5) { assertOpenClawStateDatabaseV5ForMigration(db, { pathname }); } @@ -394,6 +402,12 @@ export async function openExistingOpenClawStateDatabaseReadOnly( }, }; } + +function assertCurrentStateRuntimeSchema(database: DatabaseSync, pathname: string): void { + assertCanonicalStateSchemaShape(database, pathname); + assertOpenClawStateDatabaseForMaintenance(database, { pathname }); +} + function assertStateDatabaseIntegrityBeforeMutation( database: DatabaseSync, pathname: string, @@ -417,12 +431,16 @@ function assertStateDatabaseIntegrityBeforeMutation( userVersion === OPENCLAW_STATE_SCHEMA_VERSION ? repairCanonicalSqliteIndexes(database, pathname, OPENCLAW_STATE_SCHEMA_SQL, { allowMissingColumns: true, + validateAfterRepair: () => assertCurrentStateRuntimeSchema(database, pathname), }) : []; if (rebuiltIndexes.length === 0) { // Every physical open proves the full file before schema mutation or exposure. assertSqliteIntegrity(database, pathname); } + if (userVersion === OPENCLAW_STATE_SCHEMA_VERSION) { + assertCurrentStateRuntimeSchema(database, pathname); + } } /** Open or return a cached shared state database after schema and migration checks. */