Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
// Workboard plugin module implements card lookup behavior.
import type { WorkboardCard } from "./types.js";
export type WorkboardCardLookupResult =
| { card: WorkboardCard; error?: undefined }
| { card?: undefined; error: string };
export function resolveWorkboardCardByIdOrPrefix(
cards: readonly WorkboardCard[],
id: string,
): WorkboardCardLookupResult {
const exact = cards.find((card) => card.id === id);
if (exact) {
return { card: exact };
}
const matches = cards.filter((card) => card.id.startsWith(id));
if (matches.length === 0) {
return { error: `Card not found: ${id}` };
}
if (matches.length > 1) {
return { error: `Ambiguous card id prefix: ${id} (${matches.length} matches)` };
}
const card = matches[0];
return card ? { card } : { error: `Card not found: ${id}` };
}

View File

@@ -0,0 +1,189 @@
// Workboard tests cover cli plugin behavior.
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerWorkboardCli } from "./cli.js";
import { WorkboardStore, type PersistedWorkboardCard, type WorkboardKeyedStore } from "./store.js";
const gatewayRuntime = vi.hoisted(() => ({
callGatewayFromCli: vi.fn(),
getRuntimeConfig: vi.fn(() => ({})),
}));
vi.mock("openclaw/plugin-sdk/gateway-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/gateway-runtime")>(
"openclaw/plugin-sdk/gateway-runtime",
);
return {
...actual,
callGatewayFromCli: gatewayRuntime.callGatewayFromCli,
};
});
vi.mock("openclaw/plugin-sdk/runtime-config-snapshot", () => ({
getRuntimeConfig: gatewayRuntime.getRuntimeConfig,
}));
function createMemoryStore<T = PersistedWorkboardCard>(): WorkboardKeyedStore<T> {
const entries = new Map<string, T>();
return {
async register(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 createProgram(store: WorkboardStore): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({
writeErr: () => {},
writeOut: () => {},
});
registerWorkboardCli({ program, store });
return program;
}
async function createAmbiguousPrefix(store: WorkboardStore): Promise<string> {
const seen = new Map<string, string>();
for (let index = 0; index < 40; index += 1) {
const card = await store.create({ title: `Card ${index}` });
const prefix = card.id.slice(0, 1);
if (seen.has(prefix)) {
return prefix;
}
seen.set(prefix, card.id);
}
throw new Error("could not create cards with a shared prefix");
}
async function captureStdout(run: () => Promise<void>): Promise<string> {
const chunks: string[] = [];
const write = vi.spyOn(process.stdout, "write").mockImplementation((chunk): boolean => {
chunks.push(String(chunk));
return true;
});
try {
await run();
return chunks.join("");
} finally {
write.mockRestore();
}
}
describe("registerWorkboardCli", () => {
beforeEach(() => {
gatewayRuntime.callGatewayFromCli.mockReset();
gatewayRuntime.getRuntimeConfig.mockReset();
gatewayRuntime.getRuntimeConfig.mockReturnValue({});
delete process.env.OPENCLAW_GATEWAY_URL;
});
it("redacts claim tokens from card JSON output", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Claimed worker", status: "running" });
await store.claim(card.id, { ownerId: "worker", token: "secret-token" });
const program = createProgram(store);
const listOutput = await captureStdout(async () => {
await program.parseAsync(["workboard", "list", "--json"], { from: "user" });
});
const showOutput = await captureStdout(async () => {
await program.parseAsync(["workboard", "show", card.id, "--json"], { from: "user" });
});
expect(listOutput).not.toContain("secret-token");
expect(showOutput).not.toContain("secret-token");
expect(listOutput).toContain("[redacted]");
expect(showOutput).toContain("[redacted]");
});
it("hides archived cards from text output by default and reveals them with --include-archived", async () => {
const store = new WorkboardStore(createMemoryStore());
await store.create({ title: "Active card" });
const archived = await store.create({ title: "Archived card" });
await store.archive(archived.id, true);
const program = createProgram(store);
const defaultOutput = await captureStdout(async () => {
await program.parseAsync(["workboard", "list"], { from: "user" });
});
const includeOutput = await captureStdout(async () => {
await program.parseAsync(["workboard", "list", "--include-archived"], { from: "user" });
});
expect(defaultOutput).toContain("Active card");
expect(defaultOutput).not.toContain("Archived card");
expect(includeOutput).toContain("Active card");
expect(includeOutput).toContain("Archived card");
});
it("preserves archived cards in JSON list output by default", async () => {
const store = new WorkboardStore(createMemoryStore());
const archived = await store.create({ title: "Archived card" });
await store.archive(archived.id, true);
const program = createProgram(store);
const output = await captureStdout(async () => {
await program.parseAsync(["workboard", "list", "--json"], { from: "user" });
});
expect(output).toContain(archived.id);
expect(output).toContain("archivedAt");
});
it("does not fall back to local dispatch for explicit gateway targets", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Remote target", status: "ready" });
const program = createProgram(store);
gatewayRuntime.callGatewayFromCli.mockRejectedValueOnce(
new Error("connect ECONNREFUSED 127.0.0.1:18789"),
);
await expect(
program.parseAsync(["workboard", "dispatch", "--url", "ws://remote"], { from: "user" }),
).rejects.toThrow("ECONNREFUSED");
const after = await store.get(card.id);
expect(after?.status).toBe("ready");
expect(after?.metadata?.automation?.dispatchCount).toBeUndefined();
});
it("does not fall back to local dispatch for configured remote gateways", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Configured remote target", status: "ready" });
const program = createProgram(store);
gatewayRuntime.getRuntimeConfig.mockReturnValue({
gateway: { mode: "remote", remote: { url: "wss://gateway.example" } },
});
gatewayRuntime.callGatewayFromCli.mockRejectedValueOnce(
new Error("connect ECONNREFUSED gateway.example:443"),
);
await expect(program.parseAsync(["workboard", "dispatch"], { from: "user" })).rejects.toThrow(
"ECONNREFUSED",
);
const after = await store.get(card.id);
expect(after?.status).toBe("ready");
expect(after?.metadata?.automation?.dispatchCount).toBeUndefined();
});
it("rejects ambiguous card id prefixes", async () => {
const store = new WorkboardStore(createMemoryStore());
const prefix = await createAmbiguousPrefix(store);
const program = createProgram(store);
await expect(
program.parseAsync(["workboard", "show", prefix], { from: "user" }),
).rejects.toThrow("Ambiguous card id prefix");
});
});

View File

