Files
openclaw/extensions/workboard/src/store.test.ts
2026-06-16 09:36:32 +08:00

2720 lines
95 KiB
TypeScript

// Workboard tests cover store plugin behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { MAX_DATE_TIMESTAMP_MS } from "openclaw/plugin-sdk/number-runtime";
import { describe, expect, it, vi } from "vitest";
import { createWorkboardSqliteStores } from "./sqlite-store.js";
import {
WorkboardStore,
type PersistedWorkboardAttachment,
type PersistedWorkboardBoard,
type PersistedWorkboardCard,
type PersistedWorkboardNotificationSubscription,
type WorkboardKeyedStore,
} from "./store.js";
function createMemoryStore<T = PersistedWorkboardCard>(options?: {
beforeRegister?: (key: string, value: T) => Promise<void> | void;
}): WorkboardKeyedStore<T> {
const entries = new Map<string, T>();
return {
async register(key, value) {
await options?.beforeRegister?.(key, value);
entries.set(key, value);
},
async lookup(key) {
return entries.get(key);
},
async delete(key) {
return entries.delete(key);
},
async entries() {
return [...entries].flatMap(([key, value]) => (value ? [{ key, value }] : []));
},
};
}
function statfsFixture(type: number): ReturnType<typeof fs.statfsSync> {
return {
type,
bsize: 1024,
blocks: 1,
bfree: 1,
bavail: 1,
files: 0,
ffree: 0,
};
}
describe("WorkboardStore", () => {
it("persists boards, cards, subscriptions, and attachment blobs in sqlite", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-sqlite-"));
const dbPath = path.join(dir, "workboard.sqlite");
if (process.platform !== "win32") {
fs.chmodSync(dir, 0o755);
}
try {
const stores = createWorkboardSqliteStores({ dbPath });
const store = new WorkboardStore(stores.cards, {
boards: stores.boards,
subscriptions: stores.subscriptions,
attachments: stores.attachments,
});
const board = await store.upsertBoard({ id: "planning", name: "Planning" });
const card = await store.create({
title: "Persist it",
boardId: board.id,
labels: ["sqlite", "doctor"],
execution: {
id: "exec-1",
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "running",
model: "gpt-5.5",
sessionKey: "agent:main:test",
runId: "run-1",
startedAt: 1,
updatedAt: 2,
},
});
await store.addComment(card.id, { body: "round trip" });
const attached = await store.addAttachment(card.id, {
fileName: "proof.txt",
contentBase64: Buffer.from("ok").toString("base64"),
});
expect(attached.events?.at(-1)).toMatchObject({ kind: "attachment_added" });
await store.addAttachment(card.id, {
fileName: "large-proof.bin",
contentBase64: Buffer.alloc(70 * 1024).toString("base64"),
});
await store.update(card.id, {
metadata: { lifecycleStatusSourceUpdatedAt: 1234 },
});
const attachmentId = attached.metadata?.attachments?.[0]?.id;
const subscription = await store.subscribeNotifications({
boardId: board.id,
target: "agent:main:test",
eventKinds: ["completed"],
});
if (process.platform !== "win32") {
expect(fs.statSync(dir).mode & 0o777).toBe(0o700);
expect(fs.statSync(dbPath).mode & 0o777).toBe(0o600);
for (const sidecarPath of [`${dbPath}-wal`, `${dbPath}-shm`, `${dbPath}-journal`]) {
if (fs.existsSync(sidecarPath)) {
expect(fs.statSync(sidecarPath).mode & 0o777).toBe(0o600);
}
}
}
stores.close();
const rawDb = new DatabaseSync(dbPath);
expect(rawDb.prepare("PRAGMA journal_mode").get()).toMatchObject({
journal_mode: "wal",
});
rawDb.close();
const reopenedStores = createWorkboardSqliteStores({ dbPath });
const reopened = new WorkboardStore(reopenedStores.cards, {
boards: reopenedStores.boards,
subscriptions: reopenedStores.subscriptions,
attachments: reopenedStores.attachments,
});
expect(await reopened.listBoards()).toMatchObject({
boards: [
expect.objectContaining({ id: "default" }),
expect.objectContaining({ id: board.id, name: "Planning" }),
],
});
expect(await reopened.get(card.id)).toMatchObject({
id: card.id,
labels: ["sqlite", "doctor"],
metadata: {
automation: { boardId: "planning" },
lifecycleStatusSourceUpdatedAt: 1234,
comments: [expect.objectContaining({ body: "round trip" })],
attachments: expect.arrayContaining([
expect.objectContaining({ fileName: "proof.txt" }),
expect.objectContaining({ fileName: "large-proof.bin" }),
]),
},
});
expect(await reopened.getAttachment(attachmentId ?? "")).toMatchObject({
contentBase64: Buffer.from("ok").toString("base64"),
});
await reopened.delete(card.id);
expect(await reopened.getAttachment(attachmentId ?? "")).toBeUndefined();
expect(await reopened.listNotificationSubscriptions({ boardId: board.id })).toMatchObject({
subscriptions: [expect.objectContaining({ id: subscription.id })],
});
reopenedStores.close();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("uses rollback journaling on network-backed volumes", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-sqlite-network-"));
const dbPath = path.join(dir, "workboard.sqlite");
const statfs = vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0xff534d42));
try {
const stores = createWorkboardSqliteStores({ dbPath });
stores.close();
const rawDb = new DatabaseSync(dbPath);
expect(rawDb.prepare("PRAGMA journal_mode").get()).toMatchObject({
journal_mode: "delete",
});
rawDb.close();
expect(fs.existsSync(`${dbPath}-wal`)).toBe(false);
expect(fs.existsSync(`${dbPath}-shm`)).toBe(false);
} finally {
statfs.mockRestore();
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("creates and lists cards by status order and position", async () => {
const store = new WorkboardStore(createMemoryStore());
const review = await store.create({
title: "Review release notes",
status: "review",
priority: "high",
labels: "release, docs",
});
const todo = await store.create({ title: "Fix dashboard copy", status: "todo" });
expect((await store.list()).map((card) => card.id)).toEqual([todo.id, review.id]);
expect(review.labels).toEqual(["release", "docs"]);
expect(review.priority).toBe("high");
expect(review.events?.[0]).toMatchObject({ kind: "created", toStatus: "review" });
});
it("does not persist empty metadata for default cards", async () => {
const keyed = createMemoryStore();
const store = new WorkboardStore(keyed);
const card = await store.create({ title: "Plain card" });
expect(card.metadata).toBeUndefined();
const entry = await keyed.lookup(card.id);
expect(Object.hasOwn(entry?.card ?? {}, "metadata")).toBe(false);
});
it("preserves explicit zero positions", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Top card", status: "todo", position: 0 });
expect(card.position).toBe(0);
});
it("keeps initial session, run, and task links when creating cards", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Follow up",
sessionKey: "agent:main:dashboard:1",
runId: "run-1",
taskId: "task-1",
execution: {
id: "exec-1",
kind: "agent-session",
engine: "claude",
mode: "manual",
status: "running",
model: "anthropic/claude-sonnet-4-6",
sessionKey: "agent:main:dashboard:1",
startedAt: 10,
updatedAt: 10,
},
});
expect(card).toMatchObject({
sessionKey: "agent:main:dashboard:1",
runId: "run-1",
taskId: "task-1",
execution: {
engine: "claude",
mode: "manual",
model: "anthropic/claude-sonnet-4-6",
},
metadata: {
attempts: [
expect.objectContaining({
id: "agent:main:dashboard:1",
status: "running",
engine: "claude",
mode: "manual",
sessionKey: "agent:main:dashboard:1",
startedAt: 10,
}),
],
},
});
});
it("ignores dependency links from generic metadata writes", async () => {
const store = new WorkboardStore(createMemoryStore());
const parent = await store.create({ title: "Parent" });
const child = await store.create({
title: "Child",
metadata: {
links: [{ id: "raw-parent", type: "parent", targetCardId: parent.id, createdAt: 1 }],
},
});
expect(child.metadata?.links).toBeUndefined();
const updated = await store.update(child.id, {
metadata: {
links: [{ id: "raw-parent-2", type: "parent", targetCardId: parent.id, createdAt: 2 }],
},
});
expect(updated.metadata?.links).toBeUndefined();
});
it("stores card templates and metadata in the card record", async () => {
const keyed = createMemoryStore();
const store = new WorkboardStore(keyed);
const card = await store.create({
title: "Fix flaky lane",
templateId: "bugfix",
metadata: {
comments: [{ id: "comment-1", body: "Seen twice", createdAt: 10 }],
links: [{ id: "link-1", type: "blocks", targetCardId: "card-2", createdAt: 11 }],
proof: [{ id: "proof-1", status: "passed", command: "pnpm test", createdAt: 12 }],
},
});
await expect(keyed.lookup(card.id)).resolves.toMatchObject({
version: 1,
card: {
metadata: {
templateId: "bugfix",
comments: [expect.objectContaining({ body: "Seen twice" })],
links: [expect.objectContaining({ type: "blocks", targetCardId: "card-2" })],
proof: [expect.objectContaining({ status: "passed", command: "pnpm test" })],
},
},
});
});
it("updates automation metadata from top-level patch fields", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Tune automation" });
const updated = await store.update(card.id, {
tenant: "release",
idempotencyKey: "release:1",
skills: ["testing", "docs"],
workspace: { kind: "scratch" },
maxRuntimeSeconds: 120,
maxRetries: 2,
scheduledAt: 10_000,
});
expect(updated.metadata?.automation).toMatchObject({
tenant: "release",
idempotencyKey: "release:1",
skills: ["testing", "docs"],
workspace: { kind: "scratch" },
maxRuntimeSeconds: 120,
maxRetries: 2,
scheduledAt: 10_000,
});
const cleared = await store.update(card.id, { scheduledAt: null });
expect(cleared.metadata?.automation?.scheduledAt).toBeUndefined();
expect(cleared.metadata?.automation).toMatchObject({
tenant: "release",
maxRetries: 2,
});
const preserved = await store.update(card.id, {
scheduledAt: 20_000,
maxRuntimeSeconds: undefined,
});
expect(preserved.metadata?.automation).toMatchObject({
scheduledAt: 20_000,
maxRuntimeSeconds: 120,
});
});
it("moves cards and records lifecycle timestamps", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Ship workboard" });
const running = await store.move(card.id, "running", 500);
expect(running.status).toBe("running");
expect(running.position).toBe(500);
expect(running.startedAt).toBeGreaterThanOrEqual(card.createdAt);
expect(running.events?.at(-1)).toMatchObject({
kind: "moved",
fromStatus: "todo",
toStatus: "running",
});
const done = await store.update(card.id, { status: "done" });
expect(done.completedAt).toBeGreaterThanOrEqual(done.startedAt ?? 0);
const rolledBack = await store.update(card.id, {
status: "todo",
startedAt: null,
completedAt: null,
});
expect(rolledBack.startedAt).toBeUndefined();
expect(rolledBack.completedAt).toBeUndefined();
});
it("tracks lifecycle status provenance and clears it on manual status changes", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Sync status provenance" });
const zeroSourceLifecycle = await store.update(card.id, {
status: "running",
metadata: { lifecycleStatusSourceUpdatedAt: 0 },
});
expect(zeroSourceLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBe(0);
const lifecycleMoved = await store.update(card.id, {
status: "running",
metadata: { lifecycleStatusSourceUpdatedAt: 1000 },
});
expect(lifecycleMoved.metadata?.lifecycleStatusSourceUpdatedAt).toBe(1000);
const newerLifecycle = await store.update(card.id, {
status: "review",
metadata: { lifecycleStatusSourceUpdatedAt: 3000 },
});
expect(newerLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBe(3000);
const manual = await store.move(card.id, "running", 2000);
expect(manual.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
const staleZeroLifecycle = await store.update(card.id, {
status: "review",
metadata: { lifecycleStatusSourceUpdatedAt: 0 },
});
expect(staleZeroLifecycle).toEqual(manual);
expect(staleZeroLifecycle.status).toBe("running");
expect(staleZeroLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
const staleLifecycle = await store.update(card.id, {
status: "review",
metadata: { lifecycleStatusSourceUpdatedAt: 2000 },
});
expect(staleLifecycle).toEqual(manual);
expect(staleLifecycle.status).toBe("running");
expect(staleLifecycle.updatedAt).toBe(manual.updatedAt);
expect(staleLifecycle.events).toHaveLength(manual.events?.length ?? 0);
expect(staleLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
const freshLifecycleSourceUpdatedAt = Date.now() + 1000;
const freshLifecycle = await store.update(card.id, {
status: "review",
metadata: { lifecycleStatusSourceUpdatedAt: freshLifecycleSourceUpdatedAt },
});
expect(freshLifecycle.status).toBe("review");
expect(freshLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBe(
freshLifecycleSourceUpdatedAt,
);
});
it("keeps creation status from stale lifecycle patches", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(2000);
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Initial running status",
status: "running",
});
const staleLifecycle = await store.update(card.id, {
status: "review",
metadata: { lifecycleStatusSourceUpdatedAt: 1000 },
});
expect(staleLifecycle).toEqual(card);
expect(staleLifecycle.status).toBe("running");
expect(staleLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
const freshLifecycle = await store.update(card.id, {
status: "review",
metadata: { lifecycleStatusSourceUpdatedAt: 3000 },
});
expect(freshLifecycle.status).toBe("review");
expect(freshLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBe(3000);
} finally {
vi.useRealTimers();
}
});
it("does not let one stale bulk lifecycle patch strip later card updates", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(1000);
const store = new WorkboardStore(createMemoryStore());
const staleCard = await store.create({ title: "Stale bulk target" });
const freshCard = await store.create({ title: "Fresh bulk target" });
vi.setSystemTime(3000);
await store.move(staleCard.id, "running", 1000);
const patch = {
status: "review",
metadata: { lifecycleStatusSourceUpdatedAt: 2000 },
} as const;
const result = await store.bulkUpdate({
ids: [staleCard.id, freshCard.id],
patch,
});
expect(result.cards[0]).toMatchObject({ id: staleCard.id, status: "running" });
expect(result.cards[0]?.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
expect(result.cards[1]).toMatchObject({ id: freshCard.id, status: "review" });
expect(result.cards[1]?.metadata?.lifecycleStatusSourceUpdatedAt).toBe(2000);
expect(patch).toEqual({
status: "review",
metadata: { lifecycleStatusSourceUpdatedAt: 2000 },
});
} finally {
vi.useRealTimers();
}
});
it("keeps non-status fields from stale lifecycle patches", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Keep stale sync details",
execution: {
id: "exec-1",
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "running",
model: "openai/gpt-5.5",
sessionKey: "agent:main:dashboard:1",
runId: "run-1",
startedAt: 1,
updatedAt: 1000,
},
});
const lifecycleMoved = await store.update(card.id, {
status: "review",
metadata: {
lifecycleStatusSourceUpdatedAt: 1000,
stale: {
detectedAt: 1000,
lastSessionUpdatedAt: 1000,
reason: "Session has not reported recent activity.",
},
},
});
const manual = await store.update(card.id, {
status: "running",
metadata: lifecycleMoved.metadata,
});
const synced = await store.update(card.id, {
status: "review",
execution: {
id: "exec-1",
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "done",
model: "openai/gpt-5.5",
sessionKey: "agent:main:dashboard:1",
runId: "run-1",
startedAt: 1,
updatedAt: 2000,
},
metadata: {
lifecycleStatusSourceUpdatedAt: 1000,
stale: null,
},
});
expect(manual.metadata?.stale).toBeDefined();
expect(synced.status).toBe("running");
expect(synced.execution).toMatchObject({
runId: "run-1",
status: "done",
updatedAt: 2000,
});
expect(synced.metadata?.stale).toBeUndefined();
expect(synced.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
expect(synced.events?.at(-1)).toMatchObject({
kind: "attempt_updated",
runId: "run-1",
});
});
it("clears copied lifecycle provenance on manual status patches", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Clear copied provenance" });
const lifecycleMoved = await store.update(card.id, {
status: "review",
metadata: {
lifecycleStatusSourceUpdatedAt: 1000,
stale: {
kind: "session",
status: "done",
updatedAt: 1000,
observedAt: 1000,
},
},
});
const manual = await store.update(card.id, {
status: "running",
metadata: {
...lifecycleMoved.metadata,
stale: null,
},
});
expect(manual.status).toBe("running");
expect(manual.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
const staleLifecycle = await store.update(card.id, {
status: "review",
metadata: { lifecycleStatusSourceUpdatedAt: 1000 },
});
expect(staleLifecycle.status).toBe("running");
expect(staleLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
});
it("keeps execution session links aligned with edited card links", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Relink me",
sessionKey: "agent:main:dashboard:1",
execution: {
id: "exec-1",
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "running",
model: "openai/gpt-5.5",
sessionKey: "agent:main:dashboard:1",
startedAt: 10,
updatedAt: 10,
},
});
const relinked = await store.update(card.id, { sessionKey: "agent:main:dashboard:2" });
expect(relinked.sessionKey).toBe("agent:main:dashboard:2");
expect(relinked.execution?.sessionKey).toBe("agent:main:dashboard:2");
expect(relinked.events?.at(-1)).toMatchObject({
kind: "linked",
sessionKey: "agent:main:dashboard:2",
});
const unlinked = await store.update(card.id, { sessionKey: "" });
expect(unlinked.sessionKey).toBeUndefined();
expect(unlinked.execution?.sessionKey).toBeUndefined();
const cleared = await store.update(card.id, { execution: null });
expect(cleared.execution).toBeUndefined();
});
it("tracks execution attempts as card metadata", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Run worker" });
const running = await store.update(card.id, {
status: "running",
execution: {
id: "exec-1",
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "running",
model: "openai/gpt-5.5",
sessionKey: "agent:main:dashboard:1",
runId: "run-1",
startedAt: 10,
updatedAt: 10,
},
});
expect(running.metadata?.attempts).toEqual([
expect.objectContaining({
id: "run-1",
status: "running",
engine: "codex",
runId: "run-1",
}),
]);
expect(running.events?.at(-1)).toMatchObject({ kind: "moved" });
const blocked = await store.update(card.id, {
execution: {
...running.execution!,
status: "blocked",
updatedAt: 20,
},
});
expect(blocked.metadata?.attempts?.[0]).toMatchObject({
status: "blocked",
endedAt: 20,
});
expect(blocked.metadata?.failureCount).toBe(1);
expect(blocked.events?.at(-1)).toMatchObject({ kind: "attempt_updated", runId: "run-1" });
const commented = await store.addComment(card.id, { body: "Need provider follow-up." });
expect(commented.metadata?.failureCount).toBe(1);
expect(commented.metadata?.attempts?.[0]).toMatchObject({
status: "blocked",
endedAt: 20,
});
const retrying = await store.update(card.id, {
execution: {
...running.execution!,
id: "exec-2",
runId: "run-2",
status: "running",
startedAt: 30,
updatedAt: 30,
},
});
expect(retrying.metadata?.failureCount).toBe(1);
expect(retrying.metadata?.attempts?.[1]).toMatchObject({
id: "run-2",
startedAt: 30,
status: "running",
});
const blockedAgain = await store.update(card.id, {
execution: {
...retrying.execution!,
status: "blocked",
updatedAt: 40,
},
});
expect(blockedAgain.metadata?.failureCount).toBe(2);
});
it("adds comments, links, proof, and archive metadata", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Track proof" });
const commented = await store.addComment(card.id, { body: "Reviewer asked for screenshots." });
expect(commented.metadata?.comments?.[0]).toMatchObject({
body: "Reviewer asked for screenshots.",
});
expect(commented.events?.at(-1)).toMatchObject({ kind: "comment_added" });
const linked = await store.addLink(card.id, {
type: "blocked_by",
targetCardId: "card-upstream",
title: "Upstream fix",
});
expect(linked.metadata?.links?.[0]).toMatchObject({
type: "blocked_by",
targetCardId: "card-upstream",
});
expect(linked.events?.at(-1)).toMatchObject({ kind: "link_added" });
await expect(
store.addLink(card.id, { type: "parent", targetCardId: "card-upstream" }),
).rejects.toThrow(/linkDependency/);
const proven = await store.addProof(card.id, {
status: "passed",
command: "pnpm test extensions/workboard",
});
expect(proven.metadata?.proof?.[0]).toMatchObject({
status: "passed",
command: "pnpm test extensions/workboard",
});
expect(proven.events?.at(-1)).toMatchObject({ kind: "proof_added" });
const artifacted = await store.addArtifact(card.id, {
label: "Screenshot",
path: "/tmp/workboard.png",
mimeType: "image/png",
});
expect(artifacted.metadata?.artifacts?.[0]).toMatchObject({
label: "Screenshot",
path: "/tmp/workboard.png",
});
expect(artifacted.events?.at(-1)).toMatchObject({ kind: "artifact_added" });
const archived = await store.archive(card.id, true);
expect(archived.metadata?.archivedAt).toBeGreaterThan(0);
expect(archived.events?.at(-1)).toMatchObject({ kind: "archived" });
const restored = await store.archive(card.id, false);
expect(restored.metadata?.archivedAt).toBeUndefined();
expect(restored.events?.at(-1)).toMatchObject({ kind: "unarchived" });
});
it("stores attachments in the plugin kv namespace and adds worker context", async () => {
const attachments = createMemoryStore<PersistedWorkboardAttachment>();
const store = new WorkboardStore(createMemoryStore(), { attachments });
const card = await store.create({ title: "Review attached log" });
const attached = await store.addAttachment(card.id, {
fileName: "failure.log",
mimeType: "text/plain",
note: "Captured failing run",
contentBase64: Buffer.from("stack trace").toString("base64"),
});
expect(attached.metadata?.attachments?.[0]).toMatchObject({
fileName: "failure.log",
byteSize: "stack trace".length,
mimeType: "text/plain",
});
expect(attached.events?.at(-1)).toMatchObject({ kind: "attachment_added" });
const attachment = attached.metadata?.attachments?.[0];
if (!attachment) {
throw new Error("expected attachment metadata");
}
const persisted = await store.getAttachment(attachment.id);
if (!persisted) {
throw new Error("expected persisted attachment");
}
expect(Buffer.from(persisted.contentBase64, "base64").toString("utf8")).toBe("stack trace");
await expect(
store.addAttachment(card.id, {
fileName: "huge.bin",
contentBase64: Buffer.alloc(256 * 1024 + 1).toString("base64"),
}),
).rejects.toThrow(/attachment must be/);
await expect(
store.addAttachment(card.id, {
fileName: "sqlite-sized.bin",
contentBase64: Buffer.alloc(70 * 1024).toString("base64"),
}),
).resolves.toMatchObject({
metadata: {
attachments: expect.arrayContaining([
expect.objectContaining({ fileName: "sqlite-sized.bin" }),
]),
},
});
await expect(
store.addAttachment(card.id, {
fileName: "padded.txt",
contentBase64: `${Buffer.from("ok").toString("base64")}\n`,
}),
).rejects.toThrow(/canonical base64/);
const context = await store.buildWorkerContext(card.id);
expect(context).toContain("failure.log");
const deleted = await store.deleteAttachment(card.id, attachment.id);
expect(deleted.metadata?.attachments).toEqual([
expect.objectContaining({ fileName: "sqlite-sized.bin" }),
]);
expect(deleted.events?.at(-1)).toMatchObject({ kind: "edited" });
expect(await store.getAttachment(attachment.id)).toBeUndefined();
});
it("removes attachment blobs when the card attachment index prunes old entries", async () => {
const attachments = createMemoryStore<PersistedWorkboardAttachment>();
const store = new WorkboardStore(createMemoryStore(), { attachments });
const card = await store.create({ title: "Many attachments" });
let firstAttachmentId = "";
for (let index = 0; index < 21; index += 1) {
const updated = await store.addAttachment(card.id, {
fileName: `log-${index}.txt`,
contentBase64: Buffer.from(`log ${index}`).toString("base64"),
});
firstAttachmentId ||= updated.metadata?.attachments?.[0]?.id ?? "";
}
const saved = await store.get(card.id);
expect(saved?.metadata?.attachments).toHaveLength(20);
expect(await store.getAttachment(firstAttachmentId)).toBeUndefined();
const exported = await store.exportCards();
expect(exported.attachments).toHaveLength(20);
expect(exported.attachments[0]).not.toHaveProperty("contentBase64");
});
it("records worker logs and protocol violations on cards", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Protocol card",
status: "running",
sessionKey: "session-protocol",
runId: "run-protocol",
execution: {
id: "exec-protocol",
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "running",
model: "openai/gpt-5.5",
startedAt: 10,
updatedAt: 10,
},
});
const logged = await store.addWorkerLog(card.id, {
level: "warning",
message: "Worker nearing timeout.",
});
expect(logged.metadata?.workerLogs?.[0]).toMatchObject({
level: "warning",
message: "Worker nearing timeout.",
});
expect(logged.events?.at(-1)).toMatchObject({ kind: "orchestration" });
const violated = await store.recordProtocolViolation(card.id, {
detail: "Worker exited without workboard_complete.",
sessionKey: "observed-session",
runId: "observed-run",
});
expect(violated.status).toBe("blocked");
expect(violated.execution?.status).toBe("blocked");
expect(violated.metadata?.attempts).toEqual([
expect.objectContaining({
status: "blocked",
error: "Worker exited without workboard_complete.",
}),
]);
expect(violated.metadata?.workerProtocol).toMatchObject({
state: "violated",
detail: "Worker exited without workboard_complete.",
});
expect(violated.metadata?.failureCount).toBe(1);
expect(violated.metadata?.notifications).toEqual([
expect.objectContaining({
kind: "failed",
sessionKey: "observed-session",
runId: "observed-run",
}),
]);
expect(violated.events?.at(-1)).toMatchObject({ kind: "protocol_violation" });
});
it("keeps concurrent metadata appends from dropping siblings", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Collect notes" });
await Promise.all([
store.addComment(card.id, { body: "First note." }),
store.addComment(card.id, { body: "Second note." }),
]);
const saved = await store.get(card.id);
expect(saved?.metadata?.comments?.map((comment) => comment.body).toSorted()).toEqual([
"First note.",
"Second note.",
]);
});
it("keeps metadata under the keyed-store value budget", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Collect a lot of notes" });
for (let index = 0; index < 50; index += 1) {
await store.addComment(card.id, {
body: `${String(index).padStart(2, "0")} ${"x".repeat(1990)}`,
});
}
const saved = await store.get(card.id);
expect(Buffer.byteLength(JSON.stringify(saved?.metadata), "utf8")).toBeLessThanOrEqual(
24 * 1024,
);
expect(saved?.metadata?.comments?.at(-1)?.body).toContain("49 ");
expect(saved?.metadata?.comments?.length).toBeLessThan(50);
});
it("records append events when metadata retention drops old comments", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Track retained comments" });
let updated = card;
for (let index = 0; index < 51; index += 1) {
updated = await store.addComment(card.id, { body: `Note ${index}` });
}
expect(updated.metadata?.comments).toHaveLength(50);
expect(updated.metadata?.comments?.at(0)?.body).toBe("Note 1");
expect(updated.events?.at(-1)).toMatchObject({ kind: "comment_added" });
});
it("keeps queued metadata when lifecycle updates add stale state", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Sync stale state" });
await Promise.all([
store.update(card.id, {
status: "running",
metadata: {
stale: {
detectedAt: 10,
lastSessionUpdatedAt: 1,
reason: "Linked session has not reported recent activity.",
},
},
}),
store.addComment(card.id, { body: "Operator note." }),
]);
const saved = await store.get(card.id);
expect(saved?.status).toBe("running");
expect(saved?.metadata?.stale?.lastSessionUpdatedAt).toBe(1);
expect(saved?.metadata?.comments?.map((comment) => comment.body)).toContain("Operator note.");
});
it("exports card records with metadata", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Export me", templateId: "docs" });
await expect(store.exportCards()).resolves.toMatchObject({
cards: [expect.objectContaining({ id: card.id, metadata: { templateId: "docs" } })],
exportedAt: expect.any(Number),
});
});
it("claims cards, heartbeats, and releases the claim", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Coordinate worker", status: "todo" });
const claimed = await store.claim(card.id, { ownerId: "main", ttlSeconds: 60 });
expect(claimed.token).toBeTruthy();
expect(claimed.card.status).toBe("running");
expect(claimed.card.agentId).toBe("main");
expect(claimed.card.metadata?.claim).toMatchObject({ ownerId: "main" });
await expect(store.claim(card.id, { ownerId: "other" })).rejects.toThrow(/already claimed/);
const heartbeat = await store.heartbeat(card.id, {
ownerId: "main",
note: "Still running tests.",
});
expect(heartbeat.events?.at(-1)).toMatchObject({ kind: "heartbeat" });
expect(heartbeat.metadata?.comments?.at(-1)?.body).toBe("Still running tests.");
await expect(store.heartbeat(card.id, { ownerId: "other" })).rejects.toThrow(/owner/);
const released = await store.releaseClaim(card.id, { ownerId: "main", status: "review" });
expect(released.status).toBe("review");
expect(released.metadata?.claim).toBeUndefined();
});
it("caps oversized claim TTL seconds to a valid Date timestamp", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(1_000);
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Bound claim", status: "todo" });
const claimed = await store.claim(card.id, {
ownerId: "main",
ttlSeconds: Number.MAX_SAFE_INTEGER,
});
expect(claimed.card.metadata?.claim?.expiresAt).toBe(MAX_DATE_TIMESTAMP_MS);
} finally {
vi.useRealTimers();
}
});
it("does not let invalid stored claim expiry block a fresh claim", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Invalid claim expiry",
status: "todo",
metadata: {
claim: {
ownerId: "stale-worker",
token: "stale-token",
claimedAt: 1,
lastHeartbeatAt: 1,
expiresAt: Number.MAX_VALUE,
},
},
});
const claimed = await store.claim(card.id, { ownerId: "main", token: "fresh-token" });
expect(claimed.card.metadata?.claim).toMatchObject({
ownerId: "main",
token: "fresh-token",
});
});
it("creates idempotent child cards and promotes them when parents finish", async () => {
const store = new WorkboardStore(createMemoryStore());
const parent = await store.create({ title: "Parent", status: "running" });
const child = await store.create({
title: "Child",
status: "todo",
parents: [parent.id],
tenant: "release",
idempotencyKey: "fanout:1",
skills: ["testing"],
workspace: { kind: "scratch" },
});
expect(child.status).toBe("todo");
expect(child.metadata?.links).toEqual([
expect.objectContaining({ type: "parent", targetCardId: parent.id }),
]);
await expect(store.get(parent.id)).resolves.toMatchObject({
metadata: { links: [expect.objectContaining({ type: "child", targetCardId: child.id })] },
});
await expect(
store.create({
title: "Duplicate child",
tenant: "release",
idempotencyKey: "fanout:1",
}),
).resolves.toMatchObject({ id: child.id });
await expect(
store.create({
title: "Different tenant child",
tenant: "qa",
idempotencyKey: "fanout:1",
}),
).resolves.toMatchObject({ title: "Different tenant child" });
await expect(
store.create({ title: "Unscoped child", idempotencyKey: "fanout:1" }),
).resolves.toMatchObject({ title: "Unscoped child" });
await store.complete(parent.id, { summary: "Parent done." });
const promoted = await store.promoteReady();
expect(promoted.cards).toEqual([expect.objectContaining({ id: child.id, status: "ready" })]);
await expect(store.get(child.id)).resolves.toMatchObject({
status: "ready",
metadata: {
automation: {
tenant: "release",
idempotencyKey: "fanout:1",
skills: ["testing"],
workspace: { kind: "scratch" },
},
},
});
});
it("returns an idempotent child retry when its original parent was deleted", async () => {
const store = new WorkboardStore(createMemoryStore());
const parent = await store.create({ title: "Ephemeral parent" });
const child = await store.create({
title: "Retryable child",
parents: [parent.id],
tenant: "release",
idempotencyKey: "fanout:deleted-parent",
});
await store.delete(parent.id);
await expect(
store.create({
title: "Retryable child",
parents: [parent.id],
tenant: "release",
idempotencyKey: "fanout:deleted-parent",
}),
).resolves.toMatchObject({ id: child.id });
});
it("accepts POSIX and Windows absolute directory workspaces", async () => {
const store = new WorkboardStore(createMemoryStore());
await expect(
store.create({
title: "POSIX workspace",
workspace: { kind: "dir", path: "/Users/me/repo" },
}),
).resolves.toMatchObject({
metadata: { automation: { workspace: { kind: "dir", path: "/Users/me/repo" } } },
});
await expect(
store.create({
title: "Windows drive workspace",
workspace: { kind: "dir", path: String.raw`C:\Users\me\repo` },
}),
).resolves.toMatchObject({
metadata: {
automation: { workspace: { kind: "dir", path: String.raw`C:\Users\me\repo` } },
},
});
await expect(
store.create({
title: "Windows UNC workspace",
workspace: { kind: "dir", path: String.raw`\\server\share\repo` },
}),
).resolves.toMatchObject({
metadata: {
automation: { workspace: { kind: "dir", path: String.raw`\\server\share\repo` } },
},
});
await expect(
store.create({ title: "Relative workspace", workspace: { kind: "dir", path: "repo" } }),
).rejects.toThrow(/absolute/);
});
it("keeps future scheduled cards scheduled until their time arrives", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(0);
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Later",
status: "scheduled",
scheduledAt: 10_000,
});
const manual = await store.create({
title: "Manual scheduled",
status: "scheduled",
});
const implicit = await store.create({
title: "Implicit later",
scheduledAt: 10_000,
});
const activeRequested = await store.create({
title: "Active requested later",
status: "running",
scheduledAt: 10_000,
execution: {
id: "exec-scheduled",
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "running",
model: "openai/gpt-5.5",
startedAt: 0,
updatedAt: 0,
},
});
const parent = await store.create({ title: "Parent", status: "running" });
const dependent = await store.create({
title: "Dependent later",
status: "scheduled",
parents: [parent.id],
scheduledAt: 10_000,
});
expect((await store.dispatch(1_000)).promoted).toEqual([]);
await expect(store.get(card.id)).resolves.toMatchObject({ status: "scheduled" });
await expect(store.get(manual.id)).resolves.toMatchObject({ status: "scheduled" });
await expect(store.get(implicit.id)).resolves.toMatchObject({ status: "scheduled" });
await expect(store.get(activeRequested.id)).resolves.toMatchObject({ status: "scheduled" });
expect((await store.get(activeRequested.id))?.execution).toBeUndefined();
expect((await store.get(activeRequested.id))?.metadata?.attempts).toBeUndefined();
await expect(store.get(dependent.id)).resolves.toMatchObject({ status: "scheduled" });
await expect(store.claim(card.id, { ownerId: "main" })).rejects.toThrow(/scheduled/);
await expect(store.claim(manual.id, { ownerId: "main" })).rejects.toThrow(/scheduled/);
await expect(store.claim(implicit.id, { ownerId: "main" })).rejects.toThrow(/scheduled/);
await expect(store.move(manual.id, "running", manual.position)).rejects.toThrow(/scheduled/);
await store.complete(parent.id, { summary: "Parent done." });
expect((await store.dispatch(5_000)).promoted).toEqual([]);
await expect(store.get(dependent.id)).resolves.toMatchObject({ status: "scheduled" });
expect((await store.dispatch(20_000)).promoted).toEqual([
expect.objectContaining({ id: card.id, status: "ready" }),
expect.objectContaining({ id: implicit.id, status: "ready" }),
expect.objectContaining({ id: activeRequested.id, status: "ready" }),
expect.objectContaining({ id: dependent.id, status: "ready" }),
]);
await expect(store.get(manual.id)).resolves.toMatchObject({ status: "scheduled" });
} finally {
vi.useRealTimers();
}
});
it("holds dependent cards out of runnable statuses until parents finish", async () => {
const store = new WorkboardStore(createMemoryStore());
const parent = await store.create({ title: "Parent", status: "running" });
const child = await store.create({
title: "Child",
status: "running",
parents: [parent.id],
execution: {
id: "exec-held",
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "running",
model: "openai/gpt-5.5",
startedAt: 1,
updatedAt: 1,
},
});
expect(child.status).toBe("todo");
expect(child.execution).toBeUndefined();
expect(child.metadata?.attempts).toBeUndefined();
await expect(store.claim(child.id, { ownerId: "main" })).rejects.toThrow(/dependencies/);
await expect(store.move(child.id, "ready", child.position)).rejects.toThrow(/dependencies/);
await expect(store.move(child.id, "running", child.position)).rejects.toThrow(/dependencies/);
await expect(store.move(child.id, "done", child.position)).rejects.toThrow(/dependencies/);
await expect(store.update(child.id, { status: "ready" })).rejects.toThrow(/dependencies/);
await expect(store.update(child.id, { status: "done" })).rejects.toThrow(/dependencies/);
await expect(store.complete(child.id, { summary: "Too early." })).rejects.toThrow(
/dependencies/,
);
const linked = await store.update(child.id, {
metadata: {
links: [
{
id: "ordinary-link",
type: "relates_to",
createdAt: Date.now(),
url: "https://example.com/work",
},
],
},
});
expect(linked.metadata?.links).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "parent", targetCardId: parent.id }),
expect.objectContaining({ type: "relates_to", url: "https://example.com/work" }),
]),
);
await expect(store.claim(child.id, { ownerId: "main" })).rejects.toThrow(/dependencies/);
await store.complete(parent.id, { summary: "Parent done." });
const dispatch = await store.dispatch();
expect(dispatch.promoted).toEqual([expect.objectContaining({ id: child.id, status: "ready" })]);
const claimed = await store.claim(child.id, { ownerId: "main" });
expect(claimed.card.status).toBe("running");
await store.update(parent.id, { status: "running" });
await store.dispatch();
await expect(store.get(child.id)).resolves.toMatchObject({
status: "running",
metadata: { claim: expect.objectContaining({ ownerId: "main" }) },
});
await expect(store.releaseClaim(child.id, { ownerId: "main", status: "done" })).rejects.toThrow(
/dependencies/,
);
await expect(store.get(child.id)).resolves.toMatchObject({
status: "running",
metadata: { claim: expect.objectContaining({ ownerId: "main" }) },
});
const lateParent = await store.create({ title: "Late parent" });
await expect(store.linkCards(lateParent.id, child.id)).rejects.toThrow(/active child/);
});
it("rejects terminal children with incomplete dependency parents", async () => {
const store = new WorkboardStore(createMemoryStore());
const runningParent = await store.create({ title: "Running parent", status: "running" });
const doneChild = await store.create({ title: "Done child", status: "done" });
await expect(store.linkCards(runningParent.id, doneChild.id)).rejects.toThrow(/terminal child/);
await expect(
store.create({ title: "Already done", status: "done", parents: [runningParent.id] }),
).rejects.toThrow(/terminal child/);
const doneParent = await store.create({ title: "Done parent", status: "done" });
await expect(store.linkCards(doneParent.id, doneChild.id)).resolves.toMatchObject({
id: doneChild.id,
status: "done",
});
});
it("preserves dependency links across link caps and parent deletion", async () => {
const store = new WorkboardStore(createMemoryStore());
const parent = await store.create({ title: "Parent", status: "running" });
const child = await store.create({ title: "Child", parents: [parent.id] });
for (let index = 0; index < 60; index += 1) {
await store.addLink(child.id, {
type: "relates_to",
url: `https://example.com/${index}`,
});
}
await expect(store.claim(child.id, { ownerId: "main" })).rejects.toThrow(/dependencies/);
await store.delete(parent.id);
const claimed = await store.claim(child.id, { ownerId: "main" });
expect(claimed.card.status).toBe("running");
expect(claimed.card.metadata?.links?.some((link) => link.targetCardId === parent.id)).toBe(
false,
);
});
it("rolls back card creation when dependency link capacity rejects the parent", async () => {
const store = new WorkboardStore(createMemoryStore());
const parent = await store.create({ title: "Fanout parent" });
for (let index = 0; index < 50; index += 1) {
await store.create({ title: `Child ${index}`, parents: [parent.id] });
}
await expect(
store.create({
title: "Overflow child",
parents: [parent.id],
idempotencyKey: "overflow",
}),
).rejects.toThrow(/link limit/);
expect((await store.list()).some((card) => card.title === "Overflow child")).toBe(false);
});
it("rejects invalid parent creates without persisting partial cards", async () => {
const store = new WorkboardStore(createMemoryStore());
const parents: string[] = [];
for (let index = 0; index < 21; index += 1) {
parents.push((await store.create({ title: `Parent ${index}` })).id);
}
await expect(
store.create({
title: "Too many parents",
parents,
}),
).rejects.toThrow(/parents supports at most 20 entries/);
await expect(
store.create({
title: "Malformed parents",
parents: [parents[0], 123],
}),
).rejects.toThrow(/parents entries must be strings/);
await expect(
store.create({
title: "Orphan child",
parents: ["missing-parent"],
idempotencyKey: "fanout:missing",
}),
).rejects.toThrow(/card not found: missing-parent/);
expect((await store.list()).some((card) => card.title === "Too many parents")).toBe(false);
expect((await store.list()).some((card) => card.title === "Malformed parents")).toBe(false);
expect((await store.list()).some((card) => card.title === "Orphan child")).toBe(false);
});
it("rejects dependency cycles", async () => {
const store = new WorkboardStore(createMemoryStore());
const first = await store.create({ title: "First" });
const second = await store.create({ title: "Second", parents: [first.id] });
await expect(store.linkCards(second.id, first.id)).rejects.toThrow(/cycle/);
});
it("completes and blocks claimed cards with structured handoff metadata", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Ship child",
status: "running",
execution: {
id: "exec-complete",
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "running",
model: "openai/gpt-5.5",
startedAt: 1_000,
updatedAt: 1_000,
},
});
const child = await store.create({ title: "Follow-up", parents: [card.id] });
const claimed = await store.claim(card.id, { ownerId: "main", token: "token-1" });
const completed = await store.complete(claimed.card.id, {
ownerId: "main",
token: "token-1",
summary: "Implemented and verified.",
proof: { status: "passed", command: "pnpm test extensions/workboard" },
artifacts: [{ path: "/tmp/log.txt", label: "log" }],
createdCardIds: [child.id],
});
expect(completed).toMatchObject({
status: "done",
execution: { status: "done" },
metadata: {
attempts: [expect.objectContaining({ status: "succeeded", endedAt: expect.any(Number) })],
comments: [expect.objectContaining({ body: "Implemented and verified." })],
proof: [expect.objectContaining({ status: "passed" })],
artifacts: [expect.objectContaining({ path: "/tmp/log.txt" })],
automation: { summary: "Implemented and verified.", createdCardIds: [child.id] },
notifications: [expect.objectContaining({ kind: "completed" })],
},
});
expect(completed.metadata?.claim).toBeUndefined();
const blockedCard = await store.create({
title: "Blocked work",
status: "running",
execution: {
id: "exec-block",
kind: "agent-session",
engine: "claude",
mode: "autonomous",
status: "running",
model: "anthropic/claude-sonnet-4.6",
startedAt: 1_000,
updatedAt: 1_000,
},
});
await store.claim(blockedCard.id, { ownerId: "main", token: "token-2" });
const blocked = await store.block(blockedCard.id, {
ownerId: "main",
token: "token-2",
reason: "Needs owner decision.",
});
expect(blocked.status).toBe("blocked");
expect(blocked.execution?.status).toBe("blocked");
expect(blocked.metadata?.attempts).toEqual([
expect.objectContaining({
status: "blocked",
endedAt: expect.any(Number),
error: "Needs owner decision.",
}),
]);
expect(blocked.metadata?.failureCount).toBe(1);
expect(blocked.metadata?.claim).toBeUndefined();
expect(blocked.metadata?.notifications).toEqual([
expect.objectContaining({ kind: "failed", message: "Needs owner decision." }),
]);
const recovered = await store.complete(
(
await store.create({
title: "Recovered work",
status: "running",
metadata: { failureCount: 2 },
})
).id,
{ summary: "Recovered." },
);
expect(recovered.metadata?.failureCount).toBeUndefined();
});
it("keeps long lifecycle handoffs in comments while capping notifications", async () => {
const store = new WorkboardStore(createMemoryStore());
const completeCard = await store.create({ title: "Long complete" });
const blockCard = await store.create({ title: "Long block" });
const longSummary = "x".repeat(1000);
const longReason = "y".repeat(1000);
const completed = await store.complete(completeCard.id, { summary: longSummary });
const blocked = await store.block(blockCard.id, { reason: longReason });
expect(completed.metadata?.comments?.[0]?.body).toBe(longSummary);
expect(completed.metadata?.notifications?.[0]?.message.length).toBeLessThanOrEqual(240);
expect(blocked.metadata?.comments?.[0]?.body).toBe(longReason);
expect(blocked.metadata?.notifications?.[0]?.message.length).toBeLessThanOrEqual(240);
});
it("dispatches ready cards and blocks expired or timed-out work", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(1_000);
const store = new WorkboardStore(createMemoryStore());
const ready = await store.create({ title: "Ready", status: "ready" });
const readyUpdatedAt = ready.updatedAt;
const expired = await store.create({ title: "Expired", status: "running" });
await store.claim(expired.id, { ownerId: "main", token: "token-1", ttlSeconds: 1 });
const timed = await store.create({
title: "Timed",
status: "running",
maxRuntimeSeconds: 1,
execution: {
id: "exec-1",
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "running",
model: "openai/gpt-5.5",
startedAt: 1_000,
updatedAt: 1_000,
},
});
const claimedTimed = await store.create({
title: "Claimed timed",
status: "ready",
maxRuntimeSeconds: 1,
});
await store.claim(claimedTimed.id, { ownerId: "main", token: "token-2", ttlSeconds: 60 });
const createdRunningTimed = await store.create({
title: "Created running timed",
status: "running",
maxRuntimeSeconds: 1,
});
const result = await store.dispatch(10 * 60 * 1000);
expect(createdRunningTimed.startedAt).toBe(1_000);
expect(result.count).toBe(4);
await expect(store.get(ready.id)).resolves.toMatchObject({
updatedAt: readyUpdatedAt,
metadata: { automation: { dispatchCount: 1, lastDispatchAt: 600_000 } },
events: expect.arrayContaining([expect.objectContaining({ kind: "dispatch" })]),
});
const blockedExpired = await store.get(expired.id);
expect(blockedExpired).toMatchObject({ status: "blocked" });
expect(blockedExpired?.metadata?.claim).toBeUndefined();
await expect(store.get(timed.id)).resolves.toMatchObject({
status: "blocked",
execution: { status: "blocked" },
metadata: {
attempts: [expect.objectContaining({ status: "blocked", endedAt: 600_000 })],
},
});
const blockedClaimed = await store.get(claimedTimed.id);
expect(blockedClaimed).toMatchObject({ status: "blocked" });
expect(blockedClaimed?.metadata?.claim).toBeUndefined();
expect(blockedClaimed?.metadata?.notifications).toEqual(
expect.arrayContaining([
expect.objectContaining({ message: "Run exceeded the card max runtime." }),
]),
);
await expect(store.get(createdRunningTimed.id)).resolves.toMatchObject({
status: "blocked",
metadata: {
notifications: expect.arrayContaining([
expect.objectContaining({ message: "Run exceeded the card max runtime." }),
]),
},
});
} finally {
vi.useRealTimers();
}
});
it("caps oversized max runtime seconds during dispatch timeout checks", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(1_000);
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Bound runtime",
status: "running",
maxRuntimeSeconds: Number.MAX_SAFE_INTEGER,
});
if (card.startedAt === undefined) {
throw new Error("expected running card to have startedAt");
}
const result = await store.dispatch(card.startedAt + Number.MAX_SAFE_INTEGER + 1);
expect(result.blocked).toEqual([expect.objectContaining({ id: card.id })]);
await expect(store.get(card.id)).resolves.toMatchObject({ status: "blocked" });
} finally {
vi.useRealTimers();
}
});
it("lets in-flight retries finish before enforcing the retry budget", async () => {
const store = new WorkboardStore(createMemoryStore());
const retrying = await store.create({
title: "Retrying",
status: "ready",
maxRetries: 1,
metadata: { failureCount: 1 },
});
await store.claim(retrying.id, { ownerId: "main", token: "token-1" });
const retryDispatch = await store.dispatch();
expect(retryDispatch.blocked).toEqual([]);
await expect(store.get(retrying.id)).resolves.toMatchObject({ status: "running" });
const exhausted = await store.create({
title: "Exhausted",
status: "ready",
maxRetries: 1,
metadata: { failureCount: 2 },
});
const exhaustedTodo = await store.create({
title: "Exhausted todo",
status: "todo",
maxRetries: 1,
metadata: { failureCount: 2 },
});
const exhaustedBacklog = await store.create({
title: "Exhausted backlog",
status: "backlog",
maxRetries: 1,
metadata: { failureCount: 2 },
});
await expect(store.claim(exhausted.id, { ownerId: "main" })).rejects.toThrow(/retry budget/);
const exhaustedDispatch = await store.dispatch();
expect(exhaustedDispatch.blocked).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: exhausted.id, status: "blocked" }),
expect.objectContaining({ id: exhaustedTodo.id, status: "blocked" }),
expect.objectContaining({ id: exhaustedBacklog.id, status: "blocked" }),
]),
);
await expect(store.get(exhausted.id)).resolves.toMatchObject({
status: "blocked",
metadata: {
notifications: [expect.objectContaining({ message: "Card exhausted its retry budget." })],
},
});
const parent = await store.create({ title: "Parent retry gate", status: "running" });
const dependent = await store.create({
title: "Dependent exhausted",
parents: [parent.id],
maxRetries: 1,
metadata: { failureCount: 2 },
});
await store.complete(parent.id, { summary: "Parent done." });
const dependentDispatch = await store.dispatch();
expect(dependentDispatch.promoted.some((card) => card.id === dependent.id)).toBe(false);
expect(dependentDispatch.blocked).toEqual([
expect.objectContaining({ id: dependent.id, status: "blocked" }),
]);
});
it("extends claim expiry by the original TTL on heartbeat", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(1_000);
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Long run" });
await store.claim(card.id, { ownerId: "main", ttlSeconds: 60 });
vi.setSystemTime(31_000);
const heartbeat = await store.heartbeat(card.id, { ownerId: "main" });
expect(heartbeat.metadata?.claim).toMatchObject({
claimedAt: 1_000,
lastHeartbeatAt: 31_000,
expiresAt: 91_000,
});
vi.setSystemTime(61_000);
const secondHeartbeat = await store.heartbeat(card.id, { ownerId: "main" });
expect(secondHeartbeat.metadata?.claim).toMatchObject({
claimedAt: 1_000,
lastHeartbeatAt: 61_000,
expiresAt: 121_000,
});
} finally {
vi.useRealTimers();
}
});
it("caps heartbeat claim renewal to a valid Date timestamp", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(MAX_DATE_TIMESTAMP_MS - 30_000);
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Near date limit" });
await store.claim(card.id, { ownerId: "main", ttlSeconds: 60 });
vi.setSystemTime(MAX_DATE_TIMESTAMP_MS - 10_000);
const heartbeat = await store.heartbeat(card.id, { ownerId: "main" });
expect(heartbeat.metadata?.claim?.expiresAt).toBe(MAX_DATE_TIMESTAMP_MS);
} finally {
vi.useRealTimers();
}
});
it("keeps the claim when release status validation fails", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Keep claim" });
await store.claim(card.id, { ownerId: "main", token: "token-1" });
await expect(
store.releaseClaim(card.id, { ownerId: "main", token: "token-1", status: "invalid" }),
).rejects.toThrow(/status must be one of/);
await expect(store.get(card.id)).resolves.toMatchObject({
metadata: { claim: { ownerId: "main", token: "token-1" } },
});
});
it("checks mutation claim scope inside queued card writes", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Scoped mutation" });
await store.claim(card.id, { ownerId: "main", token: "token-1" });
await expect(
store.addComment(card.id, { body: "stale write" }, { ownerId: "other" }),
).rejects.toThrow(/claimed by main/);
await expect(store.get(card.id)).resolves.not.toMatchObject({
metadata: { comments: [expect.objectContaining({ body: "stale write" })] },
});
await expect(
store.addComment(card.id, { body: "owner write" }, { ownerId: "main" }),
).resolves.toMatchObject({
metadata: { comments: [expect.objectContaining({ body: "owner write" })] },
});
});
it("clears resolved proof diagnostics when adding proof", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Needs proof",
status: "done",
metadata: {
diagnostics: [
{
kind: "missing_proof",
severity: "warning",
title: "Missing proof",
detail: "Done card needs proof.",
actions: [],
detectedAt: 10,
},
],
},
});
const updated = await store.addProof(card.id, { status: "passed", label: "CI" });
expect(updated.metadata?.proof).toEqual([expect.objectContaining({ label: "CI" })]);
expect(updated.metadata?.diagnostics).toBeUndefined();
});
it("clears resolved proof diagnostics when adding an artifact", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Needs artifact",
status: "done",
metadata: {
diagnostics: [
{
kind: "missing_proof",
severity: "warning",
title: "Missing proof",
detail: "Done card needs proof.",
actions: [],
detectedAt: 10,
},
],
},
});
const updated = await store.addArtifact(card.id, { label: "log", path: "/tmp/log.txt" });
expect(updated.metadata?.artifacts).toEqual([expect.objectContaining({ label: "log" })]);
expect(updated.metadata?.diagnostics).toBeUndefined();
});
it("does not commit proof when proof artifact validation fails", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Atomic proof" });
await expect(
store.addProofWithArtifact(
card.id,
{ status: "passed", label: "CI" },
{ path: "x".repeat(2001) },
),
).rejects.toThrow(/artifact path/);
await expect(store.get(card.id)).resolves.not.toMatchObject({
metadata: { proof: [expect.objectContaining({ label: "CI" })] },
});
});
it("computes and refreshes card diagnostics", async () => {
const store = new WorkboardStore(createMemoryStore());
const ready = await store.create({
title: "Ready too long",
agentId: "main",
position: 10,
});
const running = await store.create({ title: "Loose run", status: "running", sessionKey: "s1" });
const failed = await store.create({
title: "Failed twice",
status: "blocked",
metadata: { failureCount: 2 },
});
const doneWithAttachment = await store.create({
title: "Done with attachment",
status: "done",
metadata: {
attachments: [
{
id: "attachment-proof",
cardId: "attachment-card",
fileName: "result.log",
byteSize: 1,
createdAt: 10,
},
],
},
});
const now = Date.now() + 2 * 24 * 60 * 60 * 1000;
const diagnostics = await store.refreshDiagnostics(now);
expect(diagnostics.count).toBeGreaterThanOrEqual(4);
await expect(store.get(ready.id)).resolves.toMatchObject({ updatedAt: ready.updatedAt });
await expect(store.get(ready.id)).resolves.toMatchObject({
metadata: { diagnostics: [expect.objectContaining({ kind: "stranded_ready" })] },
});
await expect(store.get(running.id)).resolves.toMatchObject({
metadata: {
diagnostics: expect.arrayContaining([
expect.objectContaining({ kind: "running_without_heartbeat" }),
expect.objectContaining({ kind: "orphaned_session" }),
]),
},
});
await expect(store.get(failed.id)).resolves.toMatchObject({
metadata: {
diagnostics: expect.arrayContaining([
expect.objectContaining({ kind: "blocked_too_long" }),
expect.objectContaining({ kind: "repeated_failures" }),
]),
},
});
await expect(store.get(doneWithAttachment.id)).resolves.not.toMatchObject({
metadata: {
diagnostics: expect.arrayContaining([expect.objectContaining({ kind: "missing_proof" })]),
},
});
});
it("does not drop concurrent updates while refreshing diagnostics", async () => {
let proofPromise: Promise<unknown> | undefined;
let triggered = false;
const keyed = createMemoryStore({
async beforeRegister(_key, value) {
if (triggered || !value.card.metadata?.diagnostics?.length) {
return;
}
triggered = true;
proofPromise = store.addProof(value.card.id, { status: "passed", label: "CI" });
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
},
});
const store: WorkboardStore = new WorkboardStore(keyed);
const card = await store.create({ title: "Ready too long", agentId: "main" });
await store.refreshDiagnostics(Date.now() + 2 * 24 * 60 * 60 * 1000);
await proofPromise;
await expect(store.get(card.id)).resolves.toMatchObject({
metadata: {
diagnostics: [expect.objectContaining({ kind: "stranded_ready" })],
proof: [expect.objectContaining({ label: "CI" })],
},
});
});
it("builds bounded worker context from card metadata", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Write docs",
notes: "Acceptance:\n- mention tools",
agentId: "main",
metadata: {
comments: [{ id: "comment-1", body: "Need proof.", createdAt: 10 }],
proof: [{ id: "proof-1", status: "passed", command: "pnpm test", createdAt: 12 }],
artifacts: [
{ id: "artifact-1", label: "Failure screenshot", path: "/tmp/fail.png", createdAt: 13 },
],
},
});
await expect(store.buildWorkerContext(card.id)).resolves.toContain("## Recent comments");
await expect(store.buildWorkerContext(card.id)).resolves.toContain("pnpm test");
await expect(store.buildWorkerContext(card.id)).resolves.toContain("Failure screenshot");
});
it("scopes idempotent creates and stats by board", async () => {
const store = new WorkboardStore(createMemoryStore());
const ops = await store.create({
title: "Ops work",
boardId: "ops",
idempotencyKey: "same",
});
const product = await store.create({
title: "Product work",
boardId: "product",
idempotencyKey: "same",
});
const repeatedOps = await store.create({
title: "Duplicate ops",
boardId: "ops",
idempotencyKey: "same",
});
expect(repeatedOps.id).toBe(ops.id);
expect(product.id).not.toBe(ops.id);
await expect(store.list({ boardId: "ops" })).resolves.toHaveLength(1);
await expect(store.listBoards()).resolves.toMatchObject({
boards: expect.arrayContaining([
expect.objectContaining({ id: "ops", total: 1 }),
expect.objectContaining({ id: "product", total: 1 }),
]),
});
await expect(store.stats({ boardId: "product" })).resolves.toMatchObject({
id: "product",
total: 1,
byStatus: { todo: 1 },
});
const prototypeAgentId = ["__", "proto__"].join("");
await store.create({
title: "Prototype safe",
boardId: "product",
agentId: prototypeAgentId,
});
const stats = await store.stats({ boardId: "product" });
expect(stats.byAgent[prototypeAgentId]).toBe(1);
});
it("rejects completed manifests for cards not created from the parent", async () => {
const store = new WorkboardStore(createMemoryStore());
const parent = await store.create({ title: "Parent", status: "running" });
const unrelated = await store.create({ title: "Unrelated" });
await expect(
store.complete(parent.id, { createdCardIds: [unrelated.id] }, null),
).rejects.toThrow(/not linked/);
const spoofed = await store.create({
title: "Spoofed",
createdByCardId: parent.id,
});
await expect(store.complete(parent.id, { createdCardIds: [spoofed.id] }, null)).rejects.toThrow(
/not linked/,
);
const child = await store.create({ title: "Child", parents: [parent.id] });
await expect(
store.complete(parent.id, { createdCardIds: [child.id], summary: "done" }, null),
).resolves.toMatchObject({
status: "done",
metadata: { automation: { createdCardIds: [child.id] } },
});
});
it("promotes, reassigns, and reclaims cards for operator recovery", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Recover me",
status: "blocked",
agentId: "old-agent",
metadata: { failureCount: 2 },
});
await store.refreshDiagnostics(Date.now() + 2 * 24 * 60 * 60 * 1000);
const reassigned = await store.reassign(card.id, {
agentId: "new-agent",
status: "todo",
reason: "route to fresh agent",
});
expect(reassigned).toMatchObject({
agentId: "new-agent",
status: "todo",
});
expect(reassigned.metadata?.failureCount).toBeUndefined();
expect(reassigned.metadata?.diagnostics?.map((entry) => entry.kind) ?? []).not.toContain(
"repeated_failures",
);
await expect(store.promote(card.id)).resolves.toMatchObject({ status: "ready" });
const claimed = await store.claim(card.id, { ownerId: "new-agent" });
const reclaimed = await store.reclaim(claimed.card.id, { reason: "stale session" }, null);
expect(reclaimed).toMatchObject({ status: "ready" });
expect(reclaimed.metadata?.claim).toBeUndefined();
const running = await store.create({
title: "Running recovery",
status: "running",
execution: {
id: "exec-reclaim",
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "running",
model: "openai/gpt-5.5",
startedAt: 100,
updatedAt: 100,
},
});
const stopped = await store.reclaim(running.id, { reason: "replace worker" }, null);
expect(stopped.execution).toBeUndefined();
expect(stopped.metadata?.attempts).toEqual([expect.objectContaining({ status: "stopped" })]);
expect(stopped.metadata?.failureCount).toBeUndefined();
});
it("includes parent results and recent assignee work in worker context", async () => {
const store = new WorkboardStore(createMemoryStore());
const parent = await store.create({
title: "Design",
status: "running",
agentId: "agent-a",
});
await store.complete(parent.id, { summary: "Use board-scoped queues." }, null);
await store.create({
title: "Older task",
status: "done",
agentId: "agent-a",
metadata: { automation: { summary: "Finished related cleanup." } },
});
const child = await store.create({
title: "Implement",
agentId: "agent-a",
parents: [parent.id],
});
const context = await store.buildWorkerContext(child.id);
expect(context).toContain("## Parent results");
expect(context).toContain("Use board-scoped queues.");
expect(context).toContain("## Recent done work by agent-a");
expect(context).toContain("Finished related cleanup.");
const crossBoardChild = await store.create({
title: "Cross-board child",
boardId: "product",
parents: [parent.id],
});
await expect(store.buildWorkerContext(crossBoardChild.id)).resolves.toContain(
"Use board-scoped queues.",
);
});
it("persists board metadata and notification subscriptions in separate stores", async () => {
const cards = createMemoryStore();
const boards = createMemoryStore<PersistedWorkboardBoard>();
const subscriptions = createMemoryStore<PersistedWorkboardNotificationSubscription>();
const store = new WorkboardStore(cards, { boards, subscriptions });
const board = await store.upsertBoard({
id: "ops",
name: "Ops",
description: "Operational work",
defaultWorkspace: { kind: "dir", path: "/tmp/openclaw-ops" },
});
const card = await store.create({ title: "Ops card", boardId: "ops" });
const subscription = await store.subscribeNotifications({
boardId: "ops",
cardId: card.id,
target: "session:operator",
eventKinds: ["completed", "failed"],
});
await expect(boards.lookup("ops")).resolves.toMatchObject({
version: 1,
board: { id: "ops", name: "Ops", description: "Operational work" },
});
await expect(subscriptions.lookup(subscription.id)).resolves.toMatchObject({
version: 1,
subscription: {
id: subscription.id,
boardId: "ops",
cardId: card.id,
target: "session:operator",
eventKinds: ["completed", "failed"],
},
});
await expect(cards.lookup("ops")).resolves.toBeUndefined();
expect(board.defaultWorkspace).toEqual({ kind: "dir", path: "/tmp/openclaw-ops" });
expect((await store.listBoards()).boards.find((item) => item.id === "ops")).toMatchObject({
name: "Ops",
total: 1,
active: 1,
byStatus: { todo: 1 },
});
await expect(store.listNotificationSubscriptions({ boardId: "ops" })).resolves.toMatchObject({
subscriptions: [expect.objectContaining({ id: subscription.id, cardId: card.id })],
});
});
it("replays notification events with subscription cursors", async () => {
const subscriptions = createMemoryStore<PersistedWorkboardNotificationSubscription>();
const store = new WorkboardStore(createMemoryStore(), { subscriptions });
const card = await store.create({ title: "Notify me", boardId: "ops" });
const subscription = await store.subscribeNotifications({
boardId: "ops",
cardId: card.id,
target: "session:operator",
eventKinds: ["completed"],
});
await store.complete(card.id, { summary: "Done." });
const preview = await store.notificationEvents({ subscriptionId: subscription.id });
expect(preview.events).toEqual([expect.objectContaining({ kind: "completed" })]);
const storedPreview = await subscriptions.lookup(subscription.id);
expect(storedPreview?.subscription).not.toHaveProperty("lastEventAt");
expect(storedPreview?.subscription).not.toHaveProperty("lastEventId");
const first = await store.advanceNotificationEvents({
subscriptionId: subscription.id,
});
expect(first.events).toEqual([expect.objectContaining({ kind: "completed" })]);
const event = first.events[0];
if (!event) {
throw new Error("expected notification event");
}
await expect(subscriptions.lookup(subscription.id)).resolves.toMatchObject({
subscription: {
lastEventAt: event.createdAt,
lastEventId: event.id,
},
});
await expect(store.notificationEvents({ subscriptionId: subscription.id })).resolves.toEqual({
subscription: expect.objectContaining({ id: subscription.id }),
events: [],
});
await expect(store.notificationEvents({ subscriptionId: "missing" })).rejects.toThrow(
/subscription not found/,
);
await expect(store.advanceNotificationEvents({ boardId: "ops" })).rejects.toThrow(
/subscriptionId is required/,
);
});
it("does not skip same-millisecond notification events after cursor advancement", async () => {
const store = new WorkboardStore(createMemoryStore(), {
subscriptions: createMemoryStore<PersistedWorkboardNotificationSubscription>(),
});
await store.create({
title: "First same-ms event",
boardId: "ops",
metadata: {
notifications: [
{
id: "z-event",
kind: "completed",
createdAt: 1234,
sequence: 1234000,
message: "First",
},
],
},
});
await store.create({
title: "Second same-ms event",
boardId: "ops",
metadata: {
notifications: [
{
id: "a-event",
kind: "completed",
createdAt: 1234,
sequence: 1234001,
message: "Second",
},
],
},
});
const subscription = await store.subscribeNotifications({
boardId: "ops",
target: "session:operator",
eventKinds: ["completed"],
});
const first = await store.advanceNotificationEvents({
subscriptionId: subscription.id,
limit: 1,
});
expect(first.events).toEqual([expect.objectContaining({ id: "z-event" })]);
const second = await store.notificationEvents({ subscriptionId: subscription.id });
expect(second.events).toEqual([expect.objectContaining({ id: "a-event" })]);
});
it("drains large same-millisecond notification batches without replaying delivered ids", async () => {
const store = new WorkboardStore(createMemoryStore(), {
subscriptions: createMemoryStore<PersistedWorkboardNotificationSubscription>(),
});
for (let index = 0; index < 205; index += 1) {
await store.create({
title: `Same-ms event ${index}`,
boardId: "ops",
metadata: {
notifications: [
{
id: `event-${index}`,
kind: "completed",
createdAt: 1234,
sequence: 1234000 + index,
message: `Event ${index}`,
},
],
},
});
}
const subscription = await store.subscribeNotifications({
boardId: "ops",
target: "session:operator",
eventKinds: ["completed"],
});
const first = await store.advanceNotificationEvents({
subscriptionId: subscription.id,
limit: 200,
});
expect(first.events).toHaveLength(200);
const second = await store.advanceNotificationEvents({ subscriptionId: subscription.id });
expect(second.events).toHaveLength(5);
await expect(store.notificationEvents({ subscriptionId: subscription.id })).resolves.toEqual({
subscription: expect.objectContaining({ id: subscription.id }),
events: [],
});
});
it("filters replayed notification events by session and run subscriptions", async () => {
const store = new WorkboardStore(createMemoryStore(), {
subscriptions: createMemoryStore<PersistedWorkboardNotificationSubscription>(),
});
const matching = await store.create({
title: "Matching session",
boardId: "ops",
sessionKey: "session-1",
runId: "run-1",
});
const unrelated = await store.create({
title: "Other session",
boardId: "ops",
sessionKey: "session-2",
runId: "run-2",
});
await store.create({
title: "Card-scoped failed notification",
boardId: "ops",
sessionKey: "session-1",
runId: "run-1",
metadata: {
notifications: [
{
id: "card-scoped-failed",
kind: "failed",
createdAt: 1234,
message: "Dispatch failed before stamping event scope.",
},
],
},
});
const subscription = await store.subscribeNotifications({
boardId: "ops",
sessionKey: "session-1",
runId: "run-1",
target: "session:operator",
});
await store.complete(unrelated.id, { summary: "Other done." });
await store.complete(matching.id, { summary: "Matching done." });
await expect(store.notificationEvents({ subscriptionId: subscription.id })).resolves.toEqual({
subscription: expect.objectContaining({ id: subscription.id }),
events: [
expect.objectContaining({ id: "card-scoped-failed" }),
expect.objectContaining({ sessionKey: "session-1", runId: "run-1" }),
],
});
});
it("replays card-scoped subscriptions without requiring the board id", async () => {
const store = new WorkboardStore(createMemoryStore(), {
subscriptions: createMemoryStore<PersistedWorkboardNotificationSubscription>(),
});
const card = await store.create({ title: "Ops card", boardId: "ops" });
const subscription = await store.subscribeNotifications({
cardId: card.id,
target: "session:operator",
eventKinds: ["completed"],
});
await store.complete(card.id, { summary: "Ops done." });
await expect(store.notificationEvents({ subscriptionId: subscription.id })).resolves.toEqual({
subscription: expect.objectContaining({ id: subscription.id, cardId: card.id }),
events: [expect.objectContaining({ kind: "completed" })],
});
});
it("replays stale metadata as stale notification events", async () => {
const store = new WorkboardStore(createMemoryStore(), {
subscriptions: createMemoryStore<PersistedWorkboardNotificationSubscription>(),
});
await store.create({
title: "Stale card",
boardId: "ops",
metadata: {
stale: {
detectedAt: 1234,
reason: "Session has not reported recent activity.",
},
},
});
const subscription = await store.subscribeNotifications({
boardId: "ops",
target: "session:operator",
eventKinds: ["stale"],
});
await expect(store.notificationEvents({ subscriptionId: subscription.id })).resolves.toEqual({
subscription: expect.objectContaining({ id: subscription.id }),
events: [
expect.objectContaining({
id: expect.stringContaining("stale:"),
kind: "stale",
createdAt: 1234,
}),
],
});
});
it("marks triage cards as orchestration candidates during dispatch", async () => {
const boards = createMemoryStore<PersistedWorkboardBoard>();
const store = new WorkboardStore(createMemoryStore(), { boards });
await store.upsertBoard({
id: "planning",
orchestration: { autoDecompose: true, autoDecomposePerDispatch: 1 },
});
const first = await store.create({
title: "Break down import flow",
status: "triage",
boardId: "planning",
});
const archived = await store.create({
title: "Archived import flow",
status: "triage",
boardId: "planning",
});
await store.archive(archived.id, true);
const second = await store.create({
title: "Break down export flow",
status: "triage",
boardId: "planning",
});
const dispatch = await store.dispatch(10);
expect(dispatch.orchestrated).toEqual([
expect.objectContaining({ id: first.id, status: "triage" }),
]);
expect(dispatch.count).toBe(1);
await expect(store.get(first.id)).resolves.toMatchObject({
metadata: {
workerProtocol: {
state: "idle",
detail: "Awaiting workboard_specify or workboard_decompose.",
},
workerLogs: [expect.objectContaining({ level: "info" })],
},
events: expect.arrayContaining([expect.objectContaining({ kind: "orchestration" })]),
});
await expect(store.get(second.id)).resolves.not.toMatchObject({
metadata: { workerProtocol: expect.any(Object) },
});
await expect(store.get(archived.id)).resolves.not.toMatchObject({
metadata: { workerProtocol: expect.any(Object) },
});
});
it("applies auto orchestration dispatch caps per board", async () => {
const boards = createMemoryStore<PersistedWorkboardBoard>();
const store = new WorkboardStore(createMemoryStore(), { boards });
await store.upsertBoard({
id: "ops",
orchestration: { autoDecompose: true, autoDecomposePerDispatch: 1 },
});
await store.upsertBoard({
id: "product",
orchestration: { autoDecompose: true, autoDecomposePerDispatch: 1 },
});
const ops = await store.create({ title: "Ops rough", status: "triage", boardId: "ops" });
const product = await store.create({
title: "Product rough",
status: "triage",
boardId: "product",
});
const dispatch = await store.dispatch(10);
expect(dispatch.orchestrated.map((card) => card.id).toSorted()).toEqual(
[ops.id, product.id].toSorted(),
);
});
it("scopes dispatch mutations by board", async () => {
const boards = createMemoryStore<PersistedWorkboardBoard>();
const store = new WorkboardStore(createMemoryStore(), { boards });
await store.upsertBoard({
id: "ops",
orchestration: { autoDecompose: true, autoDecomposePerDispatch: 1 },
});
await store.upsertBoard({
id: "product",
orchestration: { autoDecompose: true, autoDecomposePerDispatch: 1 },
});
const ops = await store.create({ title: "Ops rough", status: "triage", boardId: "ops" });
const product = await store.create({
title: "Product rough",
status: "triage",
boardId: "product",
});
const dispatch = await store.dispatch({ now: 10, boardId: "ops" });
expect(dispatch.orchestrated.map((card) => card.id)).toEqual([ops.id]);
await expect(store.get(ops.id)).resolves.toMatchObject({
metadata: { workerProtocol: expect.any(Object) },
});
await expect(store.get(product.id)).resolves.not.toMatchObject({
metadata: { workerProtocol: expect.any(Object) },
});
});
it("deletes board notification subscriptions with empty board metadata", async () => {
const store = new WorkboardStore(createMemoryStore(), {
boards: createMemoryStore<PersistedWorkboardBoard>(),
subscriptions: createMemoryStore<PersistedWorkboardNotificationSubscription>(),
});
await store.upsertBoard({ id: "ops", name: "Ops" });
await store.subscribeNotifications({
boardId: "ops",
target: "session:operator",
eventKinds: ["completed"],
});
await expect(store.deleteBoard("ops")).resolves.toEqual({ deleted: true });
await expect(store.listNotificationSubscriptions({ boardId: "ops" })).resolves.toEqual({
subscriptions: [],
});
});
it("deletes card notification subscriptions with the card", async () => {
const store = new WorkboardStore(createMemoryStore(), {
subscriptions: createMemoryStore<PersistedWorkboardNotificationSubscription>(),
});
const card = await store.create({ title: "Notify me" });
await store.subscribeNotifications({
cardId: card.id,
target: "session:operator",
eventKinds: ["completed"],
});
await expect(store.delete(card.id)).resolves.toEqual({ deleted: true });
await expect(store.listNotificationSubscriptions({ cardId: card.id })).resolves.toEqual({
subscriptions: [],
});
});
it("specifies and decomposes rough cards into linked children", async () => {
const store = new WorkboardStore(createMemoryStore());
const parent = await store.create({
title: "Rough idea",
status: "triage",
boardId: "planning",
tenant: "qa",
idempotencyKey: "planning:rough",
});
const specified = await store.specify(parent.id, {
title: "Clarified plan",
notes: "Acceptance: two concrete follow-up cards.",
summary: "Clarified the outcome and acceptance criteria.",
labels: ["planning"],
});
expect(specified).toMatchObject({
title: "Clarified plan",
status: "todo",
notes: "Acceptance: two concrete follow-up cards.",
labels: ["planning"],
metadata: {
comments: [
expect.objectContaining({ body: "Clarified the outcome and acceptance criteria." }),
],
},
});
expect(specified.events?.at(-1)).toMatchObject({ kind: "specified" });
const result = await store.decompose(specified.id, {
summary: "Split into implementation and review.",
children: [
{ title: "Implement SQLite persistence", priority: "high" },
{ title: "Review Workboard flows", agentId: "reviewer" },
],
});
expect(result.parent.status).toBe("done");
expect(result.parent.events?.at(-1)).toMatchObject({ kind: "decomposed" });
expect(result.parent.metadata?.automation?.createdCardIds).toEqual(
result.children.map((child) => child.id),
);
expect(result.children).toEqual([
expect.objectContaining({
title: "Implement SQLite persistence",
priority: "high",
metadata: {
automation: expect.objectContaining({
boardId: "planning",
tenant: "qa",
createdByCardId: parent.id,
idempotencyKey: "planning:rough:child:1",
}),
links: expect.arrayContaining([
expect.objectContaining({ type: "parent", targetCardId: parent.id }),
]),
},
}),
expect.objectContaining({
title: "Review Workboard flows",
agentId: "reviewer",
}),
]);
await expect(store.runs(parent.id)).resolves.toMatchObject({
card: { id: parent.id },
attempts: [],
});
});
it("keeps specify as a todo-only clarification step", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Rough idea", status: "triage" });
const blocked = await store.create({ title: "Blocked idea", status: "blocked" });
await expect(store.specify(card.id, { status: "done" })).rejects.toThrow(/must move to todo/);
await expect(store.specify(card.id, { status: "running" })).rejects.toThrow(
/must move to todo/,
);
await expect(store.specify(blocked.id, { title: "Specified" })).rejects.toThrow(
/only triage, backlog, or todo/,
);
await expect(store.specify(card.id, { title: "Specified" })).resolves.toMatchObject({
title: "Specified",
status: "todo",
});
});
it("rolls back newly created children when decomposition fails", async () => {
const store = new WorkboardStore(createMemoryStore());
const parent = await store.create({ title: "Parent", status: "todo" });
await expect(
store.decompose(parent.id, {
children: [{ title: "First child" }, { notes: "Missing title" }],
}),
).rejects.toThrow(/title is required/);
await expect(store.list()).resolves.toEqual([expect.objectContaining({ id: parent.id })]);
expect((await store.get(parent.id))?.metadata?.links).toBeUndefined();
});
it("rolls back links added to reused idempotent children when decomposition fails", async () => {
const store = new WorkboardStore(createMemoryStore());
const parent = await store.create({ title: "Parent" });
const existingChild = await store.create({
title: "Existing child",
status: "ready",
idempotencyKey: "child-key",
});
await store.addLink(existingChild.id, { type: "relates_to", targetCardId: parent.id });
await expect(
store.decompose(parent.id, {
children: [
{ title: "Existing child", idempotencyKey: "child-key" },
{ notes: "Missing title" },
],
}),
).rejects.toThrow(/title is required/);
await expect(store.list()).resolves.toHaveLength(2);
expect((await store.get(parent.id))?.metadata?.links).toBeUndefined();
await expect(store.get(existingChild.id)).resolves.toMatchObject({
status: "ready",
metadata: {
links: [expect.objectContaining({ type: "relates_to", targetCardId: parent.id })],
},
});
});
it("preserves parent child links when decomposition leaves the parent open", async () => {
const store = new WorkboardStore(createMemoryStore());
const parent = await store.create({ title: "Parent", status: "triage" });
await store.addLink(parent.id, { type: "relates_to", url: "https://example.com/context" });
const result = await store.decompose(parent.id, {
completeParent: false,
summary: "Split and keep parent open.",
children: [{ title: "Child" }],
});
expect(result.parent.status).toBe("todo");
expect(result.parent.metadata?.links).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "relates_to", url: "https://example.com/context" }),
expect.objectContaining({ type: "child", targetCardId: result.children[0]?.id }),
]),
);
await expect(
store.complete(parent.id, {
createdCardIds: result.children.map((child) => child.id),
summary: "Children recorded.",
}),
).resolves.toMatchObject({ status: "done" });
});
it("omits derived child idempotency keys when the parent key is already at the limit", async () => {
const store = new WorkboardStore(createMemoryStore());
const parent = await store.create({
title: "Parent",
idempotencyKey: "p".repeat(160),
});
const result = await store.decompose(parent.id, {
children: [{ title: "Child" }],
});
expect(result.children[0]?.metadata?.automation?.idempotencyKey).toBeUndefined();
});
it("links an idempotent existing child before completing decomposition", async () => {
const store = new WorkboardStore(createMemoryStore());
const existingChild = await store.create({
title: "Existing child",
idempotencyKey: "child-key",
});
const parent = await store.create({ title: "Parent" });
const result = await store.decompose(parent.id, {
children: [{ title: "Ignored duplicate", idempotencyKey: "child-key" }],
});
expect(result.parent.status).toBe("done");
expect(result.children).toEqual([expect.objectContaining({ id: existingChild.id })]);
expect(result.parent.metadata?.automation?.createdCardIds).toEqual([existingChild.id]);
await expect(store.get(existingChild.id)).resolves.toMatchObject({
metadata: {
links: expect.arrayContaining([
expect.objectContaining({ type: "parent", targetCardId: parent.id }),
]),
},
});
});
it("rejects invalid status values", async () => {
const store = new WorkboardStore(createMemoryStore());
await expect(store.create({ title: "Bad card", status: "later" })).rejects.toThrow(
/status must be one of/,
);
});
});