Fix Workboard status persistence

Summary:
- Persist Workboard lifecycle status provenance so stale linked session/task lifecycle updates cannot overwrite newer manual or non-default creation status.
- Add focused Workboard store/controller regressions for lifecycle-vs-manual precedence and creation-status precedence.
- Add mocked Control UI browser E2E proof for create/edit/reopen, running move, lifecycle sync, reload persistence, and read-only operator behavior.

Verification:
- `node scripts/run-vitest.mjs extensions/workboard/src/store.test.ts extensions/workboard/src/gateway.test.ts --reporter=verbose`
- `node scripts/run-vitest.mjs ui/src/ui/controllers/workboard.test.ts ui/src/ui/views/workboard.test.ts --reporter=verbose`
- `node scripts/run-vitest.mjs --config test/vitest/vitest.ui-e2e.config.ts --configLoader runner ui/src/ui/e2e/workboard-status-persistence.e2e.test.ts ui/src/ui/e2e/workboard.e2e.test.ts --reporter=verbose`
- `corepack pnpm tsgo:core:test`
- `corepack pnpm tsgo:extensions:test`
- `node scripts/run-oxlint.mjs extensions/workboard/src/sqlite-store.ts extensions/workboard/src/store.test.ts extensions/workboard/src/store.ts extensions/workboard/src/types.ts ui/src/ui/controllers/workboard.test.ts ui/src/ui/controllers/workboard.ts ui/src/ui/e2e/workboard-status-persistence.e2e.test.ts ui/src/ui/e2e/workboard.e2e.test.ts ui/src/ui/views/workboard.test.ts ui/src/ui/views/workboard.ts`
- `git diff --check`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main` clean
- GitHub PR checks green on head `6d05d6edd5ca6cbb2e625f3e478e973feba5e4cf`

Proof:
- E2E manifest: `/Users/buns/.codex/worktrees/74e7/openclaw/.artifacts/control-ui-e2e/workboard/manifest.json`
- Live Gateway success proof: `/Users/buns/.codex/worktrees/74e7/openclaw/.artifacts/live-workboard/proof/12-live-review-success.png`
- Remaining gap: read-only operator behavior is covered by mocked browser E2E, not live Gateway.
This commit is contained in:
Val Alexander
2026-06-03 16:46:14 -07:00
committed by GitHub
parent d9d4514c00
commit e07dbb27d9
10 changed files with 2031 additions and 24 deletions

View File

@@ -26,7 +26,7 @@ import type {
} from "./types.js";
const WORKBOARD_DB_RELATIVE_PATH = ["plugins", "workboard", "workboard.sqlite"] as const;
const SCHEMA_VERSION = 1;
const SCHEMA_VERSION = 2;
const WORKBOARD_SQLITE_BUSY_TIMEOUT_MS = 5000;
const WORKBOARD_SQLITE_DIR_MODE = 0o700;
const WORKBOARD_SQLITE_FILE_MODE = 0o600;
@@ -118,6 +118,21 @@ function runTransaction<T>(db: DatabaseSync, run: () => T): T {
}
}
function tableColumns(db: DatabaseSync, tableName: string): Set<string> {
return new Set(
(db.prepare(`PRAGMA table_info(${tableName})`).all() as Row[]).flatMap((row) =>
typeof row.name === "string" ? [row.name] : [],
),
);
}
function ensureColumn(db: DatabaseSync, tableName: string, columnName: string, definition: string) {
if (tableColumns(db, tableName).has(columnName)) {
return;
}
db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${definition}`);
}
function ensureWorkboardSchema(db: DatabaseSync): void {
db.exec(`
PRAGMA foreign_keys = ON;
@@ -171,6 +186,7 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
template_id TEXT,
archived_at INTEGER,
stale_json TEXT,
lifecycle_status_source_updated_at INTEGER,
failure_count INTEGER
);
CREATE INDEX IF NOT EXISTS workboard_cards_board_status_idx
@@ -333,6 +349,12 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
updated_at INTEGER NOT NULL
);
`);
ensureColumn(
db,
"workboard_cards",
"lifecycle_status_source_updated_at",
"lifecycle_status_source_updated_at INTEGER",
);
db.prepare(
"INSERT OR IGNORE INTO workboard_schema_migrations (id, applied_at) VALUES (?, ?)",
).run(`schema-${SCHEMA_VERSION}`, Date.now());
@@ -630,6 +652,7 @@ function readMetadata(db: DatabaseSync, row: Row): WorkboardMetadata | undefined
const automation = parseJson(row.automation_json) as WorkboardMetadata["automation"] | undefined;
const claim = parseJson(row.claim_json) as WorkboardMetadata["claim"] | undefined;
const stale = parseJson(row.stale_json) as WorkboardMetadata["stale"] | undefined;
const lifecycleStatusSourceUpdatedAt = numberValue(row, "lifecycle_status_source_updated_at");
return optional({
...(attempts.length > 0 ? { attempts } : {}),
...(comments.length > 0 ? { comments } : {}),
@@ -660,6 +683,7 @@ function readMetadata(db: DatabaseSync, row: Row): WorkboardMetadata | undefined
? { archivedAt: numberValue(row, "archived_at") }
: {}),
...(stale ? { stale } : {}),
...(lifecycleStatusSourceUpdatedAt !== undefined ? { lifecycleStatusSourceUpdatedAt } : {}),
...(numberValue(row, "failure_count") !== undefined
? { failureCount: numberValue(row, "failure_count") }
: {}),
@@ -738,14 +762,14 @@ function insertCard(db: DatabaseSync, card: WorkboardCard): void {
execution_id, execution_kind, execution_engine, execution_mode, execution_status,
execution_model, execution_session_key, execution_run_id, execution_started_at,
execution_updated_at, automation_json, claim_json, template_id, archived_at, stale_json,
failure_count
lifecycle_status_source_updated_at, failure_count
) VALUES (
@id, @board_id, @title, @notes, @status, @priority, @agent_id, @session_key, @run_id,
@task_id, @source_url, @position, @created_at, @updated_at, @started_at, @completed_at,
@execution_id, @execution_kind, @execution_engine, @execution_mode, @execution_status,
@execution_model, @execution_session_key, @execution_run_id, @execution_started_at,
@execution_updated_at, @automation_json, @claim_json, @template_id, @archived_at,
@stale_json, @failure_count
@stale_json, @lifecycle_status_source_updated_at, @failure_count
)
ON CONFLICT(id) DO UPDATE SET
board_id = excluded.board_id,
@@ -778,6 +802,7 @@ function insertCard(db: DatabaseSync, card: WorkboardCard): void {
template_id = excluded.template_id,
archived_at = excluded.archived_at,
stale_json = excluded.stale_json,
lifecycle_status_source_updated_at = excluded.lifecycle_status_source_updated_at,
failure_count = excluded.failure_count
`,
).run({
@@ -812,6 +837,7 @@ function insertCard(db: DatabaseSync, card: WorkboardCard): void {
template_id: bindNull(metadata?.templateId),
archived_at: bindNull(metadata?.archivedAt),
stale_json: jsonValue(metadata?.stale),
lifecycle_status_source_updated_at: bindNull(metadata?.lifecycleStatusSourceUpdatedAt),
failure_count: bindNull(metadata?.failureCount),
});

View File

@@ -77,6 +77,9 @@ describe("WorkboardStore", () => {
fileName: "large-proof.bin",
contentBase64: Buffer.alloc(70 * 1024).toString("base64"),
});
await store.update(card.id, {
metadata: { lifecycleStatusSourceUpdatedAt: 1234 },
});
const attachmentId = attached.metadata?.attachments?.[0]?.id;
const subscription = await store.subscribeNotifications({
boardId: board.id,
@@ -118,6 +121,7 @@ describe("WorkboardStore", () => {
labels: ["sqlite", "doctor"],
metadata: {
automation: { boardId: "planning" },
lifecycleStatusSourceUpdatedAt: 1234,
comments: [expect.objectContaining({ body: "round trip" })],
attachments: expect.arrayContaining([
expect.objectContaining({ fileName: "proof.txt" }),
@@ -334,6 +338,192 @@ describe("WorkboardStore", () => {
expect(rolledBack.completedAt).toBeUndefined();
});
it("tracks lifecycle status provenance and clears it on manual status changes", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Sync status provenance" });
const zeroSourceLifecycle = await store.update(card.id, {
status: "running",
metadata: { lifecycleStatusSourceUpdatedAt: 0 },
});
expect(zeroSourceLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBe(0);
const lifecycleMoved = await store.update(card.id, {
status: "running",
metadata: { lifecycleStatusSourceUpdatedAt: 1000 },
});
expect(lifecycleMoved.metadata?.lifecycleStatusSourceUpdatedAt).toBe(1000);
const newerLifecycle = await store.update(card.id, {
status: "review",
metadata: { lifecycleStatusSourceUpdatedAt: 3000 },
});
expect(newerLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBe(3000);
const manual = await store.move(card.id, "running", 2000);
expect(manual.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
const staleZeroLifecycle = await store.update(card.id, {
status: "review",
metadata: { lifecycleStatusSourceUpdatedAt: 0 },
});
expect(staleZeroLifecycle).toEqual(manual);
expect(staleZeroLifecycle.status).toBe("running");
expect(staleZeroLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
const staleLifecycle = await store.update(card.id, {
status: "review",
metadata: { lifecycleStatusSourceUpdatedAt: 2000 },
});
expect(staleLifecycle).toEqual(manual);
expect(staleLifecycle.status).toBe("running");
expect(staleLifecycle.updatedAt).toBe(manual.updatedAt);
expect(staleLifecycle.events).toHaveLength(manual.events?.length ?? 0);
expect(staleLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
const freshLifecycleSourceUpdatedAt = Date.now() + 1000;
const freshLifecycle = await store.update(card.id, {
status: "review",
metadata: { lifecycleStatusSourceUpdatedAt: freshLifecycleSourceUpdatedAt },
});
expect(freshLifecycle.status).toBe("review");
expect(freshLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBe(
freshLifecycleSourceUpdatedAt,
);
});
it("keeps creation status from stale lifecycle patches", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(2000);
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Initial running status",
status: "running",
});
const staleLifecycle = await store.update(card.id, {
status: "review",
metadata: { lifecycleStatusSourceUpdatedAt: 1000 },
});
expect(staleLifecycle).toEqual(card);
expect(staleLifecycle.status).toBe("running");
expect(staleLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
const freshLifecycle = await store.update(card.id, {
status: "review",
metadata: { lifecycleStatusSourceUpdatedAt: 3000 },
});
expect(freshLifecycle.status).toBe("review");
expect(freshLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBe(3000);
} finally {
vi.useRealTimers();
}
});
it("keeps non-status fields from stale lifecycle patches", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Keep stale sync details",
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: 1,
updatedAt: 1000,
},
});
const lifecycleMoved = await store.update(card.id, {
status: "review",
metadata: {
lifecycleStatusSourceUpdatedAt: 1000,
stale: {
detectedAt: 1000,
lastSessionUpdatedAt: 1000,
reason: "Session has not reported recent activity.",
},
},
});
const manual = await store.update(card.id, {
status: "running",
metadata: lifecycleMoved.metadata,
});
const synced = await store.update(card.id, {
status: "review",
execution: {
id: "exec-1",
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "done",
model: "openai/gpt-5.5",
sessionKey: "agent:main:dashboard:1",
runId: "run-1",
startedAt: 1,
updatedAt: 2000,
},
metadata: {
lifecycleStatusSourceUpdatedAt: 1000,
stale: null,
},
});
expect(manual.metadata?.stale).toBeDefined();
expect(synced.status).toBe("running");
expect(synced.execution).toMatchObject({
runId: "run-1",
status: "done",
updatedAt: 2000,
});
expect(synced.metadata?.stale).toBeUndefined();
expect(synced.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
expect(synced.events?.at(-1)).toMatchObject({
kind: "attempt_updated",
runId: "run-1",
});
});
it("clears copied lifecycle provenance on manual status patches", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Clear copied provenance" });
const lifecycleMoved = await store.update(card.id, {
status: "review",
metadata: {
lifecycleStatusSourceUpdatedAt: 1000,
stale: {
kind: "session",
status: "done",
updatedAt: 1000,
observedAt: 1000,
},
},
});
const manual = await store.update(card.id, {
status: "running",
metadata: {
...lifecycleMoved.metadata,
stale: null,
},
});
expect(manual.status).toBe("running");
expect(manual.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
const staleLifecycle = await store.update(card.id, {
status: "review",
metadata: { lifecycleStatusSourceUpdatedAt: 1000 },
});
expect(staleLifecycle.status).toBe("running");
expect(staleLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
});
it("keeps execution session links aligned with edited card links", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({

View File

@@ -1215,6 +1215,7 @@ function normalizeMetadata(
: null;
const hasArchivedAt = Object.hasOwn(record, "archivedAt");
const hasStale = Object.hasOwn(record, "stale");
const hasLifecycleStatusSourceUpdatedAt = Object.hasOwn(record, "lifecycleStatusSourceUpdatedAt");
const links = Array.isArray(record.links)
? record.links.map(normalizeLink).filter((link): link is WorkboardLink => link !== null)
: undefined;
@@ -1311,6 +1312,9 @@ function normalizeMetadata(
}
: undefined
: fallback.stale,
lifecycleStatusSourceUpdatedAt: hasLifecycleStatusSourceUpdatedAt
? normalizeTimestamp(record.lifecycleStatusSourceUpdatedAt, 0)
: fallback.lifecycleStatusSourceUpdatedAt,
failureCount:
typeof record.failureCount === "number" && Number.isFinite(record.failureCount)
? Math.max(0, Math.trunc(record.failureCount))
@@ -1419,6 +1423,7 @@ function removeUndefinedMetadataFields(metadata: WorkboardMetadata): WorkboardMe
"templateId",
"archivedAt",
"stale",
"lifecycleStatusSourceUpdatedAt",
"failureCount",
] as const) {
const value = next[key];
@@ -1639,6 +1644,49 @@ function latestMetadataIdChanged(
return Boolean(latestId && latestId !== existing?.at(-1)?.id);
}
function lifecycleStatusSourceUpdatedAtFromPatch(metadata: unknown): number | undefined {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
return undefined;
}
if (!Object.hasOwn(metadata, "lifecycleStatusSourceUpdatedAt")) {
return undefined;
}
const sourceUpdatedAt = normalizeTimestamp(
(metadata as Record<string, unknown>).lifecycleStatusSourceUpdatedAt,
0,
);
return sourceUpdatedAt;
}
function latestStatusTransitionAt(card: WorkboardCard): number | undefined {
for (let index = (card.events?.length ?? 0) - 1; index >= 0; index -= 1) {
const event = card.events?.[index];
if (
(event?.kind === "moved" || event?.kind === "created") &&
((event.kind === "created" && card.status !== "todo") ||
(event.kind === "moved" && event.fromStatus !== event.toStatus)) &&
event.toStatus === card.status &&
typeof event.at === "number" &&
Number.isFinite(event.at)
) {
return event.at;
}
}
return undefined;
}
function shouldSkipPersistedLifecycleStatusUpdate(
existing: WorkboardCard,
sourceUpdatedAt: number,
): boolean {
const lifecycleStatusSourceUpdatedAt = existing.metadata?.lifecycleStatusSourceUpdatedAt;
if (lifecycleStatusSourceUpdatedAt !== undefined) {
return sourceUpdatedAt < lifecycleStatusSourceUpdatedAt;
}
const statusTransitionAt = latestStatusTransitionAt(existing);
return statusTransitionAt !== undefined && sourceUpdatedAt < statusTransitionAt;
}
function updateEvent(
existing: WorkboardCard,
next: WorkboardCard,
@@ -2586,6 +2634,31 @@ export class WorkboardStore {
if (!existing) {
throw new Error(`card not found: ${id}`);
}
const lifecycleStatusSourceUpdatedAt = lifecycleStatusSourceUpdatedAtFromPatch(patch.metadata);
const existingLifecycleStatusSourceUpdatedAt =
existing.metadata?.lifecycleStatusSourceUpdatedAt;
const hasFreshLifecycleStatusSource =
lifecycleStatusSourceUpdatedAt !== undefined &&
lifecycleStatusSourceUpdatedAt !== existingLifecycleStatusSourceUpdatedAt;
if (
patch.status !== undefined &&
lifecycleStatusSourceUpdatedAt !== undefined &&
shouldSkipPersistedLifecycleStatusUpdate(existing, lifecycleStatusSourceUpdatedAt)
) {
// Ignore stale lifecycle status writes, but still accept any non-status updates in the patch.
patch.status = undefined;
if (patch.metadata && typeof patch.metadata === "object" && !Array.isArray(patch.metadata)) {
const metadataPatch = patch.metadata as Record<string, unknown>;
const { lifecycleStatusSourceUpdatedAt: _ignored, ...rest } = metadataPatch;
patch.metadata = Object.keys(rest).length > 0 ? rest : undefined;
}
const hasSemanticPatch = Object.entries(patch).some(
([key, value]) => key !== "status" && key !== "metadata" && value !== undefined,
);
if (!hasSemanticPatch && patch.metadata === undefined) {
return existing;
}
}
const status = normalizeStatus(patch.status, existing.status);
const now = Date.now();
const startedAt =
@@ -2613,6 +2686,11 @@ export class WorkboardStore {
let metadata = normalizeMetadata(patch.metadata, existing.metadata, {
allowDependencyLinks: options.allowMetadataDependencyLinks !== false,
});
if (status !== existing.status && !hasFreshLifecycleStatusSource) {
// Status patches often spread existing metadata. Only a newly supplied
// lifecycle source is provenance; copied markers must not survive a manual transition.
metadata = { ...metadata, lifecycleStatusSourceUpdatedAt: undefined };
}
const automationPatch: Record<string, unknown> = {};
for (const key of [
"tenant",

View File

@@ -297,6 +297,7 @@ export type WorkboardMetadata = {
templateId?: WorkboardTemplateId;
archivedAt?: number;
stale?: WorkboardStaleState;
lifecycleStatusSourceUpdatedAt?: number;
failureCount?: number;
};

View File

@@ -28,6 +28,11 @@ function createClient(
return { request };
}
function requestPatch(client: ReturnType<typeof createClient>, index: number) {
return (client.request.mock.calls[index]?.[1] as { patch?: Record<string, unknown> } | undefined)
?.patch;
}
function createDeferred<T>() {
let resolve: ((value: T) => void) | undefined;
const promise = new Promise<T>((res) => {
@@ -365,6 +370,157 @@ describe("workboard controller", () => {
expect(state.editingCardId).toBeNull();
});
it("keeps edit-modal status saves from being rewritten by stale lifecycle sync", async () => {
const host = {};
const state = getWorkboardState(host);
const linked = {
...sampleCard,
sessionKey: sampleSession.key,
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,
},
} satisfies WorkboardCard;
state.loaded = true;
state.cards = [linked];
state.draftOpen = true;
state.editingCardId = linked.id;
state.draftTitle = linked.title;
state.draftNotes = linked.notes ?? "";
state.draftStatus = "running";
state.draftPriority = linked.priority;
state.draftLabels = linked.labels.join(", ");
state.draftAgentId = linked.agentId ?? "";
state.draftSessionKey = linked.sessionKey ?? "";
const saved = {
...linked,
status: "running",
updatedAt: 2,
events: [
{
id: "move-1",
kind: "moved",
at: 2,
fromStatus: "todo",
toStatus: "running",
},
],
} satisfies WorkboardCard;
const client = createClient((method) => {
if (method === "workboard.cards.update") {
return { card: saved };
}
return {};
});
await saveWorkboardCardDraft({ host, client: client as never });
await syncWorkboardLifecycle({
host,
client: client as never,
sessions: [{ ...sampleSession, hasActiveRun: false, status: "done", updatedAt: 1 }],
});
expect(client.request).toHaveBeenCalledTimes(2);
expect(client.request).toHaveBeenCalledWith("workboard.cards.update", {
id: "card-1",
patch: expect.objectContaining({ status: "running" }),
});
expect(client.request.mock.calls[1]?.[1]).toMatchObject({
id: "card-1",
patch: { execution: expect.objectContaining({ status: "review" }) },
});
expect(requestPatch(client, 1)).not.toHaveProperty("status");
expect(state.cards[0]).toMatchObject({ status: "running" });
});
it("blocks stale lifecycle status writes while edit-modal status saves are in flight", async () => {
const host = {};
const state = getWorkboardState(host);
const linked = {
...sampleCard,
sessionKey: sampleSession.key,
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,
},
} satisfies WorkboardCard;
state.loaded = true;
state.cards = [linked];
state.draftOpen = true;
state.editingCardId = linked.id;
state.draftTitle = linked.title;
state.draftNotes = linked.notes ?? "";
state.draftStatus = "running";
state.draftPriority = linked.priority;
state.draftLabels = linked.labels.join(", ");
state.draftAgentId = linked.agentId ?? "";
state.draftSessionKey = linked.sessionKey ?? "";
const saved = {
...linked,
status: "running",
updatedAt: 2,
events: [
{
id: "move-1",
kind: "moved",
at: 2,
fromStatus: "todo",
toStatus: "running",
},
],
} satisfies WorkboardCard;
const saveResponse = createDeferred<{ card: WorkboardCard }>();
let updateCalls = 0;
const client = createClient((method) => {
if (method === "workboard.cards.update") {
updateCalls += 1;
if (updateCalls > 1) {
return {
card: {
...linked,
execution: { ...linked.execution, status: "succeeded", updatedAt: 3 },
updatedAt: 3,
},
};
}
return saveResponse.promise;
}
return {};
});
const saving = saveWorkboardCardDraft({ host, client: client as never });
await Promise.resolve();
await syncWorkboardLifecycle({
host,
client: client as never,
sessions: [{ ...sampleSession, hasActiveRun: false, status: "done", updatedAt: 1 }],
});
expect(client.request).toHaveBeenCalledTimes(2);
expect(client.request.mock.calls[1]?.[1]).toMatchObject({
id: "card-1",
patch: { execution: expect.objectContaining({ status: "review" }) },
});
expect(requestPatch(client, 1)).not.toHaveProperty("status");
saveResponse.resolve({ card: saved });
await saving;
expect(state.cards[0]).toMatchObject({ status: "running" });
});
it("adds operator notes to a selected detail card without opening the edit draft", async () => {
const host = {};
const state = getWorkboardState(host);
@@ -465,8 +621,8 @@ describe("workboard controller", () => {
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "running",
model: "openai/gpt-5.5",
status: "running",
sessionKey: sampleSession.key,
startedAt: 1,
updatedAt: 1,
@@ -1101,8 +1257,8 @@ describe("workboard controller", () => {
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "running",
model: "openai/gpt-5.5",
status: "running",
sessionKey: sampleTaskSessionKey,
runId: "run-1",
startedAt: 10,
@@ -1392,6 +1548,395 @@ describe("workboard controller", () => {
});
});
it("keeps dragged status changes from being rewritten by stale lifecycle sync", async () => {
const host = {};
const state = getWorkboardState(host);
const linked = {
...sampleCard,
sessionKey: sampleSession.key,
execution: {
id: "exec-1",
kind: "agent-session",
engine: "codex",
mode: "autonomous",
model: "openai/gpt-5.5",
status: "running",
sessionKey: sampleSession.key,
startedAt: 1,
updatedAt: 1,
},
} satisfies WorkboardCard;
const moved = {
...linked,
status: "running",
position: 2000,
updatedAt: 2,
events: [
{
id: "move-1",
kind: "moved",
at: 2,
fromStatus: "todo",
toStatus: "running",
},
],
} satisfies WorkboardCard;
state.loaded = true;
state.cards = [linked];
const client = createClient((method) => {
if (method === "workboard.cards.move") {
return { card: moved };
}
if (method === "workboard.cards.update") {
return { card: { ...moved, status: "review", updatedAt: 3 } };
}
return {};
});
await moveWorkboardCard({
host,
client: client as never,
cardId: "card-1",
status: "running",
position: 2000,
});
await syncWorkboardLifecycle({
host,
client: client as never,
sessions: [{ ...sampleSession, hasActiveRun: false, status: "done", updatedAt: 1 }],
});
expect(client.request).toHaveBeenCalledTimes(2);
expect(client.request).toHaveBeenCalledWith("workboard.cards.move", {
id: "card-1",
status: "running",
position: 2000,
});
expect(client.request.mock.calls[1]?.[1]).toMatchObject({
id: "card-1",
patch: { execution: expect.objectContaining({ status: "review" }) },
});
expect(requestPatch(client, 1)).not.toHaveProperty("status");
expect(state.cards[0]).toMatchObject({ status: "running", position: 2000 });
});
it("blocks stale lifecycle status writes while dragged status changes are in flight", async () => {
const host = {};
const state = getWorkboardState(host);
const linked = {
...sampleCard,
sessionKey: sampleSession.key,
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,
},
} satisfies WorkboardCard;
const moved = {
...linked,
status: "running",
position: 2000,
updatedAt: 2,
events: [
{
id: "move-1",
kind: "moved",
at: 2,
fromStatus: "todo",
toStatus: "running",
},
],
} satisfies WorkboardCard;
state.loaded = true;
state.cards = [linked];
const moveResponse = createDeferred<{ card: WorkboardCard }>();
let updateCalls = 0;
const client = createClient((method) => {
if (method === "workboard.cards.move") {
return moveResponse.promise;
}
if (method === "workboard.cards.update") {
updateCalls += 1;
if (updateCalls > 1) {
throw new Error("expected lifecycle sync to skip pending drag");
}
return { card: { ...moved, status: "review", updatedAt: 3 } };
}
return {};
});
const moving = moveWorkboardCard({
host,
client: client as never,
cardId: "card-1",
status: "running",
position: 2000,
});
await Promise.resolve();
await syncWorkboardLifecycle({
host,
client: client as never,
sessions: [{ ...sampleSession, hasActiveRun: false, status: "done", updatedAt: 1 }],
});
expect(client.request).toHaveBeenCalledTimes(2);
expect(client.request.mock.calls[1]?.[1]).toMatchObject({
id: "card-1",
patch: { execution: expect.objectContaining({ status: "review" }) },
});
expect(requestPatch(client, 1)).not.toHaveProperty("status");
moveResponse.resolve({ card: moved });
await moving;
expect(state.cards[0]).toMatchObject({ status: "running", position: 2000 });
});
it("ignores stale lifecycle responses when dragged status changes while sync is in flight", async () => {
const host = {};
const state = getWorkboardState(host);
const linked = { ...sampleCard, sessionKey: sampleSession.key } satisfies WorkboardCard;
const moved = {
...linked,
status: "running",
position: 2000,
updatedAt: 2,
events: [
{
id: "move-1",
kind: "moved",
at: 2,
fromStatus: "todo",
toStatus: "running",
},
],
} satisfies WorkboardCard;
const staleLifecycleCard = {
...linked,
status: "review",
updatedAt: 3,
metadata: { lifecycleStatusSourceUpdatedAt: 1 },
} satisfies WorkboardCard;
state.loaded = true;
state.cards = [linked];
const lifecycleResponse = createDeferred<{ card: WorkboardCard }>();
const client = createClient((method) => {
if (method === "workboard.cards.update") {
return lifecycleResponse.promise;
}
if (method === "workboard.cards.move") {
return { card: moved };
}
return {};
});
const syncing = syncWorkboardLifecycle({
host,
client: client as never,
sessions: [{ ...sampleSession, hasActiveRun: false, status: "done", updatedAt: 1 }],
});
await Promise.resolve();
await moveWorkboardCard({
host,
client: client as never,
cardId: "card-1",
status: "running",
position: 2000,
});
lifecycleResponse.resolve({ card: staleLifecycleCard });
await syncing;
expect(client.request).toHaveBeenCalledWith("workboard.cards.update", {
id: "card-1",
patch: expect.objectContaining({
status: "review",
metadata: { lifecycleStatusSourceUpdatedAt: 1 },
}),
});
expect(client.request).toHaveBeenCalledWith("workboard.cards.move", {
id: "card-1",
status: "running",
position: 2000,
});
expect(state.cards[0]).toMatchObject({ status: "running", position: 2000 });
});
it("ignores lifecycle responses without provenance when dragged status changes while sync is in flight", async () => {
const host = {};
const state = getWorkboardState(host);
const linked = {
...sampleCard,
sessionKey: sampleSession.key,
execution: {
id: "exec-1",
kind: "agent-session",
engine: "codex",
mode: "autonomous",
model: "openai/gpt-5.5",
status: "running",
sessionKey: sampleSession.key,
startedAt: 1,
updatedAt: 1,
},
} satisfies WorkboardCard;
const moved = {
...linked,
status: "running",
position: 2000,
updatedAt: 2,
events: [
{
id: "move-1",
kind: "moved",
at: 2,
fromStatus: "todo",
toStatus: "running",
},
],
} satisfies WorkboardCard;
const staleLifecycleCard = {
...linked,
status: "review",
updatedAt: 3,
execution: { ...linked.execution, status: "review" as const, updatedAt: 3 },
} satisfies WorkboardCard;
state.loaded = true;
state.cards = [linked];
const lifecycleResponse = createDeferred<{ card: WorkboardCard }>();
const client = createClient((method) => {
if (method === "workboard.cards.update") {
return lifecycleResponse.promise;
}
if (method === "workboard.cards.move") {
return { card: moved };
}
return {};
});
const syncing = syncWorkboardLifecycle({
host,
client: client as never,
sessions: [{ ...sampleSession, hasActiveRun: false, status: "done", updatedAt: null }],
});
await Promise.resolve();
await moveWorkboardCard({
host,
client: client as never,
cardId: "card-1",
status: "running",
position: 2000,
});
lifecycleResponse.resolve({ card: staleLifecycleCard });
await syncing;
expect(client.request).toHaveBeenCalledWith("workboard.cards.update", {
id: "card-1",
patch: { execution: expect.objectContaining({ status: "review" }) },
});
expect(client.request).toHaveBeenCalledWith("workboard.cards.move", {
id: "card-1",
status: "running",
position: 2000,
});
expect(state.cards[0]).toMatchObject({ status: "running", position: 2000 });
});
it("keeps non-status edits following newer linked session lifecycle sync", async () => {
const host = {};
const state = getWorkboardState(host);
const edited = {
...sampleCard,
title: "Renamed only",
status: "running",
sessionKey: sampleSession.key,
updatedAt: 5,
events: [
{
id: "move-1",
kind: "moved",
at: 2,
fromStatus: "todo",
toStatus: "running",
},
{ id: "edit-1", kind: "edited", at: 5 },
],
} satisfies WorkboardCard;
state.loaded = true;
state.cards = [edited];
const client = createClient({
"workboard.cards.update": {
card: { ...edited, status: "review", updatedAt: 6 },
},
});
await syncWorkboardLifecycle({
host,
client: client as never,
sessions: [{ ...sampleSession, hasActiveRun: false, status: "done", updatedAt: 3 }],
});
expect(client.request).toHaveBeenCalledWith("workboard.cards.update", {
id: "card-1",
patch: expect.objectContaining({ status: "review" }),
});
expect(state.cards[0]).toMatchObject({ title: "Renamed only", status: "review" });
});
it("keeps lifecycle-created moves following newer linked session lifecycle sync", async () => {
const host = {};
const state = getWorkboardState(host);
const lifecycleMoved = {
...sampleCard,
status: "running",
sessionKey: sampleSession.key,
updatedAt: 5,
metadata: { lifecycleStatusSourceUpdatedAt: 1 },
events: [
{
id: "move-1",
kind: "moved",
at: 5,
fromStatus: "todo",
toStatus: "running",
},
],
} satisfies WorkboardCard;
state.loaded = true;
state.cards = [lifecycleMoved];
const client = createClient({
"workboard.cards.update": {
card: {
...lifecycleMoved,
status: "review",
updatedAt: 6,
metadata: { lifecycleStatusSourceUpdatedAt: 3 },
},
},
});
await syncWorkboardLifecycle({
host,
client: client as never,
sessions: [{ ...sampleSession, hasActiveRun: false, status: "done", updatedAt: 3 }],
});
expect(client.request).toHaveBeenCalledWith("workboard.cards.update", {
id: "card-1",
patch: expect.objectContaining({
status: "review",
metadata: { lifecycleStatusSourceUpdatedAt: 3 },
}),
});
expect(state.cards[0]).toMatchObject({
status: "review",
metadata: { lifecycleStatusSourceUpdatedAt: 3 },
});
});
it("removes stale dependency links from local cards after delete", async () => {
const host = {};
const parent: WorkboardCard = {
@@ -1586,11 +2131,81 @@ describe("workboard controller", () => {
expect(client.request).toHaveBeenCalledOnce();
expect(client.request).toHaveBeenCalledWith("workboard.cards.update", {
id: "card-1",
patch: { status: "running" },
patch: expect.objectContaining({
status: "running",
metadata: expect.objectContaining({
lifecycleStatusSourceUpdatedAt: sampleSession.updatedAt,
}),
}),
});
expect(state.cards.find((card) => card.id === "card-review")?.status).toBe("review");
});
it("does not sync stale linked-session status over a card creation status", async () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.cards = [
{
...sampleCard,
status: "running",
sessionKey: sampleSession.key,
createdAt: 2000,
updatedAt: 2000,
events: [{ id: "event-created", kind: "created", at: 2000, toStatus: "running" }],
},
];
const client = createClient({
"workboard.cards.update": {
card: { ...sampleCard, status: "review", sessionKey: sampleSession.key },
},
});
await syncWorkboardLifecycle({
host,
client: client as never,
sessions: [
{
...sampleSession,
status: "done",
hasActiveRun: false,
updatedAt: 1000,
},
],
});
expect(client.request).not.toHaveBeenCalled();
expect(state.cards[0]?.status).toBe("running");
});
it("does not sync linked card status from sessions without lifecycle provenance", async () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.cards = [{ ...sampleCard, sessionKey: sampleSession.key }];
const client = createClient({
"workboard.cards.update": {
card: { ...sampleCard, status: "review", sessionKey: sampleSession.key },
},
});
await syncWorkboardLifecycle({
host,
client: client as never,
sessions: [
{
...sampleSession,
status: "done",
hasActiveRun: false,
updatedAt: null,
},
],
});
expect(client.request).not.toHaveBeenCalled();
expect(state.cards[0]).toMatchObject({ status: "todo" });
});
it("refreshes task lifecycle before syncing task-backed cards", async () => {
const host = {};
const state = getWorkboardState(host);
@@ -1620,7 +2235,12 @@ describe("workboard controller", () => {
expect(client.request).toHaveBeenNthCalledWith(1, "tasks.list", { limit: 500 });
expect(client.request).toHaveBeenNthCalledWith(2, "workboard.cards.update", {
id: "card-1",
patch: { status: "review" },
patch: expect.objectContaining({
status: "review",
metadata: expect.objectContaining({
lifecycleStatusSourceUpdatedAt: sampleTask.updatedAt,
}),
}),
});
expect(state.tasksByCardId.get("card-1")).toMatchObject({ status: "completed" });
});
@@ -1665,6 +2285,7 @@ describe("workboard controller", () => {
patch: {
status: "running",
metadata: {
lifecycleStatusSourceUpdatedAt: staleUpdatedAt,
stale: expect.objectContaining({
lastSessionUpdatedAt: staleUpdatedAt,
reason: "Linked session has not reported recent activity.",
@@ -1714,6 +2335,58 @@ describe("workboard controller", () => {
});
});
it("clears stale metadata after a newer manual status move", async () => {
const host = {};
const state = getWorkboardState(host);
const linked = {
...sampleCard,
status: "running",
sessionKey: sampleSession.key,
metadata: {
stale: {
detectedAt: 1,
lastSessionUpdatedAt: 1,
reason: "Linked session has not reported recent activity.",
},
},
events: [
{
id: "move-1",
kind: "moved",
at: 5,
fromStatus: "todo",
toStatus: "running",
},
],
} satisfies WorkboardCard;
state.loaded = true;
state.cards = [linked];
const client = createClient({
"workboard.cards.update": {
card: { ...linked, metadata: undefined, updatedAt: 6 },
},
});
await syncWorkboardLifecycle({
host,
client: client as never,
sessions: [
{
...sampleSession,
status: "running",
updatedAt: 3,
hasActiveRun: true,
},
],
});
expect(client.request).toHaveBeenCalledWith("workboard.cards.update", {
id: "card-1",
patch: { metadata: { stale: null } },
});
expect(state.cards[0]?.metadata?.stale).toBeUndefined();
});
it("does not rewrite unchanged stale session metadata", async () => {
const host = {};
const state = getWorkboardState(host);

View File

@@ -251,6 +251,7 @@ export type WorkboardMetadata = {
templateId?: WorkboardTemplateId;
archivedAt?: number;
stale?: WorkboardStaleState;
lifecycleStatusSourceUpdatedAt?: number;
failureCount?: number;
};
@@ -289,6 +290,7 @@ export type WorkboardLifecycle = {
session: GatewaySessionRow | null;
state: WorkboardLifecycleState;
targetStatus?: WorkboardStatus;
sourceUpdatedAt?: number;
};
export type WorkboardTaskStatus =
@@ -831,6 +833,11 @@ function normalizeMetadata(value: unknown): WorkboardMetadata | undefined {
}
: undefined;
const automation = normalizeAutomation(value.automation);
const lifecycleStatusSourceUpdatedAt =
typeof value.lifecycleStatusSourceUpdatedAt === "number" &&
Number.isFinite(value.lifecycleStatusSourceUpdatedAt)
? Math.max(0, Math.trunc(value.lifecycleStatusSourceUpdatedAt))
: undefined;
const metadata: WorkboardMetadata = {
...(attempts.length ? { attempts } : {}),
...(comments.length ? { comments } : {}),
@@ -849,6 +856,7 @@ function normalizeMetadata(value: unknown): WorkboardMetadata | undefined {
: {}),
...(typeof value.archivedAt === "number" ? { archivedAt: value.archivedAt } : {}),
...(stale ? { stale } : {}),
...(lifecycleStatusSourceUpdatedAt !== undefined ? { lifecycleStatusSourceUpdatedAt } : {}),
...(typeof value.failureCount === "number" ? { failureCount: value.failureCount } : {}),
};
return Object.keys(metadata).length ? metadata : undefined;
@@ -1021,6 +1029,17 @@ function taskUpdatedAtValue(task: WorkboardTaskSummary): number {
return 0;
}
function taskLifecycleSourceUpdatedAt(task: WorkboardTaskSummary): number | undefined {
const updatedAt = taskUpdatedAtValue(task);
return updatedAt > 0 ? updatedAt : undefined;
}
function sessionUpdatedAtValue(session: GatewaySessionRow): number | undefined {
return typeof session.updatedAt === "number" && Number.isFinite(session.updatedAt)
? session.updatedAt
: undefined;
}
function taskSessionKeyMatchesCardSession(
cardSessionKey: string,
taskSessionKey: string | undefined,
@@ -1297,12 +1316,14 @@ export function getWorkboardLifecycle(
session,
state: "running",
targetStatus: "running",
sourceUpdatedAt: taskLifecycleSourceUpdatedAt(task),
};
case "completed":
return {
session,
state: "succeeded",
targetStatus: "review",
sourceUpdatedAt: taskLifecycleSourceUpdatedAt(task),
};
case "failed":
case "cancelled":
@@ -1311,6 +1332,7 @@ export function getWorkboardLifecycle(
session,
state: "failed",
targetStatus: "blocked",
sourceUpdatedAt: taskLifecycleSourceUpdatedAt(task),
};
}
}
@@ -1321,16 +1343,36 @@ export function getWorkboardLifecycle(
return { session: null, state: "missing" };
}
if (staleSessionState(session)) {
return { session, state: "stale", targetStatus: "running" };
return {
session,
state: "stale",
targetStatus: "running",
sourceUpdatedAt: sessionUpdatedAtValue(session),
};
}
if (session.hasActiveRun === true || session.status === "running") {
return { session, state: "running", targetStatus: "running" };
return {
session,
state: "running",
targetStatus: "running",
sourceUpdatedAt: sessionUpdatedAtValue(session),
};
}
if (session.abortedLastRun || isFailedSessionStatus(session.status)) {
return { session, state: "failed", targetStatus: "blocked" };
return {
session,
state: "failed",
targetStatus: "blocked",
sourceUpdatedAt: sessionUpdatedAtValue(session),
};
}
if (session.status === "done") {
return { session, state: "succeeded", targetStatus: "review" };
return {
session,
state: "succeeded",
targetStatus: "review",
sourceUpdatedAt: sessionUpdatedAtValue(session),
};
}
return { session, state: "idle" };
}
@@ -1348,6 +1390,83 @@ function shouldSyncCardStatus(card: WorkboardCard, targetStatus: WorkboardStatus
return false;
}
const pendingStatusTransitions = new WeakMap<WorkboardHost, Set<string>>();
function pendingStatusTransitionMap(host: WorkboardHost) {
let transitions = pendingStatusTransitions.get(host);
if (!transitions) {
transitions = new Set();
pendingStatusTransitions.set(host, transitions);
}
return transitions;
}
function recordPendingStatusTransition(
host: WorkboardHost,
card: WorkboardCard | undefined,
status: WorkboardStatus,
): boolean {
if (!card || card.status === status) {
return false;
}
pendingStatusTransitionMap(host).add(card.id);
return true;
}
function clearPendingStatusTransition(host: WorkboardHost, cardId: string, recorded: boolean) {
if (!recorded) {
return;
}
const transitions = pendingStatusTransitions.get(host);
transitions?.delete(cardId);
}
function hasPendingStatusTransition(host: WorkboardHost, cardId: string): boolean {
return pendingStatusTransitions.get(host)?.has(cardId) ?? false;
}
function shouldSkipStaleLifecycleStatus(
card: WorkboardCard,
lifecycle: WorkboardLifecycle,
): boolean {
if (lifecycle.sourceUpdatedAt === undefined) {
return false;
}
const lifecycleStatusSourceUpdatedAt = card.metadata?.lifecycleStatusSourceUpdatedAt;
if (lifecycleStatusSourceUpdatedAt !== undefined) {
return lifecycle.sourceUpdatedAt < lifecycleStatusSourceUpdatedAt;
}
const statusTransitionAt = latestStatusTransitionAt(card);
return statusTransitionAt !== undefined && lifecycle.sourceUpdatedAt < statusTransitionAt;
}
function shouldSkipLifecycleStatusWrite(
host: WorkboardHost,
card: WorkboardCard,
lifecycle: WorkboardLifecycle,
): boolean {
return (
hasPendingStatusTransition(host, card.id) || shouldSkipStaleLifecycleStatus(card, lifecycle)
);
}
function latestStatusTransitionAt(card: WorkboardCard): number | undefined {
for (let index = (card.events?.length ?? 0) - 1; index >= 0; index -= 1) {
const event = card.events?.[index];
if (
(event?.kind === "moved" || event?.kind === "created") &&
((event.kind === "created" && card.status !== "todo") ||
(event.kind === "moved" && event.fromStatus !== event.toStatus)) &&
event.toStatus === card.status &&
typeof event.at === "number" &&
Number.isFinite(event.at)
) {
return event.at;
}
}
return undefined;
}
function executionStatusForLifecycle(
lifecycle: WorkboardLifecycle,
): WorkboardExecutionStatus | undefined {
@@ -1387,6 +1506,7 @@ function lifecycleSyncKey(card: WorkboardCard, lifecycle: WorkboardLifecycle): s
session?.status ?? "",
session?.hasActiveRun === true ? "active" : "idle",
session?.updatedAt ?? "",
lifecycle.sourceUpdatedAt ?? "",
card.execution?.status ?? "",
card.execution?.updatedAt ?? "",
].join(":");
@@ -1403,6 +1523,13 @@ function getLifecycleSyncKeys(host: WorkboardHost): Map<string, string> {
return keys;
}
function mergePatchMetadata(patch: Record<string, unknown>, metadata: Record<string, unknown>) {
patch.metadata = {
...(isRecord(patch.metadata) ? patch.metadata : {}),
...metadata,
};
}
function normalizeString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
@@ -1621,8 +1748,15 @@ export async function syncWorkboardLifecycle(params: {
);
const executionStatus = executionStatusForLifecycle(lifecycle);
const patch: Record<string, unknown> = {};
if (shouldSyncCardStatus(card, lifecycle.targetStatus)) {
if (
lifecycle.sourceUpdatedAt !== undefined &&
!shouldSkipLifecycleStatusWrite(params.host, card, lifecycle) &&
shouldSyncCardStatus(card, lifecycle.targetStatus)
) {
patch.status = lifecycle.targetStatus;
mergePatchMetadata(patch, {
lifecycleStatusSourceUpdatedAt: lifecycle.sourceUpdatedAt,
});
}
if (shouldSyncExecutionStatus(card, executionStatus)) {
patch.execution = {
@@ -1639,17 +1773,17 @@ export async function syncWorkboardLifecycle(params: {
existingStale.lastSessionUpdatedAt !== stale.lastSessionUpdatedAt ||
existingStale.reason !== stale.reason;
if (staleChanged) {
patch.metadata = {
mergePatchMetadata(patch, {
stale: {
...stale,
detectedAt: existingStale?.detectedAt ?? stale.detectedAt,
},
};
});
}
} else if (existingStale) {
patch.metadata = {
mergePatchMetadata(patch, {
stale: null,
};
});
}
if (Object.keys(patch).length === 0) {
continue;
@@ -1665,7 +1799,20 @@ export async function syncWorkboardLifecycle(params: {
id: card.id,
patch,
});
replaceCard(state, normalizeCardPayload(payload));
const currentCard = state.cards.find((candidate) => candidate.id === card.id);
const responseCard = normalizeCardPayload(payload);
// The user can change status after this request was sent; lifecycle responses
// are full-card replacements, so stale responses need the same guard again.
if (
!currentCard ||
hasPendingStatusTransition(params.host, currentCard.id) ||
(currentCard.status !== card.status && responseCard.status !== currentCard.status) ||
(shouldSkipStaleLifecycleStatus(currentCard, lifecycle) &&
responseCard.status !== currentCard.status)
) {
continue;
}
replaceCard(state, responseCard);
syncKeys.set(card.id, key);
} catch (error) {
state.error = formatError(error);
@@ -1716,10 +1863,16 @@ export async function saveWorkboardCardDraft(params: {
}
state.loading = true;
state.error = null;
const cardId = state.editingCardId;
const pendingStatusRecorded = recordPendingStatusTransition(
params.host,
state.cards.find((card) => card.id === cardId),
state.draftStatus,
);
params.requestUpdate?.();
try {
const payload = await params.client.request("workboard.cards.update", {
id: state.editingCardId,
id: cardId,
patch: draftPayload(state),
});
replaceCard(state, normalizeCardPayload(payload));
@@ -1727,6 +1880,7 @@ export async function saveWorkboardCardDraft(params: {
} catch (error) {
state.error = formatError(error);
} finally {
clearPendingStatusTransition(params.host, cardId, pendingStatusRecorded);
state.loading = false;
params.requestUpdate?.();
}
@@ -1781,6 +1935,11 @@ export async function moveWorkboardCard(params: {
}
state.busyCardId = params.cardId;
state.error = null;
const pendingStatusRecorded = recordPendingStatusTransition(
params.host,
state.cards.find((card) => card.id === params.cardId),
params.status,
);
params.requestUpdate?.();
try {
const payload = await params.client.request("workboard.cards.move", {
@@ -1792,6 +1951,7 @@ export async function moveWorkboardCard(params: {
} catch (error) {
state.error = formatError(error);
} finally {
clearPendingStatusTransition(params.host, params.cardId, pendingStatusRecorded);
state.busyCardId = null;
state.draggedCardId = null;
params.requestUpdate?.();

View File

@@ -0,0 +1,355 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser, type Locator, type Page } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
canRunPlaywrightChromium,
installMockGateway,
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
type ControlUiE2eServer,
type MockGatewayControls,
type MockGatewayRequest,
} from "../../test-helpers/control-ui-e2e.ts";
import type { WorkboardCard } from "../controllers/workboard.ts";
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const artifactDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"workboard-status-persistence",
);
let browser: Browser;
let server: ControlUiE2eServer;
const linkedSessionKey = "agent:main:workboard-linked-session";
const manualTodoAt = 2_000;
const editedAt = 2_500;
const draggedRunningAt = 3_000;
const staleCompletedSessionAt = 1_000;
const initialCard = {
id: "card-1",
title: "Persist queue status",
notes: "Original notes",
status: "todo",
priority: "normal",
labels: ["ui"],
agentId: "main",
position: 1_000,
createdAt: 900,
updatedAt: manualTodoAt,
sessionKey: linkedSessionKey,
events: [
{
id: "event-manual-todo",
kind: "moved",
at: manualTodoAt,
fromStatus: "running",
toStatus: "todo",
},
],
} satisfies WorkboardCard;
const editedCard = {
...initialCard,
title: "Persisted renamed card",
notes: "Edited notes survive reopening.",
priority: "high",
updatedAt: editedAt,
events: [...initialCard.events, { id: "event-edited", kind: "edited", at: editedAt }],
} satisfies WorkboardCard;
const draggedRunningCard = {
...editedCard,
status: "running",
position: 1_000,
updatedAt: draggedRunningAt,
events: [
...editedCard.events,
{
id: "event-manual-running",
kind: "moved",
at: draggedRunningAt,
fromStatus: "todo",
toStatus: "running",
},
],
} satisfies WorkboardCard;
const staleReviewCard = {
...draggedRunningCard,
status: "review",
updatedAt: draggedRunningAt + 500,
events: [
...draggedRunningCard.events,
{
id: "event-stale-review",
kind: "moved",
at: draggedRunningAt + 500,
fromStatus: "running",
toStatus: "review",
},
],
} satisfies WorkboardCard;
function requireRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Expected object value");
}
return value as Record<string, unknown>;
}
function requestParams(request: MockGatewayRequest): Record<string, unknown> {
return requireRecord(request.params);
}
function workboardColumn(page: Page, title: string) {
return page.locator(".workboard-column", {
has: page.getByRole("heading", { name: title }),
});
}
function workboardCard(page: Page, columnTitle: string, title: string) {
return workboardColumn(page, columnTitle).locator(".workboard-card", { hasText: title });
}
async function dispatchHtml5Drag(source: Locator, target: Locator): Promise<void> {
const sourceHandle = await source.elementHandle();
const targetHandle = await target.elementHandle();
if (!sourceHandle || !targetHandle) {
throw new Error("Could not resolve Workboard drag source or target");
}
try {
await sourceHandle.evaluate((sourceElement, targetElement) => {
const dataTransfer = new DataTransfer();
const init = { bubbles: true, cancelable: true, dataTransfer };
sourceElement.dispatchEvent(new DragEvent("dragstart", init));
targetElement.dispatchEvent(new DragEvent("dragover", init));
targetElement.dispatchEvent(new DragEvent("drop", init));
sourceElement.dispatchEvent(new DragEvent("dragend", init));
}, targetHandle);
} finally {
await sourceHandle.dispose();
await targetHandle.dispose();
}
}
async function waitForRequestCount(
gateway: MockGatewayControls,
method: string,
count: number,
): Promise<MockGatewayRequest[]> {
const deadline = Date.now() + 10_000;
let stableSince: number | null = null;
let latest: MockGatewayRequest[] = [];
while (Date.now() < deadline) {
latest = await gateway.getRequests(method);
if (latest.length === count) {
stableSince ??= Date.now();
if (Date.now() - stableSince >= 250) {
return latest;
}
} else {
stableSince = null;
}
await new Promise((resolve) => {
setTimeout(resolve, 50);
});
}
throw new Error(
`Timed out waiting for exactly ${count} ${method} requests: ${JSON.stringify(latest)}`,
);
}
describeControlUiE2e("Control UI Workboard status persistence E2E", () => {
beforeAll(async () => {
if (!chromiumAvailable) {
throw new Error(
`Playwright Chromium is not installed at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install chromium\`, set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to a compatible browser, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`,
);
}
await mkdir(artifactDir, { recursive: true });
server = await startControlUiE2eServer();
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
});
afterAll(async () => {
await browser?.close();
await server?.close();
});
it("persists edit/reopen fields and does not bounce a dragged linked card from stale lifecycle", async () => {
const context = await browser.newContext({
locale: "en-US",
recordVideo: { dir: artifactDir, size: { height: 900, width: 1280 } },
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"config.get": {
config: {
plugins: {
entries: {
workboard: { enabled: true },
},
},
},
hash: "workboard-e2e-config",
},
"sessions.list": {
count: 1,
defaults: {
contextTokens: null,
model: "gpt-5.5",
modelProvider: "openai",
},
path: "",
sessions: [
{
contextTokens: null,
displayName: "Completed linked session",
hasActiveRun: false,
key: linkedSessionKey,
kind: "direct",
label: "Completed linked session",
model: "gpt-5.5",
modelProvider: "openai",
status: "done",
totalTokens: 0,
updatedAt: staleCompletedSessionAt,
},
],
ts: Date.now(),
},
"tasks.list": {
nextCursor: null,
tasks: [],
},
"workboard.cards.list": {
cards: [initialCard],
statuses: [
"triage",
"backlog",
"todo",
"scheduled",
"ready",
"running",
"review",
"blocked",
"done",
],
},
"workboard.cards.move": {
card: draggedRunningCard,
},
"workboard.cards.update": {
cases: [
{
match: {
id: "card-1",
patch: {
title: "Persisted renamed card",
notes: "Edited notes survive reopening.",
status: "todo",
priority: "high",
labels: ["ui"],
agentId: "main",
sessionKey: linkedSessionKey,
},
},
response: { card: editedCard },
},
{
match: {
id: "card-1",
patch: { status: "review" },
},
response: { card: staleReviewCard },
},
],
},
},
});
try {
const response = await page.goto(`${server.baseUrl}workboard`);
expect(response?.status()).toBe(200);
await workboardCard(page, "Todo", "Persist queue status").waitFor({ timeout: 10_000 });
await waitForRequestCount(gateway, "workboard.cards.update", 0);
await workboardCard(page, "Todo", "Persist queue status").getByTitle("Edit card").click();
await page.getByLabel("Title").fill("Persisted renamed card");
await page.getByLabel("Notes").fill("Edited notes survive reopening.");
await page.getByLabel("Priority").selectOption("high");
await page.getByRole("button", { name: "Save" }).click();
const updateRequests = await waitForRequestCount(gateway, "workboard.cards.update", 1);
expect(requestParams(updateRequests[0])).toMatchObject({
id: "card-1",
patch: {
notes: "Edited notes survive reopening.",
priority: "high",
status: "todo",
title: "Persisted renamed card",
},
});
try {
await page.locator('[role="dialog"]').waitFor({ state: "detached", timeout: 10_000 });
} catch (err) {
const requests = await gateway.getRequests("workboard.cards.update");
throw new Error(
`Edit dialog stayed open after save. Update requests: ${JSON.stringify(requests)}`,
{ cause: err },
);
}
await workboardCard(page, "Todo", "Persisted renamed card").waitFor({ timeout: 10_000 });
await workboardCard(page, "Todo", "Persisted renamed card").getByTitle("Edit card").click();
await page.locator('[role="dialog"]').waitFor({ timeout: 10_000 });
await expect.poll(() => page.getByLabel("Title").inputValue()).toBe("Persisted renamed card");
await expect
.poll(() => page.getByLabel("Notes").inputValue())
.toBe("Edited notes survive reopening.");
await expect.poll(() => page.getByLabel("Priority").inputValue()).toBe("high");
await page.screenshot({
fullPage: true,
path: path.join(artifactDir, "workboard-edit-reopen.png"),
});
await page
.locator('[role="dialog"] .workboard-modal__actions')
.last()
.getByRole("button", { name: "Cancel" })
.click();
await page.locator('[role="dialog"]').waitFor({ state: "detached", timeout: 10_000 });
await dispatchHtml5Drag(
workboardCard(page, "Todo", "Persisted renamed card"),
workboardColumn(page, "Running").locator(".workboard-column__cards"),
);
const moveRequest = await gateway.waitForRequest("workboard.cards.move");
expect(requestParams(moveRequest)).toMatchObject({
id: "card-1",
position: 1_000,
status: "running",
});
await workboardCard(page, "Running", "Persisted renamed card").waitFor({
timeout: 10_000,
});
await waitForRequestCount(gateway, "workboard.cards.update", 1);
await page.screenshot({
fullPage: true,
path: path.join(artifactDir, "workboard-drag-running-persisted.png"),
});
} finally {
await context.close();
}
});
});

