fix(state): report v9 registry migration row decisions (#124862)

* fix(state): report v9 registry migration row decisions in doctor and logs

* fix(state): consume registry migration summary type

* fix(state): avoid registry path observer import cycle
This commit is contained in:
Peter Steinberger
2026-08-16 15:52:42 -07:00
committed by GitHub
parent 800a0bb52a
commit 57b1a69167
4 changed files with 88 additions and 12 deletions
+21 -7
View File
@@ -465,13 +465,20 @@ function isDefaultAgentDatabasePath(pathname: string, agentId: string): boolean
);
}
export type AgentDatabasePathMigrationSummary = {
relativized: number;
reanchored: string[];
deleted: string[];
preserved: number;
};
export function migrateAgentDatabaseRelativePaths(
db: DatabaseSync,
previousVersion: number,
databasePath: string,
): boolean {
): AgentDatabasePathMigrationSummary {
if (previousVersion >= 9 || !tableExists(db, "agent_databases")) {
return false;
return { relativized: 0, reanchored: [], deleted: [], preserved: 0 };
}
const rows = db.prepare("SELECT agent_id, path FROM agent_databases").all();
const updatePath = db.prepare(
@@ -481,7 +488,9 @@ export function migrateAgentDatabaseRelativePaths(
const hasPath = db.prepare(
"SELECT 1 FROM agent_databases WHERE agent_id = ? AND path = ? LIMIT 1",
);
let changed = false;
let relativized = 0;
const reanchored: string[] = [];
const deleted: string[] = [];
for (const row of rows) {
const agentId = row.agent_id;
const registeredPath = row.path;
@@ -494,7 +503,7 @@ export function migrateAgentDatabaseRelativePaths(
const storedPath = resolveOpenClawAgentDatabaseStoredPath(databasePath, registeredPath);
if (!path.isAbsolute(storedPath)) {
updatePath.run(storedPath, agentId, registeredPath);
changed = true;
relativized += 1;
}
}
const stateDir = resolveOpenClawStateDirForDatabasePath(databasePath);
@@ -526,16 +535,21 @@ export function migrateAgentDatabaseRelativePaths(
// The same agent already owns its in-root canonical registration. Keeping a second
// default-layout registration guarantees duplicate canonical session keys on every list.
deletePath.run(agentId, registeredPath);
changed = true;
deleted.push(registeredPath);
} else if (existsSync(counterpartAbsolute)) {
// Re-anchor a copied or moved state directory onto its copied database instead of
// deleting the registration or leaving it dangling at the source root.
updatePath.run(counterpartStored, agentId, registeredPath);
changed = true;
reanchored.push(registeredPath);
}
}
}
return changed;
return {
relativized,
reanchored,
deleted,
preserved: rows.length - relativized - reanchored.length - deleted.length,
};
}
function hasCanonicalAgentDatabasesPrimaryKey(db: DatabaseSync): boolean {
+51
View File
@@ -72,3 +72,54 @@ export function resolveOpenClawRegisteredAgentDatabasePath(
? storedPath
: `${resolveOpenClawStateDirForDatabasePath(registryDatabasePath)}${path.sep}${storedPath}`;
}
type AgentPathMigrationObservation = {
relativized: number;
reanchored: string[];
deleted: string[];
};
type AgentPathMigrationLogger = {
warn: (
message: string,
fields: { reanchored: string[]; deleted: string[]; path: string },
) => void;
};
export function describeAgentPathMigration(summary: AgentPathMigrationObservation): string[] {
const { relativized, reanchored, deleted } = summary;
if (relativized === 0 && reanchored.length === 0 && deleted.length === 0) {
return [];
}
const decisions = reanchored.length + deleted.length;
const counts = [
`${relativized} relativized`,
reanchored.length > 0 && `${reanchored.length} re-anchored`,
deleted.length > 0 && `${deleted.length} removed`,
].filter(Boolean);
return [
`Migrated agent database registry paths to state-relative storage${decisions > 0 ? ` (${counts.join(", ")})` : ""}`,
...reanchored.map(
(registeredPath) =>
`Re-anchored agent database registry path ${registeredPath} to the current state directory`,
),
...deleted.map(
(registeredPath) => `Removed duplicate agent database registry path ${registeredPath}`,
),
];
}
export function warnAgentPathMigration(
log: AgentPathMigrationLogger,
summary: AgentPathMigrationObservation,
databasePath: string,
): void {
if (summary.reanchored.length === 0 && summary.deleted.length === 0) {
return;
}
log.warn("agent database registry rows re-anchored or removed during v9 migration", {
reanchored: summary.reanchored,
deleted: summary.deleted,
path: databasePath,
});
}
+8
View File
@@ -1640,6 +1640,14 @@ describe("openclaw state database", () => {
kind: "agent-databases-relative-paths-v9",
path: databasePath,
});
expect(repairOpenClawStateDatabaseSchema({ env })).toEqual({
changes: [
"Migrated agent database registry paths to state-relative storage (2 relativized, 1 re-anchored, 1 removed)",
`Re-anchored agent database registry path ${copiedForeignPath} to the current state directory`,
`Removed duplicate agent database registry path ${dualForeignPath}`,
],
warnings: [],
});
const migrated = openOpenClawStateDatabase({ env });
expect(readSqliteNumberPragma(migrated.db, "user_version")).toBe(9);
expect(
+8 -5
View File
@@ -67,11 +67,12 @@ import {
} from "./openclaw-state-db-schema-additive.js";
import { tableExists } from "./openclaw-state-db-schema-helpers.js";
import {
type AgentDatabasePathMigrationSummary as AgentPathSummary,
assertCanonicalStateSchemaShape,
detectOpenClawStateDatabaseSchemaMigrationsFromDatabase,
dropLegacyStateTables,
markCurrentStateSchemaVersion,
migrateAgentDatabaseRelativePaths,
migrateAgentDatabaseRelativePaths as migrateAgentPaths,
migrateRetiredCommitmentsSchema,
migrateWorkerPlacementExecutionModeSchema,
repairAgentDatabasesCompositePrimaryKey,
@@ -79,6 +80,7 @@ import {
} from "./openclaw-state-db-schema-repair.js";
import * as sessionWatchMigration from "./openclaw-state-db-session-watch-migration.js";
import type { DB as OpenClawStateKyselyDatabase } from "./openclaw-state-db.generated.js";
import { describeAgentPathMigration, warnAgentPathMigration } from "./openclaw-state-db.paths.js";
import {
assertOpenClawStateWriteAllowed,
OpenClawStateOwnershipError,
@@ -204,9 +206,9 @@ function repairOpenClawStateDatabaseSchemaWithWriteAccess(
if (migrateWorkerPlacementExecutionModeSchema(db, previousVersion)) {
applied.push("Migrated cloud worker placements to execution modes");
}
if (migrateAgentDatabaseRelativePaths(db, previousVersion, pathname)) {
applied.push("Migrated agent database registry paths to state-relative storage");
}
applied.push(
...describeAgentPathMigration(migrateAgentPaths(db, previousVersion, pathname)),
);
if (repairAgentDatabasesCompositePrimaryKey(db)) {
applied.push(`Migrated shared state agent database registry primary key → agent_id,path`);
}
@@ -397,7 +399,7 @@ function ensureSchema(db: DatabaseSync, pathname: string, env: NodeJS.ProcessEnv
dropLegacyStateTables(db);
migrateRetiredCommitmentsSchema(db, previousVersion);
migrateWorkerPlacementExecutionModeSchema(db, previousVersion);
migrateAgentDatabaseRelativePaths(db, previousVersion, pathname);
const pathMigration: AgentPathSummary = migrateAgentPaths(db, previousVersion, pathname);
ensureAdditiveStateColumns(db);
sessionWatchMigration.migrateSessionWatchCursorProvenance(db);
assertCanonicalStateSchemaShape(db, pathname);
@@ -456,6 +458,7 @@ function ensureSchema(db: DatabaseSync, pathname: string, env: NodeJS.ProcessEnv
),
);
assertOpenClawStateDatabaseForMaintenance(db, { pathname });
warnAgentPathMigration(stateDbLog, pathMigration, pathname);
},
{
busyTimeoutMs: OPENCLAW_SQLITE_BUSY_TIMEOUT_MS,