Skip to content

Commit 21354f0

Browse files
committed
fix(ui): tolerate malformed cron payloads
1 parent 4eedc47 commit 21354f0

8 files changed

Lines changed: 144 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
1414
### Fixes
1515

1616
- Control UI: allow deployments to configure grouped chat message max-width with a validated `gateway.controlUi.chatMessageMaxWidth` setting instead of patching bundled CSS after upgrades. Fixes #67935. Thanks @xiew4589-lang.
17+
- Control UI/Cron: ignore malformed persisted cron rows without valid payloads before they enter UI state and guard stale cron render paths, preventing blank Control UI sections after a bad cron snapshot. Fixes #55047 and #54439; supersedes #54550 and #54552.
1718
- Control UI/sessions: bound the default Sessions tab query to recent activity and fewer rows, avoiding expensive full-history loads while keeping filters editable. Fixes #76050. (#76051) Thanks @Neomail2.
1819
- Plugins/doctor: repair missing configured provider and channel plugins from ClawHub before npm fallback, preserving ClawPack metadata in the install record. Thanks @vincentkoc.
1920
- Gateway/channels: cap startup fanout at four channel/account handoffs and recover from Bonjour ciao self-probe races, reducing Windows startup stalls with many Telegram accounts. Fixes #75687.

ui/src/ui/app-render.assistant-avatar.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,4 +240,26 @@ describe("renderApp assistant avatar routing", () => {
240240
const shell = container.querySelector<HTMLElement>(".shell");
241241
expect(shell?.style.getPropertyValue("--chat-message-max-width")).toBe("min(1280px, 82%)");
242242
});
243+
244+
it("does not throw when stale cron state contains a job without a payload", () => {
245+
expect(() =>
246+
renderApp(
247+
createState({
248+
cronJobs: [
249+
{
250+
id: "bad-missing-payload",
251+
name: "Broken",
252+
enabled: true,
253+
createdAtMs: 0,
254+
updatedAtMs: 0,
255+
schedule: { kind: "cron", expr: "0 9 * * *" },
256+
sessionTarget: "main",
257+
wakeMode: "next-heartbeat",
258+
payload: undefined,
259+
} as unknown as AppViewState["cronJobs"][number],
260+
],
261+
}),
262+
),
263+
).not.toThrow();
264+
});
243265
});

