diff --git a/extensions/workboard/src/sqlite-store.ts b/extensions/workboard/src/sqlite-store.ts index 0617777be8b3..c4f9a891e719 100644 --- a/extensions/workboard/src/sqlite-store.ts +++ b/extensions/workboard/src/sqlite-store.ts @@ -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(db: DatabaseSync, run: () => T): T { } } +function tableColumns(db: DatabaseSync, tableName: string): Set { + 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), }); diff --git a/extensions/workboard/src/store.test.ts b/extensions/workboard/src/store.test.ts index 520db0eaddf7..a315dea20abf 100644 --- a/extensions/workboard/src/store.test.ts +++ b/extensions/workboard/src/store.test.ts @@ -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({ diff --git a/extensions/workboard/src/store.ts b/extensions/workboard/src/store.ts index d67d195fbd2c..1b275f58d969 100644 --- a/extensions/workboard/src/store.ts +++ b/extensions/workboard/src/store.ts @@ -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).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; + 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 = {}; for (const key of [ "tenant", diff --git a/extensions/workboard/src/types.ts b/extensions/workboard/src/types.ts index dd7d715685bb..27783ab971c4 100644 --- a/extensions/workboard/src/types.ts +++ b/extensions/workboard/src/types.ts @@ -297,6 +297,7 @@ export type WorkboardMetadata = { templateId?: WorkboardTemplateId; archivedAt?: number; stale?: WorkboardStaleState; + lifecycleStatusSourceUpdatedAt?: number; failureCount?: number; }; diff --git a/ui/src/ui/controllers/workboard.test.ts b/ui/src/ui/controllers/workboard.test.ts index ddb2c772df2a..1bd785d85330 100644 --- a/ui/src/ui/controllers/workboard.test.ts +++ b/ui/src/ui/controllers/workboard.test.ts @@ -28,6 +28,11 @@ function createClient( return { request }; } +function requestPatch(client: ReturnType, index: number) { + return (client.request.mock.calls[index]?.[1] as { patch?: Record } | undefined) + ?.patch; +} + function createDeferred() { let resolve: ((value: T) => void) | undefined; const promise = new Promise((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); diff --git a/ui/src/ui/controllers/workboard.ts b/ui/src/ui/controllers/workboard.ts index a51ded2c30df..c7ee09d25bce 100644 --- a/ui/src/ui/controllers/workboard.ts +++ b/ui/src/ui/controllers/workboard.ts @@ -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>(); + +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 { return keys; } +function mergePatchMetadata(patch: Record, metadata: Record) { + 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 = {}; - 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?.(); diff --git a/ui/src/ui/e2e/workboard-status-persistence.e2e.test.ts b/ui/src/ui/e2e/workboard-status-persistence.e2e.test.ts new file mode 100644 index 000000000000..bc69e25b2069 --- /dev/null +++ b/ui/src/ui/e2e/workboard-status-persistence.e2e.test.ts @@ -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 { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Expected object value"); + } + return value as Record; +} + +function requestParams(request: MockGatewayRequest): Record { + 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 { + 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 { + 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(); + } + }); +}); diff --git a/ui/src/ui/e2e/workboard.e2e.test.ts b/ui/src/ui/e2e/workboard.e2e.test.ts new file mode 100644 index 000000000000..4ab4ce4457b8 --- /dev/null +++ b/ui/src/ui/e2e/workboard.e2e.test.ts @@ -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 { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Expected object value"); + } + return value as Record; +} + +function requestParams(request: MockGatewayRequest): Record { + return requireRecord(request.params); +} + +async function waitForRequests( + gateway: MockGatewayControls, + method: string, + count: number, +): Promise { + 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 { + 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 { + 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 & Pick, +): 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 { + 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 { + 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 { + 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", + ); + }); +}); diff --git a/ui/src/ui/views/workboard.test.ts b/ui/src/ui/views/workboard.test.ts index 272a41cbe18d..38277f721257 100644 --- a/ui/src/ui/views/workboard.test.ts +++ b/ui/src/ui/views/workboard.test.ts @@ -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('button[title="Edit card"]') + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + render(renderWorkboard(props), container); + + expect(container.querySelector(".workboard-draft__title")?.value).toBe( + "Renamed", + ); + expect( + [...container.querySelectorAll(".workboard-draft__meta select")].at(1) + ?.value, + ).toBe("high"); }); it("locks edit-modal actions while a comment request is in flight", () => { diff --git a/ui/src/ui/views/workboard.ts b/ui/src/ui/views/workboard.ts index 1a7aa6a9aaac..e5503d975fa9 100644 --- a/ui/src/ui/views/workboard.ts +++ b/ui/src/ui/views/workboard.ts @@ -857,7 +857,10 @@ function renderCardModal(props: WorkboardProps) { }} > ${state.statuses.map( - (status) => html``, + (status) => + html``, )} @@ -873,7 +876,10 @@ function renderCardModal(props: WorkboardProps) { }} > ${WORKBOARD_PRIORITIES.map( - (priority) => html``, + (priority) => + html``, )} @@ -887,10 +893,12 @@ function renderCardModal(props: WorkboardProps) { props.onRequestUpdate?.(); }} > - + ${agents.map( (agent) => - html``, )} @@ -906,10 +914,15 @@ function renderCardModal(props: WorkboardProps) { props.onRequestUpdate?.(); }} > - + ${sessions.map( (session) => - html``, )}