- Pipeline cancel/approve/reject corrigido com busca bidirecional - Secrets injetados no executor via cleanEnv - Versionamento automático ao atualizar agentes - writeJsonAsync com log de erro - Removido asyncHandler.js (código morto) - Restaurado permissionMode padrão bypassPermissions - Ícones dos cards alinhados à direita com wrapper - Botão Editar convertido para ícone nos cards - Webhook test agora dispara execução real do agente/pipeline - Corrigido App.navigateTo no teste de webhook
170 lines
4.9 KiB
JavaScript
170 lines
4.9 KiB
JavaScript
import cron from 'node-cron';
|
|
import { EventEmitter } from 'events';
|
|
import { schedulesStore } from '../store/db.js';
|
|
|
|
const HISTORY_LIMIT = 50;
|
|
const schedules = new Map();
|
|
const history = [];
|
|
const emitter = new EventEmitter();
|
|
|
|
const cronDateCache = new Map();
|
|
const CRON_CACHE_TTL = 60_000;
|
|
|
|
function addToHistory(entry) {
|
|
history.unshift(entry);
|
|
if (history.length > HISTORY_LIMIT) history.splice(HISTORY_LIMIT);
|
|
}
|
|
|
|
function matchesCronPart(part, value) {
|
|
if (part === '*') return true;
|
|
if (part.startsWith('*/')) {
|
|
const divisor = parseInt(part.slice(2));
|
|
if (!divisor || divisor <= 0) return false;
|
|
return value % divisor === 0;
|
|
}
|
|
if (part.includes(',')) return part.split(',').map(Number).includes(value);
|
|
if (part.includes('-')) {
|
|
const [start, end] = part.split('-').map(Number);
|
|
return value >= start && value <= end;
|
|
}
|
|
return parseInt(part) === value;
|
|
}
|
|
|
|
function computeNextCronDate(cronExpr) {
|
|
const parts = cronExpr.split(' ');
|
|
if (parts.length !== 5) return null;
|
|
|
|
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
|
|
|
const candidate = new Date();
|
|
candidate.setSeconds(0);
|
|
candidate.setMilliseconds(0);
|
|
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
|
|
for (let i = 0; i < 525600; i++) {
|
|
if (
|
|
matchesCronPart(minute, candidate.getMinutes()) &&
|
|
matchesCronPart(hour, candidate.getHours()) &&
|
|
matchesCronPart(dayOfMonth, candidate.getDate()) &&
|
|
matchesCronPart(month, candidate.getMonth() + 1) &&
|
|
matchesCronPart(dayOfWeek, candidate.getDay())
|
|
) {
|
|
return candidate.toISOString();
|
|
}
|
|
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function nextCronDate(cronExpr) {
|
|
const now = Date.now();
|
|
const cached = cronDateCache.get(cronExpr);
|
|
if (cached && now - cached.at < CRON_CACHE_TTL) return cached.val;
|
|
const val = computeNextCronDate(cronExpr);
|
|
cronDateCache.set(cronExpr, { val, at: now });
|
|
return val;
|
|
}
|
|
|
|
export function schedule(taskId, cronExpr, callback, persist = true) {
|
|
if (schedules.has(taskId)) unschedule(taskId, false);
|
|
if (!cron.validate(cronExpr)) throw new Error(`Expressão cron inválida: ${cronExpr}`);
|
|
|
|
const MIN_INTERVAL_PARTS = cronExpr.split(' ');
|
|
if (MIN_INTERVAL_PARTS[0] === '*' && MIN_INTERVAL_PARTS[1] === '*') {
|
|
throw new Error('Intervalo mínimo de agendamento é 5 minutos. Use */5 ou maior.');
|
|
}
|
|
if (MIN_INTERVAL_PARTS[0].startsWith('*/')) {
|
|
const interval = parseInt(MIN_INTERVAL_PARTS[0].slice(2));
|
|
if (interval < 5 && MIN_INTERVAL_PARTS[1] === '*') {
|
|
throw new Error(`Intervalo mínimo de agendamento é 5 minutos. Recebido: ${cronExpr}`);
|
|
}
|
|
}
|
|
|
|
const task = cron.schedule(
|
|
cronExpr,
|
|
() => {
|
|
const firedAt = new Date().toISOString();
|
|
addToHistory({ taskId, cronExpr, firedAt });
|
|
emitter.emit('scheduled-task', { taskId, firedAt });
|
|
cronDateCache.delete(cronExpr);
|
|
if (callback) callback({ taskId, firedAt });
|
|
},
|
|
{ scheduled: true }
|
|
);
|
|
|
|
schedules.set(taskId, { taskId, cronExpr, task, active: true, createdAt: new Date().toISOString() });
|
|
return { taskId, cronExpr };
|
|
}
|
|
|
|
export function unschedule(taskId, persist = true) {
|
|
const entry = schedules.get(taskId);
|
|
if (!entry) return false;
|
|
entry.task.stop();
|
|
schedules.delete(taskId);
|
|
if (persist) schedulesStore.delete(taskId);
|
|
return true;
|
|
}
|
|
|
|
export function updateSchedule(taskId, cronExpr, callback) {
|
|
const entry = schedules.get(taskId);
|
|
if (!entry) return false;
|
|
entry.task.stop();
|
|
schedules.delete(taskId);
|
|
if (!cron.validate(cronExpr)) throw new Error(`Expressão cron inválida: ${cronExpr}`);
|
|
schedule(taskId, cronExpr, callback, false);
|
|
return true;
|
|
}
|
|
|
|
export function setActive(taskId, active) {
|
|
const entry = schedules.get(taskId);
|
|
if (!entry) return false;
|
|
active ? entry.task.start() : entry.task.stop();
|
|
entry.active = active;
|
|
return true;
|
|
}
|
|
|
|
export function getSchedules() {
|
|
const stored = schedulesStore.getAll();
|
|
return stored.map((s) => {
|
|
const inMemory = schedules.get(s.id);
|
|
const cronExpr = s.cronExpression || s.cronExpr || '';
|
|
return {
|
|
...s,
|
|
cronExpr,
|
|
active: inMemory ? inMemory.active : false,
|
|
nextRun: nextCronDate(cronExpr),
|
|
};
|
|
});
|
|
}
|
|
|
|
export function getHistory() {
|
|
return [...history];
|
|
}
|
|
|
|
export function restoreSchedules(executeFn) {
|
|
const stored = schedulesStore.getAll();
|
|
let restored = 0;
|
|
|
|
for (const s of stored) {
|
|
if (!s.active) continue;
|
|
const cronExpr = s.cronExpression || s.cronExpr;
|
|
try {
|
|
schedule(s.id, cronExpr, () => executeFn(s.agentId, s.taskDescription, s.id), false);
|
|
restored++;
|
|
} catch (err) {
|
|
console.log(`[scheduler] Falha ao restaurar ${s.id}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
if (restored > 0) console.log(`[scheduler] ${restored} agendamento(s) restaurado(s)`);
|
|
}
|
|
|
|
export function on(event, listener) {
|
|
emitter.on(event, listener);
|
|
}
|
|
|
|
export function off(event, listener) {
|
|
emitter.off(event, listener);
|
|
}
|