feat(wipes): wire the auto-wiper — scheduled wipes now actually fire
Some checks failed
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Failing after 30s
CI / integration (push) Has been skipped

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>
This commit is contained in:
Vantz Stockwell
2026-06-12 01:50:49 -04:00
parent e23b6a7e69
commit 62bc9cd2a3
3 changed files with 144 additions and 46 deletions

View File

@@ -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;
}

View File

@@ -11,47 +11,7 @@ import { ScheduledTask } from '../../entities/scheduled-task.entity';
import { CreateTaskDto } from './dto/create-task.dto'; import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto'; import { UpdateTaskDto } from './dto/update-task.dto';
import { InstancesService } from '../instances/instances.service'; import { InstancesService } from '../instances/instances.service';
import { nextCronDate } from '../../common/cron.util';
/** 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;
}
@Injectable() @Injectable()
export class SchedulesService implements OnModuleInit, OnModuleDestroy { export class SchedulesService implements OnModuleInit, OnModuleDestroy {

View File

@@ -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 { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { IsNull, LessThanOrEqual, Repository } from 'typeorm';
import { WipeProfile } from '../../entities/wipe-profile.entity'; import { WipeProfile } from '../../entities/wipe-profile.entity';
import { WipeSchedule } from '../../entities/wipe-schedule.entity'; import { WipeSchedule } from '../../entities/wipe-schedule.entity';
import { WipeHistory } from '../../entities/wipe-history.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 { CreateScheduleDto } from './dto/create-schedule.dto';
import { TriggerWipeDto } from './dto/trigger-wipe.dto'; import { TriggerWipeDto } from './dto/trigger-wipe.dto';
import { InstancesService } from '../instances/instances.service'; import { InstancesService } from '../instances/instances.service';
import { nextCronDate } from '../../common/cron.util';
@Injectable() @Injectable()
export class WipesService { export class WipesService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(WipesService.name); private readonly logger = new Logger(WipesService.name);
private wipeExecutorInterval: ReturnType<typeof setInterval> | null = null;
constructor( constructor(
@InjectRepository(WipeProfile) @InjectRepository(WipeProfile)
@@ -24,6 +32,82 @@ export class WipesService {
private readonly instancesService: InstancesService, 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<void> {
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<void> {
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<WipeProfile[]> { async getProfiles(licenseId: string): Promise<WipeProfile[]> {
return this.wipeProfileRepo.find({ return this.wipeProfileRepo.find({
where: { license_id: licenseId }, where: { license_id: licenseId },
@@ -96,19 +180,22 @@ export class WipesService {
async triggerWipe( async triggerWipe(
licenseId: string, licenseId: string,
dto: TriggerWipeDto, dto: TriggerWipeDto,
triggerType: 'manual' | 'scheduled' = 'manual',
): Promise<{ wipe_history_id: string }> { ): Promise<{ wipe_history_id: string }> {
const history = this.wipeHistoryRepo.create({ const history = this.wipeHistoryRepo.create({
license_id: licenseId, license_id: licenseId,
wipe_type: dto.wipe_type, wipe_type: dto.wipe_type,
wipe_profile_id: dto.wipe_profile_id, wipe_profile_id: dto.wipe_profile_id,
trigger_type: 'manual', trigger_type: triggerType,
status: 'pending', status: 'pending',
}); });
const saved = await this.wipeHistoryRepo.save(history); const saved = await this.wipeHistoryRepo.save(history);
await this.instancesService.wipeForLicense(licenseId, dto.wipe_type, true); 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 }; return { wipe_history_id: saved.id };
} }