feat(workboard): add board ops recovery metadata

Add board-scoped Workboard metadata, stats, and recovery operations.\n\nIncludes gateway/tool contracts, docs, UI normalization, and regression coverage for board-scoped idempotency, linked child manifests, recovery diagnostics, and worker context.
This commit is contained in:
Peter Steinberger
2026-05-30 08:43:58 +02:00
committed by GitHub
parent 0915b72bcf
commit f61a5bc797
10 changed files with 695 additions and 19 deletions

View File

@@ -89,11 +89,13 @@ and rolling failure count so repeated failures remain visible on the board.
Workboard also exposes optional agent tools for board-aware workflows:
- `workboard_list` lists compact cards with claim and diagnostic state.
- `workboard_list` lists compact cards with claim and diagnostic state, with an
optional board filter.
- `workboard_read` returns one card plus bounded worker context built from notes,
attempts, comments, links, proof, artifacts, and active diagnostics.
attempts, comments, links, proof, artifacts, parent results, recent assignee
work, and active diagnostics.
- `workboard_create` creates a card with optional parents, tenant, skills,
workspace metadata, idempotency key, runtime limit, and retry budget.
board, workspace metadata, idempotency key, runtime limit, and retry budget.
- `workboard_link` links a parent card to a child card. Children stay in `todo`
until every parent reaches `done`; then dispatch promotion moves them to
`ready`.
@@ -104,11 +106,14 @@ Workboard also exposes optional agent tools for board-aware workflows:
can move the card to a next status.
- `workboard_complete` and `workboard_block` are structured lifecycle tools for
final summaries, proof, artifacts, created-card manifests, and blocker
reasons.
- `workboard_comment`, `workboard_proof`, `workboard_unblock`, and
`workboard_dispatch` let an agent add handoff notes, attach proof or artifact
references, move blocked work back to `todo`, and nudge dependency promotion or
stale-claim cleanup.
reasons. Created-card manifests must reference cards linked back to the
completed card, which keeps phantom children out of summaries.
- `workboard_boards`, `workboard_stats`, `workboard_promote`,
`workboard_reassign`, `workboard_reclaim`, `workboard_comment`,
`workboard_proof`, `workboard_unblock`, and `workboard_dispatch` let an agent
inspect board namespaces, view queue stats, recover stuck work, add handoff
notes, attach proof or artifact references, move blocked work back to `todo`,
and nudge dependency promotion or stale-claim cleanup.
Claimed cards reject agent-tool mutations from other agents unless the caller
has the claim token returned by `workboard_claim`. Dashboard operators still use

View File

