wipe_schedules rows existed but nothing read or fired them — an operator could
set a wipe schedule and it would never trigger (the headline auto-wipe feature
was inert; the manual trigger worked, the scheduler did not).
- WipesService now implements OnModuleInit/OnModuleDestroy with a 60s executor
(mirrors SchedulesService): bootstraps next_scheduled_run, then fires every
active schedule whose next_scheduled_run <= now via triggerWipe(...'scheduled')
-> instancesService.wipeForLicense -> the agent wipe handler, advancing
next_scheduled_run from the cron each cycle (advances even on failure so a
broken schedule can't re-fire every 60s).
- triggerWipe parameterized with triggerType ('manual' | 'scheduled') so
wipe_history records the real origin.
- Extracted nextCronDate into src/common/cron.util.ts (shared by the event and
wipe schedulers; was duplicated/private). Cron is evaluated UTC — the per-
schedule timezone column is still not honored, a known limitation shared by
both schedulers (follow-up: tz-aware cron lib).
Backend tsc green. Scheduling logic is at parity with the in-production event
scheduler; live end-to-end (a scheduled wipe deleting real files) verifies when
a game stack + agent are connected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
52 lines
1.7 KiB
TypeScript
52 lines
1.7 KiB
TypeScript
/**
|
|
* Minimal 5-field cron "next run" calculator, shared by the event scheduler
|
|
* (SchedulesService) and the wipe scheduler (WipesService).
|
|
*
|
|
* Supports `*` and exact numeric fields (minute hour day-of-month month
|
|
* day-of-week). Walks minute-by-minute up to 366 days ahead. Returns null on a
|
|
* malformed expression or if no match is found within a year.
|
|
*
|
|
* NOTE: the expression is evaluated in **UTC**. A per-schedule `timezone`
|
|
* column exists on both schedule tables but is NOT yet honored here — fixing it
|
|
* properly needs a timezone-aware cron library; tracked as a shared follow-up.
|
|
*/
|
|
export function nextCronDate(expr: string, after: Date): Date | null {
|
|
const parts = expr.trim().split(/\s+/);
|
|
if (parts.length !== 5) return null;
|
|
|
|
const [minuteExpr, hourExpr, domExpr, monthExpr, dowExpr] = parts;
|
|
|
|
const matches = (e: string, value: number): boolean => {
|
|
if (e === '*') return true;
|
|
return parseInt(e, 10) === value;
|
|
};
|
|
|
|
// Walk minute-by-minute up to 366 days forward to find the next match.
|
|
const candidate = new Date(after.getTime() + 60_000); // advance at least 1 minute
|
|
candidate.setSeconds(0, 0);
|
|
|
|
const limit = new Date(after.getTime() + 366 * 24 * 60 * 60 * 1000);
|
|
|
|
while (candidate < limit) {
|
|
const min = candidate.getUTCMinutes();
|
|
const hour = candidate.getUTCHours();
|
|
const dom = candidate.getUTCDate();
|
|
const month = candidate.getUTCMonth() + 1; // 1-12
|
|
const dow = candidate.getUTCDay(); // 0=Sun
|
|
|
|
if (
|
|
matches(minuteExpr, min) &&
|
|
matches(hourExpr, hour) &&
|
|
matches(domExpr, dom) &&
|
|
matches(monthExpr, month) &&
|
|
matches(dowExpr, dow)
|
|
) {
|
|
return candidate;
|
|
}
|
|
|
|
candidate.setTime(candidate.getTime() + 60_000);
|
|
}
|
|
|
|
return null;
|
|
}
|