View File

@@ -0,0 +1,495 @@
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser, type BrowserContext, type Page } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { PROTOCOL_VERSION } from "../../../../packages/gateway-protocol/src/version.js";
import {
canRunPlaywrightChromium,
installMockGateway,
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
type ControlUiE2eServer,
type MockGatewayControls,
type MockGatewayRequest,
} from "../../test-helpers/control-ui-e2e.ts";
import { WORKBOARD_STATUSES, type WorkboardCard } from "../controllers/workboard.ts";
import type { GatewaySessionRow } from "../types.ts";
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const artifactDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/workboard");
const viewport = { height: 1000, width: 2400 };
const baseTime = Date.parse("2026-06-01T18:00:00.000Z");
const linkedSessionKey = "agent:main:workboard-proof";
const linkedSessionName = "Implementation session";
let browser: Browser;
let server: ControlUiE2eServer;
type RecordedPage = {
context: BrowserContext;
page: Page;
rawVideoDir: string;
};
type ProofArtifacts = {
screenshots: string[];
videos: string[];
};
function requireRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Expected object value");
}
return value as Record<string, unknown>;
}
function requestParams(request: MockGatewayRequest): Record<string, unknown> {
return requireRecord(request.params);
}
async function waitForRequests(
gateway: MockGatewayControls,
method: string,
count: number,
): Promise<MockGatewayRequest[]> {
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
const requests = await gateway.getRequests(method);
if (requests.length >= count) {
return requests;
}
await new Promise((resolve) => {
setTimeout(resolve, 50);
});
}
throw new Error(`Timed out waiting for ${count} ${method} requests`);
}
async function waitForNextRequest(
gateway: MockGatewayControls,
method: string,
previousCount: number,
): Promise<MockGatewayRequest> {
const requests = await waitForRequests(gateway, method, previousCount + 1);
const request = requests.at(-1);
if (!request) {
throw new Error(`No ${method} request found`);
}
return request;
}
function workboardConfigSnapshot() {
const config = {
plugins: {
entries: {
workboard: { enabled: true },
},
},
};
return {
config,
hash: "workboard-e2e-config",
path: "/tmp/openclaw-e2e/openclaw.json",
raw: JSON.stringify(config, null, 2),
resolved: config,
sourceConfig: config,
};
}
function sessionsListResponse(sessions: GatewaySessionRow[]) {
return {
count: sessions.length,
defaults: {
contextTokens: null,
model: "gpt-5.5",
modelProvider: "openai",
},
path: "",
sessions,
ts: baseTime,
};
}
function sessionRow(overrides: Partial<GatewaySessionRow> = {}): GatewaySessionRow {
return {
contextTokens: 0,
displayName: linkedSessionName,
hasActiveRun: false,
key: linkedSessionKey,
kind: "direct",
label: linkedSessionName,
model: "gpt-5.5",
modelProvider: "openai",
totalTokens: 0,
updatedAt: baseTime,
...overrides,
};
}
function readOnlyConnectResponse() {
return {
auth: {
deviceToken: "e2e-read-only-device-token",
role: "operator",
scopes: ["operator.read"],
},
features: { events: [], methods: ["chat.startup"] },
protocol: PROTOCOL_VERSION,
server: { connId: "control-ui-e2e-read-only", version: "e2e" },
snapshot: {
sessionDefaults: {
defaultAgentId: "main",
mainKey: "main",
mainSessionKey: "main",
scope: "agent",
},
},
type: "hello-ok",
};
}
function card(
overrides: Partial<WorkboardCard> & Pick<WorkboardCard, "id" | "title">,
): WorkboardCard {
return {
createdAt: baseTime,
labels: [],
notes: "",
position: 1000,
priority: "normal",
status: "todo",
updatedAt: baseTime,
...overrides,
};
}
function cardsListResponse(cards: WorkboardCard[]) {
return {
cards,
statuses: WORKBOARD_STATUSES,
};
}
function statusColumn(page: Page, status: string) {
return page
.locator(".workboard-column")
.filter({
has: page.locator(".workboard-column__header h2", {
hasText: new RegExp(`^${status}$`, "u"),
}),
})
.first();
}
function cardInColumn(page: Page, status: string, title: string) {
return statusColumn(page, status).locator(".workboard-card", { hasText: title }).first();
}
async function newRecordedPage(label: string): Promise<RecordedPage> {
await mkdir(artifactDir, { recursive: true });
const rawVideoDir = path.join(artifactDir, `${label}-raw`);
await rm(rawVideoDir, { force: true, recursive: true });
await mkdir(rawVideoDir, { recursive: true });
const context = await browser.newContext({
locale: "en-US",
recordVideo: {
dir: rawVideoDir,
size: viewport,
},
serviceWorkers: "block",
viewport,
});
const page = await context.newPage();
page.setDefaultTimeout(10_000);
return { context, page, rawVideoDir };
}
async function captureScreenshot(
page: Page,
artifacts: ProofArtifacts,
name: string,
): Promise<void> {
const screenshotPath = path.join(artifactDir, `${name}.png`);
await page.screenshot({ fullPage: true, path: screenshotPath });
artifacts.screenshots.push(screenshotPath);
}
async function closeRecordedPage(
recorded: RecordedPage,
artifacts: ProofArtifacts,
label: string,
): Promise<void> {
const video = recorded.page.video();
try {
await recorded.context.close();
if (!video) {
return;
}
const rawVideoPath = await video.path();
const videoPath = path.join(artifactDir, `${label}.webm`);
await copyFile(rawVideoPath, videoPath);
artifacts.videos.push(videoPath);
} finally {
await rm(recorded.rawVideoDir, { force: true, recursive: true });
}
}
describeControlUiE2e("Control UI Workboard mocked Gateway E2E", () => {
beforeAll(async () => {
if (!chromiumAvailable) {
throw new Error(
`Playwright Chromium is not installed at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`,
);
}
server = await startControlUiE2eServer();
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
});
afterAll(async () => {
await browser?.close();
await server?.close();
});
it("persists Workboard create, edit, running move, lifecycle sync, reload, and read-only state", async () => {
await rm(artifactDir, { force: true, recursive: true });
const artifacts: ProofArtifacts = { screenshots: [], videos: [] };
const createdCard = card({
id: "card-1",
labels: ["ui", "proof"],
notes: "Acceptance: browser proof",
sessionKey: linkedSessionKey,
title: "Draft Workboard browser proof",
updatedAt: baseTime + 1,
});
const editedCard = card({
...createdCard,
labels: ["ui", "proof", "e2e"],
notes: "Acceptance: mocked Gateway browser proof\nProof: pending",
priority: "high",
title: "Workboard browser proof",
updatedAt: baseTime + 2,
});
const runningCard = card({
...editedCard,
status: "running",
updatedAt: baseTime + 3,
});
const reviewedCard = card({
...runningCard,
events: [
{
at: baseTime + 4,
fromStatus: "running",
id: "event-review",
kind: "moved",
toStatus: "review",
},
],
status: "review",
updatedAt: baseTime + 4,
});
const writable = await newRecordedPage("workboard-writable");
const writableGateway = await installMockGateway(writable.page, {
methodResponses: {
"config.get": workboardConfigSnapshot(),
"sessions.list": sessionsListResponse([sessionRow()]),
"tasks.list": { nextCursor: null, tasks: [] },
"workboard.cards.list": cardsListResponse([]),
},
});
try {
const response = await writable.page.goto(`${server.baseUrl}workboard`);
expect(response?.status()).toBe(200);
await statusColumn(writable.page, "Todo").waitFor({ state: "visible" });
await captureScreenshot(writable.page, artifacts, "01-empty-board");
await writableGateway.deferNext("workboard.cards.create");
await writable.page
.locator(".workboard-toolbar__actions")
.getByRole("button", { name: /New card/u })
.click();
const createDialog = writable.page.getByRole("dialog", { name: "New card" });
await createDialog.getByLabel("Title").fill(createdCard.title);
await createDialog.getByLabel("Notes").fill(createdCard.notes ?? "");
await createDialog.getByLabel("Session").selectOption(linkedSessionKey);
await createDialog.getByLabel("Labels").fill("ui, proof");
await captureScreenshot(writable.page, artifacts, "02-create-dialog");
const createBefore = (await writableGateway.getRequests("workboard.cards.create")).length;
await createDialog.getByRole("button", { name: /^Create$/u }).click();
const createRequest = await waitForNextRequest(
writableGateway,
"workboard.cards.create",
createBefore,
);
expect(requestParams(createRequest)).toMatchObject({
labels: ["ui", "proof"],
notes: createdCard.notes,
sessionKey: linkedSessionKey,
status: "todo",
title: createdCard.title,
});
await writableGateway.resolveDeferred("workboard.cards.create", { card: createdCard });
await cardInColumn(writable.page, "Todo", createdCard.title).waitFor({ state: "visible" });
await captureScreenshot(writable.page, artifacts, "03-created-card");
await writableGateway.deferNext("workboard.cards.update");
await cardInColumn(writable.page, "Todo", createdCard.title)
.locator('button[title="Edit card"]')
.click();
const editDialog = writable.page.getByRole("dialog", { name: "Edit card" });
await editDialog.getByLabel("Title").fill(editedCard.title);
await editDialog.getByLabel("Notes").fill(editedCard.notes ?? "");
await editDialog.getByLabel("Priority").selectOption("high");
await editDialog.getByLabel("Labels").fill("ui, proof, e2e");
const updateBeforeEdit = (await writableGateway.getRequests("workboard.cards.update")).length;
await editDialog.getByRole("button", { name: /^Save$/u }).click();
const editRequest = await waitForNextRequest(
writableGateway,
"workboard.cards.update",
updateBeforeEdit,
);
expect(requestParams(editRequest)).toMatchObject({ id: createdCard.id });
expect(requireRecord(requestParams(editRequest).patch)).toMatchObject({
labels: ["ui", "proof", "e2e"],
notes: editedCard.notes,
priority: "high",
sessionKey: linkedSessionKey,
title: editedCard.title,
});
await writableGateway.resolveDeferred("workboard.cards.update", { card: editedCard });
await cardInColumn(writable.page, "Todo", editedCard.title).waitFor({ state: "visible" });
await captureScreenshot(writable.page, artifacts, "04-edited-card");
await cardInColumn(writable.page, "Todo", editedCard.title).click();
const details = writable.page.locator(".workboard-detail");
await details.getByText(editedCard.title).waitFor({ state: "visible" });
await details.getByText("Acceptance: mocked Gateway browser proof").waitFor({
state: "visible",
});
await details.locator('button[title="Cancel"]').click();
await writableGateway.deferNext("workboard.cards.move");
const moveBefore = (await writableGateway.getRequests("workboard.cards.move")).length;
await cardInColumn(writable.page, "Todo", editedCard.title).dragTo(
statusColumn(writable.page, "Running").locator(".workboard-column__cards"),
);
const moveRequest = await waitForNextRequest(
writableGateway,
"workboard.cards.move",
moveBefore,
);
expect(requestParams(moveRequest)).toMatchObject({
id: editedCard.id,
status: "running",
});
await writableGateway.resolveDeferred("workboard.cards.move", { card: runningCard });
await cardInColumn(writable.page, "Running", editedCard.title).waitFor({
state: "visible",
});
await captureScreenshot(writable.page, artifacts, "05-moved-running");
await writableGateway.deferNext("workboard.cards.update");
const syncBefore = (await writableGateway.getRequests("workboard.cards.update")).length;
await writableGateway.emitGatewayEvent("sessions.changed", {
...sessionRow({
hasActiveRun: false,
status: "done",
updatedAt: baseTime + 4,
}),
reason: "lifecycle",
sessionKey: linkedSessionKey,
ts: baseTime + 4,
});
const syncRequest = await waitForNextRequest(
writableGateway,
"workboard.cards.update",
syncBefore,
);
expect(requestParams(syncRequest)).toMatchObject({ id: runningCard.id });
expect(requireRecord(requestParams(syncRequest).patch)).toMatchObject({
status: "review",
});
await writableGateway.resolveDeferred("workboard.cards.update", { card: reviewedCard });
const reviewedCardSurface = cardInColumn(writable.page, "Review", editedCard.title);
await reviewedCardSurface.waitFor({ state: "visible" });
await reviewedCardSurface.getByTitle("View details").click();
await writable.page.locator(".workboard-detail").getByText("Moved to Review").waitFor({
state: "visible",
});
await captureScreenshot(writable.page, artifacts, "06-lifecycle-review");
await details.locator('button[title="Cancel"]').click();
await details.waitFor({ state: "hidden" });
await writableGateway.deferNext("workboard.cards.list");
const listBeforeReload = (await writableGateway.getRequests("workboard.cards.list")).length;
await writable.page
.locator(".workboard-toolbar__actions")
.getByRole("button", { name: /^Refresh$/u })
.click();
await waitForNextRequest(writableGateway, "workboard.cards.list", listBeforeReload);
await writableGateway.resolveDeferred("workboard.cards.list", {
cards: [reviewedCard],
statuses: WORKBOARD_STATUSES,
});
await cardInColumn(writable.page, "Review", editedCard.title).waitFor({ state: "visible" });
await writable.page.getByText("Acceptance: mocked Gateway browser proof").waitFor({
state: "visible",
});
await captureScreenshot(writable.page, artifacts, "07-reloaded-review");
} finally {
await closeRecordedPage(writable, artifacts, "workboard-writable");
}
const readOnly = await newRecordedPage("workboard-read-only");
const readOnlyGateway = await installMockGateway(readOnly.page, {
methodResponses: {
connect: readOnlyConnectResponse(),
"config.get": workboardConfigSnapshot(),
"sessions.list": sessionsListResponse([
sessionRow({ hasActiveRun: false, status: "done", updatedAt: baseTime + 4 }),
]),
"tasks.list": { nextCursor: null, tasks: [] },
"workboard.cards.list": cardsListResponse([runningCard]),
},
});
try {
const response = await readOnly.page.goto(`${server.baseUrl}workboard`);
expect(response?.status()).toBe(200);
await cardInColumn(readOnly.page, "Running", editedCard.title).waitFor({
state: "visible",
});
await captureScreenshot(readOnly.page, artifacts, "08-read-only-board");
expect(await readOnly.page.getByRole("button", { name: /New card/u }).count()).toBe(0);
expect(await readOnly.page.locator('button[title="Edit card"]').count()).toBe(0);
expect(await readOnly.page.locator('button[title="Delete card"]').count()).toBe(0);
expect(await readOnly.page.locator('button[title="Run default agent"]').count()).toBe(0);
expect(
await cardInColumn(readOnly.page, "Running", editedCard.title).getAttribute("draggable"),
).toBe("false");
await cardInColumn(readOnly.page, "Running", editedCard.title).click();
await readOnly.page.locator(".workboard-detail").getByText(editedCard.title).waitFor({
state: "visible",
});
expect(await readOnly.page.locator(".workboard-detail__note").count()).toBe(0);
expect(await readOnly.page.getByRole("button", { name: /Add note/u }).count()).toBe(0);
expect(await readOnlyGateway.getRequests("workboard.cards.update")).toHaveLength(0);
expect(await readOnlyGateway.getRequests("workboard.cards.move")).toHaveLength(0);
expect(await readOnlyGateway.getRequests("workboard.cards.create")).toHaveLength(0);
} finally {
await closeRecordedPage(readOnly, artifacts, "workboard-read-only");
}
await writeFile(
path.join(artifactDir, "manifest.json"),
`${JSON.stringify(artifacts, null, 2)}\n`,
"utf-8",
);
});
});

