PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.4
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / class-optimize-runner.php

class-optimize-runner.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.2.4, at includes/class-optimize-runner.php

378 lines 13.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Optimize runner — the five phases, wired to the real site.
4 *
5 * @package XSpeed
6 */
7
8 namespace XSpeed;
9
10 defined( 'ABSPATH' ) || exit;
11
12 /**
13 * Measure, diagnose, apply, verify, re-measure — and report honestly.
14 *
15 * This is the thin layer that gives Optimize_Plan / Optimizer / Optimize_Verifier
16 * a real site to work on. Everything interesting lives in those three; what is
17 * here is the wiring and, more importantly, the shape of what comes back.
18 *
19 * ## Why `unfixable` exists
20 *
21 * The temptation with a tool like this is to return "done" and a green tick.
22 * That is false on a large class of sites, and the falseness is the expensive
23 * kind — the AI repeats it to the user, who believes their site is now fast.
24 *
25 * A real site tested during this feature's design carried a 97% hit ratio, a
26 * 124ms TTFB, and every optimization already enabled — and still scored 50,
27 * because of 15 images hotlinked from another domain, a 945KB video and 2,093
28 * DOM elements. There was nothing left for a caching plugin to do, and saying
29 * "optimized!" would have been a lie of omission.
30 *
31 * So the report names what it could not fix and why. A run that changes nothing
32 * is a legitimate outcome, reported as such.
33 *
34 * @since 1.2.0
35 */
36 final class Optimize_Runner {
37
38 /**
39 * Run the whole thing.
40 *
41 * @param array{aggressiveness?:string,dry_run?:bool,budget_seconds?:int,url?:string} $args Options.
42 * @return array<string,mixed>|\WP_Error
43 */
44 public static function run( array $args = array() ) {
45 $aggressiveness = (string) ( $args['aggressiveness'] ?? Optimize_Plan::TIER_STANDARD );
46 $dry_run = (bool) ( $args['dry_run'] ?? false );
47 $budget = (int) ( $args['budget_seconds'] ?? 120 );
48 $url = (string) ( $args['url'] ?? home_url( '/' ) );
49
50 if ( ! in_array( $aggressiveness, array( Optimize_Plan::TIER_SAFE, Optimize_Plan::TIER_STANDARD, Optimize_Plan::TIER_AGGRESSIVE ), true ) ) {
51 return new \WP_Error(
52 'xspeed_optimize_aggressiveness',
53 __( 'Aggressiveness must be safe, standard or aggressive.', 'xspeed' ),
54 array( 'status' => 400 )
55 );
56 }
57
58 // --- 1. Diagnose ------------------------------------------------
59 $current = self::current_settings();
60 $plan = Optimize_Plan::build( $current, $aggressiveness );
61
62 if ( $dry_run ) {
63 $preview = Optimize_Diagnosis::build( $current );
64 return array(
65 'dry_run' => true,
66 'message' => self::summary( $preview, 0 ),
67 'score' => $preview['score'],
68 'plan' => array_map(
69 static function ( $s ) {
70 return array(
71 'id' => $s['id'],
72 'change' => $s['label'],
73 'tier' => $s['tier'],
74 );
75 },
76 $plan['steps']
77 ),
78 'skipped' => $plan['skipped'],
79 'next_steps' => $preview['agent_fixable'],
80 'unfixable' => $preview['human_fixable'],
81 );
82 }
83
84 // Nothing to do is a real answer, and a common one on a site that has
85 // already been tuned. Returning early avoids spending two benchmarks
86 // to prove we changed nothing.
87 if ( array() === $plan['steps'] ) {
88 // The case that most needs a diagnosis attached. "Nothing to do"
89 // on a site scoring 50 is not an answer — it is the start of the
90 // conversation about what is actually wrong and who can fix it.
91 $diagnosis = Optimize_Diagnosis::build( $current );
92 return array(
93 'before' => null,
94 'applied' => array(),
95 'skipped' => $plan['skipped'],
96 'reverted' => array(),
97 'after' => null,
98 'score' => $diagnosis['score'],
99 'next_steps' => $diagnosis['agent_fixable'],
100 'unfixable' => $diagnosis['human_fixable'],
101 'verified' => true,
102 'message' => self::summary( $diagnosis, 0 ),
103 );
104 }
105
106 // --- 2. Measure -------------------------------------------------
107 $before = self::measure();
108 $baseline = Optimize_Verifier::sample( $url );
109 if ( is_wp_error( $baseline ) ) {
110 // No baseline means no way to tell a broken page from a working
111 // one. Refusing to start is the only safe option — running blind
112 // is exactly what this feature exists to stop.
113 return new \WP_Error(
114 'xspeed_optimize_no_baseline',
115 sprintf(
116 /* translators: %s: the underlying error */
117 __( 'Could not load the site to take a baseline, so no changes were made: %s', 'xspeed' ),
118 $baseline->get_error_message()
119 ),
120 array( 'status' => 502 )
121 );
122 }
123
124 // --- 3-4. Apply + verify ---------------------------------------
125 $result = Optimizer::run(
126 $plan['steps'],
127 $baseline,
128 array(
129 'apply' => static function ( array $step ): ?string {
130 return self::write( (string) $step['module'], (array) $step['values'] );
131 },
132 'revert' => static function ( array $step, array $previous ): void {
133 self::write( (string) $step['module'], $previous );
134 },
135 'purge' => static function (): void {
136 Cache::purge_all( 'optimize run' );
137 },
138 'sample' => static function () use ( $url ) {
139 $s = Optimize_Verifier::sample( $url );
140 return is_wp_error( $s ) ? null : $s;
141 },
142 // The per-step timing probe: one uncached render, its
143 // wall-clock in ms. Uses the same fetch as the integrity
144 // sample so both measure the same thing; the Optimizer
145 // medians PERF_SAMPLES of these per state and reverts a
146 // step that regresses past the noise floor. (#310)
147 'time' => static function () use ( $url ) {
148 $s = Optimize_Verifier::sample( $url );
149 if ( is_wp_error( $s ) || ! isset( $s['elapsed_ms'] ) ) {
150 return null;
151 }
152 return (float) $s['elapsed_ms'];
153 },
154 ),
155 $budget
156 );
157
158 // --- 5. Re-measure ----------------------------------------------
159 $after = self::measure();
160
161 // Re-read settings: what is still off AFTER this run is what an
162 // aggressive run could try next, and offering a step we just applied
163 // would be nonsense.
164 $diagnosis = Optimize_Diagnosis::build( self::current_settings() );
165
166 return array(
167 'before' => $before,
168 'applied' => $result['applied'],
169 'skipped' => array_merge( $plan['skipped'], $result['skipped'] ),
170 'reverted' => $result['reverted'],
171 'after' => $after,
172 'score' => $diagnosis['score'],
173 'next_steps' => $diagnosis['agent_fixable'],
174 'unfixable' => $diagnosis['human_fixable'],
175 'verified' => array() === $result['reverted'],
176 'message' => self::summary( $diagnosis, count( $result['applied'] ) ),
177 );
178 }
179
180 /**
181 * One sentence the assistant can lead with.
182 *
183 * Written so the honest outcomes read as outcomes rather than failures. A
184 * site where nothing was left to do is not a disappointing result, but
185 * "no changes" with no context reads like one — and an assistant given
186 * that alone will either apologise or invent a win.
187 *
188 * @param array<string,mixed> $diagnosis From Optimize_Diagnosis::build().
189 * @param int $applied How many changes landed.
190 */
191 private static function summary( array $diagnosis, int $applied ): string {
192 $score = $diagnosis['score']['score'] ?? null;
193 $next = count( $diagnosis['agent_fixable'] );
194 $human = count( $diagnosis['human_fixable'] );
195
196 $parts = array();
197
198 if ( $applied > 0 ) {
199 $parts[] = sprintf(
200 /* translators: %d: number of settings changed */
201 _n( 'Applied %d change.', 'Applied %d changes.', $applied, 'xspeed' ),
202 $applied
203 );
204 } else {
205 $parts[] = __( 'Everything that can be turned on safely is already on.', 'xspeed' );
206 }
207
208 if ( null !== $score ) {
209 $parts[] = sprintf(
210 /* translators: %d: last recorded performance score */
211 __( 'Last recorded score: %d.', 'xspeed' ),
212 (int) $score
213 );
214 }
215
216 if ( $next > 0 ) {
217 $parts[] = sprintf(
218 /* translators: %d: number of riskier settings available */
219 _n(
220 '%d further setting could help, but can break some sites — ask before enabling it.',
221 '%d further settings could help, but can break some sites — ask before enabling them.',
222 $next,
223 'xspeed'
224 ),
225 $next
226 );
227 }
228
229 if ( $human > 0 ) {
230 $parts[] = sprintf(
231 /* translators: %d: number of problems only the user can fix */
232 _n(
233 '%d problem is outside what caching can reach.',
234 '%d problems are outside what caching can reach.',
235 $human,
236 'xspeed'
237 ),
238 $human
239 );
240 }
241
242 return implode( ' ', $parts );
243 }
244
245 /**
246 * Current settings for every module the plan can touch.
247 *
248 * The `__global` bucket is not a module: it carries options that live
249 * outside the per-module schema, page caching being the one that matters
250 * here. Reading it the same shape as a module keeps Optimize_Plan free of
251 * special cases.
252 *
253 * @return array<string,array<string,mixed>>
254 */
255 private static function current_settings(): array {
256 $out = array();
257 foreach ( array( 'gzip', 'browser-cache', 'minify', 'lazy', 'bloat' ) as $slug ) {
258 $out[ $slug ] = Settings_Manager::get( $slug );
259 }
260
261 $global = Settings::get();
262 $out[ Optimize_Plan::MODULE_GLOBAL ] = array(
263 'cache_enabled' => (bool) ( $global['cache_enabled'] ?? false ),
264 );
265
266 return $out;
267 }
268
269 /**
270 * Write one step's values to wherever they actually live.
271 *
272 * Page caching is not a module setting — it installs the advanced-cache
273 * drop-in and sets WP_CACHE, then records a global flag. Routing it
274 * through Settings_Manager::update() writes a key no schema declares,
275 * which is dropped silently while the call still reports success (#206):
276 * the run then claims "page caching on" over a site that never enabled it.
277 * That exact false success showed up on the first live run of this
278 * feature, which is why the dispatch is explicit rather than uniform.
279 *
280 * @param string $module Module slug, or MODULE_GLOBAL.
281 * @param array<string,mixed> $values Values to write.
282 * @return string|null Reason the write was refused, or null when it landed.
283 */
284 private static function write( string $module, array $values ): ?string {
285 if ( Optimize_Plan::MODULE_GLOBAL !== $module ) {
286 Settings_Manager::update( $module, $values );
287 return null;
288 }
289
290 if ( array_key_exists( 'cache_enabled', $values ) ) {
291 $enabled = (bool) $values['cache_enabled'];
292 // Order matters: the drop-in + wp-config first, the flag second,
293 // so a failure to install never leaves the option claiming a
294 // cache that is not wired up. Persist what toggle() achieved, not
295 // what was asked for — it refuses when another plugin owns the
296 // drop-in, and the flag must follow the refusal.
297 $state = Cache::toggle( $enabled );
298 // And REPORT the refusal. Swallowing it here is what let the run
299 // return "Turn on page caching · verified" for a site where the
300 // drop-in belonged to another plugin and nothing had been
301 // changed. The Optimizer turns a returned reason into a skipped
302 // step.
303 //
304 // `blocked` alone is the test. It used to also require the
305 // operational state to differ from what was asked, and `enabled`
306 // answers "is the cache serving", not "did the write land" — so a
307 // refused step whose outcome happened to match was recorded as
308 // verified with nothing persisted behind it.
309 if ( ! empty( $state['blocked'] ) ) {
310 return is_string( $state['blocked_reason'] ) && '' !== $state['blocked_reason']
311 ? $state['blocked_reason']
312 : __( 'xSpeed would not change the page cache on this site.', 'xspeed' );
313 }
314 }
315
316 return null;
317 }
318
319 /**
320 * A cached-vs-uncached benchmark, reduced to the numbers a report needs.
321 *
322 * Deliberately NOT a Lighthouse score: this runs on the site itself, and
323 * spending someone's PageSpeed quota twice per optimize run is not ours to
324 * do. The caller can run a speed test either side if it wants one.
325 *
326 * @return array<string,mixed>|null
327 */
328 private static function measure(): ?array {
329 if ( ! class_exists( '\XSpeed\Cache_Benchmark' ) ) {
330 return null;
331 }
332 $run = Cache_Benchmark::run();
333 if ( ! is_array( $run ) ) {
334 return null;
335 }
336 return array(
337 'savings_ms' => $run['savings_ms'] ?? null,
338 'savings_pct' => $run['savings_pct'] ?? null,
339 'cache_enabled' => $run['cache_enabled'] ?? null,
340 );
341 }
342
343 /**
344 * Problems this tool cannot solve, named plainly.
345 *
346 * Derived from the site's own health checks rather than invented here, so
347 * the list stays true as those checks improve. Anything a caching plugin
348 * genuinely cannot reach — page weight, hotlinked media, DOM size — belongs
349 * here rather than being silently omitted from a success report.
350 *
351 * @return array<int,array<string,string>>
352 */
353 private static function unfixable(): array {
354 $out = array();
355
356 if ( ! class_exists( '\XSpeed\Health' ) ) {
357 return $out;
358 }
359
360 foreach ( Health::checks() as $check ) {
361 if ( 'warn' !== ( $check['tone'] ?? '' ) && 'fail' !== ( $check['tone'] ?? '' ) ) {
362 continue;
363 }
364 // Environment facts the plugin reports but cannot change itself:
365 // a PHP version, a server config snippet the host must paste.
366 $id = (string) ( $check['id'] ?? '' );
367 if ( in_array( $id, array( 'php_version', 'server', 'static_rewrite_nginx' ), true ) ) {
368 $out[] = array(
369 'issue' => (string) ( $check['label'] ?? $id ),
370 'fix' => (string) ( $check['detail'] ?? '' ),
371 );
372 }
373 }
374
375 return $out;
376 }
377 }
378