PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.0
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 1.2.0 All 28 releases
xspeed / includes / class-optimize-runner.php

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

346 lines 11.4 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 ): void {
130 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 ),
143 $budget
144 );
145
146 // --- 5. Re-measure ----------------------------------------------
147 $after = self::measure();
148
149 // Re-read settings: what is still off AFTER this run is what an
150 // aggressive run could try next, and offering a step we just applied
151 // would be nonsense.
152 $diagnosis = Optimize_Diagnosis::build( self::current_settings() );
153
154 return array(
155 'before' => $before,
156 'applied' => $result['applied'],
157 'skipped' => array_merge( $plan['skipped'], $result['skipped'] ),
158 'reverted' => $result['reverted'],
159 'after' => $after,
160 'score' => $diagnosis['score'],
161 'next_steps' => $diagnosis['agent_fixable'],
162 'unfixable' => $diagnosis['human_fixable'],
163 'verified' => array() === $result['reverted'],
164 'message' => self::summary( $diagnosis, count( $result['applied'] ) ),
165 );
166 }
167
168 /**
169 * One sentence the assistant can lead with.
170 *
171 * Written so the honest outcomes read as outcomes rather than failures. A
172 * site where nothing was left to do is not a disappointing result, but
173 * "no changes" with no context reads like one — and an assistant given
174 * that alone will either apologise or invent a win.
175 *
176 * @param array<string,mixed> $diagnosis From Optimize_Diagnosis::build().
177 * @param int $applied How many changes landed.
178 */
179 private static function summary( array $diagnosis, int $applied ): string {
180 $score = $diagnosis['score']['score'] ?? null;
181 $next = count( $diagnosis['agent_fixable'] );
182 $human = count( $diagnosis['human_fixable'] );
183
184 $parts = array();
185
186 if ( $applied > 0 ) {
187 $parts[] = sprintf(
188 /* translators: %d: number of settings changed */
189 _n( 'Applied %d change.', 'Applied %d changes.', $applied, 'xspeed' ),
190 $applied
191 );
192 } else {
193 $parts[] = __( 'Everything that can be turned on safely is already on.', 'xspeed' );
194 }
195
196 if ( null !== $score ) {
197 $parts[] = sprintf(
198 /* translators: %d: last recorded performance score */
199 __( 'Last recorded score: %d.', 'xspeed' ),
200 (int) $score
201 );
202 }
203
204 if ( $next > 0 ) {
205 $parts[] = sprintf(
206 /* translators: %d: number of riskier settings available */
207 _n(
208 '%d further setting could help, but can break some sites — ask before enabling it.',
209 '%d further settings could help, but can break some sites — ask before enabling them.',
210 $next,
211 'xspeed'
212 ),
213 $next
214 );
215 }
216
217 if ( $human > 0 ) {
218 $parts[] = sprintf(
219 /* translators: %d: number of problems only the user can fix */
220 _n(
221 '%d problem is outside what caching can reach.',
222 '%d problems are outside what caching can reach.',
223 $human,
224 'xspeed'
225 ),
226 $human
227 );
228 }
229
230 return implode( ' ', $parts );
231 }
232
233 /**
234 * Current settings for every module the plan can touch.
235 *
236 * The `__global` bucket is not a module: it carries options that live
237 * outside the per-module schema, page caching being the one that matters
238 * here. Reading it the same shape as a module keeps Optimize_Plan free of
239 * special cases.
240 *
241 * @return array<string,array<string,mixed>>
242 */
243 private static function current_settings(): array {
244 $out = array();
245 foreach ( array( 'gzip', 'browser-cache', 'minify', 'lazy', 'bloat' ) as $slug ) {
246 $out[ $slug ] = Settings_Manager::get( $slug );
247 }
248
249 $global = Settings::get();
250 $out[ Optimize_Plan::MODULE_GLOBAL ] = array(
251 'cache_enabled' => (bool) ( $global['cache_enabled'] ?? false ),
252 );
253
254 return $out;
255 }
256
257 /**
258 * Write one step's values to wherever they actually live.
259 *
260 * Page caching is not a module setting — it installs the advanced-cache
261 * drop-in and sets WP_CACHE, then records a global flag. Routing it
262 * through Settings_Manager::update() writes a key no schema declares,
263 * which is dropped silently while the call still reports success (#206):
264 * the run then claims "page caching on" over a site that never enabled it.
265 * That exact false success showed up on the first live run of this
266 * feature, which is why the dispatch is explicit rather than uniform.
267 *
268 * @param string $module Module slug, or MODULE_GLOBAL.
269 * @param array<string,mixed> $values Values to write.
270 */
271 private static function write( string $module, array $values ): void {
272 if ( Optimize_Plan::MODULE_GLOBAL !== $module ) {
273 Settings_Manager::update( $module, $values );
274 return;
275 }
276
277 if ( array_key_exists( 'cache_enabled', $values ) ) {
278 $enabled = (bool) $values['cache_enabled'];
279 // Order matters: the drop-in + wp-config first, the flag second,
280 // so a failure to install never leaves the option claiming a
281 // cache that is not wired up.
282 Cache::toggle( $enabled );
283 Settings::update( array( 'cache_enabled' => $enabled ) );
284 }
285 }
286
287 /**
288 * A cached-vs-uncached benchmark, reduced to the numbers a report needs.
289 *
290 * Deliberately NOT a Lighthouse score: this runs on the site itself, and
291 * spending someone's PageSpeed quota twice per optimize run is not ours to
292 * do. The caller can run a speed test either side if it wants one.
293 *
294 * @return array<string,mixed>|null
295 */
296 private static function measure(): ?array {
297 if ( ! class_exists( '\XSpeed\Cache_Benchmark' ) ) {
298 return null;
299 }
300 $run = Cache_Benchmark::run();
301 if ( ! is_array( $run ) ) {
302 return null;
303 }
304 return array(
305 'savings_ms' => $run['savings_ms'] ?? null,
306 'savings_pct' => $run['savings_pct'] ?? null,
307 'cache_enabled' => $run['cache_enabled'] ?? null,
308 );
309 }
310
311 /**
312 * Problems this tool cannot solve, named plainly.
313 *
314 * Derived from the site's own health checks rather than invented here, so
315 * the list stays true as those checks improve. Anything a caching plugin
316 * genuinely cannot reach — page weight, hotlinked media, DOM size — belongs
317 * here rather than being silently omitted from a success report.
318 *
319 * @return array<int,array<string,string>>
320 */
321 private static function unfixable(): array {
322 $out = array();
323
324 if ( ! class_exists( '\XSpeed\Health' ) ) {
325 return $out;
326 }
327
328 foreach ( Health::checks() as $check ) {
329 if ( 'warn' !== ( $check['tone'] ?? '' ) && 'fail' !== ( $check['tone'] ?? '' ) ) {
330 continue;
331 }
332 // Environment facts the plugin reports but cannot change itself:
333 // a PHP version, a server config snippet the host must paste.
334 $id = (string) ( $check['id'] ?? '' );
335 if ( in_array( $id, array( 'php_version', 'server', 'static_rewrite_nginx' ), true ) ) {
336 $out[] = array(
337 'issue' => (string) ( $check['label'] ?? $id ),
338 'fix' => (string) ( $check['detail'] ?? '' ),
339 );
340 }
341 }
342
343 return $out;
344 }
345 }
346