Files
openclaw/extensions/workboard/src/tools.ts
Peter Steinberger 8d6a6e9d03 feat: move workboard to relational sqlite
Move Workboard durable data into a relational SQLite database and add extension doctor migration for .28 plugin-state rows. Preserve attachment lifecycle behavior, SQLite permissions/WAL settings, and scoped plugin migration access.
2026-05-31 00:02:23 +01:00

1035 lines
40 KiB
TypeScript

import { jsonResult, readStringParam } from "openclaw/plugin-sdk/core";
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry";
import { Type } from "typebox";
import { WorkboardStore } from "./store.js";
import type { WorkboardCard } from "./types.js";
function contextOwner(ctx: OpenClawPluginToolContext | undefined): string {
const record = (ctx ?? {}) as Record<string, unknown>;
return (
(typeof record.agentId === "string" && record.agentId) ||
(typeof record.sessionKey === "string" && record.sessionKey) ||
(typeof record.sessionId === "string" && record.sessionId) ||
"agent"
);
}
function canMutateCard(card: WorkboardCard, ownerId: string, token?: string): boolean {
const claim = card.metadata?.claim;
if (!claim) {
return true;
}
return claim.ownerId === ownerId || (Boolean(token) && claim.token === token);
}
function readParentIds(value: unknown): string[] {
if (value == null) {
return [];
}
const entries =
typeof value === "string" ? value.split(",") : Array.isArray(value) ? value : undefined;
if (!entries) {
throw new Error("parents must be an array or comma-separated string.");
}
const parents: string[] = [];
for (const entry of entries) {
if (typeof entry !== "string") {
throw new Error("parents must contain only strings.");
}
const parent = entry.trim();
if (!parent || parents.includes(parent)) {
continue;
}
if (parent.length > 120) {
throw new Error("parents must be 120 characters or fewer.");
}
parents.push(parent);
if (parents.length >= 20) {
break;
}
}
return parents;
}
async function requireScopedCard(
store: WorkboardStore,
cardId: string,
ownerId: string,
token?: string,
): Promise<WorkboardCard> {
const card = await store.get(cardId);
if (!card) {
throw new Error(`card not found: ${cardId}`);
}
if (!canMutateCard(card, ownerId, token)) {
throw new Error(`card is claimed by ${card.metadata?.claim?.ownerId ?? "another agent"}.`);
}
return card;
}
async function requireClaimedCard(
store: WorkboardStore,
cardId: string,
ownerId: string,
token?: string,
): Promise<WorkboardCard> {
const card = await requireScopedCard(store, cardId, ownerId, token);
if (!card.metadata?.claim) {
throw new Error("card must be claimed before lifecycle completion.");
}
return card;
}
function summarizeCard(card: WorkboardCard) {
return {
id: card.id,
title: card.title,
status: card.status,
priority: card.priority,
agentId: card.agentId,
tenant: card.metadata?.automation?.tenant,
boardId: card.metadata?.automation?.boardId ?? "default",
parents: card.metadata?.links
?.filter((link) => link.type === "parent" && link.targetCardId)
.map((link) => link.targetCardId),
children: card.metadata?.links
?.filter((link) => link.type === "child" && link.targetCardId)
.map((link) => link.targetCardId),
claim: card.metadata?.claim
? {
ownerId: card.metadata.claim.ownerId,
claimedAt: card.metadata.claim.claimedAt,
lastHeartbeatAt: card.metadata.claim.lastHeartbeatAt,
expiresAt: card.metadata.claim.expiresAt,
}
: undefined,
diagnostics: card.metadata?.diagnostics,
archivedAt: card.metadata?.archivedAt,
updatedAt: card.updatedAt,
};
}
function redactClaimToken(card: WorkboardCard): WorkboardCard {
const claim = card.metadata?.claim;
if (!claim) {
return card;
}
return {
...card,
metadata: {
...card.metadata,
claim: {
...claim,
token: "[redacted]",
},
},
};
}
type WorkboardToolCardParams = {
record: Record<string, unknown>;
id: string;
token?: string;
scope: { ownerId: string; token?: string };
};
type WorkboardToolCardParamsReader = (rawParams: unknown) => Promise<WorkboardToolCardParams>;
type WorkboardCardMutation = (
id: string,
record: Record<string, unknown>,
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." }),
);
const OptionalOperatorNoteField = Type.Optional(
Type.String({ description: "Optional operator note." }),
);
function readCardToolParams(rawParams: unknown, ownerId: string): WorkboardToolCardParams {
const record = rawParams as Record<string, unknown>;
const id = readStringParam(record, "id", { required: true });
const token = record.token as string | undefined;
return {
record,
id,
token,
scope: { ownerId, token },
};
}
function redactedCardResult(card: WorkboardCard) {
return jsonResult({ card: redactClaimToken(card) });
}
function redactedRawCardResult(card: WorkboardCard) {
return jsonResult(redactClaimToken(card));
}
const CardIdSchema = Type.Object(
{
id: cardIdField(),
token: claimTokenField(),
},
{ additionalProperties: false },
);
export function createWorkboardTools(params: {
api: OpenClawPluginApi;
context?: OpenClawPluginToolContext;
store?: WorkboardStore;
}): AnyAgentTool[] {
const store = params.store ?? WorkboardStore.openSqlite();
const ownerId = contextOwner(params.context);
const readScopedCardToolParams = async (rawParams: unknown): Promise<WorkboardToolCardParams> => {
const input = readCardToolParams(rawParams, ownerId);
await requireScopedCard(store, input.id, ownerId, input.token);
return input;
};
const readClaimedCardToolParams = async (
rawParams: unknown,
): Promise<WorkboardToolCardParams> => {
const input = readCardToolParams(rawParams, ownerId);
await requireClaimedCard(store, input.id, ownerId, input.token);
return input;
};
const runCardMutation = async (
rawParams: unknown,
readParams: WorkboardToolCardParamsReader,
mutate: WorkboardCardMutation,
) => {
const { record, id, scope } = await readParams(rawParams);
return redactedCardResult(await mutate(id, record, scope));
};
const runScopedCardMutation = (rawParams: unknown, mutate: WorkboardCardMutation) =>
runCardMutation(rawParams, readScopedCardToolParams, mutate);
const runClaimedCardMutation = (rawParams: unknown, mutate: WorkboardCardMutation) =>
runCardMutation(rawParams, readClaimedCardToolParams, mutate);
return [
{
name: "workboard_list",
label: "Workboard List",
description:
"List Workboard cards with compact claim and diagnostic state. Use before choosing or routing board work.",
parameters: Type.Object(
{
status: Type.Optional(Type.String({ description: "Optional card status filter." })),
agentId: Type.Optional(Type.String({ description: "Optional agent id filter." })),
tenant: Type.Optional(Type.String({ description: "Optional tenant filter." })),
boardId: Type.Optional(Type.String({ description: "Optional board id filter." })),
limit: Type.Optional(
Type.Number({ description: "Maximum cards to return. Default 50." }),
),
refreshDiagnostics: Type.Optional(
Type.Boolean({ description: "Refresh stored diagnostics before listing." }),
),
includeArchived: Type.Optional(
Type.Boolean({ description: "Include archived cards. Default false." }),
),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const record = rawParams as Record<string, unknown>;
if (record.refreshDiagnostics === true) {
await store.refreshDiagnostics();
}
const status = typeof record.status === "string" ? record.status : undefined;
const agentId = typeof record.agentId === "string" ? record.agentId : undefined;
const tenant = typeof record.tenant === "string" ? record.tenant : undefined;
const boardId = typeof record.boardId === "string" ? record.boardId : undefined;
const limit =
typeof record.limit === "number" && Number.isFinite(record.limit)
? Math.max(1, Math.min(200, Math.trunc(record.limit)))
: 50;
const cards = (await store.list({ boardId }))
.filter((card) => record.includeArchived === true || !card.metadata?.archivedAt)
.filter((card) => !status || card.status === status)
.filter((card) => !agentId || card.agentId === agentId)
.filter((card) => !tenant || card.metadata?.automation?.tenant === tenant)
.slice(0, limit)
.map(summarizeCard);
return jsonResult({ cards });
},
},
{
name: "workboard_create",
label: "Workboard Create",
description:
"Create a Workboard card, optionally with parent dependencies, tenant, skills, workspace, and idempotency key.",
parameters: Type.Object(
{
title: Type.String({ description: "Card title." }),
notes: Type.Optional(Type.String({ description: "Card notes or acceptance criteria." })),
status: Type.Optional(Type.String({ description: "Initial status." })),
priority: Type.Optional(Type.String({ description: "low, normal, high, or urgent." })),
labels: Type.Optional(Type.Array(Type.String(), { description: "Card labels." })),
agentId: Type.Optional(Type.String({ description: "Assigned agent id." })),
parents: Type.Optional(Type.Array(Type.String(), { description: "Parent card ids." })),
token: Type.Optional(
Type.String({ description: "Claim token for claimed parent cards." }),
),
tenant: Type.Optional(Type.String({ description: "Soft tenant namespace." })),
boardId: Type.Optional(Type.String({ description: "Soft board namespace." })),
createdByCardId: Type.Optional(
Type.String({ description: "Parent card that created this card." }),
),
idempotencyKey: Type.Optional(Type.String({ description: "Idempotent create key." })),
skills: Type.Optional(Type.Array(Type.String(), { description: "Suggested skills." })),
workspace: Type.Optional(
Type.Object(
{
kind: Type.String({ description: "scratch, dir, or worktree." }),
path: Type.Optional(Type.String({ description: "Absolute dir/worktree path." })),
branch: Type.Optional(Type.String({ description: "Suggested branch." })),
},
{ additionalProperties: false },
),
),
maxRuntimeSeconds: Type.Optional(Type.Number({ description: "Run timeout seconds." })),
maxRetries: Type.Optional(Type.Number({ description: "Retry budget." })),
scheduledAt: Type.Optional(Type.Number({ description: "Unix epoch milliseconds." })),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const record = rawParams as Record<string, unknown>;
readParentIds(record.parents);
return jsonResult({
card: redactClaimToken(
await store.create(record, { ownerId, token: record.token as string | undefined }),
),
});
},
},
{
name: "workboard_link",
label: "Workboard Link",
description:
"Link a parent card to a child card so the child becomes ready only after parents are done.",
parameters: Type.Object(
{
parentId: Type.String({ description: "Parent card id." }),
childId: Type.String({ description: "Child card id." }),
token: Type.Optional(
Type.String({ description: "Claim token for claimed parent or child cards." }),
),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const record = rawParams as Record<string, unknown>;
const parentId = readStringParam(record, "parentId", { required: true });
const childId = readStringParam(record, "childId", { required: true });
const token = record.token as string | undefined;
return jsonResult({
card: redactClaimToken(await store.linkCards(parentId, childId, { ownerId, token })),
});
},
},
{
name: "workboard_read",
label: "Workboard Read",
description:
"Read one Workboard card and return bounded worker context with notes, attempts, comments, proof, links, and diagnostics.",
parameters: CardIdSchema,
execute: async (_toolCallId, rawParams) => {
const record = rawParams as Record<string, unknown>;
const id = readStringParam(record, "id", { required: true });
const card = await store.get(id);
if (!card) {
throw new Error(`card not found: ${id}`);
}
return jsonResult({
card: redactClaimToken(card),
workerContext: await store.buildWorkerContext(id),
});
},
},
{
name: "workboard_claim",
label: "Workboard Claim",
description:
"Claim a Workboard card for this agent and move backlog/todo cards into running. Returns a claim token for heartbeats and release.",
parameters: Type.Object(
{
id: cardIdField(),
ttlSeconds: Type.Optional(Type.Number({ description: "Claim TTL in seconds." })),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const record = rawParams as Record<string, unknown>;
const id = readStringParam(record, "id", { required: true });
const claimed = await store.claim(id, {
ownerId,
ttlSeconds: record.ttlSeconds,
});
return jsonResult({ ...claimed, card: redactClaimToken(claimed.card) });
},
},
{
name: "workboard_heartbeat",
label: "Workboard Heartbeat",
description:
"Refresh this agent's Workboard claim heartbeat. Use during long-running card work so diagnostics do not mark it stale.",
parameters: Type.Object(
{
id: cardIdField(),
token: claimTokenField(),
note: Type.Optional(Type.String({ description: "Optional compact progress note." })),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const { record, id, scope } = await readScopedCardToolParams(rawParams);
return redactedRawCardResult(
await store.heartbeat(id, {
...scope,
note: record.note,
}),
);
},
},
{
name: "workboard_release",
label: "Workboard Release",
description:
"Release this agent's Workboard claim after finishing, pausing, or handing off card work.",
parameters: Type.Object(
{
id: cardIdField(),
token: claimTokenField(),
status: Type.Optional(
Type.String({ description: "Optional next card status after release." }),
),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const { record, id, scope } = await readScopedCardToolParams(rawParams);
return redactedRawCardResult(
await store.releaseClaim(id, {
...scope,
status: record.status,
}),
);
},
},
{
name: "workboard_comment",
label: "Workboard Comment",
description: "Append a compact comment to a Workboard card.",
parameters: Type.Object(
{
id: cardIdField(),
body: Type.String({ description: "Comment body." }),
token: ScopedClaimTokenField,
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const { record, id, scope } = await readScopedCardToolParams(rawParams);
return redactedRawCardResult(await store.addComment(id, { body: record.body }, scope));
},
},
{
name: "workboard_proof",
label: "Workboard Proof",
description:
"Attach proof or artifact metadata to a Workboard card after running tests, checks, or producing screenshots/logs.",
parameters: Type.Object(
{
id: cardIdField(),
status: Type.Optional(
Type.String({ description: "passed, failed, skipped, or unknown." }),
),
label: Type.Optional(Type.String({ description: "Proof label." })),
command: Type.Optional(Type.String({ description: "Command or exact step run." })),
url: Type.Optional(Type.String({ description: "Proof or artifact URL." })),
note: Type.Optional(Type.String({ description: "Short proof note." })),
artifactPath: Type.Optional(
Type.String({ description: "Optional local artifact path." }),
),
token: ScopedClaimTokenField,
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const { record, id, scope } = await readScopedCardToolParams(rawParams);
const hasArtifact =
(typeof record.artifactPath === "string" && record.artifactPath.trim() !== "") ||
(typeof record.url === "string" && record.url.trim() !== "");
const card = hasArtifact
? await store.addProofWithArtifact(
id,
record,
{
label: record.label,
path: record.artifactPath,
url: record.url,
},
scope,
)
: await store.addProof(id, record, scope);
return redactedCardResult(card);
},
},
{
name: "workboard_complete",
label: "Workboard Complete",
description:
"Complete a claimed Workboard card with a structured summary, proof, artifacts, and created-card manifest.",
parameters: Type.Object(
{
id: cardIdField(),
token: claimTokenField(),
summary: Type.Optional(Type.String({ description: "Completion summary." })),
proof: Type.Optional(
Type.Object(
{
status: Type.Optional(
Type.String({ description: "passed, failed, skipped, or unknown." }),
),
label: Type.Optional(Type.String({ description: "Proof label." })),
command: Type.Optional(Type.String({ description: "Command or step run." })),
url: Type.Optional(Type.String({ description: "Proof URL." })),
note: Type.Optional(Type.String({ description: "Proof note." })),
},
{ additionalProperties: false },
),
),
artifacts: Type.Optional(
Type.Array(
Type.Object(
{
label: Type.Optional(Type.String()),
url: Type.Optional(Type.String()),
path: Type.Optional(Type.String()),
mimeType: Type.Optional(Type.String()),
},
{ additionalProperties: false },
),
),
),
createdCardIds: Type.Optional(
Type.Array(Type.String(), { description: "Cards created during this run." }),
),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
return runClaimedCardMutation(rawParams, (id, record, scope) =>
store.complete(id, record, scope),
);
},
},
{
name: "workboard_attachment_add",
label: "Workboard Attachment Add",
description:
"Store a small Workboard attachment in plugin SQLite KV and link it to the card.",
parameters: Type.Object(
{
id: cardIdField(),
fileName: Type.String({ description: "Attachment file name." }),
contentBase64: Type.String({ description: "Base64 attachment content." }),
mimeType: Type.Optional(Type.String({ description: "Attachment MIME type." })),
note: Type.Optional(Type.String({ description: "Optional attachment note." })),
token: ScopedClaimTokenField,
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const { record, id, scope } = await readScopedCardToolParams(rawParams);
return redactedCardResult(await store.addAttachment(id, record, scope));
},
},
{
name: "workboard_attachment_read",
label: "Workboard Attachment Read",
description: "Read one Workboard attachment from plugin SQLite KV.",
parameters: Type.Object(
{
id: Type.String({ description: "Attachment id." }),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const id = readStringParam(rawParams as Record<string, unknown>, "id", {
required: true,
});
const attachment = await store.getAttachment(id);
if (!attachment) {
throw new Error(`attachment not found: ${id}`);
}
return jsonResult(attachment);
},
},
{
name: "workboard_attachment_delete",
label: "Workboard Attachment Delete",
description: "Delete one Workboard attachment from plugin SQLite KV and the card index.",
parameters: Type.Object(
{
id: cardIdField(),
attachmentId: Type.String({ description: "Attachment id." }),
token: ScopedClaimTokenField,
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const { record, id, scope } = await readScopedCardToolParams(rawParams);
const attachmentId = readStringParam(record, "attachmentId", { required: true });
return redactedCardResult(await store.deleteAttachment(id, attachmentId, scope));
},
},
{
name: "workboard_block",
label: "Workboard Block",
description: "Block a claimed Workboard card with a durable reason and release the claim.",
parameters: Type.Object(
{
id: cardIdField(),
token: claimTokenField(),
reason: Type.Optional(Type.String({ description: "Blocker summary." })),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
return runClaimedCardMutation(rawParams, (id, record, scope) =>
store.block(id, record, scope),
);
},
},
{
name: "workboard_unblock",
label: "Workboard Unblock",
description: "Move a blocked Workboard card back to todo after adding enough context.",
parameters: CardIdSchema,
execute: async (_toolCallId, rawParams) => {
const { id, scope } = await readScopedCardToolParams(rawParams);
return redactedRawCardResult(await store.unblock(id, scope));
},
},
{
name: "workboard_boards",
label: "Workboard Boards",
description: "List Workboard board namespaces with active, archived, and status counts.",
parameters: Type.Object({}, { additionalProperties: false }),
execute: async () => jsonResult(await store.listBoards()),
},
{
name: "workboard_board_create",
label: "Workboard Board Create",
description: "Create or update a Workboard board namespace with persisted SQLite metadata.",
parameters: Type.Object(
{
id: Type.String({ description: "Board id." }),
name: Type.Optional(Type.String({ description: "Display name." })),
description: Type.Optional(Type.String({ description: "Board description." })),
icon: Type.Optional(Type.String({ description: "Short icon or label." })),
color: Type.Optional(Type.String({ description: "Display color token." })),
defaultWorkspace: Type.Optional(
Type.Object(
{
kind: Type.String({ description: "scratch, dir, or worktree." }),
path: Type.Optional(Type.String({ description: "Absolute dir/worktree path." })),
branch: Type.Optional(Type.String({ description: "Suggested branch." })),
},
{ additionalProperties: false },
),
),
orchestration: Type.Optional(
Type.Object(
{
autoDecompose: Type.Optional(
Type.Boolean({ description: "Mark ready triage cards for decomposition." }),
),
autoDecomposePerDispatch: Type.Optional(
Type.Number({ description: "Maximum orchestration candidates per dispatch." }),
),
defaultAssignee: Type.Optional(Type.String({ description: "Default assignee." })),
orchestratorProfile: Type.Optional(
Type.String({ description: "Orchestrator profile id." }),
),
},
{ additionalProperties: false },
),
),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) =>
jsonResult({ board: await store.upsertBoard(rawParams as Record<string, unknown>) }),
},
{
name: "workboard_board_archive",
label: "Workboard Board Archive",
description: "Archive or restore persisted Workboard board metadata.",
parameters: Type.Object(
{
id: Type.String({ description: "Board id." }),
archived: Type.Optional(Type.Boolean({ description: "Archive when true." })),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const record = rawParams as Record<string, unknown>;
return jsonResult({ board: await store.archiveBoard(record.id, record.archived) });
},
},
{
name: "workboard_board_delete",
label: "Workboard Board Delete",
description: "Delete an empty non-default Workboard board metadata record.",
parameters: Type.Object(
{ id: Type.String({ description: "Board id." }) },
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) =>
jsonResult(await store.deleteBoard((rawParams as Record<string, unknown>).id)),
},
{
name: "workboard_stats",
label: "Workboard Stats",
description: "Summarize Workboard counts by status and assignee for one board or all boards.",
parameters: Type.Object(
{
boardId: Type.Optional(Type.String({ description: "Optional board id filter." })),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const record = rawParams as Record<string, unknown>;
return jsonResult(await store.stats({ boardId: record.boardId }));
},
},
{
name: "workboard_runs",
label: "Workboard Runs",
description: "List persisted Workboard run attempts for one card.",
parameters: CardIdSchema,
execute: async (_toolCallId, rawParams) => {
const id = readStringParam(rawParams as Record<string, unknown>, "id", { required: true });
const result = await store.runs(id);
return jsonResult({ ...result, card: redactClaimToken(result.card) });
},
},
{
name: "workboard_specify",
label: "Workboard Specify",
description:
"Turn a rough triage/backlog Workboard card into a specified todo card after reasoning through the requirements.",
parameters: Type.Object(
{
id: Type.String({ description: "Workboard card id." }),
title: Type.Optional(Type.String({ description: "Clarified title." })),
notes: Type.Optional(
Type.String({ description: "Clarified notes or acceptance criteria." }),
),
agentId: Type.Optional(Type.String({ description: "Assigned agent id." })),
priority: Type.Optional(Type.String({ description: "low, normal, high, or urgent." })),
labels: Type.Optional(Type.Array(Type.String(), { description: "Card labels." })),
boardId: Type.Optional(Type.String({ description: "Board id." })),
tenant: Type.Optional(Type.String({ description: "Tenant or routing namespace." })),
skills: Type.Optional(Type.Array(Type.String(), { description: "Suggested skills." })),
workspace: Type.Optional(
Type.Object(
{
kind: Type.String({ description: "scratch, dir, or worktree." }),
path: Type.Optional(Type.String({ description: "Absolute dir/worktree path." })),
branch: Type.Optional(Type.String({ description: "Suggested branch." })),
},
{ additionalProperties: false },
),
),
maxRuntimeSeconds: Type.Optional(Type.Number({ description: "Runtime budget." })),
maxRetries: Type.Optional(Type.Number({ description: "Retry budget." })),
summary: Type.Optional(Type.String({ description: "Specification summary comment." })),
token: Type.Optional(Type.String({ description: "Claim token for claimed cards." })),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const record = rawParams as Record<string, unknown>;
const id = readStringParam(record, "id", { required: true });
await requireScopedCard(store, id, ownerId, record.token as string | undefined);
return jsonResult({
card: redactClaimToken(await store.specify(id, record, { ownerId, token: record.token })),
});
},
},
{
name: "workboard_decompose",
label: "Workboard Decompose",
description:
"Fan out a Workboard card into linked child cards and optionally complete the parent orchestration card.",
parameters: Type.Object(
{
id: Type.String({ description: "Parent Workboard card id." }),
token: Type.Optional(Type.String({ description: "Claim token for claimed cards." })),
summary: Type.Optional(Type.String({ description: "Decomposition summary." })),
completeParent: Type.Optional(
Type.Boolean({
description: "Complete the parent after child creation. Default true.",
}),
),
children: Type.Array(
Type.Object(
{
title: Type.String({ description: "Child title." }),
notes: Type.Optional(Type.String({ description: "Child notes." })),
agentId: Type.Optional(Type.String({ description: "Assigned agent id." })),
priority: Type.Optional(
Type.String({ description: "low, normal, high, or urgent." }),
),
labels: Type.Optional(Type.Array(Type.String())),
boardId: Type.Optional(Type.String()),
tenant: Type.Optional(Type.String()),
skills: Type.Optional(Type.Array(Type.String())),
workspace: Type.Optional(
Type.Object(
{
kind: Type.String({ description: "scratch, dir, or worktree." }),
path: Type.Optional(
Type.String({ description: "Absolute dir/worktree path." }),
),
branch: Type.Optional(Type.String({ description: "Suggested branch." })),
},
{ additionalProperties: false },
),
),
maxRuntimeSeconds: Type.Optional(Type.Number()),
maxRetries: Type.Optional(Type.Number()),
idempotencyKey: Type.Optional(Type.String()),
},
{ additionalProperties: false },
),
),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const record = rawParams as Record<string, unknown>;
const id = readStringParam(record, "id", { required: true });
await requireScopedCard(store, id, ownerId, record.token as string | undefined);
const result = await store.decompose(id, record, { ownerId, token: record.token });
return jsonResult({
parent: redactClaimToken(result.parent),
children: result.children.map(redactClaimToken),
});
},
},
{
name: "workboard_notify_subscribe",
label: "Workboard Notify Subscribe",
description: "Persist a Workboard notification subscription in the plugin SQLite store.",
parameters: Type.Object(
{
boardId: Type.Optional(Type.String({ description: "Board id. Default default." })),
cardId: Type.Optional(Type.String({ description: "Card id." })),
sessionKey: Type.Optional(Type.String({ description: "Session key." })),
runId: Type.Optional(Type.String({ description: "Run id." })),
target: Type.Optional(Type.String({ description: "Human-readable target." })),
eventKinds: Type.Optional(
Type.Array(Type.String(), { description: "completed, failed, stale." }),
),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) =>
jsonResult({
subscription: await store.subscribeNotifications(rawParams as Record<string, unknown>),
}),
},
{
name: "workboard_notify_list",
label: "Workboard Notify List",
description: "List persisted Workboard notification subscriptions.",
parameters: Type.Object(
{
boardId: Type.Optional(Type.String({ description: "Board id." })),
cardId: Type.Optional(Type.String({ description: "Card id." })),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) =>
jsonResult(await store.listNotificationSubscriptions(rawParams as Record<string, unknown>)),
},
{
name: "workboard_notify_events",
label: "Workboard Notify Events",
description: "Read replay-safe Workboard notification events without advancing cursors.",
parameters: Type.Object(
{
subscriptionId: Type.Optional(Type.String({ description: "Subscription id." })),
boardId: Type.Optional(Type.String({ description: "Board id." })),
cardId: Type.Optional(Type.String({ description: "Card id." })),
limit: Type.Optional(Type.Number({ description: "Maximum events. Default 50." })),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) =>
jsonResult(await store.notificationEvents(rawParams as Record<string, unknown>)),
},
{
name: "workboard_notify_advance",
label: "Workboard Notify Advance",
description: "Read Workboard notification events and advance the subscription cursor.",
parameters: Type.Object(
{
subscriptionId: Type.String({ description: "Subscription id." }),
limit: Type.Optional(Type.Number({ description: "Maximum events. Default 50." })),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) =>
jsonResult(await store.advanceNotificationEvents(rawParams as Record<string, unknown>)),
},
{
name: "workboard_notify_unsubscribe",
label: "Workboard Notify Unsubscribe",
description: "Delete a persisted Workboard notification subscription.",
parameters: Type.Object(
{ id: Type.String({ description: "Subscription id." }) },
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const id = readStringParam(rawParams as Record<string, unknown>, "id", { required: true });
return jsonResult(await store.deleteNotificationSubscription(id));
},
},
{
name: "workboard_promote",
label: "Workboard Promote",
description:
"Promote a dependency-ready card into ready, optionally forcing past holds for operator recovery.",
parameters: Type.Object(
{
id: cardIdField(),
token: ScopedClaimTokenField,
force: Type.Optional(
Type.Boolean({ description: "Bypass dependency or schedule holds." }),
),
reason: OptionalOperatorNoteField,
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
return runScopedCardMutation(rawParams, (id, record, scope) =>
store.promote(id, record, scope),
);
},
},
{
name: "workboard_reassign",
label: "Workboard Reassign",
description: "Change a card assignee and optionally reset failure state during recovery.",
parameters: Type.Object(
{
id: cardIdField(),
token: ScopedClaimTokenField,
agentId: Type.Optional(Type.String({ description: "New assignee id." })),
status: OptionalNextStatusField,
resetFailures: Type.Optional(Type.Boolean({ description: "Reset failure count." })),
reason: OptionalOperatorNoteField,
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
return runScopedCardMutation(rawParams, (id, record, scope) =>
store.reassign(id, record, scope),
);
},
},
{
name: "workboard_reclaim",
label: "Workboard Reclaim",
description:
"Release a stale claim and stop running attempts so another agent can pick it up.",
parameters: Type.Object(
{
id: cardIdField(),
token: ScopedClaimTokenField,
status: OptionalNextStatusField,
reason: OptionalOperatorNoteField,
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
return runScopedCardMutation(rawParams, (id, record, scope) =>
store.reclaim(id, record, scope),
);
},
},
{
name: "workboard_dispatch",
label: "Workboard Dispatch",
description:
"Nudge Workboard dependency promotion and reclaim expired claims or timed-out runs.",
parameters: Type.Object({}, { additionalProperties: false }),
execute: async () => {
const result = await store.dispatch();
return jsonResult({
...result,
promoted: result.promoted.map(redactClaimToken),
reclaimed: result.reclaimed.map(redactClaimToken),
blocked: result.blocked.map(redactClaimToken),
orchestrated: result.orchestrated.map(redactClaimToken),
});
},
},
{
name: "workboard_worker_log",
label: "Workboard Worker Log",
description: "Append a persisted worker log entry to a Workboard card.",
parameters: Type.Object(
{
id: cardIdField(),
level: Type.Optional(Type.String({ description: "info, warning, or error." })),
message: Type.String({ description: "Worker log message." }),
sessionKey: Type.Optional(Type.String({ description: "Linked session key." })),
runId: Type.Optional(Type.String({ description: "Linked run id." })),
token: ScopedClaimTokenField,
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const { record, id, scope } = await readScopedCardToolParams(rawParams);
return redactedCardResult(await store.addWorkerLog(id, record, scope));
},
},
{
name: "workboard_protocol_violation",
label: "Workboard Protocol Violation",
description:
"Block a card and record a worker protocol violation when work stops without complete/block.",
parameters: Type.Object(
{
id: cardIdField(),
detail: Type.Optional(Type.String({ description: "Violation detail." })),
sessionKey: Type.Optional(Type.String({ description: "Linked session key." })),
runId: Type.Optional(Type.String({ description: "Linked run id." })),
token: ScopedClaimTokenField,
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const { record, id, scope } = await readClaimedCardToolParams(rawParams);
return redactedCardResult(await store.recordProtocolViolation(id, record, scope));
},
},
];
}