feat(workboard): persist card metadata

This commit is contained in:
Peter Steinberger
2026-05-29 10:01:14 +01:00
parent ab3eca14f1
commit 1d645ff66b
49 changed files with 2613 additions and 88 deletions

View File

@@ -48,15 +48,17 @@ Each card stores:
- optional agent id
- optional linked session, run, task, or source URL
- optional execution metadata for a Codex or Claude session started from the card
- recent card events such as created, moved, linked, or agent-updated changes
- compact metadata for attempts, comments, links, proof, templates, archive state, and stale-session detection
- recent card events such as created, moved, linked, attempt, proof, 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.
Workboard keeps a compact per-card event history so operators can see how a
card moved through the board without opening the linked session. The event trail
is intentionally local metadata; it does not replace session transcripts or
GitHub issue history.
Workboard keeps compact per-card metadata so operators can see how a card moved
through the board without opening the linked session. Events, attempt summaries,
proof snippets, related links, comments, archive markers, and stale-session
markers are intentionally local metadata; they do not replace session
transcripts or GitHub issue history.
## Card executions
@@ -74,14 +76,20 @@ Execution metadata stores the selected engine, mode, model ref, session key,
run id, and lifecycle status on the card. Codex executions use
`openai/gpt-5.5`; Claude executions use `anthropic/claude-sonnet-4-6`.
Each linked execution also records an attempt summary on the same card record.
The attempt summary keeps the engine, mode, model, run id, timestamps, status,
and rolling failure count so repeated failures remain visible on the board.
## Session lifecycle sync
Cards can be linked to existing dashboard sessions or to the session created
when you start work from a card. Linked cards show the session lifecycle inline:
running, linked idle, done, failed, or missing.
running, stale, linked idle, done, failed, or missing.
If the linked session is missing, the card stays linked for context and still
offers start controls so you can restart work into a fresh dashboard session.
If an active linked session stops reporting recent activity, Workboard marks the
card stale and stores the marker as card metadata until the lifecycle clears it.
You can also capture an existing dashboard session from the Sessions tab with
Add to Workboard. The card is linked to that session, uses the session label or
@@ -118,12 +126,17 @@ lifecycle stay owned by the regular session system.
Use Stop on a live linked card to abort the active session run. Workboard marks
that card `blocked` so it remains visible for follow-up.
New cards can start from Workboard templates for bugfixes, docs, releases, PR
reviews, or plugin work. Templates prefill title, notes, labels, and priority,
and the selected template id is stored as card metadata.
## Permissions
The plugin registers Gateway RPC methods under the `workboard.*` namespace:
- `workboard.cards.list` requires `operator.read`
- create, update, move, and delete methods require `operator.write`
- `workboard.cards.export` requires `operator.read`
- create, update, move, delete, comment, link, proof, and archive methods require `operator.write`
Browsers connected with read-only operator access can inspect the board but
cannot mutate cards.

View File

