fix: migrate legacy plugin state sqlite

This commit is contained in:
Peter Steinberger
2026-05-16 01:53:46 +01:00
parent 3a413e8abd
commit a6a6c15dc2
2 changed files with 237 additions and 0 deletions

View File

@@ -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<string> {
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<PluginStateTestDatabase>(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");

View File

@@ -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<OpenClawStateKyselyDatabase, "plugin_state_entries">;
type PluginStateMigrationRow = Selectable<OpenClawStateKyselyDatabase["plugin_state_entries"]>;
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<PluginStateMigrationDatabase>(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<PluginStateMigrationDatabase>(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[] = [];