@@ -0,0 +1,251 @@
// Workboard plugin module implements cli behavior.
import type { Command } from "commander";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { addGatewayClientOptions, callGatewayFromCli } from "openclaw/plugin-sdk/gateway-runtime";
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveWorkboardCardByIdOrPrefix } from "./card-lookup.js";
import type { WorkboardDispatchResult, WorkboardStore } from "./store.js";
import type { WorkboardCard } from "./types.js";
type JsonOptions = {
json?: boolean;
};
type GatewayOptions = JsonOptions & {
url?: string;
token?: string;
timeout?: string;
expectFinal?: boolean;
board?: string;
};
function writeJson(value: unknown): void {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
function writeLine(value: string): void {
process.stdout.write(`${value}\n`);
}
function splitLabels(value: string | undefined): string[] | undefined {
return value
?.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}
function formatCardLine(card: WorkboardCard): string {
const boardId = card.metadata?.automation?.boardId ?? "default";
const agent = card.agentId ? ` ${card.agentId}` : "";
return `${card.id.slice(0, 8)} ${card.status.padEnd(8)} ${card.priority.padEnd(6)} ${boardId}${agent} ${card.title}`;
}
function redactClaimToken(card: WorkboardCard): WorkboardCard {
const claim = card.metadata?.claim;
if (!claim) {
return card;
}
return {
...card,
metadata: {
...card.metadata,
claim: {
...claim,
token: "[redacted]",
},
},
};
}
function redactDispatchResult(result: WorkboardDispatchResult): WorkboardDispatchResult {
return {
...result,
promoted: result.promoted.map(redactClaimToken),
reclaimed: result.reclaimed.map(redactClaimToken),
blocked: result.blocked.map(redactClaimToken),
orchestrated: result.orchestrated.map(redactClaimToken),
};
}
function writeCards(cards: WorkboardCard[], options: JsonOptions): void {
if (options.json) {
writeJson({ cards: cards.map(redactClaimToken) });
return;
}
for (const card of cards) {
writeLine(formatCardLine(card));
}
}
async function callWorkboardGateway(
method: string,
options: GatewayOptions,
params?: unknown,
): Promise<unknown> {
return await callGatewayFromCli(method, options, params, {
mode: "cli",
scopes: ["operator.write", "operator.read"],
});
}
function isGatewayUnavailableError(error: unknown): boolean {
const message = formatErrorMessage(error).toLowerCase();
return [
"econnrefused",
"econnreset",
"ehostunreach",
"enotfound",
"gateway not connected",
"gateway unavailable",
"unknown method: workboard.cards.dispatch",
].some((marker) => message.includes(marker));
}
function hasExplicitGatewayTarget(options: GatewayOptions): boolean {
return Boolean(options.url?.trim() || options.token?.trim());
}
function hasConfiguredRemoteGatewayTarget(): boolean {
if (process.env.OPENCLAW_GATEWAY_URL?.trim()) {
return true;
}
try {
return getRuntimeConfig().gateway?.mode === "remote";
} catch {
return false;
}
}
export function registerWorkboardCli(params: { program: Command; store: WorkboardStore }): void {
const workboard = params.program
.command("workboard")
.description("Manage Workboard cards and worker dispatch");
workboard
.command("list")
.description("List Workboard cards")
.option("--board <id>", "Board id")
.option("--status <status>", "Filter by status")
.option("--include-archived", "Include archived cards (default false)")
.option("--json", "Print JSON", false)
.action(
async (
options: JsonOptions & {
board?: string;
status?: string;
includeArchived?: boolean;
},
) => {
// Text output hides archived cards like /workboard list, while --json
// keeps the shipped full-card contract for existing scripts.
let cards = await params.store.list({ boardId: options.board });
if (!options.json && options.includeArchived !== true) {
cards = cards.filter((card) => !card.metadata?.archivedAt);
}
if (options.status) {
cards = cards.filter((card) => card.status === options.status);
}
writeCards(cards, options);
},
);
workboard
.command("create")
.argument("<title...>", "Card title")
.description("Create a Workboard card")
.option("--notes <text>", "Card notes")
.option("--status <status>", "Initial status", "todo")
.option("--priority <priority>", "Priority", "normal")
.option("--agent <id>", "Assigned agent id")
.option("--board <id>", "Board id")
.option("--labels <items>", "Comma-separated labels")
.option("--json", "Print JSON", false)
.action(
async (
title: string[],
options: JsonOptions & {
notes?: string;
status?: string;
priority?: string;
agent?: string;
board?: string;
labels?: string;
},
) => {
const card = await params.store.create({
title: title.join(" "),
notes: options.notes,
status: options.status,
priority: options.priority,
agentId: options.agent,
boardId: options.board,
labels: splitLabels(options.labels),
});
if (options.json) {
writeJson({ card: redactClaimToken(card) });
} else {
writeLine(formatCardLine(card));
}
},
);
workboard
.command("show")
.argument("<id>", "Card id or prefix")
.description("Show one Workboard card")
.option("--json", "Print JSON", false)
.action(async (id: string, options: JsonOptions) => {
const cards = await params.store.list();
const { card, error } = resolveWorkboardCardByIdOrPrefix(cards, id);
if (!card) {
throw new Error(error);
}
if (options.json) {
writeJson({ card: redactClaimToken(card) });
} else {
writeLine(formatCardLine(card));
if (card.notes) {
writeLine(card.notes);
}
}
});
addGatewayClientOptions(
workboard
.command("dispatch")
.description("Promote ready cards and start worker runs through the Gateway")
.option("--board <id>", "Dispatch a single board")
.option("--json", "Print JSON", false),
).action(async (options: GatewayOptions) => {
try {
const result = await callWorkboardGateway("workboard.cards.dispatch", options, {
boardId: options.board,
});
if (options.json) {
writeJson(result);
} else {
const record = isRecord(result) ? result : {};
const started = Array.isArray(record.started) ? record.started.length : 0;
const failures = Array.isArray(record.startFailures) ? record.startFailures.length : 0;
writeLine(`dispatch complete: started=${started} failures=${failures}`);
}
} catch (error) {
if (
!isGatewayUnavailableError(error) ||
hasExplicitGatewayTarget(options) ||
hasConfiguredRemoteGatewayTarget()
) {
throw error;
}
const result = redactDispatchResult(await params.store.dispatch({ boardId: options.board }));
if (options.json) {
writeJson({ ...result, gatewayUnavailable: true });
} else {
writeLine(
`gateway unavailable; data dispatch only: promoted=${result.promoted.length} blocked=${result.blocked.length}`,
);
}
}
});
}

View File

@@ -0,0 +1,115 @@
// Workboard tests cover command plugin behavior.
import { describe, expect, it, vi } from "vitest";
import { handleWorkboardCommand } from "./command.js";
import type { WorkboardSubagentRuntime } from "./dispatcher.js";
import { WorkboardStore, type PersistedWorkboardCard, type WorkboardKeyedStore } from "./store.js";
function createMemoryStore<T = PersistedWorkboardCard>(): WorkboardKeyedStore<T> {
const entries = new Map<string, T>();
return {
async register(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 createApi(run = vi.fn().mockResolvedValue({ runId: "run-1" })): {
runtime: { subagent: WorkboardSubagentRuntime };
} {
return {
runtime: {
subagent: { run },
},
};
}
async function createAmbiguousPrefix(store: WorkboardStore): Promise<string> {
const seen = new Map<string, string>();
for (let index = 0; index < 40; index += 1) {
const card = await store.create({ title: `Card ${index}` });
const prefix = card.id.slice(0, 1);
if (seen.has(prefix)) {
return prefix;
}
seen.set(prefix, card.id);
}
throw new Error("could not create cards with a shared prefix");
}
describe("handleWorkboardCommand", () => {
it("creates, lists, and dispatches workboard cards", async () => {
const store = new WorkboardStore(createMemoryStore());
const api = createApi();
await expect(
handleWorkboardCommand({
api,
store,
args: "create Ship CLI",
senderIsOwner: true,
}),
).resolves.toEqual(expect.objectContaining({ text: expect.stringContaining("Ship CLI") }));
const card = (await store.list())[0];
expect(card).toMatchObject({ title: "Ship CLI" });
await expect(handleWorkboardCommand({ api, store, args: "list" })).resolves.toEqual(
expect.objectContaining({ text: expect.stringContaining("Ship CLI") }),
);
await store.update(card.id, { status: "ready" });
await expect(
handleWorkboardCommand({
api,
store,
args: "dispatch",
gatewayClientScopes: ["operator.write"],
}),
).resolves.toEqual(expect.objectContaining({ text: expect.stringContaining("started=1") }));
expect(api.runtime.subagent.run).toHaveBeenCalledOnce();
});
it("requires write access for slash mutations", async () => {
const store = new WorkboardStore(createMemoryStore());
const api = createApi();
const card = await store.create({ title: "Ready worker", status: "ready" });
await expect(handleWorkboardCommand({ api, store, args: "list" })).resolves.toEqual(
expect.objectContaining({ text: expect.stringContaining("Ready worker") }),
);
await expect(handleWorkboardCommand({ api, store, args: "create Blocked" })).resolves.toEqual(
expect.objectContaining({
isError: true,
text: expect.stringContaining("operator.write"),
}),
);
await expect(handleWorkboardCommand({ api, store, args: "dispatch" })).resolves.toEqual(
expect.objectContaining({
isError: true,
text: expect.stringContaining("operator.write"),
}),
);
expect(api.runtime.subagent.run).not.toHaveBeenCalled();
await expect(store.get(card.id)).resolves.toMatchObject({ status: "ready" });
});
it("rejects ambiguous card id prefixes", async () => {
const store = new WorkboardStore(createMemoryStore());
const api = createApi();
const prefix = await createAmbiguousPrefix(store);
await expect(handleWorkboardCommand({ api, store, args: `show ${prefix}` })).resolves.toEqual(
expect.objectContaining({
isError: true,
text: expect.stringContaining("Ambiguous card id prefix"),
}),
);
});
});

View File

@@ -0,0 +1,162 @@
// Workboard plugin module implements command behavior.
import type { OpenClawPluginApi } from "../api.js";
import { resolveWorkboardCardByIdOrPrefix } from "./card-lookup.js";
import { dispatchAndStartWorkboardCards, type WorkboardSubagentRuntime } from "./dispatcher.js";
import type { WorkboardStore } from "./store.js";
import type { WorkboardCard } from "./types.js";
const ADMIN_SCOPE = "operator.admin";
const WRITE_SCOPE = "operator.write";
type WorkboardCommandApi = {
runtime: {
subagent: WorkboardSubagentRuntime;
};
};
function splitArgs(input: string | undefined): string[] {
return (input ?? "").trim().split(/\s+/).filter(Boolean);
}
function formatCardLine(card: WorkboardCard): string {
const boardId = card.metadata?.automation?.boardId ?? "default";
const agent = card.agentId ? ` @${card.agentId}` : "";
return `${card.id.slice(0, 8)} ${card.status.padEnd(8)} ${card.priority.padEnd(6)} [${boardId}]${agent} ${card.title}`;
}
function formatCardDetails(card: WorkboardCard): string {
const lines = [
card.title,
`id: ${card.id}`,
`status: ${card.status}`,
`priority: ${card.priority}`,
`board: ${card.metadata?.automation?.boardId ?? "default"}`,
];
if (card.agentId) {
lines.push(`agent: ${card.agentId}`);
}
if (card.sessionKey) {
lines.push(`session: ${card.sessionKey}`);
}
if (card.runId) {
lines.push(`run: ${card.runId}`);
}
if (card.notes) {
lines.push("", card.notes);
}
return lines.join("\n");
}
function normalizeTitle(tokens: string[]): string {
return tokens.join(" ").trim();
}
function canMutateWorkboard(params: {
senderIsOwner?: boolean;
gatewayClientScopes?: readonly string[];
}): boolean {
const scopes = params.gatewayClientScopes;
if (scopes) {
return scopes.includes(ADMIN_SCOPE) || scopes.includes(WRITE_SCOPE);
}
return params.senderIsOwner === true;
}
function requireWriteAccess(params: {
senderIsOwner?: boolean;
gatewayClientScopes?: readonly string[];
}): { text: string; isError: true } | undefined {
if (canMutateWorkboard(params)) {
return undefined;
}
return {
text: `This command requires gateway scope: ${WRITE_SCOPE}.`,
isError: true,
};
}
export async function handleWorkboardCommand(params: {
api: WorkboardCommandApi;
store: WorkboardStore;
args?: string;
senderIsOwner?: boolean;
gatewayClientScopes?: readonly string[];
}): Promise<{ text: string; isError?: boolean }> {
const [action = "list", ...rest] = splitArgs(params.args);
if (action === "help") {
return {
text: [
"/workboard list",
"/workboard show <card-id>",
"/workboard create <title>",
"/workboard dispatch",
].join("\n"),
};
}
if (action === "list") {
const cards = (await params.store.list()).filter((card) => !card.metadata?.archivedAt);
const rows = cards.slice(0, 12).map(formatCardLine);
return { text: rows.length ? rows.join("\n") : "No Workboard cards." };
}
if (action === "show" || action === "read") {
const id = rest[0];
if (!id) {
return { text: "Usage: /workboard show <card-id>", isError: true };
}
const cards = await params.store.list();
const { card, error } = resolveWorkboardCardByIdOrPrefix(cards, id);
return card ? { text: formatCardDetails(card) } : { text: error, isError: true };
}
if (action === "create") {
const accessError = requireWriteAccess(params);
if (accessError) {
return accessError;
}
const title = normalizeTitle(rest);
if (!title) {
return { text: "Usage: /workboard create <title>", isError: true };
}
const card = await params.store.create({ title });
return { text: `Created ${card.id.slice(0, 8)} ${card.title}` };
}
if (action === "dispatch") {
const accessError = requireWriteAccess(params);
if (accessError) {
return accessError;
}
const result = await dispatchAndStartWorkboardCards({
store: params.store,
subagent: params.api.runtime.subagent,
});
return {
text: [
`dispatch: started=${result.started.length} failures=${result.startFailures.length} promoted=${result.promoted.length} blocked=${result.blocked.length}`,
...result.started.map((run) => `started ${run.cardId.slice(0, 8)} run=${run.runId}`),
...result.startFailures.map(
(failure) => `failed ${failure.cardId.slice(0, 8)} ${failure.error}`,
),
].join("\n"),
};
}
return { text: `Unknown Workboard action: ${action}`, isError: true };
}
export function registerWorkboardCommand(params: {
api: OpenClawPluginApi;
store: WorkboardStore;
}): void {
params.api.registerCommand({
name: "workboard",
description: "List, create, inspect, and dispatch Workboard cards.",
acceptsArgs: true,
exposeSenderIsOwner: true,
handler: async (ctx) =>
await handleWorkboardCommand({
api: params.api,
store: params.store,
args: ctx.args,
senderIsOwner: ctx.senderIsOwner,
gatewayClientScopes: ctx.gatewayClientScopes,
}),
});
}

View File

@@ -0,0 +1,208 @@
// Workboard tests cover dispatcher plugin behavior.
import { describe, expect, it, vi } from "vitest";
import { dispatchAndStartWorkboardCards } from "./dispatcher.js";
import { WorkboardStore, type PersistedWorkboardCard, type WorkboardKeyedStore } from "./store.js";
function createMemoryStore<T = PersistedWorkboardCard>(): WorkboardKeyedStore<T> {
const entries = new Map<string, T>();
return {
async register(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 }] : []));
},
};
}
describe("dispatchAndStartWorkboardCards", () => {
it("claims ready cards and starts bounded subagent worker runs", async () => {
const store = new WorkboardStore(createMemoryStore());
const first = await store.create({
title: "First worker",
status: "ready",
priority: "urgent",
agentId: "codex-main",
});
const second = await store.create({
title: "Second worker",
status: "ready",
priority: "normal",
agentId: "codex-main",
});
const otherAgent = await store.create({
title: "Other worker",
status: "ready",
priority: "high",
agentId: "codex-side",
});
const run = vi
.fn()
.mockResolvedValueOnce({ runId: "run-first" })
.mockResolvedValueOnce({ runId: "run-other" });
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
options: { now: 10, maxStarts: 3 },
});
expect(result.started.map((entry) => entry.cardId).toSorted()).toEqual(
[first.id, otherAgent.id].toSorted(),
);
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[0]?.[0]).toMatchObject({
sessionKey: `agent:codex-main:subagent:workboard-default-${first.id}`,
lane: `workboard:default:${first.id}`,
deliver: false,
});
expect(run.mock.calls[0]?.[0]?.message).toContain("Claim token:");
expect(run.mock.calls[0]?.[0]?.message).toContain("workboard_complete with the card id");
expect(run.mock.calls[0]?.[0]?.message).not.toContain("ownerId and token");
await expect(store.get(first.id)).resolves.toMatchObject({
status: "running",
sessionKey: `agent:codex-main:subagent:workboard-default-${first.id}`,
runId: "run-first",
execution: { status: "running", runId: "run-first" },
metadata: {
claim: { ownerId: "codex-main" },
workerLogs: [expect.objectContaining({ message: expect.stringContaining("run-first") })],
},
});
await expect(store.get(second.id)).resolves.toMatchObject({
status: "ready",
metadata: { automation: { dispatchCount: 1 } },
});
});
it("does not let review cards consume an agent running slot", async () => {
const store = new WorkboardStore(createMemoryStore());
await store.create({
title: "Waiting for operator review",
status: "review",
priority: "normal",
agentId: "codex-main",
});
const ready = await store.create({
title: "Next ready card",
status: "ready",
priority: "high",
agentId: "codex-main",
});
const run = vi.fn().mockResolvedValue({ runId: "run-next" });
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
options: { now: 10, maxStarts: 3 },
});
expect(result.started).toEqual([
expect.objectContaining({
cardId: ready.id,
runId: "run-next",
}),
]);
expect(run).toHaveBeenCalledOnce();
});
it("starts workers only for the selected board", async () => {
const store = new WorkboardStore(createMemoryStore());
const ops = await store.create({
title: "Ops worker",
status: "ready",
priority: "urgent",
boardId: "ops",
});
const product = await store.create({
title: "Product worker",
status: "ready",
priority: "urgent",
boardId: "product",
});
const run = vi.fn().mockResolvedValue({ runId: "run-ops" });
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
options: { now: 10, maxStarts: 3, boardId: "ops" },
});
expect(result.started).toEqual([expect.objectContaining({ cardId: ops.id })]);
expect(run).toHaveBeenCalledOnce();
expect(run.mock.calls[0]?.[0]).toMatchObject({
sessionKey: `subagent:workboard-ops-${ops.id}`,
lane: `workboard:ops:${ops.id}`,
});
await expect(store.get(product.id)).resolves.toMatchObject({
status: "ready",
metadata: { automation: { boardId: "product" } },
});
});
it("keeps claimed review cards in the owner running slot", async () => {
const store = new WorkboardStore(createMemoryStore());
const review = await store.create({
title: "Claimed operator review",
status: "review",
priority: "normal",
agentId: "codex-main",
});
await store.claim(review.id, { ownerId: "codex-main", token: "review-token" });
await store.create({
title: "Next ready card",
status: "ready",
priority: "high",
agentId: "codex-main",
});
const run = vi.fn().mockResolvedValue({ runId: "run-next" });
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
options: { now: 10, maxStarts: 3 },
});
expect(result.started).toEqual([]);
expect(run).not.toHaveBeenCalled();
});
it("blocks a card when worker start fails after claim", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Fail worker", status: "ready" });
const run = vi.fn().mockRejectedValue(new Error("model unavailable"));
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
options: { now: 10, maxStarts: 1 },
});
expect(result.started).toEqual([]);
expect(result.startFailures).toEqual([
expect.objectContaining({ cardId: card.id, error: "model unavailable" }),
]);
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: `subagent:workboard-default-${card.id}`,
}),
);
await expect(store.get(card.id)).resolves.toMatchObject({
status: "blocked",
metadata: {
comments: [
expect.objectContaining({
body: expect.stringContaining("Dispatcher could not start worker"),
}),
],
},
});
expect((await store.get(card.id))?.metadata?.claim).toBeUndefined();
});
});

