mirror of https://github.com/openclaw/openclaw.git
617 lines
18 KiB
TypeScript
617 lines
18 KiB
TypeScript
import { sanitizeAgentId } from "../routing/session-key.js";
|
|
import { isRecord } from "../utils.js";
|
|
import {
|
|
TimeoutSecondsFieldSchema,
|
|
TrimmedNonEmptyStringFieldSchema,
|
|
parseDeliveryInput,
|
|
parseOptionalField,
|
|
} from "./delivery-field-schemas.js";
|
|
import { parseAbsoluteTimeMs } from "./parse.js";
|
|
import { inferLegacyName } from "./service/normalize.js";
|
|
import { normalizeCronStaggerMs, resolveDefaultCronStaggerMs } from "./stagger.js";
|
|
import type { CronJobCreate, CronJobPatch } from "./types.js";
|
|
|
|
type UnknownRecord = Record<string, unknown>;
|
|
|
|
type NormalizeOptions = {
|
|
applyDefaults?: boolean;
|
|
/** Session context for resolving "current" sessionTarget or auto-binding when not specified */
|
|
sessionContext?: { sessionKey?: string };
|
|
};
|
|
|
|
const DEFAULT_OPTIONS: NormalizeOptions = {
|
|
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(
|
|
value: unknown,
|
|
options?: { allowNull?: boolean },
|
|
): string[] | null | undefined {
|
|
if (Array.isArray(value)) {
|
|
const normalized = value
|
|
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
|
.map((entry) => entry.trim());
|
|
if (normalized.length === 0 && value.length > 0) {
|
|
return undefined;
|
|
}
|
|
return normalized;
|
|
}
|
|
if (options?.allowNull && value === null) {
|
|
return null;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function coerceSchedule(schedule: UnknownRecord) {
|
|
const next: UnknownRecord = { ...schedule };
|
|
const rawKind = typeof schedule.kind === "string" ? schedule.kind.trim().toLowerCase() : "";
|
|
const kind = rawKind === "at" || rawKind === "every" || rawKind === "cron" ? rawKind : undefined;
|
|
const exprRaw = typeof schedule.expr === "string" ? schedule.expr.trim() : "";
|
|
const legacyCronRaw = typeof schedule.cron === "string" ? schedule.cron.trim() : "";
|
|
const normalizedExpr = exprRaw || legacyCronRaw;
|
|
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) {
|
|
next.kind = kind;
|
|
} else {
|
|
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 (normalizedExpr) {
|
|
next.kind = "cron";
|
|
}
|
|
}
|
|
|
|
if (atString) {
|
|
next.at = parsedAtMs !== null ? new Date(parsedAtMs).toISOString() : atString;
|
|
} else if (parsedAtMs !== null) {
|
|
next.at = new Date(parsedAtMs).toISOString();
|
|
}
|
|
if ("atMs" in next) {
|
|
delete next.atMs;
|
|
}
|
|
|
|
if (normalizedExpr) {
|
|
next.expr = normalizedExpr;
|
|
} else if ("expr" in next) {
|
|
delete next.expr;
|
|
}
|
|
if ("cron" in next) {
|
|
delete next.cron;
|
|
}
|
|
|
|
const staggerMs = normalizeCronStaggerMs(schedule.staggerMs);
|
|
if (staggerMs !== undefined) {
|
|
next.staggerMs = staggerMs;
|
|
} else if ("staggerMs" in next) {
|
|
delete next.staggerMs;
|
|
}
|
|
|
|
if (next.kind === "at") {
|
|
delete next.everyMs;
|
|
delete next.anchorMs;
|
|
delete next.expr;
|
|
delete next.tz;
|
|
delete next.staggerMs;
|
|
} else if (next.kind === "every") {
|
|
delete next.at;
|
|
delete next.expr;
|
|
delete next.tz;
|
|
delete next.staggerMs;
|
|
} else if (next.kind === "cron") {
|
|
delete next.at;
|
|
delete next.everyMs;
|
|
delete next.anchorMs;
|
|
}
|
|
|
|
return next;
|
|
}
|
|
|
|
function coercePayload(payload: UnknownRecord) {
|
|
const next: UnknownRecord = { ...payload };
|
|
const kindRaw = typeof next.kind === "string" ? next.kind.trim().toLowerCase() : "";
|
|
if (kindRaw === "agentturn") {
|
|
next.kind = "agentTurn";
|
|
} else if (kindRaw === "systemevent") {
|
|
next.kind = "systemEvent";
|
|
} else if (kindRaw) {
|
|
next.kind = kindRaw;
|
|
}
|
|
if (!next.kind) {
|
|
const hasMessage = typeof next.message === "string" && next.message.trim().length > 0;
|
|
const hasText = typeof next.text === "string" && next.text.trim().length > 0;
|
|
if (hasMessage) {
|
|
next.kind = "agentTurn";
|
|
} else if (hasText) {
|
|
next.kind = "systemEvent";
|
|
} else if (hasAgentTurnPayloadHint(next)) {
|
|
// Accept partial agentTurn payload patches that only tweak agent-turn-only fields.
|
|
next.kind = "agentTurn";
|
|
}
|
|
}
|
|
if (typeof next.message === "string") {
|
|
const trimmed = next.message.trim();
|
|
if (trimmed) {
|
|
next.message = trimmed;
|
|
}
|
|
}
|
|
if (typeof next.text === "string") {
|
|
const trimmed = next.text.trim();
|
|
if (trimmed) {
|
|
next.text = trimmed;
|
|
}
|
|
}
|
|
if ("model" in next) {
|
|
const model = parseOptionalField(TrimmedNonEmptyStringFieldSchema, next.model);
|
|
if (model !== undefined) {
|
|
next.model = model;
|
|
} else {
|
|
delete next.model;
|
|
}
|
|
}
|
|
if ("thinking" in next) {
|
|
const thinking = parseOptionalField(TrimmedNonEmptyStringFieldSchema, next.thinking);
|
|
if (thinking !== undefined) {
|
|
next.thinking = thinking;
|
|
} else {
|
|
delete next.thinking;
|
|
}
|
|
}
|
|
if ("timeoutSeconds" in next) {
|
|
const timeoutSeconds = parseOptionalField(TimeoutSecondsFieldSchema, next.timeoutSeconds);
|
|
if (timeoutSeconds !== undefined) {
|
|
next.timeoutSeconds = timeoutSeconds;
|
|
} else {
|
|
delete next.timeoutSeconds;
|
|
}
|
|
}
|
|
if ("fallbacks" in next) {
|
|
const fallbacks = normalizeTrimmedStringArray(next.fallbacks);
|
|
if (fallbacks !== undefined) {
|
|
next.fallbacks = fallbacks;
|
|
} else {
|
|
delete next.fallbacks;
|
|
}
|
|
}
|
|
if ("toolsAllow" in next) {
|
|
const toolsAllow = normalizeTrimmedStringArray(next.toolsAllow, { allowNull: true });
|
|
if (toolsAllow !== undefined) {
|
|
next.toolsAllow = toolsAllow;
|
|
} else {
|
|
delete next.toolsAllow;
|
|
}
|
|
}
|
|
if (
|
|
"allowUnsafeExternalContent" in next &&
|
|
typeof next.allowUnsafeExternalContent !== "boolean"
|
|
) {
|
|
delete next.allowUnsafeExternalContent;
|
|
}
|
|
if (next.kind === "systemEvent") {
|
|
delete next.message;
|
|
delete next.model;
|
|
delete next.fallbacks;
|
|
delete next.thinking;
|
|
delete next.timeoutSeconds;
|
|
delete next.lightContext;
|
|
delete next.allowUnsafeExternalContent;
|
|
delete next.toolsAllow;
|
|
} else if (next.kind === "agentTurn") {
|
|
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;
|
|
}
|
|
|
|
function coerceDelivery(delivery: UnknownRecord) {
|
|
const next: UnknownRecord = { ...delivery };
|
|
const parsed = parseDeliveryInput(delivery);
|
|
if (parsed.mode !== undefined) {
|
|
next.mode = parsed.mode;
|
|
} else if ("mode" in next) {
|
|
delete next.mode;
|
|
}
|
|
if (parsed.channel !== undefined) {
|
|
next.channel = parsed.channel;
|
|
} else if ("channel" in next) {
|
|
delete next.channel;
|
|
}
|
|
if (parsed.to !== undefined) {
|
|
next.to = parsed.to;
|
|
} else if ("to" in next) {
|
|
delete next.to;
|
|
}
|
|
if (parsed.threadId !== undefined) {
|
|
next.threadId = parsed.threadId;
|
|
} else if ("threadId" in next) {
|
|
delete next.threadId;
|
|
}
|
|
if (parsed.accountId !== undefined) {
|
|
next.accountId = parsed.accountId;
|
|
} else if ("accountId" in next) {
|
|
delete next.accountId;
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function inferTopLevelPayload(next: UnknownRecord) {
|
|
const message = typeof next.message === "string" ? next.message.trim() : "";
|
|
if (message) {
|
|
return { kind: "agentTurn", message } satisfies UnknownRecord;
|
|
}
|
|
|
|
const text = typeof next.text === "string" ? next.text.trim() : "";
|
|
if (text) {
|
|
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) {
|
|
if (typeof raw !== "string") {
|
|
return undefined;
|
|
}
|
|
const trimmed = raw.trim();
|
|
const lower = trimmed.toLowerCase();
|
|
if (lower === "main" || lower === "isolated" || lower === "current") {
|
|
return lower;
|
|
}
|
|
// Support custom session IDs with "session:" prefix
|
|
if (lower.startsWith("session:")) {
|
|
const sessionId = trimmed.slice(8).trim();
|
|
if (sessionId) {
|
|
return `session:${sessionId}`;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function normalizeWakeMode(raw: unknown) {
|
|
if (typeof raw !== "string") {
|
|
return undefined;
|
|
}
|
|
const trimmed = raw.trim().toLowerCase();
|
|
if (trimmed === "now" || trimmed === "next-heartbeat") {
|
|
return trimmed;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function copyTopLevelAgentTurnFields(next: UnknownRecord, payload: UnknownRecord) {
|
|
const copyString = (field: "model" | "thinking") => {
|
|
if (typeof payload[field] === "string" && payload[field].trim()) {
|
|
return;
|
|
}
|
|
const value = next[field];
|
|
if (typeof value === "string" && value.trim()) {
|
|
payload[field] = value.trim();
|
|
}
|
|
};
|
|
copyString("model");
|
|
copyString("thinking");
|
|
|
|
if (typeof payload.timeoutSeconds !== "number" && typeof next.timeoutSeconds === "number") {
|
|
payload.timeoutSeconds = next.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 });
|
|
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.deliver;
|
|
delete next.channel;
|
|
delete next.to;
|
|
delete next.toolsAllow;
|
|
delete next.threadId;
|
|
delete next.bestEffortDeliver;
|
|
delete next.provider;
|
|
}
|
|
|
|
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 ("sessionKey" in base) {
|
|
const sessionKey = base.sessionKey;
|
|
if (sessionKey === null) {
|
|
next.sessionKey = null;
|
|
} else if (typeof sessionKey === "string") {
|
|
const trimmed = sessionKey.trim();
|
|
if (trimmed) {
|
|
next.sessionKey = trimmed;
|
|
} else {
|
|
delete next.sessionKey;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 ("sessionTarget" in base) {
|
|
const normalized = normalizeSessionTarget(base.sessionTarget);
|
|
if (normalized) {
|
|
next.sessionTarget = normalized;
|
|
} else {
|
|
delete next.sessionTarget;
|
|
}
|
|
}
|
|
|
|
if ("wakeMode" in base) {
|
|
const normalized = normalizeWakeMode(base.wakeMode);
|
|
if (normalized) {
|
|
next.wakeMode = normalized;
|
|
} else {
|
|
delete next.wakeMode;
|
|
}
|
|
}
|
|
|
|
if (isRecord(base.schedule)) {
|
|
next.schedule = coerceSchedule(base.schedule);
|
|
}
|
|
|
|
if (!("payload" in next) || !isRecord(next.payload)) {
|
|
const inferredPayload = inferTopLevelPayload(next);
|
|
if (inferredPayload) {
|
|
next.payload = inferredPayload;
|
|
}
|
|
}
|
|
|
|
if (isRecord(base.payload)) {
|
|
next.payload = coercePayload(base.payload);
|
|
}
|
|
|
|
if (isRecord(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 (!next.wakeMode) {
|
|
next.wakeMode = "now";
|
|
}
|
|
if (typeof next.enabled !== "boolean") {
|
|
next.enabled = true;
|
|
}
|
|
if (
|
|
(typeof next.name !== "string" || !next.name.trim()) &&
|
|
isRecord(next.schedule) &&
|
|
isRecord(next.payload)
|
|
) {
|
|
next.name = inferLegacyName({
|
|
schedule: next.schedule as { kind?: unknown; everyMs?: unknown; expr?: unknown },
|
|
payload: next.payload as { kind?: unknown; text?: unknown; message?: unknown },
|
|
});
|
|
} else if (typeof next.name === "string") {
|
|
const trimmed = next.name.trim();
|
|
if (trimmed) {
|
|
next.name = trimmed;
|
|
}
|
|
}
|
|
if (!next.sessionTarget && isRecord(next.payload)) {
|
|
const kind = typeof next.payload.kind === "string" ? next.payload.kind : "";
|
|
// Keep default behavior unchanged for backward compatibility:
|
|
// - systemEvent defaults to "main"
|
|
// - agentTurn defaults to "isolated" (NOT "current", to avoid token accumulation)
|
|
// Users must explicitly specify "current" or "session:xxx" for custom session binding
|
|
if (kind === "systemEvent") {
|
|
next.sessionTarget = "main";
|
|
} else if (kind === "agentTurn") {
|
|
next.sessionTarget = "isolated";
|
|
}
|
|
}
|
|
|
|
// Resolve "current" sessionTarget to the actual sessionKey from context
|
|
if (next.sessionTarget === "current") {
|
|
if (options.sessionContext?.sessionKey) {
|
|
const sessionKey = options.sessionContext.sessionKey.trim();
|
|
if (sessionKey) {
|
|
// Store as session:customId format for persistence
|
|
next.sessionTarget = `session:${sessionKey}`;
|
|
}
|
|
}
|
|
// If "current" wasn't resolved, fall back to "isolated" behavior
|
|
// This handles CLI/headless usage where no session context exists
|
|
if (next.sessionTarget === "current") {
|
|
next.sessionTarget = "isolated";
|
|
}
|
|
}
|
|
if (next.sessionTarget === "current") {
|
|
const sessionKey = options.sessionContext?.sessionKey?.trim();
|
|
if (sessionKey) {
|
|
next.sessionTarget = `session:${sessionKey}`;
|
|
} else {
|
|
next.sessionTarget = "isolated";
|
|
}
|
|
}
|
|
if (
|
|
"schedule" in next &&
|
|
isRecord(next.schedule) &&
|
|
next.schedule.kind === "at" &&
|
|
!("deleteAfterRun" in next)
|
|
) {
|
|
next.deleteAfterRun = true;
|
|
}
|
|
if ("schedule" in next && isRecord(next.schedule) && next.schedule.kind === "cron") {
|
|
const schedule = next.schedule as UnknownRecord;
|
|
const explicit = normalizeCronStaggerMs(schedule.staggerMs);
|
|
if (explicit !== undefined) {
|
|
schedule.staggerMs = explicit;
|
|
} else {
|
|
const expr = typeof schedule.expr === "string" ? schedule.expr : "";
|
|
const defaultStaggerMs = resolveDefaultCronStaggerMs(expr);
|
|
if (defaultStaggerMs !== undefined) {
|
|
schedule.staggerMs = defaultStaggerMs;
|
|
}
|
|
}
|
|
}
|
|
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 : "";
|
|
// Support "isolated", custom session IDs (session:xxx), and resolved "current" as isolated-like targets
|
|
const isIsolatedAgentTurn =
|
|
sessionTarget === "isolated" ||
|
|
sessionTarget === "current" ||
|
|
sessionTarget.startsWith("session:") ||
|
|
(sessionTarget === "" && payloadKind === "agentTurn");
|
|
const hasDelivery = "delivery" in next && next.delivery !== undefined;
|
|
if (!hasDelivery && isIsolatedAgentTurn && payloadKind === "agentTurn") {
|
|
next.delivery = { mode: "announce" };
|
|
}
|
|
}
|
|
|
|
return next;
|
|
}
|
|
|
|
export function normalizeCronJobCreate(
|
|
raw: unknown,
|
|
options?: Omit<NormalizeOptions, "applyDefaults">,
|
|
): 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;
|
|
}
|