mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-09 12:22:53 +00:00
Move Workboard durable data into a relational SQLite database and add extension doctor migration for .28 plugin-state rows. Preserve attachment lifecycle behavior, SQLite permissions/WAL settings, and scoped plugin migration access.
448 lines
14 KiB
TypeScript
448 lines
14 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { createPluginStateKeyedStoreForTests as createPluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
|
import type {
|
|
OpenKeyedStoreOptions,
|
|
PluginDoctorStateMigrationContext,
|
|
} from "openclaw/plugin-sdk/runtime-doctor";
|
|
import { describe, expect, it } from "vitest";
|
|
import { stateMigrations } from "./doctor-contract-api.js";
|
|
import { createWorkboardSqliteStores } from "./src/sqlite-store.js";
|
|
import { WorkboardStore, type PersistedWorkboardCard } from "./src/store.js";
|
|
|
|
function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext {
|
|
return {
|
|
openPluginStateKeyedStore<T>(options: OpenKeyedStoreOptions) {
|
|
return createPluginStateKeyedStore<T>("workboard", {
|
|
...options,
|
|
env: options.env ?? env,
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("workboard doctor contract", () => {
|
|
it("migrates shipped .28 plugin-state workboard data into sqlite", async () => {
|
|
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-doctor-"));
|
|
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
|
try {
|
|
const cardStore = createPluginStateKeyedStore<PersistedWorkboardCard>("workboard", {
|
|
namespace: "workboard.cards",
|
|
maxEntries: 2000,
|
|
env,
|
|
});
|
|
const boardStore = createPluginStateKeyedStore("workboard", {
|
|
namespace: "workboard.boards",
|
|
maxEntries: 200,
|
|
env,
|
|
});
|
|
const notifyStore = createPluginStateKeyedStore("workboard", {
|
|
namespace: "workboard.notify",
|
|
maxEntries: 2000,
|
|
env,
|
|
});
|
|
const attachmentStore = createPluginStateKeyedStore("workboard", {
|
|
namespace: "workboard.attachments",
|
|
maxEntries: 42_000,
|
|
env,
|
|
});
|
|
await boardStore.register("planning", {
|
|
version: 1,
|
|
board: { id: "planning", name: "Planning", createdAt: 1, updatedAt: 2 },
|
|
});
|
|
await cardStore.register("card-1", {
|
|
version: 1,
|
|
card: {
|
|
id: "card-1",
|
|
title: "Migrate me",
|
|
status: "todo",
|
|
priority: "normal",
|
|
labels: ["sqlite"],
|
|
position: 1000,
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
metadata: {
|
|
automation: { boardId: "planning" },
|
|
attachments: [
|
|
{
|
|
id: "attachment-1",
|
|
cardId: "card-1",
|
|
createdAt: 2,
|
|
fileName: "proof.txt",
|
|
byteSize: 2,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
await notifyStore.register("sub-1", {
|
|
version: 1,
|
|
subscription: { id: "sub-1", boardId: "planning", createdAt: 1, updatedAt: 2 },
|
|
});
|
|
await attachmentStore.register("attachment-1", {
|
|
version: 1,
|
|
attachment: {
|
|
id: "attachment-1",
|
|
cardId: "card-1",
|
|
createdAt: 2,
|
|
fileName: "proof.txt",
|
|
byteSize: 2,
|
|
},
|
|
contentBase64: Buffer.from("ok").toString("base64"),
|
|
});
|
|
|
|
const migration = stateMigrations[0];
|
|
await expect(
|
|
migration.detectLegacyState({
|
|
config: {},
|
|
env,
|
|
stateDir,
|
|
oauthDir: path.join(stateDir, "oauth"),
|
|
context: createDoctorContext(env),
|
|
}),
|
|
).resolves.toMatchObject({
|
|
preview: [expect.stringContaining("4 legacy .28 plugin-state KV entries")],
|
|
});
|
|
|
|
const result = await migration.migrateLegacyState({
|
|
config: {},
|
|
env,
|
|
stateDir,
|
|
oauthDir: path.join(stateDir, "oauth"),
|
|
context: createDoctorContext(env),
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
changes: [expect.stringContaining("Migrated 4 Workboard .28 plugin-state KV entries")],
|
|
warnings: [],
|
|
});
|
|
expect(await cardStore.entries()).toEqual([]);
|
|
expect(await boardStore.entries()).toEqual([]);
|
|
expect(await notifyStore.entries()).toEqual([]);
|
|
expect(await attachmentStore.entries()).toEqual([]);
|
|
|
|
const sqlite = createWorkboardSqliteStores({ env });
|
|
const store = new WorkboardStore(sqlite.cards, {
|
|
boards: sqlite.boards,
|
|
subscriptions: sqlite.subscriptions,
|
|
attachments: sqlite.attachments,
|
|
});
|
|
expect(await store.get("card-1")).toMatchObject({
|
|
title: "Migrate me",
|
|
metadata: {
|
|
automation: { boardId: "planning" },
|
|
attachments: [expect.objectContaining({ id: "attachment-1" })],
|
|
},
|
|
});
|
|
expect(await store.getAttachment("attachment-1")).toMatchObject({
|
|
contentBase64: Buffer.from("ok").toString("base64"),
|
|
});
|
|
expect(await store.listBoards()).toMatchObject({
|
|
boards: [
|
|
expect.objectContaining({ id: "default" }),
|
|
expect.objectContaining({ id: "planning" }),
|
|
],
|
|
});
|
|
expect(await store.listNotificationSubscriptions({ boardId: "planning" })).toMatchObject({
|
|
subscriptions: [expect.objectContaining({ id: "sub-1" })],
|
|
});
|
|
sqlite.close();
|
|
} finally {
|
|
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("resumes attachment migration when the owning card was already copied", async () => {
|
|
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-doctor-"));
|
|
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
|
try {
|
|
const attachmentStore = createPluginStateKeyedStore("workboard", {
|
|
namespace: "workboard.attachments",
|
|
maxEntries: 42_000,
|
|
env,
|
|
});
|
|
await attachmentStore.register("attachment-1", {
|
|
version: 1,
|
|
attachment: {
|
|
id: "attachment-1",
|
|
cardId: "card-1",
|
|
createdAt: 2,
|
|
fileName: "proof.txt",
|
|
byteSize: 2,
|
|
},
|
|
contentBase64: Buffer.from("ok").toString("base64"),
|
|
});
|
|
|
|
const sqlite = createWorkboardSqliteStores({ env });
|
|
await sqlite.cards.register("card-1", {
|
|
version: 1,
|
|
card: {
|
|
id: "card-1",
|
|
title: "Already copied",
|
|
status: "todo",
|
|
priority: "normal",
|
|
labels: [],
|
|
position: 1000,
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
metadata: {
|
|
attachments: [
|
|
{
|
|
id: "attachment-1",
|
|
cardId: "card-1",
|
|
createdAt: 2,
|
|
fileName: "proof.txt",
|
|
byteSize: 2,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
sqlite.close();
|
|
|
|
const result = await stateMigrations[0].migrateLegacyState({
|
|
config: {},
|
|
env,
|
|
stateDir,
|
|
oauthDir: path.join(stateDir, "oauth"),
|
|
context: createDoctorContext(env),
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
changes: [expect.stringContaining("Migrated 1 Workboard .28 plugin-state KV entry")],
|
|
warnings: [],
|
|
});
|
|
expect(await attachmentStore.entries()).toEqual([]);
|
|
|
|
const reopenedStores = createWorkboardSqliteStores({ env });
|
|
expect(await reopenedStores.attachments.lookup("attachment-1")).toMatchObject({
|
|
contentBase64: Buffer.from("ok").toString("base64"),
|
|
});
|
|
reopenedStores.close();
|
|
} finally {
|
|
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("skips malformed legacy attachments without aborting valid attachment migration", async () => {
|
|
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-doctor-"));
|
|
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
|
try {
|
|
const attachmentStore = createPluginStateKeyedStore<unknown>("workboard", {
|
|
namespace: "workboard.attachments",
|
|
maxEntries: 42_000,
|
|
env,
|
|
});
|
|
await attachmentStore.register("broken", { version: 1 });
|
|
await attachmentStore.register("attachment-1", {
|
|
version: 1,
|
|
attachment: {
|
|
id: "attachment-1",
|
|
cardId: "card-1",
|
|
createdAt: 2,
|
|
fileName: "proof.txt",
|
|
byteSize: 2,
|
|
},
|
|
contentBase64: Buffer.from("ok").toString("base64"),
|
|
});
|
|
|
|
const sqlite = createWorkboardSqliteStores({ env });
|
|
await sqlite.cards.register("card-1", {
|
|
version: 1,
|
|
card: {
|
|
id: "card-1",
|
|
title: "Already copied",
|
|
status: "todo",
|
|
priority: "normal",
|
|
labels: [],
|
|
position: 1000,
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
metadata: {
|
|
attachments: [
|
|
{
|
|
id: "attachment-1",
|
|
cardId: "card-1",
|
|
createdAt: 2,
|
|
fileName: "proof.txt",
|
|
byteSize: 2,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
sqlite.close();
|
|
|
|
const result = await stateMigrations[0].migrateLegacyState({
|
|
config: {},
|
|
env,
|
|
stateDir,
|
|
oauthDir: path.join(stateDir, "oauth"),
|
|
context: createDoctorContext(env),
|
|
});
|
|
|
|
expect(result.changes).toEqual([
|
|
expect.stringContaining("Migrated 1 Workboard .28 plugin-state KV entry"),
|
|
]);
|
|
expect(result.warnings).toEqual([
|
|
expect.stringContaining("Skipped malformed legacy Workboard attachment entry broken"),
|
|
]);
|
|
expect((await attachmentStore.entries()).map((entry) => entry.key)).toEqual(["broken"]);
|
|
|
|
const reopenedStores = createWorkboardSqliteStores({ env });
|
|
expect(await reopenedStores.attachments.lookup("attachment-1")).toMatchObject({
|
|
contentBase64: Buffer.from("ok").toString("base64"),
|
|
});
|
|
reopenedStores.close();
|
|
} finally {
|
|
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("keeps orphan legacy attachments when migrated card metadata does not reference them", async () => {
|
|
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-doctor-"));
|
|
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
|
try {
|
|
const cardStore = createPluginStateKeyedStore<PersistedWorkboardCard>("workboard", {
|
|
namespace: "workboard.cards",
|
|
maxEntries: 2000,
|
|
env,
|
|
});
|
|
const attachmentStore = createPluginStateKeyedStore("workboard", {
|
|
namespace: "workboard.attachments",
|
|
maxEntries: 42_000,
|
|
env,
|
|
});
|
|
await cardStore.register("card-1", {
|
|
version: 1,
|
|
card: {
|
|
id: "card-1",
|
|
title: "Migrated card",
|
|
status: "todo",
|
|
priority: "normal",
|
|
labels: [],
|
|
position: 1000,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
});
|
|
await attachmentStore.register("attachment-1", {
|
|
version: 1,
|
|
attachment: {
|
|
id: "attachment-1",
|
|
cardId: "card-1",
|
|
createdAt: 2,
|
|
fileName: "orphan.txt",
|
|
byteSize: 2,
|
|
},
|
|
contentBase64: Buffer.from("ok").toString("base64"),
|
|
});
|
|
|
|
const result = await stateMigrations[0].migrateLegacyState({
|
|
config: {},
|
|
env,
|
|
stateDir,
|
|
oauthDir: path.join(stateDir, "oauth"),
|
|
context: createDoctorContext(env),
|
|
});
|
|
|
|
expect(result.changes).toEqual([
|
|
expect.stringContaining("Migrated 1 Workboard .28 plugin-state KV entry"),
|
|
]);
|
|
expect(result.warnings).toEqual([
|
|
expect.stringContaining("does not reference the attachment"),
|
|
]);
|
|
expect(await cardStore.entries()).toEqual([]);
|
|
expect(await attachmentStore.entries()).toHaveLength(1);
|
|
|
|
const reopenedStores = createWorkboardSqliteStores({ env });
|
|
expect(await reopenedStores.attachments.lookup("attachment-1")).toBeUndefined();
|
|
reopenedStores.close();
|
|
} finally {
|
|
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("keeps current sqlite rows when legacy kv ids conflict", async () => {
|
|
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-doctor-"));
|
|
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
|
try {
|
|
const cardStore = createPluginStateKeyedStore<PersistedWorkboardCard>("workboard", {
|
|
namespace: "workboard.cards",
|
|
maxEntries: 2000,
|
|
env,
|
|
});
|
|
const attachmentStore = createPluginStateKeyedStore("workboard", {
|
|
namespace: "workboard.attachments",
|
|
maxEntries: 42_000,
|
|
env,
|
|
});
|
|
await cardStore.register("card-1", {
|
|
version: 1,
|
|
card: {
|
|
id: "card-1",
|
|
title: "Legacy card",
|
|
status: "todo",
|
|
priority: "normal",
|
|
labels: [],
|
|
position: 1000,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
});
|
|
await attachmentStore.register("attachment-1", {
|
|
version: 1,
|
|
attachment: {
|
|
id: "attachment-1",
|
|
cardId: "card-1",
|
|
createdAt: 1,
|
|
fileName: "old.txt",
|
|
byteSize: 2,
|
|
},
|
|
contentBase64: Buffer.from("no").toString("base64"),
|
|
});
|
|
|
|
const sqlite = createWorkboardSqliteStores({ env });
|
|
await sqlite.cards.register("card-1", {
|
|
version: 1,
|
|
card: {
|
|
id: "card-1",
|
|
title: "Current card",
|
|
status: "todo",
|
|
priority: "normal",
|
|
labels: [],
|
|
position: 1000,
|
|
createdAt: 2,
|
|
updatedAt: 2,
|
|
},
|
|
});
|
|
sqlite.close();
|
|
|
|
const result = await stateMigrations[0].migrateLegacyState({
|
|
config: {},
|
|
env,
|
|
stateDir,
|
|
oauthDir: path.join(stateDir, "oauth"),
|
|
context: createDoctorContext(env),
|
|
});
|
|
|
|
expect(result.changes).toEqual([]);
|
|
expect(result.warnings).toEqual([
|
|
expect.stringContaining("SQLite target already exists"),
|
|
expect.stringContaining("owning card was not migrated"),
|
|
]);
|
|
expect(await cardStore.entries()).toHaveLength(1);
|
|
expect(await attachmentStore.entries()).toHaveLength(1);
|
|
|
|
const reopenedStores = createWorkboardSqliteStores({ env });
|
|
const store = new WorkboardStore(reopenedStores.cards);
|
|
expect(await store.get("card-1")).toMatchObject({ title: "Current card" });
|
|
expect(await reopenedStores.attachments.lookup("attachment-1")).toBeUndefined();
|
|
reopenedStores.close();
|
|
} finally {
|
|
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|