mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 11:54:06 +00:00
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:
committed by
GitHub
parent
1d55caa162
commit
ed46e62bcc
@@ -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
221
docs/cli/workboard.md
Normal 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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
24
extensions/workboard/src/card-lookup.ts
Normal file
24
extensions/workboard/src/card-lookup.ts
Normal 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}` };
|
||||
}
|
||||
154
extensions/workboard/src/cli.test.ts
Normal file
154
extensions/workboard/src/cli.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
235
extensions/workboard/src/cli.ts
Normal file
235
extensions/workboard/src/cli.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
114
extensions/workboard/src/command.test.ts
Normal file
114
extensions/workboard/src/command.test.ts
Normal 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"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
161
extensions/workboard/src/command.ts
Normal file
161
extensions/workboard/src/command.ts
Normal 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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
110
extensions/workboard/src/dispatcher.test.ts
Normal file
110
extensions/workboard/src/dispatcher.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
240
extensions/workboard/src/dispatcher.ts
Normal file
240
extensions/workboard/src/dispatcher.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user