// 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(options?: { beforeRegister?: (key: string, value: T) => Promise | void; }): WorkboardKeyedStore { const entries = new Map(); 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 { return { type, bsize: 1024, blocks: 1, bfree: 1, bavail: 1, files: 0, frsize: 1024, ffree: 0, }; } describe("WorkboardStore", () => { it("emits one monotonic change after each visible mutation", async () => { const store = new WorkboardStore(createMemoryStore()); const changes = vi.fn(); store.subscribeChanges(changes); const card = await store.create({ title: "Track changes" }); await store.update(card.id, { notes: "updated" }); await store.list(); await expect(store.update("missing", { notes: "failed" })).rejects.toThrow("card not found"); expect(changes.mock.calls.map(([change]) => change.revision)).toEqual([1, 2]); expect(changes.mock.calls[1]?.[0].epoch).toBe(changes.mock.calls[0]?.[0].epoch); }); it("does not emit for no-op commands and isolates listener failures", async () => { const store = new WorkboardStore(createMemoryStore()); const changes = vi.fn(() => { throw new Error("listener failed"); }); store.subscribeChanges(changes); const card = await store.create({ title: "Idempotent", idempotencyKey: "same" }); await store.create({ title: "Duplicate", idempotencyKey: "same" }); await store.delete("missing"); expect(card.title).toBe("Idempotent"); expect(changes).toHaveBeenCalledOnce(); }); it("announces an epoch and reports failed commands that partially committed", async () => { const subscriptions = createMemoryStore(); subscriptions.entries = async () => { throw new Error("subscription cleanup failed"); }; const store = new WorkboardStore(createMemoryStore(), { subscriptions }); const changes = vi.fn(); store.subscribeChanges(changes); store.announceChangeEpoch(); const card = await store.create({ title: "Partial delete" }); await expect(store.delete(card.id)).rejects.toThrow("subscription cleanup failed"); expect(changes.mock.calls.map(([change]) => change.revision)).toEqual([1, 2, 3]); await expect(store.get(card.id)).resolves.toBeUndefined(); }); it("emits when another sqlite connection commits", async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-change-")); const dbPath = path.join(dir, "workboard.sqlite"); const readerStores = createWorkboardSqliteStores({ dbPath }); const writerStores = createWorkboardSqliteStores({ dbPath }); try { const reader = new WorkboardStore(readerStores.cards, { boards: readerStores.boards, subscriptions: readerStores.subscriptions, attachments: readerStores.attachments, dataVersion: readerStores.dataVersion, }); const writer = new WorkboardStore(writerStores.cards, { boards: writerStores.boards, subscriptions: writerStores.subscriptions, attachments: writerStores.attachments, dataVersion: writerStores.dataVersion, }); const changes = vi.fn(); reader.subscribeChanges(changes); expect(reader.reconcileExternalChanges()).toBe(false); await writer.create({ title: "External" }); expect(reader.reconcileExternalChanges()).toBe(true); expect(reader.reconcileExternalChanges()).toBe(false); expect(changes).toHaveBeenCalledOnce(); await expect(reader.list()).resolves.toEqual([ expect.objectContaining({ title: "External" }), ]); } finally { writerStores.close(); readerStores.close(); fs.rmSync(dir, { recursive: true, force: true }); } }); 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("only accepts workspace authority from trusted top-level provenance", async () => { const store = new WorkboardStore(createMemoryStore()); const card = await store.create({ title: "Restricted card", workspaceAccess: { unrestricted: false, roots: ["/workspace"], writable: true }, metadata: { automation: { workspaceAccess: { unrestricted: true } }, }, }); expect(card.metadata?.automation?.workspaceAccess).toEqual({ unrestricted: false, roots: ["/workspace"], writable: true, }); const updated = await store.update(card.id, { metadata: { automation: { workspaceAccess: { unrestricted: true } } }, }); expect(updated.metadata?.automation?.workspaceAccess).toEqual({ unrestricted: false, roots: ["/workspace"], writable: true, }); const untrusted = await store.create({ title: "Untrusted metadata", metadata: { automation: { workspaceAccess: { unrestricted: true } } }, }); expect(untrusted.metadata?.automation?.workspaceAccess).toBeUndefined(); }); 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(); 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(); 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(); const tokenCard = await store.create({ title: "Token-authorized worker", status: "todo" }); const tokenClaim = await store.claim(tokenCard.id, { ownerId: "main", ttlSeconds: 60 }); await expect( store.heartbeat(tokenCard.id, { ownerId: "other", token: "wrong-token" }), ).rejects.toThrow(/token does not match/); await expect( store.heartbeat(tokenCard.id, { ownerId: "other", token: tokenClaim.token }), ).resolves.toMatchObject({ metadata: { claim: { ownerId: "main" } } }); await expect( store.releaseClaim(tokenCard.id, { ownerId: "other", token: "wrong-token" }), ).rejects.toThrow(/token does not match/); const tokenReleased = await store.releaseClaim(tokenCard.id, { ownerId: "other", token: tokenClaim.token, }); expect(tokenReleased.metadata?.claim).toBeUndefined(); }); it("atomically guards and adopts dispatcher workspace authority", async () => { const store = new WorkboardStore(createMemoryStore()); const card = await store.create({ title: "Legacy dispatch", status: "ready" }); const expectedAuthority = { agentId: card.agentId, workspace: card.metadata?.automation?.workspace, workspaceAccess: card.metadata?.automation?.workspaceAccess, }; await store.update(card.id, { workspace: { kind: "dir", path: "/restricted" }, workspaceAccess: { unrestricted: false, roots: ["/restricted"], writable: true }, }); await expect( store.claim( card.id, { ownerId: "dispatcher" }, { expectedAuthority, adoptWorkspaceAccess: { unrestricted: true } }, ), ).rejects.toThrow("card workspace authority changed before claim"); await expect(store.get(card.id)).resolves.toMatchObject({ status: "ready", metadata: { automation: { workspaceAccess: { unrestricted: false, roots: ["/restricted"], writable: true, }, }, }, }); const legacy = await store.create({ title: "Legacy scratch", status: "ready" }); const claimed = await store.claim( legacy.id, { ownerId: "dispatcher" }, { expectedAuthority: { agentId: legacy.agentId, workspace: legacy.metadata?.automation?.workspace, workspaceAccess: legacy.metadata?.automation?.workspaceAccess, }, adoptWorkspaceAccess: { unrestricted: true }, }, ); expect(claimed.card.metadata?.automation?.workspaceAccess).toEqual({ unrestricted: true }); }); it("reports an active claim after a dependency-backed card starts running", async () => { const store = new WorkboardStore(createMemoryStore()); const parent = await store.create({ title: "Parent", status: "done" }); const child = await store.create({ title: "Child", parents: [parent.id] }); await store.claim(child.id, { ownerId: "main", ttlSeconds: 60 }); await expect(store.claim(child.id, { ownerId: "other" })).rejects.toThrow( "card already claimed by main.", ); }); it("preserves scheduled and retry-budget errors when a claim is active", async () => { vi.useFakeTimers(); try { vi.setSystemTime(1_000); const store = new WorkboardStore(createMemoryStore()); const scheduled = await store.create({ title: "Scheduled", status: "ready" }); await store.claim(scheduled.id, { ownerId: "main", ttlSeconds: 60 }); await store.update(scheduled.id, { status: "scheduled", scheduledAt: 10_000 }); const exhausted = await store.create({ title: "Exhausted", status: "ready", maxRetries: 1, metadata: { failureCount: 1 }, }); await store.claim(exhausted.id, { ownerId: "main", ttlSeconds: 60 }); await store.update(exhausted.id, { metadata: { failureCount: 2 } }); await expect(store.claim(scheduled.id, { ownerId: "other" })).rejects.toThrow( "card is scheduled for later.", ); await expect(store.claim(exhausted.id, { ownerId: "other" })).rejects.toThrow( "card exhausted its retry budget.", ); } finally { vi.useRealTimers(); } }); 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("resolves parent dependency status with targeted lookups instead of a full-corpus scan", async () => { const cardStore = createMemoryStore(); const entriesSpy = vi.spyOn(cardStore, "entries"); const store = new WorkboardStore(cardStore); const parent = await store.create({ title: "Parent" }); const children = await Promise.all( Array.from({ length: 8 }, (_, i) => store.create({ title: `Child ${i}`, status: "todo", parents: [parent.id] }), ), ); await store.complete(parent.id, { summary: "Parent done." }); entriesSpy.mockClear(); const dispatch = await store.dispatch(); const idComparator = (left: string, right: string) => left.localeCompare(right); expect(dispatch.promoted.map((card) => card.id).toSorted(idComparator)).toEqual( children.map((child) => child.id).toSorted(idComparator), ); // Regression guard for the dependencyTargetStatus N+1: before the fix, every // parented card being checked in this pass triggered its own additional // unscoped list() call (an extra full-corpus scan per child, here 8 of them). // Resolving parents via targeted get() calls keeps this flat regardless of // how many dependent cards are promoted together. expect(entriesSpy.mock.calls.length).toBeLessThanOrEqual(1); }); 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("lets operators override claims while enforcing agent-scoped moves", async () => { const store = new WorkboardStore(createMemoryStore()); const card = await store.create({ title: "Scoped move", status: "todo" }); await store.claim(card.id, { ownerId: "agent-a", token: "test-auth-token" }); await expect(store.move(card.id, "review", undefined, { ownerId: "agent-b" })).rejects.toThrow( "card is claimed by agent-a", ); await expect(store.get(card.id)).resolves.toMatchObject({ status: "running" }); await expect(store.move(card.id, "review", undefined)).resolves.toMatchObject({ status: "review", }); }); it("checks matching claim tokens inside queued card writes", async () => { const store = new WorkboardStore(createMemoryStore()); const card = await store.create({ title: "Token-scoped mutation" }); await store.claim(card.id, { ownerId: "main", token: "test-auth-token" }); await expect( store.addComment( card.id, { body: "rejected write" }, { ownerId: "other", token: "test-token-placeholder" }, ), ).rejects.toThrow(/claimed by main/); await expect( store.addComment( card.id, { body: "accepted write" }, { ownerId: "other", token: "test-auth-token" }, ), ).resolves.toMatchObject({ metadata: { comments: [expect.objectContaining({ body: "accepted 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 | 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("keeps worker-context text bounds UTF-16 safe", async () => { const store = new WorkboardStore(createMemoryStore()); const card = await store.create({ title: "Bound context", metadata: { comments: [ { id: "comment-1", body: `${"x".repeat(398)}🚀tail`, createdAt: 10, }, ], }, }); await expect(store.buildWorkerContext(card.id)).resolves.toContain(`- ${"x".repeat(398)}…`); }); 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); expect(ops.position).toBe(1000); expect(product.position).toBe(1000); 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(""); const secondProduct = await store.create({ title: "Prototype safe", boardId: "product", agentId: prototypeAgentId, }); expect(secondProduct.position).toBe(2000); const stats = await store.stats({ boardId: "product" }); expect(stats.byAgent[prototypeAgentId]).toBe(1); const metadataBoardFirst = await store.create({ title: "Metadata board first", metadata: { automation: { boardId: "metadata-board" } }, }); const metadataBoardSecond = await store.create({ title: "Metadata board second", metadata: { automation: { boardId: "metadata-board" } }, }); expect(metadataBoardFirst.position).toBe(1000); expect(metadataBoardSecond.position).toBe(2000); }); 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(); const subscriptions = createMemoryStore(); 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(); 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(), }); 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(), }); 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(), }); 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(), }); 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(), }); 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(); 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(); 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(); 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(), subscriptions: createMemoryStore(), }); 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(), }); 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/, ); }); });