mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
refactor(cron): keep runtime on canonical sqlite rows
This commit is contained in:
@@ -41,8 +41,8 @@ Cron is the Gateway's built-in scheduler. It persists jobs, wakes the agent at t
|
|||||||
|
|
||||||
- Cron runs **inside the Gateway** process (not inside the model).
|
- Cron runs **inside the Gateway** process (not inside the model).
|
||||||
- Job definitions, runtime state, and run history persist in OpenClaw's shared SQLite state database so restarts do not lose schedules.
|
- Job definitions, runtime state, and run history persist in OpenClaw's shared SQLite state database so restarts do not lose schedules.
|
||||||
- On upgrade, legacy `~/.openclaw/cron/jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files are imported once and renamed with a `.migrated` suffix. Malformed job rows are skipped from runtime and copied to `jobs-quarantine.json` for later repair or review.
|
- On upgrade, run `openclaw doctor --fix` to import legacy `~/.openclaw/cron/jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files into SQLite and rename them with a `.migrated` suffix. Malformed job rows are skipped from runtime and copied to `jobs-quarantine.json` for later repair or review.
|
||||||
- `cron.store` still names the logical cron store key and legacy import path. After import, editing that JSON file no longer changes active cron jobs; use `openclaw cron add|edit|remove` or the Gateway cron RPC methods instead.
|
- `cron.store` still names the logical cron store key and doctor import path. After import, editing that JSON file no longer changes active cron jobs; use `openclaw cron add|edit|remove` or the Gateway cron RPC methods instead.
|
||||||
- All cron executions create [background task](/automation/tasks) records.
|
- All cron executions create [background task](/automation/tasks) records.
|
||||||
- On Gateway startup, overdue isolated agent-turn jobs are rescheduled out of the channel-connect window instead of replaying immediately, so Discord/Telegram startup and native-command setup stay responsive after restarts.
|
- On Gateway startup, overdue isolated agent-turn jobs are rescheduled out of the channel-connect window instead of replaying immediately, so Discord/Telegram startup and native-command setup stay responsive after restarts.
|
||||||
- One-shot jobs (`--at`) auto-delete after success by default.
|
- One-shot jobs (`--at`) auto-delete after success by default.
|
||||||
@@ -460,7 +460,7 @@ Model override note:
|
|||||||
|
|
||||||
`maxConcurrentRuns` limits both scheduled cron dispatch and isolated agent-turn execution, and defaults to 8. Isolated cron agent turns use the queue's dedicated `cron-nested` execution lane internally, so raising this value lets independent cron LLM runs progress in parallel instead of only starting their outer cron wrappers. The shared non-cron `nested` lane is not widened by this setting.
|
`maxConcurrentRuns` limits both scheduled cron dispatch and isolated agent-turn execution, and defaults to 8. Isolated cron agent turns use the queue's dedicated `cron-nested` execution lane internally, so raising this value lets independent cron LLM runs progress in parallel instead of only starting their outer cron wrappers. The shared non-cron `nested` lane is not widened by this setting.
|
||||||
|
|
||||||
`cron.store` is a logical store key and legacy import path. Existing stores are imported into SQLite on first load and archived; future cron changes should go through the CLI or Gateway API.
|
`cron.store` is a logical store key and legacy doctor import path. Run `openclaw doctor --fix` to import existing JSON stores into SQLite and archive them; future cron changes should go through the CLI or Gateway API.
|
||||||
|
|
||||||
Disable cron: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
|
Disable cron: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
|
||||||
|
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ Notes:
|
|||||||
- Modernized health checks can expose a `repair()` path for `doctor --fix`; checks that do not expose one continue through the existing doctor repair flow.
|
- Modernized health checks can expose a `repair()` path for `doctor --fix`; checks that do not expose one continue through the existing doctor repair flow.
|
||||||
- `doctor --fix --non-interactive` reports missing or stale gateway service definitions but does not install or rewrite them outside update repair mode. Run `openclaw gateway install` for a missing service, or `openclaw gateway install --force` when you intentionally want to replace the launcher.
|
- `doctor --fix --non-interactive` reports missing or stale gateway service definitions but does not install or rewrite them outside update repair mode. Run `openclaw gateway install` for a missing service, or `openclaw gateway install --force` when you intentionally want to replace the launcher.
|
||||||
- State integrity checks now detect orphan transcript files in the sessions directory. Archiving them as `.deleted.<timestamp>` requires an interactive confirmation; `--fix`, `--yes`, and headless runs leave them in place.
|
- State integrity checks now detect orphan transcript files in the sessions directory. Archiving them as `.deleted.<timestamp>` requires an interactive confirmation; `--fix`, `--yes`, and headless runs leave them in place.
|
||||||
- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime.
|
- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and rewrites them before importing canonical rows into SQLite.
|
||||||
- Doctor reports cron jobs with explicit `payload.model` overrides, including provider namespace counts and mismatches against `agents.defaults.model`, so scheduled jobs that do not inherit the default model are visible during auth or billing investigations.
|
- Doctor reports cron jobs with explicit `payload.model` overrides, including provider namespace counts and mismatches against `agents.defaults.model`, so scheduled jobs that do not inherit the default model are visible during auth or billing investigations.
|
||||||
- On Linux, doctor warns when the user's crontab still runs legacy `~/.openclaw/bin/ensure-whatsapp.sh`; that script is no longer maintained and can log false WhatsApp gateway outages when cron lacks the systemd user-bus environment.
|
- On Linux, doctor warns when the user's crontab still runs legacy `~/.openclaw/bin/ensure-whatsapp.sh`; that script is no longer maintained and can log false WhatsApp gateway outages when cron lacks the systemd user-bus environment.
|
||||||
- When WhatsApp is enabled, doctor checks for a degraded Gateway event loop with local `openclaw-tui` clients still running. `doctor --fix` stops only verified local TUI clients so WhatsApp replies are not queued behind stale TUI refresh loops.
|
- When WhatsApp is enabled, doctor checks for a degraded Gateway event loop with local `openclaw-tui` clients still running. `doctor --fix` stops only verified local TUI clients so WhatsApp replies are not queued behind stale TUI refresh loops.
|
||||||
|
|||||||
@@ -94,10 +94,195 @@ const REMINDER_CONTEXT_PER_MESSAGE_MAX = 220;
|
|||||||
const REMINDER_CONTEXT_TOTAL_MAX = 700;
|
const REMINDER_CONTEXT_TOTAL_MAX = 700;
|
||||||
const REMINDER_CONTEXT_MARKER = "\n\nRecent context:\n";
|
const REMINDER_CONTEXT_MARKER = "\n\nRecent context:\n";
|
||||||
|
|
||||||
|
function isCronScheduleKind(value: unknown): value is (typeof CRON_SCHEDULE_KINDS)[number] {
|
||||||
|
return value === "at" || value === "every" || value === "cron";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCronPayloadKind(value: unknown): value is (typeof CRON_PAYLOAD_KINDS)[number] {
|
||||||
|
return value === "systemEvent" || value === "agentTurn";
|
||||||
|
}
|
||||||
|
|
||||||
function isMissingOrEmptyObject(value: unknown): boolean {
|
function isMissingOrEmptyObject(value: unknown): boolean {
|
||||||
return !value || (isRecord(value) && Object.keys(value).length === 0);
|
return !value || (isRecord(value) && Object.keys(value).length === 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isNonEmptyString(value: unknown): value is string {
|
||||||
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStringArrayOrNull(value: unknown): boolean {
|
||||||
|
return (
|
||||||
|
value === null || (Array.isArray(value) && value.every((entry) => typeof entry === "string"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveDefinedField(params: {
|
||||||
|
source: Record<string, unknown>;
|
||||||
|
target: Record<string, unknown>;
|
||||||
|
from: string;
|
||||||
|
to?: string;
|
||||||
|
}): boolean {
|
||||||
|
if (params.source[params.from] === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
params.target[params.to ?? params.from] = params.source[params.from];
|
||||||
|
delete params.source[params.from];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScheduleAtMs(schedule: Record<string, unknown>, value: unknown): void {
|
||||||
|
const atMs = typeof value === "number" ? value : Number(value);
|
||||||
|
schedule.at = Number.isFinite(atMs) ? new Date(Math.floor(atMs)).toISOString() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalizeCronToolSchedule(value: Record<string, unknown>): void {
|
||||||
|
const schedule = isRecord(value.schedule) ? { ...value.schedule } : {};
|
||||||
|
let hasSchedule = isRecord(value.schedule);
|
||||||
|
|
||||||
|
if (schedule.atMs !== undefined) {
|
||||||
|
setScheduleAtMs(schedule, schedule.atMs);
|
||||||
|
delete schedule.atMs;
|
||||||
|
if (!isCronScheduleKind(schedule.kind)) {
|
||||||
|
schedule.kind = "at";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (schedule.everyMs === undefined && schedule.every !== undefined) {
|
||||||
|
schedule.everyMs = schedule.every;
|
||||||
|
delete schedule.every;
|
||||||
|
}
|
||||||
|
if (schedule.expr === undefined && schedule.cron !== undefined) {
|
||||||
|
schedule.expr = schedule.cron;
|
||||||
|
delete schedule.cron;
|
||||||
|
}
|
||||||
|
if (schedule.staggerMs === undefined && schedule.stagger !== undefined) {
|
||||||
|
schedule.staggerMs = schedule.stagger;
|
||||||
|
delete schedule.stagger;
|
||||||
|
}
|
||||||
|
if (schedule.exact === true && schedule.staggerMs === undefined) {
|
||||||
|
schedule.staggerMs = 0;
|
||||||
|
}
|
||||||
|
delete schedule.exact;
|
||||||
|
|
||||||
|
if (isCronScheduleKind(value.kind) && !isCronScheduleKind(schedule.kind)) {
|
||||||
|
schedule.kind = value.kind;
|
||||||
|
delete value.kind;
|
||||||
|
hasSchedule = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const movedAt = moveDefinedField({ source: value, target: schedule, from: "at" });
|
||||||
|
if (movedAt && !isCronScheduleKind(schedule.kind)) {
|
||||||
|
schedule.kind = "at";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.atMs !== undefined) {
|
||||||
|
setScheduleAtMs(schedule, value.atMs);
|
||||||
|
delete value.atMs;
|
||||||
|
if (!isCronScheduleKind(schedule.kind)) {
|
||||||
|
schedule.kind = "at";
|
||||||
|
}
|
||||||
|
hasSchedule = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const movedEveryMs =
|
||||||
|
moveDefinedField({ source: value, target: schedule, from: "everyMs" }) ||
|
||||||
|
moveDefinedField({ source: value, target: schedule, from: "every", to: "everyMs" });
|
||||||
|
if (movedEveryMs && !isCronScheduleKind(schedule.kind)) {
|
||||||
|
schedule.kind = "every";
|
||||||
|
}
|
||||||
|
|
||||||
|
const movedCron =
|
||||||
|
moveDefinedField({ source: value, target: schedule, from: "cron", to: "expr" }) ||
|
||||||
|
moveDefinedField({ source: value, target: schedule, from: "expr" });
|
||||||
|
if (movedCron && !isCronScheduleKind(schedule.kind)) {
|
||||||
|
schedule.kind = "cron";
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of ["anchorMs", "tz", "staggerMs"] as const) {
|
||||||
|
hasSchedule = moveDefinedField({ source: value, target: schedule, from: key }) || hasSchedule;
|
||||||
|
}
|
||||||
|
hasSchedule =
|
||||||
|
moveDefinedField({ source: value, target: schedule, from: "stagger", to: "staggerMs" }) ||
|
||||||
|
hasSchedule;
|
||||||
|
|
||||||
|
if (value.exact === true && schedule.staggerMs === undefined) {
|
||||||
|
schedule.staggerMs = 0;
|
||||||
|
hasSchedule = true;
|
||||||
|
}
|
||||||
|
delete value.exact;
|
||||||
|
|
||||||
|
if (!isCronScheduleKind(schedule.kind)) {
|
||||||
|
if (schedule.at !== undefined) {
|
||||||
|
schedule.kind = "at";
|
||||||
|
} else if (schedule.everyMs !== undefined) {
|
||||||
|
schedule.kind = "every";
|
||||||
|
} else if (schedule.expr !== undefined) {
|
||||||
|
schedule.kind = "cron";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSchedule || Object.keys(schedule).length > 0) {
|
||||||
|
value.schedule = schedule;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalizeCronToolPayload(value: Record<string, unknown>): void {
|
||||||
|
const payload = isRecord(value.payload) ? { ...value.payload } : {};
|
||||||
|
let hasPayload = isRecord(value.payload);
|
||||||
|
|
||||||
|
for (const key of CRON_FLAT_PAYLOAD_KEYS) {
|
||||||
|
hasPayload = moveDefinedField({ source: value, target: payload, from: key }) || hasPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCronPayloadKind(value.kind) && !isCronPayloadKind(payload.kind)) {
|
||||||
|
payload.kind = value.kind;
|
||||||
|
delete value.kind;
|
||||||
|
hasPayload = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCronPayloadKind(payload.kind)) {
|
||||||
|
const hasAgentTurnSignal =
|
||||||
|
isNonEmptyString(payload.message) ||
|
||||||
|
isNonEmptyString(payload.model) ||
|
||||||
|
isNonEmptyString(payload.thinking) ||
|
||||||
|
typeof payload.timeoutSeconds === "number" ||
|
||||||
|
typeof payload.lightContext === "boolean" ||
|
||||||
|
typeof payload.allowUnsafeExternalContent === "boolean" ||
|
||||||
|
(payload.fallbacks !== undefined && isStringArrayOrNull(payload.fallbacks)) ||
|
||||||
|
(payload.toolsAllow !== undefined && isStringArrayOrNull(payload.toolsAllow));
|
||||||
|
if (hasAgentTurnSignal) {
|
||||||
|
payload.kind = "agentTurn";
|
||||||
|
} else if (isNonEmptyString(payload.text)) {
|
||||||
|
payload.kind = "systemEvent";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPayload || Object.keys(payload).length > 0) {
|
||||||
|
value.payload = payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalizeCronToolObject(value: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const unwrapped = isRecord(value.data) ? value.data : isRecord(value.job) ? value.job : value;
|
||||||
|
const next = { ...unwrapped };
|
||||||
|
canonicalizeCronToolSchedule(next);
|
||||||
|
canonicalizeCronToolPayload(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmptyRecoveredCronPatch(value: unknown): boolean {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const keys = Object.keys(value);
|
||||||
|
return (
|
||||||
|
keys.length === 0 ||
|
||||||
|
(keys.length === 1 &&
|
||||||
|
keys[0] === "payload" &&
|
||||||
|
isRecord(value.payload) &&
|
||||||
|
Object.keys(value.payload).length === 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function recoverCronObjectFromFlatParams(params: Record<string, unknown>): {
|
function recoverCronObjectFromFlatParams(params: Record<string, unknown>): {
|
||||||
found: boolean;
|
found: boolean;
|
||||||
value: Record<string, unknown>;
|
value: Record<string, unknown>;
|
||||||
@@ -110,19 +295,7 @@ function recoverCronObjectFromFlatParams(params: Record<string, unknown>): {
|
|||||||
found = true;
|
found = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (value.everyMs === undefined && value.every !== undefined) {
|
return { found, value: canonicalizeCronToolObject(value) };
|
||||||
value.everyMs = value.every;
|
|
||||||
}
|
|
||||||
if (value.staggerMs === undefined && value.stagger !== undefined) {
|
|
||||||
value.staggerMs = value.stagger;
|
|
||||||
}
|
|
||||||
if (value.exact === true && value.staggerMs === undefined) {
|
|
||||||
value.staggerMs = 0;
|
|
||||||
}
|
|
||||||
delete value.every;
|
|
||||||
delete value.stagger;
|
|
||||||
delete value.exact;
|
|
||||||
return { found, value };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasCronCreateSignal(value: Record<string, unknown>): boolean {
|
function hasCronCreateSignal(value: Record<string, unknown>): boolean {
|
||||||
@@ -662,10 +835,11 @@ Use jobId canonical; id accepted compat. contextMessages (0-10) adds previous me
|
|||||||
if (!params.job || typeof params.job !== "object") {
|
if (!params.job || typeof params.job !== "object") {
|
||||||
throw new Error("job required");
|
throw new Error("job required");
|
||||||
}
|
}
|
||||||
|
const canonicalJob = canonicalizeCronToolObject(params.job as Record<string, unknown>);
|
||||||
const job =
|
const job =
|
||||||
normalizeCronJobCreate(params.job, {
|
normalizeCronJobCreate(canonicalJob, {
|
||||||
sessionContext: { sessionKey: opts?.agentSessionKey },
|
sessionContext: { sessionKey: opts?.agentSessionKey },
|
||||||
}) ?? params.job;
|
}) ?? canonicalJob;
|
||||||
const cfg = getRuntimeConfig();
|
const cfg = getRuntimeConfig();
|
||||||
if (job && typeof job === "object") {
|
if (job && typeof job === "object") {
|
||||||
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
||||||
@@ -775,13 +949,11 @@ Use jobId canonical; id accepted compat. contextMessages (0-10) adds previous me
|
|||||||
if (!params.patch || typeof params.patch !== "object") {
|
if (!params.patch || typeof params.patch !== "object") {
|
||||||
throw new Error("patch required");
|
throw new Error("patch required");
|
||||||
}
|
}
|
||||||
const patch = normalizeCronJobPatch(params.patch) ?? params.patch;
|
const canonicalPatch = canonicalizeCronToolObject(
|
||||||
if (
|
params.patch as Record<string, unknown>,
|
||||||
recoveredFlatPatch &&
|
);
|
||||||
typeof patch === "object" &&
|
const patch = normalizeCronJobPatch(canonicalPatch) ?? canonicalPatch;
|
||||||
patch !== null &&
|
if (recoveredFlatPatch && isEmptyRecoveredCronPatch(patch)) {
|
||||||
Object.keys(patch as Record<string, unknown>).length === 0
|
|
||||||
) {
|
|
||||||
throw new Error("patch required");
|
throw new Error("patch required");
|
||||||
}
|
}
|
||||||
return jsonResult(
|
return jsonResult(
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import path from "node:path";
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { OpenClawConfig } from "../../../config/config.js";
|
import type { OpenClawConfig } from "../../../config/config.js";
|
||||||
import { readCronRunLogEntriesSync } from "../../../cron/run-log.js";
|
import { readCronRunLogEntriesSync } from "../../../cron/run-log.js";
|
||||||
import { loadCronStore, resolveCronQuarantinePath, saveCronStore } from "../../../cron/store.js";
|
import {
|
||||||
|
loadCronQuarantineFile,
|
||||||
|
loadCronStore,
|
||||||
|
resolveCronQuarantinePath,
|
||||||
|
saveCronStore,
|
||||||
|
} from "../../../cron/store.js";
|
||||||
|
import { runOpenClawStateWriteTransaction } from "../../../state/openclaw-state-db.js";
|
||||||
import {
|
import {
|
||||||
collectLegacyWhatsAppCrontabHealthWarning,
|
collectLegacyWhatsAppCrontabHealthWarning,
|
||||||
maybeRepairLegacyCronStore,
|
maybeRepairLegacyCronStore,
|
||||||
@@ -108,6 +114,43 @@ async function writeCurrentCronStore(storePath: string, jobs: Array<Record<strin
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function insertEarlySQLiteCronRow(
|
||||||
|
storePath: string,
|
||||||
|
job: Record<string, unknown>,
|
||||||
|
options: { payloadMessage?: string | null } = {},
|
||||||
|
) {
|
||||||
|
const schedule = requireRecord(job.schedule, "cron schedule");
|
||||||
|
const payload = requireRecord(job.payload, "cron payload");
|
||||||
|
runOpenClawStateWriteTransaction(({ db }) => {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO cron_jobs (
|
||||||
|
store_key, job_id, name, enabled, created_at_ms, updated_at,
|
||||||
|
schedule_kind, every_ms, session_target, wake_mode, payload_kind, payload_message,
|
||||||
|
job_json, state_json
|
||||||
|
) VALUES (
|
||||||
|
$storeKey, $jobId, $name, $enabled, $createdAtMs, $updatedAt,
|
||||||
|
$scheduleKind, $everyMs, $sessionTarget, $wakeMode, $payloadKind, $payloadMessage,
|
||||||
|
$jobJson, $stateJson
|
||||||
|
)`,
|
||||||
|
).run({
|
||||||
|
$storeKey: path.resolve(storePath),
|
||||||
|
$jobId: String(job.id),
|
||||||
|
$name: String(job.name),
|
||||||
|
$enabled: job.enabled === false ? 0 : 1,
|
||||||
|
$createdAtMs: Number(job.createdAtMs),
|
||||||
|
$updatedAt: Number(job.updatedAtMs),
|
||||||
|
$scheduleKind: String(schedule.kind),
|
||||||
|
$everyMs: Number(schedule.everyMs),
|
||||||
|
$sessionTarget: String(job.sessionTarget),
|
||||||
|
$wakeMode: String(job.wakeMode),
|
||||||
|
$payloadKind: String(payload.kind),
|
||||||
|
$payloadMessage: options.payloadMessage ?? null,
|
||||||
|
$jobJson: JSON.stringify(job),
|
||||||
|
$stateJson: JSON.stringify(job.state ?? {}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function writeLegacyCronArrayStore(storePath: string, jobs: Array<Record<string, unknown>>) {
|
async function writeLegacyCronArrayStore(storePath: string, jobs: Array<Record<string, unknown>>) {
|
||||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||||
await fs.writeFile(storePath, JSON.stringify(jobs, null, 2), "utf-8");
|
await fs.writeFile(storePath, JSON.stringify(jobs, null, 2), "utf-8");
|
||||||
@@ -415,6 +458,75 @@ describe("maybeRepairLegacyCronStore", () => {
|
|||||||
expectNoteContaining("Cron store migrated to SQLite", "Doctor changes");
|
expectNoteContaining("Cron store migrated to SQLite", "Doctor changes");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("backfills early SQLite rows from job_json before runtime relies on split columns", async () => {
|
||||||
|
const storePath = await makeTempStorePath();
|
||||||
|
insertEarlySQLiteCronRow(storePath, {
|
||||||
|
id: "early-sqlite-agent-turn",
|
||||||
|
name: "Early SQLite agent turn",
|
||||||
|
enabled: true,
|
||||||
|
createdAtMs: Date.parse("2026-02-03T00:00:00.000Z"),
|
||||||
|
updatedAtMs: Date.parse("2026-02-03T00:00:00.000Z"),
|
||||||
|
schedule: { kind: "every", everyMs: 3_600_000, anchorMs: 0 },
|
||||||
|
sessionTarget: "isolated",
|
||||||
|
wakeMode: "now",
|
||||||
|
payload: { kind: "agentTurn", message: "use config json" },
|
||||||
|
state: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await readPersistedJobs(storePath)).toEqual([]);
|
||||||
|
|
||||||
|
await maybeRepairLegacyCronStore({
|
||||||
|
cfg: createCronConfig(storePath),
|
||||||
|
options: {},
|
||||||
|
prompter: makePrompter(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const jobs = await readPersistedJobs(storePath);
|
||||||
|
const job = requirePersistedJob(jobs, 0);
|
||||||
|
expect(job.id).toBe("early-sqlite-agent-turn");
|
||||||
|
expect(job.payload).toEqual({ kind: "agentTurn", message: "use config json" });
|
||||||
|
expectNoteContaining("1 SQLite cron row will be backfilled", "Cron");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("backfills parseable SQLite rows when optional config fields only exist in job_json", async () => {
|
||||||
|
const storePath = await makeTempStorePath();
|
||||||
|
insertEarlySQLiteCronRow(
|
||||||
|
storePath,
|
||||||
|
{
|
||||||
|
id: "early-sqlite-model",
|
||||||
|
name: "Early SQLite model",
|
||||||
|
enabled: true,
|
||||||
|
createdAtMs: Date.parse("2026-02-03T00:00:00.000Z"),
|
||||||
|
updatedAtMs: Date.parse("2026-02-03T00:00:00.000Z"),
|
||||||
|
schedule: { kind: "every", everyMs: 3_600_000, anchorMs: 0 },
|
||||||
|
sessionTarget: "isolated",
|
||||||
|
wakeMode: "now",
|
||||||
|
payload: { kind: "agentTurn", message: "use split text", model: "openai/gpt-5.5" },
|
||||||
|
state: {},
|
||||||
|
},
|
||||||
|
{ payloadMessage: "use split text" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(requirePersistedJob(await readPersistedJobs(storePath), 0).payload).toEqual({
|
||||||
|
kind: "agentTurn",
|
||||||
|
message: "use split text",
|
||||||
|
});
|
||||||
|
|
||||||
|
await maybeRepairLegacyCronStore({
|
||||||
|
cfg: createCronConfig(storePath),
|
||||||
|
options: {},
|
||||||
|
prompter: makePrompter(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const job = requirePersistedJob(await readPersistedJobs(storePath), 0);
|
||||||
|
expect(job.payload).toEqual({
|
||||||
|
kind: "agentTurn",
|
||||||
|
message: "use split text",
|
||||||
|
model: "openai/gpt-5.5",
|
||||||
|
});
|
||||||
|
expectNoteContaining("1 SQLite cron row will be backfilled", "Cron");
|
||||||
|
});
|
||||||
|
|
||||||
it("migrates legacy run logs even when the legacy job store was already archived", async () => {
|
it("migrates legacy run logs even when the legacy job store was already archived", async () => {
|
||||||
const storePath = await makeTempStorePath();
|
const storePath = await makeTempStorePath();
|
||||||
await writeCurrentCronStore(storePath, [createCurrentCronJob()]);
|
await writeCurrentCronStore(storePath, [createCurrentCronJob()]);
|
||||||
@@ -665,7 +777,7 @@ describe("maybeRepairLegacyCronStore", () => {
|
|||||||
expect(delivery.to).toBe("https://example.invalid/cron-finished");
|
expect(delivery.to).toBe("https://example.invalid/cron-finished");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps notify fallback when cron.webhook is invalid", async () => {
|
it("warns when cron.webhook is invalid for a legacy notify fallback", async () => {
|
||||||
const storePath = await makeTempStorePath();
|
const storePath = await makeTempStorePath();
|
||||||
await writeCronStore(storePath, [
|
await writeCronStore(storePath, [
|
||||||
createLegacyCronJob({
|
createLegacyCronJob({
|
||||||
@@ -688,7 +800,7 @@ describe("maybeRepairLegacyCronStore", () => {
|
|||||||
|
|
||||||
const jobs = await readPersistedJobs(storePath);
|
const jobs = await readPersistedJobs(storePath);
|
||||||
const job = requirePersistedJob(jobs, 0);
|
const job = requirePersistedJob(jobs, 0);
|
||||||
expect(job.notify).toBe(true);
|
expect(job.notify).toBeUndefined();
|
||||||
expect(job.delivery).toBeUndefined();
|
expect(job.delivery).toBeUndefined();
|
||||||
expectNoteContaining(
|
expectNoteContaining(
|
||||||
"cron.webhook is not a valid HTTP(S) URL so doctor cannot migrate it automatically",
|
"cron.webhook is not a valid HTTP(S) URL so doctor cannot migrate it automatically",
|
||||||
@@ -696,6 +808,28 @@ describe("maybeRepairLegacyCronStore", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("quarantines invalid legacy rows before saving the repaired store", async () => {
|
||||||
|
const storePath = await makeTempStorePath();
|
||||||
|
await writeCronStore(storePath, [
|
||||||
|
createLegacyCronJob({
|
||||||
|
id: "invalid-legacy-cron",
|
||||||
|
jobId: undefined,
|
||||||
|
schedule: { kind: "cron" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await maybeRepairLegacyCronStore({
|
||||||
|
cfg: createCronConfig(storePath),
|
||||||
|
options: {},
|
||||||
|
prompter: makePrompter(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await readPersistedJobs(storePath)).toEqual([]);
|
||||||
|
const quarantine = await loadCronQuarantineFile(resolveCronQuarantinePath(storePath));
|
||||||
|
expect(quarantine.jobs[0]?.reason).toBe("invalid-schedule");
|
||||||
|
expect(quarantine.jobs[0]?.job?.id).toBe("invalid-legacy-cron");
|
||||||
|
});
|
||||||
|
|
||||||
it("repairs legacy root delivery threadId hints into delivery", async () => {
|
it("repairs legacy root delivery threadId hints into delivery", async () => {
|
||||||
const storePath = await makeTempStorePath();
|
const storePath = await makeTempStorePath();
|
||||||
await writeCronStore(storePath, [
|
await writeCronStore(storePath, [
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { isDeepStrictEqual, promisify } from "node:util";
|
||||||
import { normalizeOptionalString } from "../../../../packages/normalization-core/src/string-coerce.js";
|
import { normalizeOptionalString } from "../../../../packages/normalization-core/src/string-coerce.js";
|
||||||
import { note } from "../../../../packages/terminal-core/src/note.js";
|
import { note } from "../../../../packages/terminal-core/src/note.js";
|
||||||
import { formatCliCommand } from "../../../cli/command-format.js";
|
import { formatCliCommand } from "../../../cli/command-format.js";
|
||||||
import { resolveAgentModelPrimaryValue } from "../../../config/model-input.js";
|
import { resolveAgentModelPrimaryValue } from "../../../config/model-input.js";
|
||||||
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
|
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
|
||||||
import { migrateLegacyNotifyFallback } from "../../../cron/migrations/legacy-notify.js";
|
import { migrateLegacyNotifyFallback } from "../../../cron/migrations/legacy-notify.js";
|
||||||
|
import { normalizeCronJobInput } from "../../../cron/normalize.js";
|
||||||
import {
|
import {
|
||||||
loadCronQuarantineFile,
|
loadCronQuarantineFile,
|
||||||
loadCronStore,
|
loadCronStoreWithConfigJobs,
|
||||||
resolveCronQuarantinePath,
|
resolveCronQuarantinePath,
|
||||||
resolveCronStorePath,
|
resolveCronStorePath,
|
||||||
|
saveCronQuarantineFile,
|
||||||
saveCronStore,
|
saveCronStore,
|
||||||
} from "../../../cron/store.js";
|
} from "../../../cron/store.js";
|
||||||
import type { CronJob } from "../../../cron/types.js";
|
import type { CronJob } from "../../../cron/types.js";
|
||||||
@@ -173,6 +175,52 @@ function mergeLegacyCronJobs(params: {
|
|||||||
return { jobs: merged, importedCount };
|
return { jobs: merged, importedCount };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeRuntimeEntryIntoConfigJob(params: {
|
||||||
|
job: Record<string, unknown>;
|
||||||
|
runtimeEntry?: { updatedAtMs?: number; state?: Record<string, unknown> };
|
||||||
|
}): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
...params.job,
|
||||||
|
...(params.runtimeEntry?.updatedAtMs !== undefined
|
||||||
|
? { updatedAtMs: params.runtimeEntry.updatedAtMs }
|
||||||
|
: {}),
|
||||||
|
...(params.runtimeEntry?.state ? { state: structuredClone(params.runtimeEntry.state) } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function needsSqliteProjectionBackfill(params: {
|
||||||
|
configJob: Record<string, unknown>;
|
||||||
|
projectedJob?: CronJob;
|
||||||
|
}): boolean {
|
||||||
|
if (!params.projectedJob) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const normalizedConfig = normalizeCronJobInput(params.configJob, { applyDefaults: true });
|
||||||
|
if (!normalizedConfig) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const projected = params.projectedJob as unknown as Record<string, unknown>;
|
||||||
|
for (const field of [
|
||||||
|
"agentId",
|
||||||
|
"deleteAfterRun",
|
||||||
|
"delivery",
|
||||||
|
"description",
|
||||||
|
"enabled",
|
||||||
|
"failureAlert",
|
||||||
|
"name",
|
||||||
|
"payload",
|
||||||
|
"schedule",
|
||||||
|
"sessionKey",
|
||||||
|
"sessionTarget",
|
||||||
|
"wakeMode",
|
||||||
|
] as const) {
|
||||||
|
if (!isDeepStrictEqual(normalizedConfig[field], projected[field])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function formatProviderCounts(counts: Map<string, number>): string {
|
function formatProviderCounts(counts: Map<string, number>): string {
|
||||||
return [...counts.entries()]
|
return [...counts.entries()]
|
||||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||||
@@ -319,14 +367,34 @@ export async function maybeRepairLegacyCronStore(params: {
|
|||||||
}) {
|
}) {
|
||||||
const storePath = resolveCronStorePath(params.cfg.cron?.store);
|
const storePath = resolveCronStorePath(params.cfg.cron?.store);
|
||||||
const quarantinePath = resolveCronQuarantinePath(storePath);
|
const quarantinePath = resolveCronQuarantinePath(storePath);
|
||||||
let store: Awaited<ReturnType<typeof loadCronStore>>;
|
let store: Awaited<ReturnType<typeof loadCronStoreWithConfigJobs>>["store"];
|
||||||
let legacyStoreDetected = false;
|
let legacyStoreDetected = false;
|
||||||
let legacyRunLogDetected = false;
|
let legacyRunLogDetected = false;
|
||||||
let legacyImportCount = 0;
|
let legacyImportCount = 0;
|
||||||
|
let sqliteProjectionBackfillCount = 0;
|
||||||
try {
|
try {
|
||||||
legacyStoreDetected = await legacyCronStoreFilesExist(storePath);
|
legacyStoreDetected = await legacyCronStoreFilesExist(storePath);
|
||||||
legacyRunLogDetected = await legacyCronRunLogFilesExist(storePath);
|
legacyRunLogDetected = await legacyCronRunLogFilesExist(storePath);
|
||||||
store = await loadCronStore(storePath);
|
const loaded = await loadCronStoreWithConfigJobs(storePath);
|
||||||
|
const currentJobs =
|
||||||
|
loaded.configJobs.length > 0
|
||||||
|
? loaded.configJobs.map((job, index) =>
|
||||||
|
mergeRuntimeEntryIntoConfigJob({
|
||||||
|
job,
|
||||||
|
runtimeEntry: loaded.configJobRuntimeEntries[index],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: (loaded.store.jobs as unknown as Array<Record<string, unknown>>);
|
||||||
|
sqliteProjectionBackfillCount =
|
||||||
|
loaded.configJobs.length > 0
|
||||||
|
? currentJobs.filter((job, index) =>
|
||||||
|
needsSqliteProjectionBackfill({
|
||||||
|
configJob: job,
|
||||||
|
projectedJob: loaded.store.jobs[index],
|
||||||
|
}),
|
||||||
|
).length
|
||||||
|
: 0;
|
||||||
|
store = { version: 1, jobs: currentJobs as unknown as CronJob[] };
|
||||||
if (legacyStoreDetected) {
|
if (legacyStoreDetected) {
|
||||||
const legacyStore = (await loadLegacyCronStoreForMigration(storePath)).store;
|
const legacyStore = (await loadLegacyCronStoreForMigration(storePath)).store;
|
||||||
const merged = mergeLegacyCronJobs({
|
const merged = mergeLegacyCronJobs({
|
||||||
@@ -434,6 +502,11 @@ export async function maybeRepairLegacyCronStore(params: {
|
|||||||
if (legacyRunLogDetected) {
|
if (legacyRunLogDetected) {
|
||||||
previewLines.push("- legacy JSON cron run logs will be imported into SQLite");
|
previewLines.push("- legacy JSON cron run logs will be imported into SQLite");
|
||||||
}
|
}
|
||||||
|
if (sqliteProjectionBackfillCount > 0) {
|
||||||
|
previewLines.push(
|
||||||
|
`- ${pluralize(sqliteProjectionBackfillCount, "SQLite cron row")} will be backfilled from stored config JSON into split columns`,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (notifyCount > 0) {
|
if (notifyCount > 0) {
|
||||||
previewLines.push(
|
previewLines.push(
|
||||||
`- ${pluralize(notifyCount, "job")} still uses legacy \`notify: true\` webhook fallback`,
|
`- ${pluralize(notifyCount, "job")} still uses legacy \`notify: true\` webhook fallback`,
|
||||||
@@ -473,6 +546,7 @@ export async function maybeRepairLegacyCronStore(params: {
|
|||||||
const changed =
|
const changed =
|
||||||
legacyStoreDetected ||
|
legacyStoreDetected ||
|
||||||
legacyRunLogDetected ||
|
legacyRunLogDetected ||
|
||||||
|
sqliteProjectionBackfillCount > 0 ||
|
||||||
normalized.mutated ||
|
normalized.mutated ||
|
||||||
notifyMigration.changed ||
|
notifyMigration.changed ||
|
||||||
dreamingMigration.changed;
|
dreamingMigration.changed;
|
||||||
@@ -481,6 +555,17 @@ export async function maybeRepairLegacyCronStore(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
|
if (normalized.removedJobs.length > 0) {
|
||||||
|
await saveCronQuarantineFile({
|
||||||
|
storePath,
|
||||||
|
nowMs: Date.now(),
|
||||||
|
entries: normalized.removedJobs.map((entry) => ({
|
||||||
|
sourceIndex: entry.sourceIndex,
|
||||||
|
reason: entry.reason,
|
||||||
|
job: entry.job,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
await saveCronStore(storePath, {
|
await saveCronStore(storePath, {
|
||||||
version: 1,
|
version: 1,
|
||||||
jobs: rawJobs as unknown as CronJob[],
|
jobs: rawJobs as unknown as CronJob[],
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { tryCronScheduleIdentity } from "../../../cron/schedule-identity.js";
|
import { isRecord } from "../../../../packages/normalization-core/src/record-coerce.js";
|
||||||
|
import { normalizeOptionalString } from "../../../../packages/normalization-core/src/string-coerce.js";
|
||||||
|
import { coerceFiniteScheduleNumber } from "../../../cron/schedule-number.js";
|
||||||
|
import { normalizeCronStaggerMs } from "../../../cron/stagger.js";
|
||||||
import type {
|
import type {
|
||||||
CronConfigJobRuntimeEntry,
|
CronConfigJobRuntimeEntry,
|
||||||
LoadedCronStore,
|
LoadedCronStore,
|
||||||
QuarantinedCronConfigJob,
|
QuarantinedCronConfigJob,
|
||||||
} from "../../../cron/store.js";
|
} from "../../../cron/store.js";
|
||||||
import type { CronStoreFile } from "../../../cron/types.js";
|
import type { CronStoreFile } from "../../../cron/types.js";
|
||||||
import { isRecord } from "../../../../packages/normalization-core/src/record-coerce.js";
|
|
||||||
import { normalizeOptionalString } from "../../../../packages/normalization-core/src/string-coerce.js";
|
|
||||||
import { parseJsonWithJson5Fallback } from "../../../utils/parse-json-compat.js";
|
import { parseJsonWithJson5Fallback } from "../../../utils/parse-json-compat.js";
|
||||||
|
|
||||||
const LEGACY_CRON_ARCHIVE_SUFFIX = ".migrated";
|
const LEGACY_CRON_ARCHIVE_SUFFIX = ".migrated";
|
||||||
@@ -65,6 +66,71 @@ function parseCronStateFile(raw: string): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readString(record: Record<string, unknown>, key: string): string | undefined {
|
||||||
|
return normalizeOptionalString(record[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(record: Record<string, unknown>, key: string): number | undefined {
|
||||||
|
return coerceFiniteScheduleNumber(record[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacySchedulePayloadFromRecord(
|
||||||
|
schedule: Record<string, unknown>,
|
||||||
|
):
|
||||||
|
| { kind: "at"; at: string }
|
||||||
|
| { kind: "every"; everyMs: number; anchorMs?: number }
|
||||||
|
| { 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 = normalizeCronStaggerMs(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;
|
||||||
|
}
|
||||||
|
if (kind === "every" && everyMs !== undefined) {
|
||||||
|
return { kind: "every", everyMs, anchorMs };
|
||||||
|
}
|
||||||
|
if (kind === "cron" && expr) {
|
||||||
|
return { kind: "cron", expr, tz, staggerMs };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryLegacyCronScheduleIdentity(job: Record<string, unknown>): string | undefined {
|
||||||
|
const schedule =
|
||||||
|
job.schedule && typeof job.schedule === "object" && !Array.isArray(job.schedule)
|
||||||
|
? legacySchedulePayloadFromRecord(job.schedule as Record<string, unknown>)
|
||||||
|
: legacySchedulePayloadFromRecord(job);
|
||||||
|
if (!schedule) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
enabled: typeof job.enabled === "boolean" ? job.enabled : true,
|
||||||
|
schedule,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getRawCronJobs(parsed: unknown): unknown[] {
|
function getRawCronJobs(parsed: unknown): unknown[] {
|
||||||
return Array.isArray(parsed)
|
return Array.isArray(parsed)
|
||||||
? parsed
|
? parsed
|
||||||
@@ -135,7 +201,8 @@ function mergeStateFileEntry(job: CronStoreFile["jobs"][number], entry: unknown)
|
|||||||
job.state = isRecord(entry.state) ? (entry.state as never) : ({} as never);
|
job.state = isRecord(entry.state) ? (entry.state as never) : ({} as never);
|
||||||
if (
|
if (
|
||||||
typeof entry.scheduleIdentity === "string" &&
|
typeof entry.scheduleIdentity === "string" &&
|
||||||
entry.scheduleIdentity !== tryCronScheduleIdentity(job as unknown as Record<string, unknown>)
|
entry.scheduleIdentity !==
|
||||||
|
tryLegacyCronScheduleIdentity(job as unknown as Record<string, unknown>)
|
||||||
) {
|
) {
|
||||||
ensureJobStateObject(job);
|
ensureJobStateObject(job);
|
||||||
job.state.nextRunAtMs = undefined;
|
job.state.nextRunAtMs = undefined;
|
||||||
|
|||||||
@@ -142,6 +142,22 @@ describe("normalizeStoredCronJobs", () => {
|
|||||||
expect(result.issues.legacyPayloadKind).toBeUndefined();
|
expect(result.issues.legacyPayloadKind).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rewrites legacy systemEvent message payloads to text", () => {
|
||||||
|
const jobs = [
|
||||||
|
makeLegacyJob({
|
||||||
|
id: "legacy-system-event-message",
|
||||||
|
schedule: { kind: "every", everyMs: 60_000, anchorMs: 1 },
|
||||||
|
payload: { kind: "systemEvent", message: "tick" },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = normalizeStoredCronJobs(jobs);
|
||||||
|
|
||||||
|
expect(result.mutated).toBe(true);
|
||||||
|
expect(result.jobs[0]?.payload).toEqual({ kind: "systemEvent", text: "tick" });
|
||||||
|
expect(result.removedJobs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("removes unrepairable persisted schedule and payload shapes", () => {
|
it("removes unrepairable persisted schedule and payload shapes", () => {
|
||||||
const jobs = [
|
const jobs = [
|
||||||
makeLegacyJob({
|
makeLegacyJob({
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { parseAbsoluteTimeMs } from "../../../cron/parse.js";
|
|
||||||
import { getInvalidPersistedCronJobReason } from "../../../cron/persisted-shape.js";
|
|
||||||
import { coerceFiniteScheduleNumber } from "../../../cron/schedule.js";
|
|
||||||
import { inferLegacyName } from "../../../cron/service/normalize.js";
|
|
||||||
import { normalizeCronStaggerMs, resolveDefaultCronStaggerMs } from "../../../cron/stagger.js";
|
|
||||||
import { timestampMsToIsoString } from "../../../../packages/normalization-core/src/number-coercion.js";
|
import { timestampMsToIsoString } from "../../../../packages/normalization-core/src/number-coercion.js";
|
||||||
import {
|
import {
|
||||||
normalizeLowercaseStringOrEmpty,
|
normalizeLowercaseStringOrEmpty,
|
||||||
@@ -11,6 +6,11 @@ import {
|
|||||||
normalizeOptionalString,
|
normalizeOptionalString,
|
||||||
normalizeOptionalStringifiedId,
|
normalizeOptionalStringifiedId,
|
||||||
} from "../../../../packages/normalization-core/src/string-coerce.js";
|
} from "../../../../packages/normalization-core/src/string-coerce.js";
|
||||||
|
import { parseAbsoluteTimeMs } from "../../../cron/parse.js";
|
||||||
|
import { getInvalidPersistedCronJobReason } from "../../../cron/persisted-shape.js";
|
||||||
|
import { coerceFiniteScheduleNumber } from "../../../cron/schedule.js";
|
||||||
|
import { inferCronJobName } from "../../../cron/service/normalize.js";
|
||||||
|
import { normalizeCronStaggerMs, resolveDefaultCronStaggerMs } from "../../../cron/stagger.js";
|
||||||
import { normalizeLegacyDeliveryInput } from "./legacy-delivery.js";
|
import { normalizeLegacyDeliveryInput } from "./legacy-delivery.js";
|
||||||
import { hasLegacyOpenAICodexCronModelRef, migrateLegacyCronPayload } from "./payload-migration.js";
|
import { hasLegacyOpenAICodexCronModelRef, migrateLegacyCronPayload } from "./payload-migration.js";
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ type NormalizeCronStoreJobsResult = {
|
|||||||
issues: CronStoreIssues;
|
issues: CronStoreIssues;
|
||||||
jobs: Array<Record<string, unknown>>;
|
jobs: Array<Record<string, unknown>>;
|
||||||
mutated: boolean;
|
mutated: boolean;
|
||||||
|
removedJobs: Array<{ job: Record<string, unknown>; reason: string; sourceIndex: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function incrementIssue(issues: CronStoreIssues, key: CronStoreIssueKey) {
|
function incrementIssue(issues: CronStoreIssues, key: CronStoreIssueKey) {
|
||||||
@@ -237,8 +238,9 @@ export function normalizeStoredCronJobs(
|
|||||||
const issues: CronStoreIssues = {};
|
const issues: CronStoreIssues = {};
|
||||||
let mutated = false;
|
let mutated = false;
|
||||||
const keptJobs: Array<Record<string, unknown>> = [];
|
const keptJobs: Array<Record<string, unknown>> = [];
|
||||||
|
const removedJobs: NormalizeCronStoreJobsResult["removedJobs"] = [];
|
||||||
|
|
||||||
for (const raw of jobs) {
|
for (const [sourceIndex, raw] of jobs.entries()) {
|
||||||
const jobIssues = new Set<CronStoreIssueKey>();
|
const jobIssues = new Set<CronStoreIssueKey>();
|
||||||
const trackIssue = (key: CronStoreIssueKey) => {
|
const trackIssue = (key: CronStoreIssueKey) => {
|
||||||
if (jobIssues.has(key)) {
|
if (jobIssues.has(key)) {
|
||||||
@@ -277,7 +279,7 @@ export function normalizeStoredCronJobs(
|
|||||||
|
|
||||||
const nameRaw = raw.name;
|
const nameRaw = raw.name;
|
||||||
if (typeof nameRaw !== "string" || nameRaw.trim().length === 0) {
|
if (typeof nameRaw !== "string" || nameRaw.trim().length === 0) {
|
||||||
raw.name = inferLegacyName({
|
raw.name = inferCronJobName({
|
||||||
schedule: raw.schedule as never,
|
schedule: raw.schedule as never,
|
||||||
payload: raw.payload as never,
|
payload: raw.payload as never,
|
||||||
});
|
});
|
||||||
@@ -355,6 +357,15 @@ export function normalizeStoredCronJobs(
|
|||||||
if (payloadRecord.kind === "agentTurn" && copyTopLevelAgentTurnFields(raw, payloadRecord)) {
|
if (payloadRecord.kind === "agentTurn" && copyTopLevelAgentTurnFields(raw, payloadRecord)) {
|
||||||
mutated = true;
|
mutated = true;
|
||||||
}
|
}
|
||||||
|
if (payloadRecord.kind === "systemEvent" && !normalizeOptionalString(payloadRecord.text)) {
|
||||||
|
const message = normalizeOptionalString(payloadRecord.message);
|
||||||
|
if (message) {
|
||||||
|
payloadRecord.text = message;
|
||||||
|
delete payloadRecord.message;
|
||||||
|
mutated = true;
|
||||||
|
trackIssue("legacyPayloadKind");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hadLegacyTopLevelPayloadFields =
|
const hadLegacyTopLevelPayloadFields =
|
||||||
@@ -573,6 +584,7 @@ export function normalizeStoredCronJobs(
|
|||||||
invalidPersistedReason === "invalid-schedule"
|
invalidPersistedReason === "invalid-schedule"
|
||||||
) {
|
) {
|
||||||
trackIssue("invalidSchedule");
|
trackIssue("invalidSchedule");
|
||||||
|
removedJobs.push({ job: structuredClone(raw), reason: invalidPersistedReason, sourceIndex });
|
||||||
mutated = true;
|
mutated = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -581,6 +593,7 @@ export function normalizeStoredCronJobs(
|
|||||||
invalidPersistedReason === "invalid-payload"
|
invalidPersistedReason === "invalid-payload"
|
||||||
) {
|
) {
|
||||||
trackIssue("invalidPayload");
|
trackIssue("invalidPayload");
|
||||||
|
removedJobs.push({ job: structuredClone(raw), reason: invalidPersistedReason, sourceIndex });
|
||||||
mutated = true;
|
mutated = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -591,5 +604,5 @@ export function normalizeStoredCronJobs(
|
|||||||
jobs.splice(0, jobs.length, ...keptJobs);
|
jobs.splice(0, jobs.length, ...keptJobs);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { issues, jobs, mutated };
|
return { issues, jobs, mutated, removedJobs };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,15 +33,6 @@ function expectAnnounceDeliveryTarget(
|
|||||||
expect(delivery.to).toBe(params.to);
|
expect(delivery.to).toBe(params.to);
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectPayloadDeliveryHintsCleared(payload: Record<string, unknown>): void {
|
|
||||||
expect(payload.channel).toBeUndefined();
|
|
||||||
expect(payload.deliver).toBeUndefined();
|
|
||||||
expect(payload.to).toBeUndefined();
|
|
||||||
expect(payload.threadId).toBeUndefined();
|
|
||||||
expect(payload.bestEffortDeliver).toBeUndefined();
|
|
||||||
expect(payload.provider).toBeUndefined();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeIsolatedAgentTurnCreateJob(params: {
|
function normalizeIsolatedAgentTurnCreateJob(params: {
|
||||||
name: string;
|
name: string;
|
||||||
payload?: Record<string, unknown>;
|
payload?: Record<string, unknown>;
|
||||||
@@ -80,23 +71,6 @@ function normalizeMainSystemEventCreateJob(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("normalizeCronJobCreate", () => {
|
describe("normalizeCronJobCreate", () => {
|
||||||
it("strips payload-level legacy delivery hints from live input", () => {
|
|
||||||
const normalized = normalizeIsolatedAgentTurnCreateJob({
|
|
||||||
name: "legacy",
|
|
||||||
payload: {
|
|
||||||
deliver: true,
|
|
||||||
provider: " TeLeGrAm ",
|
|
||||||
to: "7200373102",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = normalized.payload as Record<string, unknown>;
|
|
||||||
expectPayloadDeliveryHintsCleared(payload);
|
|
||||||
|
|
||||||
const delivery = normalized.delivery as Record<string, unknown>;
|
|
||||||
expect(delivery).toEqual({ mode: "announce" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("trims agentId and drops null", () => {
|
it("trims agentId and drops null", () => {
|
||||||
const normalized = normalizeCronJobCreate({
|
const normalized = normalizeCronJobCreate({
|
||||||
name: "agent-set",
|
name: "agent-set",
|
||||||
@@ -153,42 +127,6 @@ describe("normalizeCronJobCreate", () => {
|
|||||||
expect("sessionKey" in cleared).toBe(false);
|
expect("sessionKey" in cleared).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips top-level legacy delivery hints from live input", () => {
|
|
||||||
const normalized = normalizeIsolatedAgentTurnCreateJob({
|
|
||||||
name: "legacy top-level delivery",
|
|
||||||
payload: {
|
|
||||||
kind: "agentTurn",
|
|
||||||
message: "hi",
|
|
||||||
},
|
|
||||||
delivery: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const withLegacyTopLevel = normalizeCronJobCreate({
|
|
||||||
name: "legacy top-level delivery",
|
|
||||||
enabled: true,
|
|
||||||
schedule: { kind: "cron", expr: "* * * * *" },
|
|
||||||
sessionTarget: "isolated",
|
|
||||||
wakeMode: "now",
|
|
||||||
payload: {
|
|
||||||
kind: "agentTurn",
|
|
||||||
message: "hi",
|
|
||||||
},
|
|
||||||
deliver: false,
|
|
||||||
channel: "Telegram",
|
|
||||||
to: "-1001234567890",
|
|
||||||
threadId: " 99 ",
|
|
||||||
}) as unknown as Record<string, unknown>;
|
|
||||||
|
|
||||||
expect(normalized.delivery).toEqual({ mode: "announce" });
|
|
||||||
expect(withLegacyTopLevel.deliver).toBeUndefined();
|
|
||||||
expect(withLegacyTopLevel.channel).toBeUndefined();
|
|
||||||
expect(withLegacyTopLevel.to).toBeUndefined();
|
|
||||||
expect(withLegacyTopLevel.threadId).toBeUndefined();
|
|
||||||
|
|
||||||
const delivery = withLegacyTopLevel.delivery as Record<string, unknown>;
|
|
||||||
expect(delivery).toEqual({ mode: "announce" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("canonicalizes delivery.channel casing", () => {
|
it("canonicalizes delivery.channel casing", () => {
|
||||||
const normalized = normalizeIsolatedAgentTurnCreateJob({
|
const normalized = normalizeIsolatedAgentTurnCreateJob({
|
||||||
name: "delivery channel casing",
|
name: "delivery channel casing",
|
||||||
@@ -204,34 +142,7 @@ describe("normalizeCronJobCreate", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("coerces ISO schedule.at to normalized ISO (UTC)", () => {
|
it("coerces ISO schedule.at to normalized ISO (UTC)", () => {
|
||||||
expectNormalizedAtSchedule({ at: "2026-01-12T18:00:00" });
|
expectNormalizedAtSchedule({ kind: "at", at: "2026-01-12T18:00:00" });
|
||||||
});
|
|
||||||
|
|
||||||
it("coerces schedule.atMs string to schedule.at (UTC)", () => {
|
|
||||||
expectNormalizedAtSchedule({ kind: "at", atMs: "2026-01-12T18:00:00" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps out-of-range numeric schedule.atMs invalid instead of throwing for create jobs", () => {
|
|
||||||
const normalized = normalizeMainSystemEventCreateJob({
|
|
||||||
name: "out-of-range-at-ms",
|
|
||||||
schedule: { kind: "at", atMs: 8_640_000_000_000_001 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const schedule = normalized.schedule as Record<string, unknown>;
|
|
||||||
expect(schedule).toEqual({ kind: "at" });
|
|
||||||
expect(validateCronAddParams(normalized)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("migrates legacy schedule.cron into schedule.expr", () => {
|
|
||||||
const normalized = normalizeMainSystemEventCreateJob({
|
|
||||||
name: "legacy-cron-field",
|
|
||||||
schedule: { kind: "cron", cron: "*/10 * * * *", tz: "UTC" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const schedule = normalized.schedule as Record<string, unknown>;
|
|
||||||
expect(schedule.kind).toBe("cron");
|
|
||||||
expect(schedule.expr).toBe("*/10 * * * *");
|
|
||||||
expect(schedule.cron).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults cron stagger for recurring top-of-hour schedules", () => {
|
it("defaults cron stagger for recurring top-of-hour schedules", () => {
|
||||||
@@ -258,7 +169,7 @@ describe("normalizeCronJobCreate", () => {
|
|||||||
const normalized = normalizeCronJobCreate({
|
const normalized = normalizeCronJobCreate({
|
||||||
name: "default delete",
|
name: "default delete",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
schedule: { at: "2026-01-12T18:00:00Z" },
|
schedule: { kind: "at", at: "2026-01-12T18:00:00Z" },
|
||||||
sessionTarget: "main",
|
sessionTarget: "main",
|
||||||
wakeMode: "next-heartbeat",
|
wakeMode: "next-heartbeat",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -413,141 +324,11 @@ describe("normalizeCronJobCreate", () => {
|
|||||||
expect(delivery.mode).toBe("announce");
|
expect(delivery.mode).toBe("announce");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("migrates legacy isolation settings to announce delivery", () => {
|
|
||||||
const normalized = normalizeCronJobCreate({
|
|
||||||
name: "legacy isolation",
|
|
||||||
enabled: true,
|
|
||||||
schedule: { kind: "cron", expr: "* * * * *" },
|
|
||||||
payload: {
|
|
||||||
kind: "agentTurn",
|
|
||||||
message: "hi",
|
|
||||||
},
|
|
||||||
isolation: { postToMainPrefix: "Cron" },
|
|
||||||
}) as unknown as Record<string, unknown>;
|
|
||||||
|
|
||||||
const delivery = normalized.delivery as Record<string, unknown>;
|
|
||||||
expect(delivery.mode).toBe("announce");
|
|
||||||
expect((normalized as { isolation?: unknown }).isolation).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("infers payload kind/session target and name for message-only jobs", () => {
|
|
||||||
const normalized = normalizeCronJobCreate({
|
|
||||||
schedule: { kind: "every", everyMs: 60_000 },
|
|
||||||
payload: { message: "Nightly backup" },
|
|
||||||
}) as unknown as Record<string, unknown>;
|
|
||||||
|
|
||||||
const payload = normalized.payload as Record<string, unknown>;
|
|
||||||
expect(payload.kind).toBe("agentTurn");
|
|
||||||
expect(payload.message).toBe("Nightly backup");
|
|
||||||
expect(normalized.sessionTarget).toBe("isolated");
|
|
||||||
expect(normalized.wakeMode).toBe("now");
|
|
||||||
expect(typeof normalized.name).toBe("string");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes flat legacy cron job rows", () => {
|
|
||||||
const normalized = normalizeCronJobCreate({
|
|
||||||
id: "dbus-watchdog-001",
|
|
||||||
name: "dbus-watchdog",
|
|
||||||
kind: "cron",
|
|
||||||
cron: "*/10 * * * *",
|
|
||||||
tz: "UTC",
|
|
||||||
session: "isolated",
|
|
||||||
message: "watch dbus",
|
|
||||||
tools: [" exec "],
|
|
||||||
enabled: true,
|
|
||||||
created_at: "2026-04-17T20:09:00Z",
|
|
||||||
}) as unknown as Record<string, unknown>;
|
|
||||||
|
|
||||||
expect(normalized.schedule).toEqual({
|
|
||||||
kind: "cron",
|
|
||||||
expr: "*/10 * * * *",
|
|
||||||
tz: "UTC",
|
|
||||||
});
|
|
||||||
expect(normalized.sessionTarget).toBe("isolated");
|
|
||||||
expect(normalized.payload).toEqual({
|
|
||||||
kind: "agentTurn",
|
|
||||||
message: "watch dbus",
|
|
||||||
toolsAllow: ["exec"],
|
|
||||||
});
|
|
||||||
expect(normalized.kind).toBeUndefined();
|
|
||||||
expect(normalized.cron).toBeUndefined();
|
|
||||||
expect(normalized.tz).toBeUndefined();
|
|
||||||
expect(normalized.session).toBeUndefined();
|
|
||||||
expect(normalized.tools).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps top-level model/thinking/timeout into payload for legacy add params", () => {
|
|
||||||
const normalized = normalizeCronJobCreate({
|
|
||||||
name: "legacy root fields",
|
|
||||||
schedule: { kind: "every", everyMs: 60_000 },
|
|
||||||
payload: { kind: "agentTurn", message: "hello" },
|
|
||||||
model: " openrouter/deepseek/deepseek-r1 ",
|
|
||||||
thinking: " high ",
|
|
||||||
timeoutSeconds: 45,
|
|
||||||
toolsAllow: [" exec ", " read "],
|
|
||||||
allowUnsafeExternalContent: true,
|
|
||||||
}) as unknown as Record<string, unknown>;
|
|
||||||
|
|
||||||
const payload = normalized.payload as Record<string, unknown>;
|
|
||||||
expect(payload.model).toBe("openrouter/deepseek/deepseek-r1");
|
|
||||||
expect(payload.thinking).toBe("high");
|
|
||||||
expect(payload.timeoutSeconds).toBe(45);
|
|
||||||
expect(payload.toolsAllow).toEqual(["exec", "read"]);
|
|
||||||
expect(payload.allowUnsafeExternalContent).toBe(true);
|
|
||||||
expect(validateCronAddParams(normalized)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("promotes implicit text payloads with agentTurn hints for create jobs", () => {
|
|
||||||
const normalized = normalizeCronJobCreate({
|
|
||||||
name: "nested text model",
|
|
||||||
schedule: { kind: "every", everyMs: 60_000 },
|
|
||||||
payload: {
|
|
||||||
text: " summarize issue status ",
|
|
||||||
model: " anthropic/claude-sonnet-4-6 ",
|
|
||||||
thinking: " high ",
|
|
||||||
},
|
|
||||||
}) as unknown as Record<string, unknown>;
|
|
||||||
|
|
||||||
const payload = normalized.payload as Record<string, unknown>;
|
|
||||||
expect(payload).toEqual({
|
|
||||||
kind: "agentTurn",
|
|
||||||
message: "summarize issue status",
|
|
||||||
model: "anthropic/claude-sonnet-4-6",
|
|
||||||
thinking: "high",
|
|
||||||
});
|
|
||||||
expect(normalized.sessionTarget).toBe("isolated");
|
|
||||||
expect(validateCronAddParams(normalized)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("promotes legacy top-level text with agentTurn hints for create jobs", () => {
|
|
||||||
const normalized = normalizeCronJobCreate({
|
|
||||||
name: "legacy text model",
|
|
||||||
schedule: { kind: "every", everyMs: 60_000 },
|
|
||||||
text: " summarize issue status ",
|
|
||||||
model: " openrouter/deepseek/deepseek-r1 ",
|
|
||||||
fallbacks: [],
|
|
||||||
toolsAllow: [" read "],
|
|
||||||
}) as unknown as Record<string, unknown>;
|
|
||||||
|
|
||||||
const payload = normalized.payload as Record<string, unknown>;
|
|
||||||
expect(payload).toEqual({
|
|
||||||
kind: "agentTurn",
|
|
||||||
message: "summarize issue status",
|
|
||||||
model: "openrouter/deepseek/deepseek-r1",
|
|
||||||
fallbacks: [],
|
|
||||||
toolsAllow: ["read"],
|
|
||||||
});
|
|
||||||
expect(normalized.text).toBeUndefined();
|
|
||||||
expect(normalized.model).toBeUndefined();
|
|
||||||
expect(validateCronAddParams(normalized)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves timeoutSeconds=0 for no-timeout agentTurn payloads", () => {
|
it("preserves timeoutSeconds=0 for no-timeout agentTurn payloads", () => {
|
||||||
const normalized = normalizeCronJobCreate({
|
const normalized = normalizeCronJobCreate({
|
||||||
name: "legacy no-timeout",
|
name: "no-timeout",
|
||||||
schedule: { kind: "every", everyMs: 60_000 },
|
schedule: { kind: "every", everyMs: 60_000 },
|
||||||
payload: { kind: "agentTurn", message: "hello" },
|
payload: { kind: "agentTurn", message: "hello", timeoutSeconds: 0 },
|
||||||
timeoutSeconds: 0,
|
|
||||||
}) as unknown as Record<string, unknown>;
|
}) as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
const payload = normalized.payload as Record<string, unknown>;
|
const payload = normalized.payload as Record<string, unknown>;
|
||||||
@@ -680,6 +461,7 @@ describe("normalizeCronJobCreate", () => {
|
|||||||
const normalized = normalizeCronJobCreate({
|
const normalized = normalizeCronJobCreate({
|
||||||
name: "every-string",
|
name: "every-string",
|
||||||
schedule: {
|
schedule: {
|
||||||
|
kind: "every",
|
||||||
everyMs: "60000",
|
everyMs: "60000",
|
||||||
anchorMs: "123.9",
|
anchorMs: "123.9",
|
||||||
},
|
},
|
||||||
@@ -837,19 +619,10 @@ describe("normalizeCronJobCreate", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("normalizeCronJobPatch", () => {
|
describe("normalizeCronJobPatch", () => {
|
||||||
it("infers agentTurn payloads from top-level model-only patch hints", () => {
|
it("normalizes agentTurn model-only payload patches", () => {
|
||||||
const normalized = normalizeCronJobPatch({
|
|
||||||
model: "openrouter/deepseek/deepseek-r1",
|
|
||||||
}) as unknown as Record<string, unknown>;
|
|
||||||
|
|
||||||
const payload = normalized.payload as Record<string, unknown>;
|
|
||||||
expect(payload.kind).toBe("agentTurn");
|
|
||||||
expect(payload.model).toBe("openrouter/deepseek/deepseek-r1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("infers agentTurn kind for model-only payload patches", () => {
|
|
||||||
const normalized = normalizeCronJobPatch({
|
const normalized = normalizeCronJobPatch({
|
||||||
payload: {
|
payload: {
|
||||||
|
kind: "agentTurn",
|
||||||
model: "anthropic/claude-sonnet-4-6",
|
model: "anthropic/claude-sonnet-4-6",
|
||||||
},
|
},
|
||||||
}) as unknown as Record<string, unknown>;
|
}) as unknown as Record<string, unknown>;
|
||||||
@@ -859,76 +632,10 @@ describe("normalizeCronJobPatch", () => {
|
|||||||
expect(payload.model).toBe("anthropic/claude-sonnet-4-6");
|
expect(payload.model).toBe("anthropic/claude-sonnet-4-6");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("promotes implicit text payloads with agentTurn hints for patches", () => {
|
|
||||||
const normalized = normalizeCronJobPatch({
|
|
||||||
payload: {
|
|
||||||
text: " summarize issue status ",
|
|
||||||
model: "anthropic/claude-sonnet-4-6",
|
|
||||||
},
|
|
||||||
}) as unknown as Record<string, unknown>;
|
|
||||||
|
|
||||||
const payload = normalized.payload as Record<string, unknown>;
|
|
||||||
expect(payload).toEqual({
|
|
||||||
kind: "agentTurn",
|
|
||||||
message: "summarize issue status",
|
|
||||||
model: "anthropic/claude-sonnet-4-6",
|
|
||||||
});
|
|
||||||
expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("promotes legacy top-level text with agentTurn hints for patches", () => {
|
|
||||||
const normalized = normalizeCronJobPatch({
|
|
||||||
text: " summarize issue status ",
|
|
||||||
model: "openrouter/deepseek/deepseek-r1",
|
|
||||||
}) as unknown as Record<string, unknown>;
|
|
||||||
|
|
||||||
const payload = normalized.payload as Record<string, unknown>;
|
|
||||||
expect(payload).toEqual({
|
|
||||||
kind: "agentTurn",
|
|
||||||
message: "summarize issue status",
|
|
||||||
model: "openrouter/deepseek/deepseek-r1",
|
|
||||||
});
|
|
||||||
expect(normalized.text).toBeUndefined();
|
|
||||||
expect(normalized.model).toBeUndefined();
|
|
||||||
expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("infers agentTurn kind for lightContext-only payload patches", () => {
|
|
||||||
const normalized = normalizeCronJobPatch({
|
|
||||||
payload: {
|
|
||||||
lightContext: true,
|
|
||||||
},
|
|
||||||
}) as unknown as Record<string, unknown>;
|
|
||||||
|
|
||||||
const payload = normalized.payload as Record<string, unknown>;
|
|
||||||
expect(payload.kind).toBe("agentTurn");
|
|
||||||
expect(payload.lightContext).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps top-level fallback lists into agentTurn payload patches", () => {
|
|
||||||
const normalized = normalizeCronJobPatch({
|
|
||||||
fallbacks: [" openrouter/gpt-4.1-mini ", "anthropic/claude-haiku-3-5"],
|
|
||||||
}) as unknown as Record<string, unknown>;
|
|
||||||
|
|
||||||
const payload = normalized.payload as Record<string, unknown>;
|
|
||||||
expect(payload.kind).toBe("agentTurn");
|
|
||||||
expect(payload.fallbacks).toEqual(["openrouter/gpt-4.1-mini", "anthropic/claude-haiku-3-5"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps top-level toolsAllow lists into agentTurn payload patches", () => {
|
|
||||||
const normalized = normalizeCronJobPatch({
|
|
||||||
toolsAllow: [" exec ", " read "],
|
|
||||||
}) as unknown as Record<string, unknown>;
|
|
||||||
|
|
||||||
const payload = normalized.payload as Record<string, unknown>;
|
|
||||||
expect(payload.kind).toBe("agentTurn");
|
|
||||||
expect(payload.toolsAllow).toEqual(["exec", "read"]);
|
|
||||||
expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves empty fallback lists so patches can disable fallbacks", () => {
|
it("preserves empty fallback lists so patches can disable fallbacks", () => {
|
||||||
const normalized = normalizeCronJobPatch({
|
const normalized = normalizeCronJobPatch({
|
||||||
payload: {
|
payload: {
|
||||||
|
kind: "agentTurn",
|
||||||
fallbacks: [],
|
fallbacks: [],
|
||||||
},
|
},
|
||||||
}) as unknown as Record<string, unknown>;
|
}) as unknown as Record<string, unknown>;
|
||||||
@@ -941,6 +648,7 @@ describe("normalizeCronJobPatch", () => {
|
|||||||
it("preserves empty toolsAllow lists so patches can disable all tools", () => {
|
it("preserves empty toolsAllow lists so patches can disable all tools", () => {
|
||||||
const normalized = normalizeCronJobPatch({
|
const normalized = normalizeCronJobPatch({
|
||||||
payload: {
|
payload: {
|
||||||
|
kind: "agentTurn",
|
||||||
toolsAllow: [],
|
toolsAllow: [],
|
||||||
},
|
},
|
||||||
}) as unknown as Record<string, unknown>;
|
}) as unknown as Record<string, unknown>;
|
||||||
@@ -951,9 +659,10 @@ describe("normalizeCronJobPatch", () => {
|
|||||||
expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true);
|
expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("infers agentTurn kind for fallback-only payload patches", () => {
|
it("normalizes agentTurn fallback-only payload patches", () => {
|
||||||
const normalized = normalizeCronJobPatch({
|
const normalized = normalizeCronJobPatch({
|
||||||
payload: {
|
payload: {
|
||||||
|
kind: "agentTurn",
|
||||||
fallbacks: [" openrouter/gpt-4.1-mini ", "anthropic/claude-haiku-3-5"],
|
fallbacks: [" openrouter/gpt-4.1-mini ", "anthropic/claude-haiku-3-5"],
|
||||||
},
|
},
|
||||||
}) as unknown as Record<string, unknown>;
|
}) as unknown as Record<string, unknown>;
|
||||||
@@ -963,22 +672,24 @@ describe("normalizeCronJobPatch", () => {
|
|||||||
expect(payload.fallbacks).toEqual(["openrouter/gpt-4.1-mini", "anthropic/claude-haiku-3-5"]);
|
expect(payload.fallbacks).toEqual(["openrouter/gpt-4.1-mini", "anthropic/claude-haiku-3-5"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not infer agentTurn kind for malformed fallback-only payload patches", () => {
|
it("drops malformed agentTurn fallback-only payload patches", () => {
|
||||||
const normalized = normalizeCronJobPatch({
|
const normalized = normalizeCronJobPatch({
|
||||||
payload: {
|
payload: {
|
||||||
|
kind: "agentTurn",
|
||||||
fallbacks: [123],
|
fallbacks: [123],
|
||||||
},
|
},
|
||||||
}) as unknown as Record<string, unknown>;
|
}) as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
const payload = normalized.payload as Record<string, unknown>;
|
const payload = normalized.payload as Record<string, unknown>;
|
||||||
expect(payload.kind).toBeUndefined();
|
expect(payload.kind).toBe("agentTurn");
|
||||||
expect(payload.fallbacks).toBeUndefined();
|
expect(payload.fallbacks).toBeUndefined();
|
||||||
expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(false);
|
expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("infers agentTurn kind for toolsAllow-only payload patches", () => {
|
it("normalizes agentTurn toolsAllow-only payload patches", () => {
|
||||||
const normalized = normalizeCronJobPatch({
|
const normalized = normalizeCronJobPatch({
|
||||||
payload: {
|
payload: {
|
||||||
|
kind: "agentTurn",
|
||||||
toolsAllow: [" exec ", " read "],
|
toolsAllow: [" exec ", " read "],
|
||||||
},
|
},
|
||||||
}) as unknown as Record<string, unknown>;
|
}) as unknown as Record<string, unknown>;
|
||||||
@@ -989,22 +700,24 @@ describe("normalizeCronJobPatch", () => {
|
|||||||
expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true);
|
expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not infer agentTurn kind for malformed toolsAllow-only payload patches", () => {
|
it("drops malformed agentTurn toolsAllow-only payload patches", () => {
|
||||||
const normalized = normalizeCronJobPatch({
|
const normalized = normalizeCronJobPatch({
|
||||||
payload: {
|
payload: {
|
||||||
|
kind: "agentTurn",
|
||||||
toolsAllow: [123],
|
toolsAllow: [123],
|
||||||
},
|
},
|
||||||
}) as unknown as Record<string, unknown>;
|
}) as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
const payload = normalized.payload as Record<string, unknown>;
|
const payload = normalized.payload as Record<string, unknown>;
|
||||||
expect(payload.kind).toBeUndefined();
|
expect(payload.kind).toBe("agentTurn");
|
||||||
expect(payload.toolsAllow).toBeUndefined();
|
expect(payload.toolsAllow).toBeUndefined();
|
||||||
expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(false);
|
expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves null toolsAllow so patches can clear the allow-list", () => {
|
it("preserves null toolsAllow so patches can clear the allow-list", () => {
|
||||||
const normalized = normalizeCronJobPatch({
|
const normalized = normalizeCronJobPatch({
|
||||||
payload: {
|
payload: {
|
||||||
|
kind: "agentTurn",
|
||||||
toolsAllow: null,
|
toolsAllow: null,
|
||||||
},
|
},
|
||||||
}) as unknown as Record<string, unknown>;
|
}) as unknown as Record<string, unknown>;
|
||||||
@@ -1014,18 +727,6 @@ describe("normalizeCronJobPatch", () => {
|
|||||||
expect(payload.toolsAllow).toBeNull();
|
expect(payload.toolsAllow).toBeNull();
|
||||||
expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true);
|
expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true);
|
||||||
});
|
});
|
||||||
it("does not infer agentTurn kind for delivery-only legacy hints", () => {
|
|
||||||
const normalized = normalizeCronJobPatch({
|
|
||||||
payload: {
|
|
||||||
channel: "telegram",
|
|
||||||
to: "+15550001111",
|
|
||||||
},
|
|
||||||
}) as unknown as Record<string, unknown>;
|
|
||||||
|
|
||||||
const payload = normalized.payload as Record<string, unknown>;
|
|
||||||
expect(payload.kind).toBeUndefined();
|
|
||||||
expectPayloadDeliveryHintsCleared(payload);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves null sessionKey patches and trims string values", () => {
|
it("preserves null sessionKey patches and trims string values", () => {
|
||||||
const trimmed = normalizeCronJobPatch({
|
const trimmed = normalizeCronJobPatch({
|
||||||
@@ -1067,18 +768,6 @@ describe("normalizeCronJobPatch", () => {
|
|||||||
expect(schedule.staggerMs).toBe(30_000);
|
expect(schedule.staggerMs).toBe(30_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips legacy patch threadId hints from live input", () => {
|
|
||||||
const normalized = normalizeCronJobPatch({
|
|
||||||
payload: {
|
|
||||||
kind: "agentTurn",
|
|
||||||
threadId: 77,
|
|
||||||
},
|
|
||||||
}) as unknown as Record<string, unknown>;
|
|
||||||
|
|
||||||
expect(normalized.delivery).toBeUndefined();
|
|
||||||
expect((normalized.payload as Record<string, unknown>).threadId).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prunes agentTurn-only payload fields from systemEvent patch payloads", () => {
|
it("prunes agentTurn-only payload fields from systemEvent patch payloads", () => {
|
||||||
const normalized = normalizeCronJobPatch({
|
const normalized = normalizeCronJobPatch({
|
||||||
payload: {
|
payload: {
|
||||||
@@ -1120,16 +809,6 @@ describe("normalizeCronJobPatch", () => {
|
|||||||
expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true);
|
expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps out-of-range numeric schedule.atMs invalid instead of throwing for patches", () => {
|
|
||||||
const normalized = normalizeCronJobPatch({
|
|
||||||
schedule: { kind: "at", atMs: 8_640_000_000_000_001 },
|
|
||||||
}) as unknown as Record<string, unknown>;
|
|
||||||
|
|
||||||
const schedule = normalized.schedule as Record<string, unknown>;
|
|
||||||
expect(schedule).toEqual({ kind: "at" });
|
|
||||||
expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prunes staggerMs from every schedules for patches", () => {
|
it("prunes staggerMs from every schedules for patches", () => {
|
||||||
const normalized = normalizeCronJobPatch({
|
const normalized = normalizeCronJobPatch({
|
||||||
schedule: {
|
schedule: {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from "./delivery-field-schemas.js";
|
} from "./delivery-field-schemas.js";
|
||||||
import { parseAbsoluteTimeMs } from "./parse.js";
|
import { parseAbsoluteTimeMs } from "./parse.js";
|
||||||
import { coerceFiniteScheduleNumber } from "./schedule-number.js";
|
import { coerceFiniteScheduleNumber } from "./schedule-number.js";
|
||||||
import { inferLegacyName } from "./service/normalize.js";
|
import { inferCronJobName } from "./service/normalize.js";
|
||||||
import {
|
import {
|
||||||
assertSafeCronSessionTargetId,
|
assertSafeCronSessionTargetId,
|
||||||
resolveCronCurrentSessionTarget,
|
resolveCronCurrentSessionTarget,
|
||||||
@@ -35,22 +35,6 @@ const DEFAULT_OPTIONS: NormalizeOptions = {
|
|||||||
applyDefaults: false,
|
applyDefaults: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function hasTrimmedStringValue(value: unknown) {
|
|
||||||
return parseOptionalField(TrimmedNonEmptyStringFieldSchema, value) !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasAgentTurnPayloadHint(payload: UnknownRecord) {
|
|
||||||
return (
|
|
||||||
hasTrimmedStringValue(payload.model) ||
|
|
||||||
normalizeTrimmedStringArray(payload.fallbacks) !== undefined ||
|
|
||||||
normalizeTrimmedStringArray(payload.toolsAllow, { allowNull: true }) !== undefined ||
|
|
||||||
hasTrimmedStringValue(payload.thinking) ||
|
|
||||||
typeof payload.timeoutSeconds === "number" ||
|
|
||||||
typeof payload.lightContext === "boolean" ||
|
|
||||||
typeof payload.allowUnsafeExternalContent === "boolean"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTrimmedStringArray(
|
function normalizeTrimmedStringArray(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
options?: { allowNull?: boolean },
|
options?: { allowNull?: boolean },
|
||||||
@@ -73,34 +57,13 @@ function coerceSchedule(schedule: UnknownRecord) {
|
|||||||
const rawKind = normalizeLowercaseStringOrEmpty(schedule.kind);
|
const rawKind = normalizeLowercaseStringOrEmpty(schedule.kind);
|
||||||
const kind = rawKind === "at" || rawKind === "every" || rawKind === "cron" ? rawKind : undefined;
|
const kind = rawKind === "at" || rawKind === "every" || rawKind === "cron" ? rawKind : undefined;
|
||||||
const exprRaw = normalizeOptionalString(schedule.expr) ?? "";
|
const exprRaw = normalizeOptionalString(schedule.expr) ?? "";
|
||||||
const legacyCronRaw = normalizeOptionalString(schedule.cron) ?? "";
|
|
||||||
const normalizedExpr = exprRaw || legacyCronRaw;
|
|
||||||
const everyMs = coerceFiniteScheduleNumber(schedule.everyMs);
|
const everyMs = coerceFiniteScheduleNumber(schedule.everyMs);
|
||||||
const anchorMs = coerceFiniteScheduleNumber(schedule.anchorMs);
|
const anchorMs = coerceFiniteScheduleNumber(schedule.anchorMs);
|
||||||
const atMsRaw = schedule.atMs;
|
const atString = normalizeOptionalString(schedule.at) ?? "";
|
||||||
const atRaw = schedule.at;
|
const parsedAtMs = atString ? parseAbsoluteTimeMs(atString) : null;
|
||||||
const atString = normalizeOptionalString(atRaw) ?? "";
|
|
||||||
const parsedAtMs =
|
|
||||||
typeof atMsRaw === "number"
|
|
||||||
? atMsRaw
|
|
||||||
: typeof atMsRaw === "string"
|
|
||||||
? parseAbsoluteTimeMs(atMsRaw)
|
|
||||||
: atString
|
|
||||||
? parseAbsoluteTimeMs(atString)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (kind) {
|
if (kind) {
|
||||||
next.kind = kind;
|
next.kind = kind;
|
||||||
} else if (
|
|
||||||
typeof schedule.atMs === "number" ||
|
|
||||||
typeof schedule.at === "string" ||
|
|
||||||
typeof schedule.atMs === "string"
|
|
||||||
) {
|
|
||||||
next.kind = "at";
|
|
||||||
} else if (everyMs !== undefined) {
|
|
||||||
next.kind = "every";
|
|
||||||
} else if (normalizedExpr) {
|
|
||||||
next.kind = "cron";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedAtIso = parsedAtMs !== null ? timestampMsToIsoString(parsedAtMs) : undefined;
|
const parsedAtIso = parsedAtMs !== null ? timestampMsToIsoString(parsedAtMs) : undefined;
|
||||||
@@ -109,12 +72,9 @@ function coerceSchedule(schedule: UnknownRecord) {
|
|||||||
} else if (parsedAtIso !== undefined) {
|
} else if (parsedAtIso !== undefined) {
|
||||||
next.at = parsedAtIso;
|
next.at = parsedAtIso;
|
||||||
}
|
}
|
||||||
if ("atMs" in next) {
|
|
||||||
delete next.atMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedExpr) {
|
if (exprRaw) {
|
||||||
next.expr = normalizedExpr;
|
next.expr = exprRaw;
|
||||||
} else if ("expr" in next) {
|
} else if ("expr" in next) {
|
||||||
delete next.expr;
|
delete next.expr;
|
||||||
}
|
}
|
||||||
@@ -125,10 +85,6 @@ function coerceSchedule(schedule: UnknownRecord) {
|
|||||||
if (anchorMs !== undefined && anchorMs >= 0) {
|
if (anchorMs !== undefined && anchorMs >= 0) {
|
||||||
next.anchorMs = Math.floor(anchorMs);
|
next.anchorMs = Math.floor(anchorMs);
|
||||||
}
|
}
|
||||||
if ("cron" in next) {
|
|
||||||
delete next.cron;
|
|
||||||
}
|
|
||||||
|
|
||||||
const staggerMs = normalizeCronStaggerMs(schedule.staggerMs);
|
const staggerMs = normalizeCronStaggerMs(schedule.staggerMs);
|
||||||
if (staggerMs !== undefined) {
|
if (staggerMs !== undefined) {
|
||||||
next.staggerMs = staggerMs;
|
next.staggerMs = staggerMs;
|
||||||
@@ -156,21 +112,6 @@ function coerceSchedule(schedule: UnknownRecord) {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferTopLevelSchedule(next: UnknownRecord): UnknownRecord | null {
|
|
||||||
const kindRaw = normalizeLowercaseStringOrEmpty(next.kind);
|
|
||||||
const kind = kindRaw === "at" || kindRaw === "every" || kindRaw === "cron" ? kindRaw : undefined;
|
|
||||||
const schedule: UnknownRecord = {};
|
|
||||||
if (kind) {
|
|
||||||
schedule.kind = kind;
|
|
||||||
}
|
|
||||||
for (const field of ["at", "atMs", "everyMs", "anchorMs", "expr", "cron", "tz", "staggerMs"]) {
|
|
||||||
if (field in next) {
|
|
||||||
schedule[field] = next[field];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Object.keys(schedule).length > 0 ? coerceSchedule(schedule) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function coercePayload(payload: UnknownRecord) {
|
function coercePayload(payload: UnknownRecord) {
|
||||||
const next: UnknownRecord = { ...payload };
|
const next: UnknownRecord = { ...payload };
|
||||||
const kindRaw = normalizeLowercaseStringOrEmpty(next.kind);
|
const kindRaw = normalizeLowercaseStringOrEmpty(next.kind);
|
||||||
@@ -181,22 +122,6 @@ function coercePayload(payload: UnknownRecord) {
|
|||||||
} else if (kindRaw) {
|
} else if (kindRaw) {
|
||||||
next.kind = kindRaw;
|
next.kind = kindRaw;
|
||||||
}
|
}
|
||||||
if (!next.kind) {
|
|
||||||
const message = normalizeOptionalString(next.message);
|
|
||||||
const text = normalizeOptionalString(next.text);
|
|
||||||
const hasAgentTurnHint = hasAgentTurnPayloadHint(next);
|
|
||||||
if (message) {
|
|
||||||
next.kind = "agentTurn";
|
|
||||||
} else if (text && hasAgentTurnHint) {
|
|
||||||
next.kind = "agentTurn";
|
|
||||||
next.message = text;
|
|
||||||
} else if (text) {
|
|
||||||
next.kind = "systemEvent";
|
|
||||||
} else if (hasAgentTurnHint) {
|
|
||||||
// Accept partial agentTurn payload patches that only tweak agent-turn-only fields.
|
|
||||||
next.kind = "agentTurn";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof next.message === "string") {
|
if (typeof next.message === "string") {
|
||||||
const trimmed = normalizeOptionalString(next.message) ?? "";
|
const trimmed = normalizeOptionalString(next.message) ?? "";
|
||||||
if (trimmed) {
|
if (trimmed) {
|
||||||
@@ -267,24 +192,6 @@ function coercePayload(payload: UnknownRecord) {
|
|||||||
} else if (next.kind === "agentTurn") {
|
} else if (next.kind === "agentTurn") {
|
||||||
delete next.text;
|
delete next.text;
|
||||||
}
|
}
|
||||||
if ("deliver" in next) {
|
|
||||||
delete next.deliver;
|
|
||||||
}
|
|
||||||
if ("channel" in next) {
|
|
||||||
delete next.channel;
|
|
||||||
}
|
|
||||||
if ("to" in next) {
|
|
||||||
delete next.to;
|
|
||||||
}
|
|
||||||
if ("threadId" in next) {
|
|
||||||
delete next.threadId;
|
|
||||||
}
|
|
||||||
if ("bestEffortDeliver" in next) {
|
|
||||||
delete next.bestEffortDeliver;
|
|
||||||
}
|
|
||||||
if ("provider" in next) {
|
|
||||||
delete next.provider;
|
|
||||||
}
|
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,37 +252,6 @@ function coerceCompletionDestination(value: UnknownRecord) {
|
|||||||
} satisfies UnknownRecord;
|
} satisfies UnknownRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferTopLevelPayload(next: UnknownRecord) {
|
|
||||||
const message = normalizeOptionalString(next.message) ?? "";
|
|
||||||
if (message) {
|
|
||||||
return { kind: "agentTurn", message } satisfies UnknownRecord;
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = normalizeOptionalString(next.text) ?? "";
|
|
||||||
if (text) {
|
|
||||||
if (hasAgentTurnPayloadHint(next)) {
|
|
||||||
return { kind: "agentTurn", message: text } satisfies UnknownRecord;
|
|
||||||
}
|
|
||||||
return { kind: "systemEvent", text } satisfies UnknownRecord;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasAgentTurnPayloadHint(next)) {
|
|
||||||
return { kind: "agentTurn" } satisfies UnknownRecord;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function unwrapJob(raw: UnknownRecord) {
|
|
||||||
if (isRecord(raw.data)) {
|
|
||||||
return raw.data;
|
|
||||||
}
|
|
||||||
if (isRecord(raw.job)) {
|
|
||||||
return raw.job;
|
|
||||||
}
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSessionTarget(raw: unknown) {
|
function normalizeSessionTarget(raw: unknown) {
|
||||||
if (typeof raw !== "string") {
|
if (typeof raw !== "string") {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -403,80 +279,6 @@ function normalizeWakeMode(raw: unknown) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyTopLevelAgentTurnFields(next: UnknownRecord, payload: UnknownRecord) {
|
|
||||||
const copyString = (field: "model" | "thinking") => {
|
|
||||||
if (normalizeOptionalString(payload[field])) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const value = next[field];
|
|
||||||
const normalized = normalizeOptionalString(value);
|
|
||||||
if (normalized) {
|
|
||||||
payload[field] = normalized;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
copyString("model");
|
|
||||||
copyString("thinking");
|
|
||||||
|
|
||||||
if (typeof payload.timeoutSeconds !== "number" && "timeoutSeconds" in next) {
|
|
||||||
const timeoutSeconds = parseOptionalField(TimeoutSecondsFieldSchema, next.timeoutSeconds);
|
|
||||||
if (timeoutSeconds !== undefined) {
|
|
||||||
payload.timeoutSeconds = timeoutSeconds;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!Array.isArray(payload.fallbacks) && Array.isArray(next.fallbacks)) {
|
|
||||||
const fallbacks = normalizeTrimmedStringArray(next.fallbacks);
|
|
||||||
if (fallbacks !== undefined) {
|
|
||||||
payload.fallbacks = fallbacks;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!("toolsAllow" in payload) || payload.toolsAllow === undefined) {
|
|
||||||
const toolsAllow =
|
|
||||||
normalizeTrimmedStringArray(next.toolsAllow, { allowNull: true }) ??
|
|
||||||
normalizeTrimmedStringArray(next.tools);
|
|
||||||
if (toolsAllow !== undefined) {
|
|
||||||
payload.toolsAllow = toolsAllow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof payload.lightContext !== "boolean" && typeof next.lightContext === "boolean") {
|
|
||||||
payload.lightContext = next.lightContext;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
typeof payload.allowUnsafeExternalContent !== "boolean" &&
|
|
||||||
typeof next.allowUnsafeExternalContent === "boolean"
|
|
||||||
) {
|
|
||||||
payload.allowUnsafeExternalContent = next.allowUnsafeExternalContent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripLegacyTopLevelFields(next: UnknownRecord) {
|
|
||||||
delete next.model;
|
|
||||||
delete next.thinking;
|
|
||||||
delete next.timeoutSeconds;
|
|
||||||
delete next.fallbacks;
|
|
||||||
delete next.lightContext;
|
|
||||||
delete next.toolsAllow;
|
|
||||||
delete next.allowUnsafeExternalContent;
|
|
||||||
delete next.message;
|
|
||||||
delete next.text;
|
|
||||||
delete next.kind;
|
|
||||||
delete next.cron;
|
|
||||||
delete next.tz;
|
|
||||||
delete next.at;
|
|
||||||
delete next.atMs;
|
|
||||||
delete next.everyMs;
|
|
||||||
delete next.anchorMs;
|
|
||||||
delete next.staggerMs;
|
|
||||||
delete next.session;
|
|
||||||
delete next.tools;
|
|
||||||
delete next.deliver;
|
|
||||||
delete next.channel;
|
|
||||||
delete next.to;
|
|
||||||
delete next.toolsAllow;
|
|
||||||
delete next.threadId;
|
|
||||||
delete next.bestEffortDeliver;
|
|
||||||
delete next.provider;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeCronJobInput(
|
export function normalizeCronJobInput(
|
||||||
raw: unknown,
|
raw: unknown,
|
||||||
options: NormalizeOptions = DEFAULT_OPTIONS,
|
options: NormalizeOptions = DEFAULT_OPTIONS,
|
||||||
@@ -484,7 +286,7 @@ export function normalizeCronJobInput(
|
|||||||
if (!isRecord(raw)) {
|
if (!isRecord(raw)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const base = unwrapJob(raw);
|
const base = raw;
|
||||||
const next: UnknownRecord = { ...base };
|
const next: UnknownRecord = { ...base };
|
||||||
|
|
||||||
if ("agentId" in base) {
|
if ("agentId" in base) {
|
||||||
@@ -537,11 +339,6 @@ export function normalizeCronJobInput(
|
|||||||
} else {
|
} else {
|
||||||
delete next.sessionTarget;
|
delete next.sessionTarget;
|
||||||
}
|
}
|
||||||
} else if ("session" in base) {
|
|
||||||
const normalized = normalizeSessionTarget(base.session);
|
|
||||||
if (normalized) {
|
|
||||||
next.sessionTarget = normalized;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("wakeMode" in base) {
|
if ("wakeMode" in base) {
|
||||||
@@ -555,18 +352,6 @@ export function normalizeCronJobInput(
|
|||||||
|
|
||||||
if (isRecord(base.schedule)) {
|
if (isRecord(base.schedule)) {
|
||||||
next.schedule = coerceSchedule(base.schedule);
|
next.schedule = coerceSchedule(base.schedule);
|
||||||
} else if (!isRecord(next.schedule)) {
|
|
||||||
const inferredSchedule = inferTopLevelSchedule(next);
|
|
||||||
if (inferredSchedule) {
|
|
||||||
next.schedule = inferredSchedule;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!("payload" in next) || !isRecord(next.payload)) {
|
|
||||||
const inferredPayload = inferTopLevelPayload(next);
|
|
||||||
if (inferredPayload) {
|
|
||||||
next.payload = inferredPayload;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRecord(base.payload)) {
|
if (isRecord(base.payload)) {
|
||||||
@@ -577,16 +362,6 @@ export function normalizeCronJobInput(
|
|||||||
next.delivery = coerceDelivery(base.delivery);
|
next.delivery = coerceDelivery(base.delivery);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("isolation" in next) {
|
|
||||||
delete next.isolation;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = isRecord(next.payload) ? next.payload : null;
|
|
||||||
if (payload && payload.kind === "agentTurn") {
|
|
||||||
copyTopLevelAgentTurnFields(next, payload);
|
|
||||||
}
|
|
||||||
stripLegacyTopLevelFields(next);
|
|
||||||
|
|
||||||
if (options.applyDefaults) {
|
if (options.applyDefaults) {
|
||||||
if (!next.wakeMode) {
|
if (!next.wakeMode) {
|
||||||
next.wakeMode = "now";
|
next.wakeMode = "now";
|
||||||
@@ -599,7 +374,7 @@ export function normalizeCronJobInput(
|
|||||||
isRecord(next.schedule) &&
|
isRecord(next.schedule) &&
|
||||||
isRecord(next.payload)
|
isRecord(next.payload)
|
||||||
) {
|
) {
|
||||||
next.name = inferLegacyName({
|
next.name = inferCronJobName({
|
||||||
schedule: next.schedule as { kind?: unknown; everyMs?: unknown; expr?: unknown },
|
schedule: next.schedule as { kind?: unknown; everyMs?: unknown; expr?: unknown },
|
||||||
payload: next.payload as { kind?: unknown; text?: unknown; message?: unknown },
|
payload: next.payload as { kind?: unknown; text?: unknown; message?: unknown },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,9 +27,8 @@ function schedulePayloadFromRecord(
|
|||||||
| { kind: "cron"; expr: string; tz?: string; staggerMs?: number }
|
| { kind: "cron"; expr: string; tz?: string; staggerMs?: number }
|
||||||
| undefined {
|
| undefined {
|
||||||
const rawKind = readString(schedule, "kind")?.toLowerCase();
|
const rawKind = readString(schedule, "kind")?.toLowerCase();
|
||||||
const expr = readString(schedule, "expr") ?? readString(schedule, "cron");
|
const expr = readString(schedule, "expr");
|
||||||
const at = readString(schedule, "at");
|
const at = readString(schedule, "at");
|
||||||
const atMs = readNumber(schedule, "atMs");
|
|
||||||
const everyMs = readNumber(schedule, "everyMs");
|
const everyMs = readNumber(schedule, "everyMs");
|
||||||
const anchorMs = readNumber(schedule, "anchorMs");
|
const anchorMs = readNumber(schedule, "anchorMs");
|
||||||
const tz = readString(schedule, "tz");
|
const tz = readString(schedule, "tz");
|
||||||
@@ -37,7 +36,7 @@ function schedulePayloadFromRecord(
|
|||||||
const kind =
|
const kind =
|
||||||
rawKind === "at" || rawKind === "every" || rawKind === "cron"
|
rawKind === "at" || rawKind === "every" || rawKind === "cron"
|
||||||
? rawKind
|
? rawKind
|
||||||
: at || atMs !== undefined
|
: at
|
||||||
? "at"
|
? "at"
|
||||||
: everyMs !== undefined
|
: everyMs !== undefined
|
||||||
? "every"
|
? "every"
|
||||||
@@ -46,11 +45,7 @@ function schedulePayloadFromRecord(
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (kind === "at") {
|
if (kind === "at") {
|
||||||
return at
|
return at ? { kind: "at", at } : undefined;
|
||||||
? { kind: "at", at }
|
|
||||||
: atMs !== undefined
|
|
||||||
? { kind: "at", at: String(atMs) }
|
|
||||||
: undefined;
|
|
||||||
}
|
}
|
||||||
if (kind === "every" && everyMs !== undefined) {
|
if (kind === "every" && everyMs !== undefined) {
|
||||||
return { kind: "every", everyMs, anchorMs };
|
return { kind: "every", everyMs, anchorMs };
|
||||||
@@ -67,7 +62,7 @@ function resolveSchedulePayload(
|
|||||||
if (job.schedule && typeof job.schedule === "object" && !Array.isArray(job.schedule)) {
|
if (job.schedule && typeof job.schedule === "object" && !Array.isArray(job.schedule)) {
|
||||||
return schedulePayloadFromRecord(job.schedule as Record<string, unknown>);
|
return schedulePayloadFromRecord(job.schedule as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
return schedulePayloadFromRecord(job);
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tryCronScheduleIdentity(job: CronScheduleIdentityInput): string | undefined {
|
export function tryCronScheduleIdentity(job: CronScheduleIdentityInput): string | undefined {
|
||||||
|
|||||||
@@ -58,19 +58,6 @@ describe("cron schedule", () => {
|
|||||||
).toThrow("invalid cron schedule: expr is required");
|
).toThrow("invalid cron schedule: expr is required");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports legacy cron field when expr is missing", () => {
|
|
||||||
const nowMs = Date.parse("2025-12-13T00:00:00.000Z");
|
|
||||||
const next = computeNextRunAtMs(
|
|
||||||
{
|
|
||||||
kind: "cron",
|
|
||||||
cron: "0 9 * * 3",
|
|
||||||
tz: "America/Los_Angeles",
|
|
||||||
} as unknown as { kind: "cron"; expr: string; tz?: string },
|
|
||||||
nowMs,
|
|
||||||
);
|
|
||||||
expect(next).toBe(Date.parse("2025-12-17T17:00:00.000Z"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("computes next run for every schedule", () => {
|
it("computes next run for every schedule", () => {
|
||||||
const anchor = Date.parse("2025-12-13T00:00:00.000Z");
|
const anchor = Date.parse("2025-12-13T00:00:00.000Z");
|
||||||
const now = anchor + 10_000;
|
const now = anchor + 10_000;
|
||||||
@@ -86,7 +73,7 @@ describe("cron schedule", () => {
|
|||||||
expect(next).toBe(now + 30_000);
|
expect(next).toBe(now + 30_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles string-typed everyMs and anchorMs from legacy persisted data", () => {
|
it("handles string-typed everyMs and anchorMs", () => {
|
||||||
const anchor = Date.parse("2025-12-13T00:00:00.000Z");
|
const anchor = Date.parse("2025-12-13T00:00:00.000Z");
|
||||||
const now = anchor + 10_000;
|
const now = anchor + 10_000;
|
||||||
const next = computeNextRunAtMs(
|
const next = computeNextRunAtMs(
|
||||||
|
|||||||
@@ -37,16 +37,11 @@ function resolveCachedCron(expr: string, timezone: string): Cron {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveCronFromSchedule(schedule: {
|
function resolveCronFromSchedule(schedule: { tz?: string; expr?: unknown }): Cron | undefined {
|
||||||
tz?: string;
|
if (typeof schedule.expr !== "string") {
|
||||||
expr?: unknown;
|
|
||||||
cron?: unknown;
|
|
||||||
}): Cron | undefined {
|
|
||||||
const exprSource = typeof schedule.expr === "string" ? schedule.expr : schedule.cron;
|
|
||||||
if (typeof exprSource !== "string") {
|
|
||||||
throw new Error("invalid cron schedule: expr is required");
|
throw new Error("invalid cron schedule: expr is required");
|
||||||
}
|
}
|
||||||
const expr = exprSource.trim();
|
const expr = schedule.expr.trim();
|
||||||
if (!expr) {
|
if (!expr) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -55,18 +50,7 @@ function resolveCronFromSchedule(schedule: {
|
|||||||
|
|
||||||
export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): number | undefined {
|
export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): number | undefined {
|
||||||
if (schedule.kind === "at") {
|
if (schedule.kind === "at") {
|
||||||
// Handle both canonical `at` (string) and legacy `atMs` (number) fields.
|
const atMs = parseAbsoluteTimeMs(schedule.at);
|
||||||
// The store migration should convert atMs→at, but be defensive in case
|
|
||||||
// the migration hasn't run yet or was bypassed.
|
|
||||||
const sched = schedule as { at?: string; atMs?: number | string };
|
|
||||||
const atMs =
|
|
||||||
typeof sched.atMs === "number" && Number.isFinite(sched.atMs) && sched.atMs > 0
|
|
||||||
? sched.atMs
|
|
||||||
: typeof sched.atMs === "string"
|
|
||||||
? parseAbsoluteTimeMs(sched.atMs)
|
|
||||||
: typeof sched.at === "string"
|
|
||||||
? parseAbsoluteTimeMs(sched.at)
|
|
||||||
: null;
|
|
||||||
if (atMs === null) {
|
if (atMs === null) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -89,7 +73,7 @@ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): numbe
|
|||||||
return anchor + steps * everyMs;
|
return anchor + steps * everyMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cron = resolveCronFromSchedule(schedule as { tz?: string; expr?: unknown; cron?: unknown });
|
const cron = resolveCronFromSchedule(schedule);
|
||||||
if (!cron) {
|
if (!cron) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -134,7 +118,7 @@ export function computePreviousRunAtMs(schedule: CronSchedule, nowMs: number): n
|
|||||||
if (schedule.kind !== "cron") {
|
if (schedule.kind !== "cron") {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const cron = resolveCronFromSchedule(schedule as { tz?: string; expr?: unknown; cron?: unknown });
|
const cron = resolveCronFromSchedule(schedule);
|
||||||
if (!cron) {
|
if (!cron) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ describe("CronService interval/cron jobs fire on time", () => {
|
|||||||
await store.cleanup();
|
await store.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps legacy every jobs due while minute cron jobs recompute schedules", async () => {
|
it("keeps every jobs due while minute cron jobs recompute schedules", async () => {
|
||||||
const store = await makeStorePath();
|
const store = await makeStorePath();
|
||||||
const enqueueSystemEvent = vi.fn();
|
const enqueueSystemEvent = vi.fn();
|
||||||
const requestHeartbeat = vi.fn();
|
const requestHeartbeat = vi.fn();
|
||||||
@@ -147,8 +147,8 @@ describe("CronService interval/cron jobs fire on time", () => {
|
|||||||
storePath: store.storePath,
|
storePath: store.storePath,
|
||||||
jobs: [
|
jobs: [
|
||||||
{
|
{
|
||||||
id: "legacy-every",
|
id: "loaded-every",
|
||||||
name: "legacy every",
|
name: "loaded every",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAtMs: nowMs,
|
createdAtMs: nowMs,
|
||||||
updatedAtMs: nowMs,
|
updatedAtMs: nowMs,
|
||||||
@@ -183,7 +183,7 @@ describe("CronService interval/cron jobs fire on time", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await cron.start();
|
await cron.start();
|
||||||
// Perf: a few recomputation cycles are enough to catch legacy "every" drift.
|
// Perf: a few recomputation cycles are enough to catch "every" drift.
|
||||||
for (let minute = 1; minute <= 3; minute++) {
|
for (let minute = 1; minute <= 3; minute++) {
|
||||||
vi.setSystemTime(new Date(nowMs + minute * 60_000));
|
vi.setSystemTime(new Date(nowMs + minute * 60_000));
|
||||||
const minuteRun = await cron.run("minute-cron", "force");
|
const minuteRun = await cron.run("minute-cron", "force");
|
||||||
@@ -192,7 +192,7 @@ describe("CronService interval/cron jobs fire on time", () => {
|
|||||||
|
|
||||||
// "every" cadence is 2m; verify it stays due at the 6-minute boundary.
|
// "every" cadence is 2m; verify it stays due at the 6-minute boundary.
|
||||||
vi.setSystemTime(new Date(nowMs + 6 * 60_000));
|
vi.setSystemTime(new Date(nowMs + 6 * 60_000));
|
||||||
const sfRun = await cron.run("legacy-every", "due");
|
const sfRun = await cron.run("loaded-every", "due");
|
||||||
expect(sfRun).toEqual({ ok: true, ran: true });
|
expect(sfRun).toEqual({ ok: true, ran: true });
|
||||||
|
|
||||||
const sfRuns = countMainSystemEvents(enqueueSystemEvent, "sf-tick");
|
const sfRuns = countMainSystemEvents(enqueueSystemEvent, "sf-tick");
|
||||||
@@ -201,7 +201,7 @@ describe("CronService interval/cron jobs fire on time", () => {
|
|||||||
expect(sfRuns).toBeGreaterThan(0);
|
expect(sfRuns).toBeGreaterThan(0);
|
||||||
|
|
||||||
const jobs = await cron.list({ includeDisabled: true });
|
const jobs = await cron.list({ includeDisabled: true });
|
||||||
const sfJob = jobs.find((job) => job.id === "legacy-every");
|
const sfJob = jobs.find((job) => job.id === "loaded-every");
|
||||||
expect(sfJob?.state.lastStatus).toBe("ok");
|
expect(sfJob?.state.lastStatus).toBe("ok");
|
||||||
expect(sfJob?.schedule.kind).toBe("every");
|
expect(sfJob?.schedule.kind).toBe("every");
|
||||||
expect(sfJob?.state.nextRunAtMs).toBe(nowMs + 8 * 60_000);
|
expect(sfJob?.state.nextRunAtMs).toBe(nowMs + 8 * 60_000);
|
||||||
|
|||||||
@@ -42,17 +42,6 @@ describe("Cron issue #19676 at-job reschedule", () => {
|
|||||||
expect(computeJobNextRunAtMs(job, nowMs)).toBe(RESCHEDULED_AT_MS);
|
expect(computeJobNextRunAtMs(job, nowMs)).toBe(RESCHEDULED_AT_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns the new atMs when rescheduled via legacy numeric atMs field", () => {
|
|
||||||
const job = createAtJob({
|
|
||||||
state: { lastStatus: "ok", lastRunAtMs: LAST_RUN_AT_MS },
|
|
||||||
});
|
|
||||||
// Simulate legacy numeric atMs field on the schedule object.
|
|
||||||
const schedule = job.schedule as { kind: "at"; atMs?: number };
|
|
||||||
schedule.atMs = RESCHEDULED_AT_MS;
|
|
||||||
const nowMs = LAST_RUN_AT_MS + 1_000;
|
|
||||||
expect(computeJobNextRunAtMs(job, nowMs)).toBe(RESCHEDULED_AT_MS);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns undefined when rescheduled to a time before the last run", () => {
|
it("returns undefined when rescheduled to a time before the last run", () => {
|
||||||
const beforeLastRun = LAST_RUN_AT_MS - 60_000;
|
const beforeLastRun = LAST_RUN_AT_MS - 60_000;
|
||||||
const job = createAtJob({
|
const job = createAtJob({
|
||||||
|
|||||||
@@ -836,29 +836,15 @@ describe("createJob delivery defaults", () => {
|
|||||||
});
|
});
|
||||||
expect(job.delivery).toBeUndefined();
|
expect(job.delivery).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses legacy systemEvent message text without throwing", () => {
|
|
||||||
const state = createMockState(now, { defaultAgentId: "main" });
|
|
||||||
const job = createJob(state, {
|
|
||||||
name: "legacy system event",
|
|
||||||
enabled: true,
|
|
||||||
schedule: { kind: "every", everyMs: 60_000 },
|
|
||||||
sessionTarget: "main",
|
|
||||||
wakeMode: "now",
|
|
||||||
payload: { kind: "systemEvent", message: "legacy text" } as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(resolveJobPayloadTextForMain(job)).toBe("legacy text");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("recomputeNextRuns", () => {
|
describe("recomputeNextRuns", () => {
|
||||||
it("backfills missing every anchorMs for legacy loaded jobs", () => {
|
it("backfills missing every anchorMs for loaded jobs", () => {
|
||||||
const now = Date.parse("2026-03-01T12:00:00.000Z");
|
const now = Date.parse("2026-03-01T12:00:00.000Z");
|
||||||
const createdAtMs = now - 120_000;
|
const createdAtMs = now - 120_000;
|
||||||
const job: CronJob = {
|
const job: CronJob = {
|
||||||
id: "legacy-every",
|
id: "loaded-every",
|
||||||
name: "legacy-every",
|
name: "loaded-every",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAtMs,
|
createdAtMs,
|
||||||
updatedAtMs: createdAtMs,
|
updatedAtMs: createdAtMs,
|
||||||
|
|||||||
@@ -400,18 +400,7 @@ export function computeJobNextRunAtMs(job: CronJob, nowMs: number): number | und
|
|||||||
return isFiniteTimestamp(next) ? next : undefined;
|
return isFiniteTimestamp(next) ? next : undefined;
|
||||||
}
|
}
|
||||||
if (job.schedule.kind === "at") {
|
if (job.schedule.kind === "at") {
|
||||||
// Handle both canonical `at` (string) and legacy `atMs` (number) fields.
|
const atMs = parseAbsoluteTimeMs(job.schedule.at);
|
||||||
// The store migration should convert atMs→at, but be defensive in case
|
|
||||||
// the migration hasn't run yet or was bypassed.
|
|
||||||
const schedule = job.schedule as { at?: string; atMs?: number | string };
|
|
||||||
const atMs =
|
|
||||||
typeof schedule.atMs === "number" && Number.isFinite(schedule.atMs) && schedule.atMs > 0
|
|
||||||
? schedule.atMs
|
|
||||||
: typeof schedule.atMs === "string"
|
|
||||||
? parseAbsoluteTimeMs(schedule.atMs)
|
|
||||||
: typeof schedule.at === "string"
|
|
||||||
? parseAbsoluteTimeMs(schedule.at)
|
|
||||||
: null;
|
|
||||||
// One-shot jobs stay due until they successfully finish, but if the
|
// One-shot jobs stay due until they successfully finish, but if the
|
||||||
// schedule was updated to a time after the last run, re-arm the job.
|
// schedule was updated to a time after the last run, re-arm the job.
|
||||||
if (job.state.lastStatus === "ok" && job.state.lastRunAtMs) {
|
if (job.state.lastStatus === "ok" && job.state.lastRunAtMs) {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function normalizeOptionalAgentId(raw: unknown) {
|
|||||||
return normalizeAgentId(trimmed);
|
return normalizeAgentId(trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function inferLegacyName(job: {
|
export function inferCronJobName(job: {
|
||||||
schedule?: { kind?: unknown; everyMs?: unknown; expr?: unknown };
|
schedule?: { kind?: unknown; everyMs?: unknown; expr?: unknown };
|
||||||
payload?: { kind?: unknown; text?: unknown; message?: unknown };
|
payload?: { kind?: unknown; text?: unknown; message?: unknown };
|
||||||
}) {
|
}) {
|
||||||
@@ -63,12 +63,7 @@ export function inferLegacyName(job: {
|
|||||||
|
|
||||||
export function normalizePayloadToSystemText(payload: CronPayload) {
|
export function normalizePayloadToSystemText(payload: CronPayload) {
|
||||||
if (payload.kind === "systemEvent") {
|
if (payload.kind === "systemEvent") {
|
||||||
const text = (payload as { text?: unknown }).text;
|
return typeof payload.text === "string" ? payload.text.trim() : "";
|
||||||
if (typeof text === "string") {
|
|
||||||
return text.trim();
|
|
||||||
}
|
|
||||||
const legacyMessage = (payload as { message?: unknown }).message;
|
|
||||||
return typeof legacyMessage === "string" ? legacyMessage.trim() : "";
|
|
||||||
}
|
}
|
||||||
return typeof payload.message === "string" ? payload.message.trim() : "";
|
return typeof payload.message === "string" ? payload.message.trim() : "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import * as detachedTaskRuntime from "../../tasks/detached-task-runtime.js";
|
|||||||
import { findTaskByRunId, resetTaskRegistryForTests } from "../../tasks/task-registry.js";
|
import { findTaskByRunId, resetTaskRegistryForTests } from "../../tasks/task-registry.js";
|
||||||
import { formatTaskStatusDetail } from "../../tasks/task-status.js";
|
import { formatTaskStatusDetail } from "../../tasks/task-status.js";
|
||||||
import { setupCronServiceSuite, writeCronStoreSnapshot } from "../service.test-harness.js";
|
import { setupCronServiceSuite, writeCronStoreSnapshot } from "../service.test-harness.js";
|
||||||
import { loadCronStore } from "../store.js";
|
import { loadCronStore, loadCronStoreWithConfigJobs } from "../store.js";
|
||||||
import type { CronJob } from "../types.js";
|
import type { CronJob } from "../types.js";
|
||||||
import { add, run, start, stop, update } from "./ops.js";
|
import { add, run, start, stop, update } from "./ops.js";
|
||||||
import { createCronServiceState } from "./state.js";
|
import { createCronServiceState } from "./state.js";
|
||||||
@@ -113,23 +113,34 @@ function insertCronJobRow(storePath: string, job: CronJob) {
|
|||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO cron_jobs (
|
`INSERT INTO cron_jobs (
|
||||||
store_key, job_id, name, enabled, created_at_ms, schedule_kind,
|
store_key, job_id, name, enabled, created_at_ms, schedule_kind,
|
||||||
session_target, wake_mode, payload_kind, payload_message, job_json, state_json, updated_at
|
at, every_ms, anchor_ms, schedule_expr, session_target, wake_mode, payload_kind,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
payload_message, delivery_mode, delivery_to, job_json, state_json, updated_at
|
||||||
).run(
|
) VALUES (
|
||||||
path.resolve(storePath),
|
$storeKey, $jobId, $name, $enabled, $createdAtMs, $scheduleKind,
|
||||||
job.id,
|
$at, $everyMs, $anchorMs, $scheduleExpr, $sessionTarget, $wakeMode, $payloadKind,
|
||||||
job.name,
|
$payloadMessage, $deliveryMode, $deliveryTo, $jobJson, $stateJson, $updatedAt
|
||||||
job.enabled ? 1 : 0,
|
)`,
|
||||||
job.createdAtMs,
|
).run({
|
||||||
job.schedule.kind,
|
$storeKey: path.resolve(storePath),
|
||||||
job.sessionTarget,
|
$jobId: job.id,
|
||||||
job.wakeMode,
|
$name: job.name,
|
||||||
job.payload.kind,
|
$enabled: job.enabled ? 1 : 0,
|
||||||
"message" in job.payload ? job.payload.message : null,
|
$createdAtMs: job.createdAtMs,
|
||||||
JSON.stringify(job),
|
$scheduleKind: job.schedule.kind,
|
||||||
JSON.stringify(job.state),
|
$at: job.schedule.kind === "at" ? job.schedule.at : null,
|
||||||
job.updatedAtMs,
|
$everyMs: job.schedule.kind === "every" ? job.schedule.everyMs : null,
|
||||||
);
|
$anchorMs: job.schedule.kind === "every" ? (job.schedule.anchorMs ?? null) : null,
|
||||||
|
$scheduleExpr: job.schedule.kind === "cron" ? job.schedule.expr : null,
|
||||||
|
$sessionTarget: job.sessionTarget,
|
||||||
|
$wakeMode: job.wakeMode,
|
||||||
|
$payloadKind: job.payload.kind,
|
||||||
|
$payloadMessage: "message" in job.payload ? job.payload.message : null,
|
||||||
|
$deliveryMode: job.delivery ? (job.delivery.mode ?? "announce") : null,
|
||||||
|
$deliveryTo: job.delivery?.to ?? null,
|
||||||
|
$jobJson: JSON.stringify(job),
|
||||||
|
$stateJson: JSON.stringify(job.state),
|
||||||
|
$updatedAt: job.updatedAtMs,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +260,7 @@ describe("cron service ops seam coverage", () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
createdAtMs: now - 60_000,
|
createdAtMs: now - 60_000,
|
||||||
updatedAtMs: now - 60_000,
|
updatedAtMs: now - 60_000,
|
||||||
schedule: { kind: "every", everyMs: 3_600_000 },
|
schedule: { kind: "every", everyMs: 3_600_000, anchorMs: now },
|
||||||
sessionTarget: "isolated",
|
sessionTarget: "isolated",
|
||||||
wakeMode: "next-heartbeat",
|
wakeMode: "next-heartbeat",
|
||||||
payload: { kind: "agentTurn", message: "do work" },
|
payload: { kind: "agentTurn", message: "do work" },
|
||||||
@@ -274,13 +285,14 @@ describe("cron service ops seam coverage", () => {
|
|||||||
clearTimeout(state.timer);
|
clearTimeout(state.timer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const loaded = await loadCronStore(storePath);
|
const loaded = await loadCronStoreWithConfigJobs(storePath);
|
||||||
const persisted = loaded.jobs[0] as CronJob & { notify?: unknown };
|
const persisted = loaded.store.jobs[0] as CronJob & { notify?: unknown };
|
||||||
expect(persisted.notify).toBe(true);
|
expect(persisted.notify).toBeUndefined();
|
||||||
expect(persisted.delivery).toEqual({
|
expect(persisted.delivery).toEqual({
|
||||||
mode: "announce",
|
mode: "announce",
|
||||||
to: "telegram:chat-1",
|
to: "telegram:chat-1",
|
||||||
});
|
});
|
||||||
|
expect(loaded.configJobs[0]?.notify).toBe(true);
|
||||||
expect(logger.info).not.toHaveBeenCalledWith(
|
expect(logger.info).not.toHaveBeenCalledWith(
|
||||||
{ storePath },
|
{ storePath },
|
||||||
"cron: migrated legacy notify fallback jobs before scheduler startup",
|
"cron: migrated legacy notify fallback jobs before scheduler startup",
|
||||||
|
|||||||
@@ -274,28 +274,6 @@ describe("cron service store seam coverage", () => {
|
|||||||
expect(findJobOrThrow(state, "reload-cron-expr-job").state.nextRunAtMs).toBe(dueNextRunAtMs);
|
expect(findJobOrThrow(state, "reload-cron-expr-job").state.nextRunAtMs).toBe(dueNextRunAtMs);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a force-reloaded legacy string schedule for runtime repair handling", async () => {
|
|
||||||
const { storePath } = await makeStorePath();
|
|
||||||
const staleNextRunAtMs = STORE_TEST_NOW + 3_600_000;
|
|
||||||
|
|
||||||
await writeSingleJobStore(storePath, {
|
|
||||||
...createReloadCronJob({
|
|
||||||
updatedAtMs: STORE_TEST_NOW,
|
|
||||||
state: { nextRunAtMs: staleNextRunAtMs },
|
|
||||||
}),
|
|
||||||
schedule: "0 17 * * *",
|
|
||||||
});
|
|
||||||
|
|
||||||
const state = createStoreTestState(storePath);
|
|
||||||
await expect(ensureLoaded(state, { forceReload: true, skipRecompute: true })).resolves.toBe(
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
const job = findJobOrThrow(state, "reload-cron-expr-job");
|
|
||||||
expect(job.schedule).toBe("0 17 * * *");
|
|
||||||
expect(job.state.nextRunAtMs).toBe(staleNextRunAtMs);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves nextRunAtMs after force reload when scheduling inputs are unchanged", async () => {
|
it("preserves nextRunAtMs after force reload when scheduling inputs are unchanged", async () => {
|
||||||
const { storePath } = await makeStorePath();
|
const { storePath } = await makeStorePath();
|
||||||
const originalNextRunAtMs = STORE_TEST_NOW + 3_600_000;
|
const originalNextRunAtMs = STORE_TEST_NOW + 3_600_000;
|
||||||
|
|||||||
@@ -515,95 +515,8 @@ describe("cron store", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to job_json payloads for early SQLite cron rows", async () => {
|
it("round-trips completion destinations through SQLite delivery columns", async () => {
|
||||||
const { storePath } = await makeStorePath();
|
const { storePath } = await makeStorePath();
|
||||||
const storeKey = path.resolve(storePath);
|
|
||||||
const job = makeStore("early-sqlite-job", true).jobs[0];
|
|
||||||
job.sessionTarget = "isolated";
|
|
||||||
job.payload = {
|
|
||||||
kind: "agentTurn",
|
|
||||||
message: "Keep this prompt",
|
|
||||||
externalContentSource: "gmail",
|
|
||||||
};
|
|
||||||
|
|
||||||
runOpenClawStateWriteTransaction(({ db }) => {
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO cron_jobs (
|
|
||||||
store_key, job_id, name, enabled, created_at_ms, schedule_kind,
|
|
||||||
session_target, wake_mode, payload_kind, payload_message, job_json, updated_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
).run(
|
|
||||||
storeKey,
|
|
||||||
job.id,
|
|
||||||
job.name,
|
|
||||||
1,
|
|
||||||
job.createdAtMs,
|
|
||||||
"every",
|
|
||||||
"isolated",
|
|
||||||
"next-heartbeat",
|
|
||||||
"agentTurn",
|
|
||||||
null,
|
|
||||||
JSON.stringify(job),
|
|
||||||
job.updatedAtMs,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect((await loadCronStore(storePath)).jobs[0]?.payload).toMatchObject({
|
|
||||||
kind: "agentTurn",
|
|
||||||
message: "Keep this prompt",
|
|
||||||
externalContentSource: "gmail",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to modeless job_json delivery for early SQLite cron rows", async () => {
|
|
||||||
const { storePath } = await makeStorePath();
|
|
||||||
const storeKey = path.resolve(storePath);
|
|
||||||
const job = makeStore("early-sqlite-delivery-job", true).jobs[0];
|
|
||||||
job.delivery = {
|
|
||||||
to: "telegram:chat-1",
|
|
||||||
threadId: "topic-9",
|
|
||||||
completionDestination: {
|
|
||||||
mode: "webhook",
|
|
||||||
to: "https://example.invalid/cron",
|
|
||||||
},
|
|
||||||
} as CronStoreFile["jobs"][number]["delivery"];
|
|
||||||
|
|
||||||
runOpenClawStateWriteTransaction(({ db }) => {
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO cron_jobs (
|
|
||||||
store_key, job_id, name, enabled, created_at_ms, schedule_kind,
|
|
||||||
session_target, wake_mode, payload_kind, payload_message, job_json, updated_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
).run(
|
|
||||||
storeKey,
|
|
||||||
job.id,
|
|
||||||
job.name,
|
|
||||||
1,
|
|
||||||
job.createdAtMs,
|
|
||||||
"every",
|
|
||||||
job.sessionTarget,
|
|
||||||
job.wakeMode,
|
|
||||||
"systemEvent",
|
|
||||||
null,
|
|
||||||
JSON.stringify(job),
|
|
||||||
job.updatedAtMs,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect((await loadCronStore(storePath)).jobs[0]?.delivery).toEqual({
|
|
||||||
mode: "announce",
|
|
||||||
to: "telegram:chat-1",
|
|
||||||
threadId: "topic-9",
|
|
||||||
completionDestination: {
|
|
||||||
mode: "webhook",
|
|
||||||
to: "https://example.invalid/cron",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops fallback completion destinations when SQLite stores non-announce delivery mode", async () => {
|
|
||||||
const { storePath } = await makeStorePath();
|
|
||||||
const storeKey = path.resolve(storePath);
|
|
||||||
const job = makeStore("sqlite-webhook-delivery-job", true).jobs[0];
|
const job = makeStore("sqlite-webhook-delivery-job", true).jobs[0];
|
||||||
job.delivery = {
|
job.delivery = {
|
||||||
mode: "announce",
|
mode: "announce",
|
||||||
@@ -618,34 +531,19 @@ describe("cron store", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
runOpenClawStateWriteTransaction(({ db }) => {
|
await saveCronStore(storePath, { version: 1, jobs: [job] });
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO cron_jobs (
|
|
||||||
store_key, job_id, name, enabled, created_at_ms, schedule_kind,
|
|
||||||
session_target, wake_mode, payload_kind, payload_message,
|
|
||||||
delivery_mode, delivery_to, job_json, updated_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
).run(
|
|
||||||
storeKey,
|
|
||||||
job.id,
|
|
||||||
job.name,
|
|
||||||
1,
|
|
||||||
job.createdAtMs,
|
|
||||||
"every",
|
|
||||||
job.sessionTarget,
|
|
||||||
job.wakeMode,
|
|
||||||
"systemEvent",
|
|
||||||
null,
|
|
||||||
"webhook",
|
|
||||||
"https://example.invalid/direct-webhook",
|
|
||||||
JSON.stringify(job),
|
|
||||||
job.updatedAtMs,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect((await loadCronStore(storePath)).jobs[0]?.delivery).toEqual({
|
expect((await loadCronStore(storePath)).jobs[0]?.delivery).toEqual({
|
||||||
mode: "webhook",
|
mode: "announce",
|
||||||
to: "https://example.invalid/direct-webhook",
|
channel: "telegram",
|
||||||
|
to: "telegram:chat-1",
|
||||||
|
threadId: "topic-9",
|
||||||
|
accountId: "bot-1",
|
||||||
|
bestEffort: true,
|
||||||
|
completionDestination: {
|
||||||
|
mode: "webhook",
|
||||||
|
to: "https://example.invalid/legacy-completion",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
import type { CronDelivery } from "../types.js";
|
||||||
import type { CronCompletionDestination, CronDelivery, CronMessageChannel } from "../types.js";
|
import { booleanToInteger, integerToBoolean } from "./scalar-codec.js";
|
||||||
import {
|
|
||||||
booleanToInteger,
|
|
||||||
integerToBoolean,
|
|
||||||
optionalBooleanFromRecord,
|
|
||||||
optionalStringFromRecord,
|
|
||||||
optionalThreadIdFromRecord,
|
|
||||||
} from "./scalar-codec.js";
|
|
||||||
import type { CronJobInsert, CronJobRow } from "./schema.js";
|
import type { CronJobInsert, CronJobRow } from "./schema.js";
|
||||||
|
|
||||||
export function bindDeliveryColumns(
|
export function bindDeliveryColumns(
|
||||||
@@ -16,6 +9,8 @@ export function bindDeliveryColumns(
|
|||||||
| "delivery_account_id"
|
| "delivery_account_id"
|
||||||
| "delivery_best_effort"
|
| "delivery_best_effort"
|
||||||
| "delivery_channel"
|
| "delivery_channel"
|
||||||
|
| "delivery_completion_mode"
|
||||||
|
| "delivery_completion_to"
|
||||||
| "delivery_mode"
|
| "delivery_mode"
|
||||||
| "delivery_thread_id"
|
| "delivery_thread_id"
|
||||||
| "delivery_to"
|
| "delivery_to"
|
||||||
@@ -34,6 +29,8 @@ export function bindDeliveryColumns(
|
|||||||
: String(delivery.threadId),
|
: String(delivery.threadId),
|
||||||
delivery_account_id: delivery?.accountId ?? null,
|
delivery_account_id: delivery?.accountId ?? null,
|
||||||
delivery_best_effort: booleanToInteger(delivery?.bestEffort),
|
delivery_best_effort: booleanToInteger(delivery?.bestEffort),
|
||||||
|
delivery_completion_mode: delivery?.completionDestination?.mode ?? null,
|
||||||
|
delivery_completion_to: delivery?.completionDestination?.to ?? null,
|
||||||
failure_delivery_mode: delivery?.failureDestination?.mode ?? null,
|
failure_delivery_mode: delivery?.failureDestination?.mode ?? null,
|
||||||
failure_delivery_channel: delivery?.failureDestination?.channel ?? null,
|
failure_delivery_channel: delivery?.failureDestination?.channel ?? null,
|
||||||
failure_delivery_to: delivery?.failureDestination?.to ?? null,
|
failure_delivery_to: delivery?.failureDestination?.to ?? null,
|
||||||
@@ -45,113 +42,28 @@ function cronDeliveryModeFromValue(value: unknown): CronDelivery["mode"] | undef
|
|||||||
return value === "none" || value === "announce" || value === "webhook" ? value : undefined;
|
return value === "none" || value === "announce" || value === "webhook" ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cronFailureDeliveryModeFromValue(value: unknown): "announce" | "webhook" | undefined {
|
export function deliveryFromRow(row: CronJobRow): CronDelivery | undefined {
|
||||||
return value === "announce" || value === "webhook" ? value : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function completionDestinationFromFallback(params: {
|
|
||||||
fallback: unknown;
|
|
||||||
mode: CronDelivery["mode"] | undefined;
|
|
||||||
}): CronCompletionDestination | undefined {
|
|
||||||
if (params.mode !== "announce") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const { fallback } = params;
|
|
||||||
if (!isRecord(fallback)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const raw = fallback.completionDestination;
|
|
||||||
if (!isRecord(raw) || raw.mode !== "webhook") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const to = optionalStringFromRecord(raw, "to");
|
|
||||||
return {
|
|
||||||
mode: "webhook",
|
|
||||||
...(to ? { to } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function failureDestinationFromFallback(
|
|
||||||
fallback: unknown,
|
|
||||||
): CronDelivery["failureDestination"] | undefined {
|
|
||||||
if (!isRecord(fallback)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const raw = fallback.failureDestination;
|
|
||||||
if (!isRecord(raw)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const mode = cronFailureDeliveryModeFromValue(raw.mode);
|
|
||||||
const channel = optionalStringFromRecord(raw, "channel") as CronMessageChannel | undefined;
|
|
||||||
const to = optionalStringFromRecord(raw, "to");
|
|
||||||
const accountId = optionalStringFromRecord(raw, "accountId");
|
|
||||||
if (!mode && !channel && !to && !accountId) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...(mode ? { mode } : {}),
|
|
||||||
...(channel ? { channel } : {}),
|
|
||||||
...(to ? { to } : {}),
|
|
||||||
...(accountId ? { accountId } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function fallbackDeliveryFromRecord(fallback: unknown): CronDelivery | undefined {
|
|
||||||
if (!isRecord(fallback)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const mode = cronDeliveryModeFromValue(fallback.mode);
|
|
||||||
const channel = optionalStringFromRecord(fallback, "channel") as CronMessageChannel | undefined;
|
|
||||||
const to = optionalStringFromRecord(fallback, "to");
|
|
||||||
const threadId = optionalThreadIdFromRecord(fallback, "threadId");
|
|
||||||
const accountId = optionalStringFromRecord(fallback, "accountId");
|
|
||||||
const bestEffort = optionalBooleanFromRecord(fallback, "bestEffort");
|
|
||||||
const completionDestination = completionDestinationFromFallback({
|
|
||||||
fallback,
|
|
||||||
mode: mode ?? "announce",
|
|
||||||
});
|
|
||||||
const failureDestination = failureDestinationFromFallback(fallback);
|
|
||||||
if (
|
|
||||||
!mode &&
|
|
||||||
!channel &&
|
|
||||||
!to &&
|
|
||||||
threadId == null &&
|
|
||||||
!accountId &&
|
|
||||||
bestEffort == null &&
|
|
||||||
!completionDestination &&
|
|
||||||
!failureDestination
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
mode: mode ?? "announce",
|
|
||||||
...(channel ? { channel } : {}),
|
|
||||||
...(to ? { to } : {}),
|
|
||||||
...(threadId != null ? { threadId } : {}),
|
|
||||||
...(accountId ? { accountId } : {}),
|
|
||||||
...(bestEffort != null ? { bestEffort } : {}),
|
|
||||||
...(completionDestination ? { completionDestination } : {}),
|
|
||||||
...(failureDestination ? { failureDestination } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deliveryFromRow(row: CronJobRow, fallback?: unknown): CronDelivery | undefined {
|
|
||||||
const fallbackDelivery = fallbackDeliveryFromRecord(fallback);
|
|
||||||
const rowMode = cronDeliveryModeFromValue(row.delivery_mode);
|
const rowMode = cronDeliveryModeFromValue(row.delivery_mode);
|
||||||
const mode = rowMode ?? fallbackDelivery?.mode;
|
|
||||||
const hasDeliveryColumns =
|
const hasDeliveryColumns =
|
||||||
Boolean(
|
Boolean(
|
||||||
row.delivery_channel ||
|
row.delivery_channel ||
|
||||||
row.delivery_to ||
|
row.delivery_to ||
|
||||||
row.delivery_thread_id ||
|
row.delivery_thread_id ||
|
||||||
row.delivery_account_id ||
|
row.delivery_account_id ||
|
||||||
|
row.delivery_completion_mode ||
|
||||||
|
row.delivery_completion_to ||
|
||||||
row.failure_delivery_channel ||
|
row.failure_delivery_channel ||
|
||||||
row.failure_delivery_to ||
|
row.failure_delivery_to ||
|
||||||
row.failure_delivery_mode ||
|
row.failure_delivery_mode ||
|
||||||
row.failure_delivery_account_id,
|
row.failure_delivery_account_id,
|
||||||
) || row.delivery_best_effort != null;
|
) || row.delivery_best_effort != null;
|
||||||
const completionDestination =
|
const completionDestination =
|
||||||
mode === "announce" ? fallbackDelivery?.completionDestination : undefined;
|
rowMode === "announce" && row.delivery_completion_mode === "webhook"
|
||||||
|
? {
|
||||||
|
mode: "webhook" as const,
|
||||||
|
...(row.delivery_completion_to ? { to: row.delivery_completion_to } : {}),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
const failureDestination =
|
const failureDestination =
|
||||||
row.failure_delivery_channel ||
|
row.failure_delivery_channel ||
|
||||||
row.failure_delivery_to ||
|
row.failure_delivery_to ||
|
||||||
@@ -169,25 +81,12 @@ export function deliveryFromRow(row: CronJobRow, fallback?: unknown): CronDelive
|
|||||||
? { accountId: row.failure_delivery_account_id }
|
? { accountId: row.failure_delivery_account_id }
|
||||||
: {}),
|
: {}),
|
||||||
}
|
}
|
||||||
: fallbackDelivery?.failureDestination;
|
: undefined;
|
||||||
if (!mode && !hasDeliveryColumns && !fallbackDelivery) {
|
if (!rowMode && !hasDeliveryColumns) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const fallbackDeliveryFields =
|
|
||||||
rowMode === "none" || rowMode === "webhook"
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
...(fallbackDelivery?.channel ? { channel: fallbackDelivery.channel } : {}),
|
|
||||||
...(fallbackDelivery?.to ? { to: fallbackDelivery.to } : {}),
|
|
||||||
...(fallbackDelivery?.threadId != null ? { threadId: fallbackDelivery.threadId } : {}),
|
|
||||||
...(fallbackDelivery?.accountId ? { accountId: fallbackDelivery.accountId } : {}),
|
|
||||||
...(fallbackDelivery?.bestEffort != null
|
|
||||||
? { bestEffort: fallbackDelivery.bestEffort }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
return {
|
return {
|
||||||
...fallbackDeliveryFields,
|
mode: rowMode ?? "announce",
|
||||||
mode: mode ?? "announce",
|
|
||||||
...(row.delivery_channel ? { channel: row.delivery_channel as CronDelivery["channel"] } : {}),
|
...(row.delivery_channel ? { channel: row.delivery_channel as CronDelivery["channel"] } : {}),
|
||||||
...(row.delivery_to ? { to: row.delivery_to } : {}),
|
...(row.delivery_to ? { to: row.delivery_to } : {}),
|
||||||
...(row.delivery_thread_id ? { threadId: row.delivery_thread_id } : {}),
|
...(row.delivery_thread_id ? { threadId: row.delivery_thread_id } : {}),
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
|
||||||
import type { CronPayload } from "../types.js";
|
import type { CronPayload } from "../types.js";
|
||||||
import {
|
import {
|
||||||
booleanToInteger,
|
booleanToInteger,
|
||||||
integerToBoolean,
|
integerToBoolean,
|
||||||
normalizeNumber,
|
normalizeNumber,
|
||||||
optionalBooleanFromRecord,
|
|
||||||
optionalNumberFromRecord,
|
|
||||||
optionalStringArrayFromRecord,
|
|
||||||
optionalStringFromRecord,
|
|
||||||
parseJsonArray,
|
parseJsonArray,
|
||||||
parseJsonValue,
|
parseJsonValue,
|
||||||
serializeJson,
|
serializeJson,
|
||||||
} from "./scalar-codec.js";
|
} from "./scalar-codec.js";
|
||||||
import type { CronJobInsert, CronJobRow } from "./schema.js";
|
import type { CronJobInsert, CronJobRow } from "./schema.js";
|
||||||
|
|
||||||
function parseExternalContentSource(
|
function parseExternalContentSource(raw: string | null): "gmail" | "webhook" | undefined {
|
||||||
raw: string | null,
|
const parsed = raw ? parseJsonValue<unknown>(raw, undefined) : undefined;
|
||||||
fallback: unknown,
|
|
||||||
): "gmail" | "webhook" | undefined {
|
|
||||||
const parsed = raw ? parseJsonValue<unknown>(raw, undefined) : fallback;
|
|
||||||
return parsed === "gmail" || parsed === "webhook" ? parsed : undefined;
|
return parsed === "gmail" || parsed === "webhook" ? parsed : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,47 +57,36 @@ export function bindPayloadColumns(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function payloadFromRow(row: CronJobRow, fallback: unknown): CronPayload | null {
|
export function payloadFromRow(row: CronJobRow): CronPayload | null {
|
||||||
const fallbackRecord = isRecord(fallback) ? fallback : {};
|
|
||||||
if (row.payload_kind === "systemEvent") {
|
if (row.payload_kind === "systemEvent") {
|
||||||
const text = row.payload_message ?? optionalStringFromRecord(fallbackRecord, "text");
|
return row.payload_message == null ? null : { kind: "systemEvent", text: row.payload_message };
|
||||||
return text == null ? null : { kind: "systemEvent", text };
|
|
||||||
}
|
}
|
||||||
if (row.payload_kind === "agentTurn") {
|
if (row.payload_kind === "agentTurn") {
|
||||||
const message = row.payload_message ?? optionalStringFromRecord(fallbackRecord, "message");
|
if (row.payload_message == null) {
|
||||||
if (message == null) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const model = row.payload_model ?? optionalStringFromRecord(fallbackRecord, "model");
|
|
||||||
const fallbacks = row.payload_fallbacks_json
|
const fallbacks = row.payload_fallbacks_json
|
||||||
? parseJsonArray(row.payload_fallbacks_json)
|
? parseJsonArray(row.payload_fallbacks_json)
|
||||||
: optionalStringArrayFromRecord(fallbackRecord, "fallbacks");
|
: undefined;
|
||||||
const thinking = row.payload_thinking ?? optionalStringFromRecord(fallbackRecord, "thinking");
|
const timeoutSeconds = normalizeNumber(row.payload_timeout_seconds);
|
||||||
const timeoutSeconds =
|
|
||||||
row.payload_timeout_seconds != null
|
|
||||||
? normalizeNumber(row.payload_timeout_seconds)
|
|
||||||
: optionalNumberFromRecord(fallbackRecord, "timeoutSeconds");
|
|
||||||
const allowUnsafeExternalContent =
|
const allowUnsafeExternalContent =
|
||||||
row.payload_allow_unsafe_external_content != null
|
row.payload_allow_unsafe_external_content != null
|
||||||
? integerToBoolean(row.payload_allow_unsafe_external_content)
|
? integerToBoolean(row.payload_allow_unsafe_external_content)
|
||||||
: optionalBooleanFromRecord(fallbackRecord, "allowUnsafeExternalContent");
|
: undefined;
|
||||||
const externalContentSource = parseExternalContentSource(
|
const externalContentSource = parseExternalContentSource(
|
||||||
row.payload_external_content_source_json,
|
row.payload_external_content_source_json,
|
||||||
fallbackRecord.externalContentSource,
|
|
||||||
);
|
);
|
||||||
const lightContext =
|
const lightContext =
|
||||||
row.payload_light_context != null
|
row.payload_light_context != null ? integerToBoolean(row.payload_light_context) : undefined;
|
||||||
? integerToBoolean(row.payload_light_context)
|
|
||||||
: optionalBooleanFromRecord(fallbackRecord, "lightContext");
|
|
||||||
const toolsAllow = row.payload_tools_allow_json
|
const toolsAllow = row.payload_tools_allow_json
|
||||||
? parseJsonArray(row.payload_tools_allow_json)
|
? parseJsonArray(row.payload_tools_allow_json)
|
||||||
: optionalStringArrayFromRecord(fallbackRecord, "toolsAllow");
|
: undefined;
|
||||||
return {
|
return {
|
||||||
kind: "agentTurn",
|
kind: "agentTurn",
|
||||||
message,
|
message: row.payload_message,
|
||||||
...(model ? { model } : {}),
|
...(row.payload_model ? { model: row.payload_model } : {}),
|
||||||
...(fallbacks ? { fallbacks } : {}),
|
...(fallbacks ? { fallbacks } : {}),
|
||||||
...(thinking ? { thinking } : {}),
|
...(row.payload_thinking ? { thinking: row.payload_thinking } : {}),
|
||||||
...(timeoutSeconds != null ? { timeoutSeconds } : {}),
|
...(timeoutSeconds != null ? { timeoutSeconds } : {}),
|
||||||
...(allowUnsafeExternalContent != null ? { allowUnsafeExternalContent } : {}),
|
...(allowUnsafeExternalContent != null ? { allowUnsafeExternalContent } : {}),
|
||||||
...(externalContentSource ? { externalContentSource } : {}),
|
...(externalContentSource ? { externalContentSource } : {}),
|
||||||
|
|||||||
@@ -157,16 +157,15 @@ function scheduleFromRow(row: CronJobRow): CronSchedule | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function rowToCronJob(row: CronJobRow): CronJob | null {
|
function rowToCronJob(row: CronJobRow): CronJob | null {
|
||||||
const base = parseJsonObject<Partial<CronJob>>(row.job_json, {});
|
const schedule = scheduleFromRow(row);
|
||||||
const schedule = scheduleFromRow(row) ?? base.schedule;
|
const payload = payloadFromRow(row);
|
||||||
const payload = payloadFromRow(row, base.payload) ?? base.payload;
|
const delivery = deliveryFromRow(row);
|
||||||
const delivery = deliveryFromRow(row, base.delivery);
|
|
||||||
const failureAlert = failureAlertFromRow(row);
|
const failureAlert = failureAlertFromRow(row);
|
||||||
if (!schedule || !payload) {
|
if (!schedule || !payload) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const createdAtMs = normalizeNumber(row.created_at_ms) ?? Date.now();
|
||||||
return {
|
return {
|
||||||
...base,
|
|
||||||
id: row.job_id,
|
id: row.job_id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
...(row.description ? { description: row.description } : {}),
|
...(row.description ? { description: row.description } : {}),
|
||||||
@@ -174,12 +173,9 @@ function rowToCronJob(row: CronJobRow): CronJob | null {
|
|||||||
...(row.delete_after_run != null
|
...(row.delete_after_run != null
|
||||||
? { deleteAfterRun: integerToBoolean(row.delete_after_run) }
|
? { deleteAfterRun: integerToBoolean(row.delete_after_run) }
|
||||||
: {}),
|
: {}),
|
||||||
createdAtMs: normalizeNumber(row.created_at_ms) ?? base.createdAtMs ?? Date.now(),
|
createdAtMs,
|
||||||
updatedAtMs:
|
updatedAtMs:
|
||||||
normalizeNumber(row.runtime_updated_at_ms) ??
|
normalizeNumber(row.runtime_updated_at_ms) ?? normalizeNumber(row.updated_at) ?? createdAtMs,
|
||||||
normalizeNumber(row.updated_at) ??
|
|
||||||
base.updatedAtMs ??
|
|
||||||
Date.now(),
|
|
||||||
...(row.agent_id ? { agentId: row.agent_id } : {}),
|
...(row.agent_id ? { agentId: row.agent_id } : {}),
|
||||||
...(row.session_key ? { sessionKey: row.session_key } : {}),
|
...(row.session_key ? { sessionKey: row.session_key } : {}),
|
||||||
schedule,
|
schedule,
|
||||||
|
|||||||
@@ -44,45 +44,3 @@ export function parseJsonArray(raw: string | null): string[] | undefined {
|
|||||||
? parsed.filter((item): item is string => typeof item === "string")
|
? parsed.filter((item): item is string => typeof item === "string")
|
||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function optionalStringFromRecord(
|
|
||||||
record: Record<string, unknown>,
|
|
||||||
key: string,
|
|
||||||
): string | undefined {
|
|
||||||
const value = record[key];
|
|
||||||
return typeof value === "string" ? value : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function optionalBooleanFromRecord(
|
|
||||||
record: Record<string, unknown>,
|
|
||||||
key: string,
|
|
||||||
): boolean | undefined {
|
|
||||||
const value = record[key];
|
|
||||||
return typeof value === "boolean" ? value : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function optionalNumberFromRecord(
|
|
||||||
record: Record<string, unknown>,
|
|
||||||
key: string,
|
|
||||||
): number | undefined {
|
|
||||||
const value = record[key];
|
|
||||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function optionalStringArrayFromRecord(
|
|
||||||
record: Record<string, unknown>,
|
|
||||||
key: string,
|
|
||||||
): string[] | undefined {
|
|
||||||
const value = record[key];
|
|
||||||
return Array.isArray(value) && value.every((item) => typeof item === "string")
|
|
||||||
? value
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function optionalThreadIdFromRecord(
|
|
||||||
record: Record<string, unknown>,
|
|
||||||
key: string,
|
|
||||||
): string | number | undefined {
|
|
||||||
const value = record[key];
|
|
||||||
return typeof value === "string" || typeof value === "number" ? value : undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -699,7 +699,7 @@ describe("cron method validation", () => {
|
|||||||
params: {
|
params: {
|
||||||
name: "bad-cron",
|
name: "bad-cron",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
schedule: { kind: "cron", cron: "not-a-cron-expr" },
|
schedule: { kind: "cron", expr: "not-a-cron-expr" },
|
||||||
sessionTarget: "isolated",
|
sessionTarget: "isolated",
|
||||||
wakeMode: "next-heartbeat",
|
wakeMode: "next-heartbeat",
|
||||||
payload: { kind: "agentTurn", message: "ping" },
|
payload: { kind: "agentTurn", message: "ping" },
|
||||||
@@ -725,7 +725,7 @@ describe("cron method validation", () => {
|
|||||||
params: {
|
params: {
|
||||||
id: existingJob.id,
|
id: existingJob.id,
|
||||||
patch: {
|
patch: {
|
||||||
schedule: { kind: "cron", cron: "99 * * * *" },
|
schedule: { kind: "cron", expr: "99 * * * *" },
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
respond: respond as never,
|
respond: respond as never,
|
||||||
|
|||||||
@@ -491,22 +491,6 @@ describe("gateway server cron", () => {
|
|||||||
expect(missingGetRes.ok).toBe(false);
|
expect(missingGetRes.ok).toBe(false);
|
||||||
expect(missingGetRes.error?.code).toBe("INVALID_REQUEST");
|
expect(missingGetRes.error?.code).toBe("INVALID_REQUEST");
|
||||||
expect(missingGetRes.error?.message).toContain("cron job not found: missing-job-id");
|
expect(missingGetRes.error?.message).toContain("cron job not found: missing-job-id");
|
||||||
|
|
||||||
const wrappedAtMs = Date.now() + 1000;
|
|
||||||
const wrappedRes = await directCronReq(cronState, "cron.add", {
|
|
||||||
data: {
|
|
||||||
name: "wrapped",
|
|
||||||
schedule: { at: new Date(wrappedAtMs).toISOString() },
|
|
||||||
payload: { kind: "systemEvent", text: "hello" },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(wrappedRes.ok).toBe(true);
|
|
||||||
const wrappedPayload = wrappedRes.payload as
|
|
||||||
| { schedule?: unknown; sessionTarget?: unknown; wakeMode?: unknown }
|
|
||||||
| undefined;
|
|
||||||
expect(wrappedPayload?.sessionTarget).toBe("main");
|
|
||||||
expect(wrappedPayload?.wakeMode).toBe("now");
|
|
||||||
expect((wrappedPayload?.schedule as { kind?: unknown } | undefined)?.kind).toBe("at");
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupCronTestRun({
|
await cleanupCronTestRun({
|
||||||
cronState,
|
cronState,
|
||||||
@@ -648,7 +632,7 @@ describe("gateway server cron", () => {
|
|||||||
const updateRes = await directCronReq(cronState, "cron.update", {
|
const updateRes = await directCronReq(cronState, "cron.update", {
|
||||||
id: patchJobId,
|
id: patchJobId,
|
||||||
patch: {
|
patch: {
|
||||||
schedule: { at: new Date(atMs).toISOString() },
|
schedule: { kind: "at", at: new Date(atMs).toISOString() },
|
||||||
payload: { kind: "systemEvent", text: "updated" },
|
payload: { kind: "systemEvent", text: "updated" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -716,6 +700,7 @@ describe("gateway server cron", () => {
|
|||||||
id: mergeJobId,
|
id: mergeJobId,
|
||||||
patch: {
|
patch: {
|
||||||
payload: {
|
payload: {
|
||||||
|
kind: "agentTurn",
|
||||||
model: "anthropic/claude-sonnet-4-6",
|
model: "anthropic/claude-sonnet-4-6",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -780,7 +765,7 @@ describe("gateway server cron", () => {
|
|||||||
const jobIdUpdateRes = await directCronReq(cronState, "cron.update", {
|
const jobIdUpdateRes = await directCronReq(cronState, "cron.update", {
|
||||||
jobId,
|
jobId,
|
||||||
patch: {
|
patch: {
|
||||||
schedule: { at: new Date(Date.now() + 2_000).toISOString() },
|
schedule: { kind: "at", at: new Date(Date.now() + 2_000).toISOString() },
|
||||||
payload: { kind: "systemEvent", text: "updated" },
|
payload: { kind: "systemEvent", text: "updated" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
2
src/state/openclaw-state-db.generated.d.ts
vendored
2
src/state/openclaw-state-db.generated.d.ts
vendored
@@ -208,6 +208,8 @@ export interface CronJobs {
|
|||||||
delivery_account_id: string | null;
|
delivery_account_id: string | null;
|
||||||
delivery_best_effort: number | null;
|
delivery_best_effort: number | null;
|
||||||
delivery_channel: string | null;
|
delivery_channel: string | null;
|
||||||
|
delivery_completion_mode: string | null;
|
||||||
|
delivery_completion_to: string | null;
|
||||||
delivery_mode: string | null;
|
delivery_mode: string | null;
|
||||||
delivery_thread_id: string | null;
|
delivery_thread_id: string | null;
|
||||||
delivery_to: string | null;
|
delivery_to: string | null;
|
||||||
|
|||||||
@@ -213,6 +213,8 @@ function ensureAdditiveStateColumns(db: DatabaseSync): void {
|
|||||||
ensureColumn(db, "cron_jobs", "delivery_thread_id TEXT");
|
ensureColumn(db, "cron_jobs", "delivery_thread_id TEXT");
|
||||||
ensureColumn(db, "cron_jobs", "delivery_account_id TEXT");
|
ensureColumn(db, "cron_jobs", "delivery_account_id TEXT");
|
||||||
ensureColumn(db, "cron_jobs", "delivery_best_effort INTEGER");
|
ensureColumn(db, "cron_jobs", "delivery_best_effort INTEGER");
|
||||||
|
ensureColumn(db, "cron_jobs", "delivery_completion_mode TEXT");
|
||||||
|
ensureColumn(db, "cron_jobs", "delivery_completion_to TEXT");
|
||||||
ensureColumn(db, "cron_jobs", "failure_delivery_mode TEXT");
|
ensureColumn(db, "cron_jobs", "failure_delivery_mode TEXT");
|
||||||
ensureColumn(db, "cron_jobs", "failure_delivery_channel TEXT");
|
ensureColumn(db, "cron_jobs", "failure_delivery_channel TEXT");
|
||||||
ensureColumn(db, "cron_jobs", "failure_delivery_to TEXT");
|
ensureColumn(db, "cron_jobs", "failure_delivery_to TEXT");
|
||||||
|
|||||||
@@ -835,6 +835,8 @@ CREATE TABLE IF NOT EXISTS cron_jobs (
|
|||||||
delivery_thread_id TEXT,
|
delivery_thread_id TEXT,
|
||||||
delivery_account_id TEXT,
|
delivery_account_id TEXT,
|
||||||
delivery_best_effort INTEGER,
|
delivery_best_effort INTEGER,
|
||||||
|
delivery_completion_mode TEXT,
|
||||||
|
delivery_completion_to TEXT,
|
||||||
failure_delivery_mode TEXT,
|
failure_delivery_mode TEXT,
|
||||||
failure_delivery_channel TEXT,
|
failure_delivery_channel TEXT,
|
||||||
failure_delivery_to TEXT,
|
failure_delivery_to TEXT,
|
||||||
|
|||||||
@@ -830,6 +830,8 @@ CREATE TABLE IF NOT EXISTS cron_jobs (
|
|||||||
delivery_thread_id TEXT,
|
delivery_thread_id TEXT,
|
||||||
delivery_account_id TEXT,
|
delivery_account_id TEXT,
|
||||||
delivery_best_effort INTEGER,
|
delivery_best_effort INTEGER,
|
||||||
|
delivery_completion_mode TEXT,
|
||||||
|
delivery_completion_to TEXT,
|
||||||
failure_delivery_mode TEXT,
|
failure_delivery_mode TEXT,
|
||||||
failure_delivery_channel TEXT,
|
failure_delivery_channel TEXT,
|
||||||
failure_delivery_to TEXT,
|
failure_delivery_to TEXT,
|
||||||
|
|||||||
Reference in New Issue
Block a user