diff --git a/src/state/openclaw-state-db-schema-repair.ts b/src/state/openclaw-state-db-schema-repair.ts index 4a527434aaef..a8f996d0f919 100644 --- a/src/state/openclaw-state-db-schema-repair.ts +++ b/src/state/openclaw-state-db-schema-repair.ts @@ -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 { diff --git a/src/state/openclaw-state-db.paths.ts b/src/state/openclaw-state-db.paths.ts index 33031a098051..2c215c8c2292 100644 --- a/src/state/openclaw-state-db.paths.ts +++ b/src/state/openclaw-state-db.paths.ts @@ -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, + }); +} diff --git a/src/state/openclaw-state-db.test.ts b/src/state/openclaw-state-db.test.ts index 03d48d72ec2b..67fd2c043733 100644 --- a/src/state/openclaw-state-db.test.ts +++ b/src/state/openclaw-state-db.test.ts @@ -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( diff --git a/src/state/openclaw-state-db.ts b/src/state/openclaw-state-db.ts index 1cadf840887e..09b1818a27cf 100644 --- a/src/state/openclaw-state-db.ts +++ b/src/state/openclaw-state-db.ts @@ -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,