Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.68% covered (success)
97.68%
758 / 776
78.57% covered (warning)
78.57%
33 / 42
CRAP
0.00% covered (danger)
0.00%
0 / 1
AI_Launchpad_REST
97.81% covered (success)
97.81%
758 / 775
78.57% covered (warning)
78.57%
33 / 42
219
0.00% covered (danger)
0.00%
0 / 1
 is_private_site
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 schedule_completion_pass
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 run_scheduled_completion_passes
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 register_routes
100.00% covered (success)
100.00%
167 / 167
100.00% covered (success)
100.00%
1 / 1
1
 sanitize_validation_errors
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 can_read
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 can_write
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 check_eligibility
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 get_data
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
14
 get_current_tasks
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
16
 backfill_to_minimum
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
9
 backfill_pool
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 maybe_mark_completed
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
6.01
 report_task_completions
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
10
 update_wizard
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 update_tailored
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
1 / 1
11
 log_tailoring
22.22% covered (danger)
22.22%
2 / 9
0.00% covered (danger)
0.00%
0 / 1
7.23
 tailoring_log_extra
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
4
 complete_task
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
4
 skip_task
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 dismiss
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 get_skipped_task_ids
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 apply_skipped_tasks
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 sanitize_subtitle
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 get_available_tasks
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 excluded_task_ids_for_goal
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 available_task_ids
96.97% covered (success)
96.97%
32 / 33
0.00% covered (danger)
0.00%
0 / 1
4
 adopt_builder_input
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 build_all_catalog_tasks
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
5
 build_tasks
