diff --git a/src/infra/state-migrations.doctor.ts b/src/infra/state-migrations.doctor.ts index f7306a0fbf52..96314d12ccfc 100644 --- a/src/infra/state-migrations.doctor.ts +++ b/src/infra/state-migrations.doctor.ts @@ -36,6 +36,7 @@ import { import { detectOpenClawStateDatabaseSchemaMigrations, repairOpenClawStateDatabaseSchema, + repairOpenClawStateDatabaseSchemaIfNeeded, type OpenClawStateDatabaseSchemaMigration, } from "../state/openclaw-state-db.js"; import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; @@ -956,7 +957,7 @@ export async function autoMigrateLegacyPluginDoctorState(params: { }); const stateDir = resolveStateDir(env, params.homedir ?? os.homedir); const oauthDir = resolveOAuthDir(env, stateDir); - const stateSchema = repairOpenClawStateDatabaseSchema({ + const stateSchema = repairOpenClawStateDatabaseSchemaIfNeeded({ env: { ...env, OPENCLAW_STATE_DIR: stateDir }, }); const changes = [...stateDirResult.changes, ...stateSchema.changes]; @@ -1347,9 +1348,11 @@ export async function autoMigrateLegacyState(params: { }); const stateDir = resolveStateDir(env, homedir); autoMigrateChecked.add(`${path.resolve(stateDir)}\0${migrationMode}`); - const stateSchema = repairOpenClawStateDatabaseSchema({ - env: { ...env, OPENCLAW_STATE_DIR: stateDir }, - }); + const stateSchemaOptions = { env: { ...env, OPENCLAW_STATE_DIR: stateDir } }; + const stateSchema = + params.doctorOnlyStateMigrations === true + ? repairOpenClawStateDatabaseSchema(stateSchemaOptions) + : repairOpenClawStateDatabaseSchemaIfNeeded(stateSchemaOptions); if (stateSchema.warnings.length > 0) { return { migrated: stateDirResult.migrated || stateSchema.changes.length > 0, diff --git a/src/infra/state-migrations.test.ts b/src/infra/state-migrations.test.ts index a24f4f96a698..188db445bd6e 100644 --- a/src/infra/state-migrations.test.ts +++ b/src/infra/state-migrations.test.ts @@ -512,6 +512,25 @@ describe("state migrations", () => { detectionCase = { ...detected, stateDir, env }; }); + it("keeps automatic migration read-only when the shared schema is current", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const env = createEnv(stateDir); + const databasePath = openOpenClawStateDatabase({ env }).path; + closeOpenClawStateDatabaseForTest(); + + const writer = new DatabaseSync(databasePath); + writer.exec("PRAGMA journal_mode = WAL; BEGIN IMMEDIATE;"); + try { + await expect( + autoMigrateLegacyState({ cfg: createConfig(), env, homedir: () => root }), + ).resolves.toMatchObject({ changes: [], warnings: [] }); + } finally { + writer.exec("ROLLBACK;"); + writer.close(); + } + }); + it("uses the requested environment for plugin migration refresh and writes", async () => { const root = await createTempDir(); const stateDir = path.join(root, "custom-state"); diff --git a/src/state/openclaw-state-db.test.ts b/src/state/openclaw-state-db.test.ts index a7a1c708c3c2..de6ffd62ed53 100644 --- a/src/state/openclaw-state-db.test.ts +++ b/src/state/openclaw-state-db.test.ts @@ -30,6 +30,7 @@ import { openOpenClawStateDatabase, OPENCLAW_STATE_SCHEMA_VERSION, repairOpenClawStateDatabaseSchema, + repairOpenClawStateDatabaseSchemaIfNeeded, runOpenClawStateWriteTransaction, withOpenClawStateStartupMigrationCheckpointDatabase, } from "./openclaw-state-db.js"; @@ -1068,6 +1069,32 @@ describe("openclaw state database", () => { ).toThrow(); }); + it("skips exclusive repair when the automatic schema gate is already current", () => { + const stateDir = createTempStateDir(); + const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; + const databasePath = openOpenClawStateDatabase(options).path; + closeOpenClawStateDatabaseForTest(); + + const { DatabaseSync } = requireNodeSqlite(); + const before = new DatabaseSync(databasePath); + before.prepare("UPDATE schema_meta SET updated_at = 123 WHERE meta_key = 'primary'").run(); + before.close(); + + expect(repairOpenClawStateDatabaseSchemaIfNeeded(options)).toEqual({ + changes: [], + warnings: [], + }); + + const after = new DatabaseSync(databasePath, { readOnly: true }); + try { + expect( + after.prepare("SELECT updated_at FROM schema_meta WHERE meta_key = 'primary'").get(), + ).toEqual({ updated_at: 123 }); + } finally { + after.close(); + } + }); + it("drops unreleased transient verification history on open", () => { const stateDir = createTempStateDir(); const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; diff --git a/src/state/openclaw-state-db.ts b/src/state/openclaw-state-db.ts index 6a64864441d6..6b2d0ba47524 100644 --- a/src/state/openclaw-state-db.ts +++ b/src/state/openclaw-state-db.ts @@ -64,6 +64,7 @@ import { ensureAdditiveStateColumns } from "./openclaw-state-db-schema-additive. import { tableExists } from "./openclaw-state-db-schema-helpers.js"; import { assertCanonicalStateSchemaShape, + detectOpenClawStateDatabaseSchemaMigrationsFromDatabase, dropLegacyStateTables, markCurrentStateSchemaVersion, repairAgentDatabasesCompositePrimaryKey, @@ -292,6 +293,41 @@ export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabase } } +/** Skip the exclusive doctor repair when automatic migration sees a canonical current schema. */ +export function repairOpenClawStateDatabaseSchemaIfNeeded( + options: OpenClawStateDatabaseOptions = {}, +): { + changes: string[]; + warnings: string[]; +} { + const pathname = resolveDatabasePath(options); + if (!existsSync(pathname)) { + return { changes: [], warnings: [] }; + } + + let needsRepair = true; + let database: DatabaseSync | undefined; + try { + database = openNodeSqliteDatabase(pathname, { readOnly: true }); + assertSupportedSchemaVersion(database, pathname); + needsRepair = + readSqliteUserVersion(database) !== OPENCLAW_STATE_SCHEMA_VERSION || + detectOpenClawStateDatabaseSchemaMigrationsFromDatabase(database, pathname).length > 0; + if (!needsRepair) { + assertCurrentStateRuntimeSchema(database, pathname); + } + } catch { + // Preserve the repair path's existing diagnostics for unreadable or noncanonical databases. + needsRepair = true; + } finally { + if (database?.isOpen) { + database.close(); + } + } + + return needsRepair ? repairOpenClawStateDatabaseSchema(options) : { changes: [], warnings: [] }; +} + function ensureSchema(db: DatabaseSync, pathname: string): void { const now = Date.now(); const kysely = getNodeSqliteKysely(db);