diff --git a/CHANGELOG.md b/CHANGELOG.md index b7c9b9b3752..d501bd8c872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - Discord: preserve explicit `user:` and `channel:` delivery targets through plugin routing so cron announcements and failure alerts keep their intended recipient kind. Refs #62777; carries forward #62798. Thanks @neeravmakwana. - Cron: add `failureAlert.includeSkipped` and `openclaw cron edit --failure-alert-include-skipped` so persistently skipped jobs can alert without counting skips as execution errors or affecting retry backoff. Fixes #60846. Thanks @slideshow-dingo. - Cron: invalidate stale pending runtime slots after live or offline `jobs.json` schedule edits, while preserving due slots for formatting-only rewrites. Fixes #27996 and #71607; carries forward #71651. Thanks @xialonglee and @fagnersouza666. +- Cron: keep legacy flat `jobs.json` rows loadable while comparing split-state schedule identities, so old cron stores do not crash before in-memory hydration can normalize them. Thanks @codex. - Cron: omit synthetic `delivery.resolved` errors from `--no-deliver` run records while preserving explicit no-deliver target traces for agent-initiated messages. Fixes #72210; carries forward #72219. Thanks @hatemclawbot-collab and @xydigit-sj. - Cron: classify isolated runs as errors from structured embedded-run execution-denial metadata, with final-output marker fallback for `SYSTEM_RUN_DENIED`, `INVALID_REQUEST`, and approval-binding refusals, so blocked commands no longer appear green in cron history. Fixes #67172; carries forward #67186. Thanks @oc-gh-dr, @hclsys, and @1yihui. - Onboarding/GitHub Copilot: add manifest-owned `--github-copilot-token` support for non-interactive setup, including env fallback, tokenRef storage in ref mode, saved-profile reuse, and current Copilot default-model wiring. Refs #50002 and supersedes #50003. Thanks @scottgl9. diff --git a/src/cron/schedule-identity.ts b/src/cron/schedule-identity.ts index 2e913e3d9e0..519f21f8ade 100644 --- a/src/cron/schedule-identity.ts +++ b/src/cron/schedule-identity.ts @@ -1,34 +1,91 @@ -import type { CronJob, CronSchedule } from "./types.js"; +import type { CronJob } from "./types.js"; -function schedulePayload( - schedule: CronSchedule, +function readString(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readNumber(record: Record, key: string): number | undefined { + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function schedulePayloadFromRecord( + schedule: Record, ): | { kind: "at"; at: string } | { kind: "every"; everyMs: number; anchorMs?: number } - | { kind: "cron"; expr: string; tz?: string; staggerMs?: number } { - switch (schedule.kind) { - case "at": - return { kind: "at", at: schedule.at }; - case "every": - return { kind: "every", everyMs: schedule.everyMs, anchorMs: schedule.anchorMs }; - case "cron": - return { - kind: "cron", - expr: schedule.expr, - tz: schedule.tz, - staggerMs: schedule.staggerMs, - }; + | { kind: "cron"; expr: string; tz?: string; staggerMs?: number } + | undefined { + const rawKind = readString(schedule, "kind")?.toLowerCase(); + const expr = readString(schedule, "expr") ?? readString(schedule, "cron"); + const at = readString(schedule, "at"); + const atMs = readNumber(schedule, "atMs"); + const everyMs = readNumber(schedule, "everyMs"); + const anchorMs = readNumber(schedule, "anchorMs"); + const tz = readString(schedule, "tz"); + const staggerMs = readNumber(schedule, "staggerMs"); + const kind = + rawKind === "at" || rawKind === "every" || rawKind === "cron" + ? rawKind + : at || atMs !== undefined + ? "at" + : everyMs !== undefined + ? "every" + : expr + ? "cron" + : undefined; + + if (kind === "at") { + return at + ? { kind: "at", at } + : atMs !== undefined + ? { kind: "at", at: String(atMs) } + : undefined; } - throw new Error("Unsupported cron schedule kind"); + if (kind === "every" && everyMs !== undefined) { + return { kind: "every", everyMs, anchorMs }; + } + if (kind === "cron" && expr) { + return { kind: "cron", expr, tz, staggerMs }; + } + return undefined; +} + +function resolveSchedulePayload( + job: { schedule?: unknown } & Record, +): ReturnType { + if (job.schedule && typeof job.schedule === "object" && !Array.isArray(job.schedule)) { + return schedulePayloadFromRecord(job.schedule as Record); + } + return schedulePayloadFromRecord(job); } export function cronScheduleIdentity( job: Pick & { enabled?: boolean }, ): string { + const schedule = resolveSchedulePayload(job as unknown as Record); + if (!schedule) { + throw new Error("Unsupported cron schedule kind"); + } return JSON.stringify({ version: 1, enabled: job.enabled ?? true, - schedule: schedulePayload(job.schedule), + schedule, + }); +} + +export function tryCronScheduleIdentity( + job: { schedule?: unknown; enabled?: unknown } & Record, +): string | undefined { + const schedule = resolveSchedulePayload(job); + if (!schedule) { + return undefined; + } + return JSON.stringify({ + version: 1, + enabled: typeof job.enabled === "boolean" ? job.enabled : true, + schedule, }); } diff --git a/src/cron/store.test.ts b/src/cron/store.test.ts index 38a863e2552..633c338027d 100644 --- a/src/cron/store.test.ts +++ b/src/cron/store.test.ts @@ -138,6 +138,59 @@ describe("cron store", () => { }); }); + it("compares split state identity for flat legacy cron rows", async () => { + const { storePath } = await makeStorePath(); + const statePath = storePath.replace(/\.json$/, "-state.json"); + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile( + storePath, + JSON.stringify( + { + version: 1, + jobs: [ + { + id: "legacy-flat-cron", + name: "legacy flat cron", + enabled: true, + kind: "cron", + cron: "*/10 * * * *", + tz: "UTC", + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + await fs.writeFile( + statePath, + JSON.stringify( + { + version: 1, + jobs: { + "legacy-flat-cron": { + updatedAtMs: 1, + scheduleIdentity: JSON.stringify({ + version: 1, + enabled: true, + schedule: { kind: "cron", expr: "0 * * * *", tz: "UTC" }, + }), + state: { nextRunAtMs: 123 }, + }, + }, + }, + null, + 2, + ), + "utf-8", + ); + + const loaded = await loadCronStore(storePath); + + expect(loaded.jobs[0]?.state.nextRunAtMs).toBeUndefined(); + }); + it("does not create a backup file when saving unchanged content", async () => { const store = await makeStorePath(); const payload = makeStore("job-1", true); diff --git a/src/cron/store.ts b/src/cron/store.ts index ee9f4c9cc19..a201ff01b97 100644 --- a/src/cron/store.ts +++ b/src/cron/store.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { expandHomePrefix } from "../infra/home-dir.js"; import { resolveConfigDir } from "../utils.js"; import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js"; -import { cronScheduleIdentity } from "./schedule-identity.js"; +import { tryCronScheduleIdentity } from "./schedule-identity.js"; import type { CronStoreFile } from "./types.js"; type SerializedStoreCacheEntry = { @@ -65,7 +65,7 @@ function extractStateFile(store: CronStoreFile): CronStateFile { for (const job of store.jobs) { jobs[job.id] = { updatedAtMs: job.updatedAtMs, - scheduleIdentity: cronScheduleIdentity(job), + scheduleIdentity: tryCronScheduleIdentity(job as unknown as Record), state: job.state ?? {}, }; } @@ -191,7 +191,7 @@ function mergeStateFileEntry(job: CronStoreFile["jobs"][number], entry: CronStat job.state = (entry.state ?? {}) as never; if ( typeof entry.scheduleIdentity === "string" && - entry.scheduleIdentity !== cronScheduleIdentity(job) + entry.scheduleIdentity !== tryCronScheduleIdentity(job as unknown as Record) ) { ensureJobStateObject(job); job.state.nextRunAtMs = undefined;