diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 8bdcb5a08a7..4a8af8e498a 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -64,7 +64,7 @@ import { stopWorkboardLifecycleRefresh, stopWorkboardPolling, } from "./controllers/workboard.ts"; -import { resolveCronJobLastRunStatus } from "./cron-status.ts"; +import { isCronJobActiveFailure } from "./cron-status.ts"; import { syncCustomThemeStyleTag } from "./custom-theme.ts"; import { isMonitoredAuthProvider } from "./model-auth-helpers.ts"; import { @@ -941,7 +941,7 @@ function buildAttentionItems(host: SettingsAppHost) { } const cronJobs = host.cronJobs ?? []; - const failedCron = cronJobs.filter((j) => resolveCronJobLastRunStatus(j) === "error"); + const failedCron = cronJobs.filter(isCronJobActiveFailure); if (failedCron.length > 0) { items.push({ severity: "error", diff --git a/ui/src/ui/cron-status.test.ts b/ui/src/ui/cron-status.test.ts new file mode 100644 index 00000000000..018ae4668b0 --- /dev/null +++ b/ui/src/ui/cron-status.test.ts @@ -0,0 +1,40 @@ +// Control UI tests cover cron status derivation behavior. +import { describe, expect, it } from "vitest"; +import { isCronJobActiveFailure, resolveCronJobLastRunStatus } from "./cron-status.ts"; +import type { CronJob } from "./types.ts"; + +function job(overrides: Partial = {}): CronJob { + return { + id: "job", + name: "Job", + enabled: true, + createdAtMs: 0, + updatedAtMs: 0, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "test" }, + ...overrides, + }; +} + +describe("isCronJobActiveFailure", () => { + it("counts an enabled job whose last run errored", () => { + expect(isCronJobActiveFailure(job({ state: { lastRunStatus: "error" } }))).toBe(true); + }); + + it("ignores a disabled job that retains historical error state", () => { + const disabled = job({ + enabled: false, + state: { lastRunStatus: "error", consecutiveErrors: 6, nextRunAtMs: undefined }, + }); + // Historical status is still preserved for detail views. + expect(resolveCronJobLastRunStatus(disabled)).toBe("error"); + expect(isCronJobActiveFailure(disabled)).toBe(false); + }); + + it("does not count enabled jobs whose last run succeeded or is unknown", () => { + expect(isCronJobActiveFailure(job({ state: { lastRunStatus: "ok" } }))).toBe(false); + expect(isCronJobActiveFailure(job())).toBe(false); + }); +}); diff --git a/ui/src/ui/cron-status.ts b/ui/src/ui/cron-status.ts index f6b17bf571b..616b83cb906 100644 --- a/ui/src/ui/cron-status.ts +++ b/ui/src/ui/cron-status.ts @@ -6,3 +6,11 @@ export type CronJobLastRunStatus = CronRunStatus | "unknown"; export function resolveCronJobLastRunStatus(job: CronJob): CronJobLastRunStatus { return job.state?.lastRunStatus ?? job.state?.lastStatus ?? "unknown"; } + +// Overview "failed cron" surfaces track current actionability, so a failure only +// counts while the job is still enabled. Disabled jobs keep their historical +// `lastRunStatus: "error"` for detail views, but a retired job must not be +// reported as an active operational problem. +export function isCronJobActiveFailure(job: CronJob): boolean { + return job.enabled && resolveCronJobLastRunStatus(job) === "error"; +} diff --git a/ui/src/ui/views/overview-cards.ts b/ui/src/ui/views/overview-cards.ts index 4a1c99f59de..1b38e875a13 100644 --- a/ui/src/ui/views/overview-cards.ts +++ b/ui/src/ui/views/overview-cards.ts @@ -3,7 +3,7 @@ import { asDateTimestampMs } from "@openclaw/normalization-core/number-coercion" import { html, nothing, type TemplateResult } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { t } from "../../i18n/index.ts"; -import { resolveCronJobLastRunStatus } from "../cron-status.ts"; +import { isCronJobActiveFailure } from "../cron-status.ts"; import { formatCost, formatTokens, formatRelativeTimestamp } from "../format.ts"; import { isMonitoredAuthProvider } from "../model-auth-helpers.ts"; import { formatNextRun } from "../presenter.ts"; @@ -134,9 +134,7 @@ export function renderOverviewCards(props: OverviewCardsProps) { const cronEnabled = props.cronStatus?.enabled ?? null; const cronNext = props.cronStatus?.nextWakeAtMs ?? null; const cronJobCount = props.cronJobs.length; - const failedCronCount = props.cronJobs.filter( - (j) => resolveCronJobLastRunStatus(j) === "error", - ).length; + const failedCronCount = props.cronJobs.filter(isCronJobActiveFailure).length; const authLoading = props.modelAuthStatus === null; const authProviders = props.modelAuthStatus?.providers ?? []; const monitoredProviders = authProviders.filter(isMonitoredAuthProvider);