diff --git a/src/commands/doctor/state-migrations.legacy.test.ts b/src/commands/doctor/state-migrations.legacy.test.ts index fb73a50ad85..e1d6aff3395 100644 --- a/src/commands/doctor/state-migrations.legacy.test.ts +++ b/src/commands/doctor/state-migrations.legacy.test.ts @@ -458,6 +458,79 @@ describe("state migrations", () => { await expectMissingPath(legacyPluginStatePath); }); + it("imports legacy Active Memory session toggles into unified plugin state", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const env = createEnv(stateDir); + const cfg = createConfig(); + const legacyTogglePath = path.join( + stateDir, + "plugins", + "active-memory", + "session-toggles.json", + ); + await fs.mkdir(path.dirname(legacyTogglePath), { recursive: true }); + await fs.writeFile( + legacyTogglePath, + `${JSON.stringify( + { + sessions: { + "agent:main:disabled": { disabled: true, updatedAt: 111 }, + "agent:main:enabled": { disabled: false, updatedAt: 222 }, + " ": { disabled: true, updatedAt: 333 }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const detected = await detectLegacyStateMigrations({ + cfg, + env, + homedir: () => root, + }); + + expect(detected.preview).toEqual([ + `- Active Memory session toggles: ${legacyTogglePath} → SQLite`, + ]); + + const result = await runLegacyStateMigrations({ + detected, + now: () => 1234, + }); + + expect(result.warnings).toStrictEqual([]); + expect(result.changes).toEqual([ + "Imported 1 Active Memory session toggle(s) into SQLite plugin state", + ]); + + const stateDatabase = openOpenClawStateDatabase({ env }); + const db = getNodeSqliteKysely(stateDatabase.db); + const rows = executeSqliteQuerySync( + stateDatabase.db, + db + .selectFrom("plugin_state_entries") + .select(["plugin_id", "namespace", "entry_key", "value_json", "created_at", "expires_at"]) + .orderBy("plugin_id", "asc") + .orderBy("namespace", "asc") + .orderBy("entry_key", "asc"), + ).rows; + + expect(rows).toEqual([ + { + plugin_id: "active-memory", + namespace: "session-toggles", + entry_key: "agent:main:disabled", + value_json: '{"version":1,"disabled":true,"updatedAt":111}', + created_at: 111, + expires_at: null, + }, + ]); + await expectMissingPath(legacyTogglePath); + }); + it("migrates legacy sessions for every configured agent", async () => { const root = await createTempDir(); const stateDir = path.join(root, ".openclaw"); diff --git a/src/commands/doctor/state-migrations.ts b/src/commands/doctor/state-migrations.ts index 06754497ab4..4419488cb17 100644 --- a/src/commands/doctor/state-migrations.ts +++ b/src/commands/doctor/state-migrations.ts @@ -128,6 +128,12 @@ type MigrationSourceReport = { recordCount?: number; }; +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + function parseJsonlEvents(filePath: string): unknown[] { const raw = fs.readFileSync(filePath, "utf-8"); const events: unknown[] = []; @@ -1373,6 +1379,33 @@ function collectCoreLegacyStateMigrationPlans(params: { }, }); } + const activeMemorySessionTogglesPath = resolveLegacyActiveMemorySessionTogglesPath( + params.stateDir, + ); + if (fileExists(activeMemorySessionTogglesPath)) { + plans.push({ + kind: "custom", + label: "Active Memory session toggles", + sourcePath: activeMemorySessionTogglesPath, + targetTable: "plugin_state_entries", + recordCount: countLegacyActiveMemorySessionToggleRecords(activeMemorySessionTogglesPath), + apply: () => { + const result = importLegacyActiveMemorySessionTogglesToSqlite( + activeMemorySessionTogglesPath, + params.env, + ); + return { + changes: + result.imported > 0 + ? [ + `Imported ${result.imported} Active Memory session toggle(s) into SQLite plugin state`, + ] + : [], + warnings: result.warnings, + }; + }, + }); + } const acpxGatewayInstancePath = path.join(params.stateDir, "gateway-instance-id"); if (fileExists(acpxGatewayInstancePath)) { plans.push({ @@ -1891,11 +1924,23 @@ const CLAWHUB_SKILL_STATE_OWNER_ID = "core:clawhub-skills"; const CLAWHUB_SKILL_STATE_NAMESPACE = "skill-installs"; const DEFAULT_CLAWHUB_URL = "https://clawhub.ai"; const LEGACY_PLUGIN_STATE_SIDECAR_SUFFIXES = ["", "-shm", "-wal"] as const; +const ACTIVE_MEMORY_PLUGIN_STATE_OWNER_ID = "active-memory"; +const ACTIVE_MEMORY_SESSION_TOGGLES_NAMESPACE = "session-toggles"; +const LEGACY_ACTIVE_MEMORY_SESSION_TOGGLES_FILE = "session-toggles.json"; function resolveLegacyPluginStateSqlitePath(stateDir: string): string { return path.join(stateDir, "plugin-state", "state.sqlite"); } +function resolveLegacyActiveMemorySessionTogglesPath(stateDir: string): string { + return path.join( + stateDir, + "plugins", + ACTIVE_MEMORY_PLUGIN_STATE_OWNER_ID, + LEGACY_ACTIVE_MEMORY_SESSION_TOGGLES_FILE, + ); +} + function readLegacyPluginStateSqliteRows(sourcePath: string): { rows: PluginStateMigrationRow[]; warnings: string[]; @@ -1974,6 +2019,102 @@ function importLegacyPluginStateSqliteToUnified( return { imported: result.rows.length, warnings: [] }; } +function readLegacyActiveMemorySessionToggleRows(sourcePath: string): { + rows: PluginStateMigrationRow[]; + warnings: string[]; +} { + let fallbackUpdatedAt = 0; + try { + fallbackUpdatedAt = Math.trunc(fs.statSync(sourcePath).mtimeMs); + } catch { + // keep deterministic zero fallback when stat fails + } + + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(sourcePath, "utf8")) as unknown; + } catch (error) { + return { + rows: [], + warnings: [`Failed reading legacy Active Memory toggles (${sourcePath}): ${String(error)}`], + }; + } + + const sessions = asRecord(parsed)?.sessions; + if (!sessions || typeof sessions !== "object" || Array.isArray(sessions)) { + return { rows: [], warnings: [] }; + } + + const rows: PluginStateMigrationRow[] = []; + for (const [rawSessionKey, rawValue] of Object.entries(sessions)) { + const sessionKey = rawSessionKey.trim(); + const value = asRecord(rawValue); + if (!sessionKey || value?.disabled !== true) { + continue; + } + const updatedAt = + typeof value.updatedAt === "number" && Number.isFinite(value.updatedAt) + ? Math.trunc(value.updatedAt) + : fallbackUpdatedAt; + rows.push({ + plugin_id: ACTIVE_MEMORY_PLUGIN_STATE_OWNER_ID, + namespace: ACTIVE_MEMORY_SESSION_TOGGLES_NAMESPACE, + entry_key: sessionKey, + value_json: JSON.stringify({ + version: 1, + disabled: true, + updatedAt, + }), + created_at: updatedAt, + expires_at: null, + }); + } + return { rows, warnings: [] }; +} + +function countLegacyActiveMemorySessionToggleRecords(sourcePath: string): number | undefined { + const result = readLegacyActiveMemorySessionToggleRows(sourcePath); + return result.warnings.length === 0 ? result.rows.length : undefined; +} + +function importLegacyActiveMemorySessionTogglesToSqlite( + sourcePath: string, + env: NodeJS.ProcessEnv, +): { imported: number; warnings: string[] } { + const result = readLegacyActiveMemorySessionToggleRows(sourcePath); + if (result.warnings.length > 0) { + return { imported: 0, warnings: result.warnings }; + } + + if (result.rows.length > 0) { + runOpenClawStateWriteTransaction( + (stateDatabase) => { + const db = getNodeSqliteKysely(stateDatabase.db); + for (const row of result.rows) { + executeSqliteQuerySync( + stateDatabase.db, + db + .insertInto("plugin_state_entries") + .values(row) + .onConflict((conflict) => + conflict.columns(["plugin_id", "namespace", "entry_key"]).doUpdateSet({ + value_json: (eb) => eb.ref("excluded.value_json"), + created_at: (eb) => eb.ref("excluded.created_at"), + expires_at: (eb) => eb.ref("excluded.expires_at"), + }), + ), + ); + } + }, + { env }, + ); + } + + fs.rmSync(sourcePath, { force: true }); + removeDirIfEmpty(path.dirname(sourcePath)); + return { imported: result.rows.length, warnings: [] }; +} + function collectLegacyMigrationSources(detected: LegacyStateDetection): MigrationSourceReport[] { const sources: MigrationSourceReport[] = []; const add = (source: MigrationSourceReport | null | undefined) => {