Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.20% covered (success)
97.20%
660 / 679
77.50% covered (warning)
77.50%
31 / 40
CRAP
0.00% covered (danger)
0.00%
0 / 1
AI_Launchpad_REST
97.35% covered (success)
97.35%
660 / 678
77.50% covered (warning)
77.50%
31 / 40
199
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%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 register_routes
100.00% covered (success)
100.00%
146 / 146
100.00% covered (success)
100.00%
1 / 1
1
 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%
26 / 26
100.00% covered (success)
100.00%
1 / 1
9
 get_current_tasks
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
18.15
 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%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 maybe_mark_completed
94.12% covered (success)
94.12%
16 / 17
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%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 update_tailored
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
1 / 1
9
 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%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 complete_task
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 skip_task
100.00% covered (success)
100.00%
17 / 17
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
 available_task_ids
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
 build_all_catalog_tasks
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
5
 build_tasks
94.83% covered (success)
94.83%
55 / 58
0.00% covered (danger)
0.00%
0 / 1
24.08
 get_cta_override
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 to_simple_plugins_path
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
7
 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
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 should_offer_gallery_task
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
 build_gallery_task
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
4
 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%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 get_task_title
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
8
 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 $$next-version$$
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    const MIN_VALID_TASKS = 4;
23
24    // `woo_launch_site`, `link_in_bio_launched`, and `videopress_launched` stay valid launch tasks so a stray AI
25    // emission passes PUT validation and is normalized to `site_launched` on read (see build_tasks), rather than
26    // failing the whole list into the deterministic fallback.
27    const LAUNCH_TASK_IDS = array( 'site_launched', 'blog_launched', 'woo_launch_site', 'link_in_bio_launched', 'videopress_launched' );
28
29    /**
30     * Tasks the AI Launchpad marks complete on CTA click, because their real signal is unreachable from wp-admin.
31     *
32     * Server-side allowlist so the complete-task route can only tick these ids. Mirrored client-side in model.ts.
33     */
34    const COMPLETE_ON_CLICK_TASK_IDS = array(
35        'complete_profile',
36        'manage_subscribers',
37        'manage_paid_newsletter_plan',
38        'earn_money',
39        'start_building_your_audience',
40        'site_monitoring_page',
41        'setup_ssh',
42        'share_site',
43    );
44
45    /**
46     * Task ids the AI Launchpad synthesizes itself (never present in the AI payload): the sell goal's store-setup
47     * lead tasks and the niche-gated gallery task. Skips must accept them alongside the AI-selected ids.
48     *
49     * Must list every id minted by build_store_tasks() / build_gallery_task() â€” a synthetic task missing here
50     * renders with a Skip button whose write is rejected.
51     */
52    const SYNTHETIC_TASK_IDS = array(
53        'install_woocommerce',
54        'setup_woocommerce_store',
55        'add_gallery_page',
56    );
57
58    /**
59     * Tasks whose catalog visibility gate is a false negative in this read path, so the AI Launchpad overrides it.
60     *
61     * `add_10_email_subscribers` is gated off WordPress.com, but AI_Launchpad_Subscribers_Listener reads the count on
62     * Atomic, so the task must still render. `add_about_page` is gated on the `_wpcom_template_layout_category`
63     * page-meta key being registered, which does not happen during a REST request, so the task is wrongly hidden even
64     * though its "add a page" CTA works â€” force it visible so tailoring can offer this genuinely useful task.
65     */
66    const FORCE_VISIBLE_TASK_IDS = array(
67        'add_10_email_subscribers',
68        'add_about_page',
69    );
70
71    /**
72     * Commerce tasks whose catalog visibility gate requires WooCommerce to be active.
73     *
74     * On a fresh sell site these would be dropped, collapsing the list. Instead the sell branch keeps them as a
75     * disabled preview of the store roadmap until WooCommerce is active. See build_tasks()'s $disable_hidden_woo mode.
76     */
77    const WOO_TASK_IDS = array(
78        'woo_customize_store',
79        'woo_products',
80        'set_up_payments',
81    );
82
83    /**
84     * CTA destinations the AI Launchpad repoints to wp-admin, keyed by task id, each mapping to an `admin_url()` path.
85     *
86     * The catalog sends these to Calypso flows that are a poor fit for wp-admin. Overridden on read so the shared
87     * catalog (used by the legacy launchpad too) is left untouched.
88     */
89    const CTA_OVERRIDES = array(
90        'connect_social_media' => 'admin.php?page=jetpack-social',
91    );
92
93    /**
94     * Jetpack Social tasks, hidden on private sites where wpcom does not load Publicize (so their CTA page would 404).
95     * `drive_traffic` needs no entry: it remaps onto `connect_social_media` before this gate runs.
96     */
97    const SOCIAL_PAGE_TASK_IDS = array(
98        'connect_social_media',
99    );
100
101    /**
102     * First-post tasks that can sit "in progress": the AI-created draft post exists but has not been published yet.
103     *
104     * Detected through the `_wpcom_ai_launchpad_first_post` marker meta (via AI_Launchpad_First_Post_Listener), so an
105     * unrelated pre-existing draft never counts. Paired with `add_about_page`, which has its own marker meta.
106     * `first_post_published_newsletter` needs no entry: it remaps onto `first_post_published` before this runs.
107     */
108    const IN_PROGRESS_FIRST_POST_TASK_IDS = array(
109        'first_post_published',
110    );
111
112    /**
113     * Whether the site's visibility is set to private (`blog_public = -1`).
114     *
115     * Read directly to avoid a hard dependency on the Status package in this read path.
116     *
117     * @return bool
118     */
119    private function is_private_site() {
120        return '-1' === (string) get_option( 'blog_public' );
121    }
122
123    /**
124     * Class constructor.
125     */
126    public function __construct() {
127        $this->namespace = 'wpcom/v2';
128        $this->rest_base = 'ai-launchpad';
129
130        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
131    }
132
133    /**
134     * Register our routes.
135     */
136    public function register_routes() {
137        register_rest_route(
138            $this->namespace,
139            $this->rest_base,
140            array(
141                array(
142                    'methods'             => WP_REST_Server::READABLE,
143                    'callback'            => array( $this, 'get_data' ),
144                    'permission_callback' => array( $this, 'can_read' ),
145                    'args'                => array(
146                        // Testing aid: render the full task catalog so every task can be exercised from one site.
147                        'all_tasks' => array(
148                            'description' => 'Return the full task catalog instead of the tailored list (testing aid).',
149                            'type'        => 'boolean',
150                            'default'     => false,
151                        ),
152                    ),
153                ),
154                array(
155                    'methods'             => WP_REST_Server::DELETABLE,
156                    'callback'            => array( $this, 'dismiss' ),
157                    'permission_callback' => array( $this, 'can_write' ),
158                ),
159            )
160        );
161
162        register_rest_route(
163            $this->namespace,
164            $this->rest_base . '/wizard',
165            array(
166                array(
167                    'methods'             => 'PUT',
168                    'callback'            => array( $this, 'update_wizard' ),
169                    'permission_callback' => array( $this, 'can_write' ),
170                    'args'                => array(
171                        'goal'        => array(
172                            'description' => 'The site goal picked in the wizard.',
173                            'type'        => 'string',
174                            'enum'        => array( 'write', 'build', 'sell', 'newsletter', 'educate', 'portfolio' ),
175                            'required'    => true,
176                        ),
177                        'site_name'   => array(
178                            'description'       => 'The site name entered in the wizard.',
179                            'type'              => 'string',
180                            'required'          => true,
181                            'sanitize_callback' => 'sanitize_text_field',
182                        ),
183                        'description' => array(
184                            'description'       => 'The free-text site description entered in the wizard.',
185                            'type'              => 'string',
186                            'required'          => true,
187                            'sanitize_callback' => 'sanitize_textarea_field',
188                        ),
189                        'locale'      => array(
190                            'description'       => 'The user locale.',
191                            'type'              => 'string',
192                            'default'           => 'en',
193                            'sanitize_callback' => 'sanitize_text_field',
194                        ),
195                    ),
196                ),
197            )
198        );
199
200        register_rest_route(
201            $this->namespace,
202            $this->rest_base . '/available-tasks',
203            array(
204                array(
205                    'methods'             => WP_REST_Server::READABLE,
206                    'callback'            => array( $this, 'get_available_tasks' ),
207                    'permission_callback' => array( $this, 'can_read' ),
208                    'args'                => array(
209                        'goal' => array(
210                            'description'       => 'The selected goal; sell keeps commerce tasks as available previews.',
211                            'type'              => 'string',
212                            'default'           => '',
213                            'sanitize_callback' => 'sanitize_key',
214                        ),
215                    ),
216                ),
217            )
218        );
219
220        register_rest_route(
221            $this->namespace,
222            $this->rest_base . '/complete-task',
223            array(
224                array(
225                    'methods'             => 'POST',
226                    'callback'            => array( $this, 'complete_task' ),
227                    'permission_callback' => array( $this, 'can_write' ),
228                    'args'                => array(
229                        'task_id' => array(
230                            'description'       => 'The acknowledgment task to mark complete.',
231                            'type'              => 'string',
232                            'required'          => true,
233                            'sanitize_callback' => 'sanitize_key',
234                        ),
235                    ),
236                ),
237            )
238        );
239
240        register_rest_route(
241            $this->namespace,
242            $this->rest_base . '/skip-task',
243            array(
244                array(
245                    'methods'             => 'POST',
246                    'callback'            => array( $this, 'skip_task' ),
247                    'permission_callback' => array( $this, 'can_write' ),
248                    'args'                => array(
249                        'task_id' => array(
250                            'description'       => 'The task to skip.',
251                            'type'              => 'string',
252                            'required'          => true,
253                            'sanitize_callback' => 'sanitize_key',
254                        ),
255                    ),
256                ),
257            )
258        );
259
260        register_rest_route(
261            $this->namespace,
262            $this->rest_base . '/tailored',
263            array(
264                array(
265                    'methods'             => 'PUT',
266                    'callback'            => array( $this, 'update_tailored' ),
267                    'permission_callback' => array( $this, 'can_write' ),
268                    'args'                => array(
269                        'source'      => array(
270                            'description' => 'Whether the payload came from the AI or the deterministic fallback. Query parameter; the JSON body must match the agent output schema exactly.',
271                            'type'        => 'string',
272                            'enum'        => array( 'ai', 'fallback' ),
273                            'default'     => 'ai',
274                        ),
275                        'duration_ms' => array(
276                            'description' => 'Client-measured tailoring duration in milliseconds, for the tailored Logstash record.',
277                            'type'        => 'integer',
278                            'minimum'     => 0,
279                        ),
280                        'attempts'    => array(
281                            'description' => 'How many jetpack-ai-query attempts the client made, for the tailored Logstash record.',
282                            'type'        => 'integer',
283                            'minimum'     => 0,
284                        ),
285                    ),
286                ),
287            )
288        );
289    }
290
291    /**
292     * Permission callback for reads.
293     *
294     * @return true|WP_Error|false
295     */
296    public function can_read() {
297        if ( ! current_user_can( 'edit_posts' ) ) {
298            return false;
299        }
300
301        return $this->check_eligibility();
302    }
303
304    /**
305     * Permission callback for writes.
306     *
307     * @return true|WP_Error|false
308     */
309    public function can_write() {
310        if ( ! current_user_can( 'manage_options' ) ) {
311            return false;
312        }
313
314        return $this->check_eligibility();
315    }
316
317    /**
318     * Returns a 404 error for ineligible sites, true otherwise.
319     *
320     * @return true|WP_Error
321     */
322    private function check_eligibility() {
323        // Fail closed: if the gate is unavailable, treat the site as not eligible.
324        if ( ! function_exists( 'wpcom_ai_launchpad_is_eligible' ) || ! wpcom_ai_launchpad_is_eligible() ) {
325            return new WP_Error(
326                'ai_launchpad_not_eligible',
327                __( 'This site is not eligible for the AI Launchpad.', 'jetpack-mu-wpcom' ),
328                array( 'status' => 404 )
329            );
330        }
331
332        return true;
333    }
334
335    /**
336     * Composite read: wizard payload, AI output, enriched tasks, statuses, and eligibility.
337     *
338     * @param WP_REST_Request|null $request Request object (for the `all_tasks` testing param).
339     * @return array
340     */
341    public function get_data( $request = null ) {
342        $wizard    = get_option( self::OPTION_WIZARD );
343        $ai_output = get_option( self::OPTION_AI_OUTPUT );
344        if ( is_array( $ai_output ) ) {
345            // Internal analytics bookkeeping (see report_task_completions), not part of the client contract.
346            unset( $ai_output['tracked_completed'] );
347        }
348
349        // Testing aid: ?all_tasks=1 renders the whole catalog, independent of the persisted tailored output.
350        if ( $request instanceof WP_REST_Request && $request->get_param( 'all_tasks' ) ) {
351            $tasks = $this->apply_skipped_tasks( $this->build_all_catalog_tasks() );
352        } else {
353            $tasks = $this->get_current_tasks();
354        }
355
356        // The membership tasks' completion is recomputed in build_tasks(), so overlay it to keep
357        // checklist_statuses consistent with tasks[].completed for them.
358        $checklist_statuses = (array) get_option( 'launchpad_checklist_tasks_statuses', array() );
359        foreach ( $tasks as $task ) {
360            if ( AI_Launchpad_Memberships::has_override( $task['id'] ) ) {
361                $checklist_statuses[ $task['id'] ] = $task['completed'];
362            }
363        }
364
365        $this->maybe_mark_completed();
366
367        return array(
368            'wizard'             => is_array( $wizard ) ? $wizard : null,
369            'ai_output'          => is_array( $ai_output ) ? $ai_output : null,
370            'tasks'              => $tasks,
371            'checklist_statuses' => $checklist_statuses,
372            'dismissed'          => (bool) get_option( self::OPTION_DISMISSED, false ),
373            'is_eligible'        => true,
374            // Site context the client needs for the launch-task CTA, the preview thumbnail/title, and wizard prefill.
375            'site'               => array(
376                'url'         => home_url(),
377                'title'       => get_bloginfo( 'name' ),
378                'description' => get_bloginfo( 'description' ),
379                // Block themes open the Site Editor; classic themes fall back to the Customizer.
380                'edit_url'    => wp_is_block_theme() ? admin_url( 'site-editor.php' ) : admin_url( 'customize.php' ),
381            ),
382        );
383    }
384
385    /**
386     * The site's tailored task list (AI-selected + synthetic store/gallery tasks, skip overlay applied) â€” the tasks
387     * GET renders, minus the ?all_tasks testing view. Shared by GET and the completion check.
388     *
389     * @return array
390     */
391    public function get_current_tasks() {
392        $ai_output = get_option( self::OPTION_AI_OUTPUT );
393
394        // Guard the nested payload: partial/failed writes may leave the option without payload.tasks.
395        $payload = is_array( $ai_output ) && isset( $ai_output['payload'] ) && is_array( $ai_output['payload'] )
396            ? $ai_output['payload']
397            : array();
398        // Validate `inferred` as an array before reading from it, since a partial write could leave it non-array.
399        $inferred       = isset( $payload['inferred'] ) && is_array( $payload['inferred'] ) ? $payload['inferred'] : array();
400        $theme_category = isset( $inferred['theme_category'] ) && is_string( $inferred['theme_category'] )
401            ? $inferred['theme_category']
402            : '';
403
404        $goal = isset( $inferred['goal'] ) && is_string( $inferred['goal'] ) ? $inferred['goal'] : '';
405
406        if ( ! function_exists( 'is_plugin_active' ) ) {
407            require_once ABSPATH . 'wp-admin/includes/plugin.php';
408        }
409        $woo_active = is_plugin_active( 'woocommerce/woocommerce.php' );
410
411        // On a sell site without WooCommerce, keep the gated commerce tasks as a disabled preview of the store
412        // roadmap instead of dropping them (which would collapse the list to almost nothing).
413        $disable_hidden_woo = 'sell' === $goal && ! $woo_active;
414
415        $theme_cta = $this->get_themes_showcase_path( $goal, $theme_category );
416
417        $ai_tasks = isset( $payload['tasks'] ) && is_array( $payload['tasks'] ) ? $payload['tasks'] : array();
418
419        // A store needs a theme, so the sell list always offers "Choose a theme" â€” the AI is not required to pick
420        // one. Add it when absent so build_tasks enriches it like any catalog task (get_ai_task_ids mirrors this).
421        if ( 'sell' === $goal ) {
422            $ai_tasks = $this->ensure_theme_task( $ai_tasks );
423        }
424
425        $tasks = empty( $ai_tasks ) ? array() : $this->build_tasks( $ai_tasks, false, $theme_cta, $disable_hidden_woo );
426
427        // The sell goal leads with the store-setup task; every other goal may offer the gallery task. They are
428        // mutually exclusive so a sell site with a visual niche doesn't also get an off-target gallery task.
429        if ( 'sell' === $goal ) {
430            // The store-setup tasks lead the sell list. Their synthetic ids never appear in the AI payload, so a
431            // plain prepend is safe. The theme task then follows them: pick the store's look once the store exists.
432            $tasks = array_merge( $this->build_store_tasks( $woo_active ), $tasks );
433            $tasks = $this->move_task_after( $tasks, 'site_theme_selected', 'setup_woocommerce_store' );
434        } else {
435            $gallery = $this->build_gallery_task( $inferred );
436            if ( null !== $gallery ) {
437                $tasks = $this->insert_before_launch_task( $tasks, $gallery );
438            }
439        }
440
441        // Restore the list toward six after the visibility gate has dropped tasks (before skips, which are the
442        // user's own removals). No-op on an empty list so the wizard still runs when there is no AI output.
443        $tasks = $this->backfill_to_minimum( $tasks, $theme_cta, $disable_hidden_woo );
444
445        return $this->apply_skipped_tasks( $tasks );
446    }
447
448    /**
449     * Tops a short rendered list back up toward the six tasks the AI is asked to return.
450     *
451     * The catalog visibility gate in build_tasks() drops any task it hides on this site (e.g. add_about_page needs a
452     * page-template meta key that is absent during a REST request) with no replacement, so a gate-heavy AI pick can
453     * collapse the list to two or three cards. This backfills from a small pool of broadly-useful tasks and keeps the
454     * launch task last. Candidates are built one at a time, stopping at the target, so the tail of the pool is only
455     * evaluated when the earlier fillers were not enough â€” mobile_app_installed's completion check does a remote
456     * lookup while incomplete, which the common short-by-one list never has to pay for. Backfilled cards are
457     * skippable (see skip_task); a fuller, AI-ranked overflow pool is the eventual replacement.
458     *
459     * @param array  $tasks              The rendered task list, already gated, launch task last.
460     * @param string $theme_cta          Pre-resolved themes-showcase CTA passed through to build_tasks().
461     * @param bool   $disable_hidden_woo Whether hidden commerce tasks render as a disabled preview.
462     * @return array
463     */
464    private function backfill_to_minimum( $tasks, $theme_cta, $disable_hidden_woo ) {
465        $target = 6;
466        if ( count( $tasks ) >= $target || empty( $tasks ) ) {
467            return $tasks;
468        }
469
470        foreach ( $this->backfill_pool() as $id => $subtitle ) {
471            if ( count( $tasks ) >= $target ) {
472                break;
473            }
474            $present = array_column( $tasks, 'id' );
475            if ( in_array( $id, $present, true ) ) {
476                continue;
477            }
478
479            // build_tasks() applies the same gating and remap the AI list gets, so a pool task the site
480            // hides simply does not appear.
481            $built = $this->build_tasks(
482                array(
483                    array(
484                        'id'       => $id,
485                        'subtitle' => $subtitle,
486                    ),
487                ),
488                false,
489                $theme_cta,
490                $disable_hidden_woo
491            );
492            if ( empty( $built ) ) {
493                continue;
494            }
495            $task = $built[0];
496            // A filler card that is already complete offers nothing to do; better a shorter list. The remap
497            // inside build_tasks() can also land the card on an id already present â€” skip that too.
498            if ( ! empty( $task['completed'] ) || in_array( $task['id'], $present, true ) ) {
499                continue;
500            }
501            $tasks = $this->insert_before_launch_task( $tasks, $task );
502        }
503
504        return $tasks;
505    }
506
507    /**
508     * The ordered id => subtitle pool the short-list backfill draws from: broadly-useful tasks that render on most
509     * sites and are skippable, most-broadly-applicable first. Each has a distinct card title, and none duplicate work
510     * the wizard already did (e.g. no site-title task â€” the wizard captured the name). Excludes tasks whose completion
511     * depends on the AI-task list (the theme/social listeners, the complete-on-click route), since a backfilled card is
512     * not on that list. skip_task() reads the ids here to keep every backfilled card skippable, so the set lives here.
513     *
514     * @return array<string, string>
515     */
516    private function backfill_pool() {
517        return array(
518            'design_edited'        => __( 'Make the design your own.', 'jetpack-mu-wpcom' ),
519            'add_new_page'         => __( 'Add a page your visitors will want, like About or Contact.', 'jetpack-mu-wpcom' ),
520            'connect_social_media' => __( 'Connect your social accounts to reach more people.', 'jetpack-mu-wpcom' ),
521            'mobile_app_installed' => __( 'Manage your site from anywhere with the Jetpack app.', 'jetpack-mu-wpcom' ),
522        );
523    }
524
525    /**
526     * Runs the completion pass: reports newly-completed tasks to Tracks, then latches OPTION_COMPLETED (and records
527     * the all-tasks-completed event) the first time the list is fully done. Called on every path that can finish a
528     * task (read, skip, complete-on-click); the already-set check skips the rebuild once latched.
529     *
530     * @return void
531     */
532    private function maybe_mark_completed() {
533        if ( get_option( self::OPTION_COMPLETED ) ) {
534            return;
535        }
536
537        // No AI output means the wizard hasn't produced a list yet: nothing to report or latch.
538        if ( ! get_option( self::OPTION_AI_OUTPUT ) ) {
539            return;
540        }
541
542        $tasks = $this->get_current_tasks();
543        // An empty list is not "complete" â€” the wizard still needs to run.
544        if ( empty( $tasks ) ) {
545            return;
546        }
547
548        $this->report_task_completions( $tasks );
549
550        foreach ( $tasks as $task ) {
551            // A skip coerces `completed` true; a disabled preview task stays incomplete until its prerequisite is met.
552            if ( empty( $task['completed'] ) ) {
553                return;
554            }
555        }
556
557        update_option( self::OPTION_COMPLETED, true, true );
558        // After the per-task reports above, so any task_completed reports precede it (a list finished
559        // purely by skips or born-completed tasks latches with none).
560        wpcom_ai_launchpad_record_tracks_event(
561            'jetpack_ai_launchpad_all_tasks_completed',
562            array(),
563            array_column( $tasks, 'id' )
564        );
565    }
566
567    /**
568     * Records a `task_completed` Tracks event for every rendered task that newly reads as completed, whatever
569     * completed it (client click, PHP listener, or live recomputation Ã  la Woo/domains/memberships) â€” the only
570     * uniform signal is the rendered list itself, so completions are diffed on read.
571     *
572     * The already-reported ids are embedded in the existing AI-output envelope (`tracked_completed`) rather than a
573     * new option; a re-tailor rewrites the envelope, which re-baselines the set at the same moment the skip/completed
574     * options reset. PUT /tailored seeds the key with the born-completed tasks, so a pass here only ever reports
575     * user-triggered completions; an envelope persisted before the key existed gets the same silent baseline on its
576     * first pass. Skipped tasks render as completed but already emit `task_skipped`, so they are excluded (and never
577     * reported later).
578     *
579     * @param array $tasks The current rendered tasks.
580     * @return void
581     */
582    private function report_task_completions( $tasks ) {
583        $ai_output = get_option( self::OPTION_AI_OUTPUT );
584        if ( ! is_array( $ai_output ) ) {
585            return;
586        }
587
588        $skipped   = $this->get_skipped_task_ids();
589        $completed = array();
590        foreach ( $tasks as $task ) {
591            if ( ! empty( $task['completed'] ) && ! in_array( $task['id'], $skipped, true ) ) {
592                $completed[] = $task['id'];
593            }
594        }
595
596        // null = no key yet, i.e. the baseline pass right after tailoring. Remapped like the skip overlay: a
597        // baseline recorded under a since-remapped id must keep covering the id its card renders as now, or the
598        // same completion would be re-reported once under the new id.
599        $reported = isset( $ai_output['tracked_completed'] ) && is_array( $ai_output['tracked_completed'] )
600            ? array_values( array_unique( array_map( 'wpcom_ai_launchpad_remap_task_id', $ai_output['tracked_completed'] ) ) )
601            : null;
602        $newly    = array_values( array_diff( $completed, $reported ?? array() ) );
603
604        if ( null !== $reported ) {
605            if ( empty( $newly ) ) {
606                return;
607            }
608            $rendered_ids = array_column( $tasks, 'id' );
609            foreach ( $newly as $task_id ) {
610                wpcom_ai_launchpad_record_tracks_event(
611                    'jetpack_ai_launchpad_task_completed',
612                    array( 'task_id' => $task_id ),
613                    $rendered_ids
614                );
615            }
616        }
617
618        $ai_output['tracked_completed'] = array_merge( $reported ?? array(), $newly );
619        update_option( self::OPTION_AI_OUTPUT, $ai_output, false );
620    }
621
622    /**
623     * Persists the wizard input and writes the entered Name and Brief description back to blogname / blogdescription.
624     *
625     * Empty values are skipped so the wizard never blanks an existing title or tagline.
626     *
627     * @param WP_REST_Request $request Request object.
628     * @return array
629     */
630    public function update_wizard( $request ) {
631        $wizard = array(
632            'version'      => 1,
633            'goal'         => $request['goal'],
634            'site_name'    => $request['site_name'],
635            'description'  => $request['description'],
636            'locale'       => $request['locale'],
637            'generated_at' => time(),
638        );
639
640        update_option( self::OPTION_WIZARD, $wizard, false );
641
642        if ( '' !== trim( (string) $request['site_name'] ) ) {
643            update_option( 'blogname', $request['site_name'] );
644        }
645        if ( '' !== trim( (string) $request['description'] ) ) {
646            // Collapse the textarea brief's newlines to keep the inline-rendered tagline single-line.
647            update_option( 'blogdescription', sanitize_text_field( $request['description'] ) );
648        }
649
650        return array( 'wizard' => $wizard );
651    }
652
653    /**
654     * Validates the AI output payload against the agent output schema, wraps it, and persists it.
655     *
656     * @param WP_REST_Request $request Request object.
657     * @return array|WP_Error
658     */
659    public function update_tailored( $request ) {
660        $payload = $request->get_json_params();
661
662        $validation = rest_validate_value_from_schema( $payload, $this->get_output_schema(), 'payload' );
663        if ( is_wp_error( $validation ) ) {
664            return new WP_Error( 'ai_launchpad_invalid_payload', $validation->get_error_message(), array( 'status' => 422 ) );
665        }
666
667        $last_task = end( $payload['tasks'] );
668        if ( ! in_array( $last_task['id'], self::LAUNCH_TASK_IDS, true ) ) {
669            return new WP_Error(
670                'ai_launchpad_missing_launch_task',
671                __( 'The last task must be a launch task.', 'jetpack-mu-wpcom' ),
672                array( 'status' => 422 )
673            );
674        }
675
676        // The AI's raw picks, captured before the unknown-id filter below so hallucinated ids stay observable.
677        $raw_task_ids = array_column( $payload['tasks'], 'id' );
678
679        $definitions = wpcom_launchpad_get_task_definitions();
680        $tasks       = array();
681
682        foreach ( $payload['tasks'] as $task ) {
683            if ( ! isset( $definitions[ $task['id'] ] ) ) {
684                continue;
685            }
686
687            $subtitle = $this->sanitize_subtitle( $task['subtitle'] );
688            if ( is_wp_error( $subtitle ) ) {
689                return $subtitle;
690            }
691
692            $tasks[] = array(
693                'id'       => $task['id'],
694                'subtitle' => $subtitle,
695            );
696        }
697
698        if ( count( $tasks ) < self::MIN_VALID_TASKS ) {
699            return new WP_Error(
700                'ai_launchpad_unknown_tasks',
701                __( 'Too few tasks matched the task catalog.', 'jetpack-mu-wpcom' ),
702                array( 'status' => 422 )
703            );
704        }
705
706        $payload['tasks'] = $tasks;
707
708        $ai_output = array(
709            'version'      => 1,
710            'source'       => $request['source'],
711            'generated_at' => time(),
712            'payload'      => $payload,
713        );
714
715        update_option( self::OPTION_AI_OUTPUT, $ai_output, false );
716
717        // A fresh list must not inherit the previous one's skips or "done" flag.
718        delete_option( self::OPTION_SKIPPED );
719        delete_option( self::OPTION_COMPLETED );
720
721        // Baseline the born-completed tasks now (needs the fresh options above), so completion
722        // reporting only ever emits for completions the user actually triggers afterwards.
723        $rendered_tasks = $this->get_current_tasks();
724        $baseline       = array();
725        foreach ( $rendered_tasks as $task ) {
726            if ( ! empty( $task['completed'] ) ) {
727                $baseline[] = $task['id'];
728            }
729        }
730        $ai_output['tracked_completed'] = $baseline;
731        update_option( self::OPTION_AI_OUTPUT, $ai_output, false );
732
733        // After the writes, so the observed rendered list is the fresh one.
734        $this->log_tailoring( $ai_output, $raw_task_ids, $request['duration_ms'], $request['attempts'], array_column( $rendered_tasks, 'id' ) );
735
736        // The analytics bookkeeping stays out of responses, mirroring get_data().
737        unset( $ai_output['tracked_completed'] );
738
739        return array( 'ai_output' => $ai_output );
740    }
741
742    /**
743     * Emits the tailoring observation event to Logstash, keyed `feature: atomic_ai_launchpad`, `message: tailored`
744     * (the public-api logstash endpoint whitelists features by their `atomic_` prefix â€” a bare feature name is
745     * rejected with a 400 on the Atomic HTTP dispatch path). Best-effort: logging must never fail the tailoring write.
746     *
747     * @param array         $ai_output    The persisted AI output envelope.
748     * @param string[]      $raw_task_ids The AI's selected ids before the unknown-id filter.
749     * @param int|null      $duration_ms  Client-measured tailoring duration, or null when not sent.
750     * @param int|null      $attempts     Client-reported jetpack-ai-query attempt count, or null when not sent.
751     * @param string[]|null $rendered_ids The rendered task ids, when the caller already computed them.
752     * @return void
753     */
754    private function log_tailoring( $ai_output, $raw_task_ids, $duration_ms = null, $attempts = null, $rendered_ids = null ) {
755        try {
756            /**
757             * Gates the tailoring observation event sent to Logstash. Checked before the event
758             * is built, so disabling it also skips the extra task-list rebuild the event needs.
759             *
760             * @param bool $enabled Whether to send the event. Default true.
761             */
762            if ( ! apply_filters( 'wpcom_ai_launchpad_tailoring_log_enabled', true ) ) {
763                return;
764            }
765
766            \Automattic\Jetpack\Jetpack_Mu_Wpcom::log2logstash(
767                'atomic_ai_launchpad',
768                'tailored',
769                $this->tailoring_log_extra( $ai_output, $raw_task_ids, $duration_ms, $attempts, $rendered_ids )
770            );
771        } catch ( \Throwable $e ) {
772            unset( $e );
773        }
774    }
775
776    /**
777     * The tailoring observation event: how the AI's output relates to what the site will actually render.
778     *
779     * Carries the inferred details, the AI's raw selected ids (pre-filter, so hallucinated ids are observable), the
780     * rendered ids, and their delta â€” `dropped` is what the unknown-id filter and the visibility gate removed, `added`
781     * is what synthetics and the backfill floor put in. The delta is diffed post-remap so a selected id that renders
782     * under its working equivalent does not read as a drop plus an addition. The raw wizard title/description are
783     * never included, and the inferred fields that can echo the user's own words near-verbatim are stripped:
784     * `brand_name` restates the title and `tagline` is drafted from the description.
785     *
786     * @param array         $ai_output    The persisted AI output envelope.
787     * @param string[]      $raw_task_ids The AI's selected ids before the unknown-id filter.
788     * @param int|null      $duration_ms  Client-measured tailoring duration, or null when not sent.
789     * @param int|null      $attempts     Client-reported jetpack-ai-query attempt count, or null when not sent.
790     * @param string[]|null $rendered_ids The rendered task ids, when the caller already computed them.
791     * @return array
792     */
793    private function tailoring_log_extra( $ai_output, $raw_task_ids, $duration_ms = null, $attempts = null, $rendered_ids = null ) {
794        // Schema-validated on the write path, so `inferred` is always present here.
795        $inferred = $ai_output['payload']['inferred'];
796        unset( $inferred['brand_name'], $inferred['tagline'] );
797
798        $rendered = $rendered_ids ?? array_column( $this->get_current_tasks(), 'id' );
799        $remapped = array_unique( array_map( 'wpcom_ai_launchpad_remap_task_id', $raw_task_ids ) );
800
801        $extra = array(
802            'source'   => $ai_output['source'],
803            'inferred' => $inferred,
804            'selected' => $raw_task_ids,
805            'rendered' => $rendered,
806            'dropped'  => array_values( array_diff( $remapped, $rendered ) ),
807            'added'    => array_values( array_diff( $rendered, $remapped ) ),
808        );
809
810        // Client-measured timing, replacing the retired ai_response_received Tracks event.
811        if ( null !== $duration_ms ) {
812            $extra['duration_ms'] = (int) $duration_ms;
813        }
814        if ( null !== $attempts ) {
815            $extra['attempts'] = (int) $attempts;
816        }
817
818        return $extra;
819    }
820
821    /**
822     * Marks an acknowledgment task complete when the user clicks its CTA, since these tasks have no wp-admin signal.
823     *
824     * Restricted to the COMPLETE_ON_CLICK_TASK_IDS allowlist and to tasks on the site's AI-selected list.
825     *
826     * @param WP_REST_Request $request Request object.
827     * @return array|WP_Error
828     */
829    public function complete_task( $request ) {
830        $task_id = $request['task_id'];
831
832        if ( ! in_array( $task_id, self::COMPLETE_ON_CLICK_TASK_IDS, true ) ) {
833            return new WP_Error(
834                'ai_launchpad_task_not_completable',
835                __( 'This task cannot be completed this way.', 'jetpack-mu-wpcom' ),
836                array( 'status' => 400 )
837            );
838        }
839
840        // Only tasks the AI put on this site's list may be completed.
841        if ( ! in_array( $task_id, wpcom_ai_launchpad_get_ai_task_ids(), true ) ) {
842            return new WP_Error(
843                'ai_launchpad_task_not_selected',
844                __( 'This task is not on the tailored list.', 'jetpack-mu-wpcom' ),
845                array( 'status' => 404 )
846            );
847        }
848
849        wpcom_mark_launchpad_task_complete( $task_id );
850
851        // Latch now so completing the last task hides the menu on the next page load, not just on the next read.
852        $this->maybe_mark_completed();
853
854        return array(
855            'completed' => true,
856            'task_id'   => $task_id,
857        );
858    }
859
860    /**
861     * Marks a task as skipped: it renders (and counts) as completed without its real completion signal ever firing.
862     *
863     * Restricted to tasks on the site's AI-selected list, the synthetic ids the list adds itself, and the short-list
864     * backfill pool â€” so every rendered card (AI, synthetic, or filler) is skippable. Persisted separately from
865     * `launchpad_checklist_tasks_statuses` because several catalog tasks recompute completion live (memberships, woo,
866     * domains) and would ignore a status write; the skip set is overlaid on read instead.
867     *
868     * @param WP_REST_Request $request Request object.
869     * @return array|WP_Error
870     */
871    public function skip_task( $request ) {
872        $task_id = $request['task_id'];
873
874        $skippable = array_merge( wpcom_ai_launchpad_get_ai_task_ids(), self::SYNTHETIC_TASK_IDS, array_keys( $this->backfill_pool() ) );
875        if ( ! in_array( $task_id, $skippable, true ) ) {
876            return new WP_Error(
877                'ai_launchpad_task_not_skippable',
878                __( 'This task is not on the tailored list.', 'jetpack-mu-wpcom' ),
879                array( 'status' => 404 )
880            );
881        }
882
883        $skipped = $this->get_skipped_task_ids();
884        if ( ! in_array( $task_id, $skipped, true ) ) {
885            $skipped[] = $task_id;
886            update_option( self::OPTION_SKIPPED, $skipped, false );
887        }
888
889        // Latch now so skipping the last task hides the menu on the next page load, not just on the next read.
890        $this->maybe_mark_completed();
891
892        return array(
893            'skipped' => true,
894            'task_id' => $task_id,
895        );
896    }
897
898    /**
899     * Deletes the AI output and marks the AI Launchpad as dismissed.
900     *
901     * @return array
902     */
903    public function dismiss() {
904        delete_option( self::OPTION_AI_OUTPUT );
905        delete_option( self::OPTION_SKIPPED );
906        delete_option( self::OPTION_COMPLETED );
907        update_option( self::OPTION_DISMISSED, true, true );
908
909        return array( 'dismissed' => true );
910    }
911
912    /**
913     * The persisted skipped task ids, always as a clean string array, remapped onto the
914     * ids the launchpad renders â€” a skip recorded under a task's raw id before that id
915     * was remapped must keep applying to the card it renders as now.
916     *
917     * @return string[]
918     */
919    private function get_skipped_task_ids() {
920        $skipped = get_option( self::OPTION_SKIPPED, array() );
921        if ( ! is_array( $skipped ) ) {
922            return array();
923        }
924
925        $skipped = array_map( 'wpcom_ai_launchpad_remap_task_id', array_filter( $skipped, 'is_string' ) );
926
927        return array_values( array_unique( $skipped ) );
928    }
929
930    /**
931     * Overlays the persisted skips onto the enriched tasks: a skipped task carries `skipped: true` and is coerced to
932     * completed, so progress, auto-expand, and reloads all treat it as done (a skip must never pop back open).
933     *
934     * @param array $tasks The enriched task list.
935     * @return array
936     */
937    private function apply_skipped_tasks( $tasks ) {
938        $skipped = $this->get_skipped_task_ids();
939
940        foreach ( $tasks as &$task ) {
941            $task['skipped'] = in_array( $task['id'], $skipped, true );
942            if ( $task['skipped'] ) {
943                $task['completed'] = true;
944            }
945        }
946        unset( $task );
947
948        return $tasks;
949    }
950
951    /**
952     * Strips HTML from a subtitle and rejects URLs and template syntax.
953     *
954     * @param string $subtitle The raw subtitle.
955     * @return string|WP_Error The sanitized subtitle, or an error.
956     */
957    private function sanitize_subtitle( $subtitle ) {
958        $subtitle = trim( wp_strip_all_tags( $subtitle, true ) );
959
960        if ( '' === $subtitle ) {
961            return new WP_Error(
962                'ai_launchpad_invalid_subtitle',
963                __( 'Task subtitles must contain text.', 'jetpack-mu-wpcom' ),
964                array( 'status' => 422 )
965            );
966        }
967
968        if ( preg_match( '#https?://#i', $subtitle ) ) {
969            return new WP_Error(
970                'ai_launchpad_subtitle_contains_url',
971                __( 'Task subtitles must not contain URLs.', 'jetpack-mu-wpcom' ),
972                array( 'status' => 422 )
973            );
974        }
975
976        if ( str_contains( $subtitle, '{{' ) || str_contains( $subtitle, '[[' ) ) {
977            return new WP_Error(
978                'ai_launchpad_subtitle_contains_template',
979                __( 'Task subtitles must not contain template syntax.', 'jetpack-mu-wpcom' ),
980                array( 'status' => 422 )
981            );
982        }
983
984        return mb_substr( $subtitle, 0, 200 );
985    }
986
987    /**
988     * Read endpoint backing the client's availability-aware tailoring: the task ids that will render for the given
989     * goal. Fetched before the AI call (which the wizard prewarms), so the prompt offers only renderable tasks.
990     *
991     * @param WP_REST_Request $request Request object.
992     * @return array
993     */
994    public function get_available_tasks( $request ) {
995        $ids = $this->available_task_ids( (string) $request['goal'] );
996        return array(
997            'available_task_ids'  => $ids['actionable'],
998            'renderable_task_ids' => $ids['renderable'],
999        );
1000    }
1001
1002    /**
1003     * The task ids that will actually render on this site for the given goal â€” the menu tailoring should choose from.
1004     *
1005     * Built by running the whole catalog through the real gate (visibility + force-visible overrides, and the sell
1006     * goal's woo-preview mode), so a task the AI could pick but the site would drop is never offered. `actionable`
1007     * additionally excludes tasks that are already complete â€” they leave nothing to do â€” except the launch tasks,
1008     * which the output contract requires last even on a site that already launched. `renderable` keeps the completed
1009     * ones: the client falls back to it when completion leaves too few actionable tasks to fill a valid list. The
1010     * client intersects these with its own TASK_MENU. Computed once per wizard submit.
1011     *
1012     * @param string $goal The inferred/selected goal.
1013     * @return array{renderable: string[], actionable: string[]}
1014     */
1015    private function available_task_ids( $goal ) {
1016        if ( ! function_exists( 'is_plugin_active' ) ) {
1017            require_once ABSPATH . 'wp-admin/includes/plugin.php';
1018        }
1019        // Sell keeps the commerce tasks as a disabled preview until WooCommerce is active, so they count as available.
1020        $disable_hidden_woo = 'sell' === $goal && ! is_plugin_active( 'woocommerce/woocommerce.php' );
1021
1022        $tasks = $this->build_all_catalog_tasks( false, $disable_hidden_woo );
1023
1024        $actionable = array_filter(
1025            $tasks,
1026            static function ( $task ) {
1027                return ! $task['completed'] || in_array( $task['id'], self::LAUNCH_TASK_IDS, true );
1028            }
1029        );
1030
1031        return array(
1032            'renderable' => array_column( $tasks, 'id' ),
1033            'actionable' => array_column( $actionable, 'id' ),
1034        );
1035    }
1036
1037    /**
1038     * Builds the enriched task list for every catalog task (backs `?all_tasks=1` when the gate is bypassed, and
1039     * available_task_ids() when it is not).
1040     *
1041     * Each task is enriched in isolation so one that can't be built is skipped rather than breaking the whole view.
1042     *
1043     * @param bool $bypass_visibility  Whether to skip the catalog visibility gate (the testing view does).
1044     * @param bool $disable_hidden_woo Whether hidden commerce tasks render as a disabled preview instead of dropping.
1045     * @return array
1046     */
1047    private function build_all_catalog_tasks( $bypass_visibility = true, $disable_hidden_woo = false ) {
1048        $built    = array();
1049        $seen_ids = array();
1050        foreach ( array_keys( wpcom_launchpad_get_task_definitions() ) as $task_id ) {
1051            try {
1052                $one = $this->build_tasks(
1053                    array(
1054                        array(
1055                            'id'       => $task_id,
1056                            'subtitle' => $task_id,
1057                        ),
1058                    ),
1059                    $bypass_visibility,
1060                    null,
1061                    $disable_hidden_woo
1062                );
1063            } catch ( \Throwable $e ) {
1064                continue;
1065            }
1066            // build_tasks runs per id here, so its own dedup can't see this collision: the catalog holds both
1067            // `woo_launch_site` and `site_launched`, and the former is remapped onto the latter. Keep the first.
1068            $card = $one[0] ?? null;
1069            if ( null === $card || isset( $seen_ids[ $card['id'] ] ) ) {
1070                continue;
1071            }
1072            $seen_ids[ $card['id'] ] = true;
1073            $built[]                 = $card;
1074        }
1075        return $built;
1076    }
1077
1078    /**
1079     * Enriches the persisted tasks with title, completion state, and CTA path from the catalog.
1080     *
1081     * @param array       $tasks              The persisted `payload.tasks` array.
1082     * @param bool        $bypass_visibility  Skip the catalog visibility gate (for the all-tasks testing view).
1083     * @param string|null $theme_cta          The resolved themes-showcase path for the theme-picker tasks, or
1084     *                                        null to keep their default CTAs.
1085     * @param bool        $disable_hidden_woo Keep WOO_TASK_IDS that fail the visibility gate as disabled preview
1086     *                                        cards instead of dropping them (sell goal while WooCommerce is inactive).
1087     * @return array
1088     */
1089    private function build_tasks( $tasks, $bypass_visibility = false, $theme_cta = null, $disable_hidden_woo = false ) {
1090        $definitions = wpcom_launchpad_get_task_definitions();
1091        $built       = array();
1092        $seen_ids    = array();
1093
1094        // Some catalog visibility callbacks call is_plugin_active(), which is not loaded during a REST request.
1095        if ( ! function_exists( 'is_plugin_active' ) ) {
1096            require_once ABSPATH . 'wp-admin/includes/plugin.php';
1097        }
1098
1099        $is_private_site = $this->is_private_site();
1100
1101        foreach ( $tasks as $task ) {
1102            if ( ! is_array( $task ) || ! isset( $task['id'] ) || ! isset( $task['subtitle'] ) ) {
1103                continue;
1104            }
1105
1106            // Broken/meaningless-in-context ids render as their working equivalent (see the helper for the why).
1107            $task['id'] = wpcom_ai_launchpad_remap_task_id( $task['id'] );
1108
1109            if ( ! isset( $definitions[ $task['id'] ] ) ) {
1110                continue;
1111            }
1112
1113            // One card per id â€” the client keys cards by id. The remap above can collide with the target id already
1114            // being present (notably the ?all_tasks=1 view, which enumerates every catalog id), so collapse any
1115            // repeat to the first occurrence.
1116            if ( isset( $seen_ids[ $task['id'] ] ) ) {
1117                continue;
1118            }
1119
1120            // The Jetpack Social tasks point at an admin page wpcom doesn't load on a private site, so hide them there.
1121            if ( $is_private_site && in_array( $task['id'], self::SOCIAL_PAGE_TASK_IDS, true ) ) {
1122                continue;
1123            }
1124
1125            $definition       = $definitions[ $task['id'] ];
1126            $definition['id'] = $task['id'];
1127            $disabled         = false;
1128
1129            // Honor the catalog's own visibility gate: a task the catalog would hide here must not render, since its
1130            // CTA would 404 and it could never complete. Filtered on read so the deterministic fallback stays usable.
1131            if (
1132                ! $bypass_visibility
1133                && ! in_array( $task['id'], self::FORCE_VISIBLE_TASK_IDS, true )
1134                && ! wpcom_launchpad_checklists()->is_visible( $definition )
1135            ) {
1136                // On a sell site without WooCommerce, keep the commerce tasks as a disabled preview of the store
1137                // roadmap rather than dropping them and collapsing the list. Everything else stays hidden.
1138                if ( $disable_hidden_woo && in_array( $task['id'], self::WOO_TASK_IDS, true ) ) {
1139                    $disabled = true;
1140                } else {
1141                    continue;
1142                }
1143            }
1144
1145            if ( $disabled ) {
1146                // A disabled preview always renders as the locked card: never resolve its completion (the woo
1147                // completion callback marks the task complete as a side effect, which must not fire on a read) and
1148                // never resolve a CTA path (it has no reachable action).
1149                $completed    = false;
1150                $calypso_path = null;
1151            } else {
1152                // The membership tasks' catalog callbacks are always false on Atomic; recompute from local signals.
1153                $completed = AI_Launchpad_Memberships::has_override( $task['id'] )
1154                    ? AI_Launchpad_Memberships::is_task_complete( $task['id'] )
1155                    : wpcom_launchpad_checklists()->is_task_complete( $definition );
1156
1157                // The theme-picker task points at the showcase pre-filtered for the site (Store category on sell,
1158                // the AI's inferred category elsewhere) instead of plain themes.php. The legacy design_selected/
1159                // design_completed ids consolidate onto site_theme_selected via wpcom_ai_launchpad_remap_task_id().
1160                $theme_showcase_path = 'site_theme_selected' === $task['id'] ? $theme_cta : null;
1161                $cta_override        = $this->get_cta_override( $task['id'] );
1162                if ( null !== $theme_showcase_path ) {
1163                    $calypso_path = $theme_showcase_path;
1164                } elseif ( null !== $cta_override ) {
1165                    $calypso_path = $cta_override;
1166                } else {
1167                    $calypso_path = wpcom_launchpad_checklists()->load_calypso_path( $definition );
1168                }
1169
1170                // Simple sites have no reachable wp-admin plugins screen; route any plugin-screen CTA to Calypso.
1171                $calypso_path = $this->to_simple_plugins_path( $calypso_path );
1172            }
1173
1174            $title       = isset( $definition['get_title'] ) ? $definition['get_title']() : '';
1175            $in_progress = false;
1176
1177            // A saved-but-unpublished draft (found by marker meta) puts a site-editor task "in progress": reopen that
1178            // draft instead of creating a new one, and surface the drafts icon + a "Continue…" prompt in the card.
1179            if ( ! $completed && ! $disabled ) {
1180                $draft_url = $this->get_in_progress_draft_url( $task['id'] );
1181                if ( null !== $draft_url ) {
1182                    $in_progress  = true;
1183                    $calypso_path = $draft_url;
1184                }
1185            }
1186
1187            // Title follows our precise in-progress signal so it, the icon, and the CTA agree.
1188            $title = $this->get_task_title( $task['id'], $in_progress, $title );
1189
1190            $seen_ids[ $task['id'] ] = true;
1191            $built[]                 = array(
1192                'id'           => $task['id'],
1193                'subtitle'     => $task['subtitle'],
1194                'title'        => $title,
1195                'completed'    => $completed,
1196                'in_progress'  => $in_progress,
1197                'disabled'     => $disabled,
1198                'calypso_path' => $calypso_path,
1199            );
1200        }
1201
1202        return $built;
1203    }
1204
1205    /**
1206     * The wp-admin CTA destination the AI Launchpad substitutes for a task's catalog path, or null to keep the catalog's.
1207     *
1208     * Static repoints live in CTA_OVERRIDES; `add_subscribe_block` is resolved here because its destination depends on
1209     * the active theme: the Site Editor is where a block theme adds the Subscribe block to a template (the action its
1210     * completion listener watches), and the block-based widget editor is the closest equivalent on a classic theme
1211     * (normally unreachable â€” the task's catalog visibility is FSE-only â€” but the theme can change after tailoring).
1212     *
1213     * @param string $task_id The catalog task id.
1214     * @return string|null
1215     */
1216    private function get_cta_override( $task_id ) {
1217        if ( 'add_subscribe_block' === $task_id ) {
1218            return admin_url( wp_is_block_theme() ? 'site-editor.php' : 'widgets.php' );
1219        }
1220
1221        if ( isset( self::CTA_OVERRIDES[ $task_id ] ) ) {
1222            return admin_url( self::CTA_OVERRIDES[ $task_id ] );
1223        }
1224
1225        return null;
1226    }
1227
1228    /**
1229     * On Simple sites, rewrite a wp-admin plugins-screen CTA to its Calypso equivalent.
1230     *
1231     * Simple sites have no reachable wp-admin plugins UI, so any task whose CTA lands on `plugins.php` or
1232     * `plugin-install.php` would dead-end. Those are mapped to the Calypso plugins page â€” a specific plugin
1233     * when a slug is given, otherwise the site's plugins list. Non-plugin paths and Atomic sites pass through.
1234     *
1235     * @param string|null $path        The resolved CTA path.
1236     * @param string      $plugin_slug Optional plugin slug to deep-link to on Calypso.
1237     * @return string|null
1238     */
1239    private function to_simple_plugins_path( $path, $plugin_slug = '' ) {
1240        if ( ! ( defined( 'IS_WPCOM' ) && IS_WPCOM ) || ! is_string( $path ) ) {
1241            return $path;
1242        }
1243
1244        if ( false === strpos( $path, 'plugins.php' ) && false === strpos( $path, 'plugin-install.php' ) ) {
1245            return $path;
1246        }
1247
1248        $slug_segment = '' !== $plugin_slug ? rawurlencode( $plugin_slug ) . '/' : '';
1249        return '/plugins/' . $slug_segment . rawurlencode( wpcom_get_site_slug() );
1250    }
1251
1252    /**
1253     * The theme-showcase subject-category slugs (the `subject` taxonomy from /rest/v1.2/theme-filters).
1254     * Every category carries free themes, unlike free-text search, which surfaces mostly paid results.
1255     */
1256    const THEME_CATEGORIES = array(
1257        'blog',
1258        'portfolio',
1259        'business',
1260        'store',
1261        'art-design',
1262        'about',
1263        'real-estate',
1264        'health-wellness',
1265        'authors-writers',
1266        'newsletter',
1267        'education',
1268        'magazine',
1269        'music',
1270        'restaurant',
1271        'travel-lifestyle',
1272        'fashion-beauty',
1273        'community-non-profit',
1274        'podcast',
1275        'entertainment',
1276    );
1277
1278    /**
1279     * The wordpress.com themes-showcase path the theme-picker tasks should point at.
1280     *
1281     * Sell sites always land on the showcase's Store category so shop-ready templates lead; other goals get the
1282     * showcase pre-filtered by the AI's inferred category, re-checked against the allowlist since the envelope is
1283     * stored data. Without a valid category the plain showcase is returned (never null: the catalog CTA can resolve
1284     * to wp-admin's themes.php, which skips the showcase). The client's `toNavigableUrl` resolves the relative path
1285     * against wordpress.com.
1286     *
1287     * @param string $goal     The inferred goal.
1288     * @param string $category The AI's inferred theme_category slug.
1289     * @return string
1290     */
1291    private function get_themes_showcase_path( $goal, $category ) {
1292        if ( 'sell' === $goal ) {
1293            $category = 'store';
1294        }
1295
1296        if ( ! in_array( $category, self::THEME_CATEGORIES, true ) ) {
1297            return '/themes/' . rawurlencode( wpcom_get_site_slug() );
1298        }
1299
1300        return '/themes/filter/' . $category . '/' . rawurlencode( wpcom_get_site_slug() );
1301    }
1302
1303    /**
1304     * Ensures the persisted task list contains the theme-picker task, appending `site_theme_selected` when no
1305     * task already resolves to it. A design task that remaps onto it (via wpcom_ai_launchpad_remap_task_id)
1306     * counts as present, so a theme card is never duplicated.
1307     *
1308     * @param array $tasks The persisted `payload.tasks` array.
1309     * @return array
1310     */
1311    private function ensure_theme_task( $tasks ) {
1312        foreach ( $tasks as $task ) {
1313            if ( is_array( $task ) && isset( $task['id'] ) && is_string( $task['id'] )
1314                && 'site_theme_selected' === wpcom_ai_launchpad_remap_task_id( $task['id'] ) ) {
1315                return $tasks;
1316            }
1317        }
1318
1319        $tasks[] = array(
1320            'id'       => 'site_theme_selected',
1321            'subtitle' => __( 'Choose a theme that fits your store.', 'jetpack-mu-wpcom' ),
1322        );
1323
1324        return $tasks;
1325    }
1326
1327    /**
1328     * Moves the task with the given id to immediately after another task. The list is returned unchanged
1329     * unless both ids are present.
1330     *
1331     * @param array  $tasks    The built task list.
1332     * @param string $move_id  The id of the task to move.
1333     * @param string $after_id The id of the task to place it after.
1334     * @return array
1335     */
1336    private function move_task_after( $tasks, $move_id, $after_id ) {
1337        $ids  = array_column( $tasks, 'id' );
1338        $from = array_search( $move_id, $ids, true );
1339        if ( false === $from || false === array_search( $after_id, $ids, true ) ) {
1340            return $tasks;
1341        }
1342
1343        $moved = array_splice( $tasks, $from, 1 );
1344        // Recompute the anchor: extracting an earlier element shifts it left by one.
1345        $to = array_search( $after_id, array_column( $tasks, 'id' ), true );
1346        array_splice( $tasks, $to + 1, 0, $moved );
1347
1348        return $tasks;
1349    }
1350
1351    /**
1352     * Visual-work niche keywords that (along with the `portfolio` goal) surface the synthetic gallery task.
1353     */
1354    const GALLERY_NICHE_KEYWORDS = array(
1355        'photography',
1356        'photo',
1357        'photos',
1358        'photographer',
1359        'portfolio',
1360        'gallery',
1361        'art',
1362        'artist',
1363        'illustration',
1364        'illustrator',
1365        'design',
1366        'designer',
1367        'visual',
1368        'painting',
1369        'drawing',
1370    );
1371
1372    /**
1373     * Whether the synthetic "Create your first gallery" task should be offered, based on the inferred goal/niche.
1374     *
1375     * @param array $inferred The AI output's `inferred` block.
1376     * @return bool
1377     */
1378    private function should_offer_gallery_task( $inferred ) {
1379        $goal = isset( $inferred['goal'] ) && is_string( $inferred['goal'] ) ? $inferred['goal'] : '';
1380        if ( 'portfolio' === $goal ) {
1381            return true;
1382        }
1383
1384        $niche = isset( $inferred['niche'] ) && is_string( $inferred['niche'] ) ? strtolower( $inferred['niche'] ) : '';
1385        if ( '' === $niche ) {
1386            return false;
1387        }
1388
1389        // Split on any non-alphanumeric run so hyphenated/compound niches ("wildlife-photography") tokenize like the client.
1390        $words = preg_split( '/[^a-z0-9]+/', $niche, -1, PREG_SPLIT_NO_EMPTY );
1391        return array() !== array_intersect( $words, self::GALLERY_NICHE_KEYWORDS );
1392    }
1393
1394    /**
1395     * Builds the synthetic gallery-task entry, or null when it should not be offered.
1396     *
1397     * Its id is listed in SYNTHETIC_TASK_IDS so the task stays skippable.
1398     *
1399     * Completion is read from the status option (written by AI_Launchpad_Gallery_Page_Listener on publish); an
1400     * unpublished marker draft puts it in progress and reopens that draft.
1401     *
1402     * @param array $inferred The AI output's `inferred` block.
1403     * @return array|null
1404     */
1405    private function build_gallery_task( $inferred ) {
1406        if ( ! $this->should_offer_gallery_task( $inferred ) ) {
1407            return null;
1408        }
1409
1410        $statuses  = (array) get_option( 'launchpad_checklist_tasks_statuses', array() );
1411        $completed = ! empty( $statuses['add_gallery_page'] );
1412
1413        $in_progress  = false;
1414        $calypso_path = null;
1415        if ( ! $completed ) {
1416            $draft_url = $this->get_in_progress_draft_url( 'add_gallery_page' );
1417            if ( null !== $draft_url ) {
1418                $in_progress  = true;
1419                $calypso_path = $draft_url;
1420            }
1421        }
1422
1423        return array(
1424            'id'           => 'add_gallery_page',
1425            'subtitle'     => __( 'Show your work in a beautiful photo gallery.', 'jetpack-mu-wpcom' ),
1426            'title'        => $this->get_task_title( 'add_gallery_page', $in_progress, __( 'Create your first gallery', 'jetpack-mu-wpcom' ) ),
1427            'completed'    => $completed,
1428            'in_progress'  => $in_progress,
1429            'disabled'     => false,
1430            'calypso_path' => $calypso_path,
1431        );
1432    }
1433
1434    /**
1435     * Builds the synthetic store-setup lead tasks for the sell goal: an "install WooCommerce" task and a "set up
1436     * your store" task. Their ids are listed in SYNTHETIC_TASK_IDS so the tasks stay skippable.
1437     *
1438     * Both are read live (installed/active/profiler options), so no marker or listener is needed. While WooCommerce
1439     * is inactive the setup task shows as a disabled preview, matching the disabled commerce tasks below it. Callers
1440     * gate this on the sell goal.
1441     *
1442     * @param bool $active Whether WooCommerce is active.
1443     * @return array The lead tasks in display order.
1444     */
1445    private function build_store_tasks( $active ) {
1446        return array(
1447            $this->build_install_woocommerce_task( $active ),
1448            $this->build_setup_store_task( $active ),
1449        );
1450    }
1451
1452    /**
1453     * The "Install the WooCommerce plugin" lead task: to-do until the plugin exists, in-progress while it is
1454     * installed-but-inactive, and complete once active.
1455     *
1456     * @param bool $active Whether WooCommerce is active.
1457     * @return array
1458     */
1459    private function build_install_woocommerce_task( $active ) {
1460        $in_progress = ! $active && array_key_exists( 'woocommerce/woocommerce.php', get_plugins() );
1461
1462        $calypso_path = null;
1463        if ( ! $active ) {
1464            // Installed-but-inactive activates from the plugins list; not-installed installs from the plugin search.
1465            // On Simple both wp-admin screens are unreachable, so route through the Calypso WooCommerce plugin page.
1466            $wp_admin_path = $in_progress
1467                ? admin_url( 'plugins.php?plugin_status=inactive' )
1468                : admin_url( 'plugin-install.php?s=woocommerce&tab=search&type=term' );
1469            $calypso_path  = $this->to_simple_plugins_path( $wp_admin_path, 'woocommerce' );
1470        }
1471
1472        return array(
1473            'id'           => 'install_woocommerce',
1474            'subtitle'     => $in_progress
1475                ? __( 'Activate the WooCommerce plugin to continue.', 'jetpack-mu-wpcom' )
1476                : __( 'Add the WooCommerce plugin to start selling.', 'jetpack-mu-wpcom' ),
1477            'title'        => __( 'Install the WooCommerce plugin', 'jetpack-mu-wpcom' ),
1478            'completed'    => $active,
1479            'in_progress'  => $in_progress,
1480            'disabled'     => false,
1481            'calypso_path' => $calypso_path,
1482        );
1483    }
1484
1485    /**
1486     * The "Set up your store" lead task: to-do until the WooCommerce setup wizard (core profiler) is completed or
1487     * skipped, then complete. Shown as a disabled preview until WooCommerce is active, since the wizard needs it.
1488     *
1489     * @param bool $active Whether WooCommerce is active.
1490     * @return array
1491     */
1492    private function build_setup_store_task( $active ) {
1493        $profile   = (array) get_option( 'woocommerce_onboarding_profile', array() );
1494        $completed = $active && ( ! empty( $profile['completed'] ) || ! empty( $profile['skipped'] ) );
1495
1496        return array(
1497            'id'           => 'setup_woocommerce_store',
1498            'subtitle'     => __( 'Complete or skip the WooCommerce setup wizard.', 'jetpack-mu-wpcom' ),
1499            'title'        => __( 'Set up your store', 'jetpack-mu-wpcom' ),
1500            'completed'    => $completed,
1501            'in_progress'  => false,
1502            'disabled'     => ! $active,
1503            'calypso_path' => $completed || ! $active ? null : admin_url( 'admin.php?page=wc-admin&path=%2Fsetup-wizard' ),
1504        );
1505    }
1506
1507    /**
1508     * Inserts a synthetic task immediately before the trailing launch task (or appends it), idempotently by id.
1509     *
1510     * @param array $tasks The enriched task list.
1511     * @param array $task  The synthetic task entry.
1512     * @return array
1513     */
1514    private function insert_before_launch_task( $tasks, $task ) {
1515        foreach ( $tasks as $existing ) {
1516            if ( isset( $existing['id'] ) && $existing['id'] === $task['id'] ) {
1517                return $tasks;
1518            }
1519        }
1520
1521        $insert_at = count( $tasks );
1522        foreach ( $tasks as $index => $existing ) {
1523            if ( isset( $existing['id'] ) && in_array( $existing['id'], self::LAUNCH_TASK_IDS, true ) ) {
1524                $insert_at = $index;
1525                break;
1526            }
1527        }
1528
1529        array_splice( $tasks, $insert_at, 0, array( $task ) );
1530        return $tasks;
1531    }
1532
1533    /**
1534     * Resolves the editor URL of a site-editor task's in-progress draft, or null when there is none.
1535     *
1536     * The About page is found by its marker meta; the first-post tasks by the latest draft post. Returned as an
1537     * `admin_url()` so the client reopens the existing draft rather than creating a duplicate.
1538     *
1539     * @param string $task_id The catalog task id.
1540     * @return string|null
1541     */
1542    private function get_in_progress_draft_url( $task_id ) {
1543        $draft_id = null;
1544
1545        if ( 'add_about_page' === $task_id ) {
1546            $draft_id = AI_Launchpad_About_Page_Listener::get_draft_id();
1547        } elseif ( 'add_gallery_page' === $task_id ) {
1548            $draft_id = AI_Launchpad_Gallery_Page_Listener::get_draft_id();
1549        } elseif ( in_array( $task_id, self::IN_PROGRESS_FIRST_POST_TASK_IDS, true ) ) {
1550            $draft_id = AI_Launchpad_First_Post_Listener::get_draft_id();
1551        }
1552
1553        if ( null === $draft_id ) {
1554            return null;
1555        }
1556
1557        return admin_url( 'post.php?post=' . $draft_id . '&action=edit' );
1558    }
1559
1560    /**
1561     * The card title for a site-editor task, chosen by our precise (marker-based) in-progress signal so the title,
1562     * icon, and CTA stay in agreement.
1563     *
1564     * This overrides `first_post_published`'s catalog title in both states: the catalog swaps it to "Continue…"
1565     * whenever ANY draft exists (a looser signal than our marker), so an unrelated draft would otherwise show a
1566     * "Continue…" title beside the not-started icon. Tasks not listed keep their catalog title.
1567     *
1568     * @param string $task_id     The catalog task id.
1569     * @param bool   $in_progress Whether our marker detected an in-progress draft.
1570     * @param string $default     The catalog-provided title, kept when we don't override.
1571     * @return string
1572     */
1573    private function get_task_title( $task_id, $in_progress, $default ) {
1574        switch ( $task_id ) {
1575            case 'add_about_page':
1576                return $in_progress ? __( 'Continue working on the About page', 'jetpack-mu-wpcom' ) : $default;
1577            case 'add_gallery_page':
1578                return $in_progress ? __( 'Continue working on your gallery', 'jetpack-mu-wpcom' ) : $default;
1579            case 'first_post_published':
1580                return $in_progress
1581                    ? __( 'Continue to write your first post', 'jetpack-mu-wpcom' )
1582                    : __( 'Write your first post', 'jetpack-mu-wpcom' );
1583            default:
1584                return $default;
1585        }
1586    }
1587
1588    /**
1589     * Loads the agent output schema used to validate `PUT /tailored` bodies.
1590     *
1591     * @return array
1592     */
1593    private function get_output_schema() {
1594        static $schema = null;
1595
1596        if ( null === $schema ) {
1597            $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.
1598        }
1599
1600        return $schema;
1601    }
1602}
1603
1604// @phan-suppress-next-line PhanNoopNew -- instantiated for the constructor's add_action side effect.
1605new AI_Launchpad_REST();