View File

@@ -0,0 +1,263 @@
// Workboard plugin module implements dispatcher behavior.
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import { WorkboardStore, type WorkboardDispatchResult } from "./store.js";
import type { WorkboardCard, WorkboardExecution } from "./types.js";
const DEFAULT_DISPATCH_MAX_STARTS = 3;
const DEFAULT_DISPATCH_OWNER = "workboard-dispatcher";
const DEFAULT_DISPATCH_MODEL = "default";
export type WorkboardSubagentRuntime = Pick<PluginRuntime["subagent"], "run">;
export type WorkboardDispatchStartOptions = {
maxStarts?: number;
model?: string;
provider?: string;
ownerId?: string;
boardId?: string;
now?: number;
};
export type WorkboardStartedRun = {
cardId: string;
title: string;
sessionKey: string;
runId: string;
};
export type WorkboardStartFailure = {
cardId: string;
title: string;
error: string;
};
export type WorkboardDispatchAndStartResult = WorkboardDispatchResult & {
started: WorkboardStartedRun[];
startFailures: WorkboardStartFailure[];
};
function normalizePositiveInteger(value: number | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value)
? Math.max(0, Math.trunc(value))
: fallback;
}
function cardBoardId(card: WorkboardCard): string {
return card.metadata?.automation?.boardId ?? "default";
}
function sanitizeSessionSegment(value: string | undefined, fallback: string): string {
const sanitized = (value ?? fallback)
.trim()
.replace(/[^a-zA-Z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return (sanitized || fallback).slice(0, 96);
}
function cardIsArchived(card: WorkboardCard): boolean {
return Boolean(card.metadata?.archivedAt);
}
function buildSessionKey(card: WorkboardCard): string {
const boardId = sanitizeSessionSegment(cardBoardId(card), "default");
const cardId = sanitizeSessionSegment(card.id, "card");
const suffix = `subagent:workboard-${boardId}-${cardId}`;
return card.agentId ? `agent:${sanitizeSessionSegment(card.agentId, "agent")}:${suffix}` : suffix;
}
function buildExecution(params: {
card: WorkboardCard;
sessionKey: string;
runId: string;
model: string;
now: number;
}): WorkboardExecution {
return {
id: params.card.execution?.id ?? `${params.card.id}:codex`,
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "running",
model: params.model,
sessionKey: params.sessionKey,
runId: params.runId,
startedAt: params.now,
updatedAt: params.now,
};
}
function buildWorkerPrompt(params: {
card: WorkboardCard;
context: string;
ownerId: string;
token: string;
}): string {
return [
`Work on this OpenClaw Workboard card: ${params.card.title}`,
"",
"## Worker protocol",
`Card id: ${params.card.id}`,
`Claim ownerId: ${params.ownerId}`,
`Claim token: ${params.token}`,
"",
"Heartbeat with workboard_heartbeat using the card id and token while working.",
"When done, call workboard_complete with the card id, token, summary, and proof.",
"If blocked, call workboard_block with the card id, token, and reason.",
"",
params.context,
].join("\n");
}
function sortReadyCards(a: WorkboardCard, b: WorkboardCard): number {
const priorityRank: Record<WorkboardCard["priority"], number> = {
urgent: 0,
high: 1,
normal: 2,
low: 3,
};
return (
priorityRank[a.priority] - priorityRank[b.priority] ||
a.position - b.position ||
a.createdAt - b.createdAt
);
}
function selectStartableCards(
cards: WorkboardCard[],
limit: number,
candidates: WorkboardCard[] = cards,
): WorkboardCard[] {
if (limit <= 0) {
return [];
}
const runningByOwner = new Map<string, number>();
for (const card of cards) {
const consumesOwnerSlot =
card.status === "running" ||
Boolean(card.metadata?.claim) ||
card.execution?.status === "running";
if (!consumesOwnerSlot || cardIsArchived(card)) {
continue;
}
const owner = card.agentId ?? DEFAULT_DISPATCH_OWNER;
runningByOwner.set(owner, (runningByOwner.get(owner) ?? 0) + 1);
}
const selected: WorkboardCard[] = [];
for (const card of candidates
.filter((entry) => entry.status === "ready" && !entry.metadata?.claim && !cardIsArchived(entry))
.toSorted(sortReadyCards)) {
const owner = card.agentId ?? DEFAULT_DISPATCH_OWNER;
if ((runningByOwner.get(owner) ?? 0) > 0) {
continue;
}
selected.push(card);
runningByOwner.set(owner, 1);
if (selected.length >= limit) {
break;
}
}
return selected;
}
export async function dispatchAndStartWorkboardCards(params: {
store: WorkboardStore;
subagent: WorkboardSubagentRuntime;
options?: WorkboardDispatchStartOptions;
}): Promise<WorkboardDispatchAndStartResult> {
const now = params.options?.now ?? Date.now();
const boardId = params.options?.boardId;
const dispatch = await params.store.dispatch({ now, boardId });
const maxStarts = normalizePositiveInteger(
params.options?.maxStarts,
DEFAULT_DISPATCH_MAX_STARTS,
);
const started: WorkboardStartedRun[] = [];
const startFailures: WorkboardStartFailure[] = [];
const model = params.options?.model?.trim() || DEFAULT_DISPATCH_MODEL;
const cards = await params.store.list();
const candidates = await params.store.list({ boardId });
for (const card of selectStartableCards(cards, maxStarts, candidates)) {
const ownerId = params.options?.ownerId?.trim() || card.agentId || DEFAULT_DISPATCH_OWNER;
const sessionKey = buildSessionKey(card);
let token = "";
try {
const claimed = await params.store.claim(card.id, {
ownerId,
ttlSeconds: card.metadata?.automation?.maxRuntimeSeconds,
});
token = claimed.token;
const context = await params.store.buildWorkerContext(card.id);
const run = await params.subagent.run({
sessionKey,
message: buildWorkerPrompt({
card: claimed.card,
context,
ownerId,
token,
}),
...(params.options?.provider ? { provider: params.options.provider } : {}),
...(params.options?.model ? { model: params.options.model } : {}),
lane: `workboard:${cardBoardId(card)}:${card.id}`,
idempotencyKey: `workboard:${card.id}:${claimed.card.updatedAt}`,
lightContext: true,
deliver: false,
});
const updated = await params.store.update(card.id, {
sessionKey,
runId: run.runId,
execution: buildExecution({
card: claimed.card,
sessionKey,
runId: run.runId,
model,
now,
}),
});
await params.store.addWorkerLog(
updated.id,
{
level: "info",
message: `Dispatcher started subagent run ${run.runId}.`,
sessionKey,
runId: run.runId,
},
{ ownerId, token },
);
started.push({
cardId: updated.id,
title: updated.title,
sessionKey,
runId: run.runId,
});
} catch (error) {
const message = formatErrorMessage(error);
startFailures.push({ cardId: card.id, title: card.title, error: message });
if (!token) {
continue;
}
try {
await params.store.block(
card.id,
{
ownerId,
token,
reason: `Dispatcher could not start worker: ${message}`,
},
{ ownerId, token },
);
} catch {
// Leave the original start failure visible; dispatch will diagnose stale claims later.
}
}
}
return {
...dispatch,
started,
startFailures,
count: dispatch.count + started.length + startFailures.length,
};
}

View File

@@ -0,0 +1,355 @@
// Workboard tests cover gateway plugin behavior.
import { describe, expect, it, vi } from "vitest";
import type { OpenClawPluginApi } from "../api.js";
import { registerWorkboardGatewayMethods } from "./gateway.js";
import { WorkboardStore, type PersistedWorkboardCard, type WorkboardKeyedStore } from "./store.js";
function createMemoryStore<T = PersistedWorkboardCard>(): WorkboardKeyedStore<T> {
const entries = new Map<string, T>();
return {
async register(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 }] : []));
},
};
}
describe("workboard gateway methods", () => {
it("registers CRUD methods with read/write scopes", async () => {
type RegisteredMethod = {
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
opts: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[2];
};
const methods = new Map<string, RegisteredMethod>();
const api = {
runtime: {
state: {
openKeyedStore: vi.fn(() => createMemoryStore()),
},
},
registerGatewayMethod: vi.fn(
(method: string, handler: RegisteredMethod["handler"], opts: RegisteredMethod["opts"]) => {
methods.set(method, { handler, opts });
},
),
} as unknown as OpenClawPluginApi;
registerWorkboardGatewayMethods({ api, store: new WorkboardStore(createMemoryStore()) });
expect([...methods.keys()]).toEqual([
"workboard.cards.list",
"workboard.cards.create",
"workboard.cards.update",
"workboard.cards.move",
"workboard.cards.delete",
"workboard.cards.comment",
"workboard.cards.link",
"workboard.cards.linkDependency",
"workboard.cards.proof",
"workboard.cards.artifact",
"workboard.cards.claim",
"workboard.cards.heartbeat",
"workboard.cards.release",
"workboard.cards.promote",
"workboard.cards.reassign",
"workboard.cards.reclaim",
"workboard.cards.complete",
"workboard.cards.block",
"workboard.cards.unblock",
"workboard.cards.bulk",
"workboard.cards.diagnostics",
"workboard.cards.diagnostics.refresh",
"workboard.cards.dispatch",
"workboard.boards.list",
"workboard.boards.upsert",
"workboard.boards.archive",
"workboard.boards.delete",
"workboard.cards.stats",
"workboard.cards.runs",
"workboard.cards.specify",
"workboard.cards.decompose",
"workboard.notifications.subscribe",
"workboard.notifications.list",
"workboard.notifications.delete",
"workboard.notifications.events",
"workboard.notifications.advance",
"workboard.cards.attachments.list",
"workboard.cards.attachments.get",
"workboard.cards.attachments.add",
"workboard.cards.attachments.delete",
"workboard.cards.workerLog",
"workboard.cards.protocolViolation",
"workboard.cards.archive",
"workboard.cards.export",
]);
expect(methods.get("workboard.cards.list")?.opts).toEqual({ scope: "operator.read" });
expect(methods.get("workboard.cards.diagnostics")?.opts).toEqual({ scope: "operator.read" });
expect(methods.get("workboard.cards.diagnostics.refresh")?.opts).toEqual({
scope: "operator.write",
});
expect(methods.get("workboard.cards.export")?.opts).toEqual({ scope: "operator.read" });
expect(methods.get("workboard.cards.create")?.opts).toEqual({ scope: "operator.write" });
expect(methods.get("workboard.cards.runs")?.opts).toEqual({ scope: "operator.read" });
expect(methods.get("workboard.cards.attachments.get")?.opts).toEqual({
scope: "operator.read",
});
expect(methods.get("workboard.cards.attachments.add")?.opts).toEqual({
scope: "operator.write",
});
expect(methods.get("workboard.boards.upsert")?.opts).toEqual({ scope: "operator.write" });
expect(methods.get("workboard.notifications.list")?.opts).toEqual({
scope: "operator.read",
});
expect(methods.get("workboard.notifications.events")?.opts).toEqual({
scope: "operator.read",
});
expect(methods.get("workboard.notifications.advance")?.opts).toEqual({
scope: "operator.write",
});
const createHandler = methods.get("workboard.cards.create")?.handler;
const listHandler = methods.get("workboard.cards.list")?.handler;
const createRespond = vi.fn();
await createHandler?.({
params: { title: "Investigate queue drift", priority: "urgent" },
respond: createRespond,
} as never);
expect(createRespond.mock.calls[0]?.[0]).toBe(true);
const listRespond = vi.fn();
await listHandler?.({ params: {}, respond: listRespond } as never);
expect(listRespond.mock.calls[0]?.[1]).toMatchObject({
cards: [expect.objectContaining({ title: "Investigate queue drift" })],
});
const eventsRespond = vi.fn();
await methods.get("workboard.notifications.events")?.handler({
params: { advance: true },
respond: eventsRespond,
} as never);
expect(eventsRespond.mock.calls[0]?.[0]).toBe(false);
expect(eventsRespond.mock.calls[0]?.[2]?.message).toContain("workboard.notifications.advance");
});
it("stores metadata updates through dedicated card methods", async () => {
type RegisteredMethod = {
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
opts: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[2];
};
const methods = new Map<string, RegisteredMethod>();
const api = {
runtime: {
state: {
openKeyedStore: vi.fn(() => createMemoryStore()),
},
},
registerGatewayMethod: vi.fn(
(method: string, handler: RegisteredMethod["handler"], opts: RegisteredMethod["opts"]) => {
methods.set(method, { handler, opts });
},
),
} as unknown as OpenClawPluginApi;
registerWorkboardGatewayMethods({ api, store: new WorkboardStore(createMemoryStore()) });
const createRespond = vi.fn();
await methods.get("workboard.cards.create")?.handler({
params: { title: "Carry metadata" },
respond: createRespond,
} as never);
const cardId = createRespond.mock.calls[0]?.[1]?.card.id;
const commentRespond = vi.fn();
await methods.get("workboard.cards.comment")?.handler({
params: { id: cardId, body: "Waiting on CI" },
respond: commentRespond,
} as never);
expect(commentRespond.mock.calls[0]?.[0]).toBe(true);
expect(commentRespond.mock.calls[0]?.[1]).toMatchObject({
card: {
metadata: {
comments: [expect.objectContaining({ body: "Waiting on CI" })],
},
events: expect.arrayContaining([expect.objectContaining({ kind: "comment_added" })]),
},
});
});
it("validates labels from comma-separated gateway input", async () => {
type RegisteredMethod = {
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
opts: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[2];
};
const methods = new Map<string, RegisteredMethod>();
const api = {
runtime: {
state: {
openKeyedStore: vi.fn(() => createMemoryStore()),
},
},
registerGatewayMethod: vi.fn(
(method: string, handler: RegisteredMethod["handler"], opts: RegisteredMethod["opts"]) => {
methods.set(method, { handler, opts });
},
),
} as unknown as OpenClawPluginApi;
registerWorkboardGatewayMethods({ api, store: new WorkboardStore(createMemoryStore()) });
const createHandler = methods.get("workboard.cards.create")?.handler;
const respond = vi.fn();
await createHandler?.({
params: { title: "Check labels", labels: `valid, ${"x".repeat(41)}` },
respond,
} as never);
expect(respond.mock.calls[0]?.[0]).toBe(false);
expect(respond.mock.calls[0]?.[2]).toMatchObject({
message: "labels must be 40 characters or fewer.",
});
});
it("dispatches workboard cards when gateway params are omitted", async () => {
type RegisteredMethod = {
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
opts: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[2];
};
const methods = new Map<string, RegisteredMethod>();
const run = vi.fn().mockResolvedValue({ runId: "run-card" });
const api = {
runtime: {
state: {
openKeyedStore: vi.fn(() => createMemoryStore()),
},
subagent: { run },
},
registerGatewayMethod: vi.fn(
(method: string, handler: RegisteredMethod["handler"], opts: RegisteredMethod["opts"]) => {
methods.set(method, { handler, opts });
},
),
} as unknown as OpenClawPluginApi;
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Ready worker",
status: "ready",
priority: "urgent",
});
registerWorkboardGatewayMethods({ api, store });
const respond = vi.fn();
await methods.get("workboard.cards.dispatch")?.handler({ respond } as never);
expect(respond.mock.calls[0]?.[0]).toBe(true);
expect(respond.mock.calls[0]?.[1]).toMatchObject({
started: [expect.objectContaining({ cardId: card.id, runId: "run-card" })],
});
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: `subagent:workboard-default-${card.id}`,
}),
);
});
it("claims, heartbeats, and bulk-updates cards through gateway methods", async () => {
type RegisteredMethod = {
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
opts: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[2];
};
const methods = new Map<string, RegisteredMethod>();
const api = {
runtime: {
state: {
openKeyedStore: vi.fn(() => createMemoryStore()),
},
},
registerGatewayMethod: vi.fn(
(method: string, handler: RegisteredMethod["handler"], opts: RegisteredMethod["opts"]) => {
methods.set(method, { handler, opts });
},
),
} as unknown as OpenClawPluginApi;
registerWorkboardGatewayMethods({ api, store: new WorkboardStore(createMemoryStore()) });
const createRespond = vi.fn();
await methods.get("workboard.cards.create")?.handler({
params: { title: "Claim me" },
respond: createRespond,
} as never);
const cardId = createRespond.mock.calls[0]?.[1]?.card.id;
const claimRespond = vi.fn();
await methods.get("workboard.cards.claim")?.handler({
params: { id: cardId, ownerId: "main" },
respond: claimRespond,
} as never);
expect(claimRespond.mock.calls[0]?.[1]).toMatchObject({
card: { status: "running", metadata: { claim: { ownerId: "main" } } },
token: expect.any(String),
});
const heartbeatRespond = vi.fn();
await methods.get("workboard.cards.heartbeat")?.handler({
params: { id: cardId, ownerId: "main", note: "alive" },
respond: heartbeatRespond,
} as never);
expect(heartbeatRespond.mock.calls[0]?.[1]).toMatchObject({
card: { metadata: { comments: [expect.objectContaining({ body: "alive" })] } },
});
const bulkRespond = vi.fn();
await methods.get("workboard.cards.bulk")?.handler({
params: { ids: [cardId], patch: { priority: "urgent" } },
respond: bulkRespond,
} as never);
expect(bulkRespond.mock.calls[0]?.[1]).toMatchObject({
cards: [expect.objectContaining({ priority: "urgent" })],
});
const completeRespond = vi.fn();
await methods.get("workboard.cards.complete")?.handler({
params: { id: cardId, summary: "Operator closed it." },
respond: completeRespond,
} as never);
expect(completeRespond.mock.calls[0]?.[1]).toMatchObject({
card: {
status: "done",
metadata: {
comments: expect.arrayContaining([
expect.objectContaining({ body: "Operator closed it." }),
]),
},
},
});
const blockedCreateRespond = vi.fn();
await methods.get("workboard.cards.create")?.handler({
params: { title: "Block me" },
respond: blockedCreateRespond,
} as never);
const blockedCardId = blockedCreateRespond.mock.calls[0]?.[1]?.card.id;
await methods.get("workboard.cards.claim")?.handler({
params: { id: blockedCardId, ownerId: "main" },
respond: vi.fn(),
} as never);
const blockRespond = vi.fn();
await methods.get("workboard.cards.block")?.handler({
params: { id: blockedCardId, reason: "Operator blocked it." },
respond: blockRespond,
} as never);
expect(blockRespond.mock.calls[0]?.[1]).toMatchObject({
card: { status: "blocked" },
});
});
});

