feat: триггер schedule для автоматизаций — ежедневный запуск по МСК
- планировщик 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 }
This commit is contained in:
77
server/workers/automation-scheduler.ts
Normal file
77
server/workers/automation-scheduler.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user