From 395c772ba7784cb9ea5cc1cb7f748e5855444dcb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 3 Aug 2026 07:34:49 -0700 Subject: [PATCH] fix(doctor): honor explicit plugin-only migration authority (#118700) --- ...r-config-preflight.state-migration.test.ts | 13 ++- src/commands/doctor-config-preflight.ts | 6 ++ src/infra/state-migrations.doctor.ts | 2 + src/infra/state-migrations.test.ts | 88 ++++++++++++++++++- 4 files changed, 105 insertions(+), 4 deletions(-) diff --git a/src/commands/doctor-config-preflight.state-migration.test.ts b/src/commands/doctor-config-preflight.state-migration.test.ts index 6c5741c94a88..7810fe13406b 100644 --- a/src/commands/doctor-config-preflight.state-migration.test.ts +++ b/src/commands/doctor-config-preflight.state-migration.test.ts @@ -731,7 +731,7 @@ describe("runDoctorConfigPreflight state migration", () => { }); }); - it("retains the prepared core-state fact after runtime files appear", async () => { + it("retains the prepared core-state fact and explicit Doctor repair authority", async () => { needsStartupMigrationCheckpoint.mockReturnValue(true); await runDoctorConfigPreflight({ @@ -739,10 +739,15 @@ describe("runDoctorConfigPreflight state migration", () => { invalidConfigNote: false, requireStartupMigrationCheckpoint: true, skipPristineCoreStateMigrations: true, + doctorOnlyStateMigrations: true, }); expect(autoMigrateLegacyState).not.toHaveBeenCalled(); - expect(autoMigrateLegacyPluginDoctorState).toHaveBeenCalledOnce(); + expect(autoMigrateLegacyPluginDoctorState).toHaveBeenCalledWith({ + config: { gateway: { mode: "local", port: 19091 } }, + env: process.env, + doctorOnlyStateMigrations: true, + }); }); it("blocks gateway readiness when startup migrations leave warnings", async () => { @@ -1003,7 +1008,7 @@ describe("runDoctorConfigPreflight state migration", () => { }); }); - it("keeps plugin state migrations for partially valid legacy config repairs", async () => { + it("keeps explicit Doctor repair authority for partially valid legacy config", async () => { const resolvedConfig = { gateway: { mode: "local", port: "not-a-port" }, memory: { @@ -1040,6 +1045,7 @@ describe("runDoctorConfigPreflight state migration", () => { await runDoctorConfigPreflight({ migrateLegacyConfig: false, invalidConfigNote: false, + doctorOnlyStateMigrations: true, }); expect(repairLegacyCronStoreWithoutPrompt).not.toHaveBeenCalled(); @@ -1047,6 +1053,7 @@ describe("runDoctorConfigPreflight state migration", () => { expect(autoMigrateLegacyPluginDoctorState).toHaveBeenCalledWith({ config: resolvedConfig, env: process.env, + doctorOnlyStateMigrations: true, }); expect(autoMigrateLegacyTaskStateSidecars).toHaveBeenCalledWith({ env: process.env }); expect(note).toHaveBeenCalledWith("- plugin-imported", "Doctor changes"); diff --git a/src/commands/doctor-config-preflight.ts b/src/commands/doctor-config-preflight.ts index 39640a36590e..3676b3fea45a 100644 --- a/src/commands/doctor-config-preflight.ts +++ b/src/commands/doctor-config-preflight.ts @@ -388,6 +388,9 @@ export async function runDoctorConfigPreflight( autoMigrateLegacyPluginDoctorState({ config: pluginDoctorOnlyConfig, env: process.env, + ...(options.doctorOnlyStateMigrations === true + ? { doctorOnlyStateMigrations: true } + : {}), }), ), ); @@ -432,6 +435,9 @@ export async function runDoctorConfigPreflight( autoMigrateLegacyPluginDoctorState({ config: pluginDoctorConfig, env: process.env, + ...(options.doctorOnlyStateMigrations === true + ? { doctorOnlyStateMigrations: true } + : {}), }), ), ); diff --git a/src/infra/state-migrations.doctor.ts b/src/infra/state-migrations.doctor.ts index ebb102010e32..e3089874869a 100644 --- a/src/infra/state-migrations.doctor.ts +++ b/src/infra/state-migrations.doctor.ts @@ -900,6 +900,7 @@ export async function autoMigrateLegacyPluginDoctorState(params: { env?: NodeJS.ProcessEnv; homedir?: () => string; log?: MigrationLogger; + doctorOnlyStateMigrations?: boolean; }): Promise<{ migrated: boolean; skipped: boolean; @@ -935,6 +936,7 @@ export async function autoMigrateLegacyPluginDoctorState(params: { env, stateDir, oauthDir, + includeDoctorOnly: params.doctorOnlyStateMigrations === true, warnings, }); const migrated = await migratePluginDoctorStatePlans({ diff --git a/src/infra/state-migrations.test.ts b/src/infra/state-migrations.test.ts index 67258d8350fa..1856810246cd 100644 --- a/src/infra/state-migrations.test.ts +++ b/src/infra/state-migrations.test.ts @@ -6,9 +6,13 @@ import { DatabaseSync } from "node:sqlite"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { readAcpSessionMetaForEntry } from "../acp/runtime/session-meta.js"; import type { OpenClawConfig } from "../config/config.js"; +import { readMemoryHostEventRecords } from "../memory-host-sdk/events.js"; import { loadNodeHostConfig } from "../node-host/config.js"; import { readChannelPairingStateSnapshot } from "../pairing/pairing-store-sqlite.test-helpers.js"; -import type { PluginDoctorStateMigrationContext } from "../plugins/doctor-contract-registry.js"; +import type { + PluginDoctorStateMigration, + PluginDoctorStateMigrationContext, +} from "../plugins/doctor-contract-registry.js"; import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; import { closeOpenClawStateDatabaseForTest, @@ -883,6 +887,88 @@ describe("state migrations", () => { expect(migrateLegacyState).toHaveBeenCalledOnce(); }); + it("restores retained Memory Core host events only for explicit plugin-only Doctor repair", async () => { + const root = await fs.realpath(await createTempDir()); + const stateDir = path.join(root, ".openclaw"); + const workspaceDir = path.join(root, "workspace"); + const eventPath = path.join(workspaceDir, "memory", ".dreams", "events.jsonl"); + const env = createEnv(stateDir); + const cfg = { + agents: { list: [{ id: "main", default: true, workspace: workspaceDir }] }, + } as OpenClawConfig; + const event = { + type: "memory.recall.recorded", + timestamp: "2026-07-01T00:00:00.000Z", + query: "retained before upgrade", + resultCount: 0, + results: [], + } as const; + await fs.mkdir(path.dirname(eventPath), { recursive: true }); + await fs.writeFile(eventPath, `${JSON.stringify(event)}\n`, "utf8"); + + const { stateMigrations } = (await import( + /* @vite-ignore */ new URL( + "../../extensions/memory-core/doctor-contract-api.ts", + import.meta.url, + ).href + )) as { stateMigrations: PluginDoctorStateMigration[] }; + const migration = stateMigrations.find( + (candidate) => candidate.id === "memory-core-host-events-jsonl-to-sqlite", + ); + expect(migration).toBeDefined(); + if (!migration) { + throw new Error("Expected the bundled Memory Core host-event Doctor migration"); + } + pluginDoctorStateMigrationEntries.entries = [ + { + pluginId: "memory-core", + migration: { + id: migration.id, + label: migration.label, + doctorOnly: migration.doctorOnly, + detectLegacyState: (params) => + migration.detectLegacyState({ + ...params, + context: params.context as PluginDoctorStateMigrationContext, + }), + migrateLegacyState: async (params) => { + const result = await migration.migrateLegacyState({ + ...params, + context: params.context as PluginDoctorStateMigrationContext, + }); + return { changes: result.changes, warnings: result.warnings }; + }, + }, + }, + ]; + + const automatic = await autoMigrateLegacyPluginDoctorState({ + config: cfg, + env, + homedir: () => root, + }); + expect(automatic.warnings).toEqual([]); + expect(automatic.changes).not.toContain( + "Migrated Memory Core host events -> SQLite plugin state (1 new row(s))", + ); + await expect(readMemoryHostEventRecords({ workspaceDir, env })).resolves.toEqual([]); + await expect(fs.stat(eventPath)).resolves.toBeDefined(); + + const repaired = await autoMigrateLegacyPluginDoctorState({ + config: cfg, + env, + homedir: () => root, + doctorOnlyStateMigrations: true, + }); + expect(repaired.warnings).toEqual([]); + expect(repaired.changes).toContain( + "Migrated Memory Core host events -> SQLite plugin state (1 new row(s))", + ); + await expect(readMemoryHostEventRecords({ workspaceDir, env })).resolves.toEqual([event]); + await expectMissingPath(eventPath); + await expect(fs.stat(`${eventPath}.migrated`)).resolves.toBeDefined(); + }); + it("runs doctor-only repairs after the automatic migration check", async () => { const root = await createTempDir(); const stateDir = path.join(root, ".openclaw");