diff --git a/docs/cli/workboard.md b/docs/cli/workboard.md index b68b0bc547c4..9a51670507c1 100644 --- a/docs/cli/workboard.md +++ b/docs/cli/workboard.md @@ -22,6 +22,7 @@ openclaw gateway restart openclaw workboard list [--board ] [--status ] [--include-archived] [--json] openclaw workboard create [--notes ] [--status ] [--priority ] [--agent ] [--board ] [--labels ] [--json] openclaw workboard show [--json] +openclaw workboard move --status [--json] openclaw workboard dispatch [--board ] [--max-starts ] [--admin] [--url ] [--token ] [--timeout ] [--json] ``` @@ -82,6 +83,15 @@ 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. +## `move` + +```bash +openclaw workboard move 7f4a2c10 --status review +openclaw workboard move 7f4a2c10 --status done --json +``` + +`move` changes the card's status using the same manual-operator path as dragging a card in the dashboard. It accepts a full card id or an unambiguous prefix. Active dependency and schedule holds still apply. Operators may move a claimed card without its agent claim token; claim tokens remain scoped to agent-tool mutations and are redacted from JSON output. + ## `dispatch` ```bash @@ -134,18 +144,19 @@ Command-capable channels can use the matching slash command: /workboard list /workboard show 7f4a2c10 /workboard create Fix stale worker heartbeat +/workboard move 7f4a2c10 --status review /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`. +`/workboard list` and `/workboard show` are read commands for authorized command senders. `/workboard create`, `/workboard move`, 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 normally requests Gateway `operator.write` and `operator.read` scopes. Workspace-bound cards run directly in an exact configured agent workspace; a worktree request is narrowed to that directory instead of letting the host materialize repository-controlled code. The selected worker must have writable, non-shared Docker sandbox access to that exact workspace, a live container hash matching the requested mounts and policy, and no host escape capability. Pass `--admin` to explicitly request `operator.admin`, allow another host checkout, and use normal managed-worktree setup; the connection fails if that scope is not approved for the client. A read-only Gateway token can inspect Workboard data through read methods, but it cannot create cards or dispatch workers. Workspace limits do not otherwise change manual card movement for callers with Workboard mutation permission. -Local `list`, `create`, and `show` commands operate on the local OpenClaw state directory used by the current profile. Use `--dev` or `--profile ` on the top-level `openclaw` command when you need a different state root. +Local `list`, `create`, `show`, and `move` commands operate on the local OpenClaw state directory used by the current profile. Use `--dev` or `--profile ` on the top-level `openclaw` command when you need a different state root. ## Troubleshooting diff --git a/docs/docs_map.md b/docs/docs_map.md index 697b5169191c..f155e95fc0e5 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -2164,6 +2164,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: list - H2: create - H2: show + - H2: move - H2: dispatch - H2: Slash command parity - H2: Permissions diff --git a/docs/plugins/workboard.md b/docs/plugins/workboard.md index cb281dbfd087..8dff979d309b 100644 --- a/docs/plugins/workboard.md +++ b/docs/plugins/workboard.md @@ -136,6 +136,7 @@ rule as linked sessions (see [Session lifecycle sync](#session-lifecycle-sync)). | `workboard_promote` / `workboard_reassign` / `workboard_reclaim` | Recover or hand off stuck work. | | `workboard_comment` / `workboard_proof` | Add handoff notes or attach proof/artifact references. | | `workboard_unblock` | Move blocked work back to `todo`. | +| `workboard_move` | Move a card to another status; claimed cards require the caller's agent claim scope. | | `workboard_dispatch` | Nudge dependency promotion or stale-claim cleanup without launching workers; worker launch uses Gateway or slash-command dispatch. | Claimed cards reject agent-tool mutations from other agents unless the caller @@ -241,25 +242,28 @@ through the normal Workboard tools. openclaw workboard list [--board ] [--status ] [--include-archived] [--json] openclaw workboard create "Fix stale card lifecycle" --priority high --labels bug,workboard openclaw workboard show [--json] +openclaw workboard move --status [--json] openclaw workboard dispatch [--board ] [--json] ``` `list` text output hides archived cards by default (`--include-archived` overrides); `--json` always includes archived cards, matching the full-card -contract used by existing scripts. `show` accepts an unambiguous id prefix. -`list`, `create`, and `show` always read/write local plugin state directly. -Only `dispatch` calls the running Gateway, with the fallback described above. +contract used by existing scripts. `show` and `move` accept an unambiguous id +prefix. `list`, `create`, `show`, and `move` always read/write local plugin +state directly. Only `dispatch` calls the running Gateway, with the fallback +described above. See [Workboard CLI](/cli/workboard) for full flags, JSON output, Gateway fallback behavior, id-prefix handling, dispatch selection rules, and troubleshooting. `/workboard list`, `/workboard show `, `/workboard create `, -and `/workboard dispatch` mirror the CLI. List and show are read operations -for any authorized command sender. Create and dispatch require owner status on -chat surfaces, or a Gateway client with `operator.write`/`operator.admin`. -Their worktree access still follows the same workspace boundary described -above. +`/workboard move <card-id> --status <status>`, and `/workboard dispatch` mirror +the CLI. List and show are read operations for any authorized command sender. +Create, move, and dispatch require owner status on chat surfaces, or a Gateway +client with `operator.write`/`operator.admin`. Manual operator moves use the +same claim-override behavior as dashboard drag-and-drop. Their worktree access +still follows the same workspace boundary described above. ## Session lifecycle sync diff --git a/extensions/workboard/openclaw.plugin.json b/extensions/workboard/openclaw.plugin.json index 5ecde7e6ed8b..a4b94362abf3 100644 --- a/extensions/workboard/openclaw.plugin.json +++ b/extensions/workboard/openclaw.plugin.json @@ -43,7 +43,8 @@ "workboard_proof", "workboard_worker_log", "workboard_protocol_violation", - "workboard_unblock" + "workboard_unblock", + "workboard_move" ] }, "commandAliases": [ @@ -155,6 +156,9 @@ }, "workboard_unblock": { "optional": true + }, + "workboard_move": { + "optional": true } }, "configSchema": { diff --git a/extensions/workboard/src/cli.test.ts b/extensions/workboard/src/cli.test.ts index 9854e6719811..a4f13e33ba43 100644 --- a/extensions/workboard/src/cli.test.ts +++ b/extensions/workboard/src/cli.test.ts @@ -277,4 +277,36 @@ describe("registerWorkboardCli", () => { program.parseAsync(["workboard", "show", prefix], { from: "user" }), ).rejects.toThrow("Ambiguous card id prefix"); }); + + it("moves claimed cards with operator authority and redacts JSON output", async () => { + const store = new WorkboardStore(createMemoryStore()); + const card = await store.create({ title: "Claimed card", status: "todo" }); + await store.claim(card.id, { ownerId: "worker", token: "secret-token" }); + const program = createProgram(store); + + const output = await captureStdout(async () => { + await program.parseAsync( + ["workboard", "move", card.id.slice(0, 8), "--status", "review", "--json"], + { from: "user" }, + ); + }); + + const parsed = JSON.parse(output); + expect(parsed).toMatchObject({ card: { id: card.id, status: "review" } }); + expect(parsed.card.metadata.claim.token).toBe("[redacted]"); + expect(output).not.toContain("secret-token"); + }); + + it("rejects an invalid move status", async () => { + const store = new WorkboardStore(createMemoryStore()); + const card = await store.create({ title: "Invalid move" }); + const program = createProgram(store); + + await expect( + program.parseAsync(["workboard", "move", card.id, "--status", "later"], { + from: "user", + }), + ).rejects.toThrow("--status must be one of"); + await expect(store.get(card.id)).resolves.toMatchObject({ status: "todo" }); + }); }); diff --git a/extensions/workboard/src/cli.ts b/extensions/workboard/src/cli.ts index f3388bf6e24d..777dd2588929 100644 --- a/extensions/workboard/src/cli.ts +++ b/extensions/workboard/src/cli.ts @@ -7,7 +7,7 @@ import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveWorkboardCardByIdOrPrefix } from "./card-lookup.js"; import type { WorkboardDispatchResult, WorkboardStore } from "./store.js"; -import type { WorkboardCard } from "./types.js"; +import { WORKBOARD_STATUSES, type WorkboardCard, type WorkboardStatus } from "./types.js"; type JsonOptions = { json?: boolean; @@ -57,6 +57,10 @@ function splitLabels(value: string | undefined): string[] | undefined { .filter(Boolean); } +function isWorkboardStatus(value: string): value is WorkboardStatus { + return (WORKBOARD_STATUSES as readonly string[]).includes(value); +} + function formatCardLine(card: WorkboardCard): string { const boardId = card.metadata?.automation?.boardId ?? "default"; const agent = card.agentId ? ` ${card.agentId}` : ""; @@ -241,6 +245,29 @@ export function registerWorkboardCli(params: { program: Command; store: Workboar } }); + workboard + .command("move") + .argument("<id>", "Card id or prefix") + .description("Move a Workboard card to another status") + .requiredOption("--status <status>", "Target status") + .option("--json", "Print JSON", false) + .action(async (id: string, options: JsonOptions & { status: string }) => { + if (!isWorkboardStatus(options.status)) { + throw new Error(`--status must be one of: ${WORKBOARD_STATUSES.join(", ")}.`); + } + const cards = await params.store.list(); + const { card, error } = resolveWorkboardCardByIdOrPrefix(cards, id); + if (!card) { + throw new Error(error); + } + const updated = await params.store.move(card.id, options.status, undefined); + if (options.json) { + writeJson({ card: redactClaimToken(updated) }); + } else { + writeLine(formatCardLine(updated)); + } + }); + addGatewayClientOptions( workboard .command("dispatch") diff --git a/extensions/workboard/src/command.test.ts b/extensions/workboard/src/command.test.ts index 92765ed356f8..d7e22e1595cf 100644 --- a/extensions/workboard/src/command.test.ts +++ b/extensions/workboard/src/command.test.ts @@ -242,10 +242,55 @@ describe("handleWorkboardCommand", () => { text: expect.stringContaining("operator.write"), }), ); + await expect( + handleWorkboardCommand({ api, store, args: `move ${card.id} --status running` }), + ).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("moves claimed cards for operators on slash-command surfaces", async () => { + const store = new WorkboardStore(createMemoryStore()); + const api = createApi(); + const card = await store.create({ title: "Claimed slash card", status: "todo" }); + await store.claim(card.id, { ownerId: "worker", token: "secret-token" }); + + await expect( + handleWorkboardCommand({ + api, + store, + args: `move ${card.id.slice(0, 8)} --status review`, + gatewayClientScopes: ["operator.write"], + }), + ).resolves.toEqual(expect.objectContaining({ text: expect.stringContaining("review") })); + await expect(store.get(card.id)).resolves.toMatchObject({ + status: "review", + metadata: { claim: { ownerId: "worker", token: "secret-token" } }, + }); + }); + + it("rejects invalid slash-command move statuses", async () => { + const store = new WorkboardStore(createMemoryStore()); + const api = createApi(); + const card = await store.create({ title: "Invalid slash move" }); + + await expect( + handleWorkboardCommand({ + api, + store, + args: `move ${card.id} --status later`, + senderIsOwner: true, + }), + ).resolves.toEqual( + expect.objectContaining({ isError: true, text: expect.stringContaining("status must be") }), + ); + }); + it("uses the slash caller's workspace access for worktree materialization", async () => { const store = new WorkboardStore(createMemoryStore()); const api = createApi(); diff --git a/extensions/workboard/src/command.ts b/extensions/workboard/src/command.ts index d376f658ad62..ea27d8841451 100644 --- a/extensions/workboard/src/command.ts +++ b/extensions/workboard/src/command.ts @@ -7,7 +7,7 @@ import { type WorkboardWorktreeRuntime, } from "./dispatcher.js"; import type { WorkboardStore } from "./store.js"; -import type { WorkboardCard } from "./types.js"; +import { WORKBOARD_STATUSES, type WorkboardCard, type WorkboardStatus } from "./types.js"; import { canonicalizeWorkboardWorkspaceAccess, resolveAgentWorkboardWorkspaceRuntime, @@ -64,6 +64,10 @@ function normalizeTitle(tokens: string[]): string { return tokens.join(" ").trim(); } +function isWorkboardStatus(value: string): value is WorkboardStatus { + return (WORKBOARD_STATUSES as readonly string[]).includes(value); +} + function canMutateWorkboard(params: { senderIsOwner?: boolean; gatewayClientScopes?: readonly string[]; @@ -111,6 +115,7 @@ export async function handleWorkboardCommand(params: { "/workboard list", "/workboard show <card-id>", "/workboard create <title>", + "/workboard move <card-id> --status <status>", "/workboard dispatch", ].join("\n"), }; @@ -144,6 +149,33 @@ export async function handleWorkboardCommand(params: { const card = await params.store.create({ title, workspaceAccess }); return { text: `Created ${card.id.slice(0, 8)} ${card.title}` }; } + if (action === "move") { + const accessError = requireWriteAccess(params); + if (accessError) { + return accessError; + } + const id = rest[0]; + const statusIndex = rest.indexOf("--status"); + const status = statusIndex >= 0 ? rest[statusIndex + 1] : undefined; + if (!id || !status) { + return { + text: "Usage: /workboard move <card-id> --status <status>", + isError: true, + }; + } + if (!isWorkboardStatus(status)) { + return { + text: `status must be one of: ${WORKBOARD_STATUSES.join(", ")}.`, + isError: true, + }; + } + const cards = await params.store.list(); + const { card, error } = resolveWorkboardCardByIdOrPrefix(cards, id); + if (!card) { + return { text: error, isError: true }; + } + return { text: formatCardLine(await params.store.move(card.id, status, undefined)) }; + } if (action === "dispatch") { const accessError = requireWriteAccess(params); if (accessError) { diff --git a/extensions/workboard/src/store-core.ts b/extensions/workboard/src/store-core.ts index 8a66de4978ce..14972dae7d4e 100644 --- a/extensions/workboard/src/store-core.ts +++ b/extensions/workboard/src/store-core.ts @@ -627,13 +627,6 @@ export class WorkboardCoreStore { } } - async move(id: string, status: unknown, position: unknown): Promise<WorkboardCard> { - return await this.update(id, { - status, - position, - }); - } - async delete(id: string): Promise<{ deleted: boolean }> { return await this.enqueueMutation(async () => await this.deleteDirect(id)); } diff --git a/extensions/workboard/src/store-promote.ts b/extensions/workboard/src/store-promote.ts index e1e38e0dd34a..0098c984c607 100644 --- a/extensions/workboard/src/store-promote.ts +++ b/extensions/workboard/src/store-promote.ts @@ -7,6 +7,31 @@ import { clearDiagnostics, normalizeBoundedString } from "./store-normalizers.js import type { WorkboardCard } from "./types.js"; export class WorkboardPromoteStore extends WorkboardEnrichmentStore { + async move( + id: string, + status: unknown, + position: unknown, + scope?: WorkboardMutationScope, + ): Promise<WorkboardCard> { + return await this.enqueueMutation(async () => { + const existing = await this.get(id); + if (!existing) { + throw new Error(`card not found: ${id}`); + } + // Operator surfaces omit scope and may override claims. Agent tools pass scope so a + // worker cannot move another worker's claimed card between the preflight and this write. + assertCanMutateClaimedCard(existing, scope); + return await this.updateCard( + id, + { status, position }, + { + allowMetadataDependencyLinks: false, + enforceStatusHolds: true, + }, + ); + }); + } + async promote( id: string, input: WorkboardPromoteInput = {}, diff --git a/extensions/workboard/src/store.test.ts b/extensions/workboard/src/store.test.ts index adf5b8e4f130..4acef62b91c9 100644 --- a/extensions/workboard/src/store.test.ts +++ b/extensions/workboard/src/store.test.ts @@ -1931,6 +1931,21 @@ describe("WorkboardStore", () => { }); }); + it("lets operators override claims while enforcing agent-scoped moves", async () => { + const store = new WorkboardStore(createMemoryStore()); + const card = await store.create({ title: "Scoped move", status: "todo" }); + await store.claim(card.id, { ownerId: "agent-a", token: "test-auth-token" }); + + await expect(store.move(card.id, "review", undefined, { ownerId: "agent-b" })).rejects.toThrow( + "card is claimed by agent-a", + ); + await expect(store.get(card.id)).resolves.toMatchObject({ status: "running" }); + + await expect(store.move(card.id, "review", undefined)).resolves.toMatchObject({ + status: "review", + }); + }); + it("checks matching claim tokens inside queued card writes", async () => { const store = new WorkboardStore(createMemoryStore()); const card = await store.create({ title: "Token-scoped mutation" }); diff --git a/extensions/workboard/src/tools-card-mutations.ts b/extensions/workboard/src/tools-card-mutations.ts new file mode 100644 index 000000000000..b4ec9d42c00c --- /dev/null +++ b/extensions/workboard/src/tools-card-mutations.ts @@ -0,0 +1,52 @@ +import type { AnyAgentTool } from "openclaw/plugin-sdk/plugin-entry"; +import type { AgentToolResult } from "openclaw/plugin-sdk/tool-results"; +import { Type } from "typebox"; +import type { WorkboardMutationScope } from "./store-inputs.js"; +import type { WorkboardStore } from "./store.js"; +import { WORKBOARD_STATUSES, type WorkboardCard } from "./types.js"; + +type ScopedMoveParams = { + record: Record<string, unknown>; + id: string; + scope: WorkboardMutationScope; +}; + +const ClaimTokenFieldName = "token" as const; + +export function cardIdField() { + return Type.String({ description: "Workboard card id." }); +} + +export function claimTokenField(description = "Claim token returned by workboard_claim.") { + return Type.Optional(Type.String({ description })); +} + +export function createWorkboardMoveTool(params: { + store: WorkboardStore; + readScopedCardToolParams: (rawParams: unknown) => Promise<ScopedMoveParams>; + redactedCardResult: (card: WorkboardCard) => AgentToolResult<{ card: WorkboardCard }>; +}): AnyAgentTool { + return { + name: "workboard_move", + label: "Workboard Move", + description: + "Move a Workboard card to another status. Claimed cards require matching claim scope.", + parameters: Type.Object( + { + id: cardIdField(), + status: Type.Union( + WORKBOARD_STATUSES.map((status) => Type.Literal(status)), + { description: "Target Workboard status." }, + ), + [ClaimTokenFieldName]: claimTokenField("Claim token for claimed cards."), + }, + { additionalProperties: false }, + ), + execute: async (_toolCallId, rawParams) => { + const { record, id, scope } = await params.readScopedCardToolParams(rawParams); + return params.redactedCardResult( + await params.store.move(id, record.status, undefined, scope), + ); + }, + }; +} diff --git a/extensions/workboard/src/tools.test.ts b/extensions/workboard/src/tools.test.ts index e96d0ca1f116..d6840fccf209 100644 --- a/extensions/workboard/src/tools.test.ts +++ b/extensions/workboard/src/tools.test.ts @@ -590,4 +590,41 @@ describe("workboard tools", () => { ); expect(Buffer.from(attachment.contentBase64 as string, "base64").toString("utf8")).toBe("done"); }); + + it("moves cards with agent claim scope", async () => { + const store = new WorkboardStore(createMemoryStore()); + const api = { runtime: {} } as unknown as OpenClawPluginApi; + const tools = new Map( + createWorkboardTools({ api, store, context: { agentId: "agent-b" } as never }).map((tool) => [ + tool.name, + tool, + ]), + ); + const card = await store.create({ title: "Move tool card", status: "todo" }); + + const unclaimed = readPayload( + await tools.get("workboard_move")?.execute("move-unclaimed", { + id: card.id, + status: "ready", + }), + ); + expect(unclaimed.card).toMatchObject({ status: "ready" }); + + await store.claim(card.id, { ownerId: "agent-a", token: "test-auth-token" }); + await expect( + tools.get("workboard_move")?.execute("move-denied", { + id: card.id, + status: "review", + }), + ).rejects.toThrow("card is claimed by agent-a"); + + const claimed = readPayload( + await tools.get("workboard_move")?.execute("move-claimed", { + id: card.id, + status: "review", + token: "test-auth-token", + }), + ); + expect(claimed.card).toMatchObject({ status: "review" }); + }); }); diff --git a/extensions/workboard/src/tools.ts b/extensions/workboard/src/tools.ts index 30548fc58f06..015eecc10a6d 100644 --- a/extensions/workboard/src/tools.ts +++ b/extensions/workboard/src/tools.ts @@ -5,6 +5,7 @@ import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime"; import { Type } from "typebox"; import { WorkboardStore } from "./store.js"; +import { cardIdField, claimTokenField, createWorkboardMoveTool } from "./tools-card-mutations.js"; import type { WorkboardCard } from "./types.js"; function contextOwner(ctx: OpenClawPluginToolContext | undefined): string { @@ -139,14 +140,6 @@ type WorkboardCardMutation = ( scope: WorkboardToolCardParams["scope"], ) => Promise<WorkboardCard>; -function cardIdField() { - return Type.String({ description: "Workboard card id." }); -} - -function claimTokenField(description = "Claim token returned by workboard_claim.") { - return Type.Optional(Type.String({ description })); -} - const ScopedClaimTokenField = claimTokenField("Claim token for claimed cards."); const OptionalNextStatusField = Type.Optional( Type.String({ description: "Optional next status." }), @@ -621,6 +614,7 @@ export function createWorkboardTools(params: { return redactedRawCardResult(await store.unblock(id, scope)); }, }, + createWorkboardMoveTool({ store, readScopedCardToolParams, redactedCardResult }), { name: "workboard_boards", label: "Workboard Boards", diff --git a/extensions/workboard/src/workspace-access.ts b/extensions/workboard/src/workspace-access.ts index 71c8ee4f2be3..c29c37cd0ce1 100644 --- a/extensions/workboard/src/workspace-access.ts +++ b/extensions/workboard/src/workspace-access.ts @@ -59,6 +59,7 @@ export const WORKBOARD_TOOL_NAMES = [ "workboard_worker_log", "workboard_protocol_violation", "workboard_unblock", + "workboard_move", ] as const; const WORKBOARD_REQUIRED_WORKER_TOOLS = [