@@ -49,8 +49,14 @@ describe("workboard gateway methods", () => {
"workboard.cards.update",
"workboard.cards.move",
"workboard.cards.delete",
"workboard.cards.comment",
"workboard.cards.link",
"workboard.cards.proof",
"workboard.cards.archive",
"workboard.cards.export",
]);
expect(methods.get("workboard.cards.list")?.opts).toEqual({ scope: "operator.read" });
expect(methods.get("workboard.cards.export")?.opts).toEqual({ scope: "operator.read" });
expect(methods.get("workboard.cards.create")?.opts).toEqual({ scope: "operator.write" });
const createHandler = methods.get("workboard.cards.create")?.handler;
@@ -69,6 +75,51 @@ describe("workboard gateway methods", () => {
});
});
it("stores metadata updates through dedicated card methods", async () => {
type RegisteredMethod = {
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
opts: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[2];
};
const methods = new Map<string, RegisteredMethod>();
const api = {
runtime: {
state: {
openKeyedStore: vi.fn(() => createMemoryStore()),
},
},
registerGatewayMethod: vi.fn(
(method: string, handler: RegisteredMethod["handler"], opts: RegisteredMethod["opts"]) => {
methods.set(method, { handler, opts });
},
),
} as unknown as OpenClawPluginApi;
registerWorkboardGatewayMethods({ api });
const createRespond = vi.fn();
await methods.get("workboard.cards.create")?.handler({
params: { title: "Carry metadata" },
respond: createRespond,
} as never);
const cardId = createRespond.mock.calls[0]?.[1]?.card.id;
const commentRespond = vi.fn();
await methods.get("workboard.cards.comment")?.handler({
params: { id: cardId, body: "Waiting on CI" },
respond: commentRespond,
} as never);
expect(commentRespond.mock.calls[0]?.[0]).toBe(true);
expect(commentRespond.mock.calls[0]?.[1]).toMatchObject({
card: {
metadata: {
comments: [expect.objectContaining({ body: "Waiting on CI" })],
},
events: expect.arrayContaining([expect.objectContaining({ kind: "comment_added" })]),
},
});
});
it("validates labels from comma-separated gateway input", async () => {
type RegisteredMethod = {
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];

View File

@@ -107,4 +107,72 @@ export function registerWorkboardGatewayMethods(params: { api: OpenClawPluginApi
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.comment",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: await store.addComment(readId(requestParams), requestParams),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.link",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: await store.addLink(readId(requestParams), requestParams),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.proof",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: await store.addProof(readId(requestParams), requestParams),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.archive",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: await store.archive(readId(requestParams), requestParams.archived),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.export",
async ({ respond }) => {
try {
respond(true, await store.exportCards());
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
}

View File

@@ -37,6 +37,17 @@ describe("WorkboardStore", () => {
expect(review.events?.[0]).toMatchObject({ kind: "created", toStatus: "review" });
});
it("does not persist empty metadata for default cards", async () => {
const keyed = createMemoryStore();
const store = new WorkboardStore(keyed);
const card = await store.create({ title: "Plain card" });
expect(card.metadata).toBeUndefined();
const entry = await keyed.lookup(card.id);
expect(Object.hasOwn(entry?.card ?? {}, "metadata")).toBe(false);
});
it("preserves explicit zero positions", async () => {
const store = new WorkboardStore(createMemoryStore());
@@ -75,6 +86,45 @@ describe("WorkboardStore", () => {
mode: "manual",
model: "anthropic/claude-sonnet-4-6",
},
metadata: {
attempts: [
expect.objectContaining({
id: "agent:main:dashboard:1",
status: "running",
engine: "claude",
mode: "manual",
sessionKey: "agent:main:dashboard:1",
startedAt: 10,
}),
],
},
});
});
it("stores card templates and metadata in the card record", async () => {
const keyed = createMemoryStore();
const store = new WorkboardStore(keyed);
const card = await store.create({
title: "Fix flaky lane",
templateId: "bugfix",
metadata: {
comments: [{ id: "comment-1", body: "Seen twice", createdAt: 10 }],
links: [{ id: "link-1", type: "blocks", targetCardId: "card-2", createdAt: 11 }],
proof: [{ id: "proof-1", status: "passed", command: "pnpm test", createdAt: 12 }],
},
});
await expect(keyed.lookup(card.id)).resolves.toMatchObject({
version: 1,
card: {
metadata: {
templateId: "bugfix",
comments: [expect.objectContaining({ body: "Seen twice" })],
links: [expect.objectContaining({ type: "blocks", targetCardId: "card-2" })],
proof: [expect.objectContaining({ status: "passed", command: "pnpm test" })],
},
},
});
});
@@ -130,6 +180,206 @@ describe("WorkboardStore", () => {
expect(cleared.execution).toBeUndefined();
});
it("tracks execution attempts as card metadata", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Run worker" });
const running = await store.update(card.id, {
status: "running",
execution: {
id: "exec-1",
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "running",
model: "openai/gpt-5.5",
sessionKey: "agent:main:dashboard:1",
runId: "run-1",
startedAt: 10,
updatedAt: 10,
},
});
expect(running.metadata?.attempts).toEqual([
expect.objectContaining({
id: "run-1",
status: "running",
engine: "codex",
runId: "run-1",
}),
]);
expect(running.events?.at(-1)).toMatchObject({ kind: "moved" });
const blocked = await store.update(card.id, {
execution: {
...running.execution!,
status: "blocked",
updatedAt: 20,
},
});
expect(blocked.metadata?.attempts?.[0]).toMatchObject({
status: "blocked",
endedAt: 20,
});
expect(blocked.metadata?.failureCount).toBe(1);
expect(blocked.events?.at(-1)).toMatchObject({ kind: "attempt_updated", runId: "run-1" });
const commented = await store.addComment(card.id, { body: "Need provider follow-up." });
expect(commented.metadata?.failureCount).toBe(1);
expect(commented.metadata?.attempts?.[0]).toMatchObject({
status: "blocked",
endedAt: 20,
});
const retrying = await store.update(card.id, {
execution: {
...running.execution!,
id: "exec-2",
runId: "run-2",
status: "running",
startedAt: 30,
updatedAt: 30,
},
});
expect(retrying.metadata?.failureCount).toBe(1);
expect(retrying.metadata?.attempts?.[1]).toMatchObject({
id: "run-2",
startedAt: 30,
status: "running",
});
const blockedAgain = await store.update(card.id, {
execution: {
...retrying.execution!,
status: "blocked",
updatedAt: 40,
},
});
expect(blockedAgain.metadata?.failureCount).toBe(2);
});
it("adds comments, links, proof, and archive metadata", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Track proof" });
const commented = await store.addComment(card.id, { body: "Reviewer asked for screenshots." });
expect(commented.metadata?.comments?.[0]).toMatchObject({
body: "Reviewer asked for screenshots.",
});
expect(commented.events?.at(-1)).toMatchObject({ kind: "comment_added" });
const linked = await store.addLink(card.id, {
type: "blocked_by",
targetCardId: "card-upstream",
title: "Upstream fix",
});
expect(linked.metadata?.links?.[0]).toMatchObject({
type: "blocked_by",
targetCardId: "card-upstream",
});
expect(linked.events?.at(-1)).toMatchObject({ kind: "link_added" });
const proven = await store.addProof(card.id, {
status: "passed",
command: "pnpm test extensions/workboard",
});
expect(proven.metadata?.proof?.[0]).toMatchObject({
status: "passed",
command: "pnpm test extensions/workboard",
});
expect(proven.events?.at(-1)).toMatchObject({ kind: "proof_added" });
const archived = await store.archive(card.id, true);
expect(archived.metadata?.archivedAt).toBeGreaterThan(0);
expect(archived.events?.at(-1)).toMatchObject({ kind: "archived" });
const restored = await store.archive(card.id, false);
expect(restored.metadata?.archivedAt).toBeUndefined();
expect(restored.events?.at(-1)).toMatchObject({ kind: "unarchived" });
});
it("keeps concurrent metadata appends from dropping siblings", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Collect notes" });
await Promise.all([
store.addComment(card.id, { body: "First note." }),
store.addComment(card.id, { body: "Second note." }),
]);
const saved = await store.get(card.id);
expect(saved?.metadata?.comments?.map((comment) => comment.body).toSorted()).toEqual([
"First note.",
"Second note.",
]);
});
it("keeps metadata under the keyed-store value budget", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Collect a lot of notes" });
for (let index = 0; index < 50; index += 1) {
await store.addComment(card.id, {
body: `${String(index).padStart(2, "0")} ${"x".repeat(1990)}`,
});
}
const saved = await store.get(card.id);
expect(Buffer.byteLength(JSON.stringify(saved?.metadata), "utf8")).toBeLessThanOrEqual(
24 * 1024,
);
expect(saved?.metadata?.comments?.at(-1)?.body).toContain("49 ");
expect(saved?.metadata?.comments?.length).toBeLessThan(50);
});
it("records append events when metadata retention drops old comments", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Track retained comments" });
let updated = card;
for (let index = 0; index < 51; index += 1) {
updated = await store.addComment(card.id, { body: `Note ${index}` });
}
expect(updated.metadata?.comments).toHaveLength(50);
expect(updated.metadata?.comments?.at(0)?.body).toBe("Note 1");
expect(updated.events?.at(-1)).toMatchObject({ kind: "comment_added" });
});
it("keeps queued metadata when lifecycle updates add stale state", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Sync stale state" });
await Promise.all([
store.update(card.id, {
status: "running",
metadata: {
stale: {
detectedAt: 10,
lastSessionUpdatedAt: 1,
reason: "Linked session has not reported recent activity.",
},
},
}),
store.addComment(card.id, { body: "Operator note." }),
]);
const saved = await store.get(card.id);
expect(saved?.status).toBe("running");
expect(saved?.metadata?.stale?.lastSessionUpdatedAt).toBe(1);
expect(saved?.metadata?.comments?.map((comment) => comment.body)).toContain("Operator note.");
});
it("exports card records with metadata", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Export me", templateId: "docs" });
await expect(store.exportCards()).resolves.toMatchObject({
cards: [expect.objectContaining({ id: card.id, metadata: { templateId: "docs" } })],
exportedAt: expect.any(Number),
});
});
it("rejects invalid status values", async () => {
const store = new WorkboardStore(createMemoryStore());
await expect(store.create({ title: "Bad card", status: "later" })).rejects.toThrow(

View File

@@ -3,23 +3,41 @@ import {
WORKBOARD_EXECUTION_ENGINES,
WORKBOARD_EXECUTION_MODES,
WORKBOARD_EXECUTION_STATUSES,
WORKBOARD_ATTEMPT_STATUSES,
WORKBOARD_EVENT_KINDS,
WORKBOARD_LINK_TYPES,
WORKBOARD_PRIORITIES,
WORKBOARD_PROOF_STATUSES,
WORKBOARD_STATUSES,
WORKBOARD_TEMPLATE_IDS,
type WorkboardCard,
type WorkboardAttemptStatus,
type WorkboardComment,
type WorkboardEvent,
type WorkboardEventKind,
type WorkboardExecution,
type WorkboardExecutionEngine,
type WorkboardExecutionMode,
type WorkboardExecutionStatus,
type WorkboardLink,
type WorkboardLinkType,
type WorkboardMetadata,
type WorkboardPriority,
type WorkboardProof,
type WorkboardProofStatus,
type WorkboardRunAttempt,
type WorkboardStatus,
type WorkboardTemplateId,
} from "./types.js";
const POSITION_STEP = 1000;
const MAX_CARDS = 2000;
const MAX_CARD_EVENTS = 50;
const MAX_CARD_ATTEMPTS = 30;
const MAX_CARD_COMMENTS = 50;
const MAX_CARD_LINKS = 50;
const MAX_CARD_PROOF = 40;
const MAX_CARD_METADATA_BYTES = 24 * 1024;
export type PersistedWorkboardCard = {
version: 1;
@@ -45,10 +63,26 @@ export type WorkboardCardInput = {
taskId?: unknown;
sourceUrl?: unknown;
execution?: unknown;
metadata?: unknown;
templateId?: unknown;
position?: unknown;
};
export type WorkboardCardPatch = Partial<WorkboardCardInput>;
export type WorkboardCommentInput = { body?: unknown };
export type WorkboardLinkInput = {
type?: unknown;
targetCardId?: unknown;
title?: unknown;
url?: unknown;
};
export type WorkboardProofInput = {
status?: unknown;
label?: unknown;
command?: unknown;
url?: unknown;
note?: unknown;
};
function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
@@ -76,6 +110,22 @@ function normalizeNotes(value: unknown): string | undefined {
return notes;
}
function normalizeBoundedString(
value: unknown,
fallback: string | undefined,
maxLength: number,
fieldName: string,
): string | undefined {
const normalized = normalizeOptionalString(value);
if (!normalized) {
return fallback;
}
if (normalized.length > maxLength) {
throw new Error(`${fieldName} must be ${maxLength} characters or fewer.`);
}
return normalized;
}
function normalizeStatus(value: unknown, fallback: WorkboardStatus): WorkboardStatus {
if (typeof value !== "string" || !value.trim()) {
return fallback;
@@ -168,6 +218,45 @@ function normalizeExecutionStatus(
return fallback;
}
function normalizeAttemptStatus(
value: unknown,
fallback: WorkboardAttemptStatus,
): WorkboardAttemptStatus {
if (
typeof value === "string" &&
WORKBOARD_ATTEMPT_STATUSES.includes(value as WorkboardAttemptStatus)
) {
return value as WorkboardAttemptStatus;
}
return fallback;
}
function normalizeLinkType(value: unknown, fallback: WorkboardLinkType): WorkboardLinkType {
if (typeof value === "string" && WORKBOARD_LINK_TYPES.includes(value as WorkboardLinkType)) {
return value as WorkboardLinkType;
}
return fallback;
}
function normalizeProofStatus(
value: unknown,
fallback: WorkboardProofStatus,
): WorkboardProofStatus {
if (
typeof value === "string" &&
WORKBOARD_PROOF_STATUSES.includes(value as WorkboardProofStatus)
) {
return value as WorkboardProofStatus;
}
return fallback;
}
function normalizeTemplateId(value: unknown): WorkboardTemplateId | undefined {
return typeof value === "string" && WORKBOARD_TEMPLATE_IDS.includes(value as WorkboardTemplateId)
? (value as WorkboardTemplateId)
: undefined;
}
function normalizeTimestamp(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value)
? Math.max(0, Math.trunc(value))
@@ -220,6 +309,165 @@ function normalizeEvents(value: unknown): WorkboardEvent[] {
.slice(-MAX_CARD_EVENTS);
}
function normalizeAttempt(value: unknown): WorkboardRunAttempt | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const id = normalizeOptionalString(record.id);
const startedAt = normalizeTimestamp(record.startedAt, 0);
if (!id || !startedAt) {
return null;
}
const endedAt = normalizeTimestamp(record.endedAt, 0);
const sessionKey = normalizeOptionalString(record.sessionKey);
const runId = normalizeOptionalString(record.runId);
const error = normalizeBoundedString(record.error, undefined, 800, "attempt error");
const model = normalizeBoundedString(record.model, undefined, 160, "attempt model");
return {
id,
status: normalizeAttemptStatus(record.status, "running"),
startedAt,
...(endedAt ? { endedAt } : {}),
...(typeof record.engine === "string" &&
WORKBOARD_EXECUTION_ENGINES.includes(record.engine as WorkboardExecutionEngine)
? { engine: record.engine as WorkboardExecutionEngine }
: {}),
...(typeof record.mode === "string" &&
WORKBOARD_EXECUTION_MODES.includes(record.mode as WorkboardExecutionMode)
? { mode: record.mode as WorkboardExecutionMode }
: {}),
...(model ? { model } : {}),
...(sessionKey ? { sessionKey } : {}),
...(runId ? { runId } : {}),
...(error ? { error } : {}),
};
}
function normalizeComment(value: unknown): WorkboardComment | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const id = normalizeOptionalString(record.id);
const body = normalizeBoundedString(record.body, undefined, 2000, "comment body");
const createdAt = normalizeTimestamp(record.createdAt, 0);
if (!id || !body || !createdAt) {
return null;
}
const updatedAt = normalizeTimestamp(record.updatedAt, 0);
return { id, body, createdAt, ...(updatedAt ? { updatedAt } : {}) };
}
function normalizeLink(value: unknown): WorkboardLink | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const id = normalizeOptionalString(record.id);
const createdAt = normalizeTimestamp(record.createdAt, 0);
if (!id || !createdAt) {
return null;
}
const targetCardId = normalizeBoundedString(record.targetCardId, undefined, 120, "link target");
const title = normalizeBoundedString(record.title, undefined, 180, "link title");
const url = normalizeBoundedString(record.url, undefined, 2000, "link URL");
if (!targetCardId && !url) {
return null;
}
return {
id,
type: normalizeLinkType(record.type, "relates_to"),
createdAt,
...(targetCardId ? { targetCardId } : {}),
...(title ? { title } : {}),
...(url ? { url } : {}),
};
}
function normalizeProof(value: unknown): WorkboardProof | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const id = normalizeOptionalString(record.id);
const createdAt = normalizeTimestamp(record.createdAt, 0);
if (!id || !createdAt) {
return null;
}
const label = normalizeBoundedString(record.label, undefined, 160, "proof label");
const command = normalizeBoundedString(record.command, undefined, 1000, "proof command");
const url = normalizeBoundedString(record.url, undefined, 2000, "proof URL");
const note = normalizeBoundedString(record.note, undefined, 2000, "proof note");
return {
id,
status: normalizeProofStatus(record.status, "unknown"),
createdAt,
...(label ? { label } : {}),
...(command ? { command } : {}),
...(url ? { url } : {}),
...(note ? { note } : {}),
};
}
function normalizeMetadata(value: unknown, fallback: WorkboardMetadata = {}): WorkboardMetadata {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return trimMetadataToBudget(fallback);
}
const record = value as Record<string, unknown>;
const stale =
record.stale && typeof record.stale === "object" && !Array.isArray(record.stale)
? (record.stale as Record<string, unknown>)
: null;
const hasArchivedAt = Object.hasOwn(record, "archivedAt");
const hasStale = Object.hasOwn(record, "stale");
return trimMetadataToBudget({
attempts: Array.isArray(record.attempts)
? record.attempts
.map(normalizeAttempt)
.filter((attempt): attempt is WorkboardRunAttempt => attempt !== null)
.slice(-MAX_CARD_ATTEMPTS)
: fallback.attempts,
comments: Array.isArray(record.comments)
? record.comments
.map(normalizeComment)
.filter((comment): comment is WorkboardComment => comment !== null)
.slice(-MAX_CARD_COMMENTS)
: fallback.comments,
links: Array.isArray(record.links)
? record.links
.map(normalizeLink)
.filter((link): link is WorkboardLink => link !== null)
.slice(-MAX_CARD_LINKS)
: fallback.links,
proof: Array.isArray(record.proof)
? record.proof
.map(normalizeProof)
.filter((proof): proof is WorkboardProof => proof !== null)
.slice(-MAX_CARD_PROOF)
: fallback.proof,
templateId: normalizeTemplateId(record.templateId) ?? fallback.templateId,
archivedAt: hasArchivedAt
? normalizeTimestamp(record.archivedAt, 0) || undefined
: fallback.archivedAt,
stale: hasStale
? stale
? {
detectedAt: normalizeTimestamp(stale.detectedAt, Date.now()),
lastSessionUpdatedAt: normalizeTimestamp(stale.lastSessionUpdatedAt, 0) || undefined,
reason:
normalizeBoundedString(stale.reason, fallback.stale?.reason, 240, "stale reason") ??
"Session has not reported recent activity.",
}
: undefined
: fallback.stale,
failureCount:
typeof record.failureCount === "number" && Number.isFinite(record.failureCount)
? Math.max(0, Math.trunc(record.failureCount))
: fallback.failureCount,
});
}
function normalizeExecution(value: unknown): WorkboardExecution | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
@@ -274,6 +522,66 @@ function removeUndefinedExecutionFields(execution: WorkboardExecution): Workboar
return next;
}
function removeUndefinedMetadataFields(metadata: WorkboardMetadata): WorkboardMetadata {
const next = { ...metadata };
for (const key of [
"attempts",
"comments",
"links",
"proof",
"templateId",
"archivedAt",
"stale",
"failureCount",
] as const) {
const value = next[key];
if (
value === undefined ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === "number" && value === 0 && key === "failureCount")
) {
delete next[key];
}
}
return next;
}
function metadataIsEmpty(metadata: WorkboardMetadata | undefined): boolean {
return !metadata || Object.keys(metadata).length === 0;
}
function metadataByteSize(metadata: WorkboardMetadata): number {
return Buffer.byteLength(JSON.stringify(metadata), "utf8");
}
function dropFirst<T>(items: readonly T[] | undefined): T[] | undefined {
if (!items?.length) {
return undefined;
}
const next = items.slice(1);
return next.length ? next : undefined;
}
function trimMetadataToBudget(metadata: WorkboardMetadata): WorkboardMetadata {
let next = removeUndefinedMetadataFields(metadata);
while (metadataByteSize(next) > MAX_CARD_METADATA_BYTES) {
const currentSize = metadataByteSize(next);
if (next.attempts?.length) {
next = removeUndefinedMetadataFields({ ...next, attempts: dropFirst(next.attempts) });
} else if (next.proof?.length) {
next = removeUndefinedMetadataFields({ ...next, proof: dropFirst(next.proof) });
} else if (next.links?.length) {
next = removeUndefinedMetadataFields({ ...next, links: dropFirst(next.links) });
} else if (next.comments?.length) {
next = removeUndefinedMetadataFields({ ...next, comments: dropFirst(next.comments) });
}
if (metadataByteSize(next) >= currentSize) {
break;
}
}
return next;
}
function compareCards(left: WorkboardCard, right: WorkboardCard): number {
if (left.status !== right.status) {
return WORKBOARD_STATUSES.indexOf(left.status) - WORKBOARD_STATUSES.indexOf(right.status);
@@ -292,6 +600,69 @@ function cardRunId(card: WorkboardCard): string | undefined {
return card.runId ?? card.execution?.runId;
}
function executionAttemptStatus(execution: WorkboardExecution): WorkboardAttemptStatus {
if (execution.status === "running") {
return "running";
}
if (execution.status === "blocked") {
return "blocked";
}
if (execution.status === "done" || execution.status === "review") {
return "succeeded";
}
return "stopped";
}
function syncExecutionAttemptMetadata(
metadata: WorkboardMetadata,
execution: WorkboardExecution | undefined,
now: number,
): WorkboardMetadata {
if (!execution) {
return metadata;
}
const attemptStatus = executionAttemptStatus(execution);
const attempts = [...(metadata.attempts ?? [])];
const key = execution.runId ?? execution.sessionKey ?? execution.id;
const existingIndex = attempts.findIndex(
(attempt) =>
(execution.runId && attempt.runId === execution.runId) ||
(!execution.runId && attempt.id === key),
);
const existingAttempt = existingIndex >= 0 ? attempts[existingIndex] : undefined;
const nextAttempt: WorkboardRunAttempt = {
id: existingAttempt?.id ?? key,
status: attemptStatus,
startedAt: existingAttempt?.startedAt ?? execution.startedAt,
engine: execution.engine,
mode: execution.mode,
model: execution.model,
...(execution.sessionKey ? { sessionKey: execution.sessionKey } : {}),
...(execution.runId ? { runId: execution.runId } : {}),
...(attemptStatus !== "running" && { endedAt: execution.updatedAt || now }),
};
if (existingIndex >= 0) {
attempts[existingIndex] = nextAttempt;
} else {
attempts.push(nextAttempt);
}
const previousFailed =
existingAttempt?.status === "blocked" || existingAttempt?.status === "failed";
const attemptFailed = attemptStatus === "blocked" || attemptStatus === "failed";
const failureCount = attemptFailed
? previousFailed
? metadata.failureCount
: (metadata.failureCount ?? 0) + 1
: attemptStatus === "succeeded"
? 0
: metadata.failureCount;
return removeUndefinedMetadataFields({
...metadata,
attempts: attempts.slice(-MAX_CARD_ATTEMPTS),
failureCount,
});
}
function appendEvent(
card: WorkboardCard,
event: Omit<WorkboardEvent, "id" | "at">,
@@ -307,6 +678,14 @@ function appendEvent(
].slice(-MAX_CARD_EVENTS);
}
function latestMetadataIdChanged(
existing: readonly { id: string }[] | undefined,
next: readonly { id: string }[] | undefined,
): boolean {
const latestId = next?.at(-1)?.id;
return Boolean(latestId && latestId !== existing?.at(-1)?.id);
}
function updateEvent(
existing: WorkboardCard,
next: WorkboardCard,
@@ -329,12 +708,59 @@ function updateEvent(
existing.execution?.engine !== next.execution?.engine ||
cardRunId(existing) !== cardRunId(next)
) {
const existingAttempts = existing.metadata?.attempts ?? [];
const nextAttempts = next.metadata?.attempts ?? [];
const latestAttempt = nextAttempts.at(-1);
if (nextAttempts.length > existingAttempts.length) {
return {
kind: "attempt_started",
...(latestAttempt?.sessionKey ? { sessionKey: latestAttempt.sessionKey } : {}),
...(latestAttempt?.runId ? { runId: latestAttempt.runId } : {}),
};
}
const previousAttempt = latestAttempt
? existingAttempts.find((attempt) => attempt.id === latestAttempt.id)
: undefined;
if (latestAttempt && previousAttempt?.status !== latestAttempt.status) {
return {
kind: "attempt_updated",
...(latestAttempt.sessionKey ? { sessionKey: latestAttempt.sessionKey } : {}),
...(latestAttempt.runId ? { runId: latestAttempt.runId } : {}),
};
}
return {
kind: "execution_updated",
...(cardSessionKey(next) ? { sessionKey: cardSessionKey(next) } : {}),
...(cardRunId(next) ? { runId: cardRunId(next) } : {}),
};
}
if (
(existing.metadata?.comments?.length ?? 0) !== (next.metadata?.comments?.length ?? 0) ||
latestMetadataIdChanged(existing.metadata?.comments, next.metadata?.comments)
) {
return { kind: "comment_added" };
}
if (
(existing.metadata?.links?.length ?? 0) !== (next.metadata?.links?.length ?? 0) ||
latestMetadataIdChanged(existing.metadata?.links, next.metadata?.links)
) {
return { kind: "link_added" };
}
if (
(existing.metadata?.proof?.length ?? 0) !== (next.metadata?.proof?.length ?? 0) ||
latestMetadataIdChanged(existing.metadata?.proof, next.metadata?.proof)
) {
return { kind: "proof_added" };
}
if (!existing.metadata?.archivedAt && next.metadata?.archivedAt) {
return { kind: "archived" };
}
if (existing.metadata?.archivedAt && !next.metadata?.archivedAt) {
return { kind: "unarchived" };
}
if (!existing.metadata?.stale && next.metadata?.stale) {
return { kind: "stale" };
}
return { kind: "edited" };
}
@@ -350,17 +776,45 @@ function removeUndefinedCardFields(card: WorkboardCard): WorkboardCard {
"execution",
"startedAt",
"completedAt",
"metadata",
] as const) {
if (next[key] === undefined) {
delete next[key];
}
}
if (metadataIsEmpty(next.metadata)) {
delete next.metadata;
}
return next;
}
export class WorkboardStore {
private mutationQueue: Promise<unknown> = Promise.resolve();
constructor(private readonly store: WorkboardKeyedStore) {}
private async enqueueMutation<T>(run: () => Promise<T>): Promise<T> {
const result = this.mutationQueue.then(run, run);
this.mutationQueue = result.then(
() => undefined,
() => undefined,
);
return await result;
}
private async updateMetadata(
id: string,
mutate: (existing: WorkboardCard) => WorkboardMetadata,
): Promise<WorkboardCard> {
return await this.enqueueMutation(async () => {
const existing = await this.get(id);
if (!existing) {
throw new Error(`card not found: ${id}`);
}
return await this.updateCard(id, { metadata: mutate(existing) });
});
}
async list(): Promise<WorkboardCard[]> {
const entries = await this.store.entries();
return entries
@@ -395,6 +849,12 @@ export class WorkboardStore {
const taskId = normalizeOptionalString(input.taskId);
const sourceUrl = normalizeOptionalString(input.sourceUrl);
const execution = normalizeExecution(input.execution);
const metadata = normalizeMetadata(input.metadata, {
templateId: normalizeTemplateId(input.templateId),
});
const syncedMetadata = trimMetadataToBudget(
syncExecutionAttemptMetadata(metadata, execution, now),
);
const card: WorkboardCard = {
id: randomUUID(),
title: normalizeTitle(input.title),
@@ -421,12 +881,17 @@ export class WorkboardStore {
...(taskId ? { taskId } : {}),
...(sourceUrl ? { sourceUrl } : {}),
...(execution ? { execution } : {}),
...(!metadataIsEmpty(syncedMetadata) ? { metadata: syncedMetadata } : {}),
};
await this.store.register(card.id, { version: 1, card });
return card;
}
async update(id: string, patch: WorkboardCardPatch): Promise<WorkboardCard> {
return await this.enqueueMutation(async () => await this.updateCard(id, patch));
}
private async updateCard(id: string, patch: WorkboardCardPatch): Promise<WorkboardCard> {
const existing = await this.get(id);
if (!existing) {
throw new Error(`card not found: ${id}`);
@@ -445,6 +910,7 @@ export class WorkboardStore {
? existing.execution
: syncExecutionSessionKey(existing.execution, sessionKey)
: normalizeExecution(patch.execution);
const metadata = normalizeMetadata(patch.metadata, existing.metadata);
const next = removeUndefinedCardFields({
...existing,
title: patch.title === undefined ? existing.title : normalizeTitle(patch.title),
@@ -465,6 +931,10 @@ export class WorkboardStore {
? existing.sourceUrl
: normalizeOptionalString(patch.sourceUrl),
execution,
metadata:
patch.templateId === undefined
? metadata
: { ...metadata, templateId: normalizeTemplateId(patch.templateId) },
position:
patch.position === undefined
? existing.position
@@ -473,10 +943,16 @@ export class WorkboardStore {
...(startedAt ? { startedAt } : {}),
...(completedAt ? { completedAt } : {}),
});
next.metadata = trimMetadataToBudget(
syncExecutionAttemptMetadata(next.metadata ?? {}, execution, now),
);
next.events = appendEvent(next, updateEvent(existing, next), now);
if (status !== "done") {
delete next.completedAt;
}
if (metadataIsEmpty(next.metadata)) {
delete next.metadata;
}
await this.store.register(next.id, { version: 1, card: next });
return next;
}
@@ -492,6 +968,74 @@ export class WorkboardStore {
return { deleted: await this.store.delete(id.trim()) };
}
async addComment(id: string, input: WorkboardCommentInput): Promise<WorkboardCard> {
const now = Date.now();
const body = normalizeBoundedString(input.body, undefined, 2000, "comment body");
if (!body) {
throw new Error("comment body is required.");
}
const comment = { id: randomUUID(), body, createdAt: now };
return await this.updateMetadata(id, (existing) => ({
...existing.metadata,
comments: [...(existing.metadata?.comments ?? []), comment].slice(-MAX_CARD_COMMENTS),
}));
}
async addLink(id: string, input: WorkboardLinkInput): Promise<WorkboardCard> {
const now = Date.now();
const targetCardId = normalizeBoundedString(input.targetCardId, undefined, 120, "link target");
const url = normalizeBoundedString(input.url, undefined, 2000, "link URL");
const title = normalizeBoundedString(input.title, undefined, 180, "link title");
if (!targetCardId && !url) {
throw new Error("link targetCardId or url is required.");
}
const link: WorkboardLink = {
id: randomUUID(),
type: normalizeLinkType(input.type, "relates_to"),
createdAt: now,
...(targetCardId ? { targetCardId } : {}),
...(title ? { title } : {}),
...(url ? { url } : {}),
};
return await this.updateMetadata(id, (existing) => ({
...existing.metadata,
links: [...(existing.metadata?.links ?? []), link].slice(-MAX_CARD_LINKS),
}));
}
async addProof(id: string, input: WorkboardProofInput): Promise<WorkboardCard> {
const now = Date.now();
const label = normalizeBoundedString(input.label, undefined, 160, "proof label");
const command = normalizeBoundedString(input.command, undefined, 1000, "proof command");
const url = normalizeBoundedString(input.url, undefined, 2000, "proof URL");
const note = normalizeBoundedString(input.note, undefined, 2000, "proof note");
const proof: WorkboardProof = {
id: randomUUID(),
status: normalizeProofStatus(input.status, "unknown"),
createdAt: now,
...(label ? { label } : {}),
...(command ? { command } : {}),
...(url ? { url } : {}),
...(note ? { note } : {}),
};
return await this.updateMetadata(id, (existing) => ({
...existing.metadata,
proof: [...(existing.metadata?.proof ?? []), proof].slice(-MAX_CARD_PROOF),
}));
}
async archive(id: string, archived: unknown): Promise<WorkboardCard> {
const shouldArchive = archived !== false;
return await this.updateMetadata(id, (existing) => ({
...existing.metadata,
archivedAt: shouldArchive ? Date.now() : 0,
}));
}
async exportCards(): Promise<{ cards: WorkboardCard[]; exportedAt: number }> {
return { cards: await this.list(), exportedAt: Date.now() };
}
static open(
openKeyedStore: (options: { namespace: string; maxEntries: number }) => WorkboardKeyedStore,
) {

View File

@@ -23,7 +23,25 @@ export const WORKBOARD_EVENT_KINDS = [
"moved",
"linked",
"execution_updated",
"attempt_started",
"attempt_updated",
"comment_added",
"link_added",
"proof_added",
"archived",
"unarchived",
"stale",
] as const;
export const WORKBOARD_ATTEMPT_STATUSES = [
"running",
"succeeded",
"failed",
"blocked",
"stopped",
] as const;
export const WORKBOARD_LINK_TYPES = ["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 type WorkboardStatus = (typeof WORKBOARD_STATUSES)[number];
export type WorkboardPriority = (typeof WORKBOARD_PRIORITIES)[number];
@@ -31,6 +49,10 @@ export type WorkboardExecutionEngine = (typeof WORKBOARD_EXECUTION_ENGINES)[numb
export type WorkboardExecutionMode = (typeof WORKBOARD_EXECUTION_MODES)[number];
export type WorkboardExecutionStatus = (typeof WORKBOARD_EXECUTION_STATUSES)[number];
export type WorkboardEventKind = (typeof WORKBOARD_EVENT_KINDS)[number];
export type WorkboardAttemptStatus = (typeof WORKBOARD_ATTEMPT_STATUSES)[number];
export type WorkboardLinkType = (typeof WORKBOARD_LINK_TYPES)[number];
export type WorkboardProofStatus = (typeof WORKBOARD_PROOF_STATUSES)[number];
export type WorkboardTemplateId = (typeof WORKBOARD_TEMPLATE_IDS)[number];
export type WorkboardExecution = {
id: string;
@@ -55,6 +77,62 @@ export type WorkboardEvent = {
runId?: string;
};
export type WorkboardRunAttempt = {
id: string;
status: WorkboardAttemptStatus;
startedAt: number;
endedAt?: number;
engine?: WorkboardExecutionEngine;
mode?: WorkboardExecutionMode;
model?: string;
sessionKey?: string;
runId?: string;
error?: string;
};
export type WorkboardComment = {
id: string;
body: string;
createdAt: number;
updatedAt?: number;
};
export type WorkboardLink = {
id: string;
type: WorkboardLinkType;
createdAt: number;
targetCardId?: string;
title?: string;
url?: string;
};
export type WorkboardProof = {
id: string;
status: WorkboardProofStatus;
createdAt: number;
label?: string;
command?: string;
url?: string;
note?: string;
};
export type WorkboardStaleState = {
detectedAt: number;
lastSessionUpdatedAt?: number;
reason: string;
};
export type WorkboardMetadata = {
attempts?: WorkboardRunAttempt[];
comments?: WorkboardComment[];
links?: WorkboardLink[];
proof?: WorkboardProof[];
templateId?: WorkboardTemplateId;
archivedAt?: number;
stale?: WorkboardStaleState;
failureCount?: number;
};
export type WorkboardCard = {
id: string;
title: string;
@@ -74,6 +152,7 @@ export type WorkboardCard = {
startedAt?: number;
completedAt?: number;
events?: WorkboardEvent[];
metadata?: WorkboardMetadata;
};
export type WorkboardListResult = {

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-28T23:49:35.013Z",
"generatedAt": "2026-05-29T00:32:14.710Z",
"locale": "ar",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda",
"totalKeys": 1238,
"translatedKeys": 1238,
"sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce",
"totalKeys": 1261,
"translatedKeys": 1261,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-28T23:49:02.619Z",
"generatedAt": "2026-05-29T00:31:32.282Z",
"locale": "de",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda",
"totalKeys": 1238,
"translatedKeys": 1238,
"sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce",
"totalKeys": 1261,
"translatedKeys": 1261,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-28T23:49:07.283Z",
"generatedAt": "2026-05-29T00:31:40.304Z",
"locale": "es",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda",
"totalKeys": 1238,
"translatedKeys": 1238,
"sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce",
"totalKeys": 1261,
"translatedKeys": 1261,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-28T23:51:04.444Z",
"generatedAt": "2026-05-29T00:33:25.728Z",
"locale": "fa",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda",
"totalKeys": 1238,
"translatedKeys": 1238,
"sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce",
"totalKeys": 1261,
"translatedKeys": 1261,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-28T23:49:27.030Z",
"generatedAt": "2026-05-29T00:32:06.264Z",
"locale": "fr",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda",
"totalKeys": 1238,
"translatedKeys": 1238,
"sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce",
"totalKeys": 1261,
"translatedKeys": 1261,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-28T23:50:00.392Z",
"generatedAt": "2026-05-29T00:32:46.357Z",
"locale": "id",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda",
"totalKeys": 1238,
"translatedKeys": 1238,
"sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce",
"totalKeys": 1261,
"translatedKeys": 1261,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-28T23:49:40.467Z",
"generatedAt": "2026-05-29T00:32:22.391Z",
"locale": "it",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda",
"totalKeys": 1238,
"translatedKeys": 1238,
"sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce",
"totalKeys": 1261,
"translatedKeys": 1261,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-28T23:49:14.097Z",
"generatedAt": "2026-05-29T00:31:48.617Z",
"locale": "ja-JP",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda",
"totalKeys": 1238,
"translatedKeys": 1238,
"sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce",
"totalKeys": 1261,
"translatedKeys": 1261,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-28T23:49:19.581Z",
"generatedAt": "2026-05-29T00:31:56.568Z",
"locale": "ko",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda",
"totalKeys": 1238,
"translatedKeys": 1238,
"sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce",
"totalKeys": 1261,
"translatedKeys": 1261,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-28T23:50:48.437Z",
"generatedAt": "2026-05-29T00:33:17.149Z",
"locale": "nl",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda",
"totalKeys": 1238,
"translatedKeys": 1238,
"sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce",
"totalKeys": 1261,
"translatedKeys": 1261,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-28T23:50:12.160Z",
"generatedAt": "2026-05-29T00:32:54.325Z",
"locale": "pl",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda",
"totalKeys": 1238,
"translatedKeys": 1238,
"sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce",
"totalKeys": 1261,
"translatedKeys": 1261,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-28T23:48:56.314Z",
"generatedAt": "2026-05-29T00:31:23.519Z",
"locale": "pt-BR",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda",
"totalKeys": 1238,
"translatedKeys": 1238,
"sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce",
"totalKeys": 1261,
"translatedKeys": 1261,
"workflow": 1
}

View File

@@ -4655,6 +4655,41 @@
"name": "aria-label",
"path": "ui/src/ui/views/usage-render-overview.ts",
"text": "Remove session filter"
},
{
"count": 1,
"kind": "object-property",
"name": "title",
"path": "ui/src/ui/views/workboard.ts",
"text": "Docs:"
},
{
"count": 1,
"kind": "object-property",
"name": "title",
"path": "ui/src/ui/views/workboard.ts",
"text": "Fix:"
},
{
"count": 1,
"kind": "object-property",
"name": "title",
"path": "ui/src/ui/views/workboard.ts",
"text": "Plugin:"
},
{
"count": 1,
"kind": "object-property",
"name": "title",
"path": "ui/src/ui/views/workboard.ts",
"text": "Release:"
},
{
"count": 1,
"kind": "object-property",
"name": "title",
"path": "ui/src/ui/views/workboard.ts",
"text": "Review PR"
}
]
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-28T23:50:26.593Z",
"generatedAt": "2026-05-29T00:33:02.612Z",
"locale": "th",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda",
"totalKeys": 1238,
"translatedKeys": 1238,
"sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce",
"totalKeys": 1261,
"translatedKeys": 1261,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-28T23:49:47.972Z",
"generatedAt": "2026-05-29T00:32:30.109Z",
"locale": "tr",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda",
"totalKeys": 1238,
"translatedKeys": 1238,
"sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce",
"totalKeys": 1261,
"translatedKeys": 1261,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-28T23:49:54.214Z",
"generatedAt": "2026-05-29T00:32:37.218Z",
"locale": "uk",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda",
"totalKeys": 1238,
"translatedKeys": 1238,
"sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce",
"totalKeys": 1261,
"translatedKeys": 1261,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-28T23:50:37.343Z",
"generatedAt": "2026-05-29T00:33:09.499Z",
"locale": "vi",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda",
"totalKeys": 1238,
"translatedKeys": 1238,
"sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce",
"totalKeys": 1261,
"translatedKeys": 1261,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-28T23:48:43.195Z",
"generatedAt": "2026-05-29T00:31:05.242Z",
"locale": "zh-CN",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda",
"totalKeys": 1238,
"translatedKeys": 1238,
"sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce",
"totalKeys": 1261,
"translatedKeys": 1261,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-28T23:48:49.571Z",
"generatedAt": "2026-05-29T00:31:15.002Z",
"locale": "zh-TW",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda",
"totalKeys": 1238,
"translatedKeys": 1238,
"sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce",
"totalKeys": 1261,
"translatedKeys": 1261,
"workflow": 1
}

View File

@@ -495,6 +495,7 @@ export const ar: TranslationMap = {
editCardHelp: "حدّث بيانات تعريف قائمة الانتظار وتسليم الجلسة.",
newCard: "بطاقة جديدة",
newCardHelp: "أضف العمل إلى قائمة الانتظار لجلسة وكيل.",
archiveCard: "أرشفة البطاقة",
deleteCard: "حذف البطاقة",
openSession: "فتح الجلسة",
openLinkedSession: "فتح الجلسة المرتبطة",
@@ -511,6 +512,20 @@ export const ar: TranslationMap = {
fieldAgent: "الوكيل",
fieldSession: "الجلسة",
fieldLabels: "التصنيفات",
templatesLabel: "قوالب البطاقات",
template: {
bugfix: "إصلاح خطأ",
docs: "المستندات",
release: "إصدار",
pr_review: "مراجعة PR",
plugin: "مكوّن إضافي",
},
badgeAttempts: "{count} محاولة",
badgeFailures: "{count} فشل",
badgeComments: "{count} تعليقات",
badgeLinks: "{count} روابط",
badgeProof: "{count} إثبات",
badgeStale: "قديم",
titlePlaceholder: "عنوان البطاقة",
notesPlaceholder: "ملاحظات، معايير القبول، روابط",
labelsPlaceholder: "ui, docs",
@@ -525,6 +540,8 @@ export const ar: TranslationMap = {
lifecycleIdleDetail: "لا يوجد تشغيل نشط",
lifecycleRunning: "قيد التشغيل",
lifecycleRunningDetail: "تشغيل نشط قيد التقدم",
lifecycleStale: "قديم",
lifecycleStaleDetail: "لا يوجد نشاط جلسة حديث",
lifecycleDone: "مكتمل",
lifecycleDoneDetail: "تم النقل إلى المراجعة",
lifecycleNeedsReview: "تحتاج إلى مراجعة",
@@ -536,6 +553,14 @@ export const ar: TranslationMap = {
eventMovedTo: "تم النقل إلى {status}",
eventLinked: "تم ربط الجلسة",
eventExecutionUpdated: "تم تحديث الوكيل",
eventAttemptStarted: "بدأت المحاولة",
eventAttemptUpdated: "تم تحديث المحاولة",
eventCommentAdded: "تمت إضافة تعليق",
eventLinkAdded: "تمت إضافة رابط",
eventProofAdded: "تمت إضافة إثبات",
eventArchived: "مؤرشف",
eventUnarchived: "غير مؤرشف",
eventStale: "جلسة قديمة",
gameButton: "لعبة مصغرة",
gameTitle: "مطاردة البطاقات",
gameStart: "صِل إلى مربع الإطلاق.",

View File

@@ -499,6 +499,7 @@ export const de: TranslationMap = {
editCardHelp: "Warteschlangen-Metadaten und Sitzungsübergabe aktualisieren.",
newCard: "Neue Karte",
newCardHelp: "Arbeit für eine Agentensitzung in die Warteschlange einreihen.",
archiveCard: "Karte archivieren",
deleteCard: "Karte löschen",
openSession: "Sitzung öffnen",
openLinkedSession: "Verknüpfte Sitzung öffnen",
@@ -515,6 +516,20 @@ export const de: TranslationMap = {
fieldAgent: "Agent",
fieldSession: "Sitzung",
fieldLabels: "Labels",
templatesLabel: "Kartenvorlagen",
template: {
bugfix: "Bugfix",
docs: "Dokumentation",
release: "Release",
pr_review: "PR-Review",
plugin: "Plugin",
},
badgeAttempts: "{count} Versuche",
badgeFailures: "{count} fehlgeschlagen",
badgeComments: "{count} Kommentare",
badgeLinks: "{count} Links",
badgeProof: "{count} Nachweis",
badgeStale: "veraltet",
titlePlaceholder: "Kartentitel",
notesPlaceholder: "Notizen, Akzeptanzkriterien, Links",
labelsPlaceholder: "ui, docs",
@@ -529,6 +544,8 @@ export const de: TranslationMap = {
lifecycleIdleDetail: "Kein aktiver Lauf",
lifecycleRunning: "Wird ausgeführt",
lifecycleRunningDetail: "Aktiver Lauf läuft",
lifecycleStale: "Veraltet",
lifecycleStaleDetail: "Keine aktuelle Sitzungsaktivität",
lifecycleDone: "Fertig",
lifecycleDoneDetail: "Zur Überprüfung verschoben",
lifecycleNeedsReview: "Überprüfung erforderlich",
@@ -540,6 +557,14 @@ export const de: TranslationMap = {
eventMovedTo: "Verschoben nach {status}",
eventLinked: "Sitzung verknüpft",
eventExecutionUpdated: "Agent aktualisiert",
eventAttemptStarted: "Versuch gestartet",
eventAttemptUpdated: "Versuch aktualisiert",
eventCommentAdded: "Kommentar hinzugefügt",
eventLinkAdded: "Link hinzugefügt",
eventProofAdded: "Nachweis hinzugefügt",
eventArchived: "Archiviert",
eventUnarchived: "Aus Archiv wiederhergestellt",
eventStale: "Veraltete Sitzung",
gameButton: "Minispiel",
gameTitle: "Card Chase",
gameStart: "Erreiche das Startfeld.",

View File

@@ -494,6 +494,7 @@ export const en: TranslationMap = {
editCardHelp: "Update queue metadata and session handoff.",
newCard: "New card",
newCardHelp: "Queue work for an agent session.",
archiveCard: "Archive card",
deleteCard: "Delete card",
openSession: "Open session",
openLinkedSession: "Open linked session",
@@ -510,6 +511,20 @@ export const en: TranslationMap = {
fieldAgent: "Agent",
fieldSession: "Session",
fieldLabels: "Labels",
templatesLabel: "Card templates",
template: {
bugfix: "Bugfix",
docs: "Docs",
release: "Release",
pr_review: "PR review",
plugin: "Plugin",
},
badgeAttempts: "{count} attempts",
badgeFailures: "{count} failed",
badgeComments: "{count} comments",
badgeLinks: "{count} links",
badgeProof: "{count} proof",
badgeStale: "stale",
titlePlaceholder: "Card title",
notesPlaceholder: "Notes, acceptance criteria, links",
labelsPlaceholder: "ui, docs",
@@ -524,6 +539,8 @@ export const en: TranslationMap = {
lifecycleIdleDetail: "No active run",
lifecycleRunning: "Running",
lifecycleRunningDetail: "Active run in progress",
lifecycleStale: "Stale",
lifecycleStaleDetail: "No recent session activity",
lifecycleDone: "Done",
lifecycleDoneDetail: "Moved to review",
lifecycleNeedsReview: "Needs review",
@@ -535,6 +552,14 @@ export const en: TranslationMap = {
eventMovedTo: "Moved to {status}",
eventLinked: "Linked session",
eventExecutionUpdated: "Agent updated",
eventAttemptStarted: "Attempt started",
eventAttemptUpdated: "Attempt updated",
eventCommentAdded: "Comment added",
eventLinkAdded: "Link added",
eventProofAdded: "Proof added",
eventArchived: "Archived",
eventUnarchived: "Unarchived",
eventStale: "Stale session",
gameButton: "Mini game",
gameTitle: "Card Chase",
gameStart: "Reach the launch tile.",

View File

@@ -496,6 +496,7 @@ export const es: TranslationMap = {
editCardHelp: "Actualiza los metadatos de la cola y la transferencia de sesión.",
newCard: "Nueva tarjeta",
newCardHelp: "Pon trabajo en cola para una sesión de agente.",
archiveCard: "Archivar tarjeta",
deleteCard: "Eliminar tarjeta",
openSession: "Abrir sesión",
openLinkedSession: "Abrir sesión vinculada",
@@ -512,6 +513,20 @@ export const es: TranslationMap = {
fieldAgent: "Agente",
fieldSession: "Sesión",
fieldLabels: "Etiquetas",
templatesLabel: "Plantillas de tarjetas",
template: {
bugfix: "Corrección de errores",
docs: "Documentación",
release: "Lanzamiento",
pr_review: "Revisión de PR",
plugin: "Plugin",
},
badgeAttempts: "{count} intentos",
badgeFailures: "{count} fallidos",
badgeComments: "{count} comentarios",
badgeLinks: "{count} enlaces",
badgeProof: "{count} prueba",
badgeStale: "obsoleta",
titlePlaceholder: "Título de la tarjeta",
notesPlaceholder: "Notas, criterios de aceptación, enlaces",
labelsPlaceholder: "ui, docs",
@@ -526,6 +541,8 @@ export const es: TranslationMap = {
lifecycleIdleDetail: "No hay ninguna ejecución activa",
lifecycleRunning: "En ejecución",
lifecycleRunningDetail: "Ejecución activa en curso",
lifecycleStale: "Obsoleta",
lifecycleStaleDetail: "Sin actividad de sesión reciente",
lifecycleDone: "Completado",
lifecycleDoneDetail: "Movido a revisión",
lifecycleNeedsReview: "Necesita revisión",
@@ -537,6 +554,14 @@ export const es: TranslationMap = {
eventMovedTo: "Movido a {status}",
eventLinked: "Sesión vinculada",
eventExecutionUpdated: "Agente actualizado",
eventAttemptStarted: "Intento iniciado",
eventAttemptUpdated: "Intento actualizado",
eventCommentAdded: "Comentario añadido",
eventLinkAdded: "Enlace añadido",
eventProofAdded: "Prueba añadida",
eventArchived: "Archivado",
eventUnarchived: "Desarchivado",
eventStale: "Sesión obsoleta",
gameButton: "Minijuego",
gameTitle: "Card Chase",
gameStart: "Alcanza la casilla de lanzamiento.",

View File

@@ -497,6 +497,7 @@ export const fa: TranslationMap = {
editCardHelp: "به‌روزرسانی فرادادهٔ صف و واگذاری نشست.",
newCard: "کارت جدید",
newCardHelp: "کار را برای یک نشست عامل در صف قرار دهید.",
archiveCard: "بایگانی کارت",
deleteCard: "حذف کارت",
openSession: "باز کردن نشست",
openLinkedSession: "باز کردن نشست پیوندشده",
@@ -513,6 +514,20 @@ export const fa: TranslationMap = {
fieldAgent: "عامل",
fieldSession: "نشست",
fieldLabels: "برچسب‌ها",
templatesLabel: "قالب‌های کارت",
template: {
bugfix: "رفع باگ",
docs: "مستندات",
release: "انتشار",
pr_review: "بازبینی PR",
plugin: "افزونه",
},
badgeAttempts: "{count} تلاش",
badgeFailures: "{count} ناموفق",
badgeComments: "{count} نظر",
badgeLinks: "{count} پیوند",
badgeProof: "{count} مدرک",
badgeStale: "قدیمی",
titlePlaceholder: "عنوان کارت",
notesPlaceholder: "یادداشت‌ها، معیارهای پذیرش، پیوندها",
labelsPlaceholder: "ui, docs",
@@ -527,6 +542,8 @@ export const fa: TranslationMap = {
lifecycleIdleDetail: "اجرای فعالی وجود ندارد",
lifecycleRunning: "در حال اجرا",
lifecycleRunningDetail: "اجرای فعال در جریان است",
lifecycleStale: "قدیمی",
lifecycleStaleDetail: "فعالیت جلسهٔ اخیر وجود ندارد",
lifecycleDone: "انجام شد",
lifecycleDoneDetail: "به بازبینی منتقل شد",
lifecycleNeedsReview: "نیازمند بازبینی",
@@ -538,6 +555,14 @@ export const fa: TranslationMap = {
eventMovedTo: "به {status} منتقل شد",
eventLinked: "نشست پیوند داده شد",
eventExecutionUpdated: "Agent به‌روزرسانی شد",
eventAttemptStarted: "تلاش شروع شد",
eventAttemptUpdated: "تلاش به‌روزرسانی شد",
eventCommentAdded: "نظر اضافه شد",
eventLinkAdded: "پیوند اضافه شد",
eventProofAdded: "مدرک اضافه شد",
eventArchived: "بایگانی شد",
eventUnarchived: "از بایگانی خارج شد",
eventStale: "نشست منقضی‌شده",
gameButton: "بازی کوچک",
gameTitle: "تعقیب کارت",
gameStart: "به کاشی راه‌اندازی برسید.",

View File

@@ -498,6 +498,7 @@ export const fr: TranslationMap = {
editCardHelp: "Mettez à jour les métadonnées de la file dattente et le transfert de session.",
newCard: "Nouvelle carte",
newCardHelp: "Mettez du travail en file dattente pour une session dagent.",
archiveCard: "Archiver la carte",
deleteCard: "Supprimer la carte",
openSession: "Ouvrir la session",
openLinkedSession: "Ouvrir la session liée",
@@ -514,6 +515,20 @@ export const fr: TranslationMap = {
fieldAgent: "Agent",
fieldSession: "Session",
fieldLabels: "Étiquettes",
templatesLabel: "Modèles de carte",
template: {
bugfix: "Correction de bug",
docs: "Docs",
release: "Release",
pr_review: "Revue de PR",
plugin: "Plugin",
},
badgeAttempts: "{count} tentatives",
badgeFailures: "{count} échecs",
badgeComments: "{count} commentaires",
badgeLinks: "{count} liens",
badgeProof: "{count} preuve",
badgeStale: "obsolète",
titlePlaceholder: "Titre de la carte",
notesPlaceholder: "Notes, critères dacceptation, liens",
labelsPlaceholder: "ui, docs",
@@ -528,6 +543,8 @@ export const fr: TranslationMap = {
lifecycleIdleDetail: "Aucune exécution active",
lifecycleRunning: "En cours dexécution",
lifecycleRunningDetail: "Exécution active en cours",
lifecycleStale: "Obsolète",
lifecycleStaleDetail: "Aucune activité de session récente",
lifecycleDone: "Terminé",
lifecycleDoneDetail: "Déplacé vers la révision",
lifecycleNeedsReview: "Nécessite une révision",
@@ -539,6 +556,14 @@ export const fr: TranslationMap = {
eventMovedTo: "Déplacé vers {status}",
eventLinked: "Session liée",
eventExecutionUpdated: "Agent mis à jour",
eventAttemptStarted: "Tentative démarrée",
eventAttemptUpdated: "Tentative mise à jour",
eventCommentAdded: "Commentaire ajouté",
eventLinkAdded: "Lien ajouté",
eventProofAdded: "Preuve ajoutée",
eventArchived: "Archivé",
eventUnarchived: "Désarchivé",
eventStale: "Session inactive",
gameButton: "Mini-jeu",
gameTitle: "Card Chase",
gameStart: "Atteignez la tuile de lancement.",

View File

@@ -496,6 +496,7 @@ export const id: TranslationMap = {
editCardHelp: "Perbarui metadata antrean dan serah terima sesi.",
newCard: "Kartu baru",
newCardHelp: "Antrekan pekerjaan untuk sesi agen.",
archiveCard: "Arsipkan kartu",
deleteCard: "Hapus kartu",
openSession: "Buka sesi",
openLinkedSession: "Buka sesi tertaut",
@@ -512,6 +513,20 @@ export const id: TranslationMap = {
fieldAgent: "Agen",
fieldSession: "Sesi",
fieldLabels: "Label",
templatesLabel: "Templat kartu",
template: {
bugfix: "Bugfix",
docs: "Docs",
release: "Release",
pr_review: "Tinjauan PR",
plugin: "Plugin",
},
badgeAttempts: "{count} upaya",
badgeFailures: "{count} gagal",
badgeComments: "{count} komentar",
badgeLinks: "{count} tautan",
badgeProof: "{count} bukti",
badgeStale: "usang",
titlePlaceholder: "Judul kartu",
notesPlaceholder: "Catatan, kriteria penerimaan, tautan",
labelsPlaceholder: "ui, docs",
@@ -526,6 +541,8 @@ export const id: TranslationMap = {
lifecycleIdleDetail: "Tidak ada run aktif",
lifecycleRunning: "Berjalan",
lifecycleRunningDetail: "Run aktif sedang berlangsung",
lifecycleStale: "Usang",
lifecycleStaleDetail: "Tidak ada aktivitas sesi terbaru",
lifecycleDone: "Selesai",
lifecycleDoneDetail: "Dipindahkan ke peninjauan",
lifecycleNeedsReview: "Perlu ditinjau",
@@ -537,6 +554,14 @@ export const id: TranslationMap = {
eventMovedTo: "Dipindahkan ke {status}",
eventLinked: "Sesi ditautkan",
eventExecutionUpdated: "Agen diperbarui",
eventAttemptStarted: "Upaya dimulai",
eventAttemptUpdated: "Upaya diperbarui",
eventCommentAdded: "Komentar ditambahkan",
eventLinkAdded: "Tautan ditambahkan",
eventProofAdded: "Bukti ditambahkan",
eventArchived: "Diarsipkan",
eventUnarchived: "Dibatalkan pengarsipannya",
eventStale: "Sesi kedaluwarsa",
gameButton: "Mini game",
gameTitle: "Card Chase",
gameStart: "Capai petak peluncuran.",

View File

@@ -498,6 +498,7 @@ export const it: TranslationMap = {
editCardHelp: "Aggiorna i metadati della coda e il passaggio di sessione.",
newCard: "Nuova scheda",
newCardHelp: "Metti in coda il lavoro per una sessione dell'agente.",
archiveCard: "Archivia scheda",
deleteCard: "Elimina scheda",
openSession: "Apri sessione",
openLinkedSession: "Apri sessione collegata",
@@ -514,6 +515,20 @@ export const it: TranslationMap = {
fieldAgent: "Agente",
fieldSession: "Sessione",
fieldLabels: "Etichette",
templatesLabel: "Modelli di scheda",
template: {
bugfix: "Correzione bug",
docs: "Documentazione",
release: "Release",
pr_review: "Revisione PR",
plugin: "Plugin",
},
badgeAttempts: "{count} tentativi",
badgeFailures: "{count} non riusciti",
badgeComments: "{count} commenti",
badgeLinks: "{count} link",
badgeProof: "{count} prova",
badgeStale: "obsoleto",
titlePlaceholder: "Titolo scheda",
notesPlaceholder: "Note, criteri di accettazione, link",
labelsPlaceholder: "ui, docs",
@@ -528,6 +543,8 @@ export const it: TranslationMap = {
lifecycleIdleDetail: "Nessuna esecuzione attiva",
lifecycleRunning: "In esecuzione",
lifecycleRunningDetail: "Esecuzione attiva in corso",
lifecycleStale: "Obsoleto",
lifecycleStaleDetail: "Nessuna attività recente nella sessione",
lifecycleDone: "Completato",
lifecycleDoneDetail: "Spostata in revisione",
lifecycleNeedsReview: "Richiede revisione",
@@ -539,6 +556,14 @@ export const it: TranslationMap = {
eventMovedTo: "Spostato in {status}",
eventLinked: "Sessione collegata",
eventExecutionUpdated: "Agente aggiornato",
eventAttemptStarted: "Tentativo avviato",
eventAttemptUpdated: "Tentativo aggiornato",
eventCommentAdded: "Commento aggiunto",
eventLinkAdded: "Link aggiunto",
eventProofAdded: "Prova aggiunta",
eventArchived: "Archiviato",
eventUnarchived: "Non archiviato",
eventStale: "Sessione obsoleta",
gameButton: "Mini gioco",
gameTitle: "Card Chase",
gameStart: "Raggiungi la casella di lancio.",

View File

@@ -499,6 +499,7 @@ export const ja_JP: TranslationMap = {
editCardHelp: "キューのメタデータとセッションの引き継ぎを更新します。",
newCard: "新規カード",
newCardHelp: "エージェントセッションの作業をキューに追加します。",
archiveCard: "カードをアーカイブ",
deleteCard: "カードを削除",
openSession: "セッションを開く",
openLinkedSession: "リンクされたセッションを開く",
@@ -515,6 +516,20 @@ export const ja_JP: TranslationMap = {
fieldAgent: "エージェント",
fieldSession: "セッション",
fieldLabels: "ラベル",
templatesLabel: "カードテンプレート",
template: {
bugfix: "バグ修正",
docs: "ドキュメント",
release: "リリース",
pr_review: "PR レビュー",
plugin: "プラグイン",
},
badgeAttempts: "{count} 件の試行",
badgeFailures: "{count} 件失敗",
badgeComments: "{count} 件のコメント",
badgeLinks: "{count} 件のリンク",
badgeProof: "{count} 件の証跡",
badgeStale: "古い",
titlePlaceholder: "カードのタイトル",
notesPlaceholder: "メモ、受け入れ条件、リンク",
labelsPlaceholder: "ui, docs",
@@ -529,6 +544,8 @@ export const ja_JP: TranslationMap = {
lifecycleIdleDetail: "アクティブな実行はありません",
lifecycleRunning: "実行中",
lifecycleRunningDetail: "アクティブな実行が進行中です",
lifecycleStale: "古い",
lifecycleStaleDetail: "最近のセッションアクティビティはありません",
lifecycleDone: "完了",
lifecycleDoneDetail: "レビューに移動しました",
lifecycleNeedsReview: "レビューが必要",
@@ -540,6 +557,14 @@ export const ja_JP: TranslationMap = {
eventMovedTo: "{status} に移動しました",
eventLinked: "セッションをリンクしました",
eventExecutionUpdated: "Agent が更新されました",
eventAttemptStarted: "試行を開始しました",
eventAttemptUpdated: "試行を更新しました",
eventCommentAdded: "コメントを追加しました",
eventLinkAdded: "リンクを追加しました",
eventProofAdded: "証跡を追加しました",
eventArchived: "アーカイブ済み",
eventUnarchived: "アーカイブ解除済み",
eventStale: "古いセッション",
gameButton: "ミニゲーム",
gameTitle: "Card Chase",
gameStart: "ローンチタイルに到達してください。",

View File

@@ -495,6 +495,7 @@ export const ko: TranslationMap = {
editCardHelp: "대기열 메타데이터와 세션 인계를 업데이트합니다.",
newCard: "새 카드",
newCardHelp: "에이전트 세션을 위한 작업을 대기열에 추가합니다.",
archiveCard: "카드 보관",
deleteCard: "카드 삭제",
openSession: "세션 열기",
openLinkedSession: "연결된 세션 열기",
@@ -511,6 +512,20 @@ export const ko: TranslationMap = {
fieldAgent: "에이전트",
fieldSession: "세션",
fieldLabels: "레이블",
templatesLabel: "카드 템플릿",
template: {
bugfix: "버그 수정",
docs: "문서",
release: "릴리스",
pr_review: "PR 리뷰",
plugin: "플러그인",
},
badgeAttempts: "{count}회 시도",
badgeFailures: "{count}개 실패",
badgeComments: "댓글 {count}개",
badgeLinks: "링크 {count}개",
badgeProof: "증빙 {count}개",
badgeStale: "오래됨",
titlePlaceholder: "카드 제목",
notesPlaceholder: "메모, 승인 기준, 링크",
labelsPlaceholder: "ui, docs",
@@ -525,6 +540,8 @@ export const ko: TranslationMap = {
lifecycleIdleDetail: "활성 실행 없음",
lifecycleRunning: "실행 중",
lifecycleRunningDetail: "활성 실행이 진행 중입니다",
lifecycleStale: "오래됨",
lifecycleStaleDetail: "최근 세션 활동 없음",
lifecycleDone: "완료",
lifecycleDoneDetail: "검토로 이동됨",
lifecycleNeedsReview: "검토 필요",
@@ -536,6 +553,14 @@ export const ko: TranslationMap = {
eventMovedTo: "{status}(으)로 이동됨",
eventLinked: "연결된 세션",
eventExecutionUpdated: "Agent 업데이트됨",
eventAttemptStarted: "시도 시작됨",
eventAttemptUpdated: "시도 업데이트됨",
eventCommentAdded: "댓글 추가됨",
eventLinkAdded: "링크 추가됨",
eventProofAdded: "증빙 추가됨",
eventArchived: "보관됨",
eventUnarchived: "보관 해제됨",
eventStale: "오래된 세션",
gameButton: "미니 게임",
gameTitle: "Card Chase",
gameStart: "출발 타일에 도달하세요.",

View File

@@ -498,6 +498,7 @@ export const nl: TranslationMap = {
editCardHelp: "Werk wachtrijmetadata en sessieoverdracht bij.",
newCard: "Nieuwe kaart",
newCardHelp: "Zet werk in de wachtrij voor een agentsessie.",
archiveCard: "Kaart archiveren",
deleteCard: "Kaart verwijderen",
openSession: "Sessie openen",
openLinkedSession: "Gekoppelde sessie openen",
@@ -514,6 +515,20 @@ export const nl: TranslationMap = {
fieldAgent: "Agent",
fieldSession: "Sessie",
fieldLabels: "Labels",
templatesLabel: "Kaartsjablonen",
template: {
bugfix: "Bugfix",
docs: "Docs",
release: "Release",
pr_review: "PR review",
plugin: "Plugin",
},
badgeAttempts: "{count} pogingen",
badgeFailures: "{count} mislukt",
badgeComments: "{count} opmerkingen",
badgeLinks: "{count} links",
badgeProof: "{count} bewijs",
badgeStale: "verouderd",
titlePlaceholder: "Kaarttitel",
notesPlaceholder: "Notities, acceptatiecriteria, links",
labelsPlaceholder: "ui, docs",
@@ -528,6 +543,8 @@ export const nl: TranslationMap = {
lifecycleIdleDetail: "Geen actieve run",
lifecycleRunning: "Actief",
lifecycleRunningDetail: "Actieve run bezig",
lifecycleStale: "Verouderd",
lifecycleStaleDetail: "Geen recente sessieactiviteit",
lifecycleDone: "Voltooid",
lifecycleDoneDetail: "Verplaatst naar review",
lifecycleNeedsReview: "Review nodig",
@@ -539,6 +556,14 @@ export const nl: TranslationMap = {
eventMovedTo: "Verplaatst naar {status}",
eventLinked: "Gekoppelde sessie",
eventExecutionUpdated: "Agent bijgewerkt",
eventAttemptStarted: "Poging gestart",
eventAttemptUpdated: "Poging bijgewerkt",
eventCommentAdded: "Opmerking toegevoegd",
eventLinkAdded: "Link toegevoegd",
eventProofAdded: "Bewijs toegevoegd",
eventArchived: "Gearchiveerd",
eventUnarchived: "Uit archief gehaald",
eventStale: "Verlopen sessie",
gameButton: "Minigame",
gameTitle: "Card Chase",
gameStart: "Bereik de lanceringstegel.",

View File

@@ -497,6 +497,7 @@ export const pl: TranslationMap = {
editCardHelp: "Zaktualizuj metadane kolejki i przekazanie sesji.",
newCard: "Nowa karta",
newCardHelp: "Dodaj zadanie do kolejki dla sesji agenta.",
archiveCard: "Archiwizuj kartę",
deleteCard: "Usuń kartę",
openSession: "Otwórz sesję",
openLinkedSession: "Otwórz powiązaną sesję",
@@ -513,6 +514,20 @@ export const pl: TranslationMap = {
fieldAgent: "Agent",
fieldSession: "Sesja",
fieldLabels: "Etykiety",
templatesLabel: "Szablony kart",
template: {
bugfix: "Poprawka błędu",
docs: "Dokumentacja",
release: "Wydanie",
pr_review: "Przegląd PR",
plugin: "Wtyczka",
},
badgeAttempts: "{count} prób",
badgeFailures: "{count} nieudanych",
badgeComments: "{count} komentarzy",
badgeLinks: "{count} linków",
badgeProof: "{count} dowodów",
badgeStale: "nieaktualne",
titlePlaceholder: "Tytuł karty",
notesPlaceholder: "Notatki, kryteria akceptacji, linki",
labelsPlaceholder: "ui, docs",
@@ -527,6 +542,8 @@ export const pl: TranslationMap = {
lifecycleIdleDetail: "Brak aktywnego uruchomienia",
lifecycleRunning: "Uruchomiono",
lifecycleRunningDetail: "Trwa aktywne uruchomienie",
lifecycleStale: "Nieaktualne",
lifecycleStaleDetail: "Brak ostatniej aktywności w sesji",
lifecycleDone: "Gotowe",
lifecycleDoneDetail: "Przeniesiono do przeglądu",
lifecycleNeedsReview: "Wymaga przeglądu",
@@ -538,6 +555,14 @@ export const pl: TranslationMap = {
eventMovedTo: "Przeniesiono do {status}",
eventLinked: "Połączona sesja",
eventExecutionUpdated: "Agent zaktualizowany",
eventAttemptStarted: "Rozpoczęto próbę",
eventAttemptUpdated: "Zaktualizowano próbę",
eventCommentAdded: "Dodano komentarz",
eventLinkAdded: "Dodano link",
eventProofAdded: "Dodano dowód",
eventArchived: "Zarchiwizowano",
eventUnarchived: "Przywrócono z archiwum",
eventStale: "Nieaktualna sesja",
gameButton: "Minigra",
gameTitle: "Pościg za kartą",
gameStart: "Dotrzyj do pola uruchomienia.",

View File

@@ -496,6 +496,7 @@ export const pt_BR: TranslationMap = {
editCardHelp: "Atualize os metadados da fila e a transferência de sessão.",
newCard: "Novo cartão",
newCardHelp: "Enfileire trabalho para uma sessão de agente.",
archiveCard: "Arquivar cartão",
deleteCard: "Excluir cartão",
openSession: "Abrir sessão",
openLinkedSession: "Abrir sessão vinculada",
@@ -512,6 +513,20 @@ export const pt_BR: TranslationMap = {
fieldAgent: "Agente",
fieldSession: "Sessão",
fieldLabels: "Etiquetas",
templatesLabel: "Modelos de cartão",
template: {
bugfix: "Correção de bug",
docs: "Documentação",
release: "Release",
pr_review: "Revisão de PR",
plugin: "Plugin",
},
badgeAttempts: "{count} tentativas",
badgeFailures: "{count} falharam",
badgeComments: "{count} comentários",
badgeLinks: "{count} links",
badgeProof: "{count} prova",
badgeStale: "desatualizado",
titlePlaceholder: "Título do cartão",
notesPlaceholder: "Notas, critérios de aceitação, links",
labelsPlaceholder: "ui, docs",
@@ -526,6 +541,8 @@ export const pt_BR: TranslationMap = {
lifecycleIdleDetail: "Nenhuma execução ativa",
lifecycleRunning: "Em execução",
lifecycleRunningDetail: "Execução ativa em andamento",
lifecycleStale: "Desatualizado",
lifecycleStaleDetail: "Nenhuma atividade de sessão recente",
lifecycleDone: "Concluída",
lifecycleDoneDetail: "Movido para revisão",
lifecycleNeedsReview: "Precisa de revisão",
@@ -537,6 +554,14 @@ export const pt_BR: TranslationMap = {
eventMovedTo: "Movido para {status}",
eventLinked: "Sessão vinculada",
eventExecutionUpdated: "Agente atualizado",
eventAttemptStarted: "Tentativa iniciada",
eventAttemptUpdated: "Tentativa atualizada",
eventCommentAdded: "Comentário adicionado",
eventLinkAdded: "Link adicionado",
eventProofAdded: "Prova adicionada",
eventArchived: "Arquivado",
eventUnarchived: "Desarquivado",
eventStale: "Sessão obsoleta",
gameButton: "Mini game",
gameTitle: "Card Chase",
gameStart: "Alcance o bloco de lançamento.",

View File

@@ -494,6 +494,7 @@ export const th: TranslationMap = {
editCardHelp: "อัปเดตข้อมูลเมตาของคิวและการส่งต่อเซสชัน",
newCard: "การ์ดใหม่",
newCardHelp: "จัดคิวงานสำหรับเซสชันของเอเจนต์",
archiveCard: "เก็บถาวรการ์ด",
deleteCard: "ลบการ์ด",
openSession: "เปิดเซสชัน",
openLinkedSession: "เปิดเซสชันที่ลิงก์ไว้",
@@ -510,6 +511,20 @@ export const th: TranslationMap = {
fieldAgent: "เอเจนต์",
fieldSession: "เซสชัน",
fieldLabels: "ป้ายกำกับ",
templatesLabel: "เทมเพลตการ์ด",
template: {
bugfix: "แก้ไขบั๊ก",
docs: "เอกสาร",
release: "รีลีส",
pr_review: "รีวิว PR",
plugin: "ปลั๊กอิน",
},
badgeAttempts: "{count} ครั้ง",
badgeFailures: "ล้มเหลว {count} รายการ",
badgeComments: "{count} ความคิดเห็น",
badgeLinks: "{count} ลิงก์",
badgeProof: "หลักฐาน {count} รายการ",
badgeStale: "ค้างนาน",
titlePlaceholder: "ชื่อการ์ด",
notesPlaceholder: "บันทึก, เกณฑ์การยอมรับ, ลิงก์",
labelsPlaceholder: "ui, docs",
@@ -524,6 +539,8 @@ export const th: TranslationMap = {
lifecycleIdleDetail: "ไม่มีการรันที่ทำงานอยู่",
lifecycleRunning: "กำลังทำงาน",
lifecycleRunningDetail: "กำลังมีการรันที่ทำงานอยู่",
lifecycleStale: "ค้างนาน",
lifecycleStaleDetail: "ไม่มีกิจกรรมเซสชันล่าสุด",
lifecycleDone: "เสร็จสิ้น",
lifecycleDoneDetail: "ย้ายไปยังการตรวจทานแล้ว",
lifecycleNeedsReview: "ต้องตรวจทาน",
@@ -535,6 +552,14 @@ export const th: TranslationMap = {
eventMovedTo: "ย้ายไปยัง {status}",
eventLinked: "เซสชันที่ลิงก์แล้ว",
eventExecutionUpdated: "เอเจนต์อัปเดตแล้ว",
eventAttemptStarted: "เริ่มความพยายามแล้ว",
eventAttemptUpdated: "อัปเดตความพยายามแล้ว",
eventCommentAdded: "เพิ่มความคิดเห็นแล้ว",
eventLinkAdded: "เพิ่มลิงก์แล้ว",
eventProofAdded: "เพิ่มหลักฐานแล้ว",
eventArchived: "เก็บถาวรแล้ว",
eventUnarchived: "ยกเลิกการเก็บถาวรแล้ว",
eventStale: "เซสชันหมดอายุ",
gameButton: "มินิเกม",
gameTitle: "Card Chase",
gameStart: "ไปให้ถึงช่องเปิดตัว",

View File

@@ -498,6 +498,7 @@ export const tr: TranslationMap = {
editCardHelp: "Kuyruk meta verilerini ve oturum devrini güncelleyin.",
newCard: "Yeni kart",
newCardHelp: "Bir ajan oturumu için işi kuyruğa alın.",
archiveCard: "Kartı arşivle",
deleteCard: "Kartı sil",
openSession: "Oturumu aç",
openLinkedSession: "Bağlantılı oturumu aç",
@@ -514,6 +515,20 @@ export const tr: TranslationMap = {
fieldAgent: "Aracı",
fieldSession: "Oturum",
fieldLabels: "Etiketler",
templatesLabel: "Kart şablonları",
template: {
bugfix: "Hata düzeltmesi",
docs: "Belgeler",
release: "Sürüm",
pr_review: "PR incelemesi",
plugin: "Eklenti",
},
badgeAttempts: "{count} deneme",
badgeFailures: "{count} başarısız",
badgeComments: "{count} yorum",
badgeLinks: "{count} bağlantı",
badgeProof: "{count} kanıt",
badgeStale: "eski",
titlePlaceholder: "Kart başlığı",
notesPlaceholder: "Notlar, kabul kriterleri, bağlantılar",
labelsPlaceholder: "ui, docs",
@@ -528,6 +543,8 @@ export const tr: TranslationMap = {
lifecycleIdleDetail: "Etkin çalışma yok",
lifecycleRunning: "Çalışıyor",
lifecycleRunningDetail: "Etkin çalışma devam ediyor",
lifecycleStale: "Eski",
lifecycleStaleDetail: "Yakın zamanda oturum etkinliği yok",
lifecycleDone: "Tamamlandı",
lifecycleDoneDetail: "İncelemeye taşındı",
lifecycleNeedsReview: "İnceleme gerekli",
@@ -539,6 +556,14 @@ export const tr: TranslationMap = {
eventMovedTo: "{status} durumuna taşındı",
eventLinked: "Oturum bağlandı",
eventExecutionUpdated: "Aracı güncellendi",
eventAttemptStarted: "Deneme başlatıldı",
eventAttemptUpdated: "Deneme güncellendi",
eventCommentAdded: "Yorum eklendi",
eventLinkAdded: "Bağlantı eklendi",
eventProofAdded: "Kanıt eklendi",
eventArchived: "Arşivlendi",
eventUnarchived: "Arşivden çıkarıldı",
eventStale: "Eski oturum",
gameButton: "Mini oyun",
gameTitle: "Kart Takibi",
gameStart: "Başlatma karesine ulaşın.",

View File

@@ -497,6 +497,7 @@ export const uk: TranslationMap = {
editCardHelp: "Оновіть метадані черги та передавання сесії.",
newCard: "Нова картка",
newCardHelp: "Поставте роботу в чергу для сесії агента.",
archiveCard: "Архівувати картку",
deleteCard: "Видалити картку",
openSession: "Відкрити сесію",
openLinkedSession: "Відкрити пов’язану сесію",
@@ -513,6 +514,20 @@ export const uk: TranslationMap = {
fieldAgent: "Агент",
fieldSession: "Сеанс",
fieldLabels: "Мітки",
templatesLabel: "Шаблони карток",
template: {
bugfix: "Виправлення помилки",
docs: "Документація",
release: "Реліз",
pr_review: "Перегляд PR",
plugin: "Плагін",
},
badgeAttempts: "{count} спроб",
badgeFailures: "{count} невдалих",
badgeComments: "{count} коментарів",
badgeLinks: "{count} посилань",
badgeProof: "{count} доказів",
badgeStale: "застаріле",
titlePlaceholder: "Заголовок картки",
notesPlaceholder: "Нотатки, критерії приймання, посилання",
labelsPlaceholder: "ui, docs",
@@ -527,6 +542,8 @@ export const uk: TranslationMap = {
lifecycleIdleDetail: "Немає активного запуску",
lifecycleRunning: "Запущено",
lifecycleRunningDetail: "Виконується активний запуск",
lifecycleStale: "Застаріле",
lifecycleStaleDetail: "Немає нещодавньої активності сеансу",
lifecycleDone: "Готово",
lifecycleDoneDetail: "Переміщено на перевірку",
lifecycleNeedsReview: "Потребує перевірки",
@@ -538,6 +555,14 @@ export const uk: TranslationMap = {
eventMovedTo: "Переміщено до {status}",
eventLinked: "Пов’язаний сеанс",
eventExecutionUpdated: "Агент оновлено",
eventAttemptStarted: "Спробу розпочато",
eventAttemptUpdated: "Спробу оновлено",
eventCommentAdded: "Коментар додано",
eventLinkAdded: "Посилання додано",
eventProofAdded: "Доказ додано",
eventArchived: "Заархівовано",
eventUnarchived: "Розархівовано",
eventStale: "Застарілий сеанс",
gameButton: "Мінігра",
gameTitle: "Card Chase",
gameStart: "Дістаньтеся клітинки запуску.",

View File

@@ -496,6 +496,7 @@ export const vi: TranslationMap = {
editCardHelp: "Cập nhật siêu dữ liệu hàng đợi và bàn giao phiên.",
newCard: "Thẻ mới",
newCardHelp: "Đưa công việc vào hàng đợi cho một phiên agent.",
archiveCard: "Lưu trữ thẻ",
deleteCard: "Xóa thẻ",
openSession: "Mở phiên",
openLinkedSession: "Mở phiên được liên kết",
@@ -512,6 +513,20 @@ export const vi: TranslationMap = {
fieldAgent: "Agent",
fieldSession: "Phiên",
fieldLabels: "Nhãn",
templatesLabel: "Mẫu thẻ",
template: {
bugfix: "Sửa lỗi",
docs: "Tài liệu",
release: "Bản phát hành",
pr_review: "Đánh giá PR",
plugin: "Plugin",
},
badgeAttempts: "{count} lần thử",
badgeFailures: "{count} thất bại",
badgeComments: "{count} bình luận",
badgeLinks: "{count} liên kết",
badgeProof: "{count} bằng chứng",
badgeStale: "cũ",
titlePlaceholder: "Tiêu đề thẻ",
notesPlaceholder: "Ghi chú, tiêu chí chấp nhận, liên kết",
labelsPlaceholder: "ui, docs",
@@ -526,6 +541,8 @@ export const vi: TranslationMap = {
lifecycleIdleDetail: "Không có lượt chạy đang hoạt động",
lifecycleRunning: "Đang chạy",
lifecycleRunningDetail: "Lượt chạy đang hoạt động đang diễn ra",
lifecycleStale: "Cũ",
lifecycleStaleDetail: "Không có hoạt động phiên gần đây",
lifecycleDone: "Hoàn tất",
lifecycleDoneDetail: "Đã chuyển sang xem xét",
lifecycleNeedsReview: "Cần xem xét",
@@ -537,6 +554,14 @@ export const vi: TranslationMap = {
eventMovedTo: "Đã di chuyển đến {status}",
eventLinked: "Phiên đã liên kết",
eventExecutionUpdated: "Agent đã cập nhật",
eventAttemptStarted: "Đã bắt đầu lần thử",
eventAttemptUpdated: "Đã cập nhật lần thử",
eventCommentAdded: "Đã thêm bình luận",
eventLinkAdded: "Đã thêm liên kết",
eventProofAdded: "Đã thêm bằng chứng",
eventArchived: "Đã lưu trữ",
eventUnarchived: "Đã bỏ lưu trữ",
eventStale: "Phiên đã cũ",
gameButton: "Trò chơi nhỏ",
gameTitle: "Đuổi bắt thẻ",
gameStart: "Đến ô khởi chạy.",

View File

@@ -493,6 +493,7 @@ export const zh_CN: TranslationMap = {
editCardHelp: "更新队列元数据和会话交接。",
newCard: "新建卡片",
newCardHelp: "为代理会话排队工作。",
archiveCard: "归档卡片",
deleteCard: "删除卡片",
openSession: "打开会话",
openLinkedSession: "打开关联会话",
@@ -509,6 +510,20 @@ export const zh_CN: TranslationMap = {
fieldAgent: "代理",
fieldSession: "会话",
fieldLabels: "标签",
templatesLabel: "卡片模板",
template: {
bugfix: "Bugfix",
docs: "Docs",
release: "Release",
pr_review: "PR review",
plugin: "Plugin",
},
badgeAttempts: "{count} 次尝试",
badgeFailures: "{count} 次失败",
badgeComments: "{count} 条评论",
badgeLinks: "{count} 个链接",
badgeProof: "{count} 个证明",
badgeStale: "已过期",
titlePlaceholder: "卡片标题",
notesPlaceholder: "备注、验收标准、链接",
labelsPlaceholder: "ui, docs",
@@ -523,6 +538,8 @@ export const zh_CN: TranslationMap = {
lifecycleIdleDetail: "无活动运行",
lifecycleRunning: "运行中",
lifecycleRunningDetail: "活动运行正在进行",
lifecycleStale: "已过期",
lifecycleStaleDetail: "最近没有会话活动",
lifecycleDone: "已完成",
lifecycleDoneDetail: "已移至审核",
lifecycleNeedsReview: "需要审核",
@@ -534,6 +551,14 @@ export const zh_CN: TranslationMap = {
eventMovedTo: "已移至 {status}",
eventLinked: "已关联会话",
eventExecutionUpdated: "Agent 已更新",
eventAttemptStarted: "尝试已开始",
eventAttemptUpdated: "尝试已更新",
eventCommentAdded: "评论已添加",
eventLinkAdded: "链接已添加",
eventProofAdded: "证明已添加",
eventArchived: "已归档",
eventUnarchived: "已取消归档",
eventStale: "过期会话",
gameButton: "迷你游戏",
gameTitle: "卡片追逐",
gameStart: "到达发布图块。",

View File

@@ -493,6 +493,7 @@ export const zh_TW: TranslationMap = {
editCardHelp: "更新佇列中繼資料與工作階段交接。",
newCard: "新增卡片",
newCardHelp: "為代理程式工作階段排入工作。",
archiveCard: "封存卡片",
deleteCard: "刪除卡片",
openSession: "開啟工作階段",
openLinkedSession: "開啟連結的工作階段",
@@ -509,6 +510,20 @@ export const zh_TW: TranslationMap = {
fieldAgent: "代理",
fieldSession: "工作階段",
fieldLabels: "標籤",
templatesLabel: "卡片範本",
template: {
bugfix: "錯誤修正",
docs: "文件",
release: "發佈",
pr_review: "PR 審查",
plugin: "外掛程式",
},
badgeAttempts: "{count} 次嘗試",
badgeFailures: "{count} 個失敗",
badgeComments: "{count} 則留言",
badgeLinks: "{count} 個連結",
badgeProof: "{count} 個證明",
badgeStale: "過期",
titlePlaceholder: "卡片標題",
notesPlaceholder: "備註、驗收標準、連結",
labelsPlaceholder: "ui, docs",
@@ -523,6 +538,8 @@ export const zh_TW: TranslationMap = {
lifecycleIdleDetail: "沒有進行中的執行",
lifecycleRunning: "執行中",
lifecycleRunningDetail: "正在執行中",
lifecycleStale: "過期",
lifecycleStaleDetail: "最近沒有工作階段活動",
lifecycleDone: "完成",
lifecycleDoneDetail: "已移至審查",
lifecycleNeedsReview: "需要審查",
@@ -534,6 +551,14 @@ export const zh_TW: TranslationMap = {
eventMovedTo: "已移動至 {status}",
eventLinked: "已連結工作階段",
eventExecutionUpdated: "代理程式已更新",
eventAttemptStarted: "嘗試已開始",
eventAttemptUpdated: "嘗試已更新",
eventCommentAdded: "留言已新增",
eventLinkAdded: "連結已新增",
eventProofAdded: "證明已新增",
eventArchived: "已封存",
eventUnarchived: "已取消封存",
eventStale: "過期工作階段",
gameButton: "小遊戲",
gameTitle: "卡片追逐",
gameStart: "到達啟動方格。",

View File

@@ -28,7 +28,9 @@
.workboard-toolbar__filters,
.workboard-toolbar__actions,
.workboard-draft__meta,
.workboard-template-strip,
.workboard-card__actions,
.workboard-card__badges,
.workboard-card__meta,
.workboard-card__top,
.workboard-labels {
@@ -167,6 +169,21 @@
min-width: 0;
}
.workboard-template-strip {
gap: 6px;
}
.workboard-template-strip .btn {
min-height: 28px;
padding-inline: 9px;
}
.workboard-template-strip__button--active {
border-color: color-mix(in srgb, var(--accent) 50%, var(--border-strong));
background: color-mix(in srgb, var(--accent) 14%, transparent);
color: var(--accent);
}
.workboard-draft__title {
font-weight: 650;
width: 100%;
@@ -457,6 +474,10 @@
justify-content: flex-end;
}
.workboard-card__badges {
gap: 5px;
}
.workboard-card__execution-controls {
display: grid;
grid-template-columns: repeat(2, minmax(72px, 1fr));
@@ -518,6 +539,7 @@
}
.workboard-card__priority,
.workboard-card__badges span,
.workboard-live,
.workboard-lifecycle,
.workboard-labels span {

View File

@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewaySessionRow } from "../types.ts";
import {
archiveWorkboardCard,
captureSessionToWorkboard,
createWorkboardCard,
getWorkboardLifecycle,
@@ -48,7 +49,7 @@ const sampleCard: WorkboardCard = {
const sampleSession: GatewaySessionRow = {
key: "agent:main:dashboard:1",
kind: "direct",
updatedAt: 2,
updatedAt: Date.now(),
displayName: "Dashboard session",
hasActiveRun: true,
status: "running",
@@ -97,6 +98,32 @@ describe("workboard controller", () => {
expect(state.draftSessionKey).toBe("");
});
it("creates template-backed cards from draft state", async () => {
const host = {};
const state = getWorkboardState(host);
state.draftTitle = "Fix: flaky worker";
state.draftTemplateId = "bugfix";
const created = {
...sampleCard,
id: "card-2",
title: "Fix: flaky worker",
metadata: { templateId: "bugfix" },
} satisfies WorkboardCard;
const client = createClient({ "workboard.cards.create": { card: created } });
await createWorkboardCard({ host, client: client as never });
expect(client.request).toHaveBeenCalledWith(
"workboard.cards.create",
expect.objectContaining({
title: "Fix: flaky worker",
templateId: "bugfix",
}),
);
expect(state.cards[0]?.metadata?.templateId).toBe("bugfix");
expect(state.draftTemplateId).toBe("");
});
it("updates cards from draft state when editing", async () => {
const host = {};
const state = getWorkboardState(host);
@@ -232,6 +259,39 @@ describe("workboard controller", () => {
expect(client.request).not.toHaveBeenCalled();
});
it("restores archived captured sessions instead of leaving them hidden", async () => {
const host = {};
const state = getWorkboardState(host);
const archived = {
...sampleCard,
sessionKey: sampleSession.key,
metadata: { archivedAt: 10 },
} satisfies WorkboardCard;
const restored = {
...archived,
metadata: {},
} satisfies WorkboardCard;
state.loaded = true;
state.cards = [archived];
const client = createClient({
"workboard.cards.archive": { card: restored },
});
const card = await captureSessionToWorkboard({
host,
client: client as never,
session: sampleSession,
});
expect(card).toMatchObject({ id: restored.id, sessionKey: sampleSession.key });
expect(card?.metadata?.archivedAt).toBeUndefined();
expect(client.request).toHaveBeenCalledWith("workboard.cards.archive", {
id: archived.id,
archived: false,
});
expect(state.cards[0]?.metadata?.archivedAt).toBeUndefined();
});
it("does not start duplicate capture requests while a session is in flight", async () => {
const host = {};
const state = getWorkboardState(host);
@@ -429,6 +489,54 @@ describe("workboard controller", () => {
);
});
it("resets execution start time when retrying a card run", async () => {
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1234);
try {
const host = {};
const previous = {
...sampleCard,
execution: {
id: "card-1:codex",
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "blocked",
model: "openai/gpt-5.5",
sessionKey: "agent:main:dashboard:1",
runId: "run-1",
startedAt: 10,
updatedAt: 20,
},
} satisfies WorkboardCard;
const client = createClient({
"sessions.create": { key: "agent:main:dashboard:1", runId: "run-2" },
"workboard.cards.update": { card: previous },
});
await startWorkboardCard({
host,
client: client as never,
card: previous,
engine: "codex",
});
expect(client.request).toHaveBeenNthCalledWith(
2,
"workboard.cards.update",
expect.objectContaining({
patch: expect.objectContaining({
execution: expect.objectContaining({
runId: "run-2",
startedAt: 1234,
}),
}),
}),
);
} finally {
nowSpy.mockRestore();
}
});
it("starts a manual Claude execution without sending the card prompt", async () => {
const host = {};
const running = {
@@ -570,6 +678,35 @@ describe("workboard controller", () => {
state: "failed",
targetStatus: "blocked",
});
expect(
getWorkboardLifecycle(linked, [
{
...sampleSession,
hasActiveRun: false,
status: "running",
updatedAt: Date.now() - 31 * 60 * 1000,
},
]),
).toMatchObject({
state: "stale",
targetStatus: "running",
});
expect(
getWorkboardLifecycle(linked, [
{ ...sampleSession, hasActiveRun: true, updatedAt: Date.now() - 31 * 60 * 1000 },
]),
).toMatchObject({
state: "running",
targetStatus: "running",
});
expect(
getWorkboardLifecycle(linked, [
{ ...sampleSession, hasActiveRun: undefined, updatedAt: Date.now() - 31 * 60 * 1000 },
]),
).toMatchObject({
state: "running",
targetStatus: "running",
});
expect(
getWorkboardLifecycle(
{
@@ -626,6 +763,124 @@ describe("workboard controller", () => {
expect(state.cards.find((card) => card.id === "card-review")?.status).toBe("review");
});
it("moves stale running sessions into running while recording stale metadata", async () => {
const host = {};
const state = getWorkboardState(host);
const staleUpdatedAt = Date.now() - 31 * 60 * 1000;
const linked = {
...sampleCard,
sessionKey: sampleSession.key,
metadata: {
comments: [{ id: "comment-1", body: "Keep me", createdAt: 1 }],
},
} satisfies WorkboardCard;
state.loaded = true;
state.cards = [linked];
const client = createClient({
"workboard.cards.update": {
card: {
...linked,
status: "running",
metadata: {
stale: {
detectedAt: 1,
lastSessionUpdatedAt: staleUpdatedAt,
reason: "Linked session has not reported recent activity.",
},
},
},
},
});
await syncWorkboardLifecycle({
host,
client: client as never,
sessions: [{ ...sampleSession, updatedAt: staleUpdatedAt, hasActiveRun: false }],
});
expect(client.request).toHaveBeenCalledWith("workboard.cards.update", {
id: "card-1",
patch: {
status: "running",
metadata: {
stale: expect.objectContaining({
lastSessionUpdatedAt: staleUpdatedAt,
reason: "Linked session has not reported recent activity.",
}),
},
},
});
});
it("syncs stale session metadata and clears it when the session recovers", async () => {
const host = {};
const state = getWorkboardState(host);
const linked = {
...sampleCard,
status: "running",
sessionKey: sampleSession.key,
metadata: {
comments: [{ id: "comment-1", body: "Keep me", createdAt: 1 }],
stale: {
detectedAt: 1,
lastSessionUpdatedAt: 1,
reason: "Linked session has not reported recent activity.",
},
},
} satisfies WorkboardCard;
state.loaded = true;
state.cards = [linked];
const client = createClient({
"workboard.cards.update": {
card: { ...linked, metadata: undefined, updatedAt: 3 },
},
});
await syncWorkboardLifecycle({
host,
client: client as never,
sessions: [{ ...sampleSession, updatedAt: Date.now(), hasActiveRun: true }],
});
expect(client.request).toHaveBeenCalledWith("workboard.cards.update", {
id: "card-1",
patch: {
metadata: {
stale: null,
},
},
});
});
it("does not rewrite unchanged stale session metadata", async () => {
const host = {};
const state = getWorkboardState(host);
const staleUpdatedAt = Date.now() - 31 * 60 * 1000;
const linked = {
...sampleCard,
status: "running",
sessionKey: sampleSession.key,
metadata: {
stale: {
detectedAt: 1,
lastSessionUpdatedAt: staleUpdatedAt,
reason: "Linked session has not reported recent activity.",
},
},
} satisfies WorkboardCard;
state.loaded = true;
state.cards = [linked];
const client = createClient({ "workboard.cards.update": { card: linked } });
await syncWorkboardLifecycle({
host,
client: client as never,
sessions: [{ ...sampleSession, updatedAt: staleUpdatedAt, hasActiveRun: false }],
});
expect(client.request).not.toHaveBeenCalled();
});
it("does not mark executions blocked when the linked session is missing from the current list", async () => {
const host = {};
const state = getWorkboardState(host);
@@ -774,6 +1029,27 @@ describe("workboard controller", () => {
expect(getWorkboardState(host).cards[0]).toMatchObject({ status: "blocked" });
});
it("archives cards through the plugin gateway method", async () => {
const host = {};
const archived = {
...sampleCard,
metadata: { archivedAt: 20 },
} satisfies WorkboardCard;
const client = createClient({ "workboard.cards.archive": { card: archived } });
await archiveWorkboardCard({
host,
client: client as never,
cardId: "card-1",
});
expect(client.request).toHaveBeenCalledWith("workboard.cards.archive", {
id: "card-1",
archived: true,
});
expect(getWorkboardState(host).cards[0]?.metadata?.archivedAt).toBe(20);
});
it("falls back to the active session abort when the stored run id is stale", async () => {
const host = {};
const linked = { ...sampleCard, sessionKey: sampleSession.key, runId: "old-run" };

View File

@@ -26,7 +26,25 @@ export const WORKBOARD_EVENT_KINDS = [
"moved",
"linked",
"execution_updated",
"attempt_started",
"attempt_updated",
"comment_added",
"link_added",
"proof_added",
"archived",
"unarchived",
"stale",
] as const;
export const WORKBOARD_ATTEMPT_STATUSES = [
"running",
"succeeded",
"failed",
"blocked",
"stopped",
] as const;
export const WORKBOARD_LINK_TYPES = ["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_ENGINE_MODELS = {
codex: "openai/gpt-5.5",
@@ -39,6 +57,10 @@ export type WorkboardExecutionEngine = (typeof WORKBOARD_EXECUTION_ENGINES)[numb
export type WorkboardExecutionMode = (typeof WORKBOARD_EXECUTION_MODES)[number];
export type WorkboardExecutionStatus = (typeof WORKBOARD_EXECUTION_STATUSES)[number];
export type WorkboardEventKind = (typeof WORKBOARD_EVENT_KINDS)[number];
export type WorkboardAttemptStatus = (typeof WORKBOARD_ATTEMPT_STATUSES)[number];
export type WorkboardLinkType = (typeof WORKBOARD_LINK_TYPES)[number];
export type WorkboardProofStatus = (typeof WORKBOARD_PROOF_STATUSES)[number];
export type WorkboardTemplateId = (typeof WORKBOARD_TEMPLATE_IDS)[number];
export type WorkboardExecution = {
id: string;
@@ -63,6 +85,62 @@ export type WorkboardEvent = {
runId?: string;
};
export type WorkboardRunAttempt = {
id: string;
status: WorkboardAttemptStatus;
startedAt: number;
endedAt?: number;
engine?: WorkboardExecutionEngine;
mode?: WorkboardExecutionMode;
model?: string;
sessionKey?: string;
runId?: string;
error?: string;
};
export type WorkboardComment = {
id: string;
body: string;
createdAt: number;
updatedAt?: number;
};
export type WorkboardLink = {
id: string;
type: WorkboardLinkType;
createdAt: number;
targetCardId?: string;
title?: string;
url?: string;
};
export type WorkboardProof = {
id: string;
status: WorkboardProofStatus;
createdAt: number;
label?: string;
command?: string;
url?: string;
note?: string;
};
export type WorkboardStaleState = {
detectedAt: number;
lastSessionUpdatedAt?: number;
reason: string;
};
export type WorkboardMetadata = {
attempts?: WorkboardRunAttempt[];
comments?: WorkboardComment[];
links?: WorkboardLink[];
proof?: WorkboardProof[];
templateId?: WorkboardTemplateId;
archivedAt?: number;
stale?: WorkboardStaleState;
failureCount?: number;
};
export type WorkboardCard = {
id: string;
title: string;
@@ -82,6 +160,7 @@ export type WorkboardCard = {
startedAt?: number;
completedAt?: number;
events?: WorkboardEvent[];
metadata?: WorkboardMetadata;
};
export type WorkboardLifecycleState =
@@ -89,6 +168,7 @@ export type WorkboardLifecycleState =
| "missing"
| "idle"
| "running"
| "stale"
| "succeeded"
| "failed";
@@ -116,6 +196,7 @@ export type WorkboardUiState = {
draftLabels: string;
draftAgentId: string;
draftSessionKey: string;
draftTemplateId: WorkboardTemplateId | "";
busyCardId: string | null;
draggedCardId: string | null;
syncingCardIds: Set<string>;
@@ -136,6 +217,7 @@ const SESSION_CAPTURE_HISTORY_MAX_CHARS = 6000;
const SESSION_CAPTURE_TEXT_MAX_CHARS = 700;
const WORKBOARD_CAPTURE_TITLE_MAX_CHARS = 180;
const WORKBOARD_SESSION_LABEL_MAX_CHARS = 512;
const WORKBOARD_STALE_SESSION_MS = 30 * 60 * 1000;
function createDefaultState(): WorkboardUiState {
return {
@@ -156,6 +238,7 @@ function createDefaultState(): WorkboardUiState {
draftLabels: "",
draftAgentId: "",
draftSessionKey: "",
draftTemplateId: "",
busyCardId: null,
draggedCardId: null,
syncingCardIds: new Set(),
@@ -263,6 +346,137 @@ function normalizeEvents(value: unknown): WorkboardEvent[] {
: [];
}
function normalizeMetadata(value: unknown): WorkboardMetadata | undefined {
if (!isRecord(value)) {
return undefined;
}
const attempts = Array.isArray(value.attempts)
? value.attempts.flatMap((entry): WorkboardRunAttempt[] => {
if (
!isRecord(entry) ||
typeof entry.id !== "string" ||
typeof entry.startedAt !== "number"
) {
return [];
}
const status = WORKBOARD_ATTEMPT_STATUSES.includes(entry.status as WorkboardAttemptStatus)
? (entry.status as WorkboardAttemptStatus)
: "running";
return [
{
id: entry.id,
status,
startedAt: entry.startedAt,
...(typeof entry.endedAt === "number" ? { endedAt: entry.endedAt } : {}),
...(WORKBOARD_EXECUTION_ENGINES.includes(entry.engine as WorkboardExecutionEngine)
? { engine: entry.engine as WorkboardExecutionEngine }
: {}),
...(WORKBOARD_EXECUTION_MODES.includes(entry.mode as WorkboardExecutionMode)
? { mode: entry.mode as WorkboardExecutionMode }
: {}),
...(typeof entry.model === "string" ? { model: entry.model } : {}),
...(typeof entry.sessionKey === "string" ? { sessionKey: entry.sessionKey } : {}),
...(typeof entry.runId === "string" ? { runId: entry.runId } : {}),
...(typeof entry.error === "string" ? { error: entry.error } : {}),
},
];
})
: [];
const comments = Array.isArray(value.comments)
? value.comments.flatMap((entry): WorkboardComment[] => {
if (
!isRecord(entry) ||
typeof entry.id !== "string" ||
typeof entry.body !== "string" ||
typeof entry.createdAt !== "number"
) {
return [];
}
return [
{
id: entry.id,
body: entry.body,
createdAt: entry.createdAt,
...(typeof entry.updatedAt === "number" ? { updatedAt: entry.updatedAt } : {}),
},
];
})
: [];
const links = Array.isArray(value.links)
? value.links.flatMap((entry): WorkboardLink[] => {
if (
!isRecord(entry) ||
typeof entry.id !== "string" ||
typeof entry.createdAt !== "number"
) {
return [];
}
return [
{
id: entry.id,
type: WORKBOARD_LINK_TYPES.includes(entry.type as WorkboardLinkType)
? (entry.type as WorkboardLinkType)
: "relates_to",
createdAt: entry.createdAt,
...(typeof entry.targetCardId === "string" ? { targetCardId: entry.targetCardId } : {}),
...(typeof entry.title === "string" ? { title: entry.title } : {}),
...(typeof entry.url === "string" ? { url: entry.url } : {}),
},
];
})
: [];
const proof = Array.isArray(value.proof)
? value.proof.flatMap((entry): WorkboardProof[] => {
if (
!isRecord(entry) ||
typeof entry.id !== "string" ||
typeof entry.createdAt !== "number"
) {
return [];
}
return [
{
id: entry.id,
status: WORKBOARD_PROOF_STATUSES.includes(entry.status as WorkboardProofStatus)
? (entry.status as WorkboardProofStatus)
: "unknown",
createdAt: entry.createdAt,
...(typeof entry.label === "string" ? { label: entry.label } : {}),
...(typeof entry.command === "string" ? { command: entry.command } : {}),
...(typeof entry.url === "string" ? { url: entry.url } : {}),
...(typeof entry.note === "string" ? { note: entry.note } : {}),
},
];
})
: [];
const stale = isRecord(value.stale)
? {
detectedAt:
typeof value.stale.detectedAt === "number" ? value.stale.detectedAt : Date.now(),
...(typeof value.stale.lastSessionUpdatedAt === "number"
? { lastSessionUpdatedAt: value.stale.lastSessionUpdatedAt }
: {}),
reason:
typeof value.stale.reason === "string"
? value.stale.reason
: "Session has not reported recent activity.",
}
: undefined;
const metadata: WorkboardMetadata = {
...(attempts.length ? { attempts } : {}),
...(comments.length ? { comments } : {}),
...(links.length ? { links } : {}),
...(proof.length ? { proof } : {}),
...(WORKBOARD_TEMPLATE_IDS.includes(value.templateId as WorkboardTemplateId)
? { templateId: value.templateId as WorkboardTemplateId }
: {}),
...(typeof value.archivedAt === "number" ? { archivedAt: value.archivedAt } : {}),
...(stale ? { stale } : {}),
...(typeof value.failureCount === "number" ? { failureCount: value.failureCount } : {}),
};
return Object.keys(metadata).length ? metadata : undefined;
}
function normalizeCard(value: unknown): WorkboardCard | null {
if (!isRecord(value)) {
return null;
@@ -280,6 +494,7 @@ function normalizeCard(value: unknown): WorkboardCard | null {
}
const execution = normalizeExecution(value.execution);
const events = normalizeEvents(value.events);
const metadata = normalizeMetadata(value.metadata);
return {
id,
title,
@@ -301,6 +516,7 @@ function normalizeCard(value: unknown): WorkboardCard | null {
...(typeof value.startedAt === "number" ? { startedAt: value.startedAt } : {}),
...(typeof value.completedAt === "number" ? { completedAt: value.completedAt } : {}),
...(events.length ? { events } : {}),
...(metadata ? { metadata } : {}),
};
}
@@ -385,6 +601,7 @@ function resetDraftState(state: WorkboardUiState) {
state.draftLabels = "";
state.draftAgentId = "";
state.draftSessionKey = "";
state.draftTemplateId = "";
}
function normalizeDraftLabels(value: string): string[] {
@@ -410,6 +627,7 @@ function draftPayload(state: WorkboardUiState) {
labels: normalizeDraftLabels(state.draftLabels),
agentId: state.draftAgentId,
sessionKey: state.draftSessionKey,
...(state.draftTemplateId ? { templateId: state.draftTemplateId } : {}),
};
}
@@ -417,6 +635,26 @@ function isFailedSessionStatus(status: GatewaySessionRow["status"]): boolean {
return status === "failed" || status === "killed" || status === "timeout";
}
function staleSessionState(session: GatewaySessionRow): WorkboardStaleState | undefined {
if (session.status !== "running") {
return undefined;
}
if (session.hasActiveRun !== false) {
return undefined;
}
if (
typeof session.updatedAt !== "number" ||
Date.now() - session.updatedAt < WORKBOARD_STALE_SESSION_MS
) {
return undefined;
}
return {
detectedAt: Date.now(),
lastSessionUpdatedAt: session.updatedAt,
reason: "Linked session has not reported recent activity.",
};
}
function workboardCardSessionKey(card: WorkboardCard): string | undefined {
return card.sessionKey ?? card.execution?.sessionKey;
}
@@ -436,6 +674,9 @@ export function getWorkboardLifecycle(
if (!session) {
return { session: null, state: "missing" };
}
if (staleSessionState(session)) {
return { session, state: "stale", targetStatus: "running" };
}
if (session.hasActiveRun === true || session.status === "running") {
return { session, state: "running", targetStatus: "running" };
}
@@ -466,6 +707,7 @@ function executionStatusForLifecycle(
): WorkboardExecutionStatus | undefined {
switch (lifecycle.state) {
case "running":
case "stale":
return "running";
case "succeeded":
return "review";
@@ -495,6 +737,7 @@ function lifecycleSyncKey(card: WorkboardCard, lifecycle: WorkboardLifecycle): s
card.status,
card.updatedAt,
lifecycle.targetStatus ?? "",
lifecycle.state,
session?.status ?? "",
session?.hasActiveRun === true ? "active" : "idle",
session?.updatedAt ?? "",
@@ -662,6 +905,15 @@ export async function captureSessionToWorkboard(params: {
(card) => workboardCardSessionKey(card) === params.session.key,
);
if (existing) {
if (existing.metadata?.archivedAt) {
const payload = await params.client.request("workboard.cards.archive", {
id: existing.id,
archived: false,
});
const restored = normalizeCardPayload(payload);
replaceCard(state, restored);
return restored;
}
return existing;
}
const messages = await loadSessionCaptureHistory({
@@ -720,6 +972,26 @@ export async function syncWorkboardLifecycle(params: {
updatedAt: Date.now(),
};
}
const stale = lifecycle.session ? staleSessionState(lifecycle.session) : undefined;
const existingStale = card.metadata?.stale;
if (stale) {
const staleChanged =
!existingStale ||
existingStale.lastSessionUpdatedAt !== stale.lastSessionUpdatedAt ||
existingStale.reason !== stale.reason;
if (staleChanged) {
patch.metadata = {
stale: {
...stale,
detectedAt: existingStale?.detectedAt ?? stale.detectedAt,
},
};
}
} else if (existingStale) {
patch.metadata = {
stale: null,
};
}
if (Object.keys(patch).length === 0) {
continue;
}
@@ -856,6 +1128,33 @@ export async function deleteWorkboardCard(params: {
}
}
export async function archiveWorkboardCard(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
cardId: string;
requestUpdate?: () => void;
}) {
const state = getWorkboardState(params.host);
if (!params.client) {
return;
}
state.busyCardId = params.cardId;
state.error = null;
params.requestUpdate?.();
try {
const payload = await params.client.request("workboard.cards.archive", {
id: params.cardId,
archived: true,
});
replaceCard(state, normalizeCardPayload(payload));
} catch (error) {
state.error = formatError(error);
} finally {
state.busyCardId = null;
params.requestUpdate?.();
}
}
function buildCardPrompt(card: WorkboardCard): string {
const lines = [`Work on this OpenClaw Workboard card: ${card.title}`];
if (card.notes?.trim()) {
@@ -895,7 +1194,7 @@ function buildWorkboardExecution(params: {
mode: params.mode,
status: params.status,
model: WORKBOARD_ENGINE_MODELS[params.engine],
startedAt: params.card.execution?.startedAt ?? now,
startedAt: now,
updatedAt: now,
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
...(params.runId ? { runId: params.runId } : {}),

View File

@@ -6,6 +6,7 @@ import { renderWorkboard } from "./workboard.ts";
describe("renderWorkboard", () => {
it("renders board columns and preloaded cards", () => {
const now = Date.now();
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
@@ -38,7 +39,7 @@ describe("renderWorkboard", () => {
key: "agent:main:dashboard:1",
kind: "direct",
displayName: "Dashboard session",
updatedAt: 2,
updatedAt: now,
hasActiveRun: true,
status: "running",
},
@@ -272,9 +273,44 @@ describe("renderWorkboard", () => {
);
expect(container.querySelector('[role="dialog"]')?.textContent).toContain("New card");
expect(container.querySelector('[aria-label="Card templates"]')?.textContent).toContain(
"Bugfix",
);
expect(container.querySelector(".workboard-board")).toBeTruthy();
});
it("applies card templates in the create modal", () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.draftOpen = true;
const container = document.createElement("div");
const props = {
host,
client: null,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
onRequestUpdate: () => undefined,
};
render(renderWorkboard(props), container);
[...container.querySelectorAll<HTMLButtonElement>(".workboard-template-strip .btn")]
.find((button) => button.textContent?.includes("Release"))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
render(renderWorkboard(props), container);
expect(state.draftTemplateId).toBe("release");
expect(container.querySelector<HTMLInputElement>(".workboard-draft__title")?.value).toBe(
"Release: ",
);
expect(
container.querySelector<HTMLTextAreaElement>(".workboard-draft__notes")?.value,
).toContain("Verification:");
});
it("opens and plays the mini game", () => {
const host = {};
const state = getWorkboardState(host);
@@ -346,6 +382,174 @@ describe("renderWorkboard", () => {
expect(container.querySelector(".workboard-events")?.textContent).toContain("Moved to Review");
});
it("renders card metadata badges and hides archived cards", () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.cards = [
{
id: "card-1",
title: "Metadata rich",
status: "todo",
priority: "normal",
labels: [],
position: 1000,
createdAt: 1,
updatedAt: 1,
metadata: {
templateId: "plugin",
attempts: [{ id: "run-1", status: "blocked", startedAt: 1, endedAt: 2 }],
failureCount: 1,
comments: [{ id: "comment-1", body: "Needs owner check", createdAt: 3 }],
links: [{ id: "link-1", type: "relates_to", url: "https://example.com", createdAt: 4 }],
proof: [{ id: "proof-1", status: "passed", command: "pnpm test", createdAt: 5 }],
stale: { detectedAt: 6, reason: "No recent activity." },
},
},
{
id: "card-2",
title: "Archived task",
status: "todo",
priority: "normal",
labels: [],
position: 2000,
createdAt: 1,
updatedAt: 1,
metadata: { archivedAt: 7 },
},
];
const container = document.createElement("div");
render(
renderWorkboard({
host,
client: null,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
}),
container,
);
expect(container.textContent).toContain("Plugin");
expect(container.textContent).toContain("1 attempts");
expect(container.textContent).toContain("1 failed");
expect(container.textContent).toContain("1 comments");
expect(container.textContent).toContain("1 links");
expect(container.textContent).toContain("1 proof");
expect(container.textContent).toContain("stale");
expect(container.textContent).not.toContain("Archived task");
});
it("shows stale lifecycle on executed linked cards", () => {
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(60 * 60 * 1000);
try {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.cards = [
{
id: "card-1",
title: "Watch stale run",
status: "running",
priority: "normal",
labels: [],
position: 1000,
createdAt: 1,
updatedAt: 1,
execution: {
id: "exec-1",
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "running",
model: "openai/gpt-5.5",
sessionKey: "agent:main:dashboard:1",
startedAt: 1,
updatedAt: 1,
},
},
];
const container = document.createElement("div");
render(
renderWorkboard({
host,
client: null,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [
{
key: "agent:main:dashboard:1",
kind: "direct",
displayName: "Dashboard session",
updatedAt: 1,
hasActiveRun: false,
status: "running",
},
],
onOpenSession: () => undefined,
}),
container,
);
expect(container.textContent).toContain("Stale");
expect(container.textContent).toContain("No recent session activity");
expect(container.textContent).not.toContain("codex autonomous");
expect(container.querySelector(".workboard-live")).toBeNull();
expect(container.querySelector('button[title="Stop session"]')).toBeNull();
} finally {
nowSpy.mockRestore();
}
});
it("keeps live controls for legacy running session rows", () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.cards = [
{
id: "card-1",
title: "Stop legacy run",
status: "running",
priority: "normal",
labels: [],
position: 1000,
createdAt: 1,
updatedAt: 1,
sessionKey: "agent:main:dashboard:1",
},
];
const container = document.createElement("div");
render(
renderWorkboard({
host,
client: null,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [
{
key: "agent:main:dashboard:1",
kind: "direct",
displayName: "Dashboard session",
updatedAt: 1,
status: "running",
},
],
onOpenSession: () => undefined,
}),
container,
);
expect(container.querySelector(".workboard-live")?.textContent).toContain("live");
expect(container.querySelector('button[title="Stop session"]')).not.toBeNull();
});
it("opens an edit modal and submits card updates", async () => {
const host = {};
const state = getWorkboardState(host);
@@ -414,6 +618,53 @@ describe("renderWorkboard", () => {
});
});
it("archives cards from the card action", async () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.cards = [
{
id: "card-1",
title: "Archive me",
status: "done",
priority: "normal",
labels: [],
position: 1000,
createdAt: 1,
updatedAt: 1,
},
];
const request = vi.fn(async () => ({
card: { ...state.cards[0], metadata: { archivedAt: 2 } },
}));
const container = document.createElement("div");
render(
renderWorkboard({
host,
client: { request } as unknown as GatewayBrowserClient,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
onRequestUpdate: () => undefined,
}),
container,
);
container
.querySelector<HTMLButtonElement>('button[title="Archive card"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
await Promise.resolve();
expect(request).toHaveBeenCalledWith("workboard.cards.archive", {
id: "card-1",
archived: true,
});
expect(state.cards[0]?.metadata?.archivedAt).toBe(2);
});
it("offers existing sessions when creating a card", () => {
const host = {};
const state = getWorkboardState(host);

View File

@@ -1,6 +1,7 @@
import { html, nothing } from "lit";
import { t } from "../../i18n/index.ts";
import {
archiveWorkboardCard,
deleteWorkboardCard,
findWorkboardSession,
getWorkboardLifecycle,
@@ -19,6 +20,7 @@ import {
type WorkboardLifecycle,
type WorkboardPriority,
type WorkboardStatus,
type WorkboardTemplateId,
type WorkboardUiState,
} from "../controllers/workboard.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
@@ -40,6 +42,49 @@ type WorkboardProps = {
const WORKBOARD_GAME_SIZE = 5;
const WORKBOARD_GAME_GOAL = WORKBOARD_GAME_SIZE * WORKBOARD_GAME_SIZE - 1;
const WORKBOARD_GAME_BLOCKERS = new Set([6, 8, 12, 16, 18]);
const WORKBOARD_TEMPLATES: Array<{
id: WorkboardTemplateId;
title: string;
notes: string;
labels: string;
priority: WorkboardPriority;
}> = [
{
id: "bugfix",
title: "Fix: ",
notes: "Symptom:\nCause:\nAcceptance:\nProof:",
labels: "fix, test",
priority: "high",
},
{
id: "docs",
title: "Docs: ",
notes: "Page:\nChange:\nSource proof:",
labels: "docs",
priority: "normal",
},
{
id: "release",
title: "Release: ",
notes: "Scope:\nVerification:\nCloseout:",
labels: "release",
priority: "urgent",
},
{
id: "pr_review",
title: "Review PR ",
notes: "Surface:\nRisks:\nProof:",
labels: "review",
priority: "normal",
},
{
id: "plugin",
title: "Plugin: ",
notes: "Boundary:\nConfig/docs:\nTests:",
labels: "plugin",
priority: "normal",
},
];
function formatStatusLabel(status: WorkboardStatus): string {
return t(`workboard.status.${status}`);
@@ -73,6 +118,22 @@ function formatEventLabel(event: WorkboardEvent): string {
return t("workboard.eventLinked");
case "execution_updated":
return t("workboard.eventExecutionUpdated");
case "attempt_started":
return t("workboard.eventAttemptStarted");
case "attempt_updated":
return t("workboard.eventAttemptUpdated");
case "comment_added":
return t("workboard.eventCommentAdded");
case "link_added":
return t("workboard.eventLinkAdded");
case "proof_added":
return t("workboard.eventProofAdded");
case "archived":
return t("workboard.eventArchived");
case "unarchived":
return t("workboard.eventUnarchived");
case "stale":
return t("workboard.eventStale");
}
return "";
}
@@ -96,6 +157,38 @@ function renderEvents(card: WorkboardCard) {
`;
}
function renderMetadataBadges(card: WorkboardCard) {
const metadata = card.metadata;
if (!metadata) {
return nothing;
}
const badges = [
metadata.templateId ? t(`workboard.template.${metadata.templateId}`) : null,
metadata.attempts?.length
? t("workboard.badgeAttempts", { count: String(metadata.attempts.length) })
: null,
metadata.failureCount
? t("workboard.badgeFailures", { count: String(metadata.failureCount) })
: null,
metadata.comments?.length
? t("workboard.badgeComments", { count: String(metadata.comments.length) })
: null,
metadata.links?.length
? t("workboard.badgeLinks", { count: String(metadata.links.length) })
: null,
metadata.proof?.length
? t("workboard.badgeProof", { count: String(metadata.proof.length) })
: null,
metadata.stale ? t("workboard.badgeStale") : null,
].filter((badge): badge is string => Boolean(badge));
if (badges.length === 0) {
return nothing;
}
return html`
<div class="workboard-card__badges">${badges.map((badge) => html`<span>${badge}</span>`)}</div>
`;
}
function matchesFilter(
card: WorkboardCard,
options: { query: string; priority: "all" | WorkboardPriority },
@@ -116,6 +209,15 @@ function matchesFilter(
card.execution?.mode,
card.execution?.model,
card.execution?.sessionKey,
card.metadata?.templateId,
...(card.metadata?.comments ?? []).map((comment) => comment.body),
...(card.metadata?.links ?? []).flatMap((link) => [link.title, link.url, link.targetCardId]),
...(card.metadata?.proof ?? []).flatMap((proof) => [
proof.label,
proof.command,
proof.url,
proof.note,
]),
...card.labels,
]
.filter((value): value is string => typeof value === "string")
@@ -166,6 +268,7 @@ function resetDraft(state: WorkboardUiState) {
state.draftLabels = "";
state.draftAgentId = "";
state.draftSessionKey = "";
state.draftTemplateId = "";
}
function openCreateModal(state: WorkboardUiState) {
@@ -218,6 +321,19 @@ function openEditModal(state: WorkboardUiState, card: WorkboardCard) {
state.draftLabels = card.labels.join(", ");
state.draftAgentId = card.agentId ?? "";
state.draftSessionKey = card.sessionKey ?? "";
state.draftTemplateId = card.metadata?.templateId ?? "";
}
function applyTemplate(state: WorkboardUiState, templateId: WorkboardTemplateId) {
const template = WORKBOARD_TEMPLATES.find((entry) => entry.id === templateId);
if (!template) {
return;
}
state.draftTemplateId = template.id;
state.draftTitle = template.title;
state.draftNotes = template.notes;
state.draftLabels = template.labels;
state.draftPriority = template.priority;
}
function renderGameArrow(
@@ -406,6 +522,28 @@ function renderCardModal(props: WorkboardProps) {
${icons.x}
</button>
</div>
${!editing
? html`
<div class="workboard-template-strip" aria-label=${t("workboard.templatesLabel")}>
${WORKBOARD_TEMPLATES.map(
(template) => html`
<button
class="btn btn--xs ${state.draftTemplateId === template.id
? "workboard-template-strip__button--active"
: ""}"
type="button"
@click=${() => {
applyTemplate(state, template.id);
props.onRequestUpdate?.();
}}
>
${t(`workboard.template.${template.id}`)}
</button>
`,
)}
</div>
`
: nothing}
<div class="workboard-draft__main">
<label class="workboard-field">
<span>${t("workboard.fieldTitle")}</span>
@@ -560,6 +698,12 @@ function formatLifecycle(lifecycle: WorkboardLifecycle): {
detail: t("workboard.lifecycleNeedsReviewDetail"),
tone: "blocked",
};
case "stale":
return {
label: t("workboard.lifecycleStale"),
detail: t("workboard.lifecycleStaleDetail"),
tone: "blocked",
};
case "idle":
return {
label: t("workboard.lifecycleLinked"),
@@ -587,13 +731,14 @@ function renderLifecycle(card: WorkboardCard, sessions: readonly GatewaySessionR
const formatted = formatLifecycle(lifecycle);
const session = lifecycle.session;
const execution = card.execution;
const stale = lifecycle.state === "stale";
return html`
<div class="workboard-card__lifecycle">
<span class="workboard-lifecycle workboard-lifecycle--${formatted.tone}">
${execution ? `${execution.engine} ${execution.mode}` : formatted.label}
${stale || !execution ? formatted.label : `${execution.engine} ${execution.mode}`}
</span>
<span class="workboard-card__lifecycle-detail">
${session?.displayName ?? session?.label ?? formatted.detail}
${stale ? formatted.detail : (session?.displayName ?? session?.label ?? formatted.detail)}
</span>
</div>
`;
@@ -655,7 +800,9 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
const session = findWorkboardSession(card, props.sessions);
const busy = state.busyCardId === card.id;
const syncing = state.syncingCardIds.has(card.id);
const live = session?.hasActiveRun === true || session?.status === "running";
const live =
session?.hasActiveRun === true ||
(session?.hasActiveRun !== false && session?.status === "running");
const linkedSessionKey = card.sessionKey ?? card.execution?.sessionKey;
const linked = Boolean(linkedSessionKey);
const writable = canMutate(props);
@@ -709,6 +856,7 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
${card.labels.map((label) => html`<span>${label}</span>`)}
</div>`
: nothing}
${renderMetadataBadges(card)}
<div class="workboard-card__meta">
${card.agentId
? html`<span>${card.agentId}</span>`
@@ -763,6 +911,20 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
${showStartControls ? renderStartExecutionControls(props, card) : nothing}
${writable
? html`
<button
class="btn btn--icon workboard-card__icon"
title=${t("workboard.archiveCard")}
?disabled=${busy}
@click=${() =>
archiveWorkboardCard({
host: props.host,
client: props.client,
cardId: card.id,
requestUpdate: props.onRequestUpdate,
})}
>
${icons.check}
</button>
<button
class="btn btn--icon workboard-card__icon workboard-card__delete"
title=${t("workboard.deleteCard")}
@@ -855,9 +1017,9 @@ export function renderWorkboard(props: WorkboardProps) {
`;
}
const filtered = state.cards.filter((card) =>
matchesFilter(card, { query: state.query, priority: state.priorityFilter }),
);
const filtered = state.cards
.filter((card) => !card.metadata?.archivedAt)
.filter((card) => matchesFilter(card, { query: state.query, priority: state.priorityFilter }));
const writable = canMutate(props);
const byStatus = new Map<WorkboardStatus, WorkboardCard[]>();
for (const status of state.statuses) {