Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.66% covered (success)
97.66%
501 / 513
81.82% covered (warning)
81.82%
27 / 33
CRAP
0.00% covered (danger)
0.00%
0 / 1
AI_Launchpad_REST
97.85% covered (success)
97.85%
501 / 512
81.82% covered (warning)
81.82%
27 / 33
161
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
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 register_routes
100.00% covered (success)
100.00%
117 / 117
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%
24 / 24
100.00% covered (success)
100.00%
1 / 1
8
 get_current_tasks
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
16.19
 maybe_mark_completed
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 compute_tasklist_complete
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 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%
40 / 40
100.00% covered (success)
100.00%
1 / 1
7
 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
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 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
 build_all_catalog_tasks
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 build_tasks
94.92% covered (success)
94.92%
56 / 59
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%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 niche_to_search_term
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 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%
11 / 11
100.00% covered (success)
100.00%
1 / 1
10
 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` stays a valid launch task so a stray AI emission passes PUT validation and is normalized to
25    // `site_launched` on read (see build_tasks), rather than failing the whole list into the deterministic fallback.
26    const LAUNCH_TASK_IDS = array( 'site_launched', 'blog_launched', 'woo_launch_site', 'link_in_bio_launched', 'videopress_launched' );
27
28    /**
29     * Tasks the AI Launchpad marks complete on CTA click, because their real signal is unreachable from wp-admin.
30     *
31     * Server-side allowlist so the complete-task route can only tick these ids. Mirrored client-side in model.ts.
32     */
33    const COMPLETE_ON_CLICK_TASK_IDS = array(
34        'complete_profile',
35        'manage_subscribers',
36        'manage_paid_newsletter_plan',
37        'earn_money',
38        'start_building_your_audience',
39        'site_monitoring_page',
40        'setup_ssh',
41        'share_site',
42    );
43
44    /**
45     * Task ids the AI Launchpad synthesizes itself (never present in the AI payload): the sell goal's store-setup
46     * lead tasks and the niche-gated gallery task. Skips must accept them alongside the AI-selected ids.
47     *
48     * Must list every id minted by build_store_tasks() / build_gallery_task() — a synthetic task missing here
49     * renders with a Skip button whose write is rejected.
50     */
51    const SYNTHETIC_TASK_IDS = array(
52        'install_woocommerce',
53        'setup_woocommerce_store',
54        'add_gallery_page',
55    );
56
57    /**
58     * Tasks whose catalog visibility gate encodes an `IS_WPCOM`-only assumption the AI Launchpad overrides.
59     *
60     * `add_10_email_subscribers` is gated off WordPress.com, but AI_Launchpad_Subscribers_Listener reads the count on
61     * Atomic, so the task must still render and its visibility gate is skipped here.
62     */
63    const FORCE_VISIBLE_TASK_IDS = array(
64        'add_10_email_subscribers',
65    );
66
67    /**
68     * Commerce tasks whose catalog visibility gate requires WooCommerce to be active.
69     *
70     * On a fresh sell site these would be dropped, collapsing the list. Instead the sell branch keeps them as a
71     * disabled preview of the store roadmap until WooCommerce is active. See build_tasks()'s $disable_hidden_woo mode.
72     */
73    const WOO_TASK_IDS = array(
74        'woo_customize_store',
75        'woo_products',
76        'set_up_payments',
77    );
78
79    /**
80     * CTA destinations the AI Launchpad repoints to wp-admin, keyed by task id, each mapping to an `admin_url()` path.
81     *
82     * The catalog sends these to Calypso flows that are a poor fit for wp-admin. Overridden on read so the shared
83     * catalog (used by the legacy launchpad too) is left untouched.
84     */
85    const CTA_OVERRIDES = array(
86        'connect_social_media' => 'admin.php?page=jetpack-social',
87        'design_completed'     => 'themes.php',
88        'design_selected'      => 'themes.php',
89    );
90
91    /**
92     * Theme-picker tasks. When the AI infers a niche, these point at the wordpress.com themes showcase
93     * pre-filtered by that niche, so the theme list feels relevant to what the user is building (instead of
94     * plain themes.php, which only filters already-installed themes). Falls back to the paths above when no niche.
95     */
96    const THEME_TASK_IDS = array(
97        'site_theme_selected',
98        'design_selected',
99        'design_completed',
100    );
101
102    /**
103     * Jetpack Social tasks, hidden on private sites where wpcom does not load Publicize (so their CTA page would 404).
104     */
105    const SOCIAL_PAGE_TASK_IDS = array(
106        'connect_social_media',
107        'drive_traffic',
108        'post_sharing_enabled',
109    );
110
111    /**
112     * First-post tasks that can sit "in progress": the AI-created draft post exists but has not been published yet.
113     *
114     * Detected through the `_wpcom_ai_launchpad_first_post` marker meta (via AI_Launchpad_First_Post_Listener), so an
115     * unrelated pre-existing draft never counts. Paired with `add_about_page`, which has its own marker meta.
116     */
117    const IN_PROGRESS_FIRST_POST_TASK_IDS = array(
118        'first_post_published',
119        'first_post_published_newsletter',
120    );
121
122    /**
123     * Whether the site's visibility is set to private (`blog_public = -1`).
124     *
125     * Read directly to avoid a hard dependency on the Status package in this read path.
126     *
127     * @return bool
128     */
129    private function is_private_site() {
130        return '-1' === (string) get_option( 'blog_public' );
131    }
132
133    /**
134     * Class constructor.
135     */
136    public function __construct() {
137        $this->namespace = 'wpcom/v2';
138        $this->rest_base = 'ai-launchpad';
139
140        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
141    }
142
143    /**
144     * Register our routes.
145     */
146    public function register_routes() {
147        register_rest_route(
148            $this->namespace,
149            $this->rest_base,
150            array(
151                array(
152                    'methods'             => WP_REST_Server::READABLE,
153                    'callback'            => array( $this, 'get_data' ),
154                    'permission_callback' => array( $this, 'can_read' ),
155                    'args'                => array(
156                        // Testing aid: render the full task catalog so every task can be exercised from one site.
157                        'all_tasks' => array(
158                            'description' => 'Return the full task catalog instead of the tailored list (testing aid).',
159                            'type'        => 'boolean',
160                            'default'     => false,
161                        ),
162                    ),
163                ),
164                array(
165                    'methods'             => WP_REST_Server::DELETABLE,
166                    'callback'            => array( $this, 'dismiss' ),
167                    'permission_callback' => array( $this, 'can_write' ),
168                ),
169            )
170        );
171
172        register_rest_route(
173            $this->namespace,
174            $this->rest_base . '/wizard',
175            array(
176                array(
177                    'methods'             => 'PUT',
178                    'callback'            => array( $this, 'update_wizard' ),
179                    'permission_callback' => array( $this, 'can_write' ),
180                    'args'                => array(
181                        'goal'        => array(
182                            'description' => 'The site goal picked in the wizard.',
183                            'type'        => 'string',
184                            'enum'        => array( 'write', 'build', 'sell', 'newsletter', 'educate', 'portfolio' ),
185                            'required'    => true,
186                        ),
187                        'site_name'   => array(
188                            'description'       => 'The site name entered in the wizard.',
189                            'type'              => 'string',
190                            'required'          => true,
191                            'sanitize_callback' => 'sanitize_text_field',
192                        ),
193                        'description' => array(
194                            'description'       => 'The free-text site description entered in the wizard.',
195                            'type'              => 'string',
196                            'required'          => true,
197                            'sanitize_callback' => 'sanitize_textarea_field',
198                        ),
199                        'locale'      => array(
200                            'description'       => 'The user locale.',
201                            'type'              => 'string',
202                            'default'           => 'en',
203                            'sanitize_callback' => 'sanitize_text_field',
204                        ),
205                    ),
206                ),
207            )
208        );
209
210        register_rest_route(
211            $this->namespace,
212            $this->rest_base . '/complete-task',
213            array(
214                array(
215                    'methods'             => 'POST',
216                    'callback'            => array( $this, 'complete_task' ),
217                    'permission_callback' => array( $this, 'can_write' ),
218                    'args'                => array(
219                        'task_id' => array(
220                            'description'       => 'The acknowledgment task to mark complete.',
221                            'type'              => 'string',
222                            'required'          => true,
223                            'sanitize_callback' => 'sanitize_key',
224                        ),
225                    ),
226                ),
227            )
228        );
229
230        register_rest_route(
231            $this->namespace,
232            $this->rest_base . '/skip-task',
233            array(
234                array(
235                    'methods'             => 'POST',
236                    'callback'            => array( $this, 'skip_task' ),
237                    'permission_callback' => array( $this, 'can_write' ),
238                    'args'                => array(
239                        'task_id' => array(
240                            'description'       => 'The task to skip.',
241                            'type'              => 'string',
242                            'required'          => true,
243                            'sanitize_callback' => 'sanitize_key',
244                        ),
245                    ),
246                ),
247            )
248        );
249
250        register_rest_route(
251            $this->namespace,
252            $this->rest_base . '/tailored',
253            array(
254                array(
255                    'methods'             => 'PUT',
256                    'callback'            => array( $this, 'update_tailored' ),
257                    'permission_callback' => array( $this, 'can_write' ),
258                    'args'                => array(
259                        'source' => array(
260                            'description' => 'Whether the payload came from the AI or the deterministic fallback. Query parameter; the JSON body must match the agent output schema exactly.',
261                            'type'        => 'string',
262                            'enum'        => array( 'ai', 'fallback' ),
263                            'default'     => 'ai',
264                        ),
265                    ),
266                ),
267            )
268        );
269    }
270
271    /**
272     * Permission callback for reads.
273     *
274     * @return true|WP_Error|false
275     */
276    public function can_read() {
277        if ( ! current_user_can( 'edit_posts' ) ) {
278            return false;
279        }
280
281        return $this->check_eligibility();
282    }
283
284    /**
285     * Permission callback for writes.
286     *
287     * @return true|WP_Error|false
288     */
289    public function can_write() {
290        if ( ! current_user_can( 'manage_options' ) ) {
291            return false;
292        }
293
294        return $this->check_eligibility();
295    }
296
297    /**
298     * Returns a 404 error for ineligible sites, true otherwise.
299     *
300     * @return true|WP_Error
301     */
302    private function check_eligibility() {
303        // Fail closed: if the gate is unavailable, treat the site as not eligible.
304        if ( ! function_exists( 'wpcom_ai_launchpad_is_eligible' ) || ! wpcom_ai_launchpad_is_eligible() ) {
305            return new WP_Error(
306                'ai_launchpad_not_eligible',
307                __( 'This site is not eligible for the AI Launchpad.', 'jetpack-mu-wpcom' ),
308                array( 'status' => 404 )
309            );
310        }
311
312        return true;
313    }
314
315    /**
316     * Composite read: wizard payload, AI output, enriched tasks, statuses, and eligibility.
317     *
318     * @param WP_REST_Request|null $request Request object (for the `all_tasks` testing param).
319     * @return array
320     */
321    public function get_data( $request = null ) {
322        $wizard    = get_option( self::OPTION_WIZARD );
323        $ai_output = get_option( self::OPTION_AI_OUTPUT );
324
325        // Testing aid: ?all_tasks=1 renders the whole catalog, independent of the persisted tailored output.
326        if ( $request instanceof WP_REST_Request && $request->get_param( 'all_tasks' ) ) {
327            $tasks = $this->apply_skipped_tasks( $this->build_all_catalog_tasks() );
328        } else {
329            $tasks = $this->get_current_tasks();
330        }
331
332        // The membership tasks' completion is recomputed in build_tasks(), so overlay it to keep
333        // checklist_statuses consistent with tasks[].completed for them.
334        $checklist_statuses = (array) get_option( 'launchpad_checklist_tasks_statuses', array() );
335        foreach ( $tasks as $task ) {
336            if ( AI_Launchpad_Memberships::has_override( $task['id'] ) ) {
337                $checklist_statuses[ $task['id'] ] = $task['completed'];
338            }
339        }
340
341        $this->maybe_mark_completed();
342
343        return array(
344            'wizard'             => is_array( $wizard ) ? $wizard : null,
345            'ai_output'          => is_array( $ai_output ) ? $ai_output : null,
346            'tasks'              => $tasks,
347            'checklist_statuses' => $checklist_statuses,
348            'dismissed'          => (bool) get_option( self::OPTION_DISMISSED, false ),
349            'is_eligible'        => true,
350            // Site context the client needs for the launch-task CTA, the preview thumbnail/title, and wizard prefill.
351            'site'               => array(
352                'url'         => home_url(),
353                'title'       => get_bloginfo( 'name' ),
354                'description' => get_bloginfo( 'description' ),
355                // Block themes open the Site Editor; classic themes fall back to the Customizer.
356                'edit_url'    => wp_is_block_theme() ? admin_url( 'site-editor.php' ) : admin_url( 'customize.php' ),
357            ),
358        );
359    }
360
361    /**
362     * The site's tailored task list (AI-selected + synthetic store/gallery tasks, skip overlay applied) — the tasks
363     * GET renders, minus the ?all_tasks testing view. Shared by GET and the completion check.
364     *
365     * @return array
366     */
367    public function get_current_tasks() {
368        $ai_output = get_option( self::OPTION_AI_OUTPUT );
369
370        // Guard the nested payload: partial/failed writes may leave the option without payload.tasks.
371        $payload = is_array( $ai_output ) && isset( $ai_output['payload'] ) && is_array( $ai_output['payload'] )
372            ? $ai_output['payload']
373            : array();
374        // Validate `inferred` as an array before reading from it, since a partial write could leave it non-array.
375        $inferred = isset( $payload['inferred'] ) && is_array( $payload['inferred'] ) ? $payload['inferred'] : array();
376        $niche    = isset( $inferred['niche'] ) && is_string( $inferred['niche'] )
377            ? trim( $inferred['niche'] )
378            : '';
379
380        $goal = isset( $inferred['goal'] ) && is_string( $inferred['goal'] ) ? $inferred['goal'] : '';
381
382        if ( ! function_exists( 'is_plugin_active' ) ) {
383            require_once ABSPATH . 'wp-admin/includes/plugin.php';
384        }
385        $woo_active = is_plugin_active( 'woocommerce/woocommerce.php' );
386
387        // On a sell site without WooCommerce, keep the gated commerce tasks as a disabled preview of the store
388        // roadmap instead of dropping them (which would collapse the list to almost nothing).
389        $disable_hidden_woo = 'sell' === $goal && ! $woo_active;
390
391        $tasks = array();
392        if ( isset( $payload['tasks'] ) && is_array( $payload['tasks'] ) ) {
393            $tasks = $this->build_tasks( $payload['tasks'], false, $niche, $disable_hidden_woo );
394        }
395
396        // The sell goal leads with the store-setup task; every other goal may offer the gallery task. They are
397        // mutually exclusive so a sell site with a visual niche doesn't also get an off-target gallery task.
398        if ( 'sell' === $goal ) {
399            // The store-setup tasks lead the sell list. Their synthetic ids never appear in the AI payload, so a
400            // plain prepend is safe.
401            $tasks = array_merge( $this->build_store_tasks( $woo_active ), $tasks );
402        } else {
403            $gallery = $this->build_gallery_task( $inferred );
404            if ( null !== $gallery ) {
405                $tasks = $this->insert_before_launch_task( $tasks, $gallery );
406            }
407        }
408
409        return $this->apply_skipped_tasks( $tasks );
410    }
411
412    /**
413     * Latches OPTION_COMPLETED the first time the list is fully done. Called on every path that can finish the last
414     * task (read, skip, complete-on-click); the already-set check skips the rebuild once latched.
415     *
416     * @return void
417     */
418    private function maybe_mark_completed() {
419        if ( get_option( self::OPTION_COMPLETED ) ) {
420            return;
421        }
422
423        if ( $this->compute_tasklist_complete() ) {
424            update_option( self::OPTION_COMPLETED, true, true );
425        }
426    }
427
428    /**
429     * Whether every task on the tailored list is completed or skipped. An empty/absent list is not "complete" — the
430     * wizard still needs to run.
431     *
432     * @return bool
433     */
434    private function compute_tasklist_complete() {
435        if ( ! get_option( self::OPTION_AI_OUTPUT ) ) {
436            return false;
437        }
438
439        $tasks = $this->get_current_tasks();
440        if ( empty( $tasks ) ) {
441            return false;
442        }
443
444        foreach ( $tasks as $task ) {
445            // A skip coerces `completed` true; a disabled preview task stays incomplete until its prerequisite is met.
446            if ( empty( $task['completed'] ) ) {
447                return false;
448            }
449        }
450
451        return true;
452    }
453
454    /**
455     * Persists the wizard input and writes the entered Name and Brief description back to blogname / blogdescription.
456     *
457     * Empty values are skipped so the wizard never blanks an existing title or tagline.
458     *
459     * @param WP_REST_Request $request Request object.
460     * @return array
461     */
462    public function update_wizard( $request ) {
463        $wizard = array(
464            'version'      => 1,
465            'goal'         => $request['goal'],
466            'site_name'    => $request['site_name'],
467            'description'  => $request['description'],
468            'locale'       => $request['locale'],
469            'generated_at' => time(),
470        );
471
472        update_option( self::OPTION_WIZARD, $wizard, false );
473
474        if ( '' !== trim( (string) $request['site_name'] ) ) {
475            update_option( 'blogname', $request['site_name'] );
476        }
477        if ( '' !== trim( (string) $request['description'] ) ) {
478            // Collapse the textarea brief's newlines to keep the inline-rendered tagline single-line.
479            update_option( 'blogdescription', sanitize_text_field( $request['description'] ) );
480        }
481
482        return array( 'wizard' => $wizard );
483    }
484
485    /**
486     * Validates the AI output payload against the agent output schema, wraps it, and persists it.
487     *
488     * @param WP_REST_Request $request Request object.
489     * @return array|WP_Error
490     */
491    public function update_tailored( $request ) {
492        $payload = $request->get_json_params();
493
494        $validation = rest_validate_value_from_schema( $payload, $this->get_output_schema(), 'payload' );
495        if ( is_wp_error( $validation ) ) {
496            return new WP_Error( 'ai_launchpad_invalid_payload', $validation->get_error_message(), array( 'status' => 422 ) );
497        }
498
499        $last_task = end( $payload['tasks'] );
500        if ( ! in_array( $last_task['id'], self::LAUNCH_TASK_IDS, true ) ) {
501            return new WP_Error(
502                'ai_launchpad_missing_launch_task',
503                __( 'The last task must be a launch task.', 'jetpack-mu-wpcom' ),
504                array( 'status' => 422 )
505            );
506        }
507
508        $definitions = wpcom_launchpad_get_task_definitions();
509        $tasks       = array();
510
511        foreach ( $payload['tasks'] as $task ) {
512            if ( ! isset( $definitions[ $task['id'] ] ) ) {
513                continue;
514            }
515
516            $subtitle = $this->sanitize_subtitle( $task['subtitle'] );
517            if ( is_wp_error( $subtitle ) ) {
518                return $subtitle;
519            }
520
521            $tasks[] = array(
522                'id'       => $task['id'],
523                'subtitle' => $subtitle,
524            );
525        }
526
527        if ( count( $tasks ) < self::MIN_VALID_TASKS ) {
528            return new WP_Error(
529                'ai_launchpad_unknown_tasks',
530                __( 'Too few tasks matched the task catalog.', 'jetpack-mu-wpcom' ),
531                array( 'status' => 422 )
532            );
533        }
534
535        $payload['tasks'] = $tasks;
536
537        $ai_output = array(
538            'version'      => 1,
539            'source'       => $request['source'],
540            'generated_at' => time(),
541            'payload'      => $payload,
542        );
543
544        update_option( self::OPTION_AI_OUTPUT, $ai_output, false );
545
546        // A fresh list must not inherit the previous one's skips or "done" flag.
547        delete_option( self::OPTION_SKIPPED );
548        delete_option( self::OPTION_COMPLETED );
549
550        return array( 'ai_output' => $ai_output );
551    }
552
553    /**
554     * Marks an acknowledgment task complete when the user clicks its CTA, since these tasks have no wp-admin signal.
555     *
556     * Restricted to the COMPLETE_ON_CLICK_TASK_IDS allowlist and to tasks on the site's AI-selected list.
557     *
558     * @param WP_REST_Request $request Request object.
559     * @return array|WP_Error
560     */
561    public function complete_task( $request ) {
562        $task_id = $request['task_id'];
563
564        if ( ! in_array( $task_id, self::COMPLETE_ON_CLICK_TASK_IDS, true ) ) {
565            return new WP_Error(
566                'ai_launchpad_task_not_completable',
567                __( 'This task cannot be completed this way.', 'jetpack-mu-wpcom' ),
568                array( 'status' => 400 )
569            );
570        }
571
572        // Only tasks the AI put on this site's list may be completed.
573        if ( ! in_array( $task_id, wpcom_ai_launchpad_get_ai_task_ids(), true ) ) {
574            return new WP_Error(
575                'ai_launchpad_task_not_selected',
576                __( 'This task is not on the tailored list.', 'jetpack-mu-wpcom' ),
577                array( 'status' => 404 )
578            );
579        }
580
581        wpcom_mark_launchpad_task_complete( $task_id );
582
583        // Latch now so completing the last task hides the menu on the next page load, not just on the next read.
584        $this->maybe_mark_completed();
585
586        return array(
587            'completed' => true,
588            'task_id'   => $task_id,
589        );
590    }
591
592    /**
593     * Marks a task as skipped: it renders (and counts) as completed without its real completion signal ever firing.
594     *
595     * Restricted to tasks on the site's AI-selected list plus the synthetic ids the list adds itself. Persisted
596     * separately from `launchpad_checklist_tasks_statuses` because several catalog tasks recompute completion live
597     * (memberships, woo, domains) and would ignore a status write; the skip set is overlaid on read instead.
598     *
599     * @param WP_REST_Request $request Request object.
600     * @return array|WP_Error
601     */
602    public function skip_task( $request ) {
603        $task_id = $request['task_id'];
604
605        $skippable = array_merge( wpcom_ai_launchpad_get_ai_task_ids(), self::SYNTHETIC_TASK_IDS );
606        if ( ! in_array( $task_id, $skippable, true ) ) {
607            return new WP_Error(
608                'ai_launchpad_task_not_skippable',
609                __( 'This task is not on the tailored list.', 'jetpack-mu-wpcom' ),
610                array( 'status' => 404 )
611            );
612        }
613
614        $skipped = $this->get_skipped_task_ids();
615        if ( ! in_array( $task_id, $skipped, true ) ) {
616            $skipped[] = $task_id;
617            update_option( self::OPTION_SKIPPED, $skipped, false );
618        }
619
620        // Latch now so skipping the last task hides the menu on the next page load, not just on the next read.
621        $this->maybe_mark_completed();
622
623        return array(
624            'skipped' => true,
625            'task_id' => $task_id,
626        );
627    }
628
629    /**
630     * Deletes the AI output and marks the AI Launchpad as dismissed.
631     *
632     * @return array
633     */
634    public function dismiss() {
635        delete_option( self::OPTION_AI_OUTPUT );
636        delete_option( self::OPTION_SKIPPED );
637        delete_option( self::OPTION_COMPLETED );
638        update_option( self::OPTION_DISMISSED, true, true );
639
640        return array( 'dismissed' => true );
641    }
642
643    /**
644     * The persisted skipped task ids, always as a clean string array.
645     *
646     * @return string[]
647     */
648    private function get_skipped_task_ids() {
649        $skipped = get_option( self::OPTION_SKIPPED, array() );
650
651        return is_array( $skipped ) ? array_values( array_filter( $skipped, 'is_string' ) ) : array();
652    }
653
654    /**
655     * Overlays the persisted skips onto the enriched tasks: a skipped task carries `skipped: true` and is coerced to
656     * completed, so progress, auto-expand, and reloads all treat it as done (a skip must never pop back open).
657     *
658     * @param array $tasks The enriched task list.
659     * @return array
660     */
661    private function apply_skipped_tasks( $tasks ) {
662        $skipped = $this->get_skipped_task_ids();
663
664        foreach ( $tasks as &$task ) {
665            $task['skipped'] = in_array( $task['id'], $skipped, true );
666            if ( $task['skipped'] ) {
667                $task['completed'] = true;
668            }
669        }
670        unset( $task );
671
672        return $tasks;
673    }
674
675    /**
676     * Strips HTML from a subtitle and rejects URLs and template syntax.
677     *
678     * @param string $subtitle The raw subtitle.
679     * @return string|WP_Error The sanitized subtitle, or an error.
680     */
681    private function sanitize_subtitle( $subtitle ) {
682        $subtitle = trim( wp_strip_all_tags( $subtitle, true ) );
683
684        if ( '' === $subtitle ) {
685            return new WP_Error(
686                'ai_launchpad_invalid_subtitle',
687                __( 'Task subtitles must contain text.', 'jetpack-mu-wpcom' ),
688                array( 'status' => 422 )
689            );
690        }
691
692        if ( preg_match( '#https?://#i', $subtitle ) ) {
693            return new WP_Error(
694                'ai_launchpad_subtitle_contains_url',
695                __( 'Task subtitles must not contain URLs.', 'jetpack-mu-wpcom' ),
696                array( 'status' => 422 )
697            );
698        }
699
700        if ( str_contains( $subtitle, '{{' ) || str_contains( $subtitle, '[[' ) ) {
701            return new WP_Error(
702                'ai_launchpad_subtitle_contains_template',
703                __( 'Task subtitles must not contain template syntax.', 'jetpack-mu-wpcom' ),
704                array( 'status' => 422 )
705            );
706        }
707
708        return mb_substr( $subtitle, 0, 200 );
709    }
710
711    /**
712     * Builds the enriched task list for every catalog task, bypassing the visibility gate (backs `?all_tasks=1`).
713     *
714     * Each task is enriched in isolation so one that can't be built is skipped rather than breaking the whole view.
715     *
716     * @return array
717     */
718    private function build_all_catalog_tasks() {
719        $built    = array();
720        $seen_ids = array();
721        foreach ( array_keys( wpcom_launchpad_get_task_definitions() ) as $task_id ) {
722            try {
723                $one = $this->build_tasks(
724                    array(
725                        array(
726                            'id'       => $task_id,
727                            'subtitle' => $task_id,
728                        ),
729                    ),
730                    true
731                );
732            } catch ( \Throwable $e ) {
733                continue;
734            }
735            // build_tasks runs per id here, so its own dedup can't see this collision: the catalog holds both
736            // `woo_launch_site` and `site_launched`, and the former is remapped onto the latter. Keep the first.
737            $card = $one[0] ?? null;
738            if ( null === $card || isset( $seen_ids[ $card['id'] ] ) ) {
739                continue;
740            }
741            $seen_ids[ $card['id'] ] = true;
742            $built[]                 = $card;
743        }
744        return $built;
745    }
746
747    /**
748     * Enriches the persisted tasks with title, completion state, and CTA path from the catalog.
749     *
750     * @param array  $tasks              The persisted `payload.tasks` array.
751     * @param bool   $bypass_visibility  Skip the catalog visibility gate (for the all-tasks testing view).
752     * @param string $niche              The AI-inferred niche, used to pre-filter the theme-picker CTAs.
753     * @param bool   $disable_hidden_woo Keep WOO_TASK_IDS that fail the visibility gate as disabled preview cards
754     *                                   instead of dropping them (sell goal while WooCommerce is inactive).
755     * @return array
756     */
757    private function build_tasks( $tasks, $bypass_visibility = false, $niche = '', $disable_hidden_woo = false ) {
758        $definitions = wpcom_launchpad_get_task_definitions();
759        $built       = array();
760        $seen_ids    = array();
761
762        // Some catalog visibility callbacks call is_plugin_active(), which is not loaded during a REST request.
763        if ( ! function_exists( 'is_plugin_active' ) ) {
764            require_once ABSPATH . 'wp-admin/includes/plugin.php';
765        }
766
767        $is_private_site = $this->is_private_site();
768
769        foreach ( $tasks as $task ) {
770            if ( ! is_array( $task ) || ! isset( $task['id'] ) || ! isset( $task['subtitle'] ) ) {
771                continue;
772            }
773
774            // The WooCommerce launch task deep-links into the WC onboarding task list, which renders blank when the
775            // guided setup was skipped, and only completes via a WC option the skipped-setup flow never writes. Normalize
776            // it to the canonical site-launch task, which reads the real launch signal and self-completes. The prompt no
777            // longer offers it, so this only catches a stray AI emission.
778            if ( 'woo_launch_site' === $task['id'] ) {
779                $task['id'] = 'site_launched';
780            }
781
782            if ( ! isset( $definitions[ $task['id'] ] ) ) {
783                continue;
784            }
785
786            // One card per id — the client keys cards by id. The woo_launch_site→site_launched remap above can collide
787            // with a real site_launched (notably the ?all_tasks=1 view, which enumerates every catalog id), so collapse
788            // any repeat to the first occurrence.
789            if ( isset( $seen_ids[ $task['id'] ] ) ) {
790                continue;
791            }
792
793            // The Jetpack Social tasks point at an admin page wpcom doesn't load on a private site, so hide them there.
794            if ( $is_private_site && in_array( $task['id'], self::SOCIAL_PAGE_TASK_IDS, true ) ) {
795                continue;
796            }
797
798            $definition       = $definitions[ $task['id'] ];
799            $definition['id'] = $task['id'];
800            $disabled         = false;
801
802            // Honor the catalog's own visibility gate: a task the catalog would hide here must not render, since its
803            // CTA would 404 and it could never complete. Filtered on read so the deterministic fallback stays usable.
804            if (
805                ! $bypass_visibility
806                && ! in_array( $task['id'], self::FORCE_VISIBLE_TASK_IDS, true )
807                && ! wpcom_launchpad_checklists()->is_visible( $definition )
808            ) {
809                // On a sell site without WooCommerce, keep the commerce tasks as a disabled preview of the store
810                // roadmap rather than dropping them and collapsing the list. Everything else stays hidden.
811                if ( $disable_hidden_woo && in_array( $task['id'], self::WOO_TASK_IDS, true ) ) {
812                    $disabled = true;
813                } else {
814                    continue;
815                }
816            }
817
818            if ( $disabled ) {
819                // A disabled preview always renders as the locked card: never resolve its completion (the woo
820                // completion callback marks the task complete as a side effect, which must not fire on a read) and
821                // never resolve a CTA path (it has no reachable action).
822                $completed    = false;
823                $calypso_path = null;
824            } else {
825                // The membership tasks' catalog callbacks are always false on Atomic; recompute from local signals.
826                $completed = AI_Launchpad_Memberships::has_override( $task['id'] )
827                    ? AI_Launchpad_Memberships::is_task_complete( $task['id'] )
828                    : wpcom_launchpad_checklists()->is_task_complete( $definition );
829
830                $theme_showcase_path = $this->get_themes_showcase_path( $task['id'], $niche );
831                $cta_override        = $this->get_cta_override( $task['id'] );
832                if ( null !== $theme_showcase_path ) {
833                    $calypso_path = $theme_showcase_path;
834                } elseif ( null !== $cta_override ) {
835                    $calypso_path = $cta_override;
836                } else {
837                    $calypso_path = wpcom_launchpad_checklists()->load_calypso_path( $definition );
838                }
839
840                // Simple sites have no reachable wp-admin plugins screen; route any plugin-screen CTA to Calypso.
841                $calypso_path = $this->to_simple_plugins_path( $calypso_path );
842            }
843
844            $title       = isset( $definition['get_title'] ) ? $definition['get_title']() : '';
845            $in_progress = false;
846
847            // A saved-but-unpublished draft (found by marker meta) puts a site-editor task "in progress": reopen that
848            // draft instead of creating a new one, and surface the drafts icon + a "Continue…" prompt in the card.
849            if ( ! $completed && ! $disabled ) {
850                $draft_url = $this->get_in_progress_draft_url( $task['id'] );
851                if ( null !== $draft_url ) {
852                    $in_progress  = true;
853                    $calypso_path = $draft_url;
854                }
855            }
856
857            // Title follows our precise in-progress signal so it, the icon, and the CTA agree.
858            $title = $this->get_task_title( $task['id'], $in_progress, $title );
859
860            $seen_ids[ $task['id'] ] = true;
861            $built[]                 = array(
862                'id'           => $task['id'],
863                'subtitle'     => $task['subtitle'],
864                'title'        => $title,
865                'completed'    => $completed,
866                'in_progress'  => $in_progress,
867                'disabled'     => $disabled,
868                'calypso_path' => $calypso_path,
869            );
870        }
871
872        return $built;
873    }
874
875    /**
876     * The wp-admin CTA destination the AI Launchpad substitutes for a task's catalog path, or null to keep the catalog's.
877     *
878     * Static repoints live in CTA_OVERRIDES; `add_subscribe_block` is resolved here because its destination depends on
879     * the active theme: the Site Editor is where a block theme adds the Subscribe block to a template (the action its
880     * completion listener watches), and the block-based widget editor is the closest equivalent on a classic theme
881     * (normally unreachable — the task's catalog visibility is FSE-only — but the theme can change after tailoring).
882     *
883     * @param string $task_id The catalog task id.
884     * @return string|null
885     */
886    private function get_cta_override( $task_id ) {
887        if ( 'add_subscribe_block' === $task_id ) {
888            return admin_url( wp_is_block_theme() ? 'site-editor.php' : 'widgets.php' );
889        }
890
891        if ( isset( self::CTA_OVERRIDES[ $task_id ] ) ) {
892            return admin_url( self::CTA_OVERRIDES[ $task_id ] );
893        }
894
895        return null;
896    }
897
898    /**
899     * On Simple sites, rewrite a wp-admin plugins-screen CTA to its Calypso equivalent.
900     *
901     * Simple sites have no reachable wp-admin plugins UI, so any task whose CTA lands on `plugins.php` or
902     * `plugin-install.php` would dead-end. Those are mapped to the Calypso plugins page — a specific plugin
903     * when a slug is given, otherwise the site's plugins list. Non-plugin paths and Atomic sites pass through.
904     *
905     * @param string|null $path        The resolved CTA path.
906     * @param string      $plugin_slug Optional plugin slug to deep-link to on Calypso.
907     * @return string|null
908     */
909    private function to_simple_plugins_path( $path, $plugin_slug = '' ) {
910        if ( ! ( defined( 'IS_WPCOM' ) && IS_WPCOM ) || ! is_string( $path ) ) {
911            return $path;
912        }
913
914        if ( false === strpos( $path, 'plugins.php' ) && false === strpos( $path, 'plugin-install.php' ) ) {
915            return $path;
916        }
917
918        $slug_segment = '' !== $plugin_slug ? rawurlencode( $plugin_slug ) . '/' : '';
919        return '/plugins/' . $slug_segment . rawurlencode( wpcom_get_site_slug() );
920    }
921
922    /**
923     * The wordpress.com themes-showcase path for a theme-picker task, pre-filtered by the inferred niche.
924     *
925     * Returns null for non-theme tasks or when no niche was inferred, so the caller falls back to the task's
926     * default CTA. The niche is passed as the showcase's free-text search term (`?s=`), and the client's
927     * `toNavigableUrl` resolves the relative path against wordpress.com.
928     *
929     * @param string $task_id The catalog task id.
930     * @param string $niche   The AI-inferred niche.
931     * @return string|null
932     */
933    private function get_themes_showcase_path( $task_id, $niche ) {
934        if ( '' === $niche || ! in_array( $task_id, self::THEME_TASK_IDS, true ) ) {
935            return null;
936        }
937
938        return '/themes/' . rawurlencode( wpcom_get_site_slug() ) . '?s=' . rawurlencode( $this->niche_to_search_term( $niche ) );
939    }
940
941    /**
942     * Reduces a possibly multi-word niche to a single keyword for the themes-showcase search.
943     *
944     * The showcase ANDs its search terms, so a phrase like "ceramics and pottery" matches no theme even
945     * though "ceramics" and "pottery" each do. Connective/filler words are dropped and the first remaining
946     * keyword is kept. Falls back to the trimmed niche when nothing survives filtering.
947     *
948     * @param string $niche The AI-inferred niche.
949     * @return string
950     */
951    private function niche_to_search_term( $niche ) {
952        $stop_words = array( 'and', 'or', 'the', 'a', 'an', 'of', 'for', 'with', 'in', 'on', 'to', 'your', 'my' );
953        $words      = preg_split( '/[\s,&]+/', strtolower( $niche ), -1, PREG_SPLIT_NO_EMPTY );
954
955        foreach ( $words as $word ) {
956            if ( ! in_array( $word, $stop_words, true ) ) {
957                return $word;
958            }
959        }
960
961        return trim( $niche );
962    }
963
964    /**
965     * Visual-work niche keywords that (along with the `portfolio` goal) surface the synthetic gallery task.
966     */
967    const GALLERY_NICHE_KEYWORDS = array(
968        'photography',
969        'photo',
970        'photos',
971        'photographer',
972        'portfolio',
973        'gallery',
974        'art',
975        'artist',
976        'illustration',
977        'illustrator',
978        'design',
979        'designer',
980        'visual',
981        'painting',
982        'drawing',
983    );
984
985    /**
986     * Whether the synthetic "Create your first gallery" task should be offered, based on the inferred goal/niche.
987     *
988     * @param array $inferred The AI output's `inferred` block.
989     * @return bool
990     */
991    private function should_offer_gallery_task( $inferred ) {
992        $goal = isset( $inferred['goal'] ) && is_string( $inferred['goal'] ) ? $inferred['goal'] : '';
993        if ( 'portfolio' === $goal ) {
994            return true;
995        }
996
997        $niche = isset( $inferred['niche'] ) && is_string( $inferred['niche'] ) ? strtolower( $inferred['niche'] ) : '';
998        if ( '' === $niche ) {
999            return false;
1000        }
1001
1002        // Split on any non-alphanumeric run so hyphenated/compound niches ("wildlife-photography") tokenize like the client.
1003        $words = preg_split( '/[^a-z0-9]+/', $niche, -1, PREG_SPLIT_NO_EMPTY );
1004        return array() !== array_intersect( $words, self::GALLERY_NICHE_KEYWORDS );
1005    }
1006
1007    /**
1008     * Builds the synthetic gallery-task entry, or null when it should not be offered.
1009     *
1010     * Its id is listed in SYNTHETIC_TASK_IDS so the task stays skippable.
1011     *
1012     * Completion is read from the status option (written by AI_Launchpad_Gallery_Page_Listener on publish); an
1013     * unpublished marker draft puts it in progress and reopens that draft.
1014     *
1015     * @param array $inferred The AI output's `inferred` block.
1016     * @return array|null
1017     */
1018    private function build_gallery_task( $inferred ) {
1019        if ( ! $this->should_offer_gallery_task( $inferred ) ) {
1020            return null;
1021        }
1022
1023        $statuses  = (array) get_option( 'launchpad_checklist_tasks_statuses', array() );
1024        $completed = ! empty( $statuses['add_gallery_page'] );
1025
1026        $in_progress  = false;
1027        $calypso_path = null;
1028        if ( ! $completed ) {
1029            $draft_url = $this->get_in_progress_draft_url( 'add_gallery_page' );
1030            if ( null !== $draft_url ) {
1031                $in_progress  = true;
1032                $calypso_path = $draft_url;
1033            }
1034        }
1035
1036        return array(
1037            'id'           => 'add_gallery_page',
1038            'subtitle'     => __( 'Show your work in a beautiful photo gallery.', 'jetpack-mu-wpcom' ),
1039            'title'        => $this->get_task_title( 'add_gallery_page', $in_progress, __( 'Create your first gallery', 'jetpack-mu-wpcom' ) ),
1040            'completed'    => $completed,
1041            'in_progress'  => $in_progress,
1042            'disabled'     => false,
1043            'calypso_path' => $calypso_path,
1044        );
1045    }
1046
1047    /**
1048     * Builds the synthetic store-setup lead tasks for the sell goal: an "install WooCommerce" task and a "set up
1049     * your store" task. Their ids are listed in SYNTHETIC_TASK_IDS so the tasks stay skippable.
1050     *
1051     * Both are read live (installed/active/profiler options), so no marker or listener is needed. While WooCommerce
1052     * is inactive the setup task shows as a disabled preview, matching the disabled commerce tasks below it. Callers
1053     * gate this on the sell goal.
1054     *
1055     * @param bool $active Whether WooCommerce is active.
1056     * @return array The lead tasks in display order.
1057     */
1058    private function build_store_tasks( $active ) {
1059        return array(
1060            $this->build_install_woocommerce_task( $active ),
1061            $this->build_setup_store_task( $active ),
1062        );
1063    }
1064
1065    /**
1066     * The "Install the WooCommerce plugin" lead task: to-do until the plugin exists, in-progress while it is
1067     * installed-but-inactive, and complete once active.
1068     *
1069     * @param bool $active Whether WooCommerce is active.
1070     * @return array
1071     */
1072    private function build_install_woocommerce_task( $active ) {
1073        $in_progress = ! $active && array_key_exists( 'woocommerce/woocommerce.php', get_plugins() );
1074
1075        $calypso_path = null;
1076        if ( ! $active ) {
1077            // Installed-but-inactive activates from the plugins list; not-installed installs from the plugin search.
1078            // On Simple both wp-admin screens are unreachable, so route through the Calypso WooCommerce plugin page.
1079            $wp_admin_path = $in_progress
1080                ? admin_url( 'plugins.php?plugin_status=inactive' )
1081                : admin_url( 'plugin-install.php?s=woocommerce&tab=search&type=term' );
1082            $calypso_path  = $this->to_simple_plugins_path( $wp_admin_path, 'woocommerce' );
1083        }
1084
1085        return array(
1086            'id'           => 'install_woocommerce',
1087            'subtitle'     => $in_progress
1088                ? __( 'Activate the WooCommerce plugin to continue.', 'jetpack-mu-wpcom' )
1089                : __( 'Add the WooCommerce plugin to start selling.', 'jetpack-mu-wpcom' ),
1090            'title'        => __( 'Install the WooCommerce plugin', 'jetpack-mu-wpcom' ),
1091            'completed'    => $active,
1092            'in_progress'  => $in_progress,
1093            'disabled'     => false,
1094            'calypso_path' => $calypso_path,
1095        );
1096    }
1097
1098    /**
1099     * The "Set up your store" lead task: to-do until the WooCommerce setup wizard (core profiler) is completed or
1100     * skipped, then complete. Shown as a disabled preview until WooCommerce is active, since the wizard needs it.
1101     *
1102     * @param bool $active Whether WooCommerce is active.
1103     * @return array
1104     */
1105    private function build_setup_store_task( $active ) {
1106        $profile   = (array) get_option( 'woocommerce_onboarding_profile', array() );
1107        $completed = $active && ( ! empty( $profile['completed'] ) || ! empty( $profile['skipped'] ) );
1108
1109        return array(
1110            'id'           => 'setup_woocommerce_store',
1111            'subtitle'     => __( 'Complete or skip the WooCommerce setup wizard.', 'jetpack-mu-wpcom' ),
1112            'title'        => __( 'Set up your store', 'jetpack-mu-wpcom' ),
1113            'completed'    => $completed,
1114            'in_progress'  => false,
1115            'disabled'     => ! $active,
1116            'calypso_path' => $completed || ! $active ? null : admin_url( 'admin.php?page=wc-admin&path=%2Fsetup-wizard' ),
1117        );
1118    }
1119
1120    /**
1121     * Inserts a synthetic task immediately before the trailing launch task (or appends it), idempotently by id.
1122     *
1123     * @param array $tasks The enriched task list.
1124     * @param array $task  The synthetic task entry.
1125     * @return array
1126     */
1127    private function insert_before_launch_task( $tasks, $task ) {
1128        foreach ( $tasks as $existing ) {
1129            if ( isset( $existing['id'] ) && $existing['id'] === $task['id'] ) {
1130                return $tasks;
1131            }
1132        }
1133
1134        $insert_at = count( $tasks );
1135        foreach ( $tasks as $index => $existing ) {
1136            if ( isset( $existing['id'] ) && in_array( $existing['id'], self::LAUNCH_TASK_IDS, true ) ) {
1137                $insert_at = $index;
1138                break;
1139            }
1140        }
1141
1142        array_splice( $tasks, $insert_at, 0, array( $task ) );
1143        return $tasks;
1144    }
1145
1146    /**
1147     * Resolves the editor URL of a site-editor task's in-progress draft, or null when there is none.
1148     *
1149     * The About page is found by its marker meta; the first-post tasks by the latest draft post. Returned as an
1150     * `admin_url()` so the client reopens the existing draft rather than creating a duplicate.
1151     *
1152     * @param string $task_id The catalog task id.
1153     * @return string|null
1154     */
1155    private function get_in_progress_draft_url( $task_id ) {
1156        $draft_id = null;
1157
1158        if ( 'add_about_page' === $task_id ) {
1159            $draft_id = AI_Launchpad_About_Page_Listener::get_draft_id();
1160        } elseif ( 'add_gallery_page' === $task_id ) {
1161            $draft_id = AI_Launchpad_Gallery_Page_Listener::get_draft_id();
1162        } elseif ( in_array( $task_id, self::IN_PROGRESS_FIRST_POST_TASK_IDS, true ) ) {
1163            $draft_id = AI_Launchpad_First_Post_Listener::get_draft_id();
1164        }
1165
1166        if ( null === $draft_id ) {
1167            return null;
1168        }
1169
1170        return admin_url( 'post.php?post=' . $draft_id . '&action=edit' );
1171    }
1172
1173    /**
1174     * The card title for a site-editor task, chosen by our precise (marker-based) in-progress signal so the title,
1175     * icon, and CTA stay in agreement.
1176     *
1177     * This overrides `first_post_published`'s catalog title in both states: the catalog swaps it to "Continue…"
1178     * whenever ANY draft exists (a looser signal than our marker), so an unrelated draft would otherwise show a
1179     * "Continue…" title beside the not-started icon. Tasks not listed keep their catalog title.
1180     *
1181     * @param string $task_id     The catalog task id.
1182     * @param bool   $in_progress Whether our marker detected an in-progress draft.
1183     * @param string $default     The catalog-provided title, kept when we don't override.
1184     * @return string
1185     */
1186    private function get_task_title( $task_id, $in_progress, $default ) {
1187        switch ( $task_id ) {
1188            case 'add_about_page':
1189                return $in_progress ? __( 'Continue working on the About page', 'jetpack-mu-wpcom' ) : $default;
1190            case 'add_gallery_page':
1191                return $in_progress ? __( 'Continue working on your gallery', 'jetpack-mu-wpcom' ) : $default;
1192            case 'first_post_published':
1193                return $in_progress
1194                    ? __( 'Continue to write your first post', 'jetpack-mu-wpcom' )
1195                    : __( 'Write your first post', 'jetpack-mu-wpcom' );
1196            case 'first_post_published_newsletter':
1197                return $in_progress ? __( 'Continue writing your first post', 'jetpack-mu-wpcom' ) : $default;
1198            default:
1199                return $default;
1200        }
1201    }
1202
1203    /**
1204     * Loads the agent output schema used to validate `PUT /tailored` bodies.
1205     *
1206     * @return array
1207     */
1208    private function get_output_schema() {
1209        static $schema = null;
1210
1211        if ( null === $schema ) {
1212            $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.
1213        }
1214
1215        return $schema;
1216    }
1217}
1218
1219// @phan-suppress-next-line PhanNoopNew -- instantiated for the constructor's add_action side effect.
1220new AI_Launchpad_REST();