View File

@@ -1675,6 +1675,22 @@ describe("renderWorkboard", () => {
priority: "high",
}),
});
expect(state.cards[0]).toMatchObject({ title: "Renamed", priority: "high", updatedAt: 2 });
render(renderWorkboard(props), container);
expect(container.querySelector('[role="dialog"]')).toBeNull();
container
.querySelector<HTMLButtonElement>('button[title="Edit card"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
render(renderWorkboard(props), container);
expect(container.querySelector<HTMLInputElement>(".workboard-draft__title")?.value).toBe(
"Renamed",
);
expect(
[...container.querySelectorAll<HTMLSelectElement>(".workboard-draft__meta select")].at(1)
?.value,
).toBe("high");
});
it("locks edit-modal actions while a comment request is in flight", () => {

View File

@@ -857,7 +857,10 @@ function renderCardModal(props: WorkboardProps) {
}}
>
${state.statuses.map(
(status) => html`<option value=${status}>${formatStatusLabel(status)}</option>`,
(status) =>
html`<option value=${status} ?selected=${state.draftStatus === status}>
${formatStatusLabel(status)}
</option>`,
)}
</select>
</label>
@@ -873,7 +876,10 @@ function renderCardModal(props: WorkboardProps) {
}}
>
${WORKBOARD_PRIORITIES.map(
(priority) => html`<option value=${priority}>${priority}</option>`,
(priority) =>
html`<option value=${priority} ?selected=${state.draftPriority === priority}>
${priority}
</option>`,
)}
</select>
</label>
@@ -887,10 +893,12 @@ function renderCardModal(props: WorkboardProps) {
props.onRequestUpdate?.();
}}
>
<option value="">${t("workboard.defaultAgent")}</option>
<option value="" ?selected=${!state.draftAgentId}>
${t("workboard.defaultAgent")}
</option>
${agents.map(
(agent) =>
html`<option value=${agent.id}>
html`<option value=${agent.id} ?selected=${state.draftAgentId === agent.id}>
${agent.name ?? agent.identity?.name ?? agent.id}
</option>`,
)}
@@ -906,10 +914,15 @@ function renderCardModal(props: WorkboardProps) {
props.onRequestUpdate?.();
}}
>
<option value="">${t("workboard.noLinkedSession")}</option>
<option value="" ?selected=${!state.draftSessionKey}>
${t("workboard.noLinkedSession")}
</option>
${sessions.map(
(session) =>
html`<option value=${session.key}>
html`<option
value=${session.key}
?selected=${state.draftSessionKey === session.key}
>
${session.displayName ?? session.label ?? session.key}
</option>`,
)}