array( DAY_IN_SECONDS, 'daily' ), self::WEEKLY_HOOK => array( WEEK_IN_SECONDS, 'weekly' ), self::HOURLY_HOOK => array( HOUR_IN_SECONDS, 'hourly' ), ); foreach ( $hooks as $hook => [ $interval, $recurrence ] ) { if ( self::uses_action_scheduler() ) { if ( false === as_next_scheduled_action( $hook ) ) { as_schedule_recurring_action( time() + HOUR_IN_SECONDS, $interval, $hook, array(), 'aftercare' ); } } elseif ( ! wp_next_scheduled( $hook ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, $recurrence, $hook ); } } } public static function unschedule(): void { foreach ( array( self::DAILY_HOOK, self::WEEKLY_HOOK, self::HOURLY_HOOK ) as $hook ) { if ( self::uses_action_scheduler() ) { as_unschedule_all_actions( $hook, array(), 'aftercare' ); } wp_clear_scheduled_hook( $hook ); } self::unschedule_hook( LabClient::URL_HOOK ); self::unschedule_hook( LabScheduler::CHECK_HOOK ); self::unschedule_hook( \Aftercare\Errors\AvailabilityCheck::CONFIRM_HOOK ); } /** * Remove every pending one-off action for a hook, whatever its arguments. */ public static function unschedule_hook( string $hook ): void { if ( self::uses_action_scheduler() ) { as_unschedule_all_actions( $hook ); } wp_unschedule_hook( $hook ); } /** * Queue a one-off action, skipping it when the same action with the same * arguments is already pending. * * @param array $args */ public static function schedule_single( int $timestamp, string $hook, array $args = array() ): void { if ( self::uses_action_scheduler() ) { if ( false === as_next_scheduled_action( $hook, $args, 'aftercare' ) ) { as_schedule_single_action( $timestamp, $hook, $args, 'aftercare' ); } } elseif ( ! wp_next_scheduled( $hook, $args ) ) { wp_schedule_single_event( $timestamp, $hook, $args ); } } private static function is_scheduled(): bool { foreach ( array( self::DAILY_HOOK, self::WEEKLY_HOOK, self::HOURLY_HOOK ) as $hook ) { $next = self::uses_action_scheduler() ? false !== as_next_scheduled_action( $hook ) : (bool) wp_next_scheduled( $hook ); if ( ! $next ) { return false; } } return true; } private static function uses_action_scheduler(): bool { return function_exists( 'as_schedule_recurring_action' ) && function_exists( 'as_schedule_single_action' ) && function_exists( 'as_next_scheduled_action' ) && function_exists( 'as_unschedule_all_actions' ); } public function run_daily(): void { update_option( self::LAST_RUN_OPT, time(), false ); $samples = new SampleRepository(); // 1. Pull yesterday's CrUX p75 values for every tracked URL. ( new CruxClient( $samples ) )->pull_all(); // 2. Queue today's lab checks. Each URL runs in its own action, so this // returns at once and the results land over the next few minutes. ( new LabClient( $samples ) )->queue_daily(); // 3. Fold buffered RUM beacons into daily p75 samples. RumController::aggregate_buffer( $samples ); // 4. Compare against budgets and baseline; open/resolve incidents. ( new BreachDetector( $samples ) )->run(); // 5. Retention. $samples->prune( Options::vitals_retention_days() ); ( new \Aftercare\Errors\Repository() )->prune( Options::vitals_retention_days() ); $ledger_days = Options::ledger_retention_days(); if ( $ledger_days > 0 ) { ( new LedgerRepository() )->prune( $ledger_days ); } // 6. Keep the plugin version snapshot fresh for update attribution, // and refresh the cached status snapshot (admin bar, widget). Listeners::snapshot_plugin_versions(); \Aftercare\Vitals\Status::flush_cache(); // 7. Draft last month's client report on the 1st of each month, when // the report builder add-on is installed. if ( class_exists( ReportBuilder::class ) && '1' === gmdate( 'j' ) ) { $prev_month = (int) gmdate( 'n', strtotime( 'first day of last month' ) ); $prev_year = (int) gmdate( 'Y', strtotime( 'first day of last month' ) ); ( new ReportBuilder() )->generate_draft( $prev_month, $prev_year ); } } /** * Hourly: resolve error incidents whose error has gone quiet, and check * that tracked pages still load. */ public function run_hourly(): void { ( new \Aftercare\Errors\Incidents() )->resolve_quiet(); ( new \Aftercare\Errors\AvailabilityCheck() )->run(); } /** * Warn when WP-Cron looks unreliable (disabled constant, or the daily task * has not fired for over 48 hours). */ public function maybe_warn_cron_health(): void { if ( ! current_user_can( 'manage_options' ) || self::uses_action_scheduler() ) { return; } $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( ! $screen || ! str_contains( (string) $screen->id, 'aftercare' ) ) { return; } $last = (int) get_option( self::LAST_RUN_OPT, 0 ); $disabled = defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON && ! self::real_cron_hint(); $stale = $last > 0 && ( time() - $last ) > 2 * DAY_IN_SECONDS; if ( $disabled || $stale ) { echo '

' . esc_html__( 'Aftercare:', 'aftercare' ) . ' '; echo esc_html__( 'WP-Cron does not appear to be running reliably on this site, so daily vitals collection and breach detection may be delayed. Configure a real server cron job that calls wp-cron.php, or install a plugin that provides Action Scheduler.', 'aftercare' ); echo '

'; } } private static function real_cron_hint(): bool { // If the daily task ran within the last 36h, some external cron is doing its job. $last = (int) get_option( self::LAST_RUN_OPT, 0 ); return $last > 0 && ( time() - $last ) < 1.5 * DAY_IN_SECONDS; } }