fix(ui): tolerate malformed cron payloads

Fix Control UI blank sections caused by malformed persisted cron rows.

- filter invalid cron payloads at the cron.list UI boundary
- guard stale cron payload reads in render, edit, and detail paths
- add regression coverage for malformed cron rows from #55047 and #54439

Closes #55047.
Closes #54439.
Supersedes #54550.
Supersedes #54552.
This commit is contained in:
Val Alexander
2026-05-02 10:37:53 -05:00
committed by GitHub
parent 33eebc29c3
commit aaa19fb9f3
8 changed files with 144 additions and 17 deletions

View File

@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- 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.
- 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.
- 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.
- Plugins/doctor: repair missing configured provider and channel plugins from ClawHub before npm fallback, preserving ClawPack metadata in the install record. Thanks @vincentkoc.
- 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.

View File

@@ -240,4 +240,26 @@ describe("renderApp assistant avatar routing", () => {
const shell = container.querySelector<HTMLElement>(".shell");
expect(shell?.style.getPropertyValue("--chat-message-max-width")).toBe("min(1280px, 82%)");
});
it("does not throw when stale cron state contains a job without a payload", () => {
expect(() =>
renderApp(
createState({
cronJobs: [
{
id: "bad-missing-payload",
name: "Broken",
enabled: true,
createdAtMs: 0,
updatedAtMs: 0,
schedule: { kind: "cron", expr: "0 9 * * *" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: undefined,
} as unknown as AppViewState["cronJobs"][number],
],
}),
),
).not.toThrow();
});
});

View File

