mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 17:04:07 +00:00
feat(workboard): add card execution actions
This commit is contained in:
@@ -47,10 +47,27 @@ Each card stores:
|
||||
- labels
|
||||
- optional agent id
|
||||
- optional linked session, run, task, or source URL
|
||||
- optional execution metadata for a Codex or Claude session started from the card
|
||||
|
||||
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.
|
||||
|
||||
## Card executions
|
||||
|
||||
Unlinked cards can start work from the card. Start uses the Gateway's configured
|
||||
default agent and model. Codex and Claude actions are optional explicit model
|
||||
choices:
|
||||
|
||||
- Run Codex or Run Claude creates a dashboard session, sends the card prompt,
|
||||
and marks the card `running`.
|
||||
- Open Codex or Open Claude creates a linked dashboard session without sending
|
||||
the card prompt or moving the card, so you can work manually while it stays
|
||||
attached to the board.
|
||||
|
||||
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`.
|
||||
|
||||
## Session lifecycle sync
|
||||
|
||||
Cards can be linked to existing dashboard sessions or to the session created
|
||||
|
||||
@@ -44,12 +44,28 @@ describe("WorkboardStore", () => {
|
||||
sessionKey: "agent:main:dashboard:1",
|
||||
runId: "run-1",
|
||||
taskId: "task-1",
|
||||
execution: {
|
||||
id: "exec-1",
|
||||
kind: "agent-session",
|
||||
engine: "claude",
|
||||
mode: "manual",
|
||||
status: "running",
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
sessionKey: "agent:main:dashboard:1",
|
||||
startedAt: 10,
|
||||
updatedAt: 10,
|
||||
},
|
||||
});
|
||||
|
||||
expect(card).toMatchObject({
|
||||
sessionKey: "agent:main:dashboard:1",
|
||||
runId: "run-1",
|
||||
taskId: "task-1",
|
||||
execution: {
|
||||
engine: "claude",
|
||||
mode: "manual",
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +82,36 @@ describe("WorkboardStore", () => {
|
||||
expect(done.completedAt).toBeGreaterThanOrEqual(done.startedAt ?? 0);
|
||||
});
|
||||
|
||||
it("keeps execution session links aligned with edited card links", async () => {
|
||||
const store = new WorkboardStore(createMemoryStore());
|
||||
const card = await store.create({
|
||||
title: "Relink me",
|
||||
sessionKey: "agent:main:dashboard: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: 10,
|
||||
updatedAt: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const relinked = await store.update(card.id, { sessionKey: "agent:main:dashboard:2" });
|
||||
expect(relinked.sessionKey).toBe("agent:main:dashboard:2");
|
||||
expect(relinked.execution?.sessionKey).toBe("agent:main:dashboard:2");
|
||||
|
||||
const unlinked = await store.update(card.id, { sessionKey: "" });
|
||||
expect(unlinked.sessionKey).toBeUndefined();
|
||||
expect(unlinked.execution?.sessionKey).toBeUndefined();
|
||||
|
||||
const cleared = await store.update(card.id, { execution: null });
|
||||
expect(cleared.execution).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects invalid status values", async () => {
|
||||
const store = new WorkboardStore(createMemoryStore());
|
||||
await expect(store.create({ title: "Bad card", status: "later" })).rejects.toThrow(
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
WORKBOARD_EXECUTION_ENGINES,
|
||||
WORKBOARD_EXECUTION_MODES,
|
||||
WORKBOARD_EXECUTION_STATUSES,
|
||||
WORKBOARD_PRIORITIES,
|
||||
WORKBOARD_STATUSES,
|
||||
type WorkboardCard,
|
||||
type WorkboardExecution,
|
||||
type WorkboardExecutionEngine,
|
||||
type WorkboardExecutionMode,
|
||||
type WorkboardExecutionStatus,
|
||||
type WorkboardPriority,
|
||||
type WorkboardStatus,
|
||||
} from "./types.js";
|
||||
@@ -33,6 +40,7 @@ export type WorkboardCardInput = {
|
||||
runId?: unknown;
|
||||
taskId?: unknown;
|
||||
sourceUrl?: unknown;
|
||||
execution?: unknown;
|
||||
position?: unknown;
|
||||
};
|
||||
|
||||
@@ -117,6 +125,105 @@ function normalizePosition(value: unknown, fallback: number): number {
|
||||
return Math.max(0, Math.trunc(value));
|
||||
}
|
||||
|
||||
function normalizeExecutionEngine(
|
||||
value: unknown,
|
||||
fallback: WorkboardExecutionEngine,
|
||||
): WorkboardExecutionEngine {
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
WORKBOARD_EXECUTION_ENGINES.includes(value as WorkboardExecutionEngine)
|
||||
) {
|
||||
return value as WorkboardExecutionEngine;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeExecutionMode(
|
||||
value: unknown,
|
||||
fallback: WorkboardExecutionMode,
|
||||
): WorkboardExecutionMode {
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
WORKBOARD_EXECUTION_MODES.includes(value as WorkboardExecutionMode)
|
||||
) {
|
||||
return value as WorkboardExecutionMode;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeExecutionStatus(
|
||||
value: unknown,
|
||||
fallback: WorkboardExecutionStatus,
|
||||
): WorkboardExecutionStatus {
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
WORKBOARD_EXECUTION_STATUSES.includes(value as WorkboardExecutionStatus)
|
||||
) {
|
||||
return value as WorkboardExecutionStatus;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? Math.max(0, Math.trunc(value))
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function normalizeExecution(value: unknown): WorkboardExecution | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const now = Date.now();
|
||||
const model = normalizeOptionalString(record.model);
|
||||
const id = normalizeOptionalString(record.id) ?? randomUUID();
|
||||
if (!model) {
|
||||
return undefined;
|
||||
}
|
||||
const startedAt = normalizeTimestamp(record.startedAt, now);
|
||||
const updatedAt = normalizeTimestamp(record.updatedAt, startedAt);
|
||||
const sessionKey = normalizeOptionalString(record.sessionKey);
|
||||
const runId = normalizeOptionalString(record.runId);
|
||||
return {
|
||||
id,
|
||||
kind: "agent-session",
|
||||
engine: normalizeExecutionEngine(record.engine, "codex"),
|
||||
mode: normalizeExecutionMode(record.mode, "autonomous"),
|
||||
status: normalizeExecutionStatus(record.status, "idle"),
|
||||
model,
|
||||
startedAt,
|
||||
updatedAt,
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
...(runId ? { runId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function syncExecutionSessionKey(
|
||||
execution: WorkboardExecution | undefined,
|
||||
sessionKey: string | undefined,
|
||||
): WorkboardExecution | undefined {
|
||||
if (!execution) {
|
||||
return undefined;
|
||||
}
|
||||
return removeUndefinedExecutionFields({
|
||||
...execution,
|
||||
sessionKey,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
function removeUndefinedExecutionFields(execution: WorkboardExecution): WorkboardExecution {
|
||||
const next = { ...execution };
|
||||
if (next.sessionKey === undefined) {
|
||||
delete next.sessionKey;
|
||||
}
|
||||
if (next.runId === undefined) {
|
||||
delete next.runId;
|
||||
}
|
||||
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);
|
||||
@@ -136,6 +243,7 @@ function removeUndefinedCardFields(card: WorkboardCard): WorkboardCard {
|
||||
"runId",
|
||||
"taskId",
|
||||
"sourceUrl",
|
||||
"execution",
|
||||
"startedAt",
|
||||
"completedAt",
|
||||
] as const) {
|
||||
@@ -179,6 +287,7 @@ export class WorkboardStore {
|
||||
const runId = normalizeOptionalString(input.runId);
|
||||
const taskId = normalizeOptionalString(input.taskId);
|
||||
const sourceUrl = normalizeOptionalString(input.sourceUrl);
|
||||
const execution = normalizeExecution(input.execution);
|
||||
const card: WorkboardCard = {
|
||||
id: randomUUID(),
|
||||
title: normalizeTitle(input.title),
|
||||
@@ -194,6 +303,7 @@ export class WorkboardStore {
|
||||
...(runId ? { runId } : {}),
|
||||
...(taskId ? { taskId } : {}),
|
||||
...(sourceUrl ? { sourceUrl } : {}),
|
||||
...(execution ? { execution } : {}),
|
||||
};
|
||||
await this.store.register(card.id, { version: 1, card });
|
||||
return card;
|
||||
@@ -208,6 +318,16 @@ export class WorkboardStore {
|
||||
const now = Date.now();
|
||||
const completedAt = status === "done" ? (existing.completedAt ?? now) : undefined;
|
||||
const startedAt = status === "running" ? (existing.startedAt ?? now) : existing.startedAt;
|
||||
const sessionKey =
|
||||
patch.sessionKey === undefined
|
||||
? existing.sessionKey
|
||||
: normalizeOptionalString(patch.sessionKey);
|
||||
const execution =
|
||||
patch.execution === undefined
|
||||
? patch.sessionKey === undefined
|
||||
? existing.execution
|
||||
: syncExecutionSessionKey(existing.execution, sessionKey)
|
||||
: normalizeExecution(patch.execution);
|
||||
const next = removeUndefinedCardFields({
|
||||
...existing,
|
||||
title: patch.title === undefined ? existing.title : normalizeTitle(patch.title),
|
||||
@@ -220,16 +340,14 @@ export class WorkboardStore {
|
||||
labels: patch.labels === undefined ? existing.labels : normalizeLabels(patch.labels),
|
||||
agentId:
|
||||
patch.agentId === undefined ? existing.agentId : normalizeOptionalString(patch.agentId),
|
||||
sessionKey:
|
||||
patch.sessionKey === undefined
|
||||
? existing.sessionKey
|
||||
: normalizeOptionalString(patch.sessionKey),
|
||||
sessionKey,
|
||||
runId: patch.runId === undefined ? existing.runId : normalizeOptionalString(patch.runId),
|
||||
taskId: patch.taskId === undefined ? existing.taskId : normalizeOptionalString(patch.taskId),
|
||||
sourceUrl:
|
||||
patch.sourceUrl === undefined
|
||||
? existing.sourceUrl
|
||||
: normalizeOptionalString(patch.sourceUrl),
|
||||
execution,
|
||||
position:
|
||||
patch.position === undefined
|
||||
? existing.position
|
||||
|
||||
@@ -8,9 +8,34 @@ export const WORKBOARD_STATUSES = [
|
||||
] as const;
|
||||
|
||||
export const WORKBOARD_PRIORITIES = ["low", "normal", "high", "urgent"] as const;
|
||||
export const WORKBOARD_EXECUTION_ENGINES = ["codex", "claude"] as const;
|
||||
export const WORKBOARD_EXECUTION_MODES = ["autonomous", "manual"] as const;
|
||||
export const WORKBOARD_EXECUTION_STATUSES = [
|
||||
"idle",
|
||||
"running",
|
||||
"review",
|
||||
"blocked",
|
||||
"done",
|
||||
] as const;
|
||||
|
||||
export type WorkboardStatus = (typeof WORKBOARD_STATUSES)[number];
|
||||
export type WorkboardPriority = (typeof WORKBOARD_PRIORITIES)[number];
|
||||
export type WorkboardExecutionEngine = (typeof WORKBOARD_EXECUTION_ENGINES)[number];
|
||||
export type WorkboardExecutionMode = (typeof WORKBOARD_EXECUTION_MODES)[number];
|
||||
export type WorkboardExecutionStatus = (typeof WORKBOARD_EXECUTION_STATUSES)[number];
|
||||
|
||||
export type WorkboardExecution = {
|
||||
id: string;
|
||||
kind: "agent-session";
|
||||
engine: WorkboardExecutionEngine;
|
||||
mode: WorkboardExecutionMode;
|
||||
status: WorkboardExecutionStatus;
|
||||
model: string;
|
||||
sessionKey?: string;
|
||||
runId?: string;
|
||||
startedAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type WorkboardCard = {
|
||||
id: string;
|
||||
@@ -24,6 +49,7 @@ export type WorkboardCard = {
|
||||
runId?: string;
|
||||
taskId?: string;
|
||||
sourceUrl?: string;
|
||||
execution?: WorkboardExecution;
|
||||
position: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
|
||||
@@ -457,6 +457,13 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.workboard-card__execution-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(72px, 1fr));
|
||||
gap: 6px;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.workboard-card__icon {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
@@ -481,6 +488,17 @@
|
||||
min-height: 28px;
|
||||
padding: 4px 9px;
|
||||
border-radius: 6px;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workboard-card__start--manual {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.workboard-card__start--default {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.workboard-card__lifecycle {
|
||||
|
||||
@@ -342,12 +342,133 @@ describe("workboard controller", () => {
|
||||
message: expect.stringContaining("Work on this OpenClaw Workboard card: Build board"),
|
||||
}),
|
||||
);
|
||||
expect(client.request.mock.calls[0]?.[1]).not.toHaveProperty("model");
|
||||
expect(client.request).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"workboard.cards.update",
|
||||
expect.objectContaining({
|
||||
id: "card-1",
|
||||
patch: expect.objectContaining({ status: "running", runId: "run-1" }),
|
||||
patch: expect.objectContaining({
|
||||
status: "running",
|
||||
runId: "run-1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(client.request.mock.calls[1]?.[1]).toHaveProperty("patch.execution", null);
|
||||
});
|
||||
|
||||
it("starts a Codex execution with an explicit model override", async () => {
|
||||
const host = {};
|
||||
const running = {
|
||||
...sampleCard,
|
||||
status: "running",
|
||||
sessionKey: "agent:main:dashboard:1",
|
||||
execution: {
|
||||
id: "card-1:codex",
|
||||
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,
|
||||
},
|
||||
};
|
||||
const client = createClient({
|
||||
"sessions.create": { key: "agent:main:dashboard:1", runId: "run-1" },
|
||||
"workboard.cards.update": { card: running },
|
||||
});
|
||||
|
||||
await startWorkboardCard({
|
||||
host,
|
||||
client: client as never,
|
||||
card: sampleCard,
|
||||
engine: "codex",
|
||||
});
|
||||
|
||||
expect(client.request).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"sessions.create",
|
||||
expect.objectContaining({
|
||||
model: "openai/gpt-5.5",
|
||||
message: expect.stringContaining("Work on this OpenClaw Workboard card: Build board"),
|
||||
}),
|
||||
);
|
||||
expect(client.request).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"workboard.cards.update",
|
||||
expect.objectContaining({
|
||||
id: "card-1",
|
||||
patch: expect.objectContaining({
|
||||
status: "running",
|
||||
execution: expect.objectContaining({
|
||||
engine: "codex",
|
||||
mode: "autonomous",
|
||||
model: "openai/gpt-5.5",
|
||||
runId: "run-1",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("starts a manual Claude execution without sending the card prompt", async () => {
|
||||
const host = {};
|
||||
const running = {
|
||||
...sampleCard,
|
||||
status: "todo",
|
||||
sessionKey: "agent:main:dashboard:1",
|
||||
execution: {
|
||||
id: "card-1:claude",
|
||||
kind: "agent-session",
|
||||
engine: "claude",
|
||||
mode: "manual",
|
||||
status: "idle",
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
sessionKey: "agent:main:dashboard:1",
|
||||
startedAt: 10,
|
||||
updatedAt: 10,
|
||||
},
|
||||
};
|
||||
const client = createClient({
|
||||
"sessions.create": { key: "agent:main:dashboard:1", runStarted: false },
|
||||
"workboard.cards.update": { card: running },
|
||||
});
|
||||
|
||||
const sessionKey = await startWorkboardCard({
|
||||
host,
|
||||
client: client as never,
|
||||
card: sampleCard,
|
||||
engine: "claude",
|
||||
mode: "manual",
|
||||
});
|
||||
|
||||
expect(sessionKey).toBe("agent:main:dashboard:1");
|
||||
expect(client.request).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"sessions.create",
|
||||
expect.objectContaining({
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
}),
|
||||
);
|
||||
expect(client.request.mock.calls[0]?.[1]).not.toHaveProperty("message");
|
||||
expect(client.request.mock.calls[0]?.[1]).not.toHaveProperty("task");
|
||||
expect(client.request).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"workboard.cards.update",
|
||||
expect.objectContaining({
|
||||
id: "card-1",
|
||||
patch: expect.objectContaining({
|
||||
status: "todo",
|
||||
execution: expect.objectContaining({
|
||||
engine: "claude",
|
||||
mode: "manual",
|
||||
status: "idle",
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -376,9 +497,13 @@ describe("workboard controller", () => {
|
||||
"workboard.cards.update",
|
||||
expect.objectContaining({
|
||||
id: "card-1",
|
||||
patch: { status: "blocked", sessionKey: "agent:main:dashboard:1" },
|
||||
patch: expect.objectContaining({
|
||||
status: "blocked",
|
||||
sessionKey: "agent:main:dashboard:1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(client.request.mock.calls[1]?.[1]).toHaveProperty("patch.execution", null);
|
||||
expect(getWorkboardState(host).error).toBe("Agent run did not start: provider unavailable");
|
||||
});
|
||||
|
||||
@@ -424,6 +549,28 @@ describe("workboard controller", () => {
|
||||
state: "failed",
|
||||
targetStatus: "blocked",
|
||||
});
|
||||
expect(
|
||||
getWorkboardLifecycle(
|
||||
{
|
||||
...sampleCard,
|
||||
execution: {
|
||||
id: "exec-1",
|
||||
kind: "agent-session",
|
||||
engine: "codex",
|
||||
mode: "autonomous",
|
||||
status: "running",
|
||||
model: "openai/gpt-5.5",
|
||||
sessionKey: sampleSession.key,
|
||||
startedAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
[sampleSession],
|
||||
),
|
||||
).toMatchObject({
|
||||
state: "running",
|
||||
targetStatus: "running",
|
||||
});
|
||||
});
|
||||
|
||||
it("syncs linked card status from session lifecycle without overriding manual review", async () => {
|
||||
@@ -458,6 +605,38 @@ describe("workboard controller", () => {
|
||||
expect(state.cards.find((card) => card.id === "card-review")?.status).toBe("review");
|
||||
});
|
||||
|
||||
it("does not mark executions blocked when the linked session is missing from the current list", async () => {
|
||||
const host = {};
|
||||
const state = getWorkboardState(host);
|
||||
const linked = {
|
||||
...sampleCard,
|
||||
status: "running",
|
||||
sessionKey: "agent:main:dashboard:missing",
|
||||
execution: {
|
||||
id: "exec-1",
|
||||
kind: "agent-session",
|
||||
engine: "codex",
|
||||
mode: "autonomous",
|
||||
status: "running",
|
||||
model: "openai/gpt-5.5",
|
||||
sessionKey: "agent:main:dashboard:missing",
|
||||
startedAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
} as const;
|
||||
state.loaded = true;
|
||||
state.cards = [linked];
|
||||
const client = createClient({ "workboard.cards.update": { card: linked } });
|
||||
|
||||
await syncWorkboardLifecycle({
|
||||
host,
|
||||
client: client as never,
|
||||
sessions: [],
|
||||
});
|
||||
|
||||
expect(client.request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips lifecycle writeback for read-only workboard clients", async () => {
|
||||
const host = {};
|
||||
const state = getWorkboardState(host);
|
||||
|
||||
@@ -11,9 +11,39 @@ export const WORKBOARD_STATUSES = [
|
||||
] as const;
|
||||
|
||||
export const WORKBOARD_PRIORITIES = ["low", "normal", "high", "urgent"] as const;
|
||||
export const WORKBOARD_EXECUTION_ENGINES = ["codex", "claude"] as const;
|
||||
export const WORKBOARD_EXECUTION_MODES = ["autonomous", "manual"] as const;
|
||||
export const WORKBOARD_EXECUTION_STATUSES = [
|
||||
"idle",
|
||||
"running",
|
||||
"review",
|
||||
"blocked",
|
||||
"done",
|
||||
] as const;
|
||||
|
||||
export const WORKBOARD_ENGINE_MODELS = {
|
||||
codex: "openai/gpt-5.5",
|
||||
claude: "anthropic/claude-sonnet-4-6",
|
||||
} as const;
|
||||
|
||||
export type WorkboardStatus = (typeof WORKBOARD_STATUSES)[number];
|
||||
export type WorkboardPriority = (typeof WORKBOARD_PRIORITIES)[number];
|
||||
export type WorkboardExecutionEngine = (typeof WORKBOARD_EXECUTION_ENGINES)[number];
|
||||
export type WorkboardExecutionMode = (typeof WORKBOARD_EXECUTION_MODES)[number];
|
||||
export type WorkboardExecutionStatus = (typeof WORKBOARD_EXECUTION_STATUSES)[number];
|
||||
|
||||
export type WorkboardExecution = {
|
||||
id: string;
|
||||
kind: "agent-session";
|
||||
engine: WorkboardExecutionEngine;
|
||||
mode: WorkboardExecutionMode;
|
||||
status: WorkboardExecutionStatus;
|
||||
model: string;
|
||||
sessionKey?: string;
|
||||
runId?: string;
|
||||
startedAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type WorkboardCard = {
|
||||
id: string;
|
||||
@@ -27,6 +57,7 @@ export type WorkboardCard = {
|
||||
runId?: string;
|
||||
taskId?: string;
|
||||
sourceUrl?: string;
|
||||
execution?: WorkboardExecution;
|
||||
position: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
@@ -144,6 +175,40 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function normalizeExecution(value: unknown): WorkboardExecution | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const id = typeof value.id === "string" && value.id.trim() ? value.id.trim() : "";
|
||||
const engine = WORKBOARD_EXECUTION_ENGINES.includes(value.engine as WorkboardExecutionEngine)
|
||||
? (value.engine as WorkboardExecutionEngine)
|
||||
: null;
|
||||
const mode = WORKBOARD_EXECUTION_MODES.includes(value.mode as WorkboardExecutionMode)
|
||||
? (value.mode as WorkboardExecutionMode)
|
||||
: null;
|
||||
const status = WORKBOARD_EXECUTION_STATUSES.includes(value.status as WorkboardExecutionStatus)
|
||||
? (value.status as WorkboardExecutionStatus)
|
||||
: "idle";
|
||||
const model = typeof value.model === "string" && value.model.trim() ? value.model.trim() : "";
|
||||
const startedAt = typeof value.startedAt === "number" ? value.startedAt : 0;
|
||||
const updatedAt = typeof value.updatedAt === "number" ? value.updatedAt : startedAt;
|
||||
if (!id || !engine || !mode || !model || !startedAt) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
kind: "agent-session",
|
||||
engine,
|
||||
mode,
|
||||
status,
|
||||
model,
|
||||
startedAt,
|
||||
updatedAt,
|
||||
...(typeof value.sessionKey === "string" ? { sessionKey: value.sessionKey } : {}),
|
||||
...(typeof value.runId === "string" ? { runId: value.runId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCard(value: unknown): WorkboardCard | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
@@ -159,6 +224,7 @@ function normalizeCard(value: unknown): WorkboardCard | null {
|
||||
if (!id || !title) {
|
||||
return null;
|
||||
}
|
||||
const execution = normalizeExecution(value.execution);
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
@@ -176,6 +242,7 @@ function normalizeCard(value: unknown): WorkboardCard | null {
|
||||
...(typeof value.runId === "string" ? { runId: value.runId } : {}),
|
||||
...(typeof value.taskId === "string" ? { taskId: value.taskId } : {}),
|
||||
...(typeof value.sourceUrl === "string" ? { sourceUrl: value.sourceUrl } : {}),
|
||||
...(execution ? { execution } : {}),
|
||||
...(typeof value.startedAt === "number" ? { startedAt: value.startedAt } : {}),
|
||||
...(typeof value.completedAt === "number" ? { completedAt: value.completedAt } : {}),
|
||||
};
|
||||
@@ -294,12 +361,20 @@ function isFailedSessionStatus(status: GatewaySessionRow["status"]): boolean {
|
||||
return status === "failed" || status === "killed" || status === "timeout";
|
||||
}
|
||||
|
||||
function workboardCardSessionKey(card: WorkboardCard): string | undefined {
|
||||
return card.sessionKey ?? card.execution?.sessionKey;
|
||||
}
|
||||
|
||||
function workboardCardRunId(card: WorkboardCard): string | undefined {
|
||||
return card.runId ?? card.execution?.runId;
|
||||
}
|
||||
|
||||
export function getWorkboardLifecycle(
|
||||
card: WorkboardCard,
|
||||
sessions: readonly GatewaySessionRow[],
|
||||
): WorkboardLifecycle {
|
||||
const session = findWorkboardSession(card, sessions);
|
||||
if (!card.sessionKey) {
|
||||
if (!workboardCardSessionKey(card)) {
|
||||
return { session: null, state: "unlinked" };
|
||||
}
|
||||
if (!session) {
|
||||
@@ -330,6 +405,32 @@ function shouldSyncCardStatus(card: WorkboardCard, targetStatus: WorkboardStatus
|
||||
return false;
|
||||
}
|
||||
|
||||
function executionStatusForLifecycle(
|
||||
lifecycle: WorkboardLifecycle,
|
||||
): WorkboardExecutionStatus | undefined {
|
||||
switch (lifecycle.state) {
|
||||
case "running":
|
||||
return "running";
|
||||
case "succeeded":
|
||||
return "review";
|
||||
case "failed":
|
||||
return "blocked";
|
||||
case "missing":
|
||||
return undefined;
|
||||
case "idle":
|
||||
return "idle";
|
||||
case "unlinked":
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSyncExecutionStatus(
|
||||
card: WorkboardCard,
|
||||
targetStatus: WorkboardExecutionStatus | undefined,
|
||||
) {
|
||||
return Boolean(card.execution && targetStatus && card.execution.status !== targetStatus);
|
||||
}
|
||||
|
||||
function lifecycleSyncKey(card: WorkboardCard, lifecycle: WorkboardLifecycle): string {
|
||||
const session = lifecycle.session;
|
||||
return [
|
||||
@@ -340,6 +441,8 @@ function lifecycleSyncKey(card: WorkboardCard, lifecycle: WorkboardLifecycle): s
|
||||
session?.status ?? "",
|
||||
session?.hasActiveRun === true ? "active" : "idle",
|
||||
session?.updatedAt ?? "",
|
||||
card.execution?.status ?? "",
|
||||
card.execution?.updatedAt ?? "",
|
||||
].join(":");
|
||||
}
|
||||
|
||||
@@ -546,7 +649,19 @@ export async function syncWorkboardLifecycle(params: {
|
||||
const syncKeys = getLifecycleSyncKeys(params.host);
|
||||
for (const card of state.cards) {
|
||||
const lifecycle = getWorkboardLifecycle(card, params.sessions);
|
||||
if (!shouldSyncCardStatus(card, lifecycle.targetStatus)) {
|
||||
const executionStatus = executionStatusForLifecycle(lifecycle);
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (shouldSyncCardStatus(card, lifecycle.targetStatus)) {
|
||||
patch.status = lifecycle.targetStatus;
|
||||
}
|
||||
if (shouldSyncExecutionStatus(card, executionStatus)) {
|
||||
patch.execution = {
|
||||
...card.execution,
|
||||
status: executionStatus,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
if (Object.keys(patch).length === 0) {
|
||||
continue;
|
||||
}
|
||||
const key = lifecycleSyncKey(card, lifecycle);
|
||||
@@ -558,7 +673,7 @@ export async function syncWorkboardLifecycle(params: {
|
||||
try {
|
||||
const payload = await params.client.request("workboard.cards.update", {
|
||||
id: card.id,
|
||||
patch: { status: lifecycle.targetStatus },
|
||||
patch,
|
||||
});
|
||||
replaceCard(state, normalizeCardPayload(payload));
|
||||
syncKeys.set(card.id, key);
|
||||
@@ -705,10 +820,35 @@ function buildCardSessionLabel(card: WorkboardCard): string {
|
||||
return `${title.slice(0, titleMax - 3).trimEnd()}...${suffixText}`;
|
||||
}
|
||||
|
||||
function buildWorkboardExecution(params: {
|
||||
card: WorkboardCard;
|
||||
engine: WorkboardExecutionEngine;
|
||||
mode: WorkboardExecutionMode;
|
||||
sessionKey?: string | null;
|
||||
runId?: string;
|
||||
status: WorkboardExecutionStatus;
|
||||
}): WorkboardExecution {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: params.card.execution?.id ?? `${params.card.id}:${params.engine}`,
|
||||
kind: "agent-session",
|
||||
engine: params.engine,
|
||||
mode: params.mode,
|
||||
status: params.status,
|
||||
model: WORKBOARD_ENGINE_MODELS[params.engine],
|
||||
startedAt: params.card.execution?.startedAt ?? now,
|
||||
updatedAt: now,
|
||||
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
...(params.runId ? { runId: params.runId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function startWorkboardCard(params: {
|
||||
host: WorkboardHost;
|
||||
client: GatewayBrowserClient | null;
|
||||
card: WorkboardCard;
|
||||
engine?: WorkboardExecutionEngine;
|
||||
mode?: WorkboardExecutionMode;
|
||||
requestUpdate?: () => void;
|
||||
}): Promise<string | null> {
|
||||
const state = getWorkboardState(params.host);
|
||||
@@ -718,11 +858,14 @@ export async function startWorkboardCard(params: {
|
||||
state.busyCardId = params.card.id;
|
||||
state.error = null;
|
||||
params.requestUpdate?.();
|
||||
const engine = params.engine;
|
||||
const mode = params.mode ?? "autonomous";
|
||||
try {
|
||||
const created = await params.client.request("sessions.create", {
|
||||
...(params.card.agentId ? { agentId: params.card.agentId } : {}),
|
||||
label: buildCardSessionLabel(params.card),
|
||||
message: buildCardPrompt(params.card),
|
||||
...(engine ? { model: WORKBOARD_ENGINE_MODELS[engine] } : {}),
|
||||
...(mode === "autonomous" ? { message: buildCardPrompt(params.card) } : {}),
|
||||
});
|
||||
const sessionKey =
|
||||
isRecord(created) && typeof created.key === "string" && created.key.trim()
|
||||
@@ -732,13 +875,25 @@ export async function startWorkboardCard(params: {
|
||||
isRecord(created) && typeof created.runId === "string" && created.runId.trim()
|
||||
? created.runId.trim()
|
||||
: undefined;
|
||||
const initialRunFailed = isRecord(created) && created.runStarted === false;
|
||||
const initialRunFailed =
|
||||
mode === "autonomous" && isRecord(created) && created.runStarted === false;
|
||||
if (initialRunFailed) {
|
||||
const payload = await params.client.request("workboard.cards.update", {
|
||||
id: params.card.id,
|
||||
patch: {
|
||||
status: "blocked",
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
...(engine
|
||||
? {
|
||||
execution: buildWorkboardExecution({
|
||||
card: params.card,
|
||||
engine,
|
||||
mode,
|
||||
sessionKey,
|
||||
status: "blocked",
|
||||
}),
|
||||
}
|
||||
: { execution: null }),
|
||||
},
|
||||
});
|
||||
replaceCard(state, normalizeCardPayload(payload));
|
||||
@@ -750,12 +905,26 @@ export async function startWorkboardCard(params: {
|
||||
: "Agent run did not start.";
|
||||
return sessionKey;
|
||||
}
|
||||
const nextCardStatus = mode === "autonomous" ? "running" : params.card.status;
|
||||
const nextExecutionStatus = mode === "autonomous" ? "running" : "idle";
|
||||
const payload = await params.client.request("workboard.cards.update", {
|
||||
id: params.card.id,
|
||||
patch: {
|
||||
status: "running",
|
||||
status: nextCardStatus,
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
...(runId ? { runId } : {}),
|
||||
...(engine
|
||||
? {
|
||||
execution: buildWorkboardExecution({
|
||||
card: params.card,
|
||||
engine,
|
||||
mode,
|
||||
sessionKey,
|
||||
runId,
|
||||
status: nextExecutionStatus,
|
||||
}),
|
||||
}
|
||||
: { execution: null }),
|
||||
},
|
||||
});
|
||||
replaceCard(state, normalizeCardPayload(payload));
|
||||
@@ -776,7 +945,8 @@ export async function stopWorkboardCard(params: {
|
||||
requestUpdate?: () => void;
|
||||
}) {
|
||||
const state = getWorkboardState(params.host);
|
||||
if (!params.client || !params.card.sessionKey) {
|
||||
const sessionKey = workboardCardSessionKey(params.card);
|
||||
if (!params.client || !sessionKey) {
|
||||
return;
|
||||
}
|
||||
state.busyCardId = params.card.id;
|
||||
@@ -784,16 +954,16 @@ export async function stopWorkboardCard(params: {
|
||||
params.requestUpdate?.();
|
||||
try {
|
||||
let abortResult = await params.client.request("chat.abort", {
|
||||
sessionKey: params.card.sessionKey,
|
||||
...(params.card.runId ? { runId: params.card.runId } : {}),
|
||||
sessionKey,
|
||||
...(workboardCardRunId(params.card) ? { runId: workboardCardRunId(params.card) } : {}),
|
||||
});
|
||||
let aborted =
|
||||
isRecord(abortResult) &&
|
||||
(abortResult.aborted === true ||
|
||||
(Array.isArray(abortResult.runIds) && abortResult.runIds.length > 0));
|
||||
if (!aborted && params.card.runId) {
|
||||
if (!aborted && workboardCardRunId(params.card)) {
|
||||
abortResult = await params.client.request("chat.abort", {
|
||||
sessionKey: params.card.sessionKey,
|
||||
sessionKey,
|
||||
});
|
||||
aborted =
|
||||
isRecord(abortResult) &&
|
||||
@@ -805,7 +975,18 @@ export async function stopWorkboardCard(params: {
|
||||
}
|
||||
const payload = await params.client.request("workboard.cards.update", {
|
||||
id: params.card.id,
|
||||
patch: { status: "blocked" },
|
||||
patch: {
|
||||
status: "blocked",
|
||||
...(params.card.execution
|
||||
? {
|
||||
execution: {
|
||||
...params.card.execution,
|
||||
status: "blocked",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
replaceCard(state, normalizeCardPayload(payload));
|
||||
} catch (error) {
|
||||
@@ -820,8 +1001,9 @@ export function findWorkboardSession(
|
||||
card: WorkboardCard,
|
||||
sessions: readonly GatewaySessionRow[],
|
||||
): GatewaySessionRow | null {
|
||||
if (!card.sessionKey) {
|
||||
const sessionKey = workboardCardSessionKey(card);
|
||||
if (!sessionKey) {
|
||||
return null;
|
||||
}
|
||||
return sessions.find((session) => session.key === card.sessionKey) ?? null;
|
||||
return sessions.find((session) => session.key === sessionKey) ?? null;
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("renderWorkboard", () => {
|
||||
expect(onOpenSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a labeled start action for unlinked cards", () => {
|
||||
it("shows Codex and Claude execution actions for unlinked cards", () => {
|
||||
const host = {};
|
||||
const state = getWorkboardState(host);
|
||||
state.loaded = true;
|
||||
@@ -140,8 +140,23 @@ describe("renderWorkboard", () => {
|
||||
container,
|
||||
);
|
||||
|
||||
const startButton = container.querySelector<HTMLButtonElement>(".workboard-card__start");
|
||||
expect(startButton?.textContent).toContain("Start");
|
||||
const startButtons = [
|
||||
...container.querySelectorAll<HTMLButtonElement>(".workboard-card__start"),
|
||||
];
|
||||
expect(startButtons.map((button) => button.textContent?.trim())).toEqual([
|
||||
"Start",
|
||||
"codex",
|
||||
"claude",
|
||||
"codex",
|
||||
"claude",
|
||||
]);
|
||||
expect(startButtons.map((button) => button.title)).toEqual([
|
||||
"Run default agent",
|
||||
"Run codex",
|
||||
"Run claude",
|
||||
"Open codex",
|
||||
"Open claude",
|
||||
]);
|
||||
expect(container.querySelector(".workboard-card")?.getAttribute("role")).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
stopWorkboardCard,
|
||||
syncWorkboardLifecycle,
|
||||
WORKBOARD_PRIORITIES,
|
||||
type WorkboardExecutionEngine,
|
||||
type WorkboardExecutionMode,
|
||||
type WorkboardCard,
|
||||
type WorkboardLifecycle,
|
||||
type WorkboardPriority,
|
||||
@@ -63,7 +65,17 @@ function matchesFilter(
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
return [card.title, card.notes, card.agentId, card.sessionKey, ...card.labels]
|
||||
return [
|
||||
card.title,
|
||||
card.notes,
|
||||
card.agentId,
|
||||
card.sessionKey,
|
||||
card.execution?.engine,
|
||||
card.execution?.mode,
|
||||
card.execution?.model,
|
||||
card.execution?.sessionKey,
|
||||
...card.labels,
|
||||
]
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.some((value) => value.toLowerCase().includes(query));
|
||||
}
|
||||
@@ -94,10 +106,11 @@ function openCardSession(
|
||||
props: Pick<WorkboardProps, "onOpenSession">,
|
||||
card: WorkboardCard,
|
||||
): boolean {
|
||||
if (!card.sessionKey) {
|
||||
const sessionKey = card.sessionKey ?? card.execution?.sessionKey;
|
||||
if (!sessionKey) {
|
||||
return false;
|
||||
}
|
||||
props.onOpenSession(card.sessionKey);
|
||||
props.onOpenSession(sessionKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -533,10 +546,11 @@ function renderLifecycle(card: WorkboardCard, sessions: readonly GatewaySessionR
|
||||
const lifecycle = getWorkboardLifecycle(card, sessions);
|
||||
const formatted = formatLifecycle(lifecycle);
|
||||
const session = lifecycle.session;
|
||||
const execution = card.execution;
|
||||
return html`
|
||||
<div class="workboard-card__lifecycle">
|
||||
<span class="workboard-lifecycle workboard-lifecycle--${formatted.tone}">
|
||||
${formatted.label}
|
||||
${execution ? `${execution.engine} ${execution.mode}` : formatted.label}
|
||||
</span>
|
||||
<span class="workboard-card__lifecycle-detail">
|
||||
${session?.displayName ?? session?.label ?? formatted.detail}
|
||||
@@ -545,13 +559,63 @@ function renderLifecycle(card: WorkboardCard, sessions: readonly GatewaySessionR
|
||||
`;
|
||||
}
|
||||
|
||||
function renderStartExecutionButton(
|
||||
props: WorkboardProps,
|
||||
card: WorkboardCard,
|
||||
engine: WorkboardExecutionEngine | null,
|
||||
mode: WorkboardExecutionMode,
|
||||
) {
|
||||
const state = getWorkboardState(props.host);
|
||||
const busy = state.busyCardId === card.id;
|
||||
const title = engine
|
||||
? `${mode === "autonomous" ? "Run" : "Open"} ${engine}`
|
||||
: "Run default agent";
|
||||
return html`
|
||||
<button
|
||||
class="btn btn--xs workboard-card__start workboard-card__start--${mode} ${engine
|
||||
? ""
|
||||
: "workboard-card__start--default"}"
|
||||
title=${title}
|
||||
?disabled=${busy || !props.connected}
|
||||
@click=${async () => {
|
||||
const key = await startWorkboardCard({
|
||||
host: props.host,
|
||||
client: props.client,
|
||||
card,
|
||||
...(engine ? { engine } : {}),
|
||||
mode,
|
||||
requestUpdate: props.onRequestUpdate,
|
||||
});
|
||||
if (key) {
|
||||
props.onOpenSession(key);
|
||||
}
|
||||
}}
|
||||
>
|
||||
${mode === "autonomous" ? icons.play : icons.penLine} ${engine ?? "Start"}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderStartExecutionControls(props: WorkboardProps, card: WorkboardCard) {
|
||||
return html`
|
||||
<div class="workboard-card__execution-controls">
|
||||
${renderStartExecutionButton(props, card, null, "autonomous")}
|
||||
${renderStartExecutionButton(props, card, "codex", "autonomous")}
|
||||
${renderStartExecutionButton(props, card, "claude", "autonomous")}
|
||||
${renderStartExecutionButton(props, card, "codex", "manual")}
|
||||
${renderStartExecutionButton(props, card, "claude", "manual")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCard(props: WorkboardProps, card: WorkboardCard) {
|
||||
const state = getWorkboardState(props.host);
|
||||
const session = findWorkboardSession(card, props.sessions);
|
||||
const busy = state.busyCardId === card.id;
|
||||
const syncing = state.syncingCardIds.has(card.id);
|
||||
const live = session?.hasActiveRun === true;
|
||||
const linked = Boolean(card.sessionKey);
|
||||
const linkedSessionKey = card.sessionKey ?? card.execution?.sessionKey;
|
||||
const linked = Boolean(linkedSessionKey);
|
||||
return html`
|
||||
<article
|
||||
class="workboard-card priority-${card.priority} ${busy ? "workboard-card--busy" : ""} ${linked
|
||||
@@ -612,12 +676,12 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
|
||||
>
|
||||
${icons.edit}
|
||||
</button>
|
||||
${card.sessionKey
|
||||
${linked
|
||||
? html`
|
||||
<button
|
||||
class="btn btn--icon workboard-card__icon"
|
||||
title="Open session"
|
||||
@click=${() => props.onOpenSession(card.sessionKey!)}
|
||||
@click=${() => props.onOpenSession(linkedSessionKey!)}
|
||||
>
|
||||
${icons.messageSquare}
|
||||
</button>
|
||||
@@ -640,26 +704,7 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
|
||||
`
|
||||
: nothing}
|
||||
`
|
||||
: html`
|
||||
<button
|
||||
class="btn btn--xs workboard-card__start"
|
||||
title="Start session"
|
||||
?disabled=${busy || !props.connected}
|
||||
@click=${async () => {
|
||||
const key = await startWorkboardCard({
|
||||
host: props.host,
|
||||
client: props.client,
|
||||
card,
|
||||
requestUpdate: props.onRequestUpdate,
|
||||
});
|
||||
if (key) {
|
||||
props.onOpenSession(key);
|
||||
}
|
||||
}}
|
||||
>
|
||||
${icons.play} Start
|
||||
</button>
|
||||
`}
|
||||
: renderStartExecutionControls(props, card)}
|
||||
<button
|
||||
class="btn btn--icon workboard-card__icon workboard-card__delete"
|
||||
title="Delete card"
|
||||
|
||||
Reference in New Issue
Block a user