mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
feat(workboard): persist card metadata
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
8
ui/src/i18n/.i18n/ar.meta.json
generated
8
ui/src/i18n/.i18n/ar.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/de.meta.json
generated
8
ui/src/i18n/.i18n/de.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/es.meta.json
generated
8
ui/src/i18n/.i18n/es.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/fa.meta.json
generated
8
ui/src/i18n/.i18n/fa.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/fr.meta.json
generated
8
ui/src/i18n/.i18n/fr.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/id.meta.json
generated
8
ui/src/i18n/.i18n/id.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/it.meta.json
generated
8
ui/src/i18n/.i18n/it.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/ja-JP.meta.json
generated
8
ui/src/i18n/.i18n/ja-JP.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/ko.meta.json
generated
8
ui/src/i18n/.i18n/ko.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/nl.meta.json
generated
8
ui/src/i18n/.i18n/nl.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/pl.meta.json
generated
8
ui/src/i18n/.i18n/pl.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/pt-BR.meta.json
generated
8
ui/src/i18n/.i18n/pt-BR.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
35
ui/src/i18n/.i18n/raw-copy-baseline.json
generated
35
ui/src/i18n/.i18n/raw-copy-baseline.json
generated
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/th.meta.json
generated
8
ui/src/i18n/.i18n/th.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/tr.meta.json
generated
8
ui/src/i18n/.i18n/tr.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/uk.meta.json
generated
8
ui/src/i18n/.i18n/uk.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/vi.meta.json
generated
8
ui/src/i18n/.i18n/vi.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/zh-CN.meta.json
generated
8
ui/src/i18n/.i18n/zh-CN.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/zh-TW.meta.json
generated
8
ui/src/i18n/.i18n/zh-TW.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
25
ui/src/i18n/locales/ar.ts
generated
25
ui/src/i18n/locales/ar.ts
generated
@@ -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: "صِل إلى مربع الإطلاق.",
|
||||
|
||||
25
ui/src/i18n/locales/de.ts
generated
25
ui/src/i18n/locales/de.ts
generated
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
25
ui/src/i18n/locales/es.ts
generated
25
ui/src/i18n/locales/es.ts
generated
@@ -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.",
|
||||
|
||||
25
ui/src/i18n/locales/fa.ts
generated
25
ui/src/i18n/locales/fa.ts
generated
@@ -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: "به کاشی راهاندازی برسید.",
|
||||
|
||||
25
ui/src/i18n/locales/fr.ts
generated
25
ui/src/i18n/locales/fr.ts
generated
@@ -498,6 +498,7 @@ export const fr: TranslationMap = {
|
||||
editCardHelp: "Mettez à jour les métadonnées de la file d’attente et le transfert de session.",
|
||||
newCard: "Nouvelle carte",
|
||||
newCardHelp: "Mettez du travail en file d’attente pour une session d’agent.",
|
||||
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 d’acceptation, liens",
|
||||
labelsPlaceholder: "ui, docs",
|
||||
@@ -528,6 +543,8 @@ export const fr: TranslationMap = {
|
||||
lifecycleIdleDetail: "Aucune exécution active",
|
||||
lifecycleRunning: "En cours d’exé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.",
|
||||
|
||||
25
ui/src/i18n/locales/id.ts
generated
25
ui/src/i18n/locales/id.ts
generated
@@ -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.",
|
||||
|
||||
25
ui/src/i18n/locales/it.ts
generated
25
ui/src/i18n/locales/it.ts
generated
@@ -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.",
|
||||
|
||||
25
ui/src/i18n/locales/ja-JP.ts
generated
25
ui/src/i18n/locales/ja-JP.ts
generated
@@ -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: "ローンチタイルに到達してください。",
|
||||
|
||||
25
ui/src/i18n/locales/ko.ts
generated
25
ui/src/i18n/locales/ko.ts
generated
@@ -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: "출발 타일에 도달하세요.",
|
||||
|
||||
25
ui/src/i18n/locales/nl.ts
generated
25
ui/src/i18n/locales/nl.ts
generated
@@ -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.",
|
||||
|
||||
25
ui/src/i18n/locales/pl.ts
generated
25
ui/src/i18n/locales/pl.ts
generated
@@ -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.",
|
||||
|
||||
25
ui/src/i18n/locales/pt-BR.ts
generated
25
ui/src/i18n/locales/pt-BR.ts
generated
@@ -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.",
|
||||
|
||||
25
ui/src/i18n/locales/th.ts
generated
25
ui/src/i18n/locales/th.ts
generated
@@ -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: "ไปให้ถึงช่องเปิดตัว",
|
||||
|
||||
25
ui/src/i18n/locales/tr.ts
generated
25
ui/src/i18n/locales/tr.ts
generated
@@ -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.",
|
||||
|
||||
25
ui/src/i18n/locales/uk.ts
generated
25
ui/src/i18n/locales/uk.ts
generated
@@ -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: "Дістаньтеся клітинки запуску.",
|
||||
|
||||
25
ui/src/i18n/locales/vi.ts
generated
25
ui/src/i18n/locales/vi.ts
generated
@@ -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.",
|
||||
|
||||
25
ui/src/i18n/locales/zh-CN.ts
generated
25
ui/src/i18n/locales/zh-CN.ts
generated
@@ -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: "到达发布图块。",
|
||||
|
||||
25
ui/src/i18n/locales/zh-TW.ts
generated
25
ui/src/i18n/locales/zh-TW.ts
generated
@@ -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: "到達啟動方格。",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user