feat(workboard): add worker dispatch CLI

* feat(workboard): add worker dispatch CLI

* fix(workboard): avoid new unsafe assertions

* fix(workboard): keep remote dispatch failures remote
This commit is contained in:
Peter Steinberger
2026-05-31 10:31:56 +01:00
committed by GitHub
parent 1d55caa162
commit ed46e62bcc
14 changed files with 1404 additions and 12 deletions

View File

@@ -35,7 +35,7 @@ Use the setup commands by intent:
| Pairing and channels | [`pairing`](/cli/pairing) · [`qr`](/cli/qr) · [`channels`](/cli/channels) |
| Security and plugins | [`security`](/cli/security) · [`secrets`](/cli/secrets) · [`skills`](/cli/skills) · [`plugins`](/cli/plugins) · [`proxy`](/cli/proxy) |
| Legacy aliases | [`daemon`](/cli/daemon) (gateway service) · [`clawbot`](/cli/clawbot) (namespace) |
| Plugins (optional) | [`path`](/cli/path) · [`policy`](/cli/policy) · [`voicecall`](/cli/voicecall) (if installed) |
| Plugins (optional) | [`path`](/cli/path) · [`policy`](/cli/policy) · [`voicecall`](/cli/voicecall) · [`workboard`](/cli/workboard) (if installed) |
## Global flags
@@ -124,6 +124,11 @@ openclaw [--dev] [--profile <name>] <command>
disable
doctor
marketplace list
workboard
list
create
show
dispatch
memory
status
index
@@ -368,7 +373,8 @@ openclaw [--dev] [--profile <name>] <command>
terminal (alias: tui --local)
```
Plugins can add additional top-level commands (for example `openclaw voicecall`).
Plugins can add additional top-level commands, such as
[`openclaw workboard`](/cli/workboard) or `openclaw voicecall`.
</Accordion>

221
docs/cli/workboard.md Normal file
View File

@@ -0,0 +1,221 @@
---
summary: "CLI reference for `openclaw workboard` cards, dispatch, and worker runs"
read_when:
- You want to inspect or create Workboard cards from the terminal
- You want to dispatch Workboard worker runs from the CLI
- You are debugging Workboard CLI or slash command behavior
title: "Workboard CLI"
---
`openclaw workboard` is the terminal surface for the bundled
[Workboard plugin](/plugins/workboard). It lets an operator list cards, create a
card, inspect one card, and ask the running Gateway to dispatch ready work into
subagent worker runs.
Enable the plugin before using the command:
```bash
openclaw plugins enable workboard
openclaw gateway restart
```
## Usage
```bash
openclaw workboard list [--board <id>] [--status <status>] [--json]
openclaw workboard create <title...> [--notes <text>] [--status <status>] [--priority <priority>] [--agent <id>] [--board <id>] [--labels <items>] [--json]
openclaw workboard show <id> [--json]
openclaw workboard dispatch [--url <url>] [--token <token>] [--timeout <ms>] [--json]
```
The command reads and writes the same plugin-owned SQLite database used by the
dashboard and Workboard agent tools. Card ids can be passed by full id or by an
unambiguous prefix when a command accepts a card id.
## `list`
```bash
openclaw workboard list
openclaw workboard list --board default --status ready
openclaw workboard list --json
```
Text output is compact:
```text
7f4a2c10 ready high default agent-a Fix stale worker heartbeat
```
Columns are id prefix, status, priority, board id, optional agent id, and title.
Flags:
| Flag | Purpose |
| ------------------- | ---------------------------------------- |
| `--board <id>` | Limit results to one board namespace |
| `--status <status>` | Limit results to one Workboard status |
| `--json` | Print the full card list as machine JSON |
## `create`
```bash
openclaw workboard create "Fix stale worker heartbeat" --priority high --labels bug,workboard
openclaw workboard create "Write Workboard docs" --status ready --agent docs-agent --board docs --notes "Cover CLI, slash command, dispatch, and SQLite state."
```
Flags:
| Flag | Purpose |
| ----------------------- | --------------------------------------- |
| `--notes <text>` | Initial card notes |
| `--status <status>` | Initial status, default `todo` |
| `--priority <priority>` | Priority, default `normal` |
| `--agent <id>` | Assign the card to an agent or owner id |
| `--board <id>` | Store the card on a board namespace |
| `--labels <items>` | Comma-separated labels |
| `--json` | Print the created card as machine JSON |
`create` writes directly to Workboard SQLite state. The card is immediately
visible in the Control UI Workboard tab and to Workboard tools.
## `show`
```bash
openclaw workboard show 7f4a2c10
openclaw workboard show 7f4a2c10 --json
```
Text output prints the compact card line and notes. JSON output returns the full
card record, including execution metadata, attempts, comments, links, proof,
artifacts, worker logs, protocol state, diagnostics, and automation metadata.
## `dispatch`
```bash
openclaw workboard dispatch
openclaw workboard dispatch --json
openclaw workboard dispatch --url http://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
```
`dispatch` first calls the running Gateway RPC method
`workboard.cards.dispatch`. That path uses the same subagent runtime as the
dashboard dispatch action, so ready cards can become real worker sessions.
The dispatch loop:
1. Promotes dependency-ready children to `ready`.
2. Blocks expired claims or timed-out worker runs.
3. Records dispatch metadata on ready cards.
4. Selects a small batch of unclaimed ready cards.
5. Claims each selected card for the dispatcher or assigned agent.
6. Starts a subagent worker run with bounded card context and the card claim
token.
7. Stores the worker run id, session key, execution status, and worker log on
the card.
Selection is intentionally conservative. One dispatch starts at most three
workers by default, skips archived or already-claimed cards, and starts only one
card per owner or agent in a single pass. Cards already owned by active running
or review work are left for a later dispatch.
If worker start fails after a card is claimed, Workboard blocks that card,
clears the claim, and records the failure in card execution and worker-log
metadata. This keeps failed starts visible instead of silently returning the
card to the queue.
If no explicit Gateway target is provided and the local Gateway is unavailable
or does not expose the Workboard dispatch method yet, the CLI falls back to
data-only dispatch against local Workboard state. Data-only dispatch can still
promote dependencies, clean stale claims, and block timed-out runs, but it does
not start workers. Auth, permission, validation failures, and failures for an
explicit `--url` or `--token` target are reported directly.
Text output reports worker starts:
```text
dispatch complete: started=2 failures=0
```
Fallback output is explicit:
```text
gateway unavailable; data dispatch only: promoted=1 blocked=0
```
JSON output includes the dispatch result. Gateway-backed dispatch can include
`started` and `startFailures`; data-only fallback includes
`gatewayUnavailable: true`. Claim tokens are redacted from card JSON output.
## Slash Command Parity
Command-capable channels can use the matching slash command:
```text
/workboard list
/workboard show 7f4a2c10
/workboard create Fix stale worker heartbeat
/workboard dispatch
```
Slash command dispatch also uses the Gateway subagent runtime, so it follows the
same claim, worker-start, and failure behavior as the dashboard and CLI Gateway
path.
`/workboard list` and `/workboard show` are read commands for authorized command
senders. `/workboard create` and `/workboard dispatch` mutate board state and
require owner status on chat surfaces or a Gateway client with `operator.write`
or `operator.admin`.
## Permissions
The CLI dispatch path calls Gateway RPC with `operator.read` and
`operator.write` scopes. A read-only Gateway token can inspect Workboard data
through read methods, but it cannot create cards or dispatch workers.
Local `list`, `create`, and `show` commands operate on the local OpenClaw state
directory used by the current profile. Use `--dev` or `--profile <name>` on the
top-level `openclaw` command when you need a different state root.
## Troubleshooting
### No Cards Appear
Confirm the plugin is enabled for the same profile and state root:
```bash
openclaw plugins inspect workboard --runtime --json
```
If the dashboard shows cards but the CLI does not, check that both commands use
the same `--dev` or `--profile` setting.
### Dispatch Says Data-Only
Start or restart the Gateway:
```bash
openclaw gateway restart
openclaw gateway status --deep
```
Then retry `openclaw workboard dispatch`. Data-only fallback is useful for local
state cleanup, but worker runs need a live Gateway.
### Dispatch Starts Nothing
Check for at least one `ready` card without an active claim:
```bash
openclaw workboard list --status ready
```
Cards can also be skipped when the same owner already has running or review
work. Move completed work to `done`, release stale claims through the Workboard
tools, or run dispatch again after the active worker finishes.
## Related
- [Workboard plugin](/plugins/workboard)
- [CLI reference](/cli)
- [Slash commands](/tools/slash-commands)
- [Control UI](/web/control-ui)

View File

@@ -1701,7 +1701,13 @@
},
{
"group": "Plugins and skills",
"pages": ["cli/plugins", "cli/path", "cli/policy", "cli/skills"]
"pages": [
"cli/plugins",
"cli/path",
"cli/policy",
"cli/skills",
"cli/workboard"
]
},
{
"group": "Interfaces",

View File

@@ -160,17 +160,92 @@ blocked cards that need attention, repeated failures, done cards without proof,
and running cards that only have a loose session link.
Dispatch is intentionally Gateway-local. It does not spawn arbitrary operating
system processes; normal OpenClaw sessions still own execution. A dispatch nudge
promotes dependency-ready cards, records dispatch metadata on ready cards,
blocks expired claims or timed-out runs, marks board-configured triage cards as
orchestration candidates, and leaves durable notification subscriptions for the
caller that delivers notifications.
system processes; normal OpenClaw subagent sessions still own execution. A
dispatch nudge promotes dependency-ready cards, records dispatch metadata on
ready cards, blocks expired claims or timed-out runs, marks board-configured
triage cards as orchestration candidates, then claims a small batch of ready
cards and starts worker runs through the Gateway subagent runtime. Workers get
bounded card context plus the claim token they need to heartbeat, complete, or
block the card through the Workboard tools.
### Dispatch worker selection
Each dispatch pass starts at most three workers by default. Ready cards are
ordered by priority, position, and creation time, then filtered to avoid
duplicate active ownership. A dispatch starts only one card for a given owner or
agent in the same pass, and it skips owners that already have running or review
work on the board.
Archived cards, cards with active claims, and cards without `ready` status are
not selected for worker starts. They can still be affected by the data side of
dispatch when stale claims, dependency promotion, or timeout cleanup applies.
### Worker prompt and lifecycle
The worker prompt includes the card title, bounded notes and context, the
assigned board, and the Workboard worker protocol. It also includes the claim
owner and claim token so the worker can call `workboard_heartbeat`,
`workboard_complete`, or `workboard_block` without another actor taking over the
card.
When a worker starts successfully, Workboard stores the session key, run id,
engine, mode, model label, status, and worker log on the card. The session key
is deterministic for the board and card, which makes repeated dispatches route
back to the same worker lane instead of creating unrelated sessions.
If a worker cannot be started after a card is claimed, Workboard blocks the
card, clears the claim, records the run-start failure, and appends a worker log
line. That failure is visible in the dashboard, CLI JSON, agent tools, and card
diagnostics.
### Dispatch entry points
Ready-card worker starts can happen from:
- the dashboard dispatch action
- `openclaw workboard dispatch`
- `/workboard dispatch` on a command-capable channel
All three entry points use the Gateway subagent runtime when the Gateway is
available. The CLI has one extra operator fallback: if the Gateway is offline or
does not expose the Workboard dispatch method and no explicit `--url` or
`--token` target was provided, it runs data-only dispatch against local SQLite
state. That fallback can promote dependencies, clean stale claims, and block
timed-out runs, but it cannot start workers.
Board metadata can include orchestration settings such as `autoDecompose`,
`autoDecomposePerDispatch`, `defaultAssignee`, and `orchestratorProfile`.
OpenClaw records the orchestration intent and exposes it in worker context; the
actual specification, decomposition, or session start still happens through the
normal Workboard tools and dashboard session flow.
actual specification and decomposition still happens through the normal
Workboard tools.
## CLI and slash command
The plugin registers a root CLI command:
```bash
openclaw workboard list
openclaw workboard create "Fix stale card lifecycle" --priority high --labels bug,workboard
openclaw workboard show <card-id>
openclaw workboard dispatch
```
`openclaw workboard dispatch` calls the running Gateway so worker starts use the
same subagent runtime as the dashboard. If the Gateway is unavailable, it falls
back to data-only dispatch so dependency promotion, stale-claim cleanup, and
timeout blocking can still run. Auth, permission, and validation failures still
surface as command errors, as do failures for explicit `--url` or `--token`
targets.
The `/workboard` slash command supports the same compact operator path:
`/workboard list`, `/workboard show <card-id>`, `/workboard create <title>`, and
`/workboard dispatch`. List and show are read operations for authorized command
senders. Create and dispatch require owner status on chat surfaces or a Gateway
client with `operator.write` or `operator.admin`.
See [Workboard CLI](/cli/workboard) for command flags, JSON output, Gateway
fallback behavior, unambiguous id-prefix handling, dispatch selection rules, and
troubleshooting.
## Session lifecycle sync
@@ -289,9 +364,26 @@ Workboard creates links to normal dashboard sessions. Check the card's agent id
and linked session, then open the Sessions or Chat view to inspect the actual
run state.
### Dispatch does not start a worker
Confirm there is at least one `ready` card without an active claim:
```bash
openclaw workboard list --status ready
```
If the CLI reports data-only dispatch, start or restart the Gateway and retry.
Data-only dispatch updates local board state but cannot start subagent worker
runs.
Cards can also be skipped when another card for the same owner or agent is
already running or waiting for review. Complete, block, or release that active
work before dispatching more work for the same owner.
## Related
- [Control UI](/web/control-ui)
- [Workboard CLI](/cli/workboard)
- [Plugins](/tools/plugin)
- [Manage plugins](/plugins/manage-plugins)
- [Sessions](/concepts/session)

View File

@@ -1,5 +1,6 @@
import { definePluginEntry } from "./api.js";
import { registerWorkboardGatewayMethods } from "./runtime-api.js";
import { registerWorkboardCommand } from "./src/command.js";
import { WorkboardStore } from "./src/store.js";
import { createWorkboardTools } from "./src/tools.js";
@@ -10,6 +11,22 @@ export default definePluginEntry({
register(api) {
const store = WorkboardStore.openSqlite();
registerWorkboardGatewayMethods({ api, store });
registerWorkboardCommand({ api, store });
api.registerCli(
async ({ program }) => {
const { registerWorkboardCli } = await import("./src/cli.js");
registerWorkboardCli({ program, store });
},
{
descriptors: [
{
name: "workboard",
description: "Manage Workboard cards and worker dispatch",
hasSubcommands: true,
},
],
},
);
api.registerTool((context) => createWorkboardTools({ api, context, store }), {
names: [
"workboard_list",

View File

@@ -2,7 +2,8 @@
"id": "workboard",
"enabledByDefault": false,
"activation": {
"onStartup": true
"onStartup": true,
"onCommands": ["workboard"]
},
"name": "Workboard",
"description": "Dashboard workboard for agent-owned issues and sessions.",
@@ -44,6 +45,13 @@
"workboard_unblock"
]
},
"commandAliases": [
{
"name": "workboard",
"kind": "runtime-slash",
"cliCommand": "workboard"
}
],
"toolMetadata": {
"workboard_list": {
"optional": true

View File

@@ -0,0 +1,24 @@
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,154 @@
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("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,235 @@
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 { 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;
};
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 isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
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("--json", "Print JSON", false)
.action(async (options: JsonOptions & { board?: string; status?: string }) => {
let cards = await params.store.list({ boardId: options.board });
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("--json", "Print JSON", false),
).action(async (options: GatewayOptions) => {
try {
const result = await callWorkboardGateway("workboard.cards.dispatch", options, {});
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());
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,114 @@
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,161 @@
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,110 @@
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: `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: `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("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" }),
]);
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,240 @@
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, WorkboardStatus } 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;
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 cardIsArchived(card: WorkboardCard): boolean {
return Boolean(card.metadata?.archivedAt);
}
function buildSessionKey(card: WorkboardCard): string {
return `workboard-${cardBoardId(card)}-${card.id}`.replace(/[^a-zA-Z0-9:_-]/g, "-");
}
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): WorkboardCard[] {
if (limit <= 0) {
return [];
}
const activeStatuses = new Set<WorkboardStatus>(["running", "review"]);
const runningByOwner = new Map<string, number>();
for (const card of cards) {
if (!activeStatuses.has(card.status) || 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 cards
.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 dispatch = await params.store.dispatch(now);
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();
for (const card of selectStartableCards(cards, maxStarts)) {
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

@@ -1,5 +1,6 @@
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";
@@ -383,7 +384,10 @@ export function registerWorkboardGatewayMethods(params: {
"workboard.cards.dispatch",
async ({ respond }) => {
try {
const result = await store.dispatch();
const result = await dispatchAndStartWorkboardCards({
store,
subagent: api.runtime.subagent,
});
respond(true, {
...result,
promoted: result.promoted.map(redactClaimToken),