mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
committed by
GitHub
parent
0915b72bcf
commit
f61a5bc797
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user