mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(workboard): keep bulk lifecycle patches isolated
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user