View File

@@ -0,0 +1,700 @@
// Workboard plugin module implements gateway behavior.
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { OpenClawPluginApi } from "../api.js";
import { dispatchAndStartWorkboardCards } from "./dispatcher.js";
import { WorkboardStore } from "./store.js";
import { WORKBOARD_STATUSES, type WorkboardCard } from "./types.js";
const READ_SCOPE = "operator.read" as const;
const WRITE_SCOPE = "operator.write" as const;
type GatewayMethodContext = Parameters<
Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1]
>[0];
type GatewayRespond = GatewayMethodContext["respond"];
function respondError(respond: GatewayRespond, error: unknown) {
respond(false, undefined, {
code: "workboard_error",
message: formatErrorMessage(error),
});
}
function readId(params: Record<string, unknown>): string {
const value = params.id;
if (typeof value === "string" && value.trim()) {
return value.trim();
}
throw new Error("id is required.");
}
function readPatch(params: Record<string, unknown>): Record<string, unknown> {
const patch = params.patch;
if (patch && typeof patch === "object" && !Array.isArray(patch)) {
return patch as Record<string, unknown>;
}
return params;
}
function assertNoCursorAdvance(params: Record<string, unknown>) {
if (params.advance === true) {
throw new Error("notification cursor advancement requires workboard.notifications.advance.");
}
}
function redactClaimToken(card: WorkboardCard): WorkboardCard {
const claim = card.metadata?.claim;
if (!claim) {
return card;
}
return {
...card,
metadata: {
...card.metadata,
claim: { ...claim, token: "[redacted]" },
},
};
}
function redactDiagnosticsRows(result: Awaited<ReturnType<WorkboardStore["diagnostics"]>>) {
return {
...result,
diagnostics: result.diagnostics.map((row) => ({
...row,
card: redactClaimToken(row.card),
})),
};
}
export function registerWorkboardGatewayMethods(params: {
api: OpenClawPluginApi;
store?: WorkboardStore;
}) {
const { api } = params;
const store = params.store ?? WorkboardStore.openSqlite();
api.registerGatewayMethod(
"workboard.cards.list",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
cards: (await store.list({ boardId: requestParams.boardId })).map(redactClaimToken),
statuses: WORKBOARD_STATUSES,
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.create",
async ({ params: requestParams, respond }) => {
try {
respond(true, { card: redactClaimToken(await store.create(requestParams)) });
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.update",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(
await store.update(readId(requestParams), readPatch(requestParams)),
),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.move",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(
await store.move(readId(requestParams), requestParams.status, requestParams.position),
),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.delete",
async ({ params: requestParams, respond }) => {
try {
respond(true, await store.delete(readId(requestParams)));
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.comment",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(await store.addComment(readId(requestParams), requestParams)),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.link",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(await store.addLink(readId(requestParams), requestParams)),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.linkDependency",
async ({ params: requestParams, respond }) => {
try {
const parentId = requestParams.parentId;
const childId = requestParams.childId;
if (typeof parentId !== "string" || typeof childId !== "string") {
throw new Error("parentId and childId are required.");
}
respond(true, {
card: redactClaimToken(await store.linkCards(parentId, childId)),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.proof",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(await store.addProof(readId(requestParams), requestParams)),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.artifact",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(await store.addArtifact(readId(requestParams), requestParams)),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.claim",
async ({ params: requestParams, respond }) => {
try {
const claimed = await store.claim(readId(requestParams), requestParams);
respond(true, { ...claimed, card: redactClaimToken(claimed.card) });
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.heartbeat",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(await store.heartbeat(readId(requestParams), requestParams)),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.release",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(await store.releaseClaim(readId(requestParams), requestParams)),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.promote",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(await store.promote(readId(requestParams), requestParams, null)),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.reassign",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(await store.reassign(readId(requestParams), requestParams, null)),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.reclaim",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(await store.reclaim(readId(requestParams), requestParams, null)),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.complete",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(await store.complete(readId(requestParams), requestParams, null)),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.block",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(await store.block(readId(requestParams), requestParams, null)),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.unblock",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(await store.unblock(readId(requestParams))),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.bulk",
async ({ params: requestParams, respond }) => {
try {
const result = await store.bulkUpdate(requestParams);
respond(true, { cards: result.cards.map(redactClaimToken) });
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.diagnostics",
async ({ respond }) => {
try {
respond(true, redactDiagnosticsRows(await store.diagnostics()));
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.diagnostics.refresh",
async ({ respond }) => {
try {
respond(true, redactDiagnosticsRows(await store.refreshDiagnostics()));
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.dispatch",
async ({ params: requestParams, respond }) => {
try {
const boardId =
requestParams && typeof requestParams === "object" && "boardId" in requestParams
? requestParams.boardId
: undefined;
const result = await dispatchAndStartWorkboardCards({
store,
subagent: api.runtime.subagent,
options: {
boardId: typeof boardId === "string" ? boardId : undefined,
},
});
respond(true, {
...result,
promoted: result.promoted.map(redactClaimToken),
reclaimed: result.reclaimed.map(redactClaimToken),
blocked: result.blocked.map(redactClaimToken),
orchestrated: result.orchestrated.map(redactClaimToken),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.boards.list",
async ({ respond }) => {
try {
respond(true, await store.listBoards());
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
api.registerGatewayMethod(
"workboard.boards.upsert",
async ({ params: requestParams, respond }) => {
try {
respond(true, { board: await store.upsertBoard(requestParams) });
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.boards.archive",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
board: await store.archiveBoard(requestParams.id, requestParams.archived),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.boards.delete",
async ({ params: requestParams, respond }) => {
try {
respond(true, await store.deleteBoard(requestParams.id));
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.stats",
async ({ params: requestParams, respond }) => {
try {
respond(true, await store.stats({ boardId: requestParams.boardId }));
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.runs",
async ({ params: requestParams, respond }) => {
try {
const result = await store.runs(readId(requestParams));
respond(true, { ...result, card: redactClaimToken(result.card) });
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.specify",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(await store.specify(readId(requestParams), requestParams, null)),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.decompose",
async ({ params: requestParams, respond }) => {
try {
const result = await store.decompose(readId(requestParams), requestParams, null);
respond(true, {
parent: redactClaimToken(result.parent),
children: result.children.map(redactClaimToken),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.notifications.subscribe",
async ({ params: requestParams, respond }) => {
try {
respond(true, { subscription: await store.subscribeNotifications(requestParams) });
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.notifications.list",
async ({ params: requestParams, respond }) => {
try {
respond(true, await store.listNotificationSubscriptions(requestParams));
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
api.registerGatewayMethod(
"workboard.notifications.delete",
async ({ params: requestParams, respond }) => {
try {
respond(true, await store.deleteNotificationSubscription(readId(requestParams)));
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.notifications.events",
async ({ params: requestParams, respond }) => {
try {
assertNoCursorAdvance(requestParams);
respond(true, await store.notificationEvents(requestParams));
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
api.registerGatewayMethod(
"workboard.notifications.advance",
async ({ params: requestParams, respond }) => {
try {
respond(true, await store.advanceNotificationEvents(requestParams));
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.attachments.list",
async ({ params: requestParams, respond }) => {
try {
const result = await store.listAttachments(readId(requestParams));
respond(true, { ...result, card: redactClaimToken(result.card) });
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.attachments.get",
async ({ params: requestParams, respond }) => {
try {
const attachment = await store.getAttachment(readId(requestParams));
if (!attachment) {
throw new Error(`attachment not found: ${readId(requestParams)}`);
}
respond(true, attachment);
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.attachments.add",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(await store.addAttachment(readId(requestParams), requestParams)),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.attachments.delete",
async ({ params: requestParams, respond }) => {
try {
const attachmentId = requestParams.attachmentId;
if (typeof attachmentId !== "string" || !attachmentId.trim()) {
throw new Error("attachmentId is required.");
}
respond(true, {
card: redactClaimToken(
await store.deleteAttachment(readId(requestParams), attachmentId.trim()),
),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.workerLog",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(await store.addWorkerLog(readId(requestParams), requestParams)),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.protocolViolation",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(
await store.recordProtocolViolation(readId(requestParams), requestParams),
),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.archive",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(
await store.archive(readId(requestParams), requestParams.archived),
),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.export",
async ({ respond }) => {
try {
const exported = await store.exportCards();
respond(true, { ...exported, cards: exported.cards.map(redactClaimToken) });
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
}

View File

@@ -0,0 +1,35 @@
// Workboard plugin module implements persistence types behavior.
import type {
WorkboardAttachment,
WorkboardBoardMetadata,
WorkboardCard,
WorkboardNotificationSubscription,
} from "./types.js";
export type PersistedWorkboardCard = {
version: 1;
card: WorkboardCard;
};
export type PersistedWorkboardBoard = {
version: 1;
board: WorkboardBoardMetadata;
};
export type PersistedWorkboardNotificationSubscription = {
version: 1;
subscription: WorkboardNotificationSubscription;
};
export type PersistedWorkboardAttachment = {
version: 1;
attachment: WorkboardAttachment;
contentBase64: string;
};
export type WorkboardKeyedStore<T = PersistedWorkboardCard> = {
register(key: string, value: T): Promise<void>;
lookup(key: string): Promise<T | undefined>;
delete(key: string): Promise<boolean>;
entries(): Promise<Array<{ key: string; value: T }>>;
};

View File

@@ -0,0 +1,42 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { close, configureSqliteConnectionPragmas } = vi.hoisted(() => ({
close: vi.fn(),
configureSqliteConnectionPragmas: vi.fn(),
}));
vi.mock("node:sqlite", () => ({
DatabaseSync: vi.fn(function DatabaseSync() {
return { close };
}),
}));
vi.mock("openclaw/plugin-sdk/plugin-state-runtime", () => ({
configureSqliteConnectionPragmas,
}));
import { createWorkboardSqliteStores } from "./sqlite-store.js";
describe("Workboard SQLite policy", () => {
beforeEach(() => {
close.mockClear();
configureSqliteConnectionPragmas.mockReset();
});
it("closes a newly opened database when filesystem policy refuses it", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-policy-"));
const dbPath = path.join(dir, "workboard.sqlite");
configureSqliteConnectionPragmas.mockImplementation(() => {
throw new Error("SSHFS is unsupported");
});
try {
expect(() => createWorkboardSqliteStores({ dbPath })).toThrow(/SSHFS/);
expect(close).toHaveBeenCalledTimes(1);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,465 @@
// Workboard tests cover tools plugin behavior.
import { describe, expect, it, vi } from "vitest";
import type { OpenClawPluginApi } from "../api.js";
import { WorkboardStore, type PersistedWorkboardCard, type WorkboardKeyedStore } from "./store.js";
import { createWorkboardTools } from "./tools.js";
function createMemoryStore<T = PersistedWorkboardCard>(): WorkboardKeyedStore<T> {
const entries = new Map<string, T>();
return {
async register(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 readPayload(result: unknown): Record<string, unknown> {
return (result as { details?: Record<string, unknown> }).details ?? {};
}
describe("workboard tools", () => {
it("lists, claims, heartbeats, and reads worker context", async () => {
const keyed = createMemoryStore();
const api = {
runtime: {
state: {
openKeyedStore: vi.fn(() => keyed),
},
},
} as unknown as OpenClawPluginApi;
const workboardStore = new WorkboardStore(keyed);
const tools = createWorkboardTools({
api,
store: workboardStore,
context: { agentId: "main", sessionKey: "session-1" } as never,
});
const byName = new Map(tools.map((tool) => [tool.name, tool]));
const store = keyed;
await store.register("card-1", {
version: 1,
card: {
id: "card-1",
title: "Ship coordination",
status: "todo",
priority: "normal",
labels: [],
agentId: "main",
position: 1000,
createdAt: 1,
updatedAt: 1,
},
});
await store.register("archived-1", {
version: 1,
card: {
id: "archived-1",
title: "Closed work",
status: "done",
priority: "normal",
labels: [],
position: 2000,
createdAt: 1,
updatedAt: 1,
metadata: { archivedAt: 2 },
},
});
const claimed = readPayload(
await byName.get("workboard_claim")?.execute("call-1", { id: "card-1" }),
);
expect(claimed.card).toMatchObject({
status: "running",
metadata: { claim: { ownerId: "main", token: "[redacted]" } },
});
const token = (claimed.token as string | undefined) ?? "";
const heartbeat = readPayload(
await byName
.get("workboard_heartbeat")
?.execute("call-2", { id: "card-1", token, note: "alive" }),
);
expect(heartbeat).toMatchObject({
metadata: { comments: [expect.objectContaining({ body: "alive" })] },
});
const read = readPayload(
await byName.get("workboard_read")?.execute("call-3", { id: "card-1" }),
);
expect(read.workerContext).toContain("Ship coordination");
expect(read.card).toMatchObject({ metadata: { claim: { token: "[redacted]" } } });
const released = readPayload(
await byName
.get("workboard_release")
?.execute("call-4", { id: "card-1", token, status: "review" }),
);
expect(released).toMatchObject({ status: "review" });
expect((released.metadata as { claim?: unknown } | undefined)?.claim).toBeUndefined();
const list = readPayload(await byName.get("workboard_list")?.execute("call-5", {}));
expect(list.cards).toEqual([expect.objectContaining({ id: "card-1" })]);
const archivedList = readPayload(
await byName.get("workboard_list")?.execute("call-6", { includeArchived: true }),
);
expect(archivedList.cards).toEqual(
expect.arrayContaining([expect.objectContaining({ id: "archived-1", archivedAt: 2 })]),
);
});
it("can share one store across tool instances for claim coordination", async () => {
const keyed = createMemoryStore();
const api = {
runtime: {
state: {
openKeyedStore: vi.fn(() => keyed),
},
},
} as unknown as OpenClawPluginApi;
const store = new WorkboardStore(keyed);
const mainTools = new Map(
createWorkboardTools({
api,
store,
context: { agentId: "main" } as never,
}).map((tool) => [tool.name, tool]),
);
const otherTools = new Map(
createWorkboardTools({
api,
store,
context: { agentId: "other" } as never,
}).map((tool) => [tool.name, tool]),
);
const card = await store.create({ title: "Single owner" });
await mainTools.get("workboard_claim")?.execute("call-1", { id: card.id });
await expect(
otherTools.get("workboard_claim")?.execute("call-2", { id: card.id }),
).rejects.toThrow(/already claimed/);
});
it("requires claim scope before creating or linking dependencies against claimed cards", async () => {
const keyed = createMemoryStore();
const api = {
runtime: {
state: {
openKeyedStore: vi.fn(() => keyed),
},
},
} as unknown as OpenClawPluginApi;
const store = new WorkboardStore(keyed);
const mainTools = new Map(
createWorkboardTools({
api,
store,
context: { agentId: "main" } as never,
}).map((tool) => [tool.name, tool]),
);
const otherTools = new Map(
createWorkboardTools({
api,
store,
context: { agentId: "other" } as never,
}).map((tool) => [tool.name, tool]),
);
const parent = await store.create({ title: "Claimed parent" });
const claimed = await store.claim(parent.id, { ownerId: "main", token: "parent-token" });
await expect(
otherTools.get("workboard_create")?.execute("call-1", {
title: "Blocked child",
parents: [parent.id],
}),
).rejects.toThrow(/claimed by main/);
await expect(
otherTools.get("workboard_create")?.execute("call-1b", {
title: "Blocked child",
parents: parent.id,
}),
).rejects.toThrow(/claimed by main/);
expect(await store.list()).toHaveLength(1);
await otherTools.get("workboard_create")?.execute("call-2", {
title: "Scoped child",
parents: [parent.id],
token: claimed.token,
});
const child = await store.create({ title: "Claimed child" });
await store.claim(child.id, { ownerId: "main", token: "child-token" });
await expect(
otherTools.get("workboard_link")?.execute("call-3", {
parentId: parent.id,
childId: child.id,
}),
).rejects.toThrow(/claimed by main/);
const linked = readPayload(
await otherTools.get("workboard_link")?.execute("call-4", {
parentId: parent.id,
childId: (await store.create({ title: "Idle child" })).id,
token: claimed.token,
}),
);
expect(linked.card).toMatchObject({ status: "todo" });
await expect(
mainTools.get("workboard_link")?.execute("call-5", {
parentId: parent.id,
childId: child.id,
token: "child-token",
}),
).rejects.toThrow(/active child/);
});
it("creates dependent cards and completes claimed work through tools", async () => {
const keyed = createMemoryStore();
const api = {
runtime: {
state: {
openKeyedStore: vi.fn(() => keyed),
},
},
} as unknown as OpenClawPluginApi;
const store = new WorkboardStore(keyed);
const tools = new Map(
createWorkboardTools({
api,
store,
context: { agentId: "main" } as never,
}).map((tool) => [tool.name, tool]),
);
const parentPayload = readPayload(
await tools.get("workboard_create")?.execute("call-1", {
title: "Parent",
status: "running",
}),
);
const parent = parentPayload.card as { id: string };
const childPayload = readPayload(
await tools.get("workboard_create")?.execute("call-2", {
title: "Child",
parents: [parent.id],
tenant: "qa",
skills: ["testing"],
}),
);
const child = childPayload.card as { id: string; status: string };
expect(child.status).toBe("todo");
await expect(
tools.get("workboard_complete")?.execute("call-unclaimed-complete", {
id: parent.id,
summary: "Too early.",
}),
).rejects.toThrow(/claimed/);
await expect(
tools.get("workboard_block")?.execute("call-unclaimed-block", {
id: child.id,
reason: "Too early.",
}),
).rejects.toThrow(/claimed/);
await expect(
tools.get("workboard_protocol_violation")?.execute("call-unclaimed-violation", {
id: child.id,
detail: "Too early.",
}),
).rejects.toThrow(/claimed/);
const claimed = readPayload(
await tools.get("workboard_claim")?.execute("call-3", { id: parent.id }),
);
const token = claimed.token as string;
const completed = readPayload(
await tools.get("workboard_complete")?.execute("call-4", {
id: parent.id,
token,
summary: "Done.",
createdCardIds: [child.id],
proof: { status: "passed", command: "pnpm test extensions/workboard" },
}),
);
expect(completed.card).toMatchObject({ status: "done" });
const dispatch = readPayload(await tools.get("workboard_dispatch")?.execute("call-5", {}));
expect(dispatch.promoted).toEqual([expect.objectContaining({ id: child.id, status: "ready" })]);
});
it("redacts claim tokens from dispatch tool results", async () => {
const keyed = createMemoryStore();
const api = {
runtime: {
state: {
openKeyedStore: vi.fn(() => keyed),
},
},
} as unknown as OpenClawPluginApi;
const store = new WorkboardStore(keyed);
const tools = new Map(
createWorkboardTools({
api,
store,
context: { agentId: "main" } as never,
}).map((tool) => [tool.name, tool]),
);
const card = await store.create({
title: "Scheduled",
status: "scheduled",
scheduledAt: 1,
});
await store.update(card.id, {
metadata: {
...card.metadata,
claim: {
ownerId: "main",
token: "secret-token",
claimedAt: 1,
lastHeartbeatAt: 1,
expiresAt: Date.now() + 60_000,
},
},
});
const dispatch = readPayload(await tools.get("workboard_dispatch")?.execute("call-1", {}));
const promoted = dispatch.promoted as Array<{
metadata?: { claim?: { token?: string } };
}>;
expect(promoted).toEqual([expect.objectContaining({ id: card.id })]);
expect(promoted[0]?.metadata?.claim?.token).toBe("[redacted]");
});
it("exposes board lifecycle, decomposition, runs, and notification tools", async () => {
const keyed = createMemoryStore();
const api = {
runtime: {
state: {
openKeyedStore: vi.fn(() => keyed),
},
},
} as unknown as OpenClawPluginApi;
const store = new WorkboardStore(keyed);
const tools = new Map(
createWorkboardTools({
api,
store,
context: { agentId: "main" } as never,
}).map((tool) => [tool.name, tool]),
);
const boardPayload = readPayload(
await tools.get("workboard_board_create")?.execute("call-board", {
id: "planning",
name: "Planning",
orchestration: {
autoDecompose: true,
autoDecomposePerDispatch: 2,
orchestratorProfile: "planner",
},
}),
);
expect(boardPayload.board).toMatchObject({
id: "planning",
name: "Planning",
orchestration: {
autoDecompose: true,
autoDecomposePerDispatch: 2,
orchestratorProfile: "planner",
},
});
const parent = await store.create({
title: "Rough",
status: "triage",
boardId: "planning",
idempotencyKey: "planning:rough",
});
const specified = readPayload(
await tools.get("workboard_specify")?.execute("call-specify", {
id: parent.id,
title: "Specified",
summary: "Ready to split.",
}),
);
expect(specified.card).toMatchObject({ title: "Specified", status: "todo" });
const decomposed = readPayload(
await tools.get("workboard_decompose")?.execute("call-decompose", {
id: parent.id,
summary: "Split.",
children: [{ title: "Child A" }, { title: "Child B" }],
}),
);
expect(decomposed.parent).toMatchObject({ status: "done" });
expect(decomposed.children).toEqual([
expect.objectContaining({ title: "Child A" }),
expect.objectContaining({ title: "Child B" }),
]);
const runs = readPayload(
await tools.get("workboard_runs")?.execute("call-runs", { id: parent.id }),
);
expect(runs.attempts).toEqual([]);
const subscription = readPayload(
await tools.get("workboard_notify_subscribe")?.execute("call-subscribe", {
boardId: "planning",
cardId: parent.id,
target: "session:operator",
eventKinds: ["completed"],
}),
);
expect(subscription.subscription).toMatchObject({
boardId: "planning",
cardId: parent.id,
target: "session:operator",
eventKinds: ["completed"],
});
const list = readPayload(
await tools.get("workboard_notify_list")?.execute("call-notify-list", {
boardId: "planning",
}),
);
expect(list.subscriptions).toEqual([
expect.objectContaining({ cardId: parent.id, target: "session:operator" }),
]);
const events = readPayload(
await tools.get("workboard_notify_advance")?.execute("call-notify-events", {
subscriptionId: (subscription.subscription as { id: string }).id,
}),
);
expect(events.events).toEqual([expect.objectContaining({ kind: "completed" })]);
const attached = readPayload(
await tools.get("workboard_attachment_add")?.execute("call-attach", {
id: parent.id,
fileName: "result.txt",
contentBase64: Buffer.from("done").toString("base64"),
}),
);
expect(attached.card).toMatchObject({
metadata: { attachments: [expect.objectContaining({ fileName: "result.txt" })] },
});
const attachmentId = (attached.card as { metadata: { attachments: Array<{ id: string }> } })
.metadata.attachments[0].id;
const attachment = readPayload(
await tools.get("workboard_attachment_read")?.execute("call-attachment-read", {
id: attachmentId,
}),
);
expect(Buffer.from(attachment.contentBase64 as string, "base64").toString("utf8")).toBe("done");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,330 @@
// Workboard type declarations define plugin contracts.
export const WORKBOARD_STATUSES = [
"triage",
"backlog",
"todo",
"scheduled",
"ready",
"running",
"review",
"blocked",
"done",
] as const;
export const WORKBOARD_PRIORITIES = ["low", "normal", "high", "urgent"] as const;
export const WORKBOARD_EXECUTION_ENGINES = ["codex", "claude"] as const;
export const WORKBOARD_EXECUTION_MODES = ["autonomous", "manual"] as const;
export const WORKBOARD_EXECUTION_STATUSES = [
"idle",
"running",
"review",
"blocked",
"done",
] as const;
export const WORKBOARD_EVENT_KINDS = [
"created",
"edited",
"moved",
"linked",
"specified",
"decomposed",
"claimed",
"heartbeat",
"execution_updated",
"attempt_started",
"attempt_updated",
"comment_added",
"link_added",
"proof_added",
"artifact_added",
"attachment_added",
"diagnostic",
"notification",
"dispatch",
"orchestration",
"protocol_violation",
"archived",
"unarchived",
"stale",
] as const;
export const WORKBOARD_ATTEMPT_STATUSES = [
"running",
"succeeded",
"failed",
"blocked",
"stopped",
] as const;
export const WORKBOARD_LINK_TYPES = [
"parent",
"child",
"blocks",
"blocked_by",
"relates_to",
] as const;
export const WORKBOARD_PROOF_STATUSES = ["passed", "failed", "skipped", "unknown"] as const;
export const WORKBOARD_TEMPLATE_IDS = ["bugfix", "docs", "release", "pr_review", "plugin"] as const;
export const WORKBOARD_DIAGNOSTIC_KINDS = [
"stranded_ready",
"running_without_heartbeat",
"blocked_too_long",
"repeated_failures",
"missing_proof",
"orphaned_session",
] as const;
export const WORKBOARD_DIAGNOSTIC_SEVERITIES = ["warning", "error", "critical"] as const;
export const WORKBOARD_NOTIFICATION_KINDS = ["completed", "failed", "stale"] as const;
export type WorkboardStatus = (typeof WORKBOARD_STATUSES)[number];
export type WorkboardPriority = (typeof WORKBOARD_PRIORITIES)[number];
export type WorkboardExecutionEngine = (typeof WORKBOARD_EXECUTION_ENGINES)[number];
export type WorkboardExecutionMode = (typeof WORKBOARD_EXECUTION_MODES)[number];
export type WorkboardExecutionStatus = (typeof WORKBOARD_EXECUTION_STATUSES)[number];
export type WorkboardEventKind = (typeof WORKBOARD_EVENT_KINDS)[number];
export type WorkboardAttemptStatus = (typeof WORKBOARD_ATTEMPT_STATUSES)[number];
export type WorkboardLinkType = (typeof WORKBOARD_LINK_TYPES)[number];
export type WorkboardProofStatus = (typeof WORKBOARD_PROOF_STATUSES)[number];
export type WorkboardTemplateId = (typeof WORKBOARD_TEMPLATE_IDS)[number];
export type WorkboardDiagnosticKind = (typeof WORKBOARD_DIAGNOSTIC_KINDS)[number];
export type WorkboardDiagnosticSeverity = (typeof WORKBOARD_DIAGNOSTIC_SEVERITIES)[number];
export type WorkboardNotificationKind = (typeof WORKBOARD_NOTIFICATION_KINDS)[number];
export type WorkboardExecution = {
id: string;
kind: "agent-session";
engine: WorkboardExecutionEngine;
mode: WorkboardExecutionMode;
status: WorkboardExecutionStatus;
model: string;
sessionKey?: string;
runId?: string;
startedAt: number;
updatedAt: number;
};
export type WorkboardEvent = {
id: string;
kind: WorkboardEventKind;
at: number;
fromStatus?: WorkboardStatus;
toStatus?: WorkboardStatus;
sessionKey?: string;
runId?: string;
};
export type WorkboardRunAttempt = {
id: string;
status: WorkboardAttemptStatus;
startedAt: number;
endedAt?: number;
engine?: WorkboardExecutionEngine;
mode?: WorkboardExecutionMode;
model?: string;
sessionKey?: string;
runId?: string;
error?: string;
};
export type WorkboardComment = {
id: string;
body: string;
createdAt: number;
updatedAt?: number;
};
export type WorkboardLink = {
id: string;
type: WorkboardLinkType;
createdAt: number;
targetCardId?: string;
title?: string;
url?: string;
};
export type WorkboardProof = {
id: string;
status: WorkboardProofStatus;
createdAt: number;
label?: string;
command?: string;
url?: string;
note?: string;
};
export type WorkboardArtifact = {
id: string;
createdAt: number;
label?: string;
url?: string;
path?: string;
mimeType?: string;
};
export type WorkboardAttachment = {
id: string;
cardId: string;
createdAt: number;
fileName: string;
byteSize: number;
mimeType?: string;
note?: string;
};
export type WorkboardWorkerLog = {
id: string;
createdAt: number;
level: "info" | "warning" | "error";
message: string;
sessionKey?: string;
runId?: string;
};
export type WorkboardWorkerProtocol = {
state: "idle" | "running" | "completed" | "blocked" | "violated";
updatedAt: number;
detail?: string;
};
export type WorkboardStaleState = {
detectedAt: number;
lastSessionUpdatedAt?: number;
reason: string;
};
export type WorkboardClaim = {
ownerId: string;
token: string;
claimedAt: number;
lastHeartbeatAt: number;
expiresAt?: number;
};
export type WorkboardDiagnosticAction = {
kind: "claim" | "unblock" | "promote" | "reclaim" | "reassign" | "add_proof" | "open_session";
label: string;
};
export type WorkboardDiagnostic = {
kind: WorkboardDiagnosticKind;
severity: WorkboardDiagnosticSeverity;
title: string;
detail: string;
firstSeenAt: number;
lastSeenAt: number;
count: number;
actions: WorkboardDiagnosticAction[];
};
export type WorkboardNotification = {
id: string;
kind: WorkboardNotificationKind;
createdAt: number;
sequence?: number;
message: string;
sessionKey?: string;
runId?: string;
};
export type WorkboardWorkspace = {
kind: "scratch" | "dir" | "worktree";
path?: string;
branch?: string;
};
export type WorkboardAutomation = {
tenant?: string;
boardId?: string;
createdByCardId?: string;
idempotencyKey?: string;
skills?: string[];
workspace?: WorkboardWorkspace;
maxRuntimeSeconds?: number;
maxRetries?: number;
scheduledAt?: number;
summary?: string;
createdCardIds?: string[];
dispatchCount?: number;
lastDispatchAt?: number;
};
export type WorkboardBoardMetadata = {
id: string;
name?: string;
description?: string;
icon?: string;
color?: string;
defaultWorkspace?: WorkboardWorkspace;
orchestration?: WorkboardOrchestrationSettings;
createdAt: number;
updatedAt: number;
archivedAt?: number;
};
export type WorkboardOrchestrationSettings = {
autoDecompose?: boolean;
autoDecomposePerDispatch?: number;
defaultAssignee?: string;
orchestratorProfile?: string;
};
export type WorkboardNotificationSubscription = {
id: string;
boardId: string;
cardId?: string;
sessionKey?: string;
runId?: string;
target?: string;
eventKinds?: WorkboardNotificationKind[];
lastEventAt?: number;
lastEventId?: string;
lastEventSequence?: number;
deliveredEventIds?: string[];
createdAt: number;
updatedAt: number;
};
export type WorkboardMetadata = {
attempts?: WorkboardRunAttempt[];
comments?: WorkboardComment[];
links?: WorkboardLink[];
proof?: WorkboardProof[];
artifacts?: WorkboardArtifact[];
attachments?: WorkboardAttachment[];
workerLogs?: WorkboardWorkerLog[];
workerProtocol?: WorkboardWorkerProtocol;
automation?: WorkboardAutomation;
claim?: WorkboardClaim;
diagnostics?: WorkboardDiagnostic[];
notifications?: WorkboardNotification[];
templateId?: WorkboardTemplateId;
archivedAt?: number;
stale?: WorkboardStaleState;
lifecycleStatusSourceUpdatedAt?: number;
failureCount?: number;
};
export type WorkboardCard = {
id: string;
title: string;
notes?: string;
status: WorkboardStatus;
priority: WorkboardPriority;
labels: string[];
agentId?: string;
sessionKey?: string;
runId?: string;
taskId?: string;
sourceUrl?: string;
execution?: WorkboardExecution;
position: number;
createdAt: number;
updatedAt: number;
startedAt?: number;
completedAt?: number;
events?: WorkboardEvent[];
metadata?: WorkboardMetadata;
};
export type WorkboardListResult = {
cards: WorkboardCard[];
statuses: readonly WorkboardStatus[];
};