diff --git a/src/commands/doctor/state-migrations.legacy.test.ts b/src/commands/doctor/state-migrations.legacy.test.ts index 880390a361c..fb73a50ad85 100644 --- a/src/commands/doctor/state-migrations.legacy.test.ts +++ b/src/commands/doctor/state-migrations.legacy.test.ts @@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import { loadSqliteSessionEntries } from "../../config/sessions/session-entries.sqlite.js"; import { loadSqliteSessionTranscriptEvents } from "../../config/sessions/transcript-store.sqlite.js"; import { executeSqliteQuerySync, getNodeSqliteKysely } from "../../infra/kysely-sync.js"; +import { requireNodeSqlite } from "../../infra/node-sqlite.js"; import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js"; import type { DB as OpenClawStateKyselyDatabase } from "../../state/openclaw-state-db.generated.js"; import { @@ -141,6 +142,59 @@ function createEnv(stateDir: string): NodeJS.ProcessEnv { }; } +async function createLegacyPluginStateSqlite( + stateDir: string, + rows: Array<{ + pluginId: string; + namespace: string; + key: string; + valueJson: string; + createdAt: number; + expiresAt?: number | null; + }>, +): Promise { + const sqlitePath = path.join(stateDir, "plugin-state", "state.sqlite"); + await fs.mkdir(path.dirname(sqlitePath), { recursive: true }); + const sqlite = requireNodeSqlite(); + const db = new sqlite.DatabaseSync(sqlitePath); + try { + db.exec(` + CREATE TABLE IF NOT EXISTS plugin_state_entries ( + plugin_id TEXT NOT NULL, + namespace TEXT NOT NULL, + entry_key TEXT NOT NULL, + value_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER, + PRIMARY KEY (plugin_id, namespace, entry_key) + ); + `); + const statement = db.prepare(` + INSERT INTO plugin_state_entries ( + plugin_id, + namespace, + entry_key, + value_json, + created_at, + expires_at + ) VALUES (?, ?, ?, ?, ?, ?) + `); + for (const row of rows) { + statement.run( + row.pluginId, + row.namespace, + row.key, + row.valueJson, + row.createdAt, + row.expiresAt ?? null, + ); + } + } finally { + db.close(); + } + return sqlitePath; +} + async function createLegacyStateFixture(params?: { includePreKey?: boolean }) { const root = await createTempDir(); const stateDir = path.join(root, ".openclaw"); @@ -328,6 +382,82 @@ describe("state migrations", () => { await expectMissingPath(resolveLegacyChannelAllowFromPath("chatapp", env, "beta")); }); + it("imports legacy plugin-state sidecar SQLite rows into unified state", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const env = createEnv(stateDir); + const cfg = createConfig(); + const legacyPluginStatePath = await createLegacyPluginStateSqlite(stateDir, [ + { + pluginId: "discord", + namespace: "components", + key: "interaction:1", + valueJson: '{"ok":true}', + createdAt: 1000, + }, + { + pluginId: "github-copilot", + namespace: "token-cache", + key: "default", + valueJson: '{"token":"redacted"}', + createdAt: 2000, + expiresAt: 3000, + }, + ]); + + const detected = await detectLegacyStateMigrations({ + cfg, + env, + homedir: () => root, + }); + + expect(detected.preview).toEqual([ + `- Plugin state sidecar SQLite: ${legacyPluginStatePath} → SQLite`, + ]); + + const result = await runLegacyStateMigrations({ + detected, + now: () => 1234, + }); + + expect(result.warnings).toStrictEqual([]); + expect(result.changes).toEqual([ + "Imported 2 legacy plugin-state row(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: "discord", + namespace: "components", + entry_key: "interaction:1", + value_json: '{"ok":true}', + created_at: 1000, + expires_at: null, + }, + { + plugin_id: "github-copilot", + namespace: "token-cache", + entry_key: "default", + value_json: '{"token":"redacted"}', + created_at: 2000, + expires_at: 3000, + }, + ]); + await expectMissingPath(legacyPluginStatePath); + }); + 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 8959429d592..06754497ab4 100644 --- a/src/commands/doctor/state-migrations.ts +++ b/src/commands/doctor/state-migrations.ts @@ -2,6 +2,8 @@ import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import type { DatabaseSync } from "node:sqlite"; +import type { Selectable } from "kysely"; import { listAgentIds, resolveAgentWorkspaceDir, @@ -34,6 +36,7 @@ import { isRescuePendingOperation, } from "../../crestodian/rescue-pending-state.js"; import { executeSqliteQuerySync, getNodeSqliteKysely } from "../../infra/kysely-sync.js"; +import { requireNodeSqlite } from "../../infra/node-sqlite.js"; import { normalizeConversationRef } from "../../infra/outbound/session-binding-normalization.js"; import type { SessionBindingRecord } from "../../infra/outbound/session-binding.types.js"; import { isWithinDir } from "../../infra/path-safety.js"; @@ -1350,6 +1353,26 @@ function collectCoreLegacyStateMigrationPlans(params: { }, ]; }); + const pluginStateSqlitePath = resolveLegacyPluginStateSqlitePath(params.stateDir); + if (fileExists(pluginStateSqlitePath)) { + plans.push({ + kind: "custom", + label: "Plugin state sidecar SQLite", + sourcePath: pluginStateSqlitePath, + targetTable: "plugin_state_entries", + recordCount: countLegacyPluginStateSqliteRecords(pluginStateSqlitePath), + apply: () => { + const result = importLegacyPluginStateSqliteToUnified(pluginStateSqlitePath, params.env); + return { + changes: + result.imported > 0 + ? [`Imported ${result.imported} legacy plugin-state row(s) into SQLite plugin state`] + : [], + warnings: result.warnings, + }; + }, + }); + } const acpxGatewayInstancePath = path.join(params.stateDir, "gateway-instance-id"); if (fileExists(acpxGatewayInstancePath)) { plans.push({ @@ -1862,10 +1885,94 @@ type CurrentConversationBindingsMigrationDatabase = Pick< "current_conversation_bindings" >; type PluginStateMigrationDatabase = Pick; +type PluginStateMigrationRow = Selectable; 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; + +function resolveLegacyPluginStateSqlitePath(stateDir: string): string { + return path.join(stateDir, "plugin-state", "state.sqlite"); +} + +function readLegacyPluginStateSqliteRows(sourcePath: string): { + rows: PluginStateMigrationRow[]; + warnings: string[]; +} { + let sourceDb: DatabaseSync | undefined; + try { + const sqlite = requireNodeSqlite(); + sourceDb = new sqlite.DatabaseSync(sourcePath, { readOnly: true }); + const db = getNodeSqliteKysely(sourceDb); + const rows = executeSqliteQuerySync( + sourceDb, + 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; + return { rows, warnings: [] }; + } catch (error) { + return { + rows: [], + warnings: [`Failed reading legacy plugin-state SQLite (${sourcePath}): ${String(error)}`], + }; + } finally { + sourceDb?.close(); + } +} + +function countLegacyPluginStateSqliteRecords(sourcePath: string): number | undefined { + const result = readLegacyPluginStateSqliteRows(sourcePath); + return result.warnings.length === 0 ? result.rows.length : undefined; +} + +function removeLegacyPluginStateSqliteFiles(sourcePath: string): void { + for (const suffix of LEGACY_PLUGIN_STATE_SIDECAR_SUFFIXES) { + fs.rmSync(`${sourcePath}${suffix}`, { force: true }); + } + removeDirIfEmpty(path.dirname(sourcePath)); +} + +function importLegacyPluginStateSqliteToUnified( + sourcePath: string, + env: NodeJS.ProcessEnv, +): { imported: number; warnings: string[] } { + const result = readLegacyPluginStateSqliteRows(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 }, + ); + } + + removeLegacyPluginStateSqliteFiles(sourcePath); + return { imported: result.rows.length, warnings: [] }; +} function collectLegacyMigrationSources(detected: LegacyStateDetection): MigrationSourceReport[] { const sources: MigrationSourceReport[] = [];