Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.39% covered (success)
93.39%
438 / 469
80.00% covered (warning)
80.00%
20 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
Backup_Abilities
93.39% covered (success)
93.39%
438 / 469
80.00% covered (warning)
80.00%
20 / 25
143.50
0.00% covered (danger)
0.00%
0 / 1
 get_category_slug
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_category_definition
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 register_category
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_abilities
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 backup_is_loaded
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_abilities
100.00% covered (success)
100.00%
243 / 243
100.00% covered (success)
100.00%
1 / 1
1
 can_view_backups
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 can_manage_backups
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute_get_backup_overview
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 execute_list_backups
49.02% covered (danger)
49.02%
25 / 51
0.00% covered (danger)
0.00%
0 / 1
167.68
 execute_list_restores
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 pick_backup_near_timestamp
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
11
 extract_rewindable_items
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
8.06
 summarize_backup_event
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
12.02
 map_event_status
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 event_has_warnings
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 parse_timestamp
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 execute_request_backup
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 unwrap_response
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 apply_id_or_pagination
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
 summarize_last_backup
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 summarize_backup
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 summarize_restore
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 summarize_schedule
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 summarize_storage
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
14
1<?php
2/**
3 * Jetpack Backup Abilities Registration.
4 *
5 * Registers Jetpack Backup abilities with the WordPress Abilities API so AI
6 * agents can read backup status and trigger on-demand backups through the
7 * standard `wp-abilities/v1` REST surface.
8 *
9 * @package automattic/jetpack-backup
10 */
11
12namespace Automattic\Jetpack\Backup\V0005\Abilities;
13
14use Automattic\Jetpack\Backup\V0005\Jetpack_Backup;
15use Automattic\Jetpack\My_Jetpack\Products\Backup as My_Jetpack_Backup;
16use Automattic\Jetpack\WP_Abilities\Registrar;
17use WP_Error;
18use WP_REST_Response;
19
20/**
21 * Registers Jetpack Backup abilities with the WordPress Abilities API.
22 *
23 * Exposes a small, agent-friendly surface for site backups:
24 *
25 * - `jetpack-backup/get-backup-overview` — single-call site backup health snapshot.
26 * - `jetpack-backup/list-backups` — recent backups with optional id/pagination filters.
27 * - `jetpack-backup/list-restores` — recent restores with optional id/pagination filters.
28 * - `jetpack-backup/request-backup` — enqueue an on-demand backup.
29 */
30class Backup_Abilities extends Registrar {
31
32    const PER_PAGE_DEFAULT = 20;
33    const PER_PAGE_MAX     = 100;
34
35    /**
36     * Return the ability category slug.
37     *
38     * @return string
39     */
40    public static function get_category_slug(): string {
41        return 'site';
42    }
43
44    /**
45     * Required by the abstract parent, but unused: the `site` category is
46     * already registered upstream (WordPress core / wpcom), so we don't
47     * re-declare it. Kept so the contract holds if a consumer ever asks for
48     * the definition we *would* use.
49     *
50     * @return array
51     */
52    public static function get_category_definition(): array {
53        return array(
54            'label'       => __( 'Site', 'jetpack-backup-pkg' ),
55            'description' => __( 'Site-wide management abilities (registered upstream).', 'jetpack-backup-pkg' ),
56        );
57    }
58
59    /**
60     * Override the Registrar lifecycle so the backup abilities only register
61     * on sites that actually have a Jetpack Backup product provisioned.
62     * Mirrors the gating done in the Jetpack dashboard / My Jetpack — there's
63     * no point exposing tool surfaces an agent can never use, and on free
64     * sites the upstream wpcom endpoints either silently accept writes (e.g.
65     * `request-backup` reported `enqueued: true`) or return null payloads
66     * that confuse callers.
67     *
68     * The `site` category is registered upstream by WordPress core / wpcom,
69     * so this class never tries to register a category — `register_category`
70     * is a no-op even though the parent hooks it.
71     *
72     * @return void
73     */
74    public static function register_category() {
75        // No-op: `site` is registered upstream; re-registering would either
76        // no-op or trigger "already registered" notices.
77    }
78
79    /**
80     * Register every ability returned by `get_abilities()`, gated on the
81     * Backup product being loaded. See `register_category()` for why we
82     * never register a category from here.
83     *
84     * @return void
85     */
86    public static function register_abilities() {
87        if ( ! self::backup_is_loaded() ) {
88            return;
89        }
90        parent::register_abilities();
91    }
92
93    /**
94     * Is the Jetpack Backup product actually loaded on this site?
95     *
96     * Defaults to `My_Jetpack\Products\Backup::is_active()` — the same
97     * boolean the Jetpack dashboard uses to decide whether the Backup
98     * product is usable. That returns true when the plugin is active and
99     * the site has a Backup plan (covering `STATUS_ACTIVE`,
100     * `STATUS_EXPIRING_SOON`, and the `STATUS_NEEDS_ATTENTION__*` states),
101     * and false for `STATUS_EXPIRED`, `STATUS_NEEDS_PLAN`,
102     * `STATUS_MODULE_DISABLED`, and the connection-error states. The plan
103     * lookup is cached for 15s in `MY_JETPACK_SITE_FEATURES_TRANSIENT_KEY`,
104     * so the cost on a real wpcom call is paid at most once per 15 seconds
105     * across the whole My Jetpack surface.
106     *
107     * The `jetpack_backup_abilities_should_load` filter lets consumers and
108     * tests override the answer without round-tripping through the My
109     * Jetpack product class.
110     *
111     * @return bool
112     */
113    private static function backup_is_loaded(): bool {
114        $default = class_exists( My_Jetpack_Backup::class ) && My_Jetpack_Backup::is_active();
115
116        /**
117         * Filters whether the Jetpack Backup abilities should register on
118         * this site. Defaults to `My_Jetpack\Products\Backup::is_active()`.
119         *
120         * @since 0.1.0
121         *
122         * @param bool $should_load Whether to register the backup abilities.
123         */
124        return (bool) apply_filters( 'jetpack_backup_abilities_should_load', $default );
125    }
126
127    /**
128     * Return the abilities this Registrar exposes, keyed by slug.
129     *
130     * @return array<string, array<string, mixed>>
131     */
132    public static function get_abilities(): array {
133        // `id` is the rewind_id (a timestamp-fractional string like
134        // "1752860369.781") — the single cross-system identifier exposed by
135        // the wpcom rewind, restore, and activity-log APIs. Use it whenever
136        // referring to a specific backup across abilities.
137        //
138        // For in-progress backup attempts the rewind_id isn't assigned yet,
139        // in which case `id` is null. Such backups can't be looked up by id
140        // anywhere — wait for completion and re-query.
141        $backup_item_schema = array(
142            'type'       => 'object',
143            'properties' => array(
144                'id'            => array( 'type' => array( 'string', 'null' ) ),
145                'started'       => array( 'type' => array( 'string', 'null' ) ),
146                'last_updated'  => array( 'type' => array( 'string', 'null' ) ),
147                'status'        => array( 'type' => array( 'string', 'null' ) ),
148                'period'        => array( 'type' => array( 'string', 'integer', 'null' ) ),
149                'is_rewindable' => array( 'type' => array( 'boolean', 'null' ) ),
150                'has_warnings'  => array( 'type' => array( 'boolean', 'null' ) ),
151            ),
152        );
153
154        // Same convention applies to restores: `id` is the rewind_id of the
155        // backup being restored to.
156        $restore_item_schema = array(
157            'type'       => 'object',
158            'properties' => array(
159                'id'           => array( 'type' => array( 'string', 'null' ) ),
160                'started'      => array( 'type' => array( 'string', 'null' ) ),
161                'last_updated' => array( 'type' => array( 'string', 'null' ) ),
162                'status'       => array( 'type' => array( 'string', 'null' ) ),
163                'progress'     => array( 'type' => array( 'integer', 'null' ) ),
164            ),
165        );
166
167        return array(
168            'jetpack-backup/get-backup-overview' => array(
169                'label'               => __( 'Get backup overview', 'jetpack-backup-pkg' ),
170                'description'         => __(
171                    'Return a single-call snapshot of the site backup state: { last_backup, recent_backup_count, schedule, storage }. Use this to answer "is my site protected?" before deciding whether to call list-backups, list-restores, or request-backup. Read-only and idempotent. Fields whose backing service is unreachable come back as null rather than failing the call. Requires the manage_options capability.',
172                    'jetpack-backup-pkg'
173                ),
174                'input_schema'        => array(
175                    'type'                 => 'object',
176                    'default'              => array(),
177                    'properties'           => array(),
178                    'additionalProperties' => false,
179                ),
180                'output_schema'       => array(
181                    'type'       => 'object',
182                    'properties' => array(
183                        'recent_backup_count' => array( 'type' => array( 'integer', 'null' ) ),
184                        'last_backup'         => array(
185                            'type'       => array( 'object', 'null' ),
186                            'properties' => array(
187                                'id'            => array( 'type' => array( 'string', 'null' ) ),
188                                'last_updated'  => array( 'type' => array( 'string', 'null' ) ),
189                                'status'        => array( 'type' => array( 'string', 'null' ) ),
190                                'is_rewindable' => array( 'type' => array( 'boolean', 'null' ) ),
191                                'has_warnings'  => array( 'type' => array( 'boolean', 'null' ) ),
192                            ),
193                        ),
194                        // Hour only: no WordPress.com endpoint carries a minute,
195                        // so a `minute` field could only ever be null.
196                        'schedule'            => array(
197                            'type'       => array( 'object', 'null' ),
198                            'properties' => array(
199                                'hour' => array( 'type' => array( 'integer', 'null' ) ),
200                            ),
201                        ),
202                        'storage'             => array(
203                            'type'       => array( 'object', 'null' ),
204                            'properties' => array(
205                                'used_bytes'  => array( 'type' => array( 'integer', 'null' ) ),
206                                'limit_bytes' => array( 'type' => array( 'integer', 'null' ) ),
207                            ),
208                        ),
209                    ),
210                ),
211                'execute_callback'    => array( __CLASS__, 'execute_get_backup_overview' ),
212                'permission_callback' => array( __CLASS__, 'can_view_backups' ),
213                'meta'                => array(
214                    'annotations'  => array(
215                        'readonly'    => true,
216                        'destructive' => false,
217                        'idempotent'  => true,
218                    ),
219                    'mcp'          => array(
220                        'public' => true,
221                        'type'   => 'tool',
222                    ),
223                    'show_in_rest' => true,
224                ),
225            ),
226
227            'jetpack-backup/list-backups'        => array(
228                'label'               => __( 'List backups', 'jetpack-backup-pkg' ),
229                'description'         => __(
230                    'Return zero or more backups as an array. Each item summarises one backup: { id, rewind_id, started, last_updated, status, period, is_rewindable, has_warnings }. Combine filters to narrow the result without making multiple calls. `id` returns a 0- or 1-element array for a single rewind_id. `date_from` and `date_to` window the results (ISO 8601 datetimes; server-side filter). `date` + `match` ("on_or_before" default, "on_or_after", "closest") pick a single backup near a target datetime — useful for "find a restore point near this incident"; the response stays a 0- or 1-element array. `status` filters by mapped status (e.g. "finished", "error"). `page` + `per_page` paginate the result; iterate `page=1,2,...` until you get an empty array. A page may come back with fewer than `per_page` items even when more pages exist — `status` is applied client-side, and non-backup events are filtered out — so only an empty page reliably signals end of history. Read-only and idempotent. Backed by the wpcom activity-log feed.',
231                    'jetpack-backup-pkg'
232                ),
233                'input_schema'        => array(
234                    'type'                 => 'object',
235                    'default'              => array(),
236                    'properties'           => array(
237                        'id'        => array(
238                            'type'        => 'string',
239                            'description' => __( 'Return only the backup with this rewind_id. Unknown ids yield an empty array.', 'jetpack-backup-pkg' ),
240                            'minLength'   => 1,
241                        ),
242                        'date_from' => array(
243                            'type'        => 'string',
244                            'format'      => 'date-time',
245                            'description' => __( 'Lower bound (inclusive) on backup `started` time. ISO 8601 datetime, e.g. "2026-04-01T00:00:00Z".', 'jetpack-backup-pkg' ),
246                            'minLength'   => 1,
247                        ),
248                        'date_to'   => array(
249                            'type'        => 'string',
250                            'format'      => 'date-time',
251                            'description' => __( 'Upper bound (inclusive) on backup `started` time. ISO 8601 datetime, e.g. "2026-04-30T23:59:59Z".', 'jetpack-backup-pkg' ),
252                            'minLength'   => 1,
253                        ),
254                        'date'      => array(
255                            'type'        => 'string',
256                            'format'      => 'date-time',
257                            'description' => __( 'Target datetime to find a single matching backup. When set, the response is a 0- or 1-element array. Pair with `match` to choose direction.', 'jetpack-backup-pkg' ),
258                            'minLength'   => 1,
259                        ),
260                        'match'     => array(
261                            'type'        => 'string',
262                            'enum'        => array( 'on_or_before', 'on_or_after', 'closest' ),
263                            'default'     => 'on_or_before',
264                            'description' => __( 'How to interpret `date`. "on_or_before" (default; the latest backup at or before the target — typical for restores), "on_or_after" (earliest backup at or after), or "closest" (smallest absolute time difference). Ignored when `date` is not set.', 'jetpack-backup-pkg' ),
265                        ),
266                        'status'    => array(
267                            'type'        => 'string',
268                            'description' => __( 'Filter by mapped status string (e.g. "finished", "error"). Applied client-side after the server query.', 'jetpack-backup-pkg' ),
269                            'minLength'   => 1,
270                        ),
271                        'page'      => array(
272                            'type'        => 'integer',
273                            'description' => __( '1-based page number. Ignored when `id` or `date` is set (those are single-result lookups).', 'jetpack-backup-pkg' ),
274                            'default'     => 1,
275                            'minimum'     => 1,
276                        ),
277                        'per_page'  => array(
278                            'type'        => 'integer',
279                            'description' => __( 'Cap on items returned per page (default 20, max 100). Also bounds the server-side query window the date filters are evaluated against.', 'jetpack-backup-pkg' ),
280                            'default'     => self::PER_PAGE_DEFAULT,
281                            'minimum'     => 1,
282                            'maximum'     => self::PER_PAGE_MAX,
283                        ),
284                    ),
285                    'additionalProperties' => false,
286                ),
287                'output_schema'       => array(
288                    'type'  => 'array',
289                    'items' => $backup_item_schema,
290                ),
291                'execute_callback'    => array( __CLASS__, 'execute_list_backups' ),
292                'permission_callback' => array( __CLASS__, 'can_view_backups' ),
293                'meta'                => array(
294                    'annotations'  => array(
295                        'readonly'    => true,
296                        'destructive' => false,
297                        'idempotent'  => true,
298                    ),
299                    'mcp'          => array(
300                        'public' => true,
301                        'type'   => 'tool',
302                    ),
303                    'show_in_rest' => true,
304                ),
305            ),
306
307            'jetpack-backup/list-restores'       => array(
308                'label'               => __( 'List restores', 'jetpack-backup-pkg' ),
309                'description'         => __(
310                    'Return zero or more recent restore operations as an array. Each item: { id, rewind_id, started, last_updated, status, progress }. Pass id to fetch a single restore (returns 0- or 1-element array). Otherwise paginate with page and per_page (default 20, max 100). Read-only and idempotent.',
311                    'jetpack-backup-pkg'
312                ),
313                'input_schema'        => array(
314                    'type'                 => 'object',
315                    'default'              => array(),
316                    'properties'           => array(
317                        'id'       => array(
318                            'type'        => 'string',
319                            'description' => __( 'Return only the restore with this id. Unknown ids yield an empty array.', 'jetpack-backup-pkg' ),
320                            'minLength'   => 1,
321                        ),
322                        'page'     => array(
323                            'type'        => 'integer',
324                            'description' => __( 'Page number, 1-based.', 'jetpack-backup-pkg' ),
325                            'default'     => 1,
326                            'minimum'     => 1,
327                        ),
328                        'per_page' => array(
329                            'type'        => 'integer',
330                            'description' => __( 'Items per page (default 20, max 100).', 'jetpack-backup-pkg' ),
331                            'default'     => self::PER_PAGE_DEFAULT,
332                            'minimum'     => 1,
333                            'maximum'     => self::PER_PAGE_MAX,
334                        ),
335                    ),
336                    'additionalProperties' => false,
337                ),
338                'output_schema'       => array(
339                    'type'  => 'array',
340                    'items' => $restore_item_schema,
341                ),
342                'execute_callback'    => array( __CLASS__, 'execute_list_restores' ),
343                'permission_callback' => array( __CLASS__, 'can_view_backups' ),
344                'meta'                => array(
345                    'annotations'  => array(
346                        'readonly'    => true,
347                        'destructive' => false,
348                        'idempotent'  => true,
349                    ),
350                    'mcp'          => array(
351                        'public' => true,
352                        'type'   => 'tool',
353                    ),
354                    'show_in_rest' => true,
355                ),
356            ),
357
358            'jetpack-backup/request-backup'      => array(
359                'label'               => __( 'Request a backup', 'jetpack-backup-pkg' ),
360                'description'         => __(
361                    'Enqueue an on-demand backup of this site. Returns { enqueued: bool, message: string }. Each successful call queues a new backup job; this is a state-changing write, not idempotent. Use get-backup-overview or list-backups afterwards to track progress. Requires the manage_options capability. Returns jetpack_backup_data_unavailable when the upstream service rejects the request.',
362                    'jetpack-backup-pkg'
363                ),
364                'input_schema'        => array(
365                    'type'                 => 'object',
366                    'default'              => array(),
367                    'properties'           => array(),
368                    'additionalProperties' => false,
369                ),
370                'output_schema'       => array(
371                    'type'       => 'object',
372                    'properties' => array(
373                        'enqueued' => array( 'type' => 'boolean' ),
374                        'message'  => array( 'type' => 'string' ),
375                    ),
376                ),
377                'execute_callback'    => array( __CLASS__, 'execute_request_backup' ),
378                'permission_callback' => array( __CLASS__, 'can_manage_backups' ),
379                'meta'                => array(
380                    'annotations'  => array(
381                        'readonly'    => false,
382                        'destructive' => false,
383                        'idempotent'  => false,
384                    ),
385                    'mcp'          => array(
386                        'public' => true,
387                        'type'   => 'tool',
388                    ),
389                    'show_in_rest' => true,
390                ),
391            ),
392        );
393    }
394
395    /**
396     * Permission check for read abilities. Gates on `manage_options` to
397     * match the existing REST controller (see
398     * Jetpack_Backup::backups_permissions_callback). Kept separate from
399     * `can_manage_backups()` so the read and write surfaces can diverge
400     * later without touching every spec.
401     *
402     * @return bool
403     */
404    public static function can_view_backups(): bool {
405        return current_user_can( 'manage_options' );
406    }
407
408    /**
409     * Permission check for write abilities. See `can_view_backups()`.
410     *
411     * @return bool
412     */
413    public static function can_manage_backups(): bool {
414        return current_user_can( 'manage_options' );
415    }
416
417    /**
418     * Composite read: each subfield is null on upstream failure rather than
419     * failing the whole call, so a partial wpcom outage degrades to "missing
420     * pieces" instead of "no data." Registration is gated on a Backup product
421     * being loaded (see register_abilities), so this callback assumes the
422     * site has one and only reports on the data it can fetch.
423     *
424     * @param mixed $input Unused; ability accepts no input. Typed `mixed` because
425     *                     the Abilities API may pass the raw caller-supplied value
426     *                     (string/null/array) before our `additionalProperties:false`
427     *                     schema runs — a strict array type would fatal on garbage input.
428     * @return array
429     */
430    public static function execute_get_backup_overview( $input = null ): array {
431        unset( $input );
432
433        $backups       = self::unwrap_response( Jetpack_Backup::get_recent_backups() );
434        $schedule_data = self::unwrap_response( Jetpack_Backup::get_site_backup_schedule_time() );
435        $size_data     = self::unwrap_response( Jetpack_Backup::get_site_backup_size() );
436        // Two round-trips because neither route describes storage alone: `/rewind/size`
437        // reports usage, `/rewind/policies` the limit. Fetched unconditionally so a
438        // usable limit still arrives when usage cannot be measured.
439        $policies_data = self::unwrap_response( Jetpack_Backup::get_site_backup_policies() );
440
441        return array(
442            'recent_backup_count' => is_array( $backups ) ? count( $backups ) : null,
443            'last_backup'         => self::summarize_last_backup( is_array( $backups ) ? ( $backups[0] ?? null ) : null ),
444            'schedule'            => self::summarize_schedule( $schedule_data ),
445            'storage'             => self::summarize_storage( $size_data, $policies_data ),
446        );
447    }
448
449    /**
450     * Consolidated read: queries the wpcom activity-log rewindable feed
451     * (server-side date filtering, up to 1000 items/page) and reshapes
452     * activity events back into the backup-item schema. All input filters
453     * land here; the picker is invoked when a `date` + `match` is set.
454     *
455     * @param mixed $input See input_schema on `jetpack-backup/list-backups`.
456     * @return array|WP_Error
457     */
458    public static function execute_list_backups( $input = null ) {
459        $input = is_array( $input ) ? $input : array();
460
461        // Validate `date` / `date_from` / `date_to` ahead of the round-trip so
462        // agents get a specific error rather than a 200 with mysterious empty
463        // results. Schema's `format: date-time` is advisory in WP REST.
464        foreach ( array( 'date', 'date_from', 'date_to' ) as $key ) {
465            if ( isset( $input[ $key ] ) && '' !== $input[ $key ] && null === self::parse_timestamp( $input[ $key ] ) ) {
466                return new WP_Error(
467                    'jetpack_backup_invalid_date',
468                    /* translators: %s is an input parameter name. */
469                    sprintf( __( 'The `%s` parameter must be a valid ISO 8601 datetime (e.g. "2026-05-13T14:30:00Z").', 'jetpack-backup-pkg' ), $key )
470                );
471            }
472        }
473
474        $per_page = min(
475            self::PER_PAGE_MAX,
476            max( 1, isset( $input['per_page'] ) ? (int) $input['per_page'] : self::PER_PAGE_DEFAULT )
477        );
478        $page     = max( 1, isset( $input['page'] ) ? (int) $input['page'] : 1 );
479
480        // `page` is suppressed on the single-result lookups (id, date+match)
481        // — those resolve from page 1 and walking later pages would skip
482        // candidates without an obvious benefit.
483        $is_single_lookup = ( isset( $input['id'] ) && '' !== $input['id'] )
484            || ( isset( $input['date'] ) && '' !== $input['date'] );
485
486        $query = array(
487            'number'     => $per_page,
488            'page'       => $is_single_lookup ? 1 : $page,
489            'sort_order' => 'desc',
490        );
491        if ( isset( $input['date_from'] ) && '' !== $input['date_from'] ) {
492            $query['after'] = (string) $input['date_from'];
493        }
494        if ( isset( $input['date_to'] ) && '' !== $input['date_to'] ) {
495            $query['before'] = (string) $input['date_to'];
496        }
497
498        $envelope = self::unwrap_response( Jetpack_Backup::list_backup_events( $query ) );
499        $events   = self::extract_rewindable_items( $envelope );
500        if ( ! is_array( $events ) ) {
501            return array();
502        }
503
504        $items = array_values( array_filter( array_map( array( __CLASS__, 'summarize_backup_event' ), $events ) ) );
505
506        // Single-id filter — same convention as the old endpoint: 0/1-element array.
507        if ( isset( $input['id'] ) && is_string( $input['id'] ) && '' !== $input['id'] ) {
508            foreach ( $items as $item ) {
509                if ( isset( $item['id'] ) && (string) $item['id'] === $input['id'] ) {
510                    return array( $item );
511                }
512            }
513            return array();
514        }
515
516        // Client-side status filter (server-side filters by event name, not status).
517        if ( isset( $input['status'] ) && is_string( $input['status'] ) && '' !== $input['status'] ) {
518            $want  = $input['status'];
519            $items = array_values(
520                array_filter(
521                    $items,
522                    static function ( $i ) use ( $want ) {
523                        return ( $i['status'] ?? null ) === $want;
524                    }
525                )
526            );
527        }
528
529        // Single-match shortcut.
530        if ( isset( $input['date'] ) && '' !== $input['date'] ) {
531            $target = self::parse_timestamp( $input['date'] );
532            $match  = isset( $input['match'] ) && is_string( $input['match'] ) ? $input['match'] : 'on_or_before';
533            if ( ! in_array( $match, array( 'on_or_before', 'on_or_after', 'closest' ), true ) ) {
534                $match = 'on_or_before';
535            }
536            $pick = self::pick_backup_near_timestamp( $items, (int) $target, $match );
537            return null === $pick ? array() : array( $pick );
538        }
539
540        return array_slice( $items, 0, $per_page );
541    }
542
543    /**
544     * Execute callback for `jetpack-backup/list-restores`.
545     *
546     * @param mixed $input See input_schema on the ability.
547     * @return array
548     */
549    public static function execute_list_restores( $input = null ): array {
550        $restores = self::unwrap_response( Jetpack_Backup::get_recent_restores() );
551        if ( ! is_array( $restores ) ) {
552            return array();
553        }
554
555        $summarized = array_map( array( __CLASS__, 'summarize_restore' ), $restores );
556        return self::apply_id_or_pagination( $summarized, is_array( $input ) ? $input : array() );
557    }
558
559    /**
560     * Pure picker for the `date` + `match` shortcut. Operates on already-
561     * summarized backup items (so it works regardless of which upstream
562     * helper produced them) and uses `started` as the comparison timestamp.
563     *
564     * @param array  $items     Summarized backup items.
565     * @param int    $target_ts Unix timestamp the caller is searching around.
566     * @param string $match     'on_or_before' | 'on_or_after' | 'closest'.
567     * @return array|null The winning item or null when nothing matches.
568     */
569    private static function pick_backup_near_timestamp( array $items, int $target_ts, string $match ): ?array {
570        $best       = null;
571        $best_score = null;
572
573        foreach ( $items as $item ) {
574            $ts = self::parse_timestamp( $item['started'] ?? null );
575            if ( null === $ts ) {
576                continue;
577            }
578
579            $diff  = $ts - $target_ts;
580            $score = 0;
581            switch ( $match ) {
582                case 'on_or_after':
583                    if ( $diff < 0 ) {
584                        continue 2;
585                    }
586                    $score = $diff;
587                    break;
588                case 'closest':
589                    $score = abs( $diff );
590                    break;
591                case 'on_or_before':
592                default:
593                    if ( $diff > 0 ) {
594                        continue 2;
595                    }
596                    $score = -$diff;
597                    break;
598            }
599
600            if ( null === $best_score || $score < $best_score ) {
601                $best       = $item;
602                $best_score = $score;
603            }
604        }
605
606        return $best;
607    }
608
609    /**
610     * Pull the activity-event array out of the W3C ActivityStreams envelope
611     * that `/activity/rewindable` returns. The endpoint puts the items in
612     * `current.orderedItems`; older proxy shapes used `orderedItems` at the
613     * top level, so check both before giving up.
614     *
615     * @param mixed $envelope Raw decoded response body.
616     * @return array|null
617     */
618    private static function extract_rewindable_items( $envelope ): ?array {
619        if ( ! is_array( $envelope ) && ! is_object( $envelope ) ) {
620            return null;
621        }
622        $envelope = (array) $envelope;
623        if ( isset( $envelope['current'] ) ) {
624            $current = (array) $envelope['current'];
625            if ( isset( $current['orderedItems'] ) && is_array( $current['orderedItems'] ) ) {
626                return $current['orderedItems'];
627            }
628        }
629        if ( isset( $envelope['orderedItems'] ) && is_array( $envelope['orderedItems'] ) ) {
630            return $envelope['orderedItems'];
631        }
632        return null;
633    }
634
635    /**
636     * Translate one /activity/rewindable event into the same backup-item
637     * shape `summarize_backup()` produces, so the ability's output schema
638     * stays stable across the upstream switch. Returns null for events that
639     * don't look like backups (no `rewind_id`).
640     *
641     * @param mixed $raw One element from `current.orderedItems`.
642     * @return array|null
643     */
644    private static function summarize_backup_event( $raw ): ?array {
645        if ( ! is_array( $raw ) && ! is_object( $raw ) ) {
646            return null;
647        }
648        $raw = (array) $raw;
649
650        $rewind_id = $raw['rewind_id'] ?? null;
651        if ( null === $rewind_id || '' === $rewind_id ) {
652            return null;
653        }
654
655        $published     = isset( $raw['published'] ) && is_string( $raw['published'] ) ? $raw['published'] : null;
656        $status_raw    = isset( $raw['status'] ) && is_string( $raw['status'] ) ? $raw['status'] : null;
657        $name          = isset( $raw['name'] ) && is_string( $raw['name'] ) ? $raw['name'] : '';
658        $is_rewindable = isset( $raw['is_rewindable'] ) ? (bool) $raw['is_rewindable'] : null;
659
660        return array(
661            'id'            => (string) $rewind_id,
662            'started'       => $published,
663            'last_updated'  => $published,
664            'status'        => self::map_event_status( $status_raw, $name ),
665            'period'        => self::parse_timestamp( $rewind_id ),
666            'is_rewindable' => $is_rewindable,
667            'has_warnings'  => self::event_has_warnings( $status_raw, $name ),
668        );
669    }
670
671    /**
672     * Map activity-event status / action-name to the status vocabulary the
673     * ability's output schema uses (the same labels as the old
674     * `/rewind/backups` endpoint: "finished", "error", ...). Falls back to
675     * the raw status when no mapping fits so the caller still sees signal.
676     *
677     * @param string|null $status_raw Activity event status (e.g. "success", "warning", "error").
678     * @param string      $name       Activity name, e.g. "rewind__backup_complete_full".
679     * @return string|null
680     */
681    private static function map_event_status( ?string $status_raw, string $name ): ?string {
682        if ( 'success' === $status_raw || false !== strpos( $name, 'backup_complete' ) ) {
683            return 'finished';
684        }
685        return $status_raw;
686    }
687
688    /**
689     * Derive `has_warnings` from an activity event's status / name.
690     *
691     * @param string|null $status_raw Activity event status.
692     * @param string      $name       Activity name.
693     * @return bool|null
694     */
695    private static function event_has_warnings( ?string $status_raw, string $name ): ?bool {
696        if ( 'warning' === $status_raw ) {
697            return true;
698        }
699        if ( 'success' === $status_raw || false !== strpos( $name, 'backup_complete' ) ) {
700            return false;
701        }
702        return null;
703    }
704
705    /**
706     * Coerce an ISO 8601 string, RFC-style date string, or numeric unix
707     * timestamp to an int unix timestamp. Returns null for anything that
708     * can't be unambiguously parsed (instead of strtotime's `false`, which
709     * is also a valid timestamp for 1969-12-31).
710     *
711     * Fractional numeric strings (e.g. rewind_id "1778804242.107") are
712     * accepted — the fractional part is truncated.
713     *
714     * @param mixed $value Source value (string, int, float, or anything else).
715     * @return int|null
716     */
717    private static function parse_timestamp( $value ): ?int {
718        if ( is_int( $value ) ) {
719            return $value;
720        }
721        if ( is_float( $value ) ) {
722            return (int) $value;
723        }
724        if ( is_string( $value ) && '' !== $value ) {
725            if ( is_numeric( $value ) ) {
726                return (int) $value;
727            }
728            $ts = strtotime( $value );
729            return false === $ts ? null : $ts;
730        }
731        return null;
732    }
733
734    /**
735     * Enqueue an on-demand backup. Registration is gated on a Backup product
736     * being loaded so we assume one exists by the time this runs. Returns
737     * WP_Error only when the upstream connection itself fails so agents can
738     * retry strategically.
739     *
740     * @param mixed $input Unused; see note on execute_get_backup_overview().
741     * @return array|WP_Error
742     */
743    public static function execute_request_backup( $input = null ) {
744        unset( $input );
745
746        // wpcom can return HTTP 200 with `{ success: false, error: ... }`; treat
747        // that as a failure rather than reporting the backup was enqueued.
748        $result = self::unwrap_response( Jetpack_Backup::enqueue_backup() );
749        if ( ! is_array( $result ) || empty( $result['success'] ) ) {
750            return new WP_Error(
751                'jetpack_backup_data_unavailable',
752                __( 'The backup service did not accept the request. The connection to WordPress.com may be temporarily unavailable; retry shortly.', 'jetpack-backup-pkg' )
753            );
754        }
755
756        return array(
757            'enqueued' => true,
758            'message'  => __( 'Backup enqueued. Use jetpack-backup/list-backups to monitor progress.', 'jetpack-backup-pkg' ),
759        );
760    }
761
762    /**
763     * Normalize a Jetpack_Backup helper result (WP_REST_Response, array, null,
764     * or WP_Error) to a plain value or null. Jetpack_Backup uses
765     * `rest_ensure_response()` on success; on failure its routes return a
766     * WP_Error and `list_backup_events()` returns null, so abilities need
767     * every shape flattened before summarising.
768     *
769     * @param mixed $maybe_response Result of a Jetpack_Backup helper call.
770     * @return mixed
771     */
772    private static function unwrap_response( $maybe_response ) {
773        if ( null === $maybe_response || is_wp_error( $maybe_response ) ) {
774            return null;
775        }
776        if ( $maybe_response instanceof WP_REST_Response ) {
777            return $maybe_response->get_data();
778        }
779        return $maybe_response;
780    }
781
782    /**
783     * Slice the (already-summarized) list down to a single id, or apply
784     * page/per_page pagination. Always returns the same item shape.
785     *
786     * @param array $items Summarized items.
787     * @param array $input Sanitized input.
788     * @return array
789     */
790    private static function apply_id_or_pagination( array $items, array $input ): array {
791        if ( isset( $input['id'] ) && is_string( $input['id'] ) && '' !== $input['id'] ) {
792            foreach ( $items as $item ) {
793                if ( isset( $item['id'] ) && (string) $item['id'] === $input['id'] ) {
794                    return array( $item );
795                }
796            }
797            return array();
798        }
799
800        $page     = max( 1, (int) ( $input['page'] ?? 1 ) );
801        $per_page = min( self::PER_PAGE_MAX, max( 1, (int) ( $input['per_page'] ?? self::PER_PAGE_DEFAULT ) ) );
802
803        return array_slice( $items, ( $page - 1 ) * $per_page, $per_page );
804    }
805
806    /**
807     * High-signal summary used inside `last_backup` for the overview. Same as
808     * `summarize_backup` minus the `started`/`period` fields which the agent
809     * doesn't need at a glance.
810     *
811     * @param mixed $raw One element from the upstream backups list.
812     * @return array|null
813     */
814    private static function summarize_last_backup( $raw ): ?array {
815        if ( ! is_array( $raw ) && ! is_object( $raw ) ) {
816            return null;
817        }
818        return array_diff_key(
819            self::summarize_backup( $raw ),
820            array_flip( array( 'started', 'period' ) )
821        );
822    }
823
824    /**
825     * Summarize a `/rewind/backups` payload item using `rewind_id` as the
826     * canonical `id`. The numeric attempt id wpcom also exposes is
827     * internal to VaultPress and can't be looked up via any other endpoint,
828     * so it's intentionally dropped from the agent-facing shape — see the
829     * note on `$backup_item_schema` in `get_abilities()`.
830     *
831     * @param mixed $raw Upstream backup item.
832     * @return array
833     */
834    private static function summarize_backup( $raw ): array {
835        $raw       = (array) $raw;
836        $rewind_id = $raw['rewind_id'] ?? null;
837        return array(
838            'id'            => ( null === $rewind_id || '' === $rewind_id ) ? null : (string) $rewind_id,
839            'started'       => $raw['started'] ?? null,
840            'last_updated'  => $raw['last_updated'] ?? null,
841            'status'        => $raw['status'] ?? null,
842            'period'        => $raw['period'] ?? null,
843            'is_rewindable' => isset( $raw['is_rewindable'] ) ? (bool) $raw['is_rewindable'] : null,
844            'has_warnings'  => isset( $raw['has_warnings'] ) ? (bool) $raw['has_warnings'] : null,
845        );
846    }
847
848    /**
849     * Summarize a `/rewind/restores` payload item. `id` is the rewind_id
850     * of the backup being restored to — same canonical id system as
851     * `summarize_backup()`.
852     *
853     * @param mixed $raw Upstream restore item.
854     * @return array
855     */
856    private static function summarize_restore( $raw ): array {
857        $raw       = (array) $raw;
858        $rewind_id = $raw['rewind_id'] ?? null;
859        return array(
860            'id'           => ( null === $rewind_id || '' === $rewind_id ) ? null : (string) $rewind_id,
861            'started'      => $raw['started'] ?? null,
862            'last_updated' => $raw['last_updated'] ?? null,
863            'status'       => $raw['status'] ?? null,
864            'progress'     => isset( $raw['progress'] ) ? (int) $raw['progress'] : null,
865        );
866    }
867
868    /**
869     * Summarize the `/site/backup/schedule` payload to `{ hour }`.
870     *
871     * WordPress.com answers `{ ok, scheduled_hour, scheduled_by }` — the UTC hour of the
872     * daily backup, and no minute anywhere. `ok` is its own success flag inside a 200
873     * body, so a payload without it carries no usable hour.
874     *
875     * @param mixed $raw Upstream schedule payload.
876     * @return array|null
877     */
878    private static function summarize_schedule( $raw ): ?array {
879        if ( ! is_array( $raw ) && ! is_object( $raw ) ) {
880            return null;
881        }
882        $raw = (array) $raw;
883        if ( empty( $raw['ok'] ) ) {
884            return null;
885        }
886        return array(
887            'hour' => isset( $raw['scheduled_hour'] ) ? (int) $raw['scheduled_hour'] : null,
888        );
889    }
890
891    /**
892     * Summarize storage from the two payloads that between them describe it.
893     *
894     * Usage is `size` on `/site/backup/size`, which despite its name carries no limit;
895     * the limit is `policies.storage_limit_bytes` on `/site/backup/policies`.
896     *
897     * The two are read independently, so a site whose usage could not be measured still
898     * reports what it is allowed. That is deliberately unlike `summarize_schedule()`,
899     * which has nothing left to report once its hour is gone.
900     *
901     * @param mixed $size_raw     Upstream `/site/backup/size` payload.
902     * @param mixed $policies_raw Upstream `/site/backup/policies` payload.
903     * @return array|null
904     */
905    private static function summarize_storage( $size_raw, $policies_raw ): ?array {
906        $size     = ( is_array( $size_raw ) || is_object( $size_raw ) ) ? (array) $size_raw : null;
907        $policies = ( is_array( $policies_raw ) || is_object( $policies_raw ) ) ? (array) $policies_raw : null;
908
909        if ( null === $size && null === $policies ) {
910            return null;
911        }
912
913        $used_bytes = ( null !== $size && ! empty( $size['ok'] ) && isset( $size['size'] ) )
914            ? (int) $size['size']
915            : null;
916
917        // `policies` is itself nullable inside a 200: a plan with no retention policy
918        // answers `{ "policies": null }`.
919        $policy = $policies['policies'] ?? null;
920        $policy = ( is_array( $policy ) || is_object( $policy ) ) ? (array) $policy : null;
921
922        $limit_bytes = ( null !== $policy && isset( $policy['storage_limit_bytes'] ) )
923            ? (int) $policy['storage_limit_bytes']
924            : null;
925
926        return array(
927            'used_bytes'  => $used_bytes,
928            'limit_bytes' => $limit_bytes,
929        );
930    }
931}