ui/src/ui/app-render.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ import {
116116
updateSkillEdit,
117117
updateSkillEnabled,
118118
} from "./controllers/skills.ts";
119+
import { getCronJobPayload } from "./cron-payload.ts";
119120
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts";
120121
import { icons } from "./icons.ts";
121122
import { createLazyView, renderLazyView } from "./lazy-view.ts";
@@ -838,10 +839,11 @@ export function renderApp(state: AppViewState) {
838839
...resolveConfiguredCronModelSuggestions(configValue),
839840
...state.cronJobs
840841
.map((job) => {
841-
if (job.payload.kind !== "agentTurn" || typeof job.payload.model !== "string") {
842+
const payload = getCronJobPayload(job);
843+
if (payload?.kind !== "agentTurn" || typeof payload.model !== "string") {
842844
return "";
843845
}
844-
return job.payload.model.trim();
846+
return payload.model.trim();
845847
})
846848
.filter(Boolean),
847849
].filter(Boolean),

ui/src/ui/controllers/cron.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1231,7 +1231,19 @@ describe("cron controller", () => {
12311231
sortDir: "desc",
12321232
});
12331233
return {
1234-
jobs: [{ id: "job-1", name: "Daily", enabled: true }],
1234+
jobs: [
1235+
{
1236+
id: "job-1",
1237+
name: "Daily",
1238+
enabled: true,
1239+
createdAtMs: 0,
1240+
updatedAtMs: 0,
1241+
schedule: { kind: "cron", expr: "0 9 * * *" },
1242+
sessionTarget: "main",
1243+
wakeMode: "next-heartbeat",
1244+
payload: { kind: "systemEvent", text: "ping" },
1245+
},
1246+
],
12351247
total: 1,
12361248
hasMore: false,
12371249
nextOffset: null,
@@ -1254,6 +1266,42 @@ describe("cron controller", () => {
12541266
expect(state.cronJobsHasMore).toBe(false);
12551267
});
12561268

1269+
it("drops malformed cron jobs before they enter UI state", async () => {
1270+
const request = vi.fn(async (method: string) => {
1271+
if (method === "cron.list") {
1272+
return {
1273+
jobs: [
1274+
{ id: "bad-missing-payload", name: "Broken", enabled: true },
1275+
{
1276+
id: "job-ok",
1277+
name: "Daily",
1278+
enabled: true,
1279+
createdAtMs: 0,
1280+
updatedAtMs: 0,
1281+
schedule: { kind: "cron", expr: "0 9 * * *" },
1282+
sessionTarget: "main",
1283+
wakeMode: "next-heartbeat",
1284+
payload: { kind: "systemEvent", text: "ping" },
1285+
},
1286+
],
1287+
total: 2,
1288+
hasMore: false,
1289+
nextOffset: null,
1290+
};
1291+
}
1292+
return {};
1293+
});
1294+
const state = createState({
1295+
client: { request } as unknown as CronState["client"],
1296+
});
1297+
1298+
await loadCronJobsPage(state);
1299+
1300+
expect(state.cronJobs.map((job) => job.id)).toEqual(["job-ok"]);
1301+
expect(state.cronJobsTotal).toBe(2);
1302+
expect(state.cronJobsHasMore).toBe(false);
1303+
});
1304+
12571305
it("loads and appends paged run history", async () => {
12581306
const request = vi.fn(async (method: string, payload?: unknown) => {
12591307
if (method !== "cron.runs") {

ui/src/ui/controllers/cron.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { t } from "../../i18n/index.ts";
22
import { DEFAULT_CRON_FORM } from "../app-defaults.ts";
3+
import { getCronJobPayload, hasCronJobPayload } from "../cron-payload.ts";
34
import { toNumber } from "../format.ts";
45
import type { GatewayBrowserClient } from "../gateway.ts";
56
import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";
@@ -300,14 +301,15 @@ export async function loadCronJobsPage(state: CronState, opts?: { append?: boole
300301
sortBy: state.cronJobsSortBy,
301302
sortDir: state.cronJobsSortDir,
302303
});
303-
const jobs = Array.isArray(res.jobs) ? res.jobs : [];
304+
const rawJobs = Array.isArray(res.jobs) ? res.jobs : [];
305+
const jobs = rawJobs.filter(hasCronJobPayload);
304306
state.cronJobs = append ? [...state.cronJobs, ...jobs] : jobs;
305307
const meta = normalizeCronPageMeta({
306308
totalRaw: res.total,
307309
offsetRaw: res.offset,
308310
nextOffsetRaw: res.nextOffset,
309311
hasMoreRaw: res.hasMore,
310-
pageCount: jobs.length,
312+
pageCount: rawJobs.length,
311313
});
312314
state.cronJobsTotal = Math.max(meta.total, state.cronJobs.length);
313315
state.cronJobsHasMore = meta.hasMore;
@@ -440,6 +442,7 @@ function parseStaggerSchedule(
440442

441443
function jobToForm(job: CronJob, prev: CronFormState): CronFormState {
442444
const failureAlert = job.failureAlert;
445+
const payload = getCronJobPayload(job);
443446
const next: CronFormState = {
444447
...prev,
445448
name: job.name,
@@ -460,12 +463,11 @@ function jobToForm(job: CronJob, prev: CronFormState): CronFormState {
460463
staggerUnit: "seconds",
461464
sessionTarget: job.sessionTarget,
462465
wakeMode: job.wakeMode,
463-
payloadKind: job.payload.kind,
464-
payloadText: job.payload.kind === "systemEvent" ? job.payload.text : job.payload.message,
465-
payloadModel: job.payload.kind === "agentTurn" ? (job.payload.model ?? "") : "",
466-
payloadThinking: job.payload.kind === "agentTurn" ? (job.payload.thinking ?? "") : "",
467-
payloadLightContext:
468-
job.payload.kind === "agentTurn" ? job.payload.lightContext === true : false,
466+
payloadKind: payload?.kind ?? DEFAULT_CRON_FORM.payloadKind,
467+
payloadText: payload?.kind === "systemEvent" ? payload.text : (payload?.message ?? ""),
468+
payloadModel: payload?.kind === "agentTurn" ? (payload.model ?? "") : "",
469+
payloadThinking: payload?.kind === "agentTurn" ? (payload.thinking ?? "") : "",
470+
payloadLightContext: payload?.kind === "agentTurn" ? payload.lightContext === true : false,
469471
deliveryMode: job.delivery?.mode ?? "none",
470472
deliveryChannel: job.delivery?.channel ?? CRON_CHANNEL_LAST,
471473
deliveryTo: job.delivery?.to ?? "",
@@ -499,8 +501,8 @@ function jobToForm(job: CronJob, prev: CronFormState): CronFormState {
499501
failureAlertAccountId:
500502
failureAlert && typeof failureAlert === "object" ? (failureAlert.accountId ?? "") : "",
501503
timeoutSeconds:
502-
job.payload.kind === "agentTurn" && typeof job.payload.timeoutSeconds === "number"
503-
? String(job.payload.timeoutSeconds)
504+
payload?.kind === "agentTurn" && typeof payload.timeoutSeconds === "number"
505+
? String(payload.timeoutSeconds)
504506
: "",
505507
};
506508

@@ -658,9 +660,10 @@ export async function addCronJob(state: CronState) {
658660
const editingJob = state.cronEditingJobId
659661
? state.cronJobs.find((job) => job.id === state.cronEditingJobId)
660662
: undefined;
663+
const editingPayload = editingJob ? getCronJobPayload(editingJob) : null;
661664
if (payload.kind === "agentTurn") {
662665
const existingLightContext =
663-
editingJob?.payload.kind === "agentTurn" ? editingJob.payload.lightContext : undefined;
666+
editingPayload?.kind === "agentTurn" ? editingPayload.lightContext : undefined;
664667
if (
665668
!form.payloadLightContext &&
666669
state.cronEditingJobId &&

ui/src/ui/cron-payload.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { CronJob, CronPayload } from "./types.ts";
2+
3+
function isRecord(value: unknown): value is Record<string, unknown> {
4+
return Boolean(value && typeof value === "object");
5+
}
6+
7+
export function isCronPayload(value: unknown): value is CronPayload {
8+
if (!isRecord(value)) {
9+
return false;
10+
}
11+
if (value.kind === "systemEvent") {
12+
return typeof value.text === "string";
13+
}
14+
if (value.kind === "agentTurn") {
15+
return typeof value.message === "string";
16+
}
17+
return false;
18+
}
19+
20+
export function getCronJobPayload(job: CronJob): CronPayload | null {
21+
const payload = (job as { payload?: unknown }).payload;
22+
return isCronPayload(payload) ? payload : null;
23+
}
24+
25+
export function hasCronJobPayload(job: CronJob): boolean {
26+
return getCronJobPayload(job) !== null;
27+
}

ui/src/ui/views/cron.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,25 @@ describe("cron view", () => {
299299
expect(container.textContent).toContain("https://example.invalid/cron");
300300
});
301301

302+
it("does not throw when a stale cron job has no payload", () => {
303+
const container = document.createElement("div");
304+
const job = {
305+
...createJob("job-broken"),
306+
payload: undefined,
307+
} as unknown as CronJob;
308+
309+
expect(() =>
310+
render(
311+
renderCron(
312+
createProps({
313+
jobs: [job],
314+
}),
315+
),
316+
container,
317+
),
318+
).not.toThrow();
319+
});
320+
302321
it("renders cron job prompts and run summaries as sanitized markdown", () => {
303322
const container = document.createElement("div");
304323
const onLoadRuns = vi.fn();

ui/src/ui/views/cron.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
CronJobsLastStatusFilter,
99
CronJobsScheduleKindFilter,
1010
} from "../controllers/cron.ts";
11+
import { getCronJobPayload } from "../cron-payload.ts";
1112
import { formatRelativeTimestamp, formatMs } from "../format.ts";
1213
import { toSanitizedMarkdownHtml } from "../markdown.ts";
1314
import { pathForTab } from "../navigation.ts";
@@ -1580,10 +1581,14 @@ function renderJob(job: CronJob, props: CronProps) {
15801581
}
15811582

15821583
function renderJobPayload(job: CronJob) {
1583-
if (job.payload.kind === "systemEvent") {
1584+
const payload = getCronJobPayload(job);
1585+
if (!payload) {
1586+
return html``;
1587+
}
1588+
if (payload.kind === "systemEvent") {
15841589
return html`<div class="cron-job-detail">
15851590
<span class="cron-job-detail-label">${t("cron.jobDetail.system")}</span>
1586-
<span class="muted cron-job-detail-value">${job.payload.text}</span>
1591+
<span class="muted cron-job-detail-value">${payload.text}</span>
15871592
</div>`;
15881593
}
15891594

@@ -1602,7 +1607,7 @@ function renderJobPayload(job: CronJob) {
16021607
<div class="cron-job-detail-section">
16031608
<span class="cron-job-detail-label">${t("cron.jobDetail.prompt")}</span>
16041609
<div class="muted cron-job-detail-value chat-text" @click=${stopPropagationForInteractive}>
1605-
${unsafeHTML(toSanitizedMarkdownHtml(job.payload.message))}
1610+
${unsafeHTML(toSanitizedMarkdownHtml(payload.message))}
16061611
</div>
16071612
</div>
16081613
${delivery

0 commit comments

Comments
 (0)