diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b739a7a3ad..aab93b338d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/ui/src/ui/app-render.assistant-avatar.test.ts b/ui/src/ui/app-render.assistant-avatar.test.ts index 4115b496aea..b0fc51d86e7 100644 --- a/ui/src/ui/app-render.assistant-avatar.test.ts +++ b/ui/src/ui/app-render.assistant-avatar.test.ts @@ -240,4 +240,26 @@ describe("renderApp assistant avatar routing", () => { const shell = container.querySelector(".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(); + }); }); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index ed2d8f96ef2..720f881cc94 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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), diff --git a/ui/src/ui/controllers/cron.test.ts b/ui/src/ui/controllers/cron.test.ts index fe359283290..56f47e39fc9 100644 --- a/ui/src/ui/controllers/cron.test.ts +++ b/ui/src/ui/controllers/cron.test.ts @@ -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") { diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index 93e493c99b7..141b22d6801 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -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 && diff --git a/ui/src/ui/cron-payload.ts b/ui/src/ui/cron-payload.ts new file mode 100644 index 00000000000..75d292c1105 --- /dev/null +++ b/ui/src/ui/cron-payload.ts @@ -0,0 +1,27 @@ +import type { CronJob, CronPayload } from "./types.ts"; + +function isRecord(value: unknown): value is Record { + 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; +} diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index 1bb6595aecd..51aff0ee793 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -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(); diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index 38c0c3684db..73e932c9e6c 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -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`
${t("cron.jobDetail.system")} - ${job.payload.text} + ${payload.text}
`; } @@ -1602,7 +1607,7 @@ function renderJobPayload(job: CronJob) {
${t("cron.jobDetail.prompt")}
- ${unsafeHTML(toSanitizedMarkdownHtml(job.payload.message))} + ${unsafeHTML(toSanitizedMarkdownHtml(payload.message))}
${delivery