mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-29 23:58:40 +00:00
fix: migrate legacy plugin state sqlite
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user