settings = $settings; } /** * Execute a full run. * * @return array { * @type bool $ran Whether any checks were performed. * @type string $reason Why it didn't run (when $ran is false). * @type int $runId The run identifier (unix time of the run). * @type int $checks Number of (prompt × provider) checks stored. * @type string $trigger What started it: 'scheduled' or 'manual'. * } * * @param string $trigger What started this run — 'scheduled' (the cron * cadence) or 'manual' (the owner pressed Run check * now). It travels in the result and the completion * hook because the two are not the same event to * anyone listening: nobody is watching a scheduled * run, and somebody is always watching a manual one. */ public function run( $trigger = 'scheduled' ) { $reason = $this->blocking_reason(); if ( '' !== $reason ) { return array( 'ran' => false, 'reason' => $reason ); } // A run spends real money on every (prompt × provider) check, so two runs must // never overlap — a second "Run now", or a scheduled tick firing while a // previous run is still going, would double the bill. Take a durable, cross- // process lock (an option, so it holds across the separate cron/loopback // processes a run spans, even with no object cache) and bail if a run already // holds it. A crashed run's lock self-heals after LOCK_TTL, so monitoring can // never wedge permanently. if ( ! self::acquire_run_lock() ) { return array( 'ran' => false, 'reason' => 'already_running' ); } try { return $this->do_run( $trigger ); } finally { self::release_run_lock(); } } /** * The run body, executed under the run lock (see run()). Loops every active * product × prompt × provider, stores each result, and stops early if the per-run * check ceiling is reached. * * @param string $trigger 'scheduled' or 'manual' — see run(). * @return array */ private function do_run( $trigger ) { $targets = array_values( (array) $this->settings->get( 'targets', array() ) ); $providers = $this->settings->active_providers(); // A run makes many sequential HTTP calls; give it room rather than risking // a mid-run timeout on hosts that allow lifting the limit. if ( function_exists( 'set_time_limit' ) ) { @set_time_limit( 0 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, Squiz.PHP.DiscouragedFunctions.Discouraged -- a run makes many sequential HTTP calls; a mid-run timeout would lose the whole run. } $run_id = time(); $refused = array(); // Engines that refused the key this run: provider id => true. $checks = 0; $capped = false; $max_checks = $this->max_checks_per_run(); // Each product is checked on its own name, website and competitors, against // its own list of questions. Paused products are skipped. foreach ( $targets as $t ) { if ( ! ( $t['active'] ?? true ) ) { continue; } $name = (string) ( $t['name'] ?? '' ); $domain = (string) ( $t['domain'] ?? '' ); $competitors = (array) ( $t['competitors'] ?? array() ); $prompts = array_values( (array) ( $t['prompts'] ?? array() ) ); foreach ( $prompts as $prompt ) { foreach ( $providers as $pid => $cfg ) { $provider = Provider::make( $pid ); if ( ! $provider || isset( $refused[ $pid ] ) ) { continue; } // Smooth out bursts (helps the single-engine case most, where every // check hits the same provider back to back). Skip before the first. if ( $checks > 0 && self::PACE_MS > 0 ) { usleep( self::PACE_MS * 1000 ); } $result = $provider->query( $prompt, $cfg['key'], $cfg['model'], ! empty( $cfg['web_search'] ) ); $analysis = Analyzer::analyze( $result, $name, $domain, $competitors ); Store::insert( array( 'run_id' => $run_id, 'brand' => $name, 'provider' => $pid, 'model' => $cfg['model'], 'prompt' => $prompt, 'mentioned' => $analysis['mentioned'], 'cited' => $analysis['cited'], 'position' => $analysis['position'], 'competitors' => $analysis['competitors'], 'answer' => $result['text'], 'sources' => $result['citations'], 'error' => $result['error'], ) ); $checks++; // A refused key or a spent quota answers for the rest of the run: // the recorded error says why, and the other engines keep going. if ( Provider::refused( $result ) ) { $refused[ $pid ] = true; } // Keep our own lock fresh so a long run isn't judged stale (LOCK_TTL) // and stolen by a scheduled tick mid-flight — which would start a // second, money-spending run. We hold the lock; this only re-stamps it. if ( 0 === $checks % self::LOCK_HEARTBEAT_EVERY ) { update_option( self::LOCK_OPTION, time(), false ); } // Spend backstop: stop the whole run once the ceiling is hit, // rather than keep spending past it. if ( $checks >= $max_checks ) { $capped = true; break 3; } } } } Store::prune( (int) $this->settings->get( 'retention_days', 180 ) ); update_option( self::LAST_RUN_OPTION, $run_id, false ); $result = array( 'ran' => true, 'runId' => $run_id, 'checks' => $checks, 'capped' => $capped, 'trigger' => 'manual' === $trigger ? 'manual' : 'scheduled', ); /** * Fires when a citation monitoring run completed and its results are * stored. Integrations relay the summary counts from here. * * ⭐ `trigger` says whether anybody was watching. The built-in * integrations announce the scheduled runs only — see * {@see \Agentimus\Integrations\Events::on_citation_run_finished()} — * but the hook reports every run, so a listener that wants them all * still gets them. * * @param array $result { ran, runId, checks, capped, trigger }. */ do_action( 'agentimus_citation_run_finished', $result ); return $result; } /** * The hard ceiling on (prompt × provider) checks a single run performs — a spend * backstop above the structural caps (products × prompts × providers), in case a * filter injects extra targets or those caps change. Each check's cost is itself * bounded by the providers' max-output-token caps, so this bounds a run's total * spend; lower it via the filter to cap monitoring spend more tightly. * * @return int */ public function max_checks_per_run() { /** * Filter the per-run check ceiling. * * @param int $max Default ceiling (self::DEFAULT_MAX_CHECKS). */ $max = (int) apply_filters( 'agentimus_visibility_max_checks_per_run', self::DEFAULT_MAX_CHECKS ); return $max > 0 ? $max : self::DEFAULT_MAX_CHECKS; } /** * Acquire the run lock, or false if a run already holds it. Backed by an option so * it is durable across the separate cron/loopback processes a run spans (a * transient/object-cache lock would not survive them without a persistent cache). * A lock older than LOCK_TTL is stale — left by a crashed run — and is stolen, so * monitoring can never be wedged permanently. Public for the run-lock test. * * @return bool True if this caller acquired the lock and should run. */ public static function acquire_run_lock() { $held = (int) get_option( self::LOCK_OPTION, 0 ); if ( $held > 0 ) { if ( ( time() - $held ) < self::LOCK_TTL ) { return false; // A live run holds it. } // Stale — a crashed run. Steal it (best effort; the money-losing race is // the free-lock one below, which add_option makes atomic). update_option( self::LOCK_OPTION, time(), false ); return true; } // Free: acquire atomically. add_option is a single INSERT that fails when the // row already exists, so if two runs start at once (a second "Run now", or a // cron tick racing a manual run) exactly one wins — the loser does not run and // does not spend a second round of API calls. update_option's get-then-set // could let both through. return add_option( self::LOCK_OPTION, time(), '', 'no' ); } /** * Release the run lock. Call in a `finally` so a run that throws frees it at once. * * @return void */ public static function release_run_lock() { delete_option( self::LOCK_OPTION ); } /** * Why a run can't do anything yet, or '' when it can. A run needs at least one * active product with a question, and at least one usable engine. * * @return string '' | 'no_prompts' | 'no_providers' */ public function blocking_reason() { $has_prompts = false; foreach ( (array) $this->settings->get( 'targets', array() ) as $t ) { if ( ( $t['active'] ?? true ) && ! empty( $t['prompts'] ) ) { $has_prompts = true; break; } } if ( ! $has_prompts ) { return 'no_prompts'; } if ( empty( $this->settings->active_providers() ) ) { return 'no_providers'; } return ''; } /** * Verify a single provider key/model with one cheap round-trip. Uses the * passed-in credentials so a key can be tested before it is saved. * * @param string $id Provider id. * @param string $key API key to test. * @param string $model Model id. * @return array { ok: bool, error?: string, sample?: string } */ public function test( $id, $key, $model ) { $provider = Provider::make( $id ); if ( ! $provider ) { return array( 'ok' => false, 'error' => __( 'Unknown provider.', 'agentimus' ) ); } if ( '' === trim( (string) $key ) ) { return array( 'ok' => false, 'error' => __( 'No API key provided.', 'agentimus' ) ); } $result = $provider->query( __( 'Reply with the single word: OK', 'agentimus' ), $key, $model ); if ( '' !== $result['error'] ) { return array( 'ok' => false, 'error' => $result['error'] ); } return array( 'ok' => true, 'sample' => substr( (string) $result['text'], 0, 120 ) ); } }