@@ -22,6 +22,11 @@ export default definePluginEntry({
"workboard_heartbeat",
"workboard_complete",
"workboard_block",
"workboard_boards",
"workboard_stats",
"workboard_promote",
"workboard_reassign",
"workboard_reclaim",
"workboard_dispatch",
"workboard_release",
"workboard_comment",

View File

@@ -16,6 +16,11 @@
"workboard_heartbeat",
"workboard_complete",
"workboard_block",
"workboard_boards",
"workboard_stats",
"workboard_promote",
"workboard_reassign",
"workboard_reclaim",
"workboard_dispatch",
"workboard_release",
"workboard_comment",
@@ -48,6 +53,21 @@
"workboard_block": {
"optional": true
},
"workboard_boards": {
"optional": true
},
"workboard_stats": {
"optional": true
},
"workboard_promote": {
"optional": true
},
"workboard_reassign": {
"optional": true
},
"workboard_reclaim": {
"optional": true
},
"workboard_dispatch": {
"optional": true
},

View File

@@ -57,6 +57,9 @@ describe("workboard gateway methods", () => {
"workboard.cards.claim",
"workboard.cards.heartbeat",
"workboard.cards.release",
"workboard.cards.promote",
"workboard.cards.reassign",
"workboard.cards.reclaim",
"workboard.cards.complete",
"workboard.cards.block",
"workboard.cards.unblock",
@@ -64,6 +67,8 @@ describe("workboard gateway methods", () => {
"workboard.cards.diagnostics",
"workboard.cards.diagnostics.refresh",
"workboard.cards.dispatch",
"workboard.boards.list",
"workboard.cards.stats",
"workboard.cards.archive",
"workboard.cards.export",
]);

View File

@@ -71,10 +71,10 @@ export function registerWorkboardGatewayMethods(params: {
api.registerGatewayMethod(
"workboard.cards.list",
async ({ respond }) => {
async ({ params: requestParams, respond }) => {
try {
respond(true, {
cards: (await store.list()).map(redactClaimToken),
cards: (await store.list({ boardId: requestParams.boardId })).map(redactClaimToken),
statuses: WORKBOARD_STATUSES,
});
} catch (error) {
@@ -256,6 +256,48 @@ export function registerWorkboardGatewayMethods(params: {
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.promote",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(await store.promote(readId(requestParams), requestParams, null)),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.reassign",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(await store.reassign(readId(requestParams), requestParams, null)),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.reclaim",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(await store.reclaim(readId(requestParams), requestParams, null)),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.complete",
async ({ params: requestParams, respond }) => {
@@ -353,6 +395,30 @@ export function registerWorkboardGatewayMethods(params: {
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.boards.list",
async ({ respond }) => {
try {
respond(true, await store.listBoards());
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.stats",
async ({ params: requestParams, respond }) => {
try {
respond(true, await store.stats({ boardId: requestParams.boardId }));
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.archive",
async ({ params: requestParams, respond }) => {

View File

@@ -871,7 +871,7 @@ describe("WorkboardStore", () => {
updatedAt: 1_000,
},
});
const child = await store.create({ title: "Follow-up" });
const child = await store.create({ title: "Follow-up", parents: [card.id] });
const claimed = await store.claim(card.id, { ownerId: "main", token: "token-1" });
const completed = await store.complete(claimed.card.id, {
@@ -1323,6 +1323,164 @@ describe("WorkboardStore", () => {
await expect(store.buildWorkerContext(card.id)).resolves.toContain("Failure screenshot");
});
it("scopes idempotent creates and stats by board", async () => {
const store = new WorkboardStore(createMemoryStore());
const ops = await store.create({
title: "Ops work",
boardId: "ops",
idempotencyKey: "same",
});
const product = await store.create({
title: "Product work",
boardId: "product",
idempotencyKey: "same",
});
const repeatedOps = await store.create({
title: "Duplicate ops",
boardId: "ops",
idempotencyKey: "same",
});
expect(repeatedOps.id).toBe(ops.id);
expect(product.id).not.toBe(ops.id);
await expect(store.list({ boardId: "ops" })).resolves.toHaveLength(1);
await expect(store.listBoards()).resolves.toMatchObject({
boards: expect.arrayContaining([
expect.objectContaining({ id: "ops", total: 1 }),
expect.objectContaining({ id: "product", total: 1 }),
]),
});
await expect(store.stats({ boardId: "product" })).resolves.toMatchObject({
id: "product",
total: 1,
byStatus: { todo: 1 },
});
const prototypeAgentId = ["__", "proto__"].join("");
await store.create({
title: "Prototype safe",
boardId: "product",
agentId: prototypeAgentId,
});
const stats = await store.stats({ boardId: "product" });
expect(stats.byAgent[prototypeAgentId]).toBe(1);
});
it("rejects completed manifests for cards not created from the parent", async () => {
const store = new WorkboardStore(createMemoryStore());
const parent = await store.create({ title: "Parent", status: "running" });
const unrelated = await store.create({ title: "Unrelated" });
await expect(
store.complete(parent.id, { createdCardIds: [unrelated.id] }, null),
).rejects.toThrow(/not linked/);
const spoofed = await store.create({
title: "Spoofed",
createdByCardId: parent.id,
});
await expect(store.complete(parent.id, { createdCardIds: [spoofed.id] }, null)).rejects.toThrow(
/not linked/,
);
const child = await store.create({ title: "Child", parents: [parent.id] });
await expect(
store.complete(parent.id, { createdCardIds: [child.id], summary: "done" }, null),
).resolves.toMatchObject({
status: "done",
metadata: { automation: { createdCardIds: [child.id] } },
});
});
it("promotes, reassigns, and reclaims cards for operator recovery", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Recover me",
status: "blocked",
agentId: "old-agent",
metadata: { failureCount: 2 },
});
await store.refreshDiagnostics(Date.now() + 2 * 24 * 60 * 60 * 1000);
const reassigned = await store.reassign(card.id, {
agentId: "new-agent",
status: "todo",
reason: "route to fresh agent",
});
expect(reassigned).toMatchObject({
agentId: "new-agent",
status: "todo",
});
expect(reassigned.metadata?.failureCount).toBeUndefined();
expect(reassigned.metadata?.diagnostics?.map((entry) => entry.kind) ?? []).not.toContain(
"repeated_failures",
);
await expect(store.promote(card.id)).resolves.toMatchObject({ status: "ready" });
const claimed = await store.claim(card.id, { ownerId: "new-agent" });
const reclaimed = await store.reclaim(claimed.card.id, { reason: "stale session" }, null);
expect(reclaimed).toMatchObject({ status: "ready" });
expect(reclaimed.metadata?.claim).toBeUndefined();
const running = await store.create({
title: "Running recovery",
status: "running",
execution: {
id: "exec-reclaim",
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "running",
model: "openai/gpt-5.5",
startedAt: 100,
updatedAt: 100,
},
});
const stopped = await store.reclaim(running.id, { reason: "replace worker" }, null);
expect(stopped.execution).toBeUndefined();
expect(stopped.metadata?.attempts).toEqual([expect.objectContaining({ status: "stopped" })]);
expect(stopped.metadata?.failureCount).toBeUndefined();
});
it("includes parent results and recent assignee work in worker context", async () => {
const store = new WorkboardStore(createMemoryStore());
const parent = await store.create({
title: "Design",
status: "running",
agentId: "agent-a",
});
await store.complete(parent.id, { summary: "Use board-scoped queues." }, null);
await store.create({
title: "Older task",
status: "done",
agentId: "agent-a",
metadata: { automation: { summary: "Finished related cleanup." } },
});
const child = await store.create({
title: "Implement",
agentId: "agent-a",
parents: [parent.id],
});
const context = await store.buildWorkerContext(child.id);
expect(context).toContain("## Parent results");
expect(context).toContain("Use board-scoped queues.");
expect(context).toContain("## Recent done work by agent-a");
expect(context).toContain("Finished related cleanup.");
const crossBoardChild = await store.create({
title: "Cross-board child",
boardId: "product",
parents: [parent.id],
});
await expect(store.buildWorkerContext(crossBoardChild.id)).resolves.toContain(
"Use board-scoped queues.",
);
});
it("rejects invalid status values", async () => {
const store = new WorkboardStore(createMemoryStore());
await expect(store.create({ title: "Bad card", status: "later" })).rejects.toThrow(

View File

@@ -88,6 +88,8 @@ export type WorkboardCardInput = {
templateId?: unknown;
position?: unknown;
tenant?: unknown;
boardId?: unknown;
createdByCardId?: unknown;
idempotencyKey?: unknown;
skills?: unknown;
workspace?: unknown;
@@ -157,6 +159,35 @@ export type WorkboardDispatchResult = {
blocked: WorkboardCard[];
count: number;
};
export type WorkboardListOptions = {
boardId?: unknown;
};
export type WorkboardBoardSummary = {
id: string;
total: number;
active: number;
archived: number;
byStatus: Partial<Record<WorkboardStatus, number>>;
updatedAt?: number;
};
export type WorkboardStatsResult = WorkboardBoardSummary & {
byAgent: Record<string, number>;
oldestReadyAgeMs?: number;
};
export type WorkboardPromoteInput = {
force?: unknown;
reason?: unknown;
};
export type WorkboardReassignInput = {
agentId?: unknown;
status?: unknown;
resetFailures?: unknown;
reason?: unknown;
};
export type WorkboardReclaimInput = {
status?: unknown;
reason?: unknown;
};
export type WorkboardMutationScope = {
ownerId?: unknown;
token?: unknown;
@@ -174,6 +205,20 @@ function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function normalizeBoardId(value: unknown, fallback?: string): string | undefined {
const raw = normalizeBoundedString(value, fallback, 80, "board id");
if (!raw) {
return undefined;
}
const boardId = raw.toLowerCase();
if (!/^[a-z0-9][a-z0-9._-]{0,79}$/.test(boardId)) {
throw new Error(
"board id must start with a letter or number and use letters, numbers, dots, dashes, or underscores.",
);
}
return boardId;
}
function normalizeTitle(value: unknown): string {
const title = normalizeOptionalString(value);
if (!title) {
@@ -342,6 +387,15 @@ function normalizeAutomation(
}
const record = value as Record<string, unknown>;
const tenant = normalizeBoundedString(record.tenant, fallback.tenant, 80, "tenant");
const boardId = Object.hasOwn(record, "boardId")
? normalizeBoardId(record.boardId, fallback.boardId)
: fallback.boardId;
const createdByCardId = normalizeBoundedString(
record.createdByCardId,
fallback.createdByCardId,
120,
"created by card id",
);
const idempotencyKey = normalizeBoundedString(
record.idempotencyKey,
fallback.idempotencyKey,
@@ -375,6 +429,8 @@ function normalizeAutomation(
: fallback.workspace;
const next = removeUndefinedAutomationFields({
...(tenant ? { tenant } : {}),
...(boardId ? { boardId } : {}),
...(createdByCardId ? { createdByCardId } : {}),
...(idempotencyKey ? { idempotencyKey } : {}),
...(skills?.length ? { skills } : {}),
...(workspace ? { workspace } : {}),
@@ -931,6 +987,8 @@ function removeUndefinedAutomationFields(automation: WorkboardAutomation): Workb
const next = { ...automation };
for (const key of [
"tenant",
"boardId",
"createdByCardId",
"idempotencyKey",
"skills",
"workspace",
@@ -1479,12 +1537,25 @@ function capText(value: string | undefined, max: number): string | undefined {
return value.length <= max ? value : `${value.slice(0, Math.max(0, max - 1))}`;
}
function buildWorkerContext(card: WorkboardCard): string {
function cardBoardId(card: WorkboardCard): string {
return card.metadata?.automation?.boardId ?? "default";
}
function cardResultSummary(card: WorkboardCard): string | undefined {
return (
card.metadata?.automation?.summary ??
card.metadata?.comments?.findLast((comment) => comment.body.trim())?.body ??
card.metadata?.proof?.findLast((proof) => proof.note?.trim())?.note
);
}
function buildWorkerContext(card: WorkboardCard, cards: readonly WorkboardCard[] = []): string {
const lines = [
`# Workboard card ${card.id}`,
`Title: ${card.title}`,
`Status: ${card.status}`,
`Priority: ${card.priority}`,
`Board: ${cardBoardId(card)}`,
`Agent: ${card.agentId ?? "(default)"}`,
];
if (card.notes) {
@@ -1529,12 +1600,49 @@ function buildWorkerContext(card: WorkboardCard): string {
lines.push(`- ${link.type}: ${link.title ?? link.url ?? link.targetCardId ?? ""}`);
}
}
const cardsById = new Map(cards.map((entry) => [entry.id, entry]));
const parentResults = cardParentIds(card)
.map((parentId) => cardsById.get(parentId))
.filter((parent): parent is WorkboardCard => parent !== undefined && parent.status === "done")
.slice(-6);
if (parentResults.length) {
lines.push("", "## Parent results");
for (const parent of parentResults) {
lines.push(
`- ${parent.id} ${parent.title}: ${capText(cardResultSummary(parent), 500) ?? "done"}`,
);
}
}
const recentAgentWork =
card.agentId && cards.length
? cards
.filter(
(entry) =>
entry.id !== card.id &&
cardBoardId(entry) === cardBoardId(card) &&
entry.agentId === card.agentId &&
entry.status === "done",
)
.toSorted((a, b) => b.updatedAt - a.updatedAt)
.slice(0, 5)
: [];
if (recentAgentWork.length) {
lines.push("", `## Recent done work by ${card.agentId}`);
for (const entry of recentAgentWork) {
lines.push(
`- ${entry.id} ${entry.title}: ${capText(cardResultSummary(entry), 300) ?? "done"}`,
);
}
}
const automation = card.metadata?.automation;
if (automation) {
lines.push("", "## Automation");
if (automation.tenant) {
lines.push(`Tenant: ${automation.tenant}`);
}
if (automation.boardId) {
lines.push(`Board: ${automation.boardId}`);
}
if (automation.skills?.length) {
lines.push(`Skills: ${automation.skills.join(", ")}`);
}
@@ -1564,6 +1672,13 @@ function cardParentIds(card: WorkboardCard): string[] {
.filter((id, index, ids) => ids.indexOf(id) === index);
}
function cardChildIds(card: WorkboardCard): string[] {
return (card.metadata?.links ?? [])
.filter((link) => link.type === "child" && link.targetCardId)
.map((link) => link.targetCardId!)
.filter((id, index, ids) => ids.indexOf(id) === index);
}
function latestRunningAttempt(card: WorkboardCard): WorkboardRunAttempt | undefined {
return card.metadata?.attempts?.findLast((attempt) => attempt.status === "running");
}
@@ -1633,7 +1748,8 @@ export class WorkboardStore {
});
}
async list(): Promise<WorkboardCard[]> {
async list(options: WorkboardListOptions = {}): Promise<WorkboardCard[]> {
const boardId = normalizeBoardId(options.boardId);
const entries = await this.store.entries();
return entries
.map((entry) => entry.value)
@@ -1641,9 +1757,67 @@ export class WorkboardStore {
(entry): entry is PersistedWorkboardCard => entry?.version === 1 && Boolean(entry.card?.id),
)
.map((entry) => entry.card)
.filter((card) => !boardId || cardBoardId(card) === boardId)
.toSorted(compareCards);
}
async listBoards(): Promise<{ boards: WorkboardBoardSummary[] }> {
const boards = new Map<string, WorkboardBoardSummary>();
for (const card of await this.list()) {
const boardId = cardBoardId(card);
const summary =
boards.get(boardId) ??
({
id: boardId,
total: 0,
active: 0,
archived: 0,
byStatus: {},
} satisfies WorkboardBoardSummary);
summary.total += 1;
if (card.metadata?.archivedAt) {
summary.archived += 1;
} else {
summary.active += 1;
}
summary.byStatus[card.status] = (summary.byStatus[card.status] ?? 0) + 1;
summary.updatedAt = Math.max(summary.updatedAt ?? 0, card.updatedAt);
boards.set(boardId, summary);
}
return { boards: [...boards.values()].toSorted((a, b) => a.id.localeCompare(b.id)) };
}
async stats(input: WorkboardListOptions = {}, now = Date.now()): Promise<WorkboardStatsResult> {
const cards = await this.list(input);
const boardId = normalizeBoardId(input.boardId) ?? "all";
const byStatus: Partial<Record<WorkboardStatus, number>> = {};
const byAgent = Object.create(null) as Record<string, number>;
let oldestReadyAt: number | undefined;
let updatedAt: number | undefined;
let archived = 0;
for (const card of cards) {
byStatus[card.status] = (byStatus[card.status] ?? 0) + 1;
byAgent[card.agentId ?? "(default)"] = (byAgent[card.agentId ?? "(default)"] ?? 0) + 1;
if (card.metadata?.archivedAt) {
archived += 1;
}
if (card.status === "ready") {
oldestReadyAt = Math.min(oldestReadyAt ?? card.updatedAt, card.updatedAt);
}
updatedAt = Math.max(updatedAt ?? 0, card.updatedAt);
}
return {
id: boardId,
total: cards.length,
active: cards.length - archived,
archived,
byStatus,
byAgent,
...(oldestReadyAt ? { oldestReadyAgeMs: Math.max(0, now - oldestReadyAt) } : {}),
...(updatedAt ? { updatedAt } : {}),
};
}
async get(id: string): Promise<WorkboardCard | undefined> {
const entry = await this.store.lookup(id.trim());
return entry?.version === 1 ? entry.card : undefined;
@@ -1675,6 +1849,8 @@ export class WorkboardStore {
const parents = normalizeStringList(input.parents, "parents", 120);
const automation = normalizeAutomation({
tenant: input.tenant,
boardId: input.boardId,
createdByCardId: input.createdByCardId,
idempotencyKey: input.idempotencyKey,
skills: input.skills,
workspace: input.workspace,
@@ -1695,7 +1871,8 @@ export class WorkboardStore {
const existing = cards.find(
(card) =>
card.metadata?.automation?.idempotencyKey === automation.idempotencyKey &&
card.metadata?.automation?.tenant === automation.tenant,
card.metadata?.automation?.tenant === automation.tenant &&
cardBoardId(card) === (automation.boardId ?? "default"),
);
if (existing) {
return existing;
@@ -1709,6 +1886,14 @@ export class WorkboardStore {
}
return parent;
});
const childAutomation = normalizeAutomation(
{
...automation,
createdByCardId:
automation?.createdByCardId ?? (parents.length === 1 ? parents[0] : undefined),
},
automation,
);
const normalizedPosition = normalizePosition(input.position, Number.NaN);
const position = Number.isFinite(normalizedPosition)
? normalizedPosition
@@ -1743,7 +1928,7 @@ export class WorkboardStore {
input.metadata,
{
templateId: normalizeTemplateId(input.templateId),
...(automation ? { automation } : {}),
...(childAutomation ? { automation: childAutomation } : {}),
},
{ allowDependencyLinks: false },
);
@@ -1846,6 +2031,8 @@ export class WorkboardStore {
const automationPatch: Record<string, unknown> = {};
for (const key of [
"tenant",
"boardId",
"createdByCardId",
"idempotencyKey",
"skills",
"workspace",
@@ -2403,10 +2590,17 @@ export class WorkboardStore {
assertCanMutateClaimedCard(existing, scope === null ? undefined : scope);
const now = Date.now();
const createdCardIds = normalizeStringList(input.createdCardIds, "created card ids", 120);
const childIds = cardChildIds(existing);
for (const createdCardId of createdCardIds) {
if (!(await this.get(createdCardId))) {
const createdCard = await this.get(createdCardId);
if (!createdCard) {
throw new Error(`created card not found: ${createdCardId}`);
}
const linkedFromParent =
childIds.includes(createdCardId) && cardParentIds(createdCard).includes(existing.id);
if (!linkedFromParent) {
throw new Error(`created card is not linked to this card: ${createdCardId}`);
}
}
const summary = normalizeBoundedString(input.summary, undefined, 2000, "summary");
const proofInput =
@@ -2533,6 +2727,118 @@ export class WorkboardStore {
});
}
async promote(
id: string,
input: WorkboardPromoteInput = {},
scope?: WorkboardMutationScope | null,
): Promise<WorkboardCard> {
return await this.enqueueMutation(async () => {
const existing = await this.get(id);
if (!existing) {
throw new Error(`card not found: ${id}`);
}
assertCanMutateClaimedCard(existing, scope === null ? undefined : scope);
const reason = normalizeBoundedString(input.reason, undefined, 1000, "promote reason");
const comments = reason
? [
...(existing.metadata?.comments ?? []),
{ id: randomUUID(), body: reason, createdAt: Date.now() },
].slice(-MAX_CARD_COMMENTS)
: existing.metadata?.comments;
return await this.updateCard(
id,
{
status: "ready",
metadata: {
...clearDiagnostics(existing.metadata, ["stranded_ready", "blocked_too_long"]),
comments,
stale: null,
},
},
{ enforceStatusHolds: input.force !== true },
);
});
}
async reassign(
id: string,
input: WorkboardReassignInput = {},
scope?: WorkboardMutationScope | null,
): Promise<WorkboardCard> {
return await this.enqueueMutation(async () => {
const existing = await this.get(id);
if (!existing) {
throw new Error(`card not found: ${id}`);
}
assertCanMutateClaimedCard(existing, scope === null ? undefined : scope);
const agentId =
input.agentId === undefined ? existing.agentId : normalizeOptionalString(input.agentId);
const status =
input.status === undefined
? existing.status
: normalizeStatus(input.status, existing.status);
const reason = normalizeBoundedString(input.reason, undefined, 1000, "reassign reason");
const shouldResetFailures = input.resetFailures !== false;
const baseMetadata = shouldResetFailures
? clearDiagnostics(existing.metadata, ["blocked_too_long", "repeated_failures"])
: existing.metadata;
const metadata = {
...baseMetadata,
...(shouldResetFailures ? { failureCount: 0 } : {}),
comments: reason
? [
...(baseMetadata?.comments ?? []),
{ id: randomUUID(), body: reason, createdAt: Date.now() },
].slice(-MAX_CARD_COMMENTS)
: baseMetadata?.comments,
};
return await this.updateCard(id, { agentId, status, metadata }, { enforceStatusHolds: true });
});
}
async reclaim(
id: string,
input: WorkboardReclaimInput = {},
scope?: WorkboardMutationScope | null,
): Promise<WorkboardCard> {
return await this.enqueueMutation(async () => {
const existing = await this.get(id);
if (!existing) {
throw new Error(`card not found: ${id}`);
}
assertCanMutateClaimedCard(existing, scope === null ? undefined : scope);
const now = Date.now();
const reason =
normalizeBoundedString(input.reason, undefined, 1000, "reclaim reason") ??
"Workboard claim reclaimed.";
const targetStatus =
input.status === undefined
? existing.status === "running"
? "ready"
: existing.status
: normalizeStatus(input.status, existing.status);
const reclaimed = await this.updateCard(
id,
{
status: targetStatus,
execution: existing.execution?.status === "running" ? null : existing.execution,
metadata: {
...existing.metadata,
claim: undefined,
attempts: closeRunningAttempts(existing.metadata?.attempts, now, "stopped", reason),
comments: [
...(existing.metadata?.comments ?? []),
{ id: randomUUID(), body: reason, createdAt: now },
].slice(-MAX_CARD_COMMENTS),
stale: null,
},
},
{ enforceStatusHolds: true },
);
return await this.promoteDependencyReady(reclaimed.id, now);
});
}
async dispatch(now = Date.now()): Promise<WorkboardDispatchResult> {
return await this.enqueueMutation(async () => {
const promoted: WorkboardCard[] = [];
@@ -2706,7 +3012,7 @@ export class WorkboardStore {
if (!card) {
throw new Error(`card not found: ${id}`);
}
return buildWorkerContext(card);
return buildWorkerContext(card, await this.list());
}
static open(

View File

@@ -89,6 +89,7 @@ function summarizeCard(card: WorkboardCard) {
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),
@@ -156,6 +157,7 @@ export function createWorkboardTools(params: {
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." }),
),
@@ -176,11 +178,12 @@ export function createWorkboardTools(params: {
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())
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)
@@ -208,6 +211,10 @@ export function createWorkboardTools(params: {
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(
@@ -525,6 +532,102 @@ export function createWorkboardTools(params: {
);
},
},
{
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_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_promote",
label: "Workboard Promote",
description:
"Promote a dependency-ready card into ready, optionally forcing past holds for operator recovery.",
parameters: Type.Object(
{
id: Type.String({ description: "Workboard card id." }),
token: Type.Optional(Type.String({ description: "Claim token for claimed cards." })),
force: Type.Optional(
Type.Boolean({ description: "Bypass dependency or schedule holds." }),
),
reason: Type.Optional(Type.String({ description: "Optional operator note." })),
},
{ 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.promote(id, record, { ownerId, token: record.token })),
});
},
},
{
name: "workboard_reassign",
label: "Workboard Reassign",
description: "Change a card assignee and optionally reset failure state during recovery.",
parameters: Type.Object(
{
id: Type.String({ description: "Workboard card id." }),
token: Type.Optional(Type.String({ description: "Claim token for claimed cards." })),
agentId: Type.Optional(Type.String({ description: "New assignee id." })),
status: Type.Optional(Type.String({ description: "Optional next status." })),
resetFailures: Type.Optional(Type.Boolean({ description: "Reset failure count." })),
reason: Type.Optional(Type.String({ description: "Optional operator note." })),
},
{ 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.reassign(id, record, { ownerId, token: record.token }),
),
});
},
},
{
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: Type.String({ description: "Workboard card id." }),
token: Type.Optional(Type.String({ description: "Claim token for claimed cards." })),
status: Type.Optional(Type.String({ description: "Optional next status." })),
reason: Type.Optional(Type.String({ description: "Optional operator note." })),
},
{ 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.reclaim(id, record, { ownerId, token: record.token })),
});
},
},
{
name: "workboard_dispatch",
label: "Workboard Dispatch",

View File

@@ -168,7 +168,7 @@ export type WorkboardClaim = {
};
export type WorkboardDiagnosticAction = {
kind: "claim" | "unblock" | "reassign" | "add_proof" | "open_session";
kind: "claim" | "unblock" | "promote" | "reclaim" | "reassign" | "add_proof" | "open_session";
label: string;
};
@@ -200,6 +200,8 @@ export type WorkboardWorkspace = {
export type WorkboardAutomation = {
tenant?: string;
boardId?: string;
createdByCardId?: string;
idempotencyKey?: string;
skills?: string[];
workspace?: WorkboardWorkspace;

View File

@@ -191,6 +191,8 @@ export type WorkboardWorkspace = {
export type WorkboardAutomation = {
tenant?: string;
boardId?: string;
createdByCardId?: string;
idempotencyKey?: string;
skills?: string[];
workspace?: WorkboardWorkspace;
@@ -448,6 +450,10 @@ function normalizeAutomation(value: unknown): WorkboardAutomation | undefined {
: undefined;
const automation: WorkboardAutomation = {
...(typeof value.tenant === "string" ? { tenant: value.tenant } : {}),
...(typeof value.boardId === "string" ? { boardId: value.boardId } : {}),
...(typeof value.createdByCardId === "string"
? { createdByCardId: value.createdByCardId }
: {}),
...(typeof value.idempotencyKey === "string" ? { idempotencyKey: value.idempotencyKey } : {}),
...(normalizeStringArray(value.skills).length
? { skills: normalizeStringArray(value.skills) }