mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
27
ui/src/ui/cron-payload.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user