From aa3786acee0a63b103d702563b55f8d067e3b959 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:49:47 -0700 Subject: [PATCH] fix(workboard): keep bulk lifecycle patches isolated --- extensions/workboard/src/store.test.ts | 32 ++++++++++ extensions/workboard/src/store.ts | 82 +++++++++++++++----------- 2 files changed, 80 insertions(+), 34 deletions(-) diff --git a/extensions/workboard/src/store.test.ts b/extensions/workboard/src/store.test.ts index a315dea20abf..cbcf4dbf70cb 100644 --- a/extensions/workboard/src/store.test.ts +++ b/extensions/workboard/src/store.test.ts @@ -421,6 +421,38 @@ describe("WorkboardStore", () => { } }); + it("does not let one stale bulk lifecycle patch strip later card updates", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(1000); + const store = new WorkboardStore(createMemoryStore()); + const staleCard = await store.create({ title: "Stale bulk target" }); + const freshCard = await store.create({ title: "Fresh bulk target" }); + vi.setSystemTime(3000); + await store.move(staleCard.id, "running", 1000); + + const patch = { + status: "review", + metadata: { lifecycleStatusSourceUpdatedAt: 2000 }, + } as const; + const result = await store.bulkUpdate({ + ids: [staleCard.id, freshCard.id], + patch, + }); + + expect(result.cards[0]).toMatchObject({ id: staleCard.id, status: "running" }); + expect(result.cards[0]?.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined(); + expect(result.cards[1]).toMatchObject({ id: freshCard.id, status: "review" }); + expect(result.cards[1]?.metadata?.lifecycleStatusSourceUpdatedAt).toBe(2000); + expect(patch).toEqual({ + status: "review", + metadata: { lifecycleStatusSourceUpdatedAt: 2000 }, + }); + } finally { + vi.useRealTimers(); + } + }); + it("keeps non-status fields from stale lifecycle patches", 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 1b275f58d969..54fbae98c147 100644 --- a/extensions/workboard/src/store.ts +++ b/extensions/workboard/src/store.ts @@ -2640,50 +2640,51 @@ export class WorkboardStore { const hasFreshLifecycleStatusSource = lifecycleStatusSourceUpdatedAt !== undefined && lifecycleStatusSourceUpdatedAt !== existingLifecycleStatusSourceUpdatedAt; + let effectivePatch = patch; 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; + effectivePatch = { ...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; + effectivePatch.metadata = Object.keys(rest).length > 0 ? rest : undefined; } - const hasSemanticPatch = Object.entries(patch).some( + const hasSemanticPatch = Object.entries(effectivePatch).some( ([key, value]) => key !== "status" && key !== "metadata" && value !== undefined, ); - if (!hasSemanticPatch && patch.metadata === undefined) { + if (!hasSemanticPatch && effectivePatch.metadata === undefined) { return existing; } } - const status = normalizeStatus(patch.status, existing.status); + const status = normalizeStatus(effectivePatch.status, existing.status); const now = Date.now(); const startedAt = - patch.startedAt === undefined + effectivePatch.startedAt === undefined ? status === "running" ? (existing.startedAt ?? now) : existing.startedAt - : normalizeTimestamp(patch.startedAt, 0) || undefined; + : normalizeTimestamp(effectivePatch.startedAt, 0) || undefined; const completedAt = - patch.completedAt === undefined + effectivePatch.completedAt === undefined ? status === "done" ? (existing.completedAt ?? now) : undefined - : normalizeTimestamp(patch.completedAt, 0) || undefined; + : normalizeTimestamp(effectivePatch.completedAt, 0) || undefined; const sessionKey = - patch.sessionKey === undefined + effectivePatch.sessionKey === undefined ? existing.sessionKey - : normalizeOptionalString(patch.sessionKey); + : normalizeOptionalString(effectivePatch.sessionKey); const execution = - patch.execution === undefined - ? patch.sessionKey === undefined + effectivePatch.execution === undefined + ? effectivePatch.sessionKey === undefined ? existing.execution : syncExecutionSessionKey(existing.execution, sessionKey) - : normalizeExecution(patch.execution); - let metadata = normalizeMetadata(patch.metadata, existing.metadata, { + : normalizeExecution(effectivePatch.execution); + let metadata = normalizeMetadata(effectivePatch.metadata, existing.metadata, { allowDependencyLinks: options.allowMetadataDependencyLinks !== false, }); if (status !== existing.status && !hasFreshLifecycleStatusSource) { @@ -2703,8 +2704,8 @@ export class WorkboardStore { "maxRetries", "scheduledAt", ] as const) { - if (Object.hasOwn(patch, key) && patch[key] !== undefined) { - automationPatch[key] = patch[key]; + if (Object.hasOwn(effectivePatch, key) && effectivePatch[key] !== undefined) { + automationPatch[key] = effectivePatch[key]; } } if (Object.keys(automationPatch).length > 0) { @@ -2715,32 +2716,45 @@ export class WorkboardStore { } const next = removeUndefinedCardFields({ ...existing, - title: patch.title === undefined ? existing.title : normalizeTitle(patch.title), - notes: patch.notes === undefined ? existing.notes : normalizeNotes(patch.notes), + title: + effectivePatch.title === undefined ? existing.title : normalizeTitle(effectivePatch.title), + notes: + effectivePatch.notes === undefined ? existing.notes : normalizeNotes(effectivePatch.notes), status, priority: - patch.priority === undefined + effectivePatch.priority === undefined ? existing.priority - : normalizePriority(patch.priority, existing.priority), - labels: patch.labels === undefined ? existing.labels : normalizeLabels(patch.labels), + : normalizePriority(effectivePatch.priority, existing.priority), + labels: + effectivePatch.labels === undefined + ? existing.labels + : normalizeLabels(effectivePatch.labels), agentId: - patch.agentId === undefined ? existing.agentId : normalizeOptionalString(patch.agentId), + effectivePatch.agentId === undefined + ? existing.agentId + : normalizeOptionalString(effectivePatch.agentId), sessionKey, - runId: patch.runId === undefined ? existing.runId : normalizeOptionalString(patch.runId), - taskId: patch.taskId === undefined ? existing.taskId : normalizeOptionalString(patch.taskId), + runId: + effectivePatch.runId === undefined + ? existing.runId + : normalizeOptionalString(effectivePatch.runId), + taskId: + effectivePatch.taskId === undefined + ? existing.taskId + : normalizeOptionalString(effectivePatch.taskId), sourceUrl: - patch.sourceUrl === undefined + effectivePatch.sourceUrl === undefined ? existing.sourceUrl - : normalizeOptionalString(patch.sourceUrl), + : normalizeOptionalString(effectivePatch.sourceUrl), execution, metadata: - patch.templateId === undefined + effectivePatch.templateId === undefined ? metadata - : { ...metadata, templateId: normalizeTemplateId(patch.templateId) }, + : { ...metadata, templateId: normalizeTemplateId(effectivePatch.templateId) }, position: - patch.position === undefined + effectivePatch.position === undefined ? existing.position - : normalizePosition(patch.position, existing.position), + : normalizePosition(effectivePatch.position, existing.position), updatedAt: now, ...(startedAt ? { startedAt } : {}), ...(completedAt ? { completedAt } : {}), @@ -2749,16 +2763,16 @@ export class WorkboardStore { syncExecutionAttemptMetadata(next.metadata ?? {}, execution, now), ); next.events = appendEvent(next, updateEvent(existing, next), now); - if (options.enforceStatusHolds && patch.status !== undefined) { + if (options.enforceStatusHolds && effectivePatch.status !== undefined) { await this.assertActiveStatusAllowed(existing, next, now); } if (status !== "done") { delete next.completedAt; } - if (patch.startedAt !== undefined && !startedAt) { + if (effectivePatch.startedAt !== undefined && !startedAt) { delete next.startedAt; } - if (patch.completedAt !== undefined && !completedAt) { + if (effectivePatch.completedAt !== undefined && !completedAt) { delete next.completedAt; } if (metadataIsEmpty(next.metadata)) {