fix(workboard): heal oversized persisted notifications (#120999)

Preserve the existing UTF-16-safe notification cap at the canonical metadata normalization boundary so a legacy oversized row cannot abort dispatch or starve sibling cards. Move the existing capText helper into its normalization owner without production growth.

Co-authored-by: licheer-zte <licheer-zte@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
licheer-zte
2026-08-26 00:20:12 +08:00
committed by GitHub
parent ecf0631779
commit b0e9fd6252
6 changed files with 57 additions and 13 deletions
@@ -16,7 +16,6 @@ import {
} from "@openclaw/workboard-contract";
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import {
BLOCKED_TOO_LONG_MS,
MAX_CARD_ATTEMPTS,
@@ -26,6 +25,7 @@ import {
} from "./store-constants.js";
import type { WorkboardMutationScope } from "./store-inputs.js";
import {
capText,
metadataIsEmpty,
normalizeEvents,
normalizeTimestamp,
@@ -522,13 +522,6 @@ export function computeCardDiagnostics(card: WorkboardCard, now: number): Workbo
return diagnostics;
}
export function capText(value: string | undefined, max: number): string | undefined {
if (!value) {
return undefined;
}
return value.length <= max ? value : `${truncateUtf16Safe(value, Math.max(0, max - 1))}`;
}
export function cardBoardId(card: WorkboardCard): string {
return card.metadata?.automation?.boardId ?? "default";
}
+1 -1
View File
@@ -8,7 +8,6 @@ import type {
import type { PersistedWorkboardAttachment } from "./persistence-types.js";
import {
assertCanMutateClaimedCard,
capText,
cardRunId,
cardSessionKey,
closeRunningAttempts,
@@ -30,6 +29,7 @@ import type {
WorkboardWorkerLogInput,
} from "./store-inputs.js";
import {
capText,
clearDiagnostics,
normalizeArtifact,
normalizeAttachmentInput,
@@ -44,6 +44,7 @@ import {
} from "@openclaw/workboard-contract";
import { resolveNonNegativeIntegerOption } from "openclaw/plugin-sdk/number-runtime";
import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import {
MAX_ATTACHMENT_BYTES,
MAX_CARD_ARTIFACTS,
@@ -278,6 +279,13 @@ export function normalizeBoundedString(
return normalized;
}
export function capText(value: string | undefined, max: number): string | undefined {
if (!value) {
return undefined;
}
return value.length <= max ? value : `${truncateUtf16Safe(value, Math.max(0, max - 1))}`;
}
export function normalizeStatus(value: unknown, fallback: WorkboardStatus): WorkboardStatus {
if (typeof value !== "string" || !value.trim()) {
return fallback;
@@ -941,7 +949,7 @@ function normalizeNotification(value: unknown): WorkboardNotification | null {
const kind = normalizeEnumValue(record.kind, WORKBOARD_NOTIFICATION_KINDS, undefined);
const createdAt = normalizeTimestamp(record.createdAt, Date.now());
const sequence = normalizeTimestamp(record.sequence, 0) || undefined;
const message = normalizeBoundedString(record.message, undefined, 240, "notification message");
const message = capText(normalizeOptionalString(record.message), 240);
if (!kind || !message) {
return null;
}
+1 -1
View File
@@ -13,7 +13,6 @@ import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
assertCanMutateClaimedCard,
capText,
cardBoardId,
cardChildIds,
cardParentIds,
@@ -48,6 +47,7 @@ import type {
} from "./store-inputs.js";
import {
appendCompletionProof,
capText,
clearDiagnostics,
deriveChildIdempotencyKey,
normalizeArtifact,
+44
View File
@@ -2748,6 +2748,50 @@ describe("WorkboardStore", () => {
expect(blocked.metadata?.notifications?.[0]?.message.length).toBeLessThanOrEqual(240);
});
it("heals oversized persisted notifications and keeps dispatching sibling cards", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-notification-"));
const dbPath = path.join(dir, "workboard.sqlite");
const stores = createWorkboardSqliteStores({ dbPath });
try {
const store = new WorkboardStore(stores.cards);
const poisoned = await store.create({ title: "Oversized notification", status: "ready" });
const sibling = await store.create({ title: "Unaffected sibling", status: "ready" });
const oversized = `${"x".repeat(238)}🦞${" tail".repeat(60)}`;
const rawDb = new DatabaseSync(dbPath);
try {
rawDb
.prepare(
"INSERT INTO workboard_card_notifications (id, card_id, ordinal, kind, message, created_at) VALUES (?, ?, ?, ?, ?, ?)",
)
.run("oversized", poisoned.id, 0, "failed", oversized, Date.now());
} finally {
rawDb.close();
}
expect((await store.get(poisoned.id))?.metadata?.notifications?.[0]?.message).toBe(oversized);
await expect(store.dispatch()).resolves.toBeDefined();
const repaired = await store.get(poisoned.id);
expect(repaired?.metadata?.notifications?.[0]?.message).toBe(`${"x".repeat(238)}`);
expect(repaired?.metadata?.automation?.dispatchCount).toBe(1);
expect((await store.get(sibling.id))?.metadata?.automation?.dispatchCount).toBe(1);
const verifyDb = new DatabaseSync(dbPath, { readOnly: true });
try {
expect(
verifyDb
.prepare("SELECT message FROM workboard_card_notifications WHERE id = ?")
.get("oversized"),
).toEqual({ message: `${"x".repeat(238)}` });
} finally {
verifyDb.close();
}
} finally {
stores.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("dispatches ready cards and blocks expired or timed-out work", async () => {
vi.useFakeTimers();
try {
+1 -2
View File
@@ -21,7 +21,6 @@ import { createWorkboardSqliteStores } from "./sqlite-store.js";
import {
buildWorkerContext,
assertCanMutateClaimedCard,
capText,
cardBoardId,
cardRunId,
cardSessionKey,
@@ -49,7 +48,7 @@ import type {
WorkboardDispatchResult,
WorkboardMutationScope,
} from "./store-inputs.js";
import { normalizeBoardId, normalizeTimestamp } from "./store-normalizers.js";
import { capText, normalizeBoardId, normalizeTimestamp } from "./store-normalizers.js";
import { WorkboardNotificationStore } from "./store-notifications.js";
export type { WorkboardDispatchResult } from "./store-inputs.js";