mirror of
https://github.com/eggent-ai/eggent.git
synced 2026-03-08 02:23:06 +00:00
447 lines
13 KiB
TypeScript
447 lines
13 KiB
TypeScript
import type { CronJobCreate, CronJobPatch, CronSchedule } from "@/lib/cron/types";
|
|
|
|
type UnknownRecord = Record<string, unknown>;
|
|
|
|
const CRON_JOB_KEYS: ReadonlySet<string> = new Set([
|
|
"name",
|
|
"description",
|
|
"enabled",
|
|
"deleteAfterRun",
|
|
"data",
|
|
"schedule",
|
|
"scheduleKind",
|
|
"scheduleAt",
|
|
"at",
|
|
"everyMs",
|
|
"anchorMs",
|
|
"expr",
|
|
"cronExpr",
|
|
"cronTz",
|
|
"tz",
|
|
"delaySeconds",
|
|
"delayMs",
|
|
"payload",
|
|
"message",
|
|
"payloadMessage",
|
|
"text",
|
|
"chatId",
|
|
"telegramChatId",
|
|
"telegram_chat_id",
|
|
"currentPath",
|
|
"timeoutSeconds",
|
|
"job",
|
|
]);
|
|
|
|
const CRON_PATCH_KEYS: ReadonlySet<string> = new Set([
|
|
"name",
|
|
"description",
|
|
"enabled",
|
|
"deleteAfterRun",
|
|
"data",
|
|
"schedule",
|
|
"payload",
|
|
"message",
|
|
"text",
|
|
"chatId",
|
|
"telegramChatId",
|
|
"telegram_chat_id",
|
|
"currentPath",
|
|
"timeoutSeconds",
|
|
"patch",
|
|
]);
|
|
|
|
function asRecord(value: unknown): UnknownRecord | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return null;
|
|
}
|
|
return value as UnknownRecord;
|
|
}
|
|
|
|
function toRecord(value: unknown): UnknownRecord | null {
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
|
|
return null;
|
|
}
|
|
try {
|
|
return asRecord(JSON.parse(trimmed));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
return asRecord(value);
|
|
}
|
|
|
|
function readString(value: unknown): string | undefined {
|
|
if (typeof value !== "string") {
|
|
return undefined;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed || undefined;
|
|
}
|
|
|
|
function readNumber(value: unknown): number | undefined {
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
return value;
|
|
}
|
|
if (typeof value === "string" && value.trim()) {
|
|
const parsed = Number(value);
|
|
if (Number.isFinite(parsed)) {
|
|
return parsed;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function readBoolean(value: unknown): boolean | undefined {
|
|
if (typeof value === "boolean") {
|
|
return value;
|
|
}
|
|
if (typeof value === "string") {
|
|
const lowered = value.trim().toLowerCase();
|
|
if (lowered === "true") return true;
|
|
if (lowered === "false") return false;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function copyKnownFields(input: UnknownRecord, keys: ReadonlySet<string>): UnknownRecord {
|
|
const out: UnknownRecord = {};
|
|
for (const key of Object.keys(input)) {
|
|
if (keys.has(key) && input[key] !== undefined) {
|
|
out[key] = input[key];
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function unwrapKnownWrappers(
|
|
input: UnknownRecord,
|
|
wrapperKeys: readonly string[]
|
|
): UnknownRecord {
|
|
let current = input;
|
|
for (let i = 0; i < 4; i++) {
|
|
let next: UnknownRecord | null = null;
|
|
for (const key of wrapperKeys) {
|
|
const candidate = toRecord(current[key]);
|
|
if (candidate && Object.keys(candidate).length > 0) {
|
|
next = candidate;
|
|
break;
|
|
}
|
|
}
|
|
if (!next) {
|
|
break;
|
|
}
|
|
current = next;
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function hasMeaningfulAddFields(input: UnknownRecord): boolean {
|
|
return Boolean(
|
|
input.schedule !== undefined ||
|
|
input.payload !== undefined ||
|
|
input.message !== undefined ||
|
|
input.text !== undefined ||
|
|
input.delaySeconds !== undefined ||
|
|
input.delayMs !== undefined ||
|
|
input.scheduleKind !== undefined ||
|
|
input.at !== undefined ||
|
|
input.everyMs !== undefined ||
|
|
input.expr !== undefined ||
|
|
input.cronExpr !== undefined
|
|
);
|
|
}
|
|
|
|
function extractAddSource(input: UnknownRecord): UnknownRecord {
|
|
const rawData = toRecord(input.data);
|
|
if (rawData && Object.keys(rawData).length > 0) {
|
|
return unwrapKnownWrappers(rawData, ["data", "job"]);
|
|
}
|
|
|
|
const rawJob = toRecord(input.job);
|
|
if (rawJob && Object.keys(rawJob).length > 0) {
|
|
return unwrapKnownWrappers(rawJob, ["data", "job"]);
|
|
}
|
|
|
|
const synthetic = copyKnownFields(input, CRON_JOB_KEYS);
|
|
if (hasMeaningfulAddFields(synthetic)) {
|
|
return unwrapKnownWrappers(synthetic, ["data", "job"]);
|
|
}
|
|
|
|
return unwrapKnownWrappers(input, ["data", "job"]);
|
|
}
|
|
|
|
function extractPatchSource(input: UnknownRecord, patch: unknown): UnknownRecord | null {
|
|
const patchRecord = toRecord(patch);
|
|
if (patchRecord && Object.keys(patchRecord).length > 0) {
|
|
return unwrapKnownWrappers(patchRecord, ["data", "patch", "job"]);
|
|
}
|
|
const synthetic = copyKnownFields(input, CRON_PATCH_KEYS);
|
|
return Object.keys(synthetic).length > 0
|
|
? unwrapKnownWrappers(synthetic, ["data", "patch", "job"])
|
|
: null;
|
|
}
|
|
|
|
function normalizeScheduleFromRecord(input: UnknownRecord): CronSchedule | null {
|
|
const scheduleRecord = toRecord(input.schedule);
|
|
const scheduleRaw = scheduleRecord
|
|
? unwrapKnownWrappers(scheduleRecord, ["data", "schedule"])
|
|
: input;
|
|
|
|
const rawKind = readString(scheduleRaw.kind)?.toLowerCase();
|
|
const scheduleKindHint = readString(scheduleRaw.scheduleKind)?.toLowerCase();
|
|
const explicitEveryKind = rawKind === "every" || scheduleKindHint === "every";
|
|
const at =
|
|
readString(scheduleRaw.at) ??
|
|
readString(scheduleRaw.scheduleAt) ??
|
|
readString(scheduleRaw.runAt) ??
|
|
readString(scheduleRaw.when);
|
|
const everyMsDirect =
|
|
readNumber(scheduleRaw.everyMs) ??
|
|
readNumber(scheduleRaw.intervalMs) ??
|
|
readNumber(scheduleRaw.repeatMs);
|
|
const everySeconds =
|
|
readNumber(scheduleRaw.everySeconds) ??
|
|
readNumber(scheduleRaw.intervalSeconds) ??
|
|
readNumber(scheduleRaw.repeatSeconds) ??
|
|
(explicitEveryKind ? readNumber(scheduleRaw.seconds) : undefined);
|
|
const everyMinutes =
|
|
readNumber(scheduleRaw.everyMinutes) ??
|
|
readNumber(scheduleRaw.intervalMinutes) ??
|
|
readNumber(scheduleRaw.repeatMinutes);
|
|
const everyHours =
|
|
readNumber(scheduleRaw.everyHours) ??
|
|
readNumber(scheduleRaw.intervalHours) ??
|
|
readNumber(scheduleRaw.repeatHours);
|
|
const everyMs =
|
|
typeof everyMsDirect === "number" && everyMsDirect > 0
|
|
? everyMsDirect
|
|
: typeof everySeconds === "number" && everySeconds > 0
|
|
? everySeconds * 1_000
|
|
: typeof everyMinutes === "number" && everyMinutes > 0
|
|
? everyMinutes * 60_000
|
|
: typeof everyHours === "number" && everyHours > 0
|
|
? everyHours * 3_600_000
|
|
: undefined;
|
|
const anchorMs = readNumber(scheduleRaw.anchorMs);
|
|
const expr = readString(scheduleRaw.expr) ?? readString(scheduleRaw.cronExpr);
|
|
const tz = readString(scheduleRaw.tz) ?? readString(scheduleRaw.cronTz);
|
|
|
|
const kind =
|
|
rawKind === "at" || rawKind === "every" || rawKind === "cron"
|
|
? rawKind
|
|
: at
|
|
? "at"
|
|
: everyMs
|
|
? "every"
|
|
: expr
|
|
? "cron"
|
|
: scheduleKindHint;
|
|
|
|
if (kind === "at" && at) {
|
|
return { kind: "at", at };
|
|
}
|
|
if (kind === "every" && everyMs && everyMs > 0) {
|
|
return {
|
|
kind: "every",
|
|
everyMs: Math.max(1, Math.floor(everyMs)),
|
|
anchorMs:
|
|
typeof anchorMs === "number" && Number.isFinite(anchorMs)
|
|
? Math.max(0, Math.floor(anchorMs))
|
|
: undefined,
|
|
};
|
|
}
|
|
if (kind === "cron" && expr) {
|
|
return { kind: "cron", expr, tz };
|
|
}
|
|
|
|
const delaySeconds =
|
|
readNumber(scheduleRaw.delaySeconds) ??
|
|
readNumber(scheduleRaw.inSeconds) ??
|
|
readNumber(scheduleRaw.afterSeconds) ??
|
|
readNumber(scheduleRaw.seconds);
|
|
const delayMs =
|
|
readNumber(scheduleRaw.delayMs) ??
|
|
readNumber(scheduleRaw.inMs) ??
|
|
readNumber(scheduleRaw.afterMs);
|
|
|
|
const totalMs =
|
|
typeof delayMs === "number" && delayMs > 0
|
|
? delayMs
|
|
: typeof delaySeconds === "number" && delaySeconds > 0
|
|
? delaySeconds * 1_000
|
|
: 0;
|
|
if (kind === "every" && totalMs > 0) {
|
|
return {
|
|
kind: "every",
|
|
everyMs: Math.max(1, Math.floor(totalMs)),
|
|
anchorMs:
|
|
typeof anchorMs === "number" && Number.isFinite(anchorMs)
|
|
? Math.max(0, Math.floor(anchorMs))
|
|
: undefined,
|
|
};
|
|
}
|
|
if (totalMs > 0) {
|
|
return { kind: "at", at: new Date(Date.now() + totalMs).toISOString() };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function normalizePayloadFromRecord(input: UnknownRecord): CronJobCreate["payload"] | null {
|
|
const payloadRaw = toRecord(input.payload);
|
|
const payload = payloadRaw ? unwrapKnownWrappers(payloadRaw, ["data", "payload"]) : input;
|
|
|
|
const rawKind = readString(payload.kind)?.toLowerCase();
|
|
const kind = rawKind === "agentturn" ? "agentTurn" : rawKind;
|
|
const message =
|
|
readString(payload.message) ??
|
|
readString(payload.text) ??
|
|
readString(input.message) ??
|
|
readString(input.payloadMessage) ??
|
|
readString(input.text);
|
|
|
|
if ((kind && kind !== "agentturn" && kind !== "agentTurn") || !message) {
|
|
return null;
|
|
}
|
|
|
|
const timeoutSeconds = readNumber(payload.timeoutSeconds) ?? readNumber(input.timeoutSeconds);
|
|
return {
|
|
kind: "agentTurn",
|
|
message,
|
|
chatId: readString(payload.chatId) ?? readString(input.chatId),
|
|
telegramChatId:
|
|
readString(payload.telegramChatId) ??
|
|
readString(payload.telegram_chat_id) ??
|
|
readString(input.telegramChatId) ??
|
|
readString(input.telegram_chat_id),
|
|
currentPath: readString(payload.currentPath) ?? readString(input.currentPath),
|
|
timeoutSeconds:
|
|
typeof timeoutSeconds === "number" && Number.isFinite(timeoutSeconds)
|
|
? Math.max(0, Math.floor(timeoutSeconds))
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
function explainAddInputFailure(source: UnknownRecord): string {
|
|
const schedule = normalizeScheduleFromRecord(source);
|
|
const payload = normalizePayloadFromRecord(source);
|
|
|
|
if (schedule && payload) {
|
|
return "";
|
|
}
|
|
|
|
const problems: string[] = [];
|
|
if (!schedule) {
|
|
problems.push(
|
|
"Missing schedule. Provide `schedule` (`at`/`every`/`cron`) or `delaySeconds`/`delayMs`."
|
|
);
|
|
}
|
|
|
|
if (!payload) {
|
|
const payloadRecord = toRecord(source.payload);
|
|
const hasMessage =
|
|
Boolean(readString(payloadRecord?.message)) ||
|
|
Boolean(readString(payloadRecord?.text)) ||
|
|
Boolean(readString(source.message)) ||
|
|
Boolean(readString(source.payloadMessage)) ||
|
|
Boolean(readString(source.text));
|
|
const rawKind = readString(payloadRecord?.kind);
|
|
|
|
if (!hasMessage) {
|
|
problems.push("Missing payload message. Provide `payload.message` (or top-level `message`).");
|
|
} else if (rawKind && rawKind.toLowerCase() !== "agentturn") {
|
|
problems.push("Invalid payload kind. `payload.kind` must be `agentTurn`.");
|
|
} else {
|
|
problems.push("Invalid payload object. Expected `payload.kind=\"agentTurn\"` + `payload.message`.");
|
|
}
|
|
}
|
|
|
|
problems.push(
|
|
"Example: {\"action\":\"add\",\"delaySeconds\":30,\"message\":\"Отправь пользователю: привет\"}"
|
|
);
|
|
problems.push(
|
|
"Recurring example: {\"action\":\"add\",\"schedule\":{\"kind\":\"every\",\"everyMs\":30000},\"payload\":{\"kind\":\"agentTurn\",\"message\":\"Отправь пользователю: привет\"}}"
|
|
);
|
|
return problems.join(" ");
|
|
}
|
|
|
|
export function normalizeCronToolAddInput(rawInput: unknown): CronJobCreate | null {
|
|
const input = toRecord(rawInput);
|
|
if (!input) return null;
|
|
|
|
const source = extractAddSource(input);
|
|
const schedule = normalizeScheduleFromRecord(source);
|
|
const payload = normalizePayloadFromRecord(source);
|
|
if (schedule && payload) {
|
|
const enabled = readBoolean(source.enabled);
|
|
const deleteAfterRun = readBoolean(source.deleteAfterRun);
|
|
return {
|
|
name: readString(source.name) ?? "Cron job",
|
|
description: readString(source.description),
|
|
enabled: enabled ?? true,
|
|
deleteAfterRun: deleteAfterRun ?? (schedule.kind === "at" ? true : undefined),
|
|
schedule,
|
|
payload,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function explainCronToolAddInputFailure(rawInput: unknown): string {
|
|
const input = toRecord(rawInput);
|
|
if (!input) {
|
|
return "Arguments must be a JSON object.";
|
|
}
|
|
const source = extractAddSource(input);
|
|
return explainAddInputFailure(source);
|
|
}
|
|
|
|
export function normalizeCronToolPatchInput(
|
|
rawInput: unknown,
|
|
rawPatch: unknown
|
|
): CronJobPatch | null {
|
|
const input = toRecord(rawInput);
|
|
if (!input) return null;
|
|
const source = extractPatchSource(input, rawPatch);
|
|
if (!source) return null;
|
|
|
|
const patch: CronJobPatch = {};
|
|
if ("name" in source) {
|
|
patch.name = readString(source.name) ?? "";
|
|
}
|
|
if ("description" in source) {
|
|
patch.description = readString(source.description) ?? "";
|
|
}
|
|
if ("enabled" in source) {
|
|
const enabled = readBoolean(source.enabled);
|
|
if (typeof enabled === "boolean") patch.enabled = enabled;
|
|
}
|
|
if ("deleteAfterRun" in source) {
|
|
const deleteAfterRun = readBoolean(source.deleteAfterRun);
|
|
if (typeof deleteAfterRun === "boolean") patch.deleteAfterRun = deleteAfterRun;
|
|
}
|
|
|
|
if ("schedule" in source || "scheduleKind" in source || "at" in source || "everyMs" in source || "expr" in source || "cronExpr" in source) {
|
|
const schedule = normalizeScheduleFromRecord(source);
|
|
if (!schedule) return null;
|
|
patch.schedule = schedule;
|
|
}
|
|
|
|
const payload = normalizePayloadFromRecord(source);
|
|
if (payload) {
|
|
patch.payload = {
|
|
kind: "agentTurn",
|
|
message: payload.message,
|
|
chatId: payload.chatId,
|
|
telegramChatId: payload.telegramChatId,
|
|
currentPath: payload.currentPath,
|
|
timeoutSeconds: payload.timeoutSeconds,
|
|
};
|
|
}
|
|
|
|
return Object.keys(patch).length > 0 ? patch : null;
|
|
}
|