mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
fix(cron): tolerate legacy flat schedule identity
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<string, unknown>, key: string): string | undefined {
|
||||
const value = record[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readNumber(record: Record<string, unknown>, key: string): number | undefined {
|
||||
const value = record[key];
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function schedulePayloadFromRecord(
|
||||
schedule: Record<string, unknown>,
|
||||
):
|
||||
| { 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<string, unknown>,
|
||||
): ReturnType<typeof schedulePayloadFromRecord> {
|
||||
if (job.schedule && typeof job.schedule === "object" && !Array.isArray(job.schedule)) {
|
||||
return schedulePayloadFromRecord(job.schedule as Record<string, unknown>);
|
||||
}
|
||||
return schedulePayloadFromRecord(job);
|
||||
}
|
||||
|
||||
export function cronScheduleIdentity(
|
||||
job: Pick<CronJob, "schedule"> & { enabled?: boolean },
|
||||
): string {
|
||||
const schedule = resolveSchedulePayload(job as unknown as Record<string, unknown>);
|
||||
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, unknown>,
|
||||
): string | undefined {
|
||||
const schedule = resolveSchedulePayload(job);
|
||||
if (!schedule) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
enabled: typeof job.enabled === "boolean" ? job.enabled : true,
|
||||
schedule,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown>),
|
||||
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<string, unknown>)
|
||||
) {
|
||||
ensureJobStateObject(job);
|
||||
job.state.nextRunAtMs = undefined;
|
||||
|
||||
Reference in New Issue
Block a user