@@ -116,6 +116,7 @@ import {
updateSkillEdit,
updateSkillEnabled,
} from "./controllers/skills.ts";
import { getCronJobPayload } from "./cron-payload.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts";
import { icons } from "./icons.ts";
import { createLazyView, renderLazyView } from "./lazy-view.ts";
@@ -838,10 +839,11 @@ export function renderApp(state: AppViewState) {
...resolveConfiguredCronModelSuggestions(configValue),
...state.cronJobs
.map((job) => {
if (job.payload.kind !== "agentTurn" || typeof job.payload.model !== "string") {
const payload = getCronJobPayload(job);
if (payload?.kind !== "agentTurn" || typeof payload.model !== "string") {
return "";
}
return job.payload.model.trim();
return payload.model.trim();
})
.filter(Boolean),
].filter(Boolean),

View File

@@ -1231,7 +1231,19 @@ describe("cron controller", () => {
sortDir: "desc",
});
return {
jobs: [{ id: "job-1", name: "Daily", enabled: true }],
jobs: [
{
id: "job-1",
name: "Daily",
enabled: true,
createdAtMs: 0,
updatedAtMs: 0,
schedule: { kind: "cron", expr: "0 9 * * *" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "ping" },
},
],
total: 1,
hasMore: false,
nextOffset: null,
@@ -1254,6 +1266,42 @@ describe("cron controller", () => {
expect(state.cronJobsHasMore).toBe(false);
});
it("drops malformed cron jobs before they enter UI state", async () => {
const request = vi.fn(async (method: string) => {
if (method === "cron.list") {
return {
jobs: [
{ id: "bad-missing-payload", name: "Broken", enabled: true },
{
id: "job-ok",
name: "Daily",
enabled: true,
createdAtMs: 0,
updatedAtMs: 0,
schedule: { kind: "cron", expr: "0 9 * * *" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "ping" },
},
],
total: 2,
hasMore: false,
nextOffset: null,
};
}
return {};
});
const state = createState({
client: { request } as unknown as CronState["client"],
});
await loadCronJobsPage(state);
expect(state.cronJobs.map((job) => job.id)).toEqual(["job-ok"]);
expect(state.cronJobsTotal).toBe(2);
expect(state.cronJobsHasMore).toBe(false);
});
it("loads and appends paged run history", async () => {
const request = vi.fn(async (method: string, payload?: unknown) => {
if (method !== "cron.runs") {

View File

@@ -1,5 +1,6 @@
import { t } from "../../i18n/index.ts";
import { DEFAULT_CRON_FORM } from "../app-defaults.ts";
import { getCronJobPayload, hasCronJobPayload } from "../cron-payload.ts";
import { toNumber } from "../format.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";
@@ -300,14 +301,15 @@ export async function loadCronJobsPage(state: CronState, opts?: { append?: boole
sortBy: state.cronJobsSortBy,
sortDir: state.cronJobsSortDir,
});
const jobs = Array.isArray(res.jobs) ? res.jobs : [];
const rawJobs = Array.isArray(res.jobs) ? res.jobs : [];
const jobs = rawJobs.filter(hasCronJobPayload);
state.cronJobs = append ? [...state.cronJobs, ...jobs] : jobs;
const meta = normalizeCronPageMeta({
totalRaw: res.total,
offsetRaw: res.offset,
nextOffsetRaw: res.nextOffset,
hasMoreRaw: res.hasMore,
pageCount: jobs.length,
pageCount: rawJobs.length,
});
state.cronJobsTotal = Math.max(meta.total, state.cronJobs.length);
state.cronJobsHasMore = meta.hasMore;
@@ -440,6 +442,7 @@ function parseStaggerSchedule(
function jobToForm(job: CronJob, prev: CronFormState): CronFormState {
const failureAlert = job.failureAlert;
const payload = getCronJobPayload(job);
const next: CronFormState = {
...prev,
name: job.name,
@@ -460,12 +463,11 @@ function jobToForm(job: CronJob, prev: CronFormState): CronFormState {
staggerUnit: "seconds",
sessionTarget: job.sessionTarget,
wakeMode: job.wakeMode,
payloadKind: job.payload.kind,
payloadText: job.payload.kind === "systemEvent" ? job.payload.text : job.payload.message,
payloadModel: job.payload.kind === "agentTurn" ? (job.payload.model ?? "") : "",
payloadThinking: job.payload.kind === "agentTurn" ? (job.payload.thinking ?? "") : "",
payloadLightContext:
job.payload.kind === "agentTurn" ? job.payload.lightContext === true : false,
payloadKind: payload?.kind ?? DEFAULT_CRON_FORM.payloadKind,
payloadText: payload?.kind === "systemEvent" ? payload.text : (payload?.message ?? ""),
payloadModel: payload?.kind === "agentTurn" ? (payload.model ?? "") : "",
payloadThinking: payload?.kind === "agentTurn" ? (payload.thinking ?? "") : "",
payloadLightContext: payload?.kind === "agentTurn" ? payload.lightContext === true : false,
deliveryMode: job.delivery?.mode ?? "none",
deliveryChannel: job.delivery?.channel ?? CRON_CHANNEL_LAST,
deliveryTo: job.delivery?.to ?? "",
@@ -499,8 +501,8 @@ function jobToForm(job: CronJob, prev: CronFormState): CronFormState {
failureAlertAccountId:
failureAlert && typeof failureAlert === "object" ? (failureAlert.accountId ?? "") : "",
timeoutSeconds:
job.payload.kind === "agentTurn" && typeof job.payload.timeoutSeconds === "number"
? String(job.payload.timeoutSeconds)
payload?.kind === "agentTurn" && typeof payload.timeoutSeconds === "number"
? String(payload.timeoutSeconds)
: "",
};
@@ -658,9 +660,10 @@ export async function addCronJob(state: CronState) {
const editingJob = state.cronEditingJobId
? state.cronJobs.find((job) => job.id === state.cronEditingJobId)
: undefined;
const editingPayload = editingJob ? getCronJobPayload(editingJob) : null;
if (payload.kind === "agentTurn") {
const existingLightContext =
editingJob?.payload.kind === "agentTurn" ? editingJob.payload.lightContext : undefined;
editingPayload?.kind === "agentTurn" ? editingPayload.lightContext : undefined;
if (
!form.payloadLightContext &&
state.cronEditingJobId &&

27
ui/src/ui/cron-payload.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { CronJob, CronPayload } from "./types.ts";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object");
}
export function isCronPayload(value: unknown): value is CronPayload {
if (!isRecord(value)) {
return false;
}
if (value.kind === "systemEvent") {
return typeof value.text === "string";
}
if (value.kind === "agentTurn") {
return typeof value.message === "string";
}
return false;
}
export function getCronJobPayload(job: CronJob): CronPayload | null {
const payload = (job as { payload?: unknown }).payload;
return isCronPayload(payload) ? payload : null;
}
export function hasCronJobPayload(job: CronJob): boolean {
return getCronJobPayload(job) !== null;
}

View File

@@ -299,6 +299,25 @@ describe("cron view", () => {
expect(container.textContent).toContain("https://example.invalid/cron");
});
it("does not throw when a stale cron job has no payload", () => {
const container = document.createElement("div");
const job = {
...createJob("job-broken"),
payload: undefined,
} as unknown as CronJob;
expect(() =>
render(
renderCron(
createProps({
jobs: [job],
}),
),
container,
),
).not.toThrow();
});
it("renders cron job prompts and run summaries as sanitized markdown", () => {
const container = document.createElement("div");
const onLoadRuns = vi.fn();

View File

@@ -8,6 +8,7 @@ import type {
CronJobsLastStatusFilter,
CronJobsScheduleKindFilter,
} from "../controllers/cron.ts";
import { getCronJobPayload } from "../cron-payload.ts";
import { formatRelativeTimestamp, formatMs } from "../format.ts";
import { toSanitizedMarkdownHtml } from "../markdown.ts";
import { pathForTab } from "../navigation.ts";
@@ -1580,10 +1581,14 @@ function renderJob(job: CronJob, props: CronProps) {
}
function renderJobPayload(job: CronJob) {
if (job.payload.kind === "systemEvent") {
const payload = getCronJobPayload(job);
if (!payload) {
return html``;
}
if (payload.kind === "systemEvent") {
return html`<div class="cron-job-detail">
<span class="cron-job-detail-label">${t("cron.jobDetail.system")}</span>
<span class="muted cron-job-detail-value">${job.payload.text}</span>
<span class="muted cron-job-detail-value">${payload.text}</span>
</div>`;
}
@@ -1602,7 +1607,7 @@ function renderJobPayload(job: CronJob) {
<div class="cron-job-detail-section">
<span class="cron-job-detail-label">${t("cron.jobDetail.prompt")}</span>
<div class="muted cron-job-detail-value chat-text" @click=${stopPropagationForInteractive}>
${unsafeHTML(toSanitizedMarkdownHtml(job.payload.message))}
${unsafeHTML(toSanitizedMarkdownHtml(payload.message))}
</div>
</div>
${delivery