fix(workboard): keep bulk lifecycle patches isolated

This commit is contained in:
Val Alexander
2026-06-03 16:49:47 -07:00
parent 6d05d6edd5
commit aa3786acee
2 changed files with 80 additions and 34 deletions

View File

@@ -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({

View File

@@ -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<string, unknown>;
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)) {