From 62bc9cd2a36c4cd22c7deb098a78b8027759a8ab Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Fri, 12 Jun 2026 01:50:49 -0400 Subject: [PATCH] =?UTF-8?q?feat(wipes):=20wire=20the=20auto-wiper=20?= =?UTF-8?q?=E2=80=94=20scheduled=20wipes=20now=20actually=20fire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend-nest/src/common/cron.util.ts | 51 ++++++++++ .../modules/schedules/schedules.service.ts | 42 +------- .../src/modules/wipes/wipes.service.ts | 97 ++++++++++++++++++- 3 files changed, 144 insertions(+), 46 deletions(-) create mode 100644 backend-nest/src/common/cron.util.ts diff --git a/backend-nest/src/common/cron.util.ts b/backend-nest/src/common/cron.util.ts new file mode 100644 index 0000000..13e12bc --- /dev/null +++ b/backend-nest/src/common/cron.util.ts @@ -0,0 +1,51 @@ +/** + * 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; +} diff --git a/backend-nest/src/modules/schedules/schedules.service.ts b/backend-nest/src/modules/schedules/schedules.service.ts index 24e4942..cfae98b 100644 --- a/backend-nest/src/modules/schedules/schedules.service.ts +++ b/backend-nest/src/modules/schedules/schedules.service.ts @@ -11,47 +11,7 @@ import { ScheduledTask } from '../../entities/scheduled-task.entity'; import { CreateTaskDto } from './dto/create-task.dto'; import { UpdateTaskDto } from './dto/update-task.dto'; import { InstancesService } from '../instances/instances.service'; - -/** Parse a 5-field cron expression and return the next Date after `after`. */ -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; - - function matches(expr: string, value: number): boolean { - if (expr === '*') return true; - return parseInt(expr, 10) === value; - } - - // Walk minute-by-minute up to 366 days forward to find 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; -} +import { nextCronDate } from '../../common/cron.util'; @Injectable() export class SchedulesService implements OnModuleInit, OnModuleDestroy { diff --git a/backend-nest/src/modules/wipes/wipes.service.ts b/backend-nest/src/modules/wipes/wipes.service.ts index 73f5730..0ba0f11 100644 --- a/backend-nest/src/modules/wipes/wipes.service.ts +++ b/backend-nest/src/modules/wipes/wipes.service.ts @@ -1,6 +1,12 @@ -import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + Logger, + OnModuleInit, + OnModuleDestroy, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { IsNull, LessThanOrEqual, Repository } from 'typeorm'; import { WipeProfile } from '../../entities/wipe-profile.entity'; import { WipeSchedule } from '../../entities/wipe-schedule.entity'; import { WipeHistory } from '../../entities/wipe-history.entity'; @@ -9,10 +15,12 @@ import { UpdateProfileDto } from './dto/update-profile.dto'; import { CreateScheduleDto } from './dto/create-schedule.dto'; import { TriggerWipeDto } from './dto/trigger-wipe.dto'; import { InstancesService } from '../instances/instances.service'; +import { nextCronDate } from '../../common/cron.util'; @Injectable() -export class WipesService { +export class WipesService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(WipesService.name); + private wipeExecutorInterval: ReturnType | null = null; constructor( @InjectRepository(WipeProfile) @@ -24,6 +32,82 @@ export class WipesService { private readonly instancesService: InstancesService, ) {} + // --------------------------------------------------------------------------- + // Scheduled-wipe executor — the auto-wiper. Mirrors SchedulesService: a 60s + // poll fires every active wipe schedule whose next_scheduled_run is due, then + // advances it from its cron expression. Without this, wipe_schedules rows + // never fire (the headline auto-wipe feature was inert). + // --------------------------------------------------------------------------- + + onModuleInit(): void { + this.bootstrapWipeSchedules().catch((err) => + this.logger.error('Failed to bootstrap wipe-schedule next runs', err), + ); + this.wipeExecutorInterval = setInterval(() => { + this.executeDueWipes().catch((err) => + this.logger.error('Wipe-schedule executor error', err), + ); + }, 60_000); + this.logger.log('Wipe-schedule executor started (60s polling interval)'); + } + + onModuleDestroy(): void { + if (this.wipeExecutorInterval) { + clearInterval(this.wipeExecutorInterval); + this.wipeExecutorInterval = null; + } + } + + /** On startup, stamp next_scheduled_run on active schedules that lack one. */ + private async bootstrapWipeSchedules(): Promise { + const schedules = await this.wipeScheduleRepo.find({ + where: { is_active: true, next_scheduled_run: IsNull() }, + }); + for (const s of schedules) { + const next = nextCronDate(s.cron_expression, new Date()); + if (next) { + s.next_scheduled_run = next; + await this.wipeScheduleRepo.save(s); + } + } + if (schedules.length > 0) { + this.logger.log(`Bootstrapped next run for ${schedules.length} wipe schedule(s)`); + } + } + + /** Fire every active wipe schedule whose next_scheduled_run <= now. */ + private async executeDueWipes(): Promise { + const now = new Date(); + const due = await this.wipeScheduleRepo.find({ + where: { is_active: true, next_scheduled_run: LessThanOrEqual(now) }, + }); + if (due.length === 0) return; + + this.logger.log(`Executing ${due.length} due wipe schedule(s)`); + for (const s of due) { + try { + await this.triggerWipe( + s.license_id, + { + wipe_type: s.wipe_type as TriggerWipeDto['wipe_type'], + wipe_profile_id: s.wipe_profile_id, + }, + 'scheduled', + ); + } catch (err) { + this.logger.error( + `Scheduled wipe failed for schedule ${s.id} (${s.schedule_name})`, + (err as Error).stack, + ); + } finally { + // Advance next_scheduled_run regardless, so a failing schedule doesn't + // re-fire every 60s. + s.next_scheduled_run = nextCronDate(s.cron_expression, now); + await this.wipeScheduleRepo.save(s); + } + } + } + async getProfiles(licenseId: string): Promise { return this.wipeProfileRepo.find({ where: { license_id: licenseId }, @@ -96,19 +180,22 @@ export class WipesService { async triggerWipe( licenseId: string, dto: TriggerWipeDto, + triggerType: 'manual' | 'scheduled' = 'manual', ): Promise<{ wipe_history_id: string }> { const history = this.wipeHistoryRepo.create({ license_id: licenseId, wipe_type: dto.wipe_type, wipe_profile_id: dto.wipe_profile_id, - trigger_type: 'manual', + trigger_type: triggerType, status: 'pending', }); const saved = await this.wipeHistoryRepo.save(history); await this.instancesService.wipeForLicense(licenseId, dto.wipe_type, true); - this.logger.log(`Wipe triggered for license ${licenseId} — history id ${saved.id}`); + this.logger.log( + `Wipe ${triggerType === 'scheduled' ? 'scheduled' : 'triggered'} for license ${licenseId} — history id ${saved.id}`, + ); return { wipe_history_id: saved.id }; }