Skip to content

Commit e0546ed

Browse files
authored
fix(cron): normalize flat legacy job rows
1 parent bbd6dfb commit e0546ed

4 files changed

Lines changed: 106 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
2323
- Google video generation: download direct MLDev Veo `video.uri` results instead of passing them through the Files API path, fixing 404s after successful generation/polling. Fixes #71200. Thanks @panhaishan.
2424
- Google video generation: fall back to the REST `predictLongRunning` Veo endpoint for text-only SDK 404s while keeping reference image/video generation on the SDK path. Fixes #62309 and #63008. (#62343) Thanks @leoleedev.
2525
- MiniMax music generation: switch the bundled default model from the unsupported `music-2.5+` id to the current `music-2.6` API model. Fixes #64870 and addresses the music default from #62315. Thanks @noahclanman and @edwardzheng1.
26+
- Cron: hydrate flat legacy job rows with top-level `cron`, `tz`, `session`, and `message` fields into canonical schedule, target, and payload objects before startup recomputes run times. Fixes #43351.
2627
- Google media generation: strip a configured trailing `/v1beta` from Google music/video provider base URLs before calling the Google GenAI SDK, preventing doubled `/v1beta/v1beta` paths. Fixes #63240. (#63258) Thanks @Hybirdss.
2728
- Discord: restore direct-message voice-note preflight transcription and classify URL-only Ogg/Opus voice attachments as audio while skipping partial attachments without usable URLs. Fixes #61314 and #64803.
2829
- Google Chat: preserve reply text when a typing indicator message is deleted or can no longer be updated, so media captions and first text chunks are resent instead of silently disappearing. (#71498) Thanks @colin-lgtm.

src/cron/normalize.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,38 @@ describe("normalizeCronJobCreate", () => {
405405
expect(typeof normalized.name).toBe("string");
406406
});
407407

408+
it("normalizes flat legacy cron job rows", () => {
409+
const normalized = normalizeCronJobCreate({
410+
id: "dbus-watchdog-001",
411+
name: "dbus-watchdog",
412+
kind: "cron",
413+
cron: "*/10 * * * *",
414+
tz: "UTC",
415+
session: "isolated",
416+
message: "watch dbus",
417+
tools: [" exec "],
418+
enabled: true,
419+
created_at: "2026-04-17T20:09:00Z",
420+
}) as unknown as Record<string, unknown>;
421+
422+
expect(normalized.schedule).toEqual({
423+
kind: "cron",
424+
expr: "*/10 * * * *",
425+
tz: "UTC",
426+
});
427+
expect(normalized.sessionTarget).toBe("isolated");
428+
expect(normalized.payload).toEqual({
429+
kind: "agentTurn",
430+
message: "watch dbus",
431+
toolsAllow: ["exec"],
432+
});
433+
expect(normalized.kind).toBeUndefined();
434+
expect(normalized.cron).toBeUndefined();
435+
expect(normalized.tz).toBeUndefined();
436+
expect(normalized.session).toBeUndefined();
437+
expect(normalized.tools).toBeUndefined();
438+
});
439+
408440
it("maps top-level model/thinking/timeout into payload for legacy add params", () => {
409441
const normalized = normalizeCronJobCreate({
410442
name: "legacy root fields",

src/cron/normalize.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,21 @@ function coerceSchedule(schedule: UnknownRecord) {
144144
return next;
145145
}
146146

147+
function inferTopLevelSchedule(next: UnknownRecord): UnknownRecord | null {
148+
const kindRaw = normalizeLowercaseStringOrEmpty(next.kind);
149+
const kind = kindRaw === "at" || kindRaw === "every" || kindRaw === "cron" ? kindRaw : undefined;
150+
const schedule: UnknownRecord = {};
151+
if (kind) {
152+
schedule.kind = kind;
153+
}
154+
for (const field of ["at", "atMs", "everyMs", "anchorMs", "expr", "cron", "tz", "staggerMs"]) {
155+
if (field in next) {
156+
schedule[field] = next[field];
157+
}
158+
}
159+
return Object.keys(schedule).length > 0 ? coerceSchedule(schedule) : null;
160+
}
161+
147162
function coercePayload(payload: UnknownRecord) {
148163
const next: UnknownRecord = { ...payload };
149164
const kindRaw = normalizeLowercaseStringOrEmpty(next.kind);
@@ -367,7 +382,9 @@ function copyTopLevelAgentTurnFields(next: UnknownRecord, payload: UnknownRecord
367382
}
368383
}
369384
if (!("toolsAllow" in payload) || payload.toolsAllow === undefined) {
370-
const toolsAllow = normalizeTrimmedStringArray(next.toolsAllow, { allowNull: true });
385+
const toolsAllow =
386+
normalizeTrimmedStringArray(next.toolsAllow, { allowNull: true }) ??
387+
normalizeTrimmedStringArray(next.tools);
371388
if (toolsAllow !== undefined) {
372389
payload.toolsAllow = toolsAllow;
373390
}
@@ -393,6 +410,16 @@ function stripLegacyTopLevelFields(next: UnknownRecord) {
393410
delete next.allowUnsafeExternalContent;
394411
delete next.message;
395412
delete next.text;
413+
delete next.kind;
414+
delete next.cron;
415+
delete next.tz;
416+
delete next.at;
417+
delete next.atMs;
418+
delete next.everyMs;
419+
delete next.anchorMs;
420+
delete next.staggerMs;
421+
delete next.session;
422+
delete next.tools;
396423
delete next.deliver;
397424
delete next.channel;
398425
delete next.to;
@@ -462,6 +489,11 @@ export function normalizeCronJobInput(
462489
} else {
463490
delete next.sessionTarget;
464491
}
492+
} else if ("session" in base) {
493+
const normalized = normalizeSessionTarget(base.session);
494+
if (normalized) {
495+
next.sessionTarget = normalized;
496+
}
465497
}
466498

467499
if ("wakeMode" in base) {
@@ -475,6 +507,11 @@ export function normalizeCronJobInput(
475507

476508
if (isRecord(base.schedule)) {
477509
next.schedule = coerceSchedule(base.schedule);
510+
} else if (!isRecord(next.schedule)) {
511+
const inferredSchedule = inferTopLevelSchedule(next);
512+
if (inferredSchedule) {
513+
next.schedule = inferredSchedule;
514+
}
478515
}
479516

480517
if (!("payload" in next) || !isRecord(next.payload)) {

src/cron/service/store.load-missing-session-target.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,41 @@ function createStoreTestState(storePath: string) {
3030
}
3131

3232
describe("cron service store load: missing sessionTarget", () => {
33+
it("hydrates flat legacy cron rows before recomputing next runs", async () => {
34+
const { storePath } = await makeStorePath();
35+
36+
await writeSingleJobStore(storePath, {
37+
id: "legacy-flat-cron",
38+
name: "dbus-watchdog",
39+
kind: "cron",
40+
cron: "*/10 * * * *",
41+
tz: "UTC",
42+
session: "isolated",
43+
message: "watch dbus",
44+
tools: ["exec"],
45+
enabled: true,
46+
created_at: "2026-04-17T20:09:00Z",
47+
});
48+
49+
const state = createStoreTestState(storePath);
50+
await ensureLoaded(state);
51+
52+
const job = findJobOrThrow(state, "legacy-flat-cron");
53+
expect(job.schedule).toEqual({
54+
kind: "cron",
55+
expr: "*/10 * * * *",
56+
tz: "UTC",
57+
});
58+
expect(job.sessionTarget).toBe("isolated");
59+
expect(job.payload).toEqual({
60+
kind: "agentTurn",
61+
message: "watch dbus",
62+
toolsAllow: ["exec"],
63+
});
64+
expect(job.state.nextRunAtMs).toEqual(expect.any(Number));
65+
expect(() => assertSupportedJobSpec(job)).not.toThrow();
66+
});
67+
3368
it('defaults missing sessionTarget to "main" for systemEvent payloads', async () => {
3469
const { storePath } = await makeStorePath();
3570

0 commit comments

Comments
 (0)