95.45% covered (success)
95.45%
63 / 66
0.00% covered (danger)
0.00%
0 / 1
28
 get_cta_override
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 get_themes_showcase_path
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 ensure_theme_task
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 move_task_after
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 build_store_tasks
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 build_install_woocommerce_task
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
 build_setup_store_task
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 insert_before_launch_task
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
7.05
 get_in_progress_draft_url
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 get_task_title
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 get_output_schema
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * AI Launchpad REST endpoints.
4 *
5 * @package automattic/jetpack-mu-wpcom
6 * @since 6.11.0
7 */
8
9/**
10 * REST endpoints for the AI Launchpad wizard and AI output options.
11 */
12class AI_Launchpad_REST extends WP_REST_Controller {
13
14    const OPTION_WIZARD    = 'wpcom_ai_launchpad_wizard';
15    const OPTION_AI_OUTPUT = 'wpcom_ai_launchpad_ai_output';
16    const OPTION_DISMISSED = 'wpcom_ai_launchpad_dismissed';
17    const OPTION_SKIPPED   = 'wpcom_ai_launchpad_skipped_tasks';
18    // Latched "every task is done" flag, autoloaded so the menu gate reads it without rebuilding the task list.
19    // Set once; cleared only by an explicit reset (re-tailor, dismiss, reset).
20    const OPTION_COMPLETED = 'wpcom_ai_launchpad_completed';
21
22    // The goals the wizard offers.
23    const GOALS = array( 'write', 'build', 'sell', 'newsletter', 'educate', 'portfolio' );
24
25    /**
26     * Options whose writes can finish a task outside Site Setup: every listener records completion in the statuses
27     * option, and the launch task reads `launch-status` live.
28     */
29    const COMPLETION_TRIGGER_OPTIONS = array( 'launchpad_checklist_tasks_statuses', 'launch-status' );
30
31    /**
32     * Caps on the client-reported `validation_errors` that reach the `tailored` Logstash record. The client applies
33     * the same caps; these are the ones that hold for any caller.
34     */
35    const MAX_VALIDATION_ERRORS       = 4;
36    const MAX_VALIDATION_ERROR_LENGTH = 400;
37
38    /**
39     * Blogs with a completion pass queued for shutdown, as blog id => true.
40     *
41     * @var array<int, true>
42     */
43    private $pending_completion_blog_ids = array();
44
45    // `woo_launch_site`, `link_in_bio_launched`, and `videopress_launched` stay valid launch tasks so a stray AI
46    // emission passes PUT validation rather than failing the whole list into the deterministic fallback. It is
47    // normalized to `site_launched` as it is persisted (see update_tailored) and again on read (see build_tasks,
48    // which is what covers lists persisted before the write-side remap existed).
49    const LAUNCH_TASK_IDS = array( 'site_launched', 'blog_launched', 'woo_launch_site', 'link_in_bio_launched', 'videopress_launched' );
50
51    /**
52     * Tasks the AI Launchpad marks complete on CTA click, because their real signal is unreachable from wp-admin.
53     *
54     * Server-side allowlist so the complete-task route can only tick these ids. Mirrored client-side in model.ts.
55     *
56     * A registry task may be listed here, but only if its `is_complete` reads `launchpad_checklist_tasks_statuses`:
57     * that is the option complete_task() writes for it, and a definition computing completion from live site state
58     * would ignore the write and render as to-do straight after being ticked.
59     */
60    const COMPLETE_ON_CLICK_TASK_IDS = array(
61        'complete_profile',
62        'manage_subscribers',
63        'manage_paid_newsletter_plan',
64        'earn_money',
65        'start_building_your_audience',
66        'site_monitoring_page',
67        'setup_ssh',
68        'share_site',
69        'pick_fonts_colors',
70    );
71
72    /**
73     * Task ids the AI Launchpad synthesizes itself (never present in the AI payload): the sell goal's store-setup
74     * lead tasks. Skips must accept them alongside the AI-selected ids.
75     *
76     * Must list every id minted by build_store_tasks() â€” a synthetic task missing here renders with a
77     * Skip button whose write is rejected.
78     */
79    const SYNTHETIC_TASK_IDS = array(
80        'install_woocommerce',
81        'setup_woocommerce_store',
82    );
83
84    /**
85     * Tasks whose catalog visibility gate is a false negative in this read path, so the AI Launchpad overrides it.
86     *
87     * `add_10_email_subscribers` is gated off WordPress.com, but AI_Launchpad_Subscribers_Listener reads the count on
88     * Atomic, so the task must still render. `add_about_page` is gated on the `_wpcom_template_layout_category`
89     * page-meta key being registered, which does not happen during a REST request, so the task is wrongly hidden even
90     * though its "add a page" CTA works â€” force it visible so tailoring can offer this genuinely useful task.
91     */
92    const FORCE_VISIBLE_TASK_IDS = array(
93        'add_10_email_subscribers',
94        'add_about_page',
95    );
96
97    /**
98     * Commerce tasks whose catalog visibility gate requires WooCommerce to be active.
99     *
100     * On a fresh sell site these would be dropped, collapsing the list. Instead the sell branch keeps them as a
101     * disabled preview of the store roadmap until WooCommerce is active. See build_tasks()'s $disable_hidden_woo mode.
102     */
103    const WOO_TASK_IDS = array(
104        'woo_customize_store',
105        'woo_products',
106        'set_up_payments',
107    );
108
109    /**
110     * CTA destinations the AI Launchpad repoints to wp-admin, keyed by task id, each mapping to an `admin_url()` path.
111     *
112     * The catalog sends these to Calypso flows that are a poor fit for wp-admin. Overridden on read so the shared
113     * catalog (used by the legacy launchpad too) is left untouched.
114     */
115    const CTA_OVERRIDES = array(
116        'connect_social_media' => 'admin.php?page=jetpack-social',
117    );
118
119    /**
120     * Jetpack Social tasks, hidden on private sites where wpcom does not load Publicize (so their CTA page would 404).
121     * `drive_traffic` needs no entry: it remaps onto `connect_social_media` before this gate runs.
122     */
123    const SOCIAL_PAGE_TASK_IDS = array(
124        'connect_social_media',
125    );
126
127    /**
128     * Tasks the model may pick only when the site's goal is one of the listed goals.
129     *
130     * These were prose rules in the tailoring prompt ("Only include woo_* if the goal is sell OR the user
131     * explicitly mentions selling"). Prose is not enforcement: the model can ignore it, and when the
132     * available-tasks lookup fails the prompt falls back to the unfiltered menu. Enforced here instead, at
133     * both ends â€” the menu never offers them (available_task_ids) and PUT drops them (update_tailored).
134     *
135     * The free-text escape hatch is deliberately gone. The wizard goal is an explicit user choice, and the
136     * escape hatch was the non-determinism being removed.
137     *
138     * The two directions of disagreement with a task's `goals` annotation in js/lib/prompts.ts are not
139     * equivalent, and only one is acceptable:
140     *
141     * - Annotation BROADER than this map is fine. The annotation is soft affinity for the model, this is the
142     *   hard rule, and a task can be a tasteful fit for a goal it is not permitted on â€” the rule still blocks
143     *   it. The payment tasks are the live example: annotated for `newsletter` as well as `sell`, permitted
144     *   only on `sell`.
145     * - Annotation NARROWER than this map is a bug. It means a task the annotation itself calls goal-specific
146     *   can be selected and persisted on any goal, which is the inappropriate-task problem this whole change
147     *   exists to fix. `woo_tax`, `woo_marketing` and `woo_add_domain` were exactly that: annotated `sell`,
148     *   unrestricted here, and renderable on a blog â€” their catalog gate
149     *   (wpcom_launchpad_is_woocommerce_setup_visible) is goal-agnostic and passes on any WoA site with
150     *   WooCommerce active, which is every site this feature runs on.
151     *
152     * So: every id annotated with a single goal belongs here. No exceptions â€” `sensei_setup` is listed even
153     * though its catalog gate (WoA plus Sensei LMS active) already hides it almost everywhere, because a rule
154     * with one documented exception is a rule the next auditor has to re-derive. AI_Launchpad_Task_Menu_Test
155     * reads the annotations and fails if a single-goal one is missing here.
156     *
157     * The ids are matched after wpcom_ai_launchpad_remap_task_id(), so a twin of a restricted task is covered
158     * by the entry for the id it renders as and must not be listed separately.
159     */
160    const GOAL_RESTRICTED_TASK_IDS = array(
161        'woo_products'             => 'sell',
162        'woo_customize_store'      => 'sell',
163        'woo_woocommerce_payments' => 'sell',
164        'woo_tax'                  => 'sell',
165        'woo_marketing'            => 'sell',
166        'woo_add_domain'           => 'sell',
167        'set_up_payments'          => 'sell',
168        'stripe_connected'         => 'sell',
169        'add_10_email_subscribers' => 'newsletter',
170        'import_subscribers'       => 'newsletter',
171        'newsletter_plan_created'  => 'newsletter',
172        'sensei_setup'             => 'educate',
173    );
174
175    /**
176     * Tasks excluded for one specific goal and allowed on every other â€” the inverse of GOAL_RESTRICTED_TASK_IDS.
177     *
178     * Split into its own map rather than folded in behind a `!sell` marker, so neither map needs a value whose
179     * meaning flips on a prefix and each docblock describes all of its own entries.
180     *
181     * `add_gallery_page` is excluded for sell so a store site cannot end up with both the store sequence and a
182     * gallery. get_current_tasks() used to enforce that structurally, through the if/else that injected the
183     * gallery only on the non-sell branch; now that the model picks the gallery from the menu, this entry is
184     * the only thing holding it â€” the menu never offers it on sell, and PUT drops it if the model picks it anyway.
185     *
186     * Both ends are write-side: build_tasks() applies no exclusion, so a payload that already holds an excluded id
187     * renders it. Reaching that needs a compound failure (the availability lookup fails, so the prompt falls back
188     * to the full menu, AND the wizard-goal option has not landed yet, so the goal comes from the model's echo).
189     * True of every entry in both maps, not just this one; read-side enforcement is the fix if it ever bites.
190     */
191    const GOAL_EXCLUDED_TASK_IDS = array(
192        'add_gallery_page' => 'sell',
193    );
194
195    /**
196     * Tasks the AI builder already did, never offered on an AI-built site: its content, its design (store design
197     * included), and its name. AI_Launchpad_Task_Menu_Test checks the JS AI-built fallback lists against it.
198     */
199    const AI_BUILT_EXCLUDED_TASK_IDS = array(
200        'first_post_published',
201        'add_about_page',
202        'update_about_page',
203        'add_new_page',
204        'edit_page',
205        'add_contact_page',
206        'add_events_page',
207        'add_video_page',
208        'add_gallery_page',
209        'add_portfolio_piece',
210        'site_theme_selected',
211        'design_edited',
212        'front_page_updated',
213        'pick_fonts_colors',
214        'woo_customize_store',
215        'site_title',
216        'setup_general',
217    );
218
219    /**
220     * First-post tasks that can sit "in progress": the AI-created draft post exists but has not been published yet.
221     *
222     * Detected through the `_wpcom_ai_launchpad_first_post` marker meta (via AI_Launchpad_First_Post_Listener), so an
223     * unrelated pre-existing draft never counts. Paired with `add_about_page`, which has its own marker meta.
224     * `first_post_published_newsletter` needs no entry: it remaps onto `first_post_published` before this runs.
225     */
226    const IN_PROGRESS_FIRST_POST_TASK_IDS = array(
227        'first_post_published',
228    );
229
230    /**
231     * Whether the site's visibility is set to private (`blog_public = -1`).
232     *
233     * Read directly to avoid a hard dependency on the Status package in this read path.
234     *
235     * @return bool
236     */
237    private function is_private_site() {
238        return '-1' === (string) get_option( 'blog_public' );
239    }
240
241    /**
242     * Class constructor.
243     */
244    public function __construct() {
245        $this->namespace = 'wpcom/v2';
246        $this->rest_base = 'ai-launchpad';
247
248        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
249
250        foreach ( self::COMPLETION_TRIGGER_OPTIONS as $option ) {
251            add_action( "add_option_{$option}", array( $this, 'schedule_completion_pass' ) );
252            add_action( "update_option_{$option}", array( $this, 'schedule_completion_pass' ) );
253        }
254    }
255
256    /**
257     * Queues a completion pass for shutdown, so finishing the last task outside Site Setup (a launch, the editor)
258     * hides the menu on the next page load. Deferred so it sees the request's final state, not a write mid-way.
259     *
260     * @return void
261     */
262    public function schedule_completion_pass() {
263        // Some tasks judge completion by the current user, so cron or a visitor must not decide the owner's list.
264        if (
265            ! current_user_can( 'manage_options' )
266            || get_option( self::OPTION_COMPLETED )
267            || ! get_option( self::OPTION_AI_OUTPUT )
268            || true !== $this->check_eligibility()
269        ) {
270            return;
271        }
272
273        $this->pending_completion_blog_ids[ get_current_blog_id() ] = true;
274        add_action( 'shutdown', array( $this, 'run_scheduled_completion_passes' ) );
275    }
276
277    /**
278     * Runs the queued completion passes, each in the blog that queued it: on WordPress.com Simple the write can
279     * happen inside a switch_to_blog() that is restored by shutdown.
280     *
281     * @return void
282     */
283    public function run_scheduled_completion_passes() {
284        foreach ( array_keys( $this->pending_completion_blog_ids ) as $blog_id ) {
285            $switched = is_multisite() && get_current_blog_id() !== $blog_id && switch_to_blog( $blog_id );
286            $this->maybe_mark_completed();
287            if ( $switched ) {
288                restore_current_blog();
289            }
290        }
291        $this->pending_completion_blog_ids = array();
292    }
293
294    /**
295     * Register our routes.
296     */
297    public function register_routes() {
298        register_rest_route(
299            $this->namespace,
300            $this->rest_base,
301            array(
302                array(
303                    'methods'             => WP_REST_Server::READABLE,
304                    'callback'            => array( $this, 'get_data' ),
305                    'permission_callback' => array( $this, 'can_read' ),
306                    'args'                => array(
307                        // Testing aid: render the full task catalog so every task can be exercised from one site.
308                        'all_tasks' => array(
309                            'description' => 'Return the full task catalog instead of the tailored list (testing aid).',
310                            'type'        => 'boolean',
311                            'default'     => false,
312                        ),
313                    ),
314                ),
315                array(
316                    'methods'             => WP_REST_Server::DELETABLE,
317                    'callback'            => array( $this, 'dismiss' ),
318                    'permission_callback' => array( $this, 'can_write' ),
319                ),
320            )
321        );
322
323        register_rest_route(
324            $this->namespace,
325            $this->rest_base . '/wizard',
326            array(
327                array(
328                    'methods'             => 'PUT',
329                    'callback'            => array( $this, 'update_wizard' ),
330                    'permission_callback' => array( $this, 'can_write' ),
331                    'args'                => array(
332                        'goal'        => array(
333                            'description' => 'The site goal picked in the wizard.',
334                            'type'        => 'string',
335                            'enum'        => self::GOALS,
336                            'required'    => true,
337                        ),
338                        'site_name'   => array(
339                            'description'       => 'The site name entered in the wizard.',
340                            'type'              => 'string',
341                            'required'          => true,
342                            'sanitize_callback' => 'sanitize_text_field',
343                        ),
344                        'description' => array(
345                            'description'       => 'The free-text site description entered in the wizard.',
346                            'type'              => 'string',
347                            'required'          => true,
348                            'sanitize_callback' => 'sanitize_textarea_field',
349                        ),
350                        'locale'      => array(
351                            'description'       => 'The site language the AI writes the drafts and page intros in.',
352                            'type'              => 'string',
353                            'default'           => 'en',
354                            'sanitize_callback' => 'sanitize_text_field',
355                        ),
356                        'ui_locale'   => array(
357                            'description'       => 'The account language the AI writes the task subtitles in.',
358                            'type'              => 'string',
359                            'default'           => 'en',
360                            'sanitize_callback' => 'sanitize_text_field',
361                        ),
362                    ),
363                ),
364            )
365        );
366
367        register_rest_route(
368            $this->namespace,
369            $this->rest_base . '/available-tasks',
370            array(
371                array(
372                    'methods'             => WP_REST_Server::READABLE,
373                    'callback'            => array( $this, 'get_available_tasks' ),
374                    'permission_callback' => array( $this, 'can_read' ),
375                    'args'                => array(
376                        'goal' => array(
377                            'description'       => 'The selected goal; sell keeps commerce tasks as available previews.',
378                            'type'              => 'string',
379                            'default'           => '',
380                            'sanitize_callback' => 'sanitize_key',
381                        ),
382                    ),
383                ),
384            )
385        );
386
387        register_rest_route(
388            $this->namespace,
389            $this->rest_base . '/complete-task',
390            array(
391                array(
392                    'methods'             => 'POST',
393                    'callback'            => array( $this, 'complete_task' ),
394                    'permission_callback' => array( $this, 'can_write' ),
395                    'args'                => array(
396                        'task_id' => array(
397                            'description'       => 'The acknowledgment task to mark complete.',
398                            'type'              => 'string',
399                            'required'          => true,
400                            'sanitize_callback' => 'sanitize_key',
401                        ),
402                    ),
403                ),
404            )
405        );
406
407        register_rest_route(
408            $this->namespace,
409            $this->rest_base . '/skip-task',
410            array(
411                array(
412                    'methods'             => 'POST',
413                    'callback'            => array( $this, 'skip_task' ),
414                    'permission_callback' => array( $this, 'can_write' ),
415                    'args'                => array(
416                        'task_id' => array(
417                            'description'       => 'The task to skip.',
418                            'type'              => 'string',
419                            'required'          => true,
420                            'sanitize_callback' => 'sanitize_key',
421                        ),
422                    ),
423                ),
424            )
425        );
426
427        register_rest_route(
428            $this->namespace,
429            $this->rest_base . '/tailored',
430            array(
431                array(
432                    'methods'             => 'PUT',
433                    'callback'            => array( $this, 'update_tailored' ),
434                    'permission_callback' => array( $this, 'can_write' ),
435                    'args'                => array(
436                        'source'            => array(
437                            'description' => 'Whether the payload came from the AI or the deterministic fallback. Query parameter; the JSON body must match the agent output schema exactly.',
438                            'type'        => 'string',
439                            'enum'        => array( 'ai', 'fallback' ),
440                            'default'     => 'ai',
441                        ),
442                        'duration_ms'       => array(
443                            'description' => 'Client-measured tailoring duration in milliseconds, for the tailored Logstash record.',
444                            'type'        => 'integer',
445                            'minimum'     => 0,
446                        ),
447                        'attempts'          => array(
448                            'description' => 'How many jetpack-ai-query attempts the client made, for the tailored Logstash record.',
449                            'type'        => 'integer',
450                            'minimum'     => 0,
451                        ),
452                        'ai_session_id'     => array(
453                            'description'       => 'Client-minted id for this tailoring run, carried by every Tracks event fired afterwards.',
454                            'type'              => 'string',
455                            'default'           => '',
456                            // A UUID is 36 characters; 64 leaves headroom without letting an
457                            // oversized value reach the option, the inline script, and every
458                            // Tracks event. sanitize_key() doesn't bound length on its own, so
459                            // the validate_callback is what actually enforces maxLength here â€”
460                            // WP only runs per-arg schema validation when one is wired in.
461                            'maxLength'         => 64,
462                            'sanitize_callback' => 'sanitize_key',
463                            'validate_callback' => 'rest_validate_request_arg',
464                        ),
465                        'validation_errors' => array(
466                            'description'       => 'Why each failed jetpack-ai-query attempt failed, one entry per attempt (schema paths and rule names, never values), for the tailored Logstash record.',
467                            'type'              => 'array',
468                            'items'             => array( 'type' => 'string' ),
469                            'default'           => array(),
470                            // Sanitized down rather than validated: a malformed diagnostic must never cost the
471                            // user their tailored list.
472                            'sanitize_callback' => array( self::class, 'sanitize_validation_errors' ),
473                        ),
474                    ),
475                ),
476            )
477        );
478    }
479
480    /**
481     * Sanitizes the client-reported jetpack-ai-query failure reasons for the `tailored` Logstash record.
482     *
483     * The client sends only schema paths and rule names. This keeps it that way for any caller: anything that is
484     * not a string is dropped, every reason is reduced to the characters such a path and rule use (no quotes, so no
485     * quoted value survives), each is cut to MAX_VALIDATION_ERROR_LENGTH, and at most MAX_VALIDATION_ERRORS are kept.
486     *
487     * @param mixed $value The raw param value.
488     * @return string[] The sanitized reasons.
489     */
490    public static function sanitize_validation_errors( $value ) {
491        if ( is_string( $value ) ) {
492            $value = array( $value );
493        }
494        if ( ! is_array( $value ) ) {
495            return array();
496        }
497
498        $reasons = array();
499        foreach ( $value as $reason ) {
500            if ( ! is_string( $reason ) ) {
501                continue;
502            }
503            $reason = trim( substr( (string) preg_replace( '/[^A-Za-z0-9_.$\[\]:;<>= -]/', '', $reason ), 0, self::MAX_VALIDATION_ERROR_LENGTH ) );
504            if ( '' === $reason ) {
505                continue;
506            }
507            $reasons[] = $reason;
508            if ( count( $reasons ) >= self::MAX_VALIDATION_ERRORS ) {
509                break;
510            }
511        }
512
513        return $reasons;
514    }
515
516    /**
517     * Permission callback for reads.
518     *
519     * @return true|WP_Error|false
520     */
521    public function can_read() {
522        if ( ! current_user_can( 'edit_posts' ) ) {
523            return false;
524        }
525
526        return $this->check_eligibility();
527    }
528
529    /**
530     * Permission callback for writes.
531     *
532     * @return true|WP_Error|false
533     */
534    public function can_write() {
535        if ( ! current_user_can( 'manage_options' ) ) {
536            return false;
537        }
538
539        return $this->check_eligibility();
540    }
541
542    /**
543     * Returns a 404 error for ineligible sites, true otherwise.
544     *
545     * @return true|WP_Error
546     */
547    private function check_eligibility() {
548        // Fail closed: if the gate is unavailable, treat the site as not eligible.
549        if ( ! function_exists( 'wpcom_ai_launchpad_is_eligible' ) || ! wpcom_ai_launchpad_is_eligible() ) {
550            return new WP_Error(
551                'ai_launchpad_not_eligible',
552                __( 'This site is not eligible for the AI Launchpad.', 'jetpack-mu-wpcom' ),
553                array( 'status' => 404 )
554            );
555        }
556
557        return true;
558    }
559
560    /**
561     * Composite read: wizard payload, AI output, enriched tasks, statuses, and eligibility.
562     *
563     * @param WP_REST_Request|null $request Request object (for the `all_tasks` testing param).
564     * @return array
565     */
566    public function get_data( $request = null ) {
567        $wizard    = get_option( self::OPTION_WIZARD );
568        $ai_output = get_option( self::OPTION_AI_OUTPUT );
569        if ( is_array( $ai_output ) ) {
570            // Internal analytics bookkeeping (see report_task_completions), not part of the client contract.
571            unset( $ai_output['tracked_completed'] );
572        }
573
574        // Testing aid: ?all_tasks=1 renders the whole catalog, independent of the persisted tailored output.
575        $all_tasks = $request instanceof WP_REST_Request && $request->get_param( 'all_tasks' );
576        if ( $all_tasks ) {
577            $tasks = $this->apply_skipped_tasks( $this->build_all_catalog_tasks() );
578        } else {
579            $tasks = $this->get_current_tasks();
580        }
581
582        // The membership tasks' completion is recomputed in build_tasks(), so overlay it to keep
583        // checklist_statuses consistent with tasks[].completed for them.
584        $checklist_statuses = (array) get_option( 'launchpad_checklist_tasks_statuses', array() );
585        foreach ( $tasks as $task ) {
586            if ( AI_Launchpad_Memberships::has_override( $task['id'] ) ) {
587                $checklist_statuses[ $task['id'] ] = $task['completed'];
588            }
589        }
590
591        $this->maybe_mark_completed();
592
593        // The client shows the wizard when there is no AI output and ?all_tasks=1 is off (decideInitialView()
594        // in js/lib/orchestration.ts). The banner never renders there, so skip the offer and its ExPlat
595        // assignment: a user who sees only the wizard must not count as exposed. The list reads this endpoint
596        // again once the wizard finishes, and that read carries the offer.
597        $shows_wizard = ! is_array( $ai_output ) && ! $all_tasks;
598
599        $inferred_input = null;
600        if ( $shows_wizard && current_user_can( 'manage_options' ) ) {
601            $builder_input = wpcom_ai_launchpad_builder_input();
602            if ( null !== $builder_input ) {
603                $inferred_input = $this->adopt_builder_input( $builder_input );
604                $wizard         = get_option( self::OPTION_WIZARD );
605            }
606        }
607
608        return array(
609            'wizard'             => is_array( $wizard ) ? $wizard : null,
610            'ai_output'          => is_array( $ai_output ) ? $ai_output : null,
611            'tasks'              => $tasks,
612            'checklist_statuses' => $checklist_statuses,
613            'dismissed'          => (bool) get_option( self::OPTION_DISMISSED, false ),
614            // Lets the page celebrate a list finished elsewhere, which this read latches.
615            'list_completed'     => (bool) get_option( self::OPTION_COMPLETED ),
616            'is_eligible'        => true,
617            // Whether the next tailoring is for an AI-built site, so the client fallback skips the builder's tasks.
618            'ai_built'           => wpcom_ai_launchpad_is_ai_built(),
619            // What the AI Website Builder learned, so an AI-built site skips the wizard and tailors with it.
620            'inferred_input'     => $inferred_input,
621            // The language this request is translated into, which is the reader's own. Task subtitles
622            // follow it, since they are read here and never published.
623            'user_language'      => determine_locale(),
624            // Site context the client needs for the launch-task CTA, the preview thumbnail/title, and wizard prefill.
625            'site'               => array(
626                'url'         => home_url(),
627                'title'       => get_bloginfo( 'name' ),
628                'description' => get_bloginfo( 'description' ),
629                // Block themes open the Site Editor; classic themes fall back to the Customizer.
630                'edit_url'    => wp_is_block_theme() ? admin_url( 'site-editor.php' ) : admin_url( 'customize.php' ),
631                // The site language, which the AI output and the copy written into pages follow.
632                'language'    => wpcom_ai_launchpad_site_locale(),
633                'copy'        => wpcom_ai_launchpad_site_copy(),
634            ),
635            // The free DIFM offer banner under the task list, or null when it must not show.
636            'difm_offer'         => $shows_wizard ? null : AI_Launchpad_Difm_Offer::get_offer(),
637        );
638    }
639
640    /**
641     * The site's tailored task list (AI-selected + the synthetic store tasks, skip overlay applied) â€” the tasks
642     * GET renders, minus the ?all_tasks testing view. Shared by GET and the completion check.
643     *
644     * @return array
645     */
646    public function get_current_tasks() {
647        $ai_output = get_option( self::OPTION_AI_OUTPUT );
648
649        // Guard the nested payload: partial/failed writes may leave the option without payload.tasks.
650        $payload = is_array( $ai_output ) && isset( $ai_output['payload'] ) && is_array( $ai_output['payload'] )
651            ? $ai_output['payload']
652            : array();
653        // Validate `inferred` as an array before reading from it, since a partial write could leave it non-array.
654        $inferred       = isset( $payload['inferred'] ) && is_array( $payload['inferred'] ) ? $payload['inferred'] : array();
655        $theme_category = isset( $inferred['theme_category'] ) && is_string( $inferred['theme_category'] )
656            ? $inferred['theme_category']
657            : '';
658
659        // The same authority update_tailored() enforces against: the user's own wizard goal, not the
660        // model's echo of it. Anything else lets PUT strip a goal's tasks while GET injects them.
661        $goal = wpcom_ai_launchpad_resolve_goal( $payload );
662
663        if ( ! function_exists( 'is_plugin_active' ) ) {
664            require_once ABSPATH . 'wp-admin/includes/plugin.php';
665        }
666        $woo_active = is_plugin_active( 'woocommerce/woocommerce.php' );
667
668        // On a sell site without WooCommerce, keep the gated commerce tasks as a disabled preview of the store
669        // roadmap instead of dropping them (which would collapse the list to almost nothing).
670        $disable_hidden_woo = 'sell' === $goal && ! $woo_active;
671
672        $theme_cta = $this->get_themes_showcase_path( $goal, $theme_category );
673
674        $ai_tasks = isset( $payload['tasks'] ) && is_array( $payload['tasks'] ) ? $payload['tasks'] : array();
675
676        // A store needs a theme, so a sell list offers "Choose a theme" even when the AI did not pick one â€” unless it
677        // was tailored for an AI-built site, whose builder already chose the design (get_ai_task_ids mirrors this).
678        if ( 'sell' === $goal && ! wpcom_ai_launchpad_is_list_ai_built() ) {
679            $ai_tasks = $this->ensure_theme_task( $ai_tasks );
680        }
681
682        $tasks = empty( $ai_tasks ) ? array() : $this->build_tasks( $ai_tasks, false, $theme_cta, $disable_hidden_woo );
683
684        // The sell goal leads with the store-setup tasks; the theme task then follows them: pick the
685        // store's look once the store exists. Other goals need no injection â€” the gallery task is now
686        // on the menu, so the AI picks it when the site is visual.
687        if ( 'sell' === $goal ) {
688            $tasks = array_merge( $this->build_store_tasks( $woo_active ), $tasks );
689            $tasks = $this->move_task_after( $tasks, 'site_theme_selected', 'setup_woocommerce_store' );
690        }
691
692        // Restore the list toward six after the visibility gate has dropped tasks (before skips, which are the
693        // user's own removals). No-op on an empty list so the wizard still runs when there is no AI output.
694        $tasks = $this->backfill_to_minimum( $tasks, $theme_cta, $disable_hidden_woo );
695
696        return $this->apply_skipped_tasks( $tasks );
697    }
698
699    /**
700     * Tops a short rendered list back up toward the six tasks the AI is asked to return.
701     *
702     * The catalog visibility gate in build_tasks() drops any task it hides on this site (e.g. add_about_page needs a
703     * page-template meta key that is absent during a REST request) with no replacement, so a gate-heavy AI pick can
704     * collapse the list to two or three cards. This backfills from a small pool of broadly-useful tasks and keeps the
705     * launch task last. Candidates are built one at a time, stopping at the target, so the tail of the pool is only
706     * evaluated when the earlier fillers were not enough â€” mobile_app_installed's completion check does a remote
707     * lookup while incomplete, which the common short-by-one list never has to pay for. Backfilled cards are
708     * skippable (see skip_task); a fuller, AI-ranked overflow pool is the eventual replacement.
709     *
710     * @param array  $tasks              The rendered task list, already gated, launch task last.
711     * @param string $theme_cta          Pre-resolved themes-showcase CTA passed through to build_tasks().
712     * @param bool   $disable_hidden_woo Whether hidden commerce tasks render as a disabled preview.
713     * @return array
714     */
715    private function backfill_to_minimum( $tasks, $theme_cta, $disable_hidden_woo ) {
716        $target = 6;
717        if ( count( $tasks ) >= $target || empty( $tasks ) ) {
718            return $tasks;
719        }
720
721        foreach ( $this->backfill_pool() as $id => $subtitle ) {
722            if ( count( $tasks ) >= $target ) {
723                break;
724            }
725            $present = array_column( $tasks, 'id' );
726            if ( in_array( $id, $present, true ) ) {
727                continue;
728            }
729
730            // build_tasks() applies the same gating and remap the AI list gets, so a pool task the site
731            // hides simply does not appear.
732            $built = $this->build_tasks(
733                array(
734                    array(
735                        'id'       => $id,
736                        'subtitle' => $subtitle,
737                    ),
738                ),
739                false,
740                $theme_cta,
741                $disable_hidden_woo
742            );
743            if ( empty( $built ) ) {
744                continue;
745            }
746            $task = $built[0];
747            // A filler card that is already complete offers nothing to do; better a shorter list. The remap
748            // inside build_tasks() can also land the card on an id already present â€” skip that too.
749            if ( ! empty( $task['completed'] ) || in_array( $task['id'], $present, true ) ) {
750                continue;
751            }
752            $tasks = $this->insert_before_launch_task( $tasks, $task );
753        }
754
755        return $tasks;
756    }
757
758    /**
759     * The ordered id => subtitle pool the short-list backfill draws from: broadly-useful tasks that render on most
760     * sites and are skippable, most-broadly-applicable first. Each has a distinct card title, and none duplicate work
761     * the wizard already did (e.g. no site-title task â€” the wizard captured the name). Excludes tasks whose completion
762     * depends on the AI-task list (the theme/social listeners, the complete-on-click route), since a backfilled card is
763     * not on that list. skip_task() reads the ids here to keep every backfilled card skippable, so the set lives here.
764     * A list tailored for an AI-built site swaps the design and page fillers for ones the builder did not cover.
765     *
766     * @return array<string, string>
767     */
768    private function backfill_pool() {
769        if ( wpcom_ai_launchpad_is_list_ai_built() ) {
770            $pool = array(
771                'add_site_icon' => __( 'Add your logo or site icon.', 'jetpack-mu-wpcom' ),
772                'domain_upsell' => __( 'Give your site its own domain.', 'jetpack-mu-wpcom' ),
773                // domain_upsell's stand-in on a plan that includes a domain, where the upsell is hidden.
774                'domain_claim'  => __( 'Claim the free domain that comes with your plan.', 'jetpack-mu-wpcom' ),
775            );
776        } else {
777            $pool = array(
778                'design_edited' => __( 'Make the design your own.', 'jetpack-mu-wpcom' ),
779                'add_new_page'  => __( 'Add a page your visitors will want, like About or Contact.', 'jetpack-mu-wpcom' ),
780            );
781        }
782
783        // Last, so the remote lookup in mobile_app_installed's completion check only runs when nothing else fits.
784        return $pool + array(
785            'connect_social_media' => __( 'Connect your social accounts to reach more people.', 'jetpack-mu-wpcom' ),
786            'mobile_app_installed' => __( 'Manage your site from anywhere with the Jetpack app.', 'jetpack-mu-wpcom' ),
787        );
788    }
789
790    /**
791     * Runs the completion pass: reports newly-completed tasks to Tracks, then latches OPTION_COMPLETED (and records
792     * the all-tasks-completed event) the first time the list is fully done. Called on every path that can finish a
793     * task (read, skip, complete-on-click, a COMPLETION_TRIGGER_OPTIONS write); the already-set check skips the
794     * rebuild once latched.
795     *
796     * @return void
797     */
798    private function maybe_mark_completed() {
799        if ( get_option( self::OPTION_COMPLETED ) ) {
800            return;
801        }
802
803        // No AI output means the wizard hasn't produced a list yet: nothing to report or latch.
804        if ( ! get_option( self::OPTION_AI_OUTPUT ) ) {
805            return;
806        }
807
808        $tasks = $this->get_current_tasks();
809        // After the rebuild, whose own status writes queue a pass too: this one covers them all.
810        unset( $this->pending_completion_blog_ids[ get_current_blog_id() ] );
811        // An empty list is not "complete" â€” the wizard still needs to run.
812        if ( empty( $tasks ) ) {
813            return;
814        }
815
816        $this->report_task_completions( $tasks );
817
818        foreach ( $tasks as $task ) {
819            // A skip coerces `completed` true; a disabled preview task stays incomplete until its prerequisite is met.
820            if ( empty( $task['completed'] ) ) {
821                return;
822            }
823        }
824
825        update_option( self::OPTION_COMPLETED, true, true );
826        // After the per-task reports above, so any task_completed reports precede it (a list finished
827        // purely by skips or born-completed tasks latches with none).
828        wpcom_ai_launchpad_record_tracks_event(
829            'jetpack_ai_launchpad_all_tasks_completed',
830            array(),
831            array_column( $tasks, 'id' )
832        );
833    }
834
835    /**
836     * Records a `task_completed` Tracks event for every rendered task that newly reads as completed, whatever
837     * completed it (client click, PHP listener, or live recomputation Ã  la Woo/domains/memberships) â€” the only
838     * uniform signal is the rendered list itself, so completions are diffed on read.
839     *
840     * The already-reported ids are embedded in the existing AI-output envelope (`tracked_completed`) rather than a
841     * new option; a re-tailor rewrites the envelope, which re-baselines the set at the same moment the skip/completed
842     * options reset. PUT /tailored seeds the key with the born-completed tasks, so a pass here only ever reports
843     * user-triggered completions; an envelope persisted before the key existed gets the same silent baseline on its
844     * first pass. Skipped tasks render as completed but already emit `task_skipped`, so they are excluded (and never
845     * reported later).
846     *
847     * @param array $tasks The current rendered tasks.
848     * @return void
849     */
850    private function report_task_completions( $tasks ) {
851        $ai_output = get_option( self::OPTION_AI_OUTPUT );
852        if ( ! is_array( $ai_output ) ) {
853            return;
854        }
855
856        $skipped   = $this->get_skipped_task_ids();
857        $completed = array();
858        foreach ( $tasks as $task ) {
859            if ( ! empty( $task['completed'] ) && ! in_array( $task['id'], $skipped, true ) ) {
860                $completed[] = $task['id'];
861            }
862        }
863
864        // null = no key yet, i.e. the baseline pass right after tailoring. Remapped like the skip overlay: a
865        // baseline recorded under a since-remapped id must keep covering the id its card renders as now, or the
866        // same completion would be re-reported once under the new id.
867        $reported = isset( $ai_output['tracked_completed'] ) && is_array( $ai_output['tracked_completed'] )
868            ? array_values( array_unique( array_map( 'wpcom_ai_launchpad_remap_task_id', $ai_output['tracked_completed'] ) ) )
869            : null;
870        $newly    = array_values( array_diff( $completed, $reported ?? array() ) );
871
872        if ( null !== $reported ) {
873            if ( empty( $newly ) ) {
874                return;
875            }
876            $rendered_ids = array_column( $tasks, 'id' );
877            foreach ( $newly as $task_id ) {
878                wpcom_ai_launchpad_record_tracks_event(
879                    'jetpack_ai_launchpad_task_completed',
880                    array( 'task_id' => $task_id ),
881                    $rendered_ids
882                );
883            }
884        }
885
886        $ai_output['tracked_completed'] = array_merge( $reported ?? array(), $newly );
887        update_option( self::OPTION_AI_OUTPUT, $ai_output, false );
888    }
889
890    /**
891     * Persists the wizard input and writes the entered Name and Brief description back to blogname / blogdescription.
892     *
893     * Empty values are skipped so the wizard never blanks an existing title or tagline.
894     *
895     * @param WP_REST_Request $request Request object.
896     * @return array
897     */
898    public function update_wizard( $request ) {
899        $wizard = array(
900            'version'      => 1,
901            'goal'         => $request['goal'],
902            'site_name'    => $request['site_name'],
903            'description'  => $request['description'],
904            'locale'       => $request['locale'],
905            'ui_locale'    => $request['ui_locale'],
906            'generated_at' => time(),
907        );
908
909        update_option( self::OPTION_WIZARD, $wizard, false );
910
911        if ( '' !== trim( (string) $request['site_name'] ) ) {
912            update_option( 'blogname', $request['site_name'] );
913        }
914        if ( '' !== trim( (string) $request['description'] ) ) {
915            // Collapse the textarea brief's newlines to keep the inline-rendered tagline single-line.
916            update_option( 'blogdescription', sanitize_text_field( $request['description'] ) );
917        }
918
919        return array( 'wizard' => $wizard );
920    }
921
922    /**
923     * Validates the AI output payload against the agent output schema, wraps it, and persists it.
924     *
925     * @param WP_REST_Request $request Request object.
926     * @return array|WP_Error
927     */
928    public function update_tailored( $request ) {
929        $payload = $request->get_json_params();
930
931        $validation = rest_validate_value_from_schema( $payload, $this->get_output_schema(), 'payload' );
932        if ( is_wp_error( $validation ) ) {
933            return new WP_Error( 'ai_launchpad_invalid_payload', $validation->get_error_message(), array( 'status' => 422 ) );
934        }
935
936        $last_task = end( $payload['tasks'] );
937        if ( ! in_array( $last_task['id'], self::LAUNCH_TASK_IDS, true ) ) {
938            return new WP_Error(
939                'ai_launchpad_missing_launch_task',
940                __( 'The last task must be a launch task.', 'jetpack-mu-wpcom' ),
941                array( 'status' => 422 )
942            );
943        }
944
945        // The AI's raw picks, captured before the unknown-id filter below so hallucinated ids stay observable.
946        $raw_task_ids = array_column( $payload['tasks'], 'id' );
947
948        $definitions = wpcom_launchpad_get_task_definitions();
949        $tasks       = array();
950
951        // The exclusions key off the goal the user chose, resolved through the shared helper so this and the
952        // read path cannot disagree about which goal the site has.
953        $excluded = self::excluded_task_ids_for_goal( wpcom_ai_launchpad_resolve_goal( $payload ) );
954
955        foreach ( $payload['tasks'] as $task ) {
956            // Judge â€” and persist â€” the id this task will actually render as. build_tasks() remaps broken and
957            // twinned ids on read, so checking the raw id would let a restricted task in under its other name:
958            // `subscribers_added` is off the menu and unrestricted, yet renders as the newsletter-restricted
959            // `import_subscribers`. The rule has to see the card, not the spelling.
960            $task_id = wpcom_ai_launchpad_remap_task_id( $task['id'] );
961
962            if ( ! isset( $definitions[ $task_id ] ) && ! AI_Launchpad_Task_Registry::has( $task_id ) ) {
963                continue;
964            }
965
966            // Goal-restricted tasks are dropped even if the model picked them: the menu filter is advisory
967            // (a failed availability lookup falls back to the full menu), this is not.
968            if ( in_array( $task_id, $excluded, true ) ) {
969                continue;
970            }
971
972            $subtitle = $this->sanitize_subtitle( $task['subtitle'] );
973            if ( is_wp_error( $subtitle ) ) {
974                return $subtitle;
975            }
976
977            $tasks[] = array(
978                'id'       => $task_id,
979                'subtitle' => $subtitle,
980            );
981        }
982
983        // However few tasks survive (the launch task always does), keep them: the read side backfills the list.
984        $payload['tasks'] = $tasks;
985
986        $ai_output = array(
987            'version'      => 1,
988            'source'       => $request['source'],
989            'generated_at' => time(),
990            'ai_built'     => wpcom_ai_launchpad_is_ai_built(),
991            'payload'      => $payload,
992        );
993
994        // Omitted rather than stored empty, so the props builder reports "none" for a write
995        // that carried no session id (a client from before this shipped, or a direct call).
996        if ( '' !== $request['ai_session_id'] ) {
997            $ai_output['ai_session_id'] = $request['ai_session_id'];
998        }
999
1000        update_option( self::OPTION_AI_OUTPUT, $ai_output, false );
1001
1002        // A fresh list must not inherit the previous one's skips or "done" flag.
1003        delete_option( self::OPTION_SKIPPED );
1004        delete_option( self::OPTION_COMPLETED );
1005
1006        // Baseline the born-completed tasks now (needs the fresh options above), so completion
1007        // reporting only ever emits for completions the user actually triggers afterwards.
1008        $rendered_tasks = $this->get_current_tasks();
1009        $baseline       = array();
1010        foreach ( $rendered_tasks as $task ) {
1011            if ( ! empty( $task['completed'] ) ) {
1012                $baseline[] = $task['id'];
1013            }
1014        }
1015        $ai_output['tracked_completed'] = $baseline;
1016        update_option( self::OPTION_AI_OUTPUT, $ai_output, false );
1017
1018        // After the writes, so the observed rendered list is the fresh one.
1019        $this->log_tailoring( $ai_output, $raw_task_ids, $request['duration_ms'], $request['attempts'], array_column( $rendered_tasks, 'id' ), (array) $request['validation_errors'] );
1020
1021        // The analytics bookkeeping stays out of responses, mirroring get_data().
1022        unset( $ai_output['tracked_completed'] );
1023
1024        return array( 'ai_output' => $ai_output );
1025    }
1026
1027    /**
1028     * Emits the tailoring observation event to Logstash, keyed `feature: atomic_ai_launchpad`, `message: tailored`
1029     * (the public-api logstash endpoint whitelists features by their `atomic_` prefix â€” a bare feature name is
1030     * rejected with a 400 on the Atomic HTTP dispatch path). Best-effort: logging must never fail the tailoring write.
1031     *
1032     * @param array         $ai_output    The persisted AI output envelope.
1033     * @param string[]      $raw_task_ids The AI's selected ids before the unknown-id filter.
1034     * @param int|null      $duration_ms  Client-measured tailoring duration, or null when not sent.
1035     * @param int|null      $attempts     Client-reported jetpack-ai-query attempt count, or null when not sent.
1036     * @param string[]|null $rendered_ids The rendered task ids, when the caller already computed them.
1037     * @param string[]      $validation_errors Why each failed jetpack-ai-query attempt failed, already sanitized.
1038     * @return void
1039     */
1040    private function log_tailoring( $ai_output, $raw_task_ids, $duration_ms = null, $attempts = null, $rendered_ids = null, $validation_errors = array() ) {
1041        try {
1042            /**
1043             * Gates the tailoring observation event sent to Logstash. Checked before the event
1044             * is built, so disabling it also skips the extra task-list rebuild the event needs.
1045             *
1046             * @param bool $enabled Whether to send the event. Default true.
1047             */
1048            if ( ! apply_filters( 'wpcom_ai_launchpad_tailoring_log_enabled', true ) ) {
1049                return;
1050            }
1051
1052            \Automattic\Jetpack\Jetpack_Mu_Wpcom::log2logstash(
1053                'atomic_ai_launchpad',
1054                'tailored',
1055                $this->tailoring_log_extra( $ai_output, $raw_task_ids, $duration_ms, $attempts, $rendered_ids, $validation_errors )
1056            );
1057        } catch ( \Throwable $e ) {
1058            unset( $e );
1059        }
1060    }
1061
1062    /**
1063     * The tailoring observation event: how the AI's output relates to what the site will actually render.
1064     *
1065     * Carries the inferred details, the AI's raw selected ids (pre-filter, so hallucinated ids are observable), the
1066     * rendered ids, and their delta â€” `dropped` is what the unknown-id filter and the visibility gate removed, `added`
1067     * is what synthetics and the backfill floor put in. The delta is diffed post-remap so a selected id that renders
1068     * under its working equivalent does not read as a drop plus an addition. The raw wizard title/description are
1069     * never included, and the one inferred field that echoes the user's own words near-verbatim is stripped:
1070     * `brand_name` restates the title.
1071     *
1072     * @param array         $ai_output    The persisted AI output envelope.
1073     * @param string[]      $raw_task_ids The AI's selected ids before the unknown-id filter.
1074     * @param int|null      $duration_ms  Client-measured tailoring duration, or null when not sent.
1075     * @param int|null      $attempts     Client-reported jetpack-ai-query attempt count, or null when not sent.
1076     * @param string[]|null $rendered_ids The rendered task ids, when the caller already computed them.
1077     * @param string[]      $validation_errors Why each failed jetpack-ai-query attempt failed, already sanitized.
1078     * @return array
1079     */
1080    private function tailoring_log_extra( $ai_output, $raw_task_ids, $duration_ms = null, $attempts = null, $rendered_ids = null, $validation_errors = array() ) {
1081        // Schema-validated on the write path, so `inferred` is always present here.
1082        $inferred = $ai_output['payload']['inferred'];
1083        unset( $inferred['brand_name'] );
1084
1085        $rendered = $rendered_ids ?? array_column( $this->get_current_tasks(), 'id' );
1086        $remapped = array_unique( array_map( 'wpcom_ai_launchpad_remap_task_id', $raw_task_ids ) );
1087
1088        $extra = array(
1089            'source'   => $ai_output['source'],
1090            'ai_built' => ! empty( $ai_output['ai_built'] ),
1091            'inferred' => $inferred,
1092            'selected' => $raw_task_ids,
1093            'rendered' => $rendered,
1094            'dropped'  => array_values( array_diff( $remapped, $rendered ) ),
1095            'added'    => array_values( array_diff( $rendered, $remapped ) ),
1096        );
1097
1098        // Client-measured timing, replacing the retired ai_response_received Tracks event.
1099        if ( null !== $duration_ms ) {
1100            $extra['duration_ms'] = (int) $duration_ms;
1101        }
1102        if ( null !== $attempts ) {
1103            $extra['attempts'] = (int) $attempts;
1104        }
1105        // Only when an attempt failed, so a first-try success keeps the record's usual shape.
1106        if ( ! empty( $validation_errors ) ) {
1107            $extra['validation_errors'] = array_values( $validation_errors );
1108        }
1109
1110        return $extra;
1111    }
1112
1113    /**
1114     * Marks an acknowledgment task complete when the user clicks its CTA, since these tasks have no wp-admin signal.
1115     *
1116     * Restricted to the COMPLETE_ON_CLICK_TASK_IDS allowlist and to tasks on the site's AI-selected list.
1117     *
1118     * @param WP_REST_Request $request Request object.
1119     * @return array|WP_Error
1120     */
1121    public function complete_task( $request ) {
1122        $task_id = $request['task_id'];
1123
1124        if ( ! in_array( $task_id, self::COMPLETE_ON_CLICK_TASK_IDS, true ) ) {
1125            return new WP_Error(
1126                'ai_launchpad_task_not_completable',
1127                __( 'This task cannot be completed this way.', 'jetpack-mu-wpcom' ),
1128                array( 'status' => 400 )
1129            );
1130        }
1131
1132        // Only tasks the AI put on this site's list may be completed.
1133        if ( ! in_array( $task_id, wpcom_ai_launchpad_get_ai_task_ids(), true ) ) {
1134            return new WP_Error(
1135                'ai_launchpad_task_not_selected',
1136                __( 'This task is not on the tailored list.', 'jetpack-mu-wpcom' ),
1137                array( 'status' => 404 )
1138            );
1139        }
1140
1141        // The registry's ids are not in the shared catalog, and wpcom_mark_launchpad_task_complete() drops
1142        // what the catalog does not define, so those go through the registry's own write.
1143        if ( AI_Launchpad_Task_Registry::has( $task_id ) ) {
1144            AI_Launchpad_Task_Registry::mark_complete( $task_id );
1145        } else {
1146            wpcom_mark_launchpad_task_complete( $task_id );
1147        }
1148
1149        // Latch now so completing the last task hides the menu on the next page load, not just on the next read.
1150        $this->maybe_mark_completed();
1151
1152        return array(
1153            'completed'      => true,
1154            'task_id'        => $task_id,
1155            'list_completed' => (bool) get_option( self::OPTION_COMPLETED ),
1156        );
1157    }
1158
1159    /**
1160     * Marks a task as skipped: it renders (and counts) as completed without its real completion signal ever firing.
1161     *
1162     * Restricted to tasks on the site's AI-selected list, the synthetic ids the list adds itself, and the short-list
1163     * backfill pool â€” so every rendered card (AI, synthetic, or filler) is skippable. Persisted separately from
1164     * `launchpad_checklist_tasks_statuses` because several catalog tasks recompute completion live (memberships, woo,
1165     * domains) and would ignore a status write; the skip set is overlaid on read instead.
1166     *
1167     * @param WP_REST_Request $request Request object.
1168     * @return array|WP_Error
1169     */
1170    public function skip_task( $request ) {
1171        $task_id = $request['task_id'];
1172
1173        $skippable = array_merge( wpcom_ai_launchpad_get_ai_task_ids(), self::SYNTHETIC_TASK_IDS, array_keys( $this->backfill_pool() ) );
1174        if ( ! in_array( $task_id, $skippable, true ) ) {
1175            return new WP_Error(
1176                'ai_launchpad_task_not_skippable',
1177                __( 'This task is not on the tailored list.', 'jetpack-mu-wpcom' ),
1178                array( 'status' => 404 )
1179            );
1180        }
1181
1182        $skipped = $this->get_skipped_task_ids();
1183        if ( ! in_array( $task_id, $skipped, true ) ) {
1184            $skipped[] = $task_id;
1185            update_option( self::OPTION_SKIPPED, $skipped, false );
1186        }
1187
1188        // Latch now so skipping the last task hides the menu on the next page load, not just on the next read.
1189        $this->maybe_mark_completed();
1190
1191        return array(
1192            'skipped'        => true,
1193            'task_id'        => $task_id,
1194            'list_completed' => (bool) get_option( self::OPTION_COMPLETED ),
1195        );
1196    }
1197
1198    /**
1199     * Deletes the AI output and marks the AI Launchpad as dismissed.
1200     *
1201     * @return array
1202     */
1203    public function dismiss() {
1204        delete_option( self::OPTION_AI_OUTPUT );
1205        delete_option( self::OPTION_SKIPPED );
1206        delete_option( self::OPTION_COMPLETED );
1207        update_option( self::OPTION_DISMISSED, true, true );
1208
1209        return array( 'dismissed' => true );
1210    }
1211
1212    /**
1213     * The persisted skipped task ids, always as a clean string array, remapped onto the
1214     * ids the launchpad renders â€” a skip recorded under a task's raw id before that id
1215     * was remapped must keep applying to the card it renders as now.
1216     *
1217     * @return string[]
1218     */
1219    private function get_skipped_task_ids() {
1220        $skipped = get_option( self::OPTION_SKIPPED, array() );
1221        if ( ! is_array( $skipped ) ) {
1222            return array();
1223        }
1224
1225        $skipped = array_map( 'wpcom_ai_launchpad_remap_task_id', array_filter( $skipped, 'is_string' ) );
1226
1227        return array_values( array_unique( $skipped ) );
1228    }
1229
1230    /**
1231     * Overlays the persisted skips onto the enriched tasks: a skipped task carries `skipped: true` and is coerced to
1232     * completed, so progress, auto-expand, and reloads all treat it as done (a skip must never pop back open).
1233     *
1234     * @param array $tasks The enriched task list.
1235     * @return array
1236     */
1237    private function apply_skipped_tasks( $tasks ) {
1238        $skipped = $this->get_skipped_task_ids();
1239
1240        foreach ( $tasks as &$task ) {
1241            $task['skipped'] = in_array( $task['id'], $skipped, true );
1242            if ( $task['skipped'] ) {
1243                $task['completed'] = true;
1244            }
1245        }
1246        unset( $task );
1247
1248        return $tasks;
1249    }
1250
1251    /**
1252     * Strips HTML from a subtitle and rejects URLs and template syntax.
1253     *
1254     * @param string $subtitle The raw subtitle.
1255     * @return string|WP_Error The sanitized subtitle, or an error.
1256     */
1257    private function sanitize_subtitle( $subtitle ) {
1258        $subtitle = trim( wp_strip_all_tags( $subtitle, true ) );
1259
1260        if ( '' === $subtitle ) {
1261            return new WP_Error(
1262                'ai_launchpad_invalid_subtitle',
1263                __( 'Task subtitles must contain text.', 'jetpack-mu-wpcom' ),
1264                array( 'status' => 422 )
1265            );
1266        }
1267
1268        if ( preg_match( '#https?://#i', $subtitle ) ) {
1269            return new WP_Error(
1270                'ai_launchpad_subtitle_contains_url',
1271                __( 'Task subtitles must not contain URLs.', 'jetpack-mu-wpcom' ),
1272                array( 'status' => 422 )
1273            );
1274        }
1275
1276        if ( str_contains( $subtitle, '{{' ) || str_contains( $subtitle, '[[' ) ) {
1277            return new WP_Error(
1278                'ai_launchpad_subtitle_contains_template',
1279                __( 'Task subtitles must not contain template syntax.', 'jetpack-mu-wpcom' ),
1280                array( 'status' => 422 )
1281            );
1282        }
1283
1284        return mb_substr( $subtitle, 0, 200 );
1285    }
1286
1287    /**
1288     * Read endpoint backing the client's availability-aware tailoring: the task ids that will render for the given
1289     * goal. Fetched before the AI call, so the prompt offers only renderable tasks.
1290     *
1291     * @param WP_REST_Request $request Request object.
1292     * @return array
1293     */
1294    public function get_available_tasks( $request ) {
1295        $ids = $this->available_task_ids( (string) $request['goal'] );
1296        return array(
1297            'available_task_ids'  => $ids['actionable'],
1298            'renderable_task_ids' => $ids['renderable'],
1299        );
1300    }
1301
1302    /**
1303     * The task ids that must not be offered to, or accepted from, the model for a given goal.
1304     *
1305     * The two maps read in opposite directions, and an unknown or empty goal matches neither: every
1306     * GOAL_RESTRICTED_TASK_IDS entry is excluded (it never got the goal it requires), while every
1307     * GOAL_EXCLUDED_TASK_IDS entry is allowed (it never hit the goal that excludes it).
1308     * On an AI-built site it also excludes AI_BUILT_EXCLUDED_TASK_IDS, whatever the goal.
1309     *
1310     * @param string $goal The selected goal.
1311     * @return string[]
1312     */
1313    public static function excluded_task_ids_for_goal( $goal ) {
1314        $excluded = array();
1315
1316        foreach ( self::GOAL_RESTRICTED_TASK_IDS as $task_id => $required_goal ) {
1317            if ( $goal !== $required_goal ) {
1318                $excluded[] = $task_id;
1319            }
1320        }
1321
1322        foreach ( self::GOAL_EXCLUDED_TASK_IDS as $task_id => $excluded_goal ) {
1323            if ( $goal === $excluded_goal ) {
1324                $excluded[] = $task_id;
1325            }
1326        }
1327
1328        // Tailoring follows the live spec; a saved list follows its own flag (is_list_ai_built).
1329        if ( wpcom_ai_launchpad_is_ai_built() ) {
1330            $excluded = array_merge( $excluded, self::AI_BUILT_EXCLUDED_TASK_IDS );
1331        }
1332
1333        return $excluded;
1334    }
1335
1336    /**
1337     * The task ids that will actually render on this site for the given goal â€” the menu tailoring should choose from.
1338     *
1339     * Built by running the whole catalog through the real gate (visibility + force-visible overrides, and the sell
1340     * goal's woo-preview mode), so a task the AI could pick but the site would drop is never offered. `actionable`
1341     * additionally excludes tasks that are already complete â€” they leave nothing to do â€” except the launch tasks,
1342     * which the output contract requires last even on a site that already launched. `renderable` keeps the completed
1343     * ones: the client falls back to it when completion leaves too few actionable tasks to fill a valid list. The
1344     * client intersects these with its own TASK_MENU. Computed once per wizard submit.
1345     *
1346     * The AI Launchpad's own tasks are appended separately, since they are not catalog entries and the catalog
1347     * sweep cannot find them. They run their own gate on the way in: a registry definition may declare an
1348     * `is_visible` callable, and one that fails it is withheld from both lists here exactly as a catalog task
1349     * failing wpcom_launchpad_checklists()->is_visible() is. A definition without one is visible everywhere â€”
1350     * the gallery's shape, since it asks nothing of the site.
1351     *
1352     * @param string $goal The inferred/selected goal.
1353     * @return array{renderable: string[], actionable: string[]}
1354     */
1355    private function available_task_ids( $goal ) {
1356        if ( ! function_exists( 'is_plugin_active' ) ) {
1357            require_once ABSPATH . 'wp-admin/includes/plugin.php';
1358        }
1359        // Sell keeps the commerce tasks as a disabled preview until WooCommerce is active, so they count as available.
1360        $disable_hidden_woo = 'sell' === $goal && ! is_plugin_active( 'woocommerce/woocommerce.php' );
1361
1362        $tasks = $this->build_all_catalog_tasks( false, $disable_hidden_woo );
1363
1364        $actionable = array_filter(
1365            $tasks,
1366            static function ( $task ) {
1367                return ! $task['completed'] || in_array( $task['id'], self::LAUNCH_TASK_IDS, true );
1368            }
1369        );
1370
1371        $excluded = self::excluded_task_ids_for_goal( $goal );
1372
1373        // The registry's tasks are not in the catalog, so the sweep above cannot see them. Offer every registry
1374        // task that this site can render and that is not already complete; a completed one still renders, so it
1375        // stays on the renderable list the client relaxes to.
1376        $registry_renderable = array_values(
1377            array_filter(
1378                AI_Launchpad_Task_Registry::task_ids(),
1379                static function ( $task_id ) {
1380                    return AI_Launchpad_Task_Registry::is_visible( $task_id );
1381                }
1382            )
1383        );
1384        $registry_actionable = array_values(
1385            array_filter(
1386                $registry_renderable,
1387                static function ( $task_id ) {
1388                    return ! AI_Launchpad_Task_Registry::is_complete( $task_id );
1389                }
1390            )
1391        );
1392
1393        // array_unique guards a future registry id that shadows a catalog one: the endpoint would otherwise
1394        // advertise it twice.
1395        $renderable = array_unique( array_merge( array_column( $tasks, 'id' ), $registry_renderable ) );
1396        $actionable = array_unique( array_merge( array_column( $actionable, 'id' ), $registry_actionable ) );
1397
1398        return array(
1399            'renderable' => array_values( array_diff( $renderable, $excluded ) ),
1400            'actionable' => array_values( array_diff( $actionable, $excluded ) ),
1401        );
1402    }
1403
1404    /**
1405     * Records the builder's input as the wizard answers, or reuses an existing answer's goal.
1406     *
1407     * The builder's name and description are always used; blogname and blogdescription are never touched.
1408     *
1409     * @param array $builder_input The builder's input.
1410     * @return array The input to tailor with.
1411     */
1412    private function adopt_builder_input( $builder_input ) {
1413        $wizard = get_option( self::OPTION_WIZARD );
1414        if ( false === $wizard ) {
1415            $wizard = array(
1416                'version'      => 1,
1417                'goal'         => $builder_input['goal'],
1418                'site_name'    => $builder_input['site_name'],
1419                'description'  => $builder_input['description'],
1420                'locale'       => wpcom_ai_launchpad_site_locale(),
1421                'ui_locale'    => determine_locale(),
1422                'generated_at' => time(),
1423                'source'       => 'ai_builder',
1424            );
1425            add_option( self::OPTION_WIZARD, $wizard, '', false );
1426        }
1427
1428        // update_tailored() enforces the wizard's goal, so the prompt must use it too.
1429        if ( is_array( $wizard ) && isset( $wizard['goal'] ) && in_array( $wizard['goal'], self::GOALS, true ) ) {
1430            $builder_input['goal'] = $wizard['goal'];
1431        }
1432
1433        return $builder_input;
1434    }
1435
1436    /**
1437     * Builds the enriched task list for every catalog task (backs `?all_tasks=1` when the gate is bypassed, and
1438     * available_task_ids() when it is not).
1439     *
1440     * Each task is enriched in isolation so one that can't be built is skipped rather than breaking the whole view.
1441     *
1442     * @param bool $bypass_visibility  Whether to skip the catalog visibility gate (the testing view does).
1443     * @param bool $disable_hidden_woo Whether hidden commerce tasks render as a disabled preview instead of dropping.
1444     * @return array
1445     */
1446    private function build_all_catalog_tasks( $bypass_visibility = true, $disable_hidden_woo = false ) {
1447        $built    = array();
1448        $seen_ids = array();
1449        foreach ( array_keys( wpcom_launchpad_get_task_definitions() ) as $task_id ) {
1450            try {
1451                $one = $this->build_tasks(
1452                    array(
1453                        array(
1454                            'id'       => $task_id,
1455                            'subtitle' => $task_id,
1456                        ),
1457                    ),
1458                    $bypass_visibility,
1459                    null,
1460                    $disable_hidden_woo
1461                );
1462            } catch ( \Throwable $e ) {
1463                continue;
1464            }
1465            // build_tasks runs per id here, so its own dedup can't see this collision: the catalog holds both
1466            // `woo_launch_site` and `site_launched`, and the former is remapped onto the latter. Keep the first.
1467            $card = $one[0] ?? null;
1468            if ( null === $card || isset( $seen_ids[ $card['id'] ] ) ) {
1469                continue;
1470            }
1471            $seen_ids[ $card['id'] ] = true;
1472            $built[]                 = $card;
1473        }
1474        return $built;
1475    }
1476
1477    /**
1478     * Enriches the persisted tasks with title, completion state, and CTA path from the catalog.
1479     *
1480     * @param array       $tasks              The persisted `payload.tasks` array.
1481     * @param bool        $bypass_visibility  Skip the catalog visibility gate (for the all-tasks testing view).
1482     * @param string|null $theme_cta          The resolved themes-showcase path for the theme-picker tasks, or
1483     *                                        null to keep their default CTAs.
1484     * @param bool        $disable_hidden_woo Keep WOO_TASK_IDS that fail the visibility gate as disabled preview
1485     *                                        cards instead of dropping them (sell goal while WooCommerce is inactive).
1486     * @return array
1487     */
1488    private function build_tasks( $tasks, $bypass_visibility = false, $theme_cta = null, $disable_hidden_woo = false ) {
1489        $definitions = wpcom_launchpad_get_task_definitions();
1490        $built       = array();
1491        $seen_ids    = array();
1492
1493        // Some catalog visibility callbacks call is_plugin_active(), which is not loaded during a REST request.
1494        if ( ! function_exists( 'is_plugin_active' ) ) {
1495            require_once ABSPATH . 'wp-admin/includes/plugin.php';
1496        }
1497
1498        $is_private_site = $this->is_private_site();
1499
1500        foreach ( $tasks as $task ) {
1501            if ( ! is_array( $task ) || ! isset( $task['id'] ) || ! isset( $task['subtitle'] ) ) {
1502                continue;
1503            }
1504
1505            // Broken/meaningless-in-context ids render as their working equivalent (see the helper for the why).
1506            $task['id'] = wpcom_ai_launchpad_remap_task_id( $task['id'] );
1507
1508            // One card per id â€” the client keys cards by id. The remap above can collide with the target id already
1509            // being present (notably the ?all_tasks=1 view, which enumerates every catalog id), so collapse any
1510            // repeat to the first occurrence.
1511            if ( isset( $seen_ids[ $task['id'] ] ) ) {
1512                continue;
1513            }
1514
1515            // Tasks the AI Launchpad owns are built from its own registry: the shared catalog does not
1516            // define them, and routing them through wpcom_launchpad_checklists() would mean relying on
1517            // the catalog accepting entries it never registered.
1518            if ( AI_Launchpad_Task_Registry::has( $task['id'] ) ) {
1519                // The registry's own visibility gate, filtered on read for the same reason as the catalog's
1520                // below: a persisted list outlives the site state it was tailored for, so a task whose
1521                // precondition has since gone must stop rendering rather than offer a CTA that leads nowhere.
1522                if ( ! $bypass_visibility && ! AI_Launchpad_Task_Registry::is_visible( $task['id'] ) ) {
1523                    continue;
1524                }
1525
1526                $card = AI_Launchpad_Task_Registry::build( $task['id'], (string) $task['subtitle'] );
1527                if ( null !== $card ) {
1528                    $seen_ids[ $task['id'] ] = true;
1529                    $built[]                 = $card;
1530                }
1531                continue;
1532            }
1533
1534            if ( ! isset( $definitions[ $task['id'] ] ) ) {
1535                continue;
1536            }
1537
1538            // The Jetpack Social tasks point at an admin page wpcom doesn't load on a private site, so hide them there.
1539            if ( $is_private_site && in_array( $task['id'], self::SOCIAL_PAGE_TASK_IDS, true ) ) {
1540                continue;
1541            }
1542
1543            $definition       = $definitions[ $task['id'] ];
1544            $definition['id'] = $task['id'];
1545            $disabled         = false;
1546
1547            // Honor the catalog's own visibility gate: a task the catalog would hide here must not render, since its
1548            // CTA would 404 and it could never complete. Filtered on read so the deterministic fallback stays usable.
1549            if (
1550                ! $bypass_visibility
1551                && ! in_array( $task['id'], self::FORCE_VISIBLE_TASK_IDS, true )
1552                && ! wpcom_launchpad_checklists()->is_visible( $definition )
1553            ) {
1554                // On a sell site without WooCommerce, keep the commerce tasks as a disabled preview of the store
1555                // roadmap rather than dropping them and collapsing the list. Everything else stays hidden.
1556                if ( $disable_hidden_woo && in_array( $task['id'], self::WOO_TASK_IDS, true ) ) {
1557                    $disabled = true;
1558                } else {
1559                    continue;
1560                }
1561            }
1562
1563            if ( $disabled ) {
1564                // A disabled preview always renders as the locked card: never resolve its completion (the woo
1565                // completion callback marks the task complete as a side effect, which must not fire on a read) and
1566                // never resolve a CTA path (it has no reachable action).
1567                $completed    = false;
1568                $calypso_path = null;
1569            } else {
1570                // The membership tasks' catalog callbacks are always false on Atomic; recompute from local signals.
1571                $completed = AI_Launchpad_Memberships::has_override( $task['id'] )
1572                    ? AI_Launchpad_Memberships::is_task_complete( $task['id'] )
1573                    : wpcom_launchpad_checklists()->is_task_complete( $definition );
1574
1575                // The theme-picker task points at the showcase pre-filtered for the site (Store category on sell,
1576                // the AI's inferred category elsewhere) instead of plain themes.php. The legacy design_selected/
1577                // design_completed ids consolidate onto site_theme_selected via wpcom_ai_launchpad_remap_task_id().
1578                $theme_showcase_path = 'site_theme_selected' === $task['id'] ? $theme_cta : null;
1579                $cta_override        = $this->get_cta_override( $task['id'] );
1580                if ( null !== $theme_showcase_path ) {
1581                    $calypso_path = $theme_showcase_path;
1582                } elseif ( null !== $cta_override ) {
1583                    $calypso_path = $cta_override;
1584                } else {
1585                    $calypso_path = wpcom_launchpad_checklists()->load_calypso_path( $definition );
1586                }
1587
1588                // Simple sites have no reachable wp-admin plugins screen; route any plugin-screen CTA to Calypso.
1589                $calypso_path = wpcom_ai_launchpad_to_simple_plugins_path( $calypso_path );
1590            }
1591
1592            $title       = isset( $definition['get_title'] ) ? $definition['get_title']() : '';
1593            $in_progress = false;
1594
1595            // A saved-but-unpublished draft (found by marker meta) puts a site-editor task "in progress": reopen that
1596            // draft instead of creating a new one, and surface the drafts icon + a "Continue…" prompt in the card.
1597            if ( ! $completed && ! $disabled ) {
1598                $draft_url = $this->get_in_progress_draft_url( $task['id'] );
1599                if ( null !== $draft_url ) {
1600                    $in_progress  = true;
1601                    $calypso_path = $draft_url;
1602                }
1603            }
1604
1605            // Title follows our precise in-progress signal so it, the icon, and the CTA agree.
1606            $title = $this->get_task_title( $task['id'], $in_progress, $title );
1607
1608            $seen_ids[ $task['id'] ] = true;
1609            $built[]                 = array(
1610                'id'           => $task['id'],
1611                'subtitle'     => $task['subtitle'],
1612                'title'        => $title,
1613                'completed'    => $completed,
1614                'in_progress'  => $in_progress,
1615                'disabled'     => $disabled,
1616                'calypso_path' => $calypso_path,
1617            );
1618        }
1619
1620        return $built;
1621    }
1622
1623    /**
1624     * The wp-admin CTA destination the AI Launchpad substitutes for a task's catalog path, or null to keep the catalog's.
1625     *
1626     * Static repoints live in CTA_OVERRIDES; `add_subscribe_block` is resolved here because its destination depends on
1627     * the active theme: the Site Editor is where a block theme adds the Subscribe block to a template (the action its
1628     * completion listener watches), and the block-based widget editor is the closest equivalent on a classic theme
1629     * (normally unreachable â€” the task's catalog visibility is FSE-only â€” but the theme can change after tailoring).
1630     *
1631     * @param string $task_id The catalog task id.
1632     * @return string|null
1633     */
1634    private function get_cta_override( $task_id ) {
1635        if ( 'add_subscribe_block' === $task_id ) {
1636            return admin_url( wp_is_block_theme() ? 'site-editor.php' : 'widgets.php' );
1637        }
1638
1639        if ( isset( self::CTA_OVERRIDES[ $task_id ] ) ) {
1640            return admin_url( self::CTA_OVERRIDES[ $task_id ] );
1641        }
1642
1643        return null;
1644    }
1645
1646    /**
1647     * The theme-showcase subject-category slugs (the `subject` taxonomy from /rest/v1.2/theme-filters).
1648     * Every category carries free themes, unlike free-text search, which surfaces mostly paid results.
1649     */
1650    const THEME_CATEGORIES = array(
1651        'blog',
1652        'portfolio',
1653        'business',
1654        'store',
1655        'art-design',
1656        'about',
1657        'real-estate',
1658        'health-wellness',
1659        'authors-writers',
1660        'newsletter',
1661        'education',
1662        'magazine',
1663        'music',
1664        'restaurant',
1665        'travel-lifestyle',
1666        'fashion-beauty',
1667        'community-non-profit',
1668        'podcast',
1669        'entertainment',
1670    );
1671
1672    /**
1673     * The wordpress.com themes-showcase path the theme-picker tasks should point at.
1674     *
1675     * Sell sites always land on the showcase's Store category so shop-ready templates lead; other goals get the
1676     * showcase pre-filtered by the AI's inferred category, re-checked against the allowlist since the envelope is
1677     * stored data. Without a valid category the plain showcase is returned (never null: the catalog CTA can resolve
1678     * to wp-admin's themes.php, which skips the showcase). The client's `toNavigableUrl` resolves the relative path
1679     * against wordpress.com.
1680     *
1681     * @param string $goal     The inferred goal.
1682     * @param string $category The AI's inferred theme_category slug.
1683     * @return string
1684     */
1685    private function get_themes_showcase_path( $goal, $category ) {
1686        if ( 'sell' === $goal ) {
1687            $category = 'store';
1688        }
1689
1690        if ( ! in_array( $category, self::THEME_CATEGORIES, true ) ) {
1691            return '/themes/' . rawurlencode( wpcom_get_site_slug() );
1692        }
1693
1694        return '/themes/filter/' . $category . '/' . rawurlencode( wpcom_get_site_slug() );
1695    }
1696
1697    /**
1698     * Ensures the persisted task list contains the theme-picker task, appending `site_theme_selected` when no
1699     * task already resolves to it. A design task that remaps onto it (via wpcom_ai_launchpad_remap_task_id)
1700     * counts as present, so a theme card is never duplicated.
1701     *
1702     * @param array $tasks The persisted `payload.tasks` array.
1703     * @return array
1704     */
1705    private function ensure_theme_task( $tasks ) {
1706        foreach ( $tasks as $task ) {
1707            if ( is_array( $task ) && isset( $task['id'] ) && is_string( $task['id'] )
1708                && 'site_theme_selected' === wpcom_ai_launchpad_remap_task_id( $task['id'] ) ) {
1709                return $tasks;
1710            }
1711        }
1712
1713        $tasks[] = array(
1714            'id'       => 'site_theme_selected',
1715            'subtitle' => __( 'Choose a theme that fits your store.', 'jetpack-mu-wpcom' ),
1716        );
1717
1718        return $tasks;
1719    }
1720
1721    /**
1722     * Moves the task with the given id to immediately after another task. The list is returned unchanged
1723     * unless both ids are present.
1724     *
1725     * @param array  $tasks    The built task list.
1726     * @param string $move_id  The id of the task to move.
1727     * @param string $after_id The id of the task to place it after.
1728     * @return array
1729     */
1730    private function move_task_after( $tasks, $move_id, $after_id ) {
1731        $ids  = array_column( $tasks, 'id' );
1732        $from = array_search( $move_id, $ids, true );
1733        if ( false === $from || false === array_search( $after_id, $ids, true ) ) {
1734            return $tasks;
1735        }
1736
1737        $moved = array_splice( $tasks, $from, 1 );
1738        // Recompute the anchor: extracting an earlier element shifts it left by one.
1739        $to = array_search( $after_id, array_column( $tasks, 'id' ), true );
1740        array_splice( $tasks, $to + 1, 0, $moved );
1741
1742        return $tasks;
1743    }
1744
1745    /**
1746     * Builds the synthetic store-setup lead tasks for the sell goal: an "install WooCommerce" task and a "set up
1747     * your store" task. Their ids are listed in SYNTHETIC_TASK_IDS so the tasks stay skippable.
1748     *
1749     * Both are read live (installed/active/profiler options), so no marker or listener is needed. While WooCommerce
1750     * is inactive the setup task shows as a disabled preview, matching the disabled commerce tasks below it. Callers
1751     * gate this on the sell goal.
1752     *
1753     * @param bool $active Whether WooCommerce is active.
1754     * @return array The lead tasks in display order.
1755     */
1756    private function build_store_tasks( $active ) {
1757        return array(
1758            $this->build_install_woocommerce_task( $active ),
1759            $this->build_setup_store_task( $active ),
1760        );
1761    }
1762
1763    /**
1764     * The "Install the WooCommerce plugin" lead task: to-do until the plugin exists, in-progress while it is
1765     * installed-but-inactive, and complete once active.
1766     *
1767     * @param bool $active Whether WooCommerce is active.
1768     * @return array
1769     */
1770    private function build_install_woocommerce_task( $active ) {
1771        $in_progress = ! $active && array_key_exists( 'woocommerce/woocommerce.php', get_plugins() );
1772
1773        $calypso_path = null;
1774        if ( ! $active ) {
1775            // Installed-but-inactive activates from the plugins list; not-installed installs from the plugin search.
1776            // On Simple both wp-admin screens are unreachable, so route through the Calypso WooCommerce plugin page.
1777            $wp_admin_path = $in_progress
1778                ? admin_url( 'plugins.php?plugin_status=inactive' )
1779                : admin_url( 'plugin-install.php?s=woocommerce&tab=search&type=term' );
1780            $calypso_path  = wpcom_ai_launchpad_to_simple_plugins_path( $wp_admin_path, 'woocommerce' );
1781        }
1782
1783        return array(
1784            'id'           => 'install_woocommerce',
1785            'subtitle'     => $in_progress
1786                ? __( 'Activate the WooCommerce plugin to continue.', 'jetpack-mu-wpcom' )
1787                : __( 'Add the WooCommerce plugin to start selling.', 'jetpack-mu-wpcom' ),
1788            'title'        => __( 'Install the WooCommerce plugin', 'jetpack-mu-wpcom' ),
1789            'completed'    => $active,
1790            'in_progress'  => $in_progress,
1791            'disabled'     => false,
1792            'calypso_path' => $calypso_path,
1793        );
1794    }
1795
1796    /**
1797     * The "Set up your store" lead task: to-do until the WooCommerce setup wizard (core profiler) is completed or
1798     * skipped, then complete. Shown as a disabled preview until WooCommerce is active, since the wizard needs it.
1799     *
1800     * @param bool $active Whether WooCommerce is active.
1801     * @return array
1802     */
1803    private function build_setup_store_task( $active ) {
1804        $profile   = (array) get_option( 'woocommerce_onboarding_profile', array() );
1805        $completed = $active && ( ! empty( $profile['completed'] ) || ! empty( $profile['skipped'] ) );
1806
1807        return array(
1808            'id'           => 'setup_woocommerce_store',
1809            'subtitle'     => __( 'Complete or skip the WooCommerce setup wizard.', 'jetpack-mu-wpcom' ),
1810            'title'        => __( 'Set up your store', 'jetpack-mu-wpcom' ),
1811            'completed'    => $completed,
1812            'in_progress'  => false,
1813            'disabled'     => ! $active,
1814            'calypso_path' => $completed || ! $active ? null : admin_url( 'admin.php?page=wc-admin&path=%2Fsetup-wizard' ),
1815        );
1816    }
1817
1818    /**
1819     * Inserts a task immediately before the trailing launch task (or appends it), idempotently by id.
1820     *
1821     * @param array $tasks The enriched task list.
1822     * @param array $task  The task entry to insert.
1823     * @return array
1824     */
1825    private function insert_before_launch_task( $tasks, $task ) {
1826        foreach ( $tasks as $existing ) {
1827            if ( isset( $existing['id'] ) && $existing['id'] === $task['id'] ) {
1828                return $tasks;
1829            }
1830        }
1831
1832        $insert_at = count( $tasks );
1833        foreach ( $tasks as $index => $existing ) {
1834            if ( isset( $existing['id'] ) && in_array( $existing['id'], self::LAUNCH_TASK_IDS, true ) ) {
1835                $insert_at = $index;
1836                break;
1837            }
1838        }
1839
1840        array_splice( $tasks, $insert_at, 0, array( $task ) );
1841        return $tasks;
1842    }
1843
1844    /**
1845     * Resolves the editor URL of a site-editor task's in-progress draft, or null when there is none.
1846     *
1847     * The About page is found by its marker meta; the first-post tasks by the latest draft post. Returned as an
1848     * `admin_url()` so the client reopens the existing draft rather than creating a duplicate.
1849     *
1850     * @param string $task_id The catalog task id.
1851     * @return string|null
1852     */
1853    private function get_in_progress_draft_url( $task_id ) {
1854        $draft_id = null;
1855
1856        if ( 'add_about_page' === $task_id ) {
1857            $draft_id = AI_Launchpad_About_Page_Listener::get_draft_id();
1858        } elseif ( in_array( $task_id, self::IN_PROGRESS_FIRST_POST_TASK_IDS, true ) ) {
1859            $draft_id = AI_Launchpad_First_Post_Listener::get_draft_id();
1860        }
1861
1862        if ( null === $draft_id ) {
1863            return null;
1864        }
1865
1866        return admin_url( 'post.php?post=' . $draft_id . '&action=edit' );
1867    }
1868
1869    /**
1870     * The card title for a site-editor task, chosen by our precise (marker-based) in-progress signal so the title,
1871     * icon, and CTA stay in agreement.
1872     *
1873     * This overrides `first_post_published`'s catalog title in both states: the catalog swaps it to "Continue…"
1874     * whenever ANY draft exists (a looser signal than our marker), so an unrelated draft would otherwise show a
1875     * "Continue…" title beside the not-started icon. Tasks not listed keep their catalog title.
1876     *
1877     * @param string $task_id     The catalog task id.
1878     * @param bool   $in_progress Whether our marker detected an in-progress draft.
1879     * @param string $default     The catalog-provided title, kept when we don't override.
1880     * @return string
1881     */
1882    private function get_task_title( $task_id, $in_progress, $default ) {
1883        switch ( $task_id ) {
1884            case 'add_about_page':
1885                return $in_progress ? __( 'Continue working on the About page', 'jetpack-mu-wpcom' ) : $default;
1886            case 'first_post_published':
1887                return $in_progress
1888                    ? __( 'Continue to write your first post', 'jetpack-mu-wpcom' )
1889                    : __( 'Write your first post', 'jetpack-mu-wpcom' );
1890            default:
1891                return $default;
1892        }
1893    }
1894
1895    /**
1896     * Loads the agent output schema used to validate `PUT /tailored` bodies.
1897     *
1898     * @return array
1899     */
1900    private function get_output_schema() {
1901        static $schema = null;
1902
1903        if ( null === $schema ) {
1904            $schema = json_decode( file_get_contents( __DIR__ . '/contracts/agent-output-schema.json' ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local package file.
1905        }
1906
1907        return $schema;
1908    }
1909}
1910
1911// @phan-suppress-next-line PhanNoopNew -- instantiated for the constructor's add_action side effect.
1912new AI_Launchpad_REST();