settings()->events_enabled() ?? $this->credentials()->is_paired(); if ( $events ) { $sampler = new RequestSampler( $this->recorder(), $this->settings() ); $sampler->register(); ( new WooHooks( $this->recorder() ) )->register(); // Funnel/form observation (ADR-019): counts only; page-view steps share the sampler's decision. ( new FunnelHooks( $this->recorder(), static fn(): ?float => $sampler->sampled_rate() ) )->register(); ( new FormHooks( $this->recorder() ) )->register(); // Outbound HTTP by host (ADR-042): two hook registrations on every request, work only when a call is made. ( new HttpHooks( $this->recorder(), $this->settings() ) )->register(); // Mail handed to the mailer (ADR-058): three hook registrations, work only when a mail is sent. ( new MailHooks( $this->recorder() ) )->register(); ( new SubscriptionHooks( $this->recorder() ) )->register(); // First attempt or retry on every payment outcome (ADR-049): one hook registration, a flag in memory. PaymentAttempt::register(); // Why Stripe refused a payment (ADR-087): one hook registration, Stripe's codes in memory until the failure is recorded. PaymentReason::register(); ( new ChangeHooks( $this->recorder() ) )->register(); // Site history (ADR-076): named settings, money-path products and pages, roles — with who made the change. ( new HistoryHooks( $this->recorder() ) )->register(); ( new ErrorHooks( $this->recorder() ) )->register(); // The hooks a site's own code calls (ADR-046): six actions, nothing happens until one is called. ( new PublicHooks( $this->recorder() ) )->register(); } // Cron-driven work. ( new Scheduler( $this->settings() ) )->register(); add_action( Scheduler::HOOK_COLLECT, array( $this, 'run_collect' ) ); add_action( Scheduler::HOOK_FLUSH, array( $this, 'run_flush' ) ); add_action( Scheduler::HOOK_POLL, array( $this, 'run_poll' ) ); add_action( Scheduler::HOOK_DISCOVER, array( $this, 'run_discover' ) ); // Uptime probe target (ADR-012): hook registration only; the handler answers 404 unless paired. ( new ProbeEndpoint( $this->credentials() ) )->register(); // Inbound read API is registered only when the customer enabled direct inbound mode (T13). if ( $this->settings()->connection_mode() === Settings::MODE_DIRECT_INBOUND && $this->credentials()->is_paired() ) { ( new InboundController( $this->credentials(), $this->settings(), new Executor( $this->recorder(), $this->settings() ), $this->recorder() ) )->register(); } // Plugin updates (ADR-036). Registered only where WordPress asks about updates — never a visitor request — // and only when paired, because the endpoint is signed and an unpaired site has nothing to sign with. The // wordpress.org build has no `src/Update` at all (ADR-072 §8 R2), and then there is nothing to register. if ( ( is_admin() || wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI ) ) && $this->credentials()->is_paired() ) { $this->updater()?->register(); } if ( is_admin() ) { ( new SettingsPage( $this ) )->register(); ( new AdminNotices( $this ) )->register(); // Credentials wrapped with a salt that has since changed (CredentialStore): said once, where an // administrator is, and in the internal-error log the heartbeat would carry if it could still be sent. // Never on a visitor request, which must not write (ADR-003). if ( $this->credentials()->salt_rotated() && (string) $this->settings()->get( 'last_error', '' ) !== CredentialStore::SALT_ROTATED_MESSAGE ) { $this->settings()->record_internal_error( CredentialStore::SALT_ROTATED_MESSAGE ); } } if ( defined( 'WP_CLI' ) && WP_CLI ) { Commands::register( $this ); } } public function on_activate(): void { self::force_autoload( Settings::OPTION ); self::force_autoload( StorageLocator::OPTION ); ( new Scheduler( $this->settings() ) )->schedule_all(); if ( $this->credentials()->is_paired() ) { wp_schedule_single_event( time() + 30, Scheduler::HOOK_DISCOVER, array( 'activation' ) ); } } /** * Turns autoloading on for an option written before ADR-031, so an existing install does not keep paying two * `SELECT`s per front-end request until something happens to rewrite it. * * Both options self-heal without this — `Settings::set_many()` and `StorageLocator::spool()` now write with the * new flag, and the collect hook touches the settings within one interval — but the spool location is rewritten * only when its daily revalidation falls due, which is up to 24 hours of paying for nothing. Activation is the * one moment where fixing it is free. * * `update_option()` returns early when the value has not changed and never reaches the autoload column, so the * value cannot simply be rewritten. `wp_set_option_autoload()` is the right call and exists on WordPress 6.6+; * below that the option is deleted and re-added with the flag. A crash between the two would lose settings that * are re-created from `Settings::defaults()` on the next read, and a spool location the locator re-decides — * which is why this is acceptable here and would not be for the credentials option. */ private static function force_autoload( string $option ): void { if ( function_exists( 'wp_set_option_autoload' ) ) { wp_set_option_autoload( $option, true ); return; } $value = get_option( $option, null ); if ( $value === null ) { return; } delete_option( $option ); add_option( $option, $value, '', true ); } public function on_deactivate(): void { ( new Scheduler( $this->settings() ) )->unschedule_all(); } /** Forgets the credentials and stops recording at once, rather than at the next collect. */ public function unpair(): void { $this->credentials()->clear(); $this->settings()->set( 'events_enabled', false ); } /* ---------------------------------------------------------------------------------------- * Cron entry points. Each is wrapped so that a failure is logged locally and never propagates. * -------------------------------------------------------------------------------------- */ public function run_collect(): void { $this->guard( function (): void { // Recorded before anything else: "last collect ran at" is the local proof that cron fires at all. $this->settings()->set( 'last_collect_at', time() ); $paired = $this->credentials()->is_paired(); // Keep the request path's flag in step with the credentials, here on cron where reading the // credentials costs nothing that matters. if ( $this->settings()->events_enabled() !== $paired ) { $this->settings()->set( 'events_enabled', $paired ); } if ( ! $paired ) { return; } // Same check as `wp cron test`, throttled; the result rides on the heartbeat below. ( new CronProbe( $this->settings() ) )->check_if_due( time() ); // The update manifest, at most once per `Updater::CHECK_TTL`, here on cron rather than on an admin // page load (ADR-036; invariant 2 — `admin-ajax.php` is `is_admin()` and serves the storefront). $this->updater()?->refresh_if_due(); $as = ActionSchedulerCollector::summary(); // A burst of refused inbound requests that has stopped is reported as one event (T13). InboundController::flush_denied( $this->recorder() ); ( new ActionSchedulerCollector( $this->recorder() ) )->collect( $as ); // Orders that were paid and never moved (ADR-041). Same shape as the queue summary: a few indexed // aggregate queries on cron, never in a visitor's request. ( new OrdersCollector( $this->recorder() ) )->collect(); // Host resources from inside PHP (ADR-047): /proc, cgroups, disk, two SHOW GLOBAL statements, opcache // and the error-log tail, each with a scope, under a time budget. Behind both halves of the switch. $this->run_host_collector(); // The counts behind the heartbeat's fixed checks (ADR-072 §2), so they are there whatever else is sent, // and whether the store can still take an order, judged against when it was last seen able to (ADR-083). // The store's webhooks, and which of them WooCommerce switched off for failing (ADR-086). $store = StoreState::compute( (array) $this->settings()->get( 'store_state_seen', array() ) ); $disabled = WebhookState::disabled(); $webhooks = WebhookState::compute( $disabled ); if ( $webhooks['disabled'] !== $disabled ) { update_option( WebhookState::OPTION_DISABLED, $webhooks['disabled'], false ); } $this->settings()->set_many( array( 'store_state_seen' => $store['seen'], 'watch_summary' => WatchSummary::compute( $as ) + array( 'store' => $store['store'], 'webhooks' => $webhooks['webhooks'] ) ) ); // ADR-018 (milestone 4): on a Mode C site whose daemon is demonstrably on duty, the daemon sends the // heartbeat. The rest of this pass is local work and always runs; the moment the daemon stops // refreshing its record, or the site leaves persistent_stream mode, cron sends it again. if ( ! OutboundWorker::cron_should_heartbeat( $this->settings() ) ) { return; } $hb = ( new HeartbeatCollector( $this->client(), $this->settings(), $this->spool(), $this->recorder() ) )->send(); if ( $hb !== null ) { $this->settings()->apply_remote_config( $hb['config'] ?? array() ); if ( ! empty( $hb['discovery_requested'] ) ) { wp_schedule_single_event( time() + 5, Scheduler::HOOK_DISCOVER, array( 'scheduled' ) ); } if ( ! empty( $hb['pending_jobs'] ) && $this->settings()->connection_mode() !== Settings::MODE_DIRECT_INBOUND ) { $this->run_poll(); } } } ); } /** The host collector, with the spool directory passed when the spool has one (so its filesystem can be read too). */ public function run_host_collector(): void { $this->guard( function (): void { $spool = $this->spool(); $dir = method_exists( $spool, 'dir' ) ? $spool->dir() : null; ( new HostCollector( $this->recorder(), $this->settings(), array( 'spool_dir' => is_string( $dir ) ? $dir : null ) ) )->collect(); } ); } public function run_flush(): void { $this->guard( function (): void { if ( ! $this->credentials()->is_paired() ) { return; } // Same rule as the heartbeat: cron stands down only while a Mode C daemon is demonstrably flushing. if ( ! OutboundWorker::cron_should_flush( $this->settings() ) ) { return; } ( new Flusher( $this->spool(), $this->client(), $this->settings(), 'cron' ) )->flush(); } ); } public function run_poll(): void { $this->guard( function (): void { if ( ! $this->credentials()->is_paired() || $this->settings()->connection_mode() === Settings::MODE_DIRECT_INBOUND ) { return; } ( new Poller( $this->client(), new Executor( $this->recorder(), $this->settings() ) ) )->poll(); } ); } /** * @param string $trigger Discovery trigger label. */ public function run_discover( string $trigger = 'scheduled' ): void { $this->guard( function () use ( $trigger ): void { if ( ! $this->credentials()->is_paired() ) { return; } $snapshot = ( new Discoverer( $this->settings() ) )->discover( $trigger ); $this->client()->post_json( '/v1/agent/discovery', $snapshot ); $this->settings()->set_last_discovery_at( time() ); } ); } /** * Runs $fn, swallowing and locally logging any throwable (fail-open invariant). * * @param callable(): void $fn Callback. */ public function guard( callable $fn ): void { try { $fn(); } catch ( \Throwable $e ) { $this->settings()->record_internal_error( $e->getMessage() ); } } /* ------------------------------------------------ services ------------------------------------------------ */ public function settings(): Settings { return $this->settings ??= new Settings(); } public function credentials(): CredentialStore { return $this->credentials ??= new CredentialStore(); } public function spool(): SpoolInterface { return $this->spool ??= ( new StorageLocator( $this->settings() ) )->spool(); } public function recorder(): Recorder { // Nothing is written unless the site is paired (ADR-072 §8 Q1). The collect pass keeps `events_enabled` in // step; until it has run once, an unknown flag records (the old behaviour). return $this->recorder ??= new Recorder( $this->spool(), fn(): bool => $this->settings()->events_enabled() !== false ); } public function client(): Client { return $this->client ??= new Client( $this->credentials(), $this->settings() ); } /** * The update channel (ADR-036); the settings page's "Check for updates" uses it outside `register()`. * * **Null in the wordpress.org build.** `scripts/package-agent.sh --channel directory` leaves `src/Update` out, * because the directory refuses a plugin that updates itself from elsewhere (guideline 8). The same source tree * then runs without it: the autoloader finds no file, `class_exists()` is false, and every caller does without. * The builds our API serves keep it until the wordpress.org listing takes over (docs/deploy/wordpress-org-handover.md). */ public function updater(): ?Updater { if ( $this->updater === null && ! self::has_updater() ) { return null; } return $this->updater ??= new Updater( $this->client(), $this->settings(), $this->credentials() ); } /** Whether this build carries the updater (the API-served build) or not (the wordpress.org build). */ public static function has_updater(): bool { return class_exists( Updater::class ); } }