feat(workboard): add orchestration primitives

Adds Workboard orchestration statuses, dependency links, idempotent child creation, dispatch, and complete/block lifecycle operations backed by the plugin SQLite keyed store.

Persists tenant, skills, workspace, schedule, runtime, retry, dispatch, and handoff metadata in card records, with claim scoping and token redaction. Surfaces the new states and metadata in the Control UI, horizontal board layout, localized strings, and Workboard docs.

Verification:
- pnpm test extensions/workboard/src/store.test.ts extensions/workboard/src/tools.test.ts extensions/workboard/src/gateway.test.ts ui/src/ui/controllers/workboard.test.ts ui/src/styles/workboard.test.ts ui/src/ui/views/workboard.test.ts -- --reporter=verbose
- pnpm ui:i18n:check
- /Users/steipete/Projects/agent-scripts/skills/autoreview/scripts/autoreview --mode branch --base origin/main, followed by focused clean local autoreview loops for final fixes
- env -u OPENCLAW_TESTBOX pnpm check:changed
- git diff --check
This commit is contained in:
Peter Steinberger
2026-05-30 02:40:46 +02:00
committed by GitHub
parent 18f94fc83a
commit a341ae27ec
53 changed files with 3117 additions and 193 deletions
+31 -10
View File
@@ -42,14 +42,19 @@ view shows a plugin-unavailable state instead of local card data.
Each card stores:
- title and notes
- status: `backlog`, `todo`, `running`, `review`, `blocked`, or `done`
- status: `triage`, `backlog`, `todo`, `scheduled`, `ready`, `running`,
`review`, `blocked`, or `done`
- priority: `low`, `normal`, `high`, or `urgent`
- labels
- optional agent id
- optional linked session, run, task, or source URL
- optional execution metadata for a Codex or Claude session started from the card
- compact metadata for attempts, comments, links, proof, artifacts, claims, diagnostics, notifications, templates, archive state, and stale-session detection
- recent card events such as created, moved, linked, claimed, heartbeat, attempt, proof, artifact, diagnostic, notification, archive, stale, or agent-updated changes
- compact metadata for attempts, comments, links, proof, artifacts, automation,
claims, diagnostics, notifications, templates, archive state, and
stale-session detection
- recent card events such as created, moved, linked, claimed, heartbeat,
attempt, proof, artifact, diagnostic, notification, dispatch, archive, stale,
or agent-updated changes
Cards are stored in the plugin's Gateway state. They are local to the Gateway
state directory and move with the rest of that Gateway's OpenClaw state.
@@ -87,14 +92,23 @@ Workboard also exposes optional agent tools for board-aware workflows:
- `workboard_list` lists compact cards with claim and diagnostic state.
- `workboard_read` returns one card plus bounded worker context built from notes,
attempts, comments, links, proof, artifacts, and active diagnostics.
- `workboard_claim` claims a card for the calling agent and moves backlog or todo
cards into `running`.
- `workboard_create` creates a card with optional parents, tenant, skills,
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`.
- `workboard_claim` claims a card for the calling agent and moves backlog, todo,
or ready cards into `running`.
- `workboard_heartbeat` refreshes the claim heartbeat during longer runs.
- `workboard_release` releases the claim after completion, pause, or handoff and
can move the card to a next status.
- `workboard_comment`, `workboard_proof`, and `workboard_unblock` let an agent
add handoff notes, attach proof or artifact references, and move blocked work
back to `todo`.
- `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.
Claimed cards reject agent-tool mutations from other agents unless the caller
has the claim token returned by `workboard_claim`. Dashboard operators still use
@@ -105,6 +119,12 @@ flag assigned cards that wait too long, running cards without recent heartbeat,
blocked cards that need attention, repeated failures, done cards without proof,
and running cards that only have a loose session link.
Dispatch is intentionally Gateway-local. It does not spawn arbitrary operating
system processes; normal OpenClaw sessions still own execution. A dispatch nudge
promotes dependency-ready cards, records dispatch metadata on ready cards, and
blocks expired claims or timed-out runs so operators can recover them from the
board.
## Session lifecycle sync
Cards can be linked to existing dashboard sessions or to the session created
@@ -163,8 +183,9 @@ The plugin registers Gateway RPC methods under the `workboard.*` namespace:
- `workboard.cards.export` requires `operator.read`
- `workboard.cards.diagnostics` requires `operator.read`
- `workboard.cards.diagnostics.refresh` requires `operator.write`
- create, update, move, delete, comment, link, proof, artifact, claim, heartbeat,
release, unblock, bulk, and archive methods require `operator.write`
- create, update, move, delete, comment, link, dependency link, proof, artifact,
claim, heartbeat, release, complete, block, unblock, dispatch, bulk, and
archive methods require `operator.write`
Browsers connected with read-only operator access can inspect the board but
cannot mutate cards.
+5
View File
@@ -15,9 +15,14 @@ export default definePluginEntry({
api.registerTool((context) => createWorkboardTools({ api, context, store }), {
names: [
"workboard_list",
"workboard_create",
"workboard_link",
"workboard_read",
"workboard_claim",
"workboard_heartbeat",
"workboard_complete",
"workboard_block",
"workboard_dispatch",
"workboard_release",
"workboard_comment",
"workboard_proof",
+20
View File
@@ -9,9 +9,14 @@
"contracts": {
"tools": [
"workboard_list",
"workboard_create",
"workboard_link",
"workboard_read",
"workboard_claim",
"workboard_heartbeat",
"workboard_complete",
"workboard_block",
"workboard_dispatch",
"workboard_release",
"workboard_comment",
"workboard_proof",
@@ -22,6 +27,12 @@
"workboard_list": {
"optional": true
},
"workboard_create": {
"optional": true
},
"workboard_link": {
"optional": true
},
"workboard_read": {
"optional": true
},
@@ -31,6 +42,15 @@
"workboard_heartbeat": {
"optional": true
},
"workboard_complete": {
"optional": true
},
"workboard_block": {
"optional": true
},
"workboard_dispatch": {
"optional": true
},
"workboard_release": {
"optional": true
},
+39
View File
@@ -51,15 +51,19 @@ describe("workboard gateway methods", () => {
"workboard.cards.delete",
"workboard.cards.comment",
"workboard.cards.link",
"workboard.cards.linkDependency",
"workboard.cards.proof",
"workboard.cards.artifact",
"workboard.cards.claim",
"workboard.cards.heartbeat",
"workboard.cards.release",
"workboard.cards.complete",
"workboard.cards.block",
"workboard.cards.unblock",
"workboard.cards.bulk",
"workboard.cards.diagnostics",
"workboard.cards.diagnostics.refresh",
"workboard.cards.dispatch",
"workboard.cards.archive",
"workboard.cards.export",
]);
@@ -221,5 +225,40 @@ describe("workboard gateway methods", () => {
expect(bulkRespond.mock.calls[0]?.[1]).toMatchObject({
cards: [expect.objectContaining({ priority: "urgent" })],
});
const completeRespond = vi.fn();
await methods.get("workboard.cards.complete")?.handler({
params: { id: cardId, summary: "Operator closed it." },
respond: completeRespond,
} as never);
expect(completeRespond.mock.calls[0]?.[1]).toMatchObject({
card: {
status: "done",
metadata: {
comments: expect.arrayContaining([
expect.objectContaining({ body: "Operator closed it." }),
]),
},
},
});
const blockedCreateRespond = vi.fn();
await methods.get("workboard.cards.create")?.handler({
params: { title: "Block me" },
respond: blockedCreateRespond,
} as never);
const blockedCardId = blockedCreateRespond.mock.calls[0]?.[1]?.card.id;
await methods.get("workboard.cards.claim")?.handler({
params: { id: blockedCardId, ownerId: "main" },
respond: vi.fn(),
} as never);
const blockRespond = vi.fn();
await methods.get("workboard.cards.block")?.handler({
params: { id: blockedCardId, reason: "Operator blocked it." },
respond: blockRespond,
} as never);
expect(blockRespond.mock.calls[0]?.[1]).toMatchObject({
card: { status: "blocked" },
});
});
});
+65
View File
@@ -168,6 +168,25 @@ export function registerWorkboardGatewayMethods(params: {
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.linkDependency",
async ({ params: requestParams, respond }) => {
try {
const parentId = requestParams.parentId;
const childId = requestParams.childId;
if (typeof parentId !== "string" || typeof childId !== "string") {
throw new Error("parentId and childId are required.");
}
respond(true, {
card: redactClaimToken(await store.linkCards(parentId, childId)),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.proof",
async ({ params: requestParams, respond }) => {
@@ -237,6 +256,34 @@ export function registerWorkboardGatewayMethods(params: {
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.complete",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(await store.complete(readId(requestParams), requestParams, null)),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.block",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: redactClaimToken(await store.block(readId(requestParams), requestParams, null)),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.unblock",
async ({ params: requestParams, respond }) => {
@@ -288,6 +335,24 @@ export function registerWorkboardGatewayMethods(params: {
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.dispatch",
async ({ respond }) => {
try {
const result = await store.dispatch();
respond(true, {
...result,
promoted: result.promoted.map(redactClaimToken),
reclaimed: result.reclaimed.map(redactClaimToken),
blocked: result.blocked.map(redactClaimToken),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.archive",
async ({ params: requestParams, respond }) => {
+682
View File
@@ -107,6 +107,26 @@ describe("WorkboardStore", () => {
});
});
it("ignores dependency links from generic metadata writes", async () => {
const store = new WorkboardStore(createMemoryStore());
const parent = await store.create({ title: "Parent" });
const child = await store.create({
title: "Child",
metadata: {
links: [{ id: "raw-parent", type: "parent", targetCardId: parent.id, createdAt: 1 }],
},
});
expect(child.metadata?.links).toBeUndefined();
const updated = await store.update(child.id, {
metadata: {
links: [{ id: "raw-parent-2", type: "parent", targetCardId: parent.id, createdAt: 2 }],
},
});
expect(updated.metadata?.links).toBeUndefined();
});
it("stores card templates and metadata in the card record", async () => {
const keyed = createMemoryStore();
const store = new WorkboardStore(keyed);
@@ -134,6 +154,47 @@ describe("WorkboardStore", () => {
});
});
it("updates automation metadata from top-level patch fields", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Tune automation" });
const updated = await store.update(card.id, {
tenant: "release",
idempotencyKey: "release:1",
skills: ["testing", "docs"],
workspace: { kind: "scratch" },
maxRuntimeSeconds: 120,
maxRetries: 2,
scheduledAt: 10_000,
});
expect(updated.metadata?.automation).toMatchObject({
tenant: "release",
idempotencyKey: "release:1",
skills: ["testing", "docs"],
workspace: { kind: "scratch" },
maxRuntimeSeconds: 120,
maxRetries: 2,
scheduledAt: 10_000,
});
const cleared = await store.update(card.id, { scheduledAt: null });
expect(cleared.metadata?.automation?.scheduledAt).toBeUndefined();
expect(cleared.metadata?.automation).toMatchObject({
tenant: "release",
maxRetries: 2,
});
const preserved = await store.update(card.id, {
scheduledAt: 20_000,
maxRuntimeSeconds: undefined,
});
expect(preserved.metadata?.automation).toMatchObject({
scheduledAt: 20_000,
maxRuntimeSeconds: 120,
});
});
it("moves cards and records lifecycle timestamps", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Ship workboard" });
@@ -150,6 +211,14 @@ describe("WorkboardStore", () => {
const done = await store.update(card.id, { status: "done" });
expect(done.completedAt).toBeGreaterThanOrEqual(done.startedAt ?? 0);
const rolledBack = await store.update(card.id, {
status: "todo",
startedAt: null,
completedAt: null,
});
expect(rolledBack.startedAt).toBeUndefined();
expect(rolledBack.completedAt).toBeUndefined();
});
it("keeps execution session links aligned with edited card links", async () => {
@@ -284,6 +353,9 @@ describe("WorkboardStore", () => {
targetCardId: "card-upstream",
});
expect(linked.events?.at(-1)).toMatchObject({ kind: "link_added" });
await expect(
store.addLink(card.id, { type: "parent", targetCardId: "card-upstream" }),
).rejects.toThrow(/linkDependency/);
const proven = await store.addProof(card.id, {
status: "passed",
@@ -424,6 +496,616 @@ describe("WorkboardStore", () => {
expect(released.metadata?.claim).toBeUndefined();
});
it("creates idempotent child cards and promotes them when parents finish", async () => {
const store = new WorkboardStore(createMemoryStore());
const parent = await store.create({ title: "Parent", status: "running" });
const child = await store.create({
title: "Child",
status: "todo",
parents: [parent.id],
tenant: "release",
idempotencyKey: "fanout:1",
skills: ["testing"],
workspace: { kind: "scratch" },
});
expect(child.status).toBe("todo");
expect(child.metadata?.links).toEqual([
expect.objectContaining({ type: "parent", targetCardId: parent.id }),
]);
await expect(store.get(parent.id)).resolves.toMatchObject({
metadata: { links: [expect.objectContaining({ type: "child", targetCardId: child.id })] },
});
await expect(
store.create({
title: "Duplicate child",
tenant: "release",
idempotencyKey: "fanout:1",
}),
).resolves.toMatchObject({ id: child.id });
await expect(
store.create({
title: "Different tenant child",
tenant: "qa",
idempotencyKey: "fanout:1",
}),
).resolves.toMatchObject({ title: "Different tenant child" });
await expect(
store.create({ title: "Unscoped child", idempotencyKey: "fanout:1" }),
).resolves.toMatchObject({ title: "Unscoped child" });
await store.complete(parent.id, { summary: "Parent done." });
const promoted = await store.promoteReady();
expect(promoted.cards).toEqual([expect.objectContaining({ id: child.id, status: "ready" })]);
await expect(store.get(child.id)).resolves.toMatchObject({
status: "ready",
metadata: {
automation: {
tenant: "release",
idempotencyKey: "fanout:1",
skills: ["testing"],
workspace: { kind: "scratch" },
},
},
});
});
it("returns an idempotent child retry when its original parent was deleted", async () => {
const store = new WorkboardStore(createMemoryStore());
const parent = await store.create({ title: "Ephemeral parent" });
const child = await store.create({
title: "Retryable child",
parents: [parent.id],
tenant: "release",
idempotencyKey: "fanout:deleted-parent",
});
await store.delete(parent.id);
await expect(
store.create({
title: "Retryable child",
parents: [parent.id],
tenant: "release",
idempotencyKey: "fanout:deleted-parent",
}),
).resolves.toMatchObject({ id: child.id });
});
it("accepts POSIX and Windows absolute directory workspaces", async () => {
const store = new WorkboardStore(createMemoryStore());
await expect(
store.create({
title: "POSIX workspace",
workspace: { kind: "dir", path: "/Users/me/repo" },
}),
).resolves.toMatchObject({
metadata: { automation: { workspace: { kind: "dir", path: "/Users/me/repo" } } },
});
await expect(
store.create({
title: "Windows drive workspace",
workspace: { kind: "dir", path: String.raw`C:\Users\me\repo` },
}),
).resolves.toMatchObject({
metadata: {
automation: { workspace: { kind: "dir", path: String.raw`C:\Users\me\repo` } },
},
});
await expect(
store.create({
title: "Windows UNC workspace",
workspace: { kind: "dir", path: String.raw`\\server\share\repo` },
}),
).resolves.toMatchObject({
metadata: {
automation: { workspace: { kind: "dir", path: String.raw`\\server\share\repo` } },
},
});
await expect(
store.create({ title: "Relative workspace", workspace: { kind: "dir", path: "repo" } }),
).rejects.toThrow(/absolute/);
});
it("keeps future scheduled cards scheduled until their time arrives", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(0);
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Later",
status: "scheduled",
scheduledAt: 10_000,
});
const manual = await store.create({
title: "Manual scheduled",
status: "scheduled",
});
const implicit = await store.create({
title: "Implicit later",
scheduledAt: 10_000,
});
const activeRequested = await store.create({
title: "Active requested later",
status: "running",
scheduledAt: 10_000,
execution: {
id: "exec-scheduled",
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "running",
model: "openai/gpt-5.5",
startedAt: 0,
updatedAt: 0,
},
});
const parent = await store.create({ title: "Parent", status: "running" });
const dependent = await store.create({
title: "Dependent later",
status: "scheduled",
parents: [parent.id],
scheduledAt: 10_000,
});
expect((await store.dispatch(1_000)).promoted).toEqual([]);
await expect(store.get(card.id)).resolves.toMatchObject({ status: "scheduled" });
await expect(store.get(manual.id)).resolves.toMatchObject({ status: "scheduled" });
await expect(store.get(implicit.id)).resolves.toMatchObject({ status: "scheduled" });
await expect(store.get(activeRequested.id)).resolves.toMatchObject({ status: "scheduled" });
expect((await store.get(activeRequested.id))?.execution).toBeUndefined();
expect((await store.get(activeRequested.id))?.metadata?.attempts).toBeUndefined();
await expect(store.get(dependent.id)).resolves.toMatchObject({ status: "scheduled" });
await expect(store.claim(card.id, { ownerId: "main" })).rejects.toThrow(/scheduled/);
await expect(store.claim(manual.id, { ownerId: "main" })).rejects.toThrow(/scheduled/);
await expect(store.claim(implicit.id, { ownerId: "main" })).rejects.toThrow(/scheduled/);
await expect(store.move(manual.id, "running", manual.position)).rejects.toThrow(/scheduled/);
await store.complete(parent.id, { summary: "Parent done." });
expect((await store.dispatch(5_000)).promoted).toEqual([]);
await expect(store.get(dependent.id)).resolves.toMatchObject({ status: "scheduled" });
expect((await store.dispatch(20_000)).promoted).toEqual([
expect.objectContaining({ id: card.id, status: "ready" }),
expect.objectContaining({ id: implicit.id, status: "ready" }),
expect.objectContaining({ id: activeRequested.id, status: "ready" }),
expect.objectContaining({ id: dependent.id, status: "ready" }),
]);
await expect(store.get(manual.id)).resolves.toMatchObject({ status: "scheduled" });
} finally {
vi.useRealTimers();
}
});
it("holds dependent cards out of runnable statuses until parents finish", async () => {
const store = new WorkboardStore(createMemoryStore());
const parent = await store.create({ title: "Parent", status: "running" });
const child = await store.create({
title: "Child",
status: "running",
parents: [parent.id],
execution: {
id: "exec-held",
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "running",
model: "openai/gpt-5.5",
startedAt: 1,
updatedAt: 1,
},
});
expect(child.status).toBe("todo");
expect(child.execution).toBeUndefined();
expect(child.metadata?.attempts).toBeUndefined();
await expect(store.claim(child.id, { ownerId: "main" })).rejects.toThrow(/dependencies/);
await expect(store.move(child.id, "ready", child.position)).rejects.toThrow(/dependencies/);
await expect(store.move(child.id, "running", child.position)).rejects.toThrow(/dependencies/);
await expect(store.move(child.id, "done", child.position)).rejects.toThrow(/dependencies/);
await expect(store.update(child.id, { status: "ready" })).rejects.toThrow(/dependencies/);
await expect(store.update(child.id, { status: "done" })).rejects.toThrow(/dependencies/);
await expect(store.complete(child.id, { summary: "Too early." })).rejects.toThrow(
/dependencies/,
);
const linked = await store.update(child.id, {
metadata: {
links: [
{
id: "ordinary-link",
type: "relates_to",
createdAt: Date.now(),
url: "https://example.com/work",
},
],
},
});
expect(linked.metadata?.links).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "parent", targetCardId: parent.id }),
expect.objectContaining({ type: "relates_to", url: "https://example.com/work" }),
]),
);
await expect(store.claim(child.id, { ownerId: "main" })).rejects.toThrow(/dependencies/);
await store.complete(parent.id, { summary: "Parent done." });
const dispatch = await store.dispatch();
expect(dispatch.promoted).toEqual([expect.objectContaining({ id: child.id, status: "ready" })]);
const claimed = await store.claim(child.id, { ownerId: "main" });
expect(claimed.card.status).toBe("running");
await store.update(parent.id, { status: "running" });
await store.dispatch();
await expect(store.get(child.id)).resolves.toMatchObject({
status: "running",
metadata: { claim: expect.objectContaining({ ownerId: "main" }) },
});
await expect(store.releaseClaim(child.id, { ownerId: "main", status: "done" })).rejects.toThrow(
/dependencies/,
);
await expect(store.get(child.id)).resolves.toMatchObject({
status: "running",
metadata: { claim: expect.objectContaining({ ownerId: "main" }) },
});
const lateParent = await store.create({ title: "Late parent" });
await expect(store.linkCards(lateParent.id, child.id)).rejects.toThrow(/active child/);
});
it("rejects terminal children with incomplete dependency parents", async () => {
const store = new WorkboardStore(createMemoryStore());
const runningParent = await store.create({ title: "Running parent", status: "running" });
const doneChild = await store.create({ title: "Done child", status: "done" });
await expect(store.linkCards(runningParent.id, doneChild.id)).rejects.toThrow(/terminal child/);
await expect(
store.create({ title: "Already done", status: "done", parents: [runningParent.id] }),
).rejects.toThrow(/terminal child/);
const doneParent = await store.create({ title: "Done parent", status: "done" });
await expect(store.linkCards(doneParent.id, doneChild.id)).resolves.toMatchObject({
id: doneChild.id,
status: "done",
});
});
it("preserves dependency links across link caps and parent deletion", async () => {
const store = new WorkboardStore(createMemoryStore());
const parent = await store.create({ title: "Parent", status: "running" });
const child = await store.create({ title: "Child", parents: [parent.id] });
for (let index = 0; index < 60; index += 1) {
await store.addLink(child.id, {
type: "relates_to",
url: `https://example.com/${index}`,
});
}
await expect(store.claim(child.id, { ownerId: "main" })).rejects.toThrow(/dependencies/);
await store.delete(parent.id);
const claimed = await store.claim(child.id, { ownerId: "main" });
expect(claimed.card.status).toBe("running");
expect(claimed.card.metadata?.links?.some((link) => link.targetCardId === parent.id)).toBe(
false,
);
});
it("rolls back card creation when dependency link capacity rejects the parent", async () => {
const store = new WorkboardStore(createMemoryStore());
const parent = await store.create({ title: "Fanout parent" });
for (let index = 0; index < 50; index += 1) {
await store.create({ title: `Child ${index}`, parents: [parent.id] });
}
await expect(
store.create({
title: "Overflow child",
parents: [parent.id],
idempotencyKey: "overflow",
}),
).rejects.toThrow(/link limit/);
expect((await store.list()).some((card) => card.title === "Overflow child")).toBe(false);
});
it("rejects invalid parent creates without persisting partial cards", async () => {
const store = new WorkboardStore(createMemoryStore());
const parents: string[] = [];
for (let index = 0; index < 21; index += 1) {
parents.push((await store.create({ title: `Parent ${index}` })).id);
}
await expect(
store.create({
title: "Too many parents",
parents,
}),
).rejects.toThrow(/parents supports at most 20 entries/);
await expect(
store.create({
title: "Malformed parents",
parents: [parents[0], 123],
}),
).rejects.toThrow(/parents entries must be strings/);
await expect(
store.create({
title: "Orphan child",
parents: ["missing-parent"],
idempotencyKey: "fanout:missing",
}),
).rejects.toThrow(/card not found: missing-parent/);
expect((await store.list()).some((card) => card.title === "Too many parents")).toBe(false);
expect((await store.list()).some((card) => card.title === "Malformed parents")).toBe(false);
expect((await store.list()).some((card) => card.title === "Orphan child")).toBe(false);
});
it("rejects dependency cycles", async () => {
const store = new WorkboardStore(createMemoryStore());
const first = await store.create({ title: "First" });
const second = await store.create({ title: "Second", parents: [first.id] });
await expect(store.linkCards(second.id, first.id)).rejects.toThrow(/cycle/);
});
it("completes and blocks claimed cards with structured handoff metadata", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Ship child",
status: "running",
execution: {
id: "exec-complete",
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "running",
model: "openai/gpt-5.5",
startedAt: 1_000,
updatedAt: 1_000,
},
});
const child = await store.create({ title: "Follow-up" });
const claimed = await store.claim(card.id, { ownerId: "main", token: "token-1" });
const completed = await store.complete(claimed.card.id, {
ownerId: "main",
token: "token-1",
summary: "Implemented and verified.",
proof: { status: "passed", command: "pnpm test extensions/workboard" },
artifacts: [{ path: "/tmp/log.txt", label: "log" }],
createdCardIds: [child.id],
});
expect(completed).toMatchObject({
status: "done",
execution: { status: "done" },
metadata: {
attempts: [expect.objectContaining({ status: "succeeded", endedAt: expect.any(Number) })],
comments: [expect.objectContaining({ body: "Implemented and verified." })],
proof: [expect.objectContaining({ status: "passed" })],
artifacts: [expect.objectContaining({ path: "/tmp/log.txt" })],
automation: { summary: "Implemented and verified.", createdCardIds: [child.id] },
notifications: [expect.objectContaining({ kind: "completed" })],
},
});
expect(completed.metadata?.claim).toBeUndefined();
const blockedCard = await store.create({
title: "Blocked work",
status: "running",
execution: {
id: "exec-block",
kind: "agent-session",
engine: "claude",
mode: "autonomous",
status: "running",
model: "anthropic/claude-sonnet-4.6",
startedAt: 1_000,
updatedAt: 1_000,
},
});
await store.claim(blockedCard.id, { ownerId: "main", token: "token-2" });
const blocked = await store.block(blockedCard.id, {
ownerId: "main",
token: "token-2",
reason: "Needs owner decision.",
});
expect(blocked.status).toBe("blocked");
expect(blocked.execution?.status).toBe("blocked");
expect(blocked.metadata?.attempts).toEqual([
expect.objectContaining({
status: "blocked",
endedAt: expect.any(Number),
error: "Needs owner decision.",
}),
]);
expect(blocked.metadata?.failureCount).toBe(1);
expect(blocked.metadata?.claim).toBeUndefined();
expect(blocked.metadata?.notifications).toEqual([
expect.objectContaining({ kind: "failed", message: "Needs owner decision." }),
]);
const recovered = await store.complete(
(
await store.create({
title: "Recovered work",
status: "running",
metadata: { failureCount: 2 },
})
).id,
{ summary: "Recovered." },
);
expect(recovered.metadata?.failureCount).toBeUndefined();
});
it("keeps long lifecycle handoffs in comments while capping notifications", async () => {
const store = new WorkboardStore(createMemoryStore());
const completeCard = await store.create({ title: "Long complete" });
const blockCard = await store.create({ title: "Long block" });
const longSummary = "x".repeat(1000);
const longReason = "y".repeat(1000);
const completed = await store.complete(completeCard.id, { summary: longSummary });
const blocked = await store.block(blockCard.id, { reason: longReason });
expect(completed.metadata?.comments?.[0]?.body).toBe(longSummary);
expect(completed.metadata?.notifications?.[0]?.message.length).toBeLessThanOrEqual(240);
expect(blocked.metadata?.comments?.[0]?.body).toBe(longReason);
expect(blocked.metadata?.notifications?.[0]?.message.length).toBeLessThanOrEqual(240);
});
it("dispatches ready cards and blocks expired or timed-out work", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(1_000);
const store = new WorkboardStore(createMemoryStore());
const ready = await store.create({ title: "Ready", status: "ready" });
const readyUpdatedAt = ready.updatedAt;
const expired = await store.create({ title: "Expired", status: "running" });
await store.claim(expired.id, { ownerId: "main", token: "token-1", ttlSeconds: 1 });
const timed = await store.create({
title: "Timed",
status: "running",
maxRuntimeSeconds: 1,
execution: {
id: "exec-1",
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "running",
model: "openai/gpt-5.5",
startedAt: 1_000,
updatedAt: 1_000,
},
});
const claimedTimed = await store.create({
title: "Claimed timed",
status: "ready",
maxRuntimeSeconds: 1,
});
await store.claim(claimedTimed.id, { ownerId: "main", token: "token-2", ttlSeconds: 60 });
const createdRunningTimed = await store.create({
title: "Created running timed",
status: "running",
maxRuntimeSeconds: 1,
});
const result = await store.dispatch(10 * 60 * 1000);
expect(createdRunningTimed.startedAt).toBe(1_000);
expect(result.count).toBe(4);
await expect(store.get(ready.id)).resolves.toMatchObject({
updatedAt: readyUpdatedAt,
metadata: { automation: { dispatchCount: 1, lastDispatchAt: 600_000 } },
events: expect.arrayContaining([expect.objectContaining({ kind: "dispatch" })]),
});
const blockedExpired = await store.get(expired.id);
expect(blockedExpired).toMatchObject({ status: "blocked" });
expect(blockedExpired?.metadata?.claim).toBeUndefined();
await expect(store.get(timed.id)).resolves.toMatchObject({
status: "blocked",
execution: { status: "blocked" },
metadata: {
attempts: [expect.objectContaining({ status: "blocked", endedAt: 600_000 })],
},
});
const blockedClaimed = await store.get(claimedTimed.id);
expect(blockedClaimed).toMatchObject({ status: "blocked" });
expect(blockedClaimed?.metadata?.claim).toBeUndefined();
expect(blockedClaimed?.metadata?.notifications).toEqual(
expect.arrayContaining([
expect.objectContaining({ message: "Run exceeded the card max runtime." }),
]),
);
await expect(store.get(createdRunningTimed.id)).resolves.toMatchObject({
status: "blocked",
metadata: {
notifications: expect.arrayContaining([
expect.objectContaining({ message: "Run exceeded the card max runtime." }),
]),
},
});
} finally {
vi.useRealTimers();
}
});
it("lets in-flight retries finish before enforcing the retry budget", async () => {
const store = new WorkboardStore(createMemoryStore());
const retrying = await store.create({
title: "Retrying",
status: "ready",
maxRetries: 1,
metadata: { failureCount: 1 },
});
await store.claim(retrying.id, { ownerId: "main", token: "token-1" });
const retryDispatch = await store.dispatch();
expect(retryDispatch.blocked).toEqual([]);
await expect(store.get(retrying.id)).resolves.toMatchObject({ status: "running" });
const exhausted = await store.create({
title: "Exhausted",
status: "ready",
maxRetries: 1,
metadata: { failureCount: 2 },
});
const exhaustedTodo = await store.create({
title: "Exhausted todo",
status: "todo",
maxRetries: 1,
metadata: { failureCount: 2 },
});
const exhaustedBacklog = await store.create({
title: "Exhausted backlog",
status: "backlog",
maxRetries: 1,
metadata: { failureCount: 2 },
});
await expect(store.claim(exhausted.id, { ownerId: "main" })).rejects.toThrow(/retry budget/);
const exhaustedDispatch = await store.dispatch();
expect(exhaustedDispatch.blocked).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: exhausted.id, status: "blocked" }),
expect.objectContaining({ id: exhaustedTodo.id, status: "blocked" }),
expect.objectContaining({ id: exhaustedBacklog.id, status: "blocked" }),
]),
);
await expect(store.get(exhausted.id)).resolves.toMatchObject({
status: "blocked",
metadata: {
notifications: [expect.objectContaining({ message: "Card exhausted its retry budget." })],
},
});
const parent = await store.create({ title: "Parent retry gate", status: "running" });
const dependent = await store.create({
title: "Dependent exhausted",
parents: [parent.id],
maxRetries: 1,
metadata: { failureCount: 2 },
});
await store.complete(parent.id, { summary: "Parent done." });
const dependentDispatch = await store.dispatch();
expect(dependentDispatch.promoted.some((card) => card.id === dependent.id)).toBe(false);
expect(dependentDispatch.blocked).toEqual([
expect.objectContaining({ id: dependent.id, status: "blocked" }),
]);
});
it("extends claim expiry by the original TTL on heartbeat", async () => {
vi.useFakeTimers();
try {
File diff suppressed because it is too large Load Diff
+185
View File
@@ -145,4 +145,189 @@ describe("workboard tools", () => {
otherTools.get("workboard_claim")?.execute("call-2", { id: card.id }),
).rejects.toThrow(/already claimed/);
});
it("requires claim scope before creating or linking dependencies against claimed cards", async () => {
const keyed = createMemoryStore();
const api = {
runtime: {
state: {
openKeyedStore: vi.fn(() => keyed),
},
},
} as unknown as OpenClawPluginApi;
const store = new WorkboardStore(keyed);
const mainTools = new Map(
createWorkboardTools({
api,
store,
context: { agentId: "main" } as never,
}).map((tool) => [tool.name, tool]),
);
const otherTools = new Map(
createWorkboardTools({
api,
store,
context: { agentId: "other" } as never,
}).map((tool) => [tool.name, tool]),
);
const parent = await store.create({ title: "Claimed parent" });
const claimed = await store.claim(parent.id, { ownerId: "main", token: "parent-token" });
await expect(
otherTools.get("workboard_create")?.execute("call-1", {
title: "Blocked child",
parents: [parent.id],
}),
).rejects.toThrow(/claimed by main/);
await expect(
otherTools.get("workboard_create")?.execute("call-1b", {
title: "Blocked child",
parents: parent.id,
}),
).rejects.toThrow(/claimed by main/);
expect(await store.list()).toHaveLength(1);
await otherTools.get("workboard_create")?.execute("call-2", {
title: "Scoped child",
parents: [parent.id],
token: claimed.token,
});
const child = await store.create({ title: "Claimed child" });
await store.claim(child.id, { ownerId: "main", token: "child-token" });
await expect(
otherTools.get("workboard_link")?.execute("call-3", {
parentId: parent.id,
childId: child.id,
}),
).rejects.toThrow(/claimed by main/);
const linked = readPayload(
await otherTools.get("workboard_link")?.execute("call-4", {
parentId: parent.id,
childId: (await store.create({ title: "Idle child" })).id,
token: claimed.token,
}),
);
expect(linked.card).toMatchObject({ status: "todo" });
await expect(
mainTools.get("workboard_link")?.execute("call-5", {
parentId: parent.id,
childId: child.id,
token: "child-token",
}),
).rejects.toThrow(/active child/);
});
it("creates dependent cards and completes claimed work through tools", async () => {
const keyed = createMemoryStore();
const api = {
runtime: {
state: {
openKeyedStore: vi.fn(() => keyed),
},
},
} as unknown as OpenClawPluginApi;
const store = new WorkboardStore(keyed);
const tools = new Map(
createWorkboardTools({
api,
store,
context: { agentId: "main" } as never,
}).map((tool) => [tool.name, tool]),
);
const parentPayload = readPayload(
await tools.get("workboard_create")?.execute("call-1", {
title: "Parent",
status: "running",
}),
);
const parent = parentPayload.card as { id: string };
const childPayload = readPayload(
await tools.get("workboard_create")?.execute("call-2", {
title: "Child",
parents: [parent.id],
tenant: "qa",
skills: ["testing"],
}),
);
const child = childPayload.card as { id: string; status: string };
expect(child.status).toBe("todo");
await expect(
tools.get("workboard_complete")?.execute("call-unclaimed-complete", {
id: parent.id,
summary: "Too early.",
}),
).rejects.toThrow(/claimed/);
await expect(
tools.get("workboard_block")?.execute("call-unclaimed-block", {
id: child.id,
reason: "Too early.",
}),
).rejects.toThrow(/claimed/);
const claimed = readPayload(
await tools.get("workboard_claim")?.execute("call-3", { id: parent.id }),
);
const token = claimed.token as string;
const completed = readPayload(
await tools.get("workboard_complete")?.execute("call-4", {
id: parent.id,
token,
summary: "Done.",
createdCardIds: [child.id],
proof: { status: "passed", command: "pnpm test extensions/workboard" },
}),
);
expect(completed.card).toMatchObject({ status: "done" });
const dispatch = readPayload(await tools.get("workboard_dispatch")?.execute("call-5", {}));
expect(dispatch.promoted).toEqual([expect.objectContaining({ id: child.id, status: "ready" })]);
});
it("redacts claim tokens from dispatch tool results", async () => {
const keyed = createMemoryStore();
const api = {
runtime: {
state: {
openKeyedStore: vi.fn(() => keyed),
},
},
} as unknown as OpenClawPluginApi;
const store = new WorkboardStore(keyed);
const tools = new Map(
createWorkboardTools({
api,
store,
context: { agentId: "main" } as never,
}).map((tool) => [tool.name, tool]),
);
const card = await store.create({
title: "Scheduled",
status: "scheduled",
scheduledAt: 1,
});
await store.update(card.id, {
metadata: {
...card.metadata,
claim: {
ownerId: "main",
token: "secret-token",
claimedAt: 1,
lastHeartbeatAt: 1,
expiresAt: Date.now() + 60_000,
},
},
});
const dispatch = readPayload(await tools.get("workboard_dispatch")?.execute("call-1", {}));
const promoted = dispatch.promoted as Array<{
metadata?: { claim?: { token?: string } };
}>;
expect(promoted).toEqual([expect.objectContaining({ id: card.id })]);
expect(promoted[0]?.metadata?.claim?.token).toBe("[redacted]");
});
});
+218
View File
@@ -23,6 +23,35 @@ function canMutateCard(card: WorkboardCard, ownerId: string, token?: string): bo
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,
@@ -39,6 +68,19 @@ async function requireScopedCard(
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,
@@ -46,6 +88,13 @@ function summarizeCard(card: WorkboardCard) {
status: card.status,
priority: card.priority,
agentId: card.agentId,
tenant: card.metadata?.automation?.tenant,
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,
@@ -106,6 +155,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." })),
limit: Type.Optional(
Type.Number({ description: "Maximum cards to return. Default 50." }),
),
@@ -125,6 +175,7 @@ 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 limit =
typeof record.limit === "number" && Number.isFinite(record.limit)
? Math.max(1, Math.min(200, Math.trunc(record.limit)))
@@ -133,11 +184,83 @@ export function createWorkboardTools(params: {
.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." })),
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",
@@ -309,6 +432,85 @@ export function createWorkboardTools(params: {
return jsonResult({ card: redactClaimToken(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: Type.String({ description: "Workboard card id." }),
token: Type.Optional(
Type.String({ description: "Claim token returned by workboard_claim." }),
),
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) => {
const record = rawParams as Record<string, unknown>;
const id = readStringParam(record, "id", { required: true });
const scope = { ownerId, token: record.token };
await requireClaimedCard(store, id, ownerId, record.token as string | undefined);
return jsonResult({
card: redactClaimToken(await store.complete(id, record, 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: Type.String({ description: "Workboard card id." }),
token: Type.Optional(
Type.String({ description: "Claim token returned by workboard_claim." }),
),
reason: Type.Optional(Type.String({ description: "Blocker summary." })),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const record = rawParams as Record<string, unknown>;
const id = readStringParam(record, "id", { required: true });
const scope = { ownerId, token: record.token };
await requireClaimedCard(store, id, ownerId, record.token as string | undefined);
return jsonResult({
card: redactClaimToken(await store.block(id, record, scope)),
});
},
},
{
name: "workboard_unblock",
label: "Workboard Unblock",
@@ -323,5 +525,21 @@ export function createWorkboardTools(params: {
);
},
},
{
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),
});
},
},
];
}
+32 -1
View File
@@ -1,6 +1,9 @@
export const WORKBOARD_STATUSES = [
"triage",
"backlog",
"todo",
"scheduled",
"ready",
"running",
"review",
"blocked",
@@ -33,6 +36,7 @@ export const WORKBOARD_EVENT_KINDS = [
"artifact_added",
"diagnostic",
"notification",
"dispatch",
"archived",
"unarchived",
"stale",
@@ -44,7 +48,13 @@ export const WORKBOARD_ATTEMPT_STATUSES = [
"blocked",
"stopped",
] as const;
export const WORKBOARD_LINK_TYPES = ["blocks", "blocked_by", "relates_to"] as const;
export const WORKBOARD_LINK_TYPES = [
"parent",
"child",
"blocks",
"blocked_by",
"relates_to",
] as const;
export const WORKBOARD_PROOF_STATUSES = ["passed", "failed", "skipped", "unknown"] as const;
export const WORKBOARD_TEMPLATE_IDS = ["bugfix", "docs", "release", "pr_review", "plugin"] as const;
export const WORKBOARD_DIAGNOSTIC_KINDS = [
@@ -182,12 +192,33 @@ export type WorkboardNotification = {
runId?: string;
};
export type WorkboardWorkspace = {
kind: "scratch" | "dir" | "worktree";
path?: string;
branch?: string;
};
export type WorkboardAutomation = {
tenant?: string;
idempotencyKey?: string;
skills?: string[];
workspace?: WorkboardWorkspace;
maxRuntimeSeconds?: number;
maxRetries?: number;
scheduledAt?: number;
summary?: string;
createdCardIds?: string[];
dispatchCount?: number;
lastDispatchAt?: number;
};
export type WorkboardMetadata = {
attempts?: WorkboardRunAttempt[];
comments?: WorkboardComment[];
links?: WorkboardLink[];
proof?: WorkboardProof[];
artifacts?: WorkboardArtifact[];
automation?: WorkboardAutomation;
claim?: WorkboardClaim;
diagnostics?: WorkboardDiagnostic[];
notifications?: WorkboardNotification[];
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-29T21:01:05.561Z",
"generatedAt": "2026-05-30T00:01:13.998Z",
"locale": "ar",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d51c5347e718c0fbe7353fccf82cefc0ef09b95b25acbc840a2f19b0c17b9296",
"totalKeys": 1271,
"translatedKeys": 1271,
"sourceHash": "8f9697af4f21650eef01a2449759b1059950ed0f10455b09693cf3d8837ed538",
"totalKeys": 1278,
"translatedKeys": 1278,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-29T21:00:05.360Z",
"generatedAt": "2026-05-30T00:01:12.229Z",
"locale": "de",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d51c5347e718c0fbe7353fccf82cefc0ef09b95b25acbc840a2f19b0c17b9296",
"totalKeys": 1271,
"translatedKeys": 1271,
"sourceHash": "8f9697af4f21650eef01a2449759b1059950ed0f10455b09693cf3d8837ed538",
"totalKeys": 1278,
"translatedKeys": 1278,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-29T21:00:16.244Z",
"generatedAt": "2026-05-30T00:01:12.582Z",
"locale": "es",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d51c5347e718c0fbe7353fccf82cefc0ef09b95b25acbc840a2f19b0c17b9296",
"totalKeys": 1271,
"translatedKeys": 1271,
"sourceHash": "8f9697af4f21650eef01a2449759b1059950ed0f10455b09693cf3d8837ed538",
"totalKeys": 1278,
"translatedKeys": 1278,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-29T21:02:42.653Z",
"generatedAt": "2026-05-30T00:01:17.237Z",
"locale": "fa",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d51c5347e718c0fbe7353fccf82cefc0ef09b95b25acbc840a2f19b0c17b9296",
"totalKeys": 1271,
"translatedKeys": 1271,
"sourceHash": "8f9697af4f21650eef01a2449759b1059950ed0f10455b09693cf3d8837ed538",
"totalKeys": 1278,
"translatedKeys": 1278,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-29T21:00:46.756Z",
"generatedAt": "2026-05-30T00:01:13.644Z",
"locale": "fr",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d51c5347e718c0fbe7353fccf82cefc0ef09b95b25acbc840a2f19b0c17b9296",
"totalKeys": 1271,
"translatedKeys": 1271,
"sourceHash": "8f9697af4f21650eef01a2449759b1059950ed0f10455b09693cf3d8837ed538",
"totalKeys": 1278,
"translatedKeys": 1278,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-29T21:01:44.176Z",
"generatedAt": "2026-05-30T00:01:15.407Z",
"locale": "id",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d51c5347e718c0fbe7353fccf82cefc0ef09b95b25acbc840a2f19b0c17b9296",
"totalKeys": 1271,
"translatedKeys": 1271,
"sourceHash": "8f9697af4f21650eef01a2449759b1059950ed0f10455b09693cf3d8837ed538",
"totalKeys": 1278,
"translatedKeys": 1278,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-29T21:01:19.882Z",
"generatedAt": "2026-05-30T00:01:14.349Z",
"locale": "it",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d51c5347e718c0fbe7353fccf82cefc0ef09b95b25acbc840a2f19b0c17b9296",
"totalKeys": 1271,
"translatedKeys": 1271,
"sourceHash": "8f9697af4f21650eef01a2449759b1059950ed0f10455b09693cf3d8837ed538",
"totalKeys": 1278,
"translatedKeys": 1278,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-29T21:00:25.952Z",
"generatedAt": "2026-05-30T00:01:12.935Z",
"locale": "ja-JP",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d51c5347e718c0fbe7353fccf82cefc0ef09b95b25acbc840a2f19b0c17b9296",
"totalKeys": 1271,
"translatedKeys": 1271,
"sourceHash": "8f9697af4f21650eef01a2449759b1059950ed0f10455b09693cf3d8837ed538",
"totalKeys": 1278,
"translatedKeys": 1278,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-29T21:00:36.567Z",
"generatedAt": "2026-05-30T00:01:13.288Z",
"locale": "ko",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d51c5347e718c0fbe7353fccf82cefc0ef09b95b25acbc840a2f19b0c17b9296",
"totalKeys": 1271,
"translatedKeys": 1271,
"sourceHash": "8f9697af4f21650eef01a2449759b1059950ed0f10455b09693cf3d8837ed538",
"totalKeys": 1278,
"translatedKeys": 1278,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-29T21:02:33.330Z",
"generatedAt": "2026-05-30T00:01:16.874Z",
"locale": "nl",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d51c5347e718c0fbe7353fccf82cefc0ef09b95b25acbc840a2f19b0c17b9296",
"totalKeys": 1271,
"translatedKeys": 1271,
"sourceHash": "8f9697af4f21650eef01a2449759b1059950ed0f10455b09693cf3d8837ed538",
"totalKeys": 1278,
"translatedKeys": 1278,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-29T21:02:00.415Z",
"generatedAt": "2026-05-30T00:01:15.770Z",
"locale": "pl",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d51c5347e718c0fbe7353fccf82cefc0ef09b95b25acbc840a2f19b0c17b9296",
"totalKeys": 1271,
"translatedKeys": 1271,
"sourceHash": "8f9697af4f21650eef01a2449759b1059950ed0f10455b09693cf3d8837ed538",
"totalKeys": 1278,
"translatedKeys": 1278,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-29T20:59:56.115Z",
"generatedAt": "2026-05-30T00:01:11.874Z",
"locale": "pt-BR",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d51c5347e718c0fbe7353fccf82cefc0ef09b95b25acbc840a2f19b0c17b9296",
"totalKeys": 1271,
"translatedKeys": 1271,
"sourceHash": "8f9697af4f21650eef01a2449759b1059950ed0f10455b09693cf3d8837ed538",
"totalKeys": 1278,
"translatedKeys": 1278,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-29T21:02:14.037Z",
"generatedAt": "2026-05-30T00:01:16.159Z",
"locale": "th",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d51c5347e718c0fbe7353fccf82cefc0ef09b95b25acbc840a2f19b0c17b9296",
"totalKeys": 1271,
"translatedKeys": 1271,
"sourceHash": "8f9697af4f21650eef01a2449759b1059950ed0f10455b09693cf3d8837ed538",
"totalKeys": 1278,
"translatedKeys": 1278,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-29T21:01:26.035Z",
"generatedAt": "2026-05-30T00:01:14.704Z",
"locale": "tr",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d51c5347e718c0fbe7353fccf82cefc0ef09b95b25acbc840a2f19b0c17b9296",
"totalKeys": 1271,
"translatedKeys": 1271,
"sourceHash": "8f9697af4f21650eef01a2449759b1059950ed0f10455b09693cf3d8837ed538",
"totalKeys": 1278,
"translatedKeys": 1278,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-29T21:01:33.693Z",
"generatedAt": "2026-05-30T00:01:15.053Z",
"locale": "uk",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d51c5347e718c0fbe7353fccf82cefc0ef09b95b25acbc840a2f19b0c17b9296",
"totalKeys": 1271,
"translatedKeys": 1271,
"sourceHash": "8f9697af4f21650eef01a2449759b1059950ed0f10455b09693cf3d8837ed538",
"totalKeys": 1278,
"translatedKeys": 1278,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-29T21:02:23.397Z",
"generatedAt": "2026-05-30T00:01:16.521Z",
"locale": "vi",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d51c5347e718c0fbe7353fccf82cefc0ef09b95b25acbc840a2f19b0c17b9296",
"totalKeys": 1271,
"translatedKeys": 1271,
"sourceHash": "8f9697af4f21650eef01a2449759b1059950ed0f10455b09693cf3d8837ed538",
"totalKeys": 1278,
"translatedKeys": 1278,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-29T20:59:45.228Z",
"generatedAt": "2026-05-30T00:01:11.085Z",
"locale": "zh-CN",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d51c5347e718c0fbe7353fccf82cefc0ef09b95b25acbc840a2f19b0c17b9296",
"totalKeys": 1271,
"translatedKeys": 1271,
"sourceHash": "8f9697af4f21650eef01a2449759b1059950ed0f10455b09693cf3d8837ed538",
"totalKeys": 1278,
"translatedKeys": 1278,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-29T20:59:52.034Z",
"generatedAt": "2026-05-30T00:01:11.524Z",
"locale": "zh-TW",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "d51c5347e718c0fbe7353fccf82cefc0ef09b95b25acbc840a2f19b0c17b9296",
"totalKeys": 1271,
"translatedKeys": 1271,
"sourceHash": "8f9697af4f21650eef01a2449759b1059950ed0f10455b09693cf3d8837ed538",
"totalKeys": 1278,
"translatedKeys": 1278,
"workflow": 1
}
+7
View File
@@ -484,8 +484,11 @@ export const ar: TranslationMap = {
enableConfigKey: "plugins.entries.workboard.enabled = true",
disabledHelpEnd: "، ثم أعد تحميل علامة التبويب هذه.",
status: {
triage: "Triage",
backlog: "الأعمال المتراكمة",
todo: "المهام",
scheduled: "مجدول",
ready: "جاهز",
running: "قيد التشغيل",
review: "مراجعة",
blocked: "محظور",
@@ -528,6 +531,9 @@ export const ar: TranslationMap = {
badgeLinks: "{count} روابط",
badgeProof: "{count} إثبات",
badgeArtifacts: "{count} مرفقات",
badgeTenant: "المستأجر {tenant}",
badgeSkills: "{count} مهارات",
badgeDispatches: "{count} عمليات إرسال",
badgeClaimed: "محجوزة بواسطة {owner}",
badgeDiagnostics: "{count} تشخيصات",
badgeStale: "قديم",
@@ -568,6 +574,7 @@ export const ar: TranslationMap = {
eventArtifactAdded: "تمت إضافة مرفق",
eventDiagnostic: "تشخيص",
eventNotification: "إشعار",
eventDispatch: "إرسال",
eventArchived: "مؤرشف",
eventUnarchived: "غير مؤرشف",
eventStale: "جلسة قديمة",
+7
View File
@@ -488,8 +488,11 @@ export const de: TranslationMap = {
enableConfigKey: "plugins.entries.workboard.enabled = true",
disabledHelpEnd: ", und laden Sie dann diesen Tab neu.",
status: {
triage: "Triage",
backlog: "Backlog",
todo: "To-do",
scheduled: "Geplant",
ready: "Bereit",
running: "Wird ausgeführt",
review: "Prüfen",
blocked: "Blockiert",
@@ -532,6 +535,9 @@ export const de: TranslationMap = {
badgeLinks: "{count} Links",
badgeProof: "{count} Nachweis",
badgeArtifacts: "{count} Artefakte",
badgeTenant: "Mandant {tenant}",
badgeSkills: "{count} Fähigkeiten",
badgeDispatches: "{count} Auslösungen",
badgeClaimed: "beansprucht von {owner}",
badgeDiagnostics: "{count} Diagnosen",
badgeStale: "veraltet",
@@ -572,6 +578,7 @@ export const de: TranslationMap = {
eventArtifactAdded: "Artefakt hinzugefügt",
eventDiagnostic: "Diagnose",
eventNotification: "Benachrichtigung",
eventDispatch: "Ausgelöst",
eventArchived: "Archiviert",
eventUnarchived: "Aus Archiv wiederhergestellt",
eventStale: "Veraltete Sitzung",
+7
View File
@@ -483,8 +483,11 @@ export const en: TranslationMap = {
enableConfigKey: "plugins.entries.workboard.enabled = true",
disabledHelpEnd: ", then reload this tab.",
status: {
triage: "Triage",
backlog: "Backlog",
todo: "Todo",
scheduled: "Scheduled",
ready: "Ready",
running: "Running",
review: "Review",
blocked: "Blocked",
@@ -527,6 +530,9 @@ export const en: TranslationMap = {
badgeLinks: "{count} links",
badgeProof: "{count} proof",
badgeArtifacts: "{count} artifacts",
badgeTenant: "tenant {tenant}",
badgeSkills: "{count} skills",
badgeDispatches: "{count} dispatches",
badgeClaimed: "claimed by {owner}",
badgeDiagnostics: "{count} diagnostics",
badgeStale: "stale",
@@ -567,6 +573,7 @@ export const en: TranslationMap = {
eventArtifactAdded: "Artifact added",
eventDiagnostic: "Diagnostic",
eventNotification: "Notification",
eventDispatch: "Dispatch",
eventArchived: "Archived",
eventUnarchived: "Unarchived",
eventStale: "Stale session",
+7
View File
@@ -485,8 +485,11 @@ export const es: TranslationMap = {
enableConfigKey: "plugins.entries.workboard.enabled = true",
disabledHelpEnd: ", luego vuelve a cargar esta pestaña.",
status: {
triage: "Triage",
backlog: "Pendientes",
todo: "Por hacer",
scheduled: "Programado",
ready: "Listo",
running: "En ejecución",
review: "Revisión",
blocked: "Bloqueado",
@@ -529,6 +532,9 @@ export const es: TranslationMap = {
badgeLinks: "{count} enlaces",
badgeProof: "{count} prueba",
badgeArtifacts: "{count} artefactos",
badgeTenant: "inquilino {tenant}",
badgeSkills: "{count} habilidades",
badgeDispatches: "{count} envíos",
badgeClaimed: "reclamada por {owner}",
badgeDiagnostics: "{count} diagnósticos",
badgeStale: "obsoleta",
@@ -569,6 +575,7 @@ export const es: TranslationMap = {
eventArtifactAdded: "Artefacto añadido",
eventDiagnostic: "Diagnóstico",
eventNotification: "Notificación",
eventDispatch: "Envío",
eventArchived: "Archivado",
eventUnarchived: "Desarchivado",
eventStale: "Sesión obsoleta",
+7
View File
@@ -486,8 +486,11 @@ export const fa: TranslationMap = {
enableConfigKey: "plugins.entries.workboard.enabled = true",
disabledHelpEnd: "، سپس این زبانه را دوباره بارگذاری کنید.",
status: {
triage: "Triage",
backlog: "بک‌لاگ",
todo: "برای انجام",
scheduled: "زمان‌بندی‌شده",
ready: "آماده",
running: "در حال اجرا",
review: "بازبینی",
blocked: "مسدود",
@@ -530,6 +533,9 @@ export const fa: TranslationMap = {
badgeLinks: "{count} پیوند",
badgeProof: "{count} مدرک",
badgeArtifacts: "{count} آرتیفکت",
badgeTenant: "مستاجر {tenant}",
badgeSkills: "{count} مهارت",
badgeDispatches: "{count} ارسال",
badgeClaimed: "ادعا شده توسط {owner}",
badgeDiagnostics: "{count} تشخیص",
badgeStale: "قدیمی",
@@ -570,6 +576,7 @@ export const fa: TranslationMap = {
eventArtifactAdded: "آرتیفکت اضافه شد",
eventDiagnostic: "تشخیص",
eventNotification: "اعلان",
eventDispatch: "ارسال",
eventArchived: "بایگانی شد",
eventUnarchived: "از بایگانی خارج شد",
eventStale: "نشست منقضی‌شده",
+7
View File
@@ -487,8 +487,11 @@ export const fr: TranslationMap = {
enableConfigKey: "plugins.entries.workboard.enabled = true",
disabledHelpEnd: ", puis rechargez cet onglet.",
status: {
triage: "Triage",
backlog: "Backlog",
todo: "À faire",
scheduled: "Planifié",
ready: "Prêt",
running: "En cours dexécution",
review: "Vérification",
blocked: "Bloqué",
@@ -531,6 +534,9 @@ export const fr: TranslationMap = {
badgeLinks: "{count} liens",
badgeProof: "{count} preuve",
badgeArtifacts: "{count} artefacts",
badgeTenant: "locataire {tenant}",
badgeSkills: "{count} compétences",
badgeDispatches: "{count} envois",
badgeClaimed: "revendiqué par {owner}",
badgeDiagnostics: "{count} diagnostics",
badgeStale: "obsolète",
@@ -571,6 +577,7 @@ export const fr: TranslationMap = {
eventArtifactAdded: "Artefact ajouté",
eventDiagnostic: "Diagnostic",
eventNotification: "Notification",
eventDispatch: "Envoi",
eventArchived: "Archivé",
eventUnarchived: "Désarchivé",
eventStale: "Session inactive",
+7
View File
@@ -485,8 +485,11 @@ export const id: TranslationMap = {
enableConfigKey: "plugins.entries.workboard.enabled = true",
disabledHelpEnd: ", lalu muat ulang tab ini.",
status: {
triage: "Triage",
backlog: "Backlog",
todo: "Tugas",
scheduled: "Terjadwal",
ready: "Siap",
running: "Berjalan",
review: "Tinjau",
blocked: "Terhalang",
@@ -529,6 +532,9 @@ export const id: TranslationMap = {
badgeLinks: "{count} tautan",
badgeProof: "{count} bukti",
badgeArtifacts: "{count} artefak",
badgeTenant: "penyewa {tenant}",
badgeSkills: "{count} keahlian",
badgeDispatches: "{count} pengiriman",
badgeClaimed: "diklaim oleh {owner}",
badgeDiagnostics: "{count} diagnostik",
badgeStale: "usang",
@@ -569,6 +575,7 @@ export const id: TranslationMap = {
eventArtifactAdded: "Artefak ditambahkan",
eventDiagnostic: "Diagnostik",
eventNotification: "Notifikasi",
eventDispatch: "Pengiriman",
eventArchived: "Diarsipkan",
eventUnarchived: "Dibatalkan pengarsipannya",
eventStale: "Sesi kedaluwarsa",
+7
View File
@@ -487,8 +487,11 @@ export const it: TranslationMap = {
enableConfigKey: "plugins.entries.workboard.enabled = true",
disabledHelpEnd: ", quindi ricarica questa scheda.",
status: {
triage: "Triage",
backlog: "Backlog",
todo: "Da fare",
scheduled: "Pianificato",
ready: "Pronto",
running: "In esecuzione",
review: "Revisione",
blocked: "Bloccata",
@@ -531,6 +534,9 @@ export const it: TranslationMap = {
badgeLinks: "{count} link",
badgeProof: "{count} prova",
badgeArtifacts: "{count} artefatti",
badgeTenant: "ambiente {tenant}",
badgeSkills: "{count} competenze",
badgeDispatches: "{count} invii",
badgeClaimed: "rivendicato da {owner}",
badgeDiagnostics: "{count} diagnostiche",
badgeStale: "obsoleto",
@@ -571,6 +577,7 @@ export const it: TranslationMap = {
eventArtifactAdded: "Artefatto aggiunto",
eventDiagnostic: "Diagnostica",
eventNotification: "Notifica",
eventDispatch: "Invio",
eventArchived: "Archiviato",
eventUnarchived: "Non archiviato",
eventStale: "Sessione obsoleta",
+7
View File
@@ -488,8 +488,11 @@ export const ja_JP: TranslationMap = {
enableConfigKey: "plugins.entries.workboard.enabled = true",
disabledHelpEnd: "を設定してから、このタブを再読み込みしてください。",
status: {
triage: "Triage",
backlog: "バックログ",
todo: "ToDo",
scheduled: "予定済み",
ready: "準備完了",
running: "実行中",
review: "レビュー",
blocked: "ブロック済み",
@@ -532,6 +535,9 @@ export const ja_JP: TranslationMap = {
badgeLinks: "{count} 件のリンク",
badgeProof: "{count} 件の証跡",
badgeArtifacts: "{count} 件の成果物",
badgeTenant: "テナント {tenant}",
badgeSkills: "{count} 個のスキル",
badgeDispatches: "{count} 回のディスパッチ",
badgeClaimed: "{owner} が要求済み",
badgeDiagnostics: "{count} 件の診断",
badgeStale: "古い",
@@ -572,6 +578,7 @@ export const ja_JP: TranslationMap = {
eventArtifactAdded: "成果物を追加しました",
eventDiagnostic: "診断",
eventNotification: "通知",
eventDispatch: "ディスパッチ",
eventArchived: "アーカイブ済み",
eventUnarchived: "アーカイブ解除済み",
eventStale: "古いセッション",
+7
View File
@@ -484,8 +484,11 @@ export const ko: TranslationMap = {
enableConfigKey: "plugins.entries.workboard.enabled = true",
disabledHelpEnd: ", 그런 다음 이 탭을 새로고침하세요.",
status: {
triage: "Triage",
backlog: "백로그",
todo: "할 일",
scheduled: "예약됨",
ready: "준비됨",
running: "실행 중",
review: "검토",
blocked: "차단됨",
@@ -528,6 +531,9 @@ export const ko: TranslationMap = {
badgeLinks: "링크 {count}개",
badgeProof: "증빙 {count}개",
badgeArtifacts: "아티팩트 {count}개",
badgeTenant: "테넌트 {tenant}",
badgeSkills: "{count}개 스킬",
badgeDispatches: "{count}회 디스패치",
badgeClaimed: "{owner}가 할당함",
badgeDiagnostics: "진단 {count}개",
badgeStale: "오래됨",
@@ -568,6 +574,7 @@ export const ko: TranslationMap = {
eventArtifactAdded: "아티팩트 추가됨",
eventDiagnostic: "진단",
eventNotification: "알림",
eventDispatch: "디스패치",
eventArchived: "보관됨",
eventUnarchived: "보관 해제됨",
eventStale: "오래된 세션",
+7
View File
@@ -487,8 +487,11 @@ export const nl: TranslationMap = {
enableConfigKey: "plugins.entries.workboard.enabled = true",
disabledHelpEnd: "in en laad dit tabblad opnieuw.",
status: {
triage: "Triage",
backlog: "Backlog",
todo: "Te doen",
scheduled: "Gepland",
ready: "Gereed",
running: "Actief",
review: "Beoordeling",
blocked: "Geblokkeerd",
@@ -531,6 +534,9 @@ export const nl: TranslationMap = {
badgeLinks: "{count} links",
badgeProof: "{count} bewijs",
badgeArtifacts: "{count} artefacten",
badgeTenant: "omgeving {tenant}",
badgeSkills: "{count} vaardigheden",
badgeDispatches: "{count} verzendingen",
badgeClaimed: "geclaimd door {owner}",
badgeDiagnostics: "{count} diagnoses",
badgeStale: "verouderd",
@@ -571,6 +577,7 @@ export const nl: TranslationMap = {
eventArtifactAdded: "Artefact toegevoegd",
eventDiagnostic: "Diagnose",
eventNotification: "Melding",
eventDispatch: "Verzending",
eventArchived: "Gearchiveerd",
eventUnarchived: "Uit archief gehaald",
eventStale: "Verlopen sessie",
+7
View File
@@ -486,8 +486,11 @@ export const pl: TranslationMap = {
enableConfigKey: "plugins.entries.workboard.enabled = true",
disabledHelpEnd: ", a następnie odśwież tę kartę.",
status: {
triage: "Triage",
backlog: "Zaległe",
todo: "Do zrobienia",
scheduled: "Zaplanowane",
ready: "Gotowe",
running: "Uruchomiono",
review: "Przegląd",
blocked: "Zablokowane",
@@ -530,6 +533,9 @@ export const pl: TranslationMap = {
badgeLinks: "{count} linków",
badgeProof: "{count} dowodów",
badgeArtifacts: "{count} artefaktów",
badgeTenant: "dzierżawca {tenant}",
badgeSkills: "{count} umiejętności",
badgeDispatches: "{count} wysyłki",
badgeClaimed: "przejęte przez {owner}",
badgeDiagnostics: "{count} diagnostyk",
badgeStale: "nieaktualne",
@@ -570,6 +576,7 @@ export const pl: TranslationMap = {
eventArtifactAdded: "Dodano artefakt",
eventDiagnostic: "Diagnostyka",
eventNotification: "Powiadomienie",
eventDispatch: "Wysyłka",
eventArchived: "Zarchiwizowano",
eventUnarchived: "Przywrócono z archiwum",
eventStale: "Nieaktualna sesja",
+7
View File
@@ -485,8 +485,11 @@ export const pt_BR: TranslationMap = {
enableConfigKey: "plugins.entries.workboard.enabled = true",
disabledHelpEnd: ", depois recarregue esta aba.",
status: {
triage: "Triage",
backlog: "Backlog",
todo: "A fazer",
scheduled: "Agendado",
ready: "Pronto",
running: "Em execução",
review: "Revisão",
blocked: "Bloqueado",
@@ -529,6 +532,9 @@ export const pt_BR: TranslationMap = {
badgeLinks: "{count} links",
badgeProof: "{count} prova",
badgeArtifacts: "{count} artefatos",
badgeTenant: "locatário {tenant}",
badgeSkills: "{count} habilidades",
badgeDispatches: "{count} despachos",
badgeClaimed: "reivindicado por {owner}",
badgeDiagnostics: "{count} diagnósticos",
badgeStale: "desatualizado",
@@ -569,6 +575,7 @@ export const pt_BR: TranslationMap = {
eventArtifactAdded: "Artefato adicionado",
eventDiagnostic: "Diagnóstico",
eventNotification: "Notificação",
eventDispatch: "Despacho",
eventArchived: "Arquivado",
eventUnarchived: "Desarquivado",
eventStale: "Sessão obsoleta",
+7
View File
@@ -483,8 +483,11 @@ export const th: TranslationMap = {
enableConfigKey: "plugins.entries.workboard.enabled = true",
disabledHelpEnd: " แล้วโหลดแท็บนี้ใหม่",
status: {
triage: "Triage",
backlog: "งานค้าง",
todo: "สิ่งที่ต้องทำ",
scheduled: "กำหนดเวลาแล้ว",
ready: "พร้อม",
running: "กำลังทำงาน",
review: "ตรวจสอบ",
blocked: "ถูกบล็อก",
@@ -527,6 +530,9 @@ export const th: TranslationMap = {
badgeLinks: "{count} ลิงก์",
badgeProof: "หลักฐาน {count} รายการ",
badgeArtifacts: "อาร์ติแฟกต์ {count} รายการ",
badgeTenant: "ผู้เช่า {tenant}",
badgeSkills: "{count} ทักษะ",
badgeDispatches: "ส่งงาน {count} ครั้ง",
badgeClaimed: "ถูกอ้างสิทธิ์โดย {owner}",
badgeDiagnostics: "การวินิจฉัย {count} รายการ",
badgeStale: "ค้างนาน",
@@ -567,6 +573,7 @@ export const th: TranslationMap = {
eventArtifactAdded: "เพิ่มอาร์ติแฟกต์แล้ว",
eventDiagnostic: "การวินิจฉัย",
eventNotification: "การแจ้งเตือน",
eventDispatch: "ส่งงาน",
eventArchived: "เก็บถาวรแล้ว",
eventUnarchived: "ยกเลิกการเก็บถาวรแล้ว",
eventStale: "เซสชันหมดอายุ",
+7
View File
@@ -487,8 +487,11 @@ export const tr: TranslationMap = {
enableConfigKey: "plugins.entries.workboard.enabled = true",
disabledHelpEnd: ", ardından bu sekmeyi yeniden yükleyin.",
status: {
triage: "Triage",
backlog: "Birikmiş İşler",
todo: "Yapılacaklar",
scheduled: "Zamanlandı",
ready: "Hazır",
running: "Çalışıyor",
review: "İncele",
blocked: "Engelli",
@@ -531,6 +534,9 @@ export const tr: TranslationMap = {
badgeLinks: "{count} bağlantı",
badgeProof: "{count} kanıt",
badgeArtifacts: "{count} yapıt",
badgeTenant: "kiracı {tenant}",
badgeSkills: "{count} beceri",
badgeDispatches: "{count} gönderim",
badgeClaimed: "{owner} tarafından üstlenildi",
badgeDiagnostics: "{count} tanılama",
badgeStale: "eski",
@@ -571,6 +577,7 @@ export const tr: TranslationMap = {
eventArtifactAdded: "Yapıt eklendi",
eventDiagnostic: "Tanılama",
eventNotification: "Bildirim",
eventDispatch: "Gönderim",
eventArchived: "Arşivlendi",
eventUnarchived: "Arşivden çıkarıldı",
eventStale: "Eski oturum",
+7
View File
@@ -486,8 +486,11 @@ export const uk: TranslationMap = {
enableConfigKey: "plugins.entries.workboard.enabled = true",
disabledHelpEnd: ", потім перезавантажте цю вкладку.",
status: {
triage: "Triage",
backlog: "Беклог",
todo: "До виконання",
scheduled: "Заплановано",
ready: "Готово",
running: "Запущено",
review: "Огляд",
blocked: "Заблоковано",
@@ -530,6 +533,9 @@ export const uk: TranslationMap = {
badgeLinks: "{count} посилань",
badgeProof: "{count} доказів",
badgeArtifacts: "{count} артефактів",
badgeTenant: "орендар {tenant}",
badgeSkills: "{count} навичок",
badgeDispatches: "{count} відправлень",
badgeClaimed: "призначено {owner}",
badgeDiagnostics: "{count} діагностик",
badgeStale: "застаріле",
@@ -570,6 +576,7 @@ export const uk: TranslationMap = {
eventArtifactAdded: "Артефакт додано",
eventDiagnostic: "Діагностика",
eventNotification: "Сповіщення",
eventDispatch: "Відправлення",
eventArchived: "Заархівовано",
eventUnarchived: "Розархівовано",
eventStale: "Застарілий сеанс",
+7
View File
@@ -485,8 +485,11 @@ export const vi: TranslationMap = {
enableConfigKey: "plugins.entries.workboard.enabled = true",
disabledHelpEnd: ", rồi tải lại tab này.",
status: {
triage: "Triage",
backlog: "Việc tồn đọng",
todo: "Việc cần làm",
scheduled: "Đã lên lịch",
ready: "Sẵn sàng",
running: "Đang chạy",
review: "Đánh giá",
blocked: "Bị chặn",
@@ -529,6 +532,9 @@ export const vi: TranslationMap = {
badgeLinks: "{count} liên kết",
badgeProof: "{count} bằng chứng",
badgeArtifacts: "{count} hiện vật",
badgeTenant: "đối tượng thuê {tenant}",
badgeSkills: "{count} kỹ năng",
badgeDispatches: "{count} lượt điều phối",
badgeClaimed: "được nhận bởi {owner}",
badgeDiagnostics: "{count} chẩn đoán",
badgeStale: "cũ",
@@ -569,6 +575,7 @@ export const vi: TranslationMap = {
eventArtifactAdded: "Đã thêm hiện vật",
eventDiagnostic: "Chẩn đoán",
eventNotification: "Thông báo",
eventDispatch: "Điều phối",
eventArchived: "Đã lưu trữ",
eventUnarchived: "Đã bỏ lưu trữ",
eventStale: "Phiên đã cũ",
+7
View File
@@ -482,8 +482,11 @@ export const zh_CN: TranslationMap = {
enableConfigKey: "plugins.entries.workboard.enabled = true",
disabledHelpEnd: ",然后重新加载此标签页。",
status: {
triage: "Triage",
backlog: "待办池",
todo: "待办",
scheduled: "已计划",
ready: "就绪",
running: "运行中",
review: "查看",
blocked: "已阻挡",
@@ -526,6 +529,9 @@ export const zh_CN: TranslationMap = {
badgeLinks: "{count} 个链接",
badgeProof: "{count} 个证明",
badgeArtifacts: "{count} 个产物",
badgeTenant: "租户 {tenant}",
badgeSkills: "{count} 个技能",
badgeDispatches: "{count} 次调度",
badgeClaimed: "由 {owner} 认领",
badgeDiagnostics: "{count} 个诊断",
badgeStale: "已过期",
@@ -566,6 +572,7 @@ export const zh_CN: TranslationMap = {
eventArtifactAdded: "产物已添加",
eventDiagnostic: "诊断",
eventNotification: "通知",
eventDispatch: "调度",
eventArchived: "已归档",
eventUnarchived: "已取消归档",
eventStale: "过期会话",
+7
View File
@@ -482,8 +482,11 @@ export const zh_TW: TranslationMap = {
enableConfigKey: "plugins.entries.workboard.enabled = true",
disabledHelpEnd: ",然後重新載入此分頁。",
status: {
triage: "Triage",
backlog: "待處理",
todo: "待辦",
scheduled: "已排程",
ready: "就緒",
running: "執行中",
review: "檢視",
blocked: "已封鎖",
@@ -526,6 +529,9 @@ export const zh_TW: TranslationMap = {
badgeLinks: "{count} 個連結",
badgeProof: "{count} 個證明",
badgeArtifacts: "{count} 個產物",
badgeTenant: "租戶 {tenant}",
badgeSkills: "{count} 個技能",
badgeDispatches: "{count} 次調度",
badgeClaimed: "由 {owner} 認領",
badgeDiagnostics: "{count} 個診斷",
badgeStale: "過期",
@@ -566,6 +572,7 @@ export const zh_TW: TranslationMap = {
eventArtifactAdded: "產物已新增",
eventDiagnostic: "診斷",
eventNotification: "通知",
eventDispatch: "調度",
eventArchived: "已封存",
eventUnarchived: "已取消封存",
eventStale: "過期工作階段",
+4 -2
View File
@@ -358,7 +358,9 @@
.workboard-board {
display: grid;
grid-template-columns: repeat(6, minmax(220px, 1fr));
grid-auto-flow: column;
grid-auto-columns: minmax(220px, 1fr);
grid-template-columns: none;
gap: 12px;
flex: 1;
min-height: 0;
@@ -658,6 +660,6 @@
}
.workboard-board {
grid-template-columns: repeat(6, minmax(260px, 82vw));
grid-auto-columns: minmax(260px, 82vw);
}
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { readStyleSheet } from "../../../test/helpers/ui-style-fixtures.js";
function readWorkboardCss(): string {
return readStyleSheet("ui/src/styles/workboard.css");
}
describe("workboard styles", () => {
it("keeps status columns in one horizontally scrollable grid row", () => {
const css = readWorkboardCss();
expect(css).toContain(".workboard-board {\n display: grid;\n grid-auto-flow: column;");
expect(css).toContain("grid-auto-columns: minmax(220px, 1fr);");
expect(css).toContain("overflow-x: auto;");
expect(css).toContain("grid-auto-columns: minmax(260px, 82vw);");
expect(css).not.toContain("grid-template-columns: repeat(6");
});
});
+393 -7
View File
@@ -4,6 +4,7 @@ import {
archiveWorkboardCard,
captureSessionToWorkboard,
createWorkboardCard,
deleteWorkboardCard,
getWorkboardLifecycle,
getWorkboardState,
loadWorkboard,
@@ -68,6 +69,39 @@ describe("workboard controller", () => {
expect(getWorkboardState(host).cards).toEqual([sampleCard]);
});
it("preserves automation metadata loaded from the plugin gateway method", async () => {
const host = {};
const client = createClient({
"workboard.cards.list": {
cards: [
{
...sampleCard,
metadata: {
automation: {
tenant: "qa",
skills: ["testing"],
workspace: { kind: "scratch" },
dispatchCount: 2,
lastDispatchAt: 20,
},
},
},
],
statuses: ["ready", "done"],
},
});
await loadWorkboard({ host, client: client as never, force: true });
expect(getWorkboardState(host).cards[0]?.metadata?.automation).toMatchObject({
tenant: "qa",
skills: ["testing"],
workspace: { kind: "scratch" },
dispatchCount: 2,
lastDispatchAt: 20,
});
});
it("creates cards from draft state", async () => {
const host = {};
const state = getWorkboardState(host);
@@ -411,15 +445,23 @@ describe("workboard controller", () => {
expect(sessionKey).toBe("agent:main:dashboard:1");
expect(client.request).toHaveBeenNthCalledWith(
1,
"workboard.cards.update",
expect.objectContaining({
id: "card-1",
patch: { status: "running" },
}),
);
expect(client.request).toHaveBeenNthCalledWith(
2,
"sessions.create",
expect.objectContaining({
label: "Build board (card-1)",
message: expect.stringContaining("Work on this OpenClaw Workboard card: Build board"),
}),
);
expect(client.request.mock.calls[0]?.[1]).not.toHaveProperty("model");
expect(client.request.mock.calls[1]?.[1]).not.toHaveProperty("model");
expect(client.request).toHaveBeenNthCalledWith(
2,
3,
"workboard.cards.update",
expect.objectContaining({
id: "card-1",
@@ -429,7 +471,290 @@ describe("workboard controller", () => {
}),
}),
);
expect(client.request.mock.calls[1]?.[1]).toHaveProperty("patch.execution", null);
expect(client.request.mock.calls[2]?.[1]).toHaveProperty("patch.execution", null);
});
it("lets the gateway preflight decide starts when local parent state is stale", async () => {
const host = {};
const parent = { ...sampleCard, id: "parent-1", title: "Parent", status: "running" };
const child: WorkboardCard = {
...sampleCard,
id: "child-1",
title: "Child",
metadata: {
links: [{ id: "link-1", type: "parent", targetCardId: parent.id, createdAt: 1 }],
},
};
const running = { ...child, status: "running", sessionKey: "agent:main:dashboard:child" };
const client = createClient((method) => {
if (method === "workboard.cards.list") {
return { cards: [parent, child], statuses: ["todo", "running", "done"] };
}
if (method === "sessions.create") {
return { key: "agent:main:dashboard:child" };
}
return { card: running };
});
await loadWorkboard({ host, client: client as never, force: true });
client.request.mockClear();
const sessionKey = await startWorkboardCard({
host,
client: client as never,
card: child,
});
expect(sessionKey).toBe("agent:main:dashboard:child");
expect(client.request).toHaveBeenNthCalledWith(
1,
"workboard.cards.update",
expect.objectContaining({ id: child.id, patch: { status: "running" } }),
);
});
it("does not create a session when the gateway rejects start preflight", async () => {
const host = {};
const client = createClient((method) => {
if (method === "workboard.cards.update") {
throw new Error("Parent cards must be done before starting this card.");
}
return { key: "agent:main:dashboard:1" };
});
const sessionKey = await startWorkboardCard({
host,
client: client as never,
card: sampleCard,
});
expect(sessionKey).toBeNull();
expect(client.request).toHaveBeenCalledTimes(1);
expect(client.request).toHaveBeenCalledWith(
"workboard.cards.update",
expect.objectContaining({ patch: { status: "running" } }),
);
expect(getWorkboardState(host).error).toBe(
"Parent cards must be done before starting this card.",
);
});
it("rolls back the running preflight when session creation fails", async () => {
const host = {};
const running = { ...sampleCard, status: "running" } satisfies WorkboardCard;
let updateCalls = 0;
const client = createClient((method) => {
if (method === "workboard.cards.update") {
updateCalls += 1;
return { card: updateCalls === 1 ? running : sampleCard };
}
if (method === "sessions.create") {
throw new Error("gateway disconnected");
}
return {};
});
const sessionKey = await startWorkboardCard({
host,
client: client as never,
card: sampleCard,
});
expect(sessionKey).toBeNull();
expect(client.request).toHaveBeenNthCalledWith(
1,
"workboard.cards.update",
expect.objectContaining({ patch: { status: "running" } }),
);
expect(client.request).toHaveBeenNthCalledWith(
3,
"workboard.cards.update",
expect.objectContaining({
patch: expect.objectContaining({
status: "todo",
startedAt: null,
completedAt: null,
}),
}),
);
expect(getWorkboardState(host).cards).toEqual([sampleCard]);
expect(getWorkboardState(host).error).toBe("gateway disconnected");
});
it("rolls back the running preflight when final session link update fails", async () => {
const host = {};
const running = { ...sampleCard, status: "running" } satisfies WorkboardCard;
let updateCalls = 0;
const client = createClient((method) => {
if (method === "workboard.cards.update") {
updateCalls += 1;
if (updateCalls === 1) {
return { card: running };
}
if (updateCalls === 2) {
throw new Error("write conflict");
}
return { card: sampleCard };
}
if (method === "sessions.create") {
return { key: "agent:main:dashboard:1", runId: "run-1" };
}
return {};
});
const sessionKey = await startWorkboardCard({
host,
client: client as never,
card: sampleCard,
});
expect(sessionKey).toBeNull();
expect(client.request).toHaveBeenNthCalledWith(
4,
"workboard.cards.update",
expect.objectContaining({
patch: expect.objectContaining({
status: "todo",
startedAt: null,
completedAt: null,
}),
}),
);
expect(getWorkboardState(host).cards).toEqual([sampleCard]);
expect(getWorkboardState(host).error).toBe("write conflict");
});
it("does not start a card before its scheduled time", async () => {
const host = {};
const scheduled = {
...sampleCard,
id: "scheduled-1",
status: "scheduled",
metadata: { automation: { scheduledAt: Date.now() + 60_000 } },
} satisfies WorkboardCard;
const client = createClient({
"workboard.cards.list": { cards: [scheduled], statuses: ["scheduled", "running", "done"] },
});
await loadWorkboard({ host, client: client as never, force: true });
client.request.mockClear();
const sessionKey = await startWorkboardCard({
host,
client: client as never,
card: scheduled,
});
expect(sessionKey).toBeNull();
expect(client.request).not.toHaveBeenCalled();
expect(getWorkboardState(host).error).toBe(
"Scheduled cards cannot start before their scheduled time.",
);
const manualScheduled = {
...sampleCard,
id: "scheduled-2",
status: "scheduled",
metadata: { automation: { scheduledAt: Date.now() + 60_000 } },
} satisfies WorkboardCard;
const manualLinked = {
...manualScheduled,
status: "todo",
metadata: {},
sessionKey: "agent:main:dashboard:manual",
execution: {
id: "exec-manual",
kind: "agent-session",
engine: "codex",
mode: "manual",
status: "idle",
model: "openai/gpt-5.5",
sessionKey: "agent:main:dashboard:manual",
startedAt: 1,
updatedAt: 1,
},
} satisfies WorkboardCard;
const manualClient = createClient({
"sessions.create": { key: "agent:main:dashboard:manual" },
"workboard.cards.update": { card: manualLinked },
});
const manualSessionKey = await startWorkboardCard({
host,
client: manualClient as never,
card: manualScheduled,
mode: "manual",
});
expect(manualSessionKey).toBe("agent:main:dashboard:manual");
expect(manualClient.request).toHaveBeenNthCalledWith(
1,
"sessions.create",
expect.not.objectContaining({ message: expect.any(String) }),
);
expect(manualClient.request).toHaveBeenNthCalledWith(
2,
"workboard.cards.update",
expect.objectContaining({
id: manualScheduled.id,
patch: expect.objectContaining({ status: "todo", scheduledAt: null }),
}),
);
const readyWithSchedule = {
...sampleCard,
id: "scheduled-2b",
status: "ready",
metadata: { automation: { scheduledAt: Date.now() + 60_000 } },
} satisfies WorkboardCard;
const readyManualClient = createClient({
"sessions.create": { key: "agent:main:dashboard:ready-manual" },
"workboard.cards.update": {
card: { ...readyWithSchedule, sessionKey: "agent:main:dashboard:ready-manual" },
},
});
await startWorkboardCard({
host,
client: readyManualClient as never,
card: readyWithSchedule,
mode: "manual",
});
expect(readyManualClient.request).toHaveBeenNthCalledWith(
2,
"workboard.cards.update",
expect.objectContaining({
id: readyWithSchedule.id,
patch: expect.objectContaining({ status: "ready", scheduledAt: null }),
}),
);
const dueScheduled = {
...scheduled,
id: "scheduled-3",
metadata: { automation: { scheduledAt: Date.now() - 60_000 } },
} satisfies WorkboardCard;
const dueRunning = {
...dueScheduled,
status: "running",
sessionKey: "agent:main:dashboard:1",
} satisfies WorkboardCard;
const dueClient = createClient({
"workboard.cards.list": { cards: [dueScheduled], statuses: ["scheduled", "running", "done"] },
"sessions.create": { key: "agent:main:dashboard:1", runId: "run-1" },
"workboard.cards.update": { card: dueRunning },
});
await loadWorkboard({ host, client: dueClient as never, force: true });
dueClient.request.mockClear();
const dueSessionKey = await startWorkboardCard({
host,
client: dueClient as never,
card: dueScheduled,
});
expect(dueSessionKey).toBe("agent:main:dashboard:1");
expect(dueClient.request).toHaveBeenCalledWith(
"sessions.create",
expect.objectContaining({
label: "Build board (schedule)",
}),
);
});
it("starts a Codex execution with an explicit model override", async () => {
@@ -465,6 +790,13 @@ describe("workboard controller", () => {
expect(client.request).toHaveBeenNthCalledWith(
1,
"workboard.cards.update",
expect.objectContaining({
patch: { status: "running" },
}),
);
expect(client.request).toHaveBeenNthCalledWith(
2,
"sessions.create",
expect.objectContaining({
model: "openai/gpt-5.5",
@@ -472,7 +804,7 @@ describe("workboard controller", () => {
}),
);
expect(client.request).toHaveBeenNthCalledWith(
2,
3,
"workboard.cards.update",
expect.objectContaining({
id: "card-1",
@@ -521,7 +853,7 @@ describe("workboard controller", () => {
});
expect(client.request).toHaveBeenNthCalledWith(
2,
3,
"workboard.cards.update",
expect.objectContaining({
patch: expect.objectContaining({
@@ -616,7 +948,7 @@ describe("workboard controller", () => {
expect(sessionKey).toBe("agent:main:dashboard:1");
expect(client.request).toHaveBeenNthCalledWith(
2,
3,
"workboard.cards.update",
expect.objectContaining({
id: "card-1",
@@ -626,7 +958,7 @@ describe("workboard controller", () => {
}),
}),
);
expect(client.request.mock.calls[1]?.[1]).toHaveProperty("patch.execution", null);
expect(client.request.mock.calls[2]?.[1]).toHaveProperty("patch.execution", null);
expect(getWorkboardState(host).error).toBe("Agent run did not start: provider unavailable");
});
@@ -649,6 +981,60 @@ describe("workboard controller", () => {
});
});
it("removes stale dependency links from local cards after delete", async () => {
const host = {};
const parent: WorkboardCard = {
...sampleCard,
id: "parent-1",
title: "Parent",
status: "done",
};
const child: WorkboardCard = {
...sampleCard,
id: "child-1",
title: "Child",
metadata: {
links: [{ id: "link-1", type: "parent", targetCardId: parent.id, createdAt: 1 }],
},
};
const client = createClient((method) => {
if (method === "workboard.cards.delete") {
return { deleted: true };
}
if (method === "sessions.create") {
return { key: "agent:main:dashboard:child", runId: "run-child" };
}
return { card: { ...child, status: "running", metadata: undefined } };
});
getWorkboardState(host).cards = [parent, child];
await deleteWorkboardCard({
host,
client: client as never,
cardId: parent.id,
});
const remaining = getWorkboardState(host).cards[0];
expect(remaining).toMatchObject({ id: child.id });
expect(remaining?.metadata?.links).toBeUndefined();
client.request.mockClear();
await startWorkboardCard({
host,
client: client as never,
card: remaining,
});
expect(client.request).toHaveBeenNthCalledWith(
1,
"workboard.cards.update",
expect.objectContaining({
id: child.id,
patch: { status: "running" },
}),
);
});
it("derives lifecycle state from linked dashboard sessions", () => {
expect(getWorkboardLifecycle(sampleCard, [sampleSession])).toEqual({
session: null,
+174 -14
View File
@@ -2,8 +2,11 @@ import type { GatewayBrowserClient } from "../gateway.ts";
import type { GatewaySessionRow } from "../types.ts";
export const WORKBOARD_STATUSES = [
"triage",
"backlog",
"todo",
"scheduled",
"ready",
"running",
"review",
"blocked",
@@ -36,6 +39,7 @@ export const WORKBOARD_EVENT_KINDS = [
"artifact_added",
"diagnostic",
"notification",
"dispatch",
"archived",
"unarchived",
"stale",
@@ -47,7 +51,13 @@ export const WORKBOARD_ATTEMPT_STATUSES = [
"blocked",
"stopped",
] as const;
export const WORKBOARD_LINK_TYPES = ["blocks", "blocked_by", "relates_to"] as const;
export const WORKBOARD_LINK_TYPES = [
"parent",
"child",
"blocks",
"blocked_by",
"relates_to",
] as const;
export const WORKBOARD_PROOF_STATUSES = ["passed", "failed", "skipped", "unknown"] as const;
export const WORKBOARD_TEMPLATE_IDS = ["bugfix", "docs", "release", "pr_review", "plugin"] as const;
export const WORKBOARD_DIAGNOSTIC_SEVERITIES = ["warning", "error", "critical"] as const;
@@ -173,12 +183,33 @@ export type WorkboardNotification = {
runId?: string;
};
export type WorkboardWorkspace = {
kind: "scratch" | "dir" | "worktree";
path?: string;
branch?: string;
};
export type WorkboardAutomation = {
tenant?: string;
idempotencyKey?: string;
skills?: string[];
workspace?: WorkboardWorkspace;
maxRuntimeSeconds?: number;
maxRetries?: number;
scheduledAt?: number;
summary?: string;
createdCardIds?: string[];
dispatchCount?: number;
lastDispatchAt?: number;
};
export type WorkboardMetadata = {
attempts?: WorkboardRunAttempt[];
comments?: WorkboardComment[];
links?: WorkboardLink[];
proof?: WorkboardProof[];
artifacts?: WorkboardArtifact[];
automation?: WorkboardAutomation;
claim?: WorkboardClaim;
diagnostics?: WorkboardDiagnostic[];
notifications?: WorkboardNotification[];
@@ -393,6 +424,50 @@ function normalizeEvents(value: unknown): WorkboardEvent[] {
: [];
}
function normalizeStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "")
: [];
}
function normalizeAutomation(value: unknown): WorkboardAutomation | undefined {
if (!isRecord(value)) {
return undefined;
}
const workspace = isRecord(value.workspace)
? {
kind:
value.workspace.kind === "scratch" ||
value.workspace.kind === "dir" ||
value.workspace.kind === "worktree"
? value.workspace.kind
: undefined,
...(typeof value.workspace.path === "string" ? { path: value.workspace.path } : {}),
...(typeof value.workspace.branch === "string" ? { branch: value.workspace.branch } : {}),
}
: undefined;
const automation: WorkboardAutomation = {
...(typeof value.tenant === "string" ? { tenant: value.tenant } : {}),
...(typeof value.idempotencyKey === "string" ? { idempotencyKey: value.idempotencyKey } : {}),
...(normalizeStringArray(value.skills).length
? { skills: normalizeStringArray(value.skills) }
: {}),
...(workspace?.kind ? { workspace: workspace as WorkboardWorkspace } : {}),
...(typeof value.maxRuntimeSeconds === "number"
? { maxRuntimeSeconds: value.maxRuntimeSeconds }
: {}),
...(typeof value.maxRetries === "number" ? { maxRetries: value.maxRetries } : {}),
...(typeof value.scheduledAt === "number" ? { scheduledAt: value.scheduledAt } : {}),
...(typeof value.summary === "string" ? { summary: value.summary } : {}),
...(normalizeStringArray(value.createdCardIds).length
? { createdCardIds: normalizeStringArray(value.createdCardIds) }
: {}),
...(typeof value.dispatchCount === "number" ? { dispatchCount: value.dispatchCount } : {}),
...(typeof value.lastDispatchAt === "number" ? { lastDispatchAt: value.lastDispatchAt } : {}),
};
return Object.keys(automation).length ? automation : undefined;
}
function normalizeMetadata(value: unknown): WorkboardMetadata | undefined {
if (!isRecord(value)) {
return undefined;
@@ -585,12 +660,14 @@ function normalizeMetadata(value: unknown): WorkboardMetadata | undefined {
: "Session has not reported recent activity.",
}
: undefined;
const automation = normalizeAutomation(value.automation);
const metadata: WorkboardMetadata = {
...(attempts.length ? { attempts } : {}),
...(comments.length ? { comments } : {}),
...(links.length ? { links } : {}),
...(proof.length ? { proof } : {}),
...(artifacts.length ? { artifacts } : {}),
...(automation ? { automation } : {}),
...(claim?.ownerId && claim.claimedAt ? { claim } : {}),
...(diagnostics.length ? { diagnostics } : {}),
...(notifications.length ? { notifications } : {}),
@@ -718,6 +795,29 @@ function replaceCard(state: WorkboardUiState, card: WorkboardCard) {
state.cards = next.toSorted((left, right) => left.position - right.position);
}
function removeCardAndReferences(cards: readonly WorkboardCard[], cardId: string): WorkboardCard[] {
const nextCards: WorkboardCard[] = [];
for (const card of cards) {
if (card.id === cardId) {
continue;
}
const links = card.metadata?.links;
if (!links?.some((link) => link.targetCardId === cardId)) {
nextCards.push(card);
continue;
}
const nextLinks = links.filter((link) => link.targetCardId !== cardId);
const metadata: WorkboardMetadata = { ...card.metadata, links: nextLinks };
if (nextLinks.length === 0) {
delete metadata.links;
}
nextCards.push(
Object.keys(metadata).length ? { ...card, metadata } : { ...card, metadata: undefined },
);
}
return nextCards;
}
function resetDraftState(state: WorkboardUiState) {
state.draftOpen = false;
state.editingCardId = null;
@@ -821,10 +921,10 @@ function shouldSyncCardStatus(card: WorkboardCard, targetStatus: WorkboardStatus
return false;
}
if (targetStatus === "running") {
return card.status === "backlog" || card.status === "todo";
return card.status === "backlog" || card.status === "todo" || card.status === "ready";
}
if (targetStatus === "blocked" || targetStatus === "review") {
return card.status === "running" || card.status === "todo";
return card.status === "running" || card.status === "todo" || card.status === "ready";
}
return false;
}
@@ -1246,7 +1346,7 @@ export async function deleteWorkboardCard(params: {
params.requestUpdate?.();
try {
await params.client.request("workboard.cards.delete", { id: params.cardId });
state.cards = state.cards.filter((card) => card.id !== params.cardId);
state.cards = removeCardAndReferences(state.cards, params.cardId);
} catch (error) {
state.error = formatError(error);
} finally {
@@ -1290,6 +1390,19 @@ function buildCardPrompt(card: WorkboardCard): string {
if (card.labels.length > 0) {
lines.push("", `Labels: ${card.labels.join(", ")}`);
}
const parents = card.metadata?.links
?.filter((link) => link.type === "parent" && link.targetCardId)
.map((link) => link.targetCardId);
if (parents?.length) {
lines.push("", `Parents: ${parents.join(", ")}`);
}
if (card.metadata?.automation?.skills?.length) {
lines.push("", `Suggested skills: ${card.metadata.automation.skills.join(", ")}`);
}
if (card.metadata?.automation?.workspace) {
const workspace = card.metadata.automation.workspace;
lines.push("", `Workspace: ${workspace.kind}${workspace.path ? ` ${workspace.path}` : ""}`);
}
lines.push("", "When done, summarize what changed and what remains.");
return lines.join("\n");
}
@@ -1305,6 +1418,14 @@ function buildCardSessionLabel(card: WorkboardCard): string {
return `${title.slice(0, titleMax - 3).trimEnd()}...${suffixText}`;
}
function isScheduledForLater(card: WorkboardCard, now = Date.now()): boolean {
const scheduledAt = card.metadata?.automation?.scheduledAt;
if (typeof scheduledAt === "number") {
return scheduledAt > now;
}
return card.status === "scheduled";
}
function buildWorkboardExecution(params: {
card: WorkboardCard;
engine: WorkboardExecutionEngine;
@@ -1340,17 +1461,41 @@ export async function startWorkboardCard(params: {
if (!params.client) {
return null;
}
state.busyCardId = params.card.id;
state.error = null;
params.requestUpdate?.();
const engine = params.engine;
const mode = params.mode ?? "autonomous";
state.error = null;
if (mode === "autonomous" && isScheduledForLater(params.card)) {
state.error = "Scheduled cards cannot start before their scheduled time.";
params.requestUpdate?.();
return null;
}
state.busyCardId = params.card.id;
params.requestUpdate?.();
let preflightCard: WorkboardCard | null = null;
try {
const shouldClearManualSchedule =
mode === "manual" && params.card.metadata?.automation?.scheduledAt !== undefined;
const shouldUnscheduleManual = mode === "manual" && params.card.status === "scheduled";
const nextCardStatus =
mode === "autonomous" ? "running" : shouldUnscheduleManual ? "todo" : params.card.status;
const nextExecutionStatus = mode === "autonomous" ? "running" : "idle";
let card = params.card;
if (mode === "autonomous") {
const preflightPayload = await params.client.request("workboard.cards.update", {
id: params.card.id,
patch: { status: nextCardStatus },
});
preflightCard = normalizeCardPayload(preflightPayload);
if (preflightCard) {
replaceCard(state, preflightCard);
card = preflightCard;
}
}
const created = await params.client.request("sessions.create", {
...(params.card.agentId ? { agentId: params.card.agentId } : {}),
label: buildCardSessionLabel(params.card),
...(card.agentId ? { agentId: card.agentId } : {}),
label: buildCardSessionLabel(card),
...(engine ? { model: WORKBOARD_ENGINE_MODELS[engine] } : {}),
...(mode === "autonomous" ? { message: buildCardPrompt(params.card) } : {}),
...(mode === "autonomous" ? { message: buildCardPrompt(card) } : {}),
});
const sessionKey =
isRecord(created) && typeof created.key === "string" && created.key.trim()
@@ -1371,7 +1516,7 @@ export async function startWorkboardCard(params: {
...(engine
? {
execution: buildWorkboardExecution({
card: params.card,
card,
engine,
mode,
sessionKey,
@@ -1390,18 +1535,17 @@ export async function startWorkboardCard(params: {
: "Agent run did not start.";
return sessionKey;
}
const nextCardStatus = mode === "autonomous" ? "running" : params.card.status;
const nextExecutionStatus = mode === "autonomous" ? "running" : "idle";
const payload = await params.client.request("workboard.cards.update", {
id: params.card.id,
patch: {
status: nextCardStatus,
...(shouldClearManualSchedule ? { scheduledAt: null } : {}),
...(sessionKey ? { sessionKey } : {}),
...(runId ? { runId } : {}),
...(engine
? {
execution: buildWorkboardExecution({
card: params.card,
card,
engine,
mode,
sessionKey,
@@ -1415,6 +1559,22 @@ export async function startWorkboardCard(params: {
replaceCard(state, normalizeCardPayload(payload));
return sessionKey;
} catch (error) {
if (preflightCard) {
try {
const rollbackPayload = await params.client.request("workboard.cards.update", {
id: params.card.id,
patch: {
status: params.card.status,
startedAt: params.card.startedAt ?? null,
completedAt: params.card.completedAt ?? null,
...(params.card.execution !== undefined ? { execution: params.card.execution } : {}),
},
});
replaceCard(state, normalizeCardPayload(rollbackPayload) ?? params.card);
} catch {
replaceCard(state, params.card);
}
}
state.error = formatError(error);
return null;
} finally {
+1 -1
View File
@@ -53,7 +53,7 @@ describe("renderWorkboard", () => {
expect(container.textContent).toContain("Wire dashboard tab");
expect(container.textContent).toContain("Running");
expect(container.textContent).toContain("Dashboard session");
expect(container.querySelectorAll(".workboard-column")).toHaveLength(6);
expect(container.querySelectorAll(".workboard-column")).toHaveLength(9);
expect(container.querySelector(".workboard-card__priority")?.textContent).toContain("high");
});
+18
View File
@@ -138,6 +138,8 @@ function formatEventLabel(event: WorkboardEvent): string {
return t("workboard.eventDiagnostic");
case "notification":
return t("workboard.eventNotification");
case "dispatch":
return t("workboard.eventDispatch");
case "archived":
return t("workboard.eventArchived");
case "unarchived":
@@ -192,6 +194,15 @@ function renderMetadataBadges(card: WorkboardCard) {
metadata.artifacts?.length
? t("workboard.badgeArtifacts", { count: String(metadata.artifacts.length) })
: null,
metadata.automation?.tenant
? t("workboard.badgeTenant", { tenant: metadata.automation.tenant })
: null,
metadata.automation?.skills?.length
? t("workboard.badgeSkills", { count: String(metadata.automation.skills.length) })
: null,
metadata.automation?.dispatchCount
? t("workboard.badgeDispatches", { count: String(metadata.automation.dispatchCount) })
: null,
metadata.claim ? t("workboard.badgeClaimed", { owner: metadata.claim.ownerId }) : null,
metadata.diagnostics?.length
? t("workboard.badgeDiagnostics", { count: String(metadata.diagnostics.length) })
@@ -227,6 +238,13 @@ function matchesFilter(
card.execution?.model,
card.execution?.sessionKey,
card.metadata?.templateId,
card.metadata?.automation?.tenant,
card.metadata?.automation?.idempotencyKey,
card.metadata?.automation?.workspace?.kind,
card.metadata?.automation?.workspace?.path,
card.metadata?.automation?.workspace?.branch,
...(card.metadata?.automation?.skills ?? []),
...(card.metadata?.automation?.createdCardIds ?? []),
...(card.metadata?.comments ?? []).map((comment) => comment.body),
...(card.metadata?.links ?? []).flatMap((link) => [link.title, link.url, link.targetCardId]),
...(card.metadata?.proof ?? []).flatMap((proof) => [