fix(doctor): honor explicit plugin-only migration authority (#118700)

This commit is contained in:
Peter Steinberger
2026-08-03 07:34:49 -07:00
committed by GitHub
parent a1a73cf421
commit 395c772ba7
4 changed files with 105 additions and 4 deletions
@@ -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");
+6
View File
@@ -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 }
: {}),
}),
),
);
+2
View File
@@ -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({
+87 -1
View File
@@ -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");