Files
openclaw/src/cron/normalize.ts
Tyler Yust 3f82daefd8 feat(cron): enhance delivery modes and job configuration
- Updated isolated cron jobs to support new delivery modes: `announce` and `none`, improving output management.
- Refactored job configuration to remove legacy fields and streamline delivery settings.
- Enhanced the `CronJobEditor` UI to reflect changes in delivery options, including a new segmented control for delivery mode selection.
- Updated documentation to clarify the new delivery configurations and their implications for job execution.
- Improved tests to validate the new delivery behavior and ensure backward compatibility with legacy settings.

This update provides users with greater flexibility in managing how isolated jobs deliver their outputs, enhancing overall usability and clarity in job configurations.
2026-02-04 01:03:59 -08:00

265 lines
7.0 KiB
TypeScript

import type { CronJobCreate, CronJobPatch } from "./types.js";
import { sanitizeAgentId } from "../routing/session-key.js";
import { parseAbsoluteTimeMs } from "./parse.js";
import { migrateLegacyCronPayload } from "./payload-migration.js";
type UnknownRecord = Record<string, unknown>;
type NormalizeOptions = {
applyDefaults?: boolean;
};
const DEFAULT_OPTIONS: NormalizeOptions = {
applyDefaults: false,
};
function isRecord(value: unknown): value is UnknownRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function coerceSchedule(schedule: UnknownRecord) {
const next: UnknownRecord = { ...schedule };
const kind = typeof schedule.kind === "string" ? schedule.kind : undefined;
const atMsRaw = schedule.atMs;
const atRaw = schedule.at;
const atString = typeof atRaw === "string" ? atRaw.trim() : "";
const parsedAtMs =
typeof atMsRaw === "number"
? atMsRaw
: typeof atMsRaw === "string"
? parseAbsoluteTimeMs(atMsRaw)
: atString
? parseAbsoluteTimeMs(atString)
: null;
if (!kind) {
if (
typeof schedule.atMs === "number" ||
typeof schedule.at === "string" ||
typeof schedule.atMs === "string"
) {
next.kind = "at";
} else if (typeof schedule.everyMs === "number") {
next.kind = "every";
} else if (typeof schedule.expr === "string") {
next.kind = "cron";
}
}
if (atString) {
next.at = parsedAtMs ? new Date(parsedAtMs).toISOString() : atString;
} else if (parsedAtMs !== null) {
next.at = new Date(parsedAtMs).toISOString();
}
if ("atMs" in next) {
delete next.atMs;
}
return next;
}
function coercePayload(payload: UnknownRecord) {
const next: UnknownRecord = { ...payload };
// Back-compat: older configs used `provider` for delivery channel.
migrateLegacyCronPayload(next);
return next;
}
function coerceDelivery(delivery: UnknownRecord) {
const next: UnknownRecord = { ...delivery };
if (typeof delivery.mode === "string") {
const mode = delivery.mode.trim().toLowerCase();
next.mode = mode === "deliver" ? "announce" : mode;
}
if (typeof delivery.channel === "string") {
const trimmed = delivery.channel.trim().toLowerCase();
if (trimmed) {
next.channel = trimmed;
} else {
delete next.channel;
}
}
if (typeof delivery.to === "string") {
const trimmed = delivery.to.trim();
if (trimmed) {
next.to = trimmed;
} else {
delete next.to;
}
}
return next;
}
function hasLegacyDeliveryHints(payload: UnknownRecord) {
if (typeof payload.deliver === "boolean") {
return true;
}
if (typeof payload.bestEffortDeliver === "boolean") {
return true;
}
if (typeof payload.to === "string" && payload.to.trim()) {
return true;
}
return false;
}
function buildDeliveryFromLegacyPayload(payload: UnknownRecord): UnknownRecord {
const deliver = payload.deliver;
const mode = deliver === false ? "none" : "announce";
const channelRaw =
typeof payload.channel === "string" ? payload.channel.trim().toLowerCase() : "";
const toRaw = typeof payload.to === "string" ? payload.to.trim() : "";
const next: UnknownRecord = { mode };
if (channelRaw) {
next.channel = channelRaw;
}
if (toRaw) {
next.to = toRaw;
}
if (typeof payload.bestEffortDeliver === "boolean") {
next.bestEffort = payload.bestEffortDeliver;
}
return next;
}
function stripLegacyDeliveryFields(payload: UnknownRecord) {
if ("deliver" in payload) {
delete payload.deliver;
}
if ("channel" in payload) {
delete payload.channel;
}
if ("to" in payload) {
delete payload.to;
}
if ("bestEffortDeliver" in payload) {
delete payload.bestEffortDeliver;
}
}
function unwrapJob(raw: UnknownRecord) {
if (isRecord(raw.data)) {
return raw.data;
}
if (isRecord(raw.job)) {
return raw.job;
}
return raw;
}
export function normalizeCronJobInput(
raw: unknown,
options: NormalizeOptions = DEFAULT_OPTIONS,
): UnknownRecord | null {
if (!isRecord(raw)) {
return null;
}
const base = unwrapJob(raw);
const next: UnknownRecord = { ...base };
if ("agentId" in base) {
const agentId = base.agentId;
if (agentId === null) {
next.agentId = null;
} else if (typeof agentId === "string") {
const trimmed = agentId.trim();
if (trimmed) {
next.agentId = sanitizeAgentId(trimmed);
} else {
delete next.agentId;
}
}
}
if ("enabled" in base) {
const enabled = base.enabled;
if (typeof enabled === "boolean") {
next.enabled = enabled;
} else if (typeof enabled === "string") {
const trimmed = enabled.trim().toLowerCase();
if (trimmed === "true") {
next.enabled = true;
}
if (trimmed === "false") {
next.enabled = false;
}
}
}
if (isRecord(base.schedule)) {
next.schedule = coerceSchedule(base.schedule);
}
if (isRecord(base.payload)) {
next.payload = coercePayload(base.payload);
}
if (isRecord(base.delivery)) {
next.delivery = coerceDelivery(base.delivery);
}
if (isRecord(base.isolation)) {
delete next.isolation;
}
if (options.applyDefaults) {
if (!next.wakeMode) {
next.wakeMode = "next-heartbeat";
}
if (!next.sessionTarget && isRecord(next.payload)) {
const kind = typeof next.payload.kind === "string" ? next.payload.kind : "";
if (kind === "systemEvent") {
next.sessionTarget = "main";
}
if (kind === "agentTurn") {
next.sessionTarget = "isolated";
}
}
if (
"schedule" in next &&
isRecord(next.schedule) &&
next.schedule.kind === "at" &&
!("deleteAfterRun" in next)
) {
next.deleteAfterRun = true;
}
const payload = isRecord(next.payload) ? next.payload : null;
const payloadKind = payload && typeof payload.kind === "string" ? payload.kind : "";
const sessionTarget = typeof next.sessionTarget === "string" ? next.sessionTarget : "";
const isIsolatedAgentTurn =
sessionTarget === "isolated" || (sessionTarget === "" && payloadKind === "agentTurn");
const hasDelivery = "delivery" in next && next.delivery !== undefined;
const hasLegacyDelivery = payload ? hasLegacyDeliveryHints(payload) : false;
if (!hasDelivery && isIsolatedAgentTurn && payloadKind === "agentTurn") {
if (payload && hasLegacyDelivery) {
next.delivery = buildDeliveryFromLegacyPayload(payload);
stripLegacyDeliveryFields(payload);
} else {
next.delivery = { mode: "announce" };
}
}
}
return next;
}
export function normalizeCronJobCreate(
raw: unknown,
options?: NormalizeOptions,
): CronJobCreate | null {
return normalizeCronJobInput(raw, {
applyDefaults: true,
...options,
}) as CronJobCreate | null;
}
export function normalizeCronJobPatch(
raw: unknown,
options?: NormalizeOptions,
): CronJobPatch | null {
return normalizeCronJobInput(raw, {
applyDefaults: false,
...options,
}) as CronJobPatch | null;
}