- планировщик server/workers/automation-scheduler.ts (тик 60 сек, все организации)
- защита от дублей: automations.last_scheduled_run_at (миграция 0065), catch-up в пределах суток
- UI: выбор триггера 'По расписанию' и времени запуска в Automations.tsx
- MCP: create_automation/update_automation принимают trigger 'schedule'
- triggerData: { trigger: 'schedule', scheduledAt }
78 lines
3.1 KiB
TypeScript
78 lines
3.1 KiB
TypeScript
import { storage } from "../storage";
|
||
import { withSuperAdmin } from "../db";
|
||
import { runAutomationInWorker } from "../routes/automation.routes";
|
||
|
||
/**
|
||
* Планировщик schedule-автоматизаций.
|
||
*
|
||
* Раз в минуту проверяет активные автоматизации с trigger = 'schedule' всех
|
||
* организаций и запускает те, у которых время из triggerConfig.time (HH:MM, МСК)
|
||
* уже наступило сегодня, а запуска сегодня ещё не было. Отметка о запуске
|
||
* хранится в automations.last_scheduled_run_at, поэтому переживает рестарт
|
||
* контейнера; при даунтайме в момент HH:MM автоматизация запустится
|
||
* при первой возможности в тот же день (catch-up в пределах суток).
|
||
*/
|
||
|
||
const TICK_MS = 60_000;
|
||
const MSK_OFFSET_MS = 3 * 60 * 60 * 1000; // МСК = UTC+3, перехода на летнее время нет
|
||
|
||
function mskNow(): { today: string; minutesOfDay: number } {
|
||
const msk = new Date(Date.now() + MSK_OFFSET_MS);
|
||
return {
|
||
today: msk.toISOString().slice(0, 10),
|
||
minutesOfDay: msk.getUTCHours() * 60 + msk.getUTCMinutes(),
|
||
};
|
||
}
|
||
|
||
function parseTime(value: unknown): number | null {
|
||
if (typeof value !== "string") return null;
|
||
const m = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
|
||
if (!m) return null;
|
||
const h = Number(m[1]);
|
||
const min = Number(m[2]);
|
||
if (h > 23 || min > 59) return null;
|
||
return h * 60 + min;
|
||
}
|
||
|
||
async function tick(): Promise<void> {
|
||
const { today, minutesOfDay } = mskNow();
|
||
const list = await withSuperAdmin(() => storage.getActiveScheduleAutomations());
|
||
|
||
for (const automation of list) {
|
||
try {
|
||
const config = (automation.triggerConfig as Record<string, unknown>) || {};
|
||
const target = parseTime(config.time);
|
||
if (target === null || target > minutesOfDay) continue;
|
||
|
||
if (automation.lastScheduledRunAt) {
|
||
const lastMskDay = new Date(automation.lastScheduledRunAt.getTime() + MSK_OFFSET_MS)
|
||
.toISOString()
|
||
.slice(0, 10);
|
||
if (lastMskDay >= today) continue; // сегодня уже запускалась
|
||
}
|
||
|
||
const { logs, error } = await withSuperAdmin(() =>
|
||
runAutomationInWorker(automation.code, automation.organizationId, {
|
||
trigger: "schedule",
|
||
scheduledAt: new Date().toISOString(),
|
||
})
|
||
);
|
||
await withSuperAdmin(() => storage.markAutomationScheduledRun(automation.id));
|
||
|
||
console.log(
|
||
`[Automation Scheduler] '${automation.name}' (id ${automation.id}, org ${automation.organizationId}): ${error ? "ERROR " + error : "ok"}`
|
||
);
|
||
for (const line of logs) console.log(`[Automation Scheduler] ${line}`);
|
||
} catch (err) {
|
||
console.error(`[Automation Scheduler] Failed automation ${automation.id}:`, err);
|
||
}
|
||
}
|
||
}
|
||
|
||
export function startAutomationScheduler(): void {
|
||
setInterval(() => {
|
||
tick().catch((err) => console.error("[Automation Scheduler] Tick error:", err));
|
||
}, TICK_MS);
|
||
console.log("[Automation Scheduler] started");
|
||
}
|