Files
openclaw/extensions/workboard/doctor-contract-api.test.ts
Peter Steinberger 8d6a6e9d03 feat: move workboard to relational sqlite
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.
2026-05-31 00:02:23 +01:00

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 });
}
});
});