Skip to content

Commit c2d825a

Browse files
authored
fix: migrate legacy agent registry schema via doctor
Move the shipped legacy shared-state agent database registry repair into doctor. Runtime now fails fast with a doctor repair hint when the old primary-key shape remains.
1 parent f36e54c commit c2d825a

5 files changed

Lines changed: 364 additions & 11 deletions

File tree

src/commands/doctor-state-migrations.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import fs from "node:fs";
33
import os from "node:os";
44
import path from "node:path";
5+
import type { DatabaseSync } from "node:sqlite";
56
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
67
import type { OpenClawConfig } from "../config/config.js";
78
import type { SessionEntry } from "../config/sessions/types.js";
@@ -259,6 +260,51 @@ function writeJson5(filePath: string, value: unknown) {
259260
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf-8");
260261
}
261262

263+
function readPrimaryKeyColumns(db: DatabaseSync, tableName: string): string[] {
264+
const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{
265+
name?: unknown;
266+
pk?: unknown;
267+
}>;
268+
return rows
269+
.filter((row) => Number(row.pk ?? 0) > 0 && typeof row.name === "string")
270+
.toSorted((left, right) => Number(left.pk ?? 0) - Number(right.pk ?? 0))
271+
.map((row) => row.name as string);
272+
}
273+
274+
function createLegacyAgentDatabaseRegistry(stateDir: string): string {
275+
const stateDatabasePath = path.join(stateDir, "state", "openclaw.sqlite");
276+
fs.mkdirSync(path.dirname(stateDatabasePath), { recursive: true });
277+
const { DatabaseSync } = requireNodeSqlite();
278+
const db = new DatabaseSync(stateDatabasePath);
279+
try {
280+
db.exec(`
281+
CREATE TABLE agent_databases (
282+
agent_id TEXT NOT NULL PRIMARY KEY,
283+
path TEXT NOT NULL,
284+
schema_version INTEGER NOT NULL,
285+
last_seen_at INTEGER NOT NULL,
286+
size_bytes INTEGER
287+
);
288+
INSERT INTO agent_databases (
289+
agent_id,
290+
path,
291+
schema_version,
292+
last_seen_at,
293+
size_bytes
294+
) VALUES (
295+
'worker-1',
296+
'/legacy/worker-1/openclaw-agent.sqlite',
297+
1,
298+
10,
299+
20
300+
);
301+
`);
302+
} finally {
303+
db.close();
304+
}
305+
return stateDatabasePath;
306+
}
307+
262308
function writeLegacySessionsFixture(params: {
263309
root: string;
264310
sessions: Record<string, Record<string, unknown> & { sessionId: string; updatedAt: number }>;
@@ -637,6 +683,82 @@ describe("doctor legacy state migrations", () => {
637683
expect(store["agent:main:subagent:xyz"]?.sessionId).toBe("e");
638684
});
639685

686+
it("migrates the legacy shared state agent registry primary key", async () => {
687+
const root = await makeTempRoot();
688+
const stateDir = path.join(root, ".openclaw");
689+
const stateDatabasePath = createLegacyAgentDatabaseRegistry(stateDir);
690+
const detected = await detectLegacyStateMigrations({
691+
cfg: {},
692+
env: {} as NodeJS.ProcessEnv,
693+
homedir: () => root,
694+
});
695+
696+
expect(detected.preview).toContain(
697+
"- Shared SQLite schema: agent database registry primary key → agent_id,path",
698+
);
699+
700+
const result = await runLegacyStateMigrations({ detected });
701+
expect(result.warnings).toStrictEqual([]);
702+
expect(result.changes).toStrictEqual([
703+
"Migrated shared state agent database registry primary key → agent_id,path",
704+
]);
705+
706+
const { DatabaseSync } = requireNodeSqlite();
707+
const db = new DatabaseSync(stateDatabasePath);
708+
try {
709+
expect(readPrimaryKeyColumns(db, "agent_databases")).toEqual(["agent_id", "path"]);
710+
expect(() =>
711+
db.exec(`
712+
INSERT INTO agent_databases (
713+
agent_id,
714+
path,
715+
schema_version,
716+
last_seen_at,
717+
size_bytes
718+
) VALUES (
719+
'worker-1',
720+
'/relocated/worker-1/openclaw-agent.sqlite',
721+
1,
722+
20,
723+
30
724+
)
725+
ON CONFLICT(agent_id, path) DO UPDATE SET
726+
last_seen_at = excluded.last_seen_at,
727+
size_bytes = excluded.size_bytes;
728+
`),
729+
).not.toThrow();
730+
} finally {
731+
db.close();
732+
}
733+
});
734+
735+
it("does not repair newer shared state schemas", async () => {
736+
const root = await makeTempRoot();
737+
const stateDir = path.join(root, ".openclaw");
738+
const stateDatabasePath = createLegacyAgentDatabaseRegistry(stateDir);
739+
const { DatabaseSync } = requireNodeSqlite();
740+
const seededDb = new DatabaseSync(stateDatabasePath);
741+
seededDb.exec("PRAGMA user_version = 2;");
742+
seededDb.close();
743+
744+
const detected = await detectLegacyStateMigrations({
745+
cfg: {},
746+
env: {} as NodeJS.ProcessEnv,
747+
homedir: () => root,
748+
});
749+
const result = await runLegacyStateMigrations({ detected });
750+
expect(result.changes).toStrictEqual([]);
751+
expect(result.warnings).toHaveLength(1);
752+
expect(result.warnings[0]).toContain("uses newer schema version 2");
753+
754+
const db = new DatabaseSync(stateDatabasePath);
755+
try {
756+
expect(readPrimaryKeyColumns(db, "agent_databases")).toEqual(["agent_id"]);
757+
} finally {
758+
db.close();
759+
}
760+
});
761+
640762
it("migrates legacy ACP metadata from sessions.json into shared SQLite", async () => {
641763
const root = await makeTempRoot();
642764
const cfg: OpenClawConfig = {};

src/commands/doctor.e2e-harness.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,10 @@ function createLegacyStateMigrationDetectionResult(params?: {
227227
sourcePath: "/tmp/state/plugins/installs.json",
228228
hasLegacy: false,
229229
},
230+
stateSchema: {
231+
hasLegacy: false,
232+
preview: [],
233+
},
230234
taskStateSidecars: {
231235
taskRunsPath: "/tmp/state/tasks/runs.sqlite",
232236
flowRunsPath: "/tmp/state/flows/registry.sqlite",

src/infra/state-migrations.ts

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,11 @@ import {
5656
} from "../routing/session-key.js";
5757
import { normalizeSessionKeyPreservingOpaquePeerIds } from "../sessions/session-key-utils.js";
5858
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
59-
import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js";
59+
import {
60+
detectOpenClawStateDatabaseSchemaMigrations,
61+
repairOpenClawStateDatabaseSchema,
62+
runOpenClawStateWriteTransaction,
63+
} from "../state/openclaw-state-db.js";
6064
import { expandHomePrefix } from "./home-dir.js";
6165
import {
6266
executeSqliteQuerySync,
@@ -110,6 +114,10 @@ export type LegacyStateDetection = {
110114
sourcePath: string;
111115
hasLegacy: boolean;
112116
};
117+
stateSchema: {
118+
hasLegacy: boolean;
119+
preview: string[];
120+
};
113121
taskStateSidecars: {
114122
taskRunsPath: string;
115123
flowRunsPath: string;
@@ -2627,6 +2635,9 @@ export async function detectLegacyStateMigrations(params: {
26272635
const hasPluginStateSidecar = fileExists(pluginStateSidecarPath);
26282636
const pluginInstallIndexPath = resolveLegacyInstalledPluginIndexStorePath({ stateDir });
26292637
const hasPluginInstallIndex = fileExists(pluginInstallIndexPath);
2638+
const stateSchemaMigrations = detectOpenClawStateDatabaseSchemaMigrations({
2639+
env: { ...env, OPENCLAW_STATE_DIR: stateDir },
2640+
});
26302641
const taskRunsSidecarPath = resolveLegacyTaskRunsSidecarPath(stateDir);
26312642
const flowRunsSidecarPath = resolveLegacyFlowRunsSidecarPath(stateDir);
26322643
const hasTaskStateSidecars = fileExists(taskRunsSidecarPath) || fileExists(flowRunsSidecarPath);
@@ -2645,12 +2656,15 @@ export async function detectLegacyStateMigrations(params: {
26452656
stateDir,
26462657
oauthDir,
26472658
});
2648-
const pluginPlans = await collectPluginDoctorStateMigrationPlans({
2649-
cfg: params.cfg,
2650-
env,
2651-
stateDir,
2652-
oauthDir,
2653-
});
2659+
const pluginPlans =
2660+
stateSchemaMigrations.length > 0
2661+
? []
2662+
: await collectPluginDoctorStateMigrationPlans({
2663+
cfg: params.cfg,
2664+
env,
2665+
stateDir,
2666+
oauthDir,
2667+
});
26542668

26552669
const preview: string[] = [];
26562670
if (hasLegacySessions) {
@@ -2668,6 +2682,12 @@ export async function detectLegacyStateMigrations(params: {
26682682
if (hasPluginInstallIndex) {
26692683
preview.push(`- Plugin install index: ${pluginInstallIndexPath} → shared SQLite state`);
26702684
}
2685+
if (stateSchemaMigrations.length > 0) {
2686+
preview.push("- Shared SQLite schema: agent database registry primary key → agent_id,path");
2687+
preview.push(
2688+
"- Rerun doctor after shared SQLite schema repair to detect plugin state migrations",
2689+
);
2690+
}
26712691
if (fileExists(taskRunsSidecarPath)) {
26722692
preview.push(`- Task registry sidecar: ${taskRunsSidecarPath} → shared SQLite state`);
26732693
}
@@ -2719,6 +2739,10 @@ export async function detectLegacyStateMigrations(params: {
27192739
sourcePath: pluginInstallIndexPath,
27202740
hasLegacy: hasPluginInstallIndex,
27212741
},
2742+
stateSchema: {
2743+
hasLegacy: stateSchemaMigrations.length > 0,
2744+
preview: stateSchemaMigrations.map((migration) => migration.path),
2745+
},
27222746
taskStateSidecars: {
27232747
taskRunsPath: taskRunsSidecarPath,
27242748
flowRunsPath: flowRunsSidecarPath,
@@ -2969,6 +2993,15 @@ async function runPluginDoctorStateMigrationPlans(params: {
29692993
return { changes, warnings };
29702994
}
29712995

2996+
function migrateLegacyStateSchema(detected: LegacyStateDetection): {
2997+
changes: string[];
2998+
warnings: string[];
2999+
} {
3000+
return repairOpenClawStateDatabaseSchema({
3001+
env: { ...process.env, OPENCLAW_STATE_DIR: detected.stateDir },
3002+
});
3003+
}
3004+
29723005
export async function runLegacyStateMigrations(params: {
29733006
detected: LegacyStateDetection;
29743007
config?: OpenClawConfig;
@@ -2977,6 +3010,10 @@ export async function runLegacyStateMigrations(params: {
29773010
}): Promise<{ changes: string[]; warnings: string[] }> {
29783011
const now = params.now ?? (() => Date.now());
29793012
const detected = params.detected;
3013+
const stateSchema = migrateLegacyStateSchema(detected);
3014+
if (detected.stateSchema.hasLegacy && stateSchema.warnings.length > 0) {
3015+
return stateSchema;
3016+
}
29803017
const pluginStateSidecar = await migrateLegacyPluginStateSidecar({
29813018
stateDir: detected.stateDir,
29823019
});
@@ -2992,10 +3029,12 @@ export async function runLegacyStateMigrations(params: {
29923029
const preSessionChannelPlans = await runLegacyMigrationPlans(
29933030
detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"),
29943031
);
2995-
const pluginPlans = await runPluginDoctorStateMigrationPlans({
2996-
detected,
2997-
config: params.config ?? ({} as OpenClawConfig),
2998-
});
3032+
const pluginPlans = detected.stateSchema.hasLegacy
3033+
? { changes: [], warnings: [] }
3034+
: await runPluginDoctorStateMigrationPlans({
3035+
detected,
3036+
config: params.config ?? ({} as OpenClawConfig),
3037+
});
29993038
const sessions = await migrateLegacySessions(detected, now, {
30003039
recoverCorruptTargetStore: params.recoverCorruptTargetStore,
30013040
});
@@ -3010,6 +3049,7 @@ export async function runLegacyStateMigrations(params: {
30103049
);
30113050
return {
30123051
changes: [
3052+
...stateSchema.changes,
30133053
...pluginStateSidecar.changes,
30143054
...pluginInstallIndex.changes,
30153055
...taskStateSidecars.changes,
@@ -3022,6 +3062,7 @@ export async function runLegacyStateMigrations(params: {
30223062
...channelPlans.changes,
30233063
],
30243064
warnings: [
3065+
...stateSchema.warnings,
30253066
...pluginStateSidecar.warnings,
30263067
...pluginInstallIndex.warnings,
30273068
...taskStateSidecars.warnings,
@@ -3306,6 +3347,10 @@ export async function autoMigrateLegacyState(params: {
33063347
homedir: params.homedir,
33073348
log: params.log,
33083349
});
3350+
const stateDir = resolveStateDir(env, params.homedir ?? os.homedir);
3351+
const stateSchema = repairOpenClawStateDatabaseSchema({
3352+
env: { ...env, OPENCLAW_STATE_DIR: stateDir },
3353+
});
33093354

33103355
// Canonicalize orphaned session keys regardless of whether legacy migration
33113356
// is needed — the orphan-key bug (#29683) affects all installs with
@@ -3362,6 +3407,7 @@ export async function autoMigrateLegacyState(params: {
33623407
});
33633408
const changes = [
33643409
...stateDirResult.changes,
3410+
...stateSchema.changes,
33653411
...orphanKeys.changes,
33663412
...acpSessionMetadata.changes,
33673413
...pluginStateSidecar.changes,
@@ -3373,6 +3419,7 @@ export async function autoMigrateLegacyState(params: {
33733419
];
33743420
const warnings = [
33753421
...stateDirResult.warnings,
3422+
...stateSchema.warnings,
33763423
...orphanKeys.warnings,
33773424
...acpSessionMetadata.warnings,
33783425
...pluginStateSidecar.warnings,
@@ -3386,6 +3433,7 @@ export async function autoMigrateLegacyState(params: {
33863433
return {
33873434
migrated:
33883435
stateDirResult.migrated ||
3436+
stateSchema.changes.length > 0 ||
33893437
orphanKeys.changes.length > 0 ||
33903438
acpSessionMetadata.changes.length > 0 ||
33913439
pluginStateSidecar.changes.length > 0 ||
@@ -3406,23 +3454,27 @@ export async function autoMigrateLegacyState(params: {
34063454
!detected.pluginPlans?.hasLegacy &&
34073455
!detected.pluginStateSidecar.hasLegacy &&
34083456
!detected.pluginInstallIndex.hasLegacy &&
3457+
!detected.stateSchema.hasLegacy &&
34093458
!detected.taskStateSidecars.hasLegacy &&
34103459
!detected.deliveryQueues.hasLegacy
34113460
) {
34123461
const changes = [
34133462
...stateDirResult.changes,
3463+
...stateSchema.changes,
34143464
...orphanKeys.changes,
34153465
...acpSessionMetadata.changes,
34163466
];
34173467
const warnings = [
34183468
...stateDirResult.warnings,
3469+
...stateSchema.warnings,
34193470
...orphanKeys.warnings,
34203471
...acpSessionMetadata.warnings,
34213472
];
34223473
logMigrationResults(changes, warnings);
34233474
return {
34243475
migrated:
34253476
stateDirResult.migrated ||
3477+
stateSchema.changes.length > 0 ||
34263478
orphanKeys.changes.length > 0 ||
34273479
acpSessionMetadata.changes.length > 0,
34283480
skipped: false,
@@ -3465,6 +3517,7 @@ export async function autoMigrateLegacyState(params: {
34653517
);
34663518
const changes = [
34673519
...stateDirResult.changes,
3520+
...stateSchema.changes,
34683521
...orphanKeys.changes,
34693522
...acpSessionMetadata.changes,
34703523
...pluginStateSidecar.changes,
@@ -3480,6 +3533,7 @@ export async function autoMigrateLegacyState(params: {
34803533
];
34813534
const warnings = [
34823535
...stateDirResult.warnings,
3536+
...stateSchema.warnings,
34833537
...orphanKeys.warnings,
34843538
...acpSessionMetadata.warnings,
34853539
...pluginStateSidecar.warnings,

0 commit comments

Comments
 (0)