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:
2026-07-24 13:52:03 +03:00
parent c152a670ae
commit b4bded2ffb
8 changed files with 130 additions and 7 deletions

View 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");
}