| 1 |
<?php |
| 2 |
/** |
| 3 |
* Full Site Import's agent-facing capabilities (spec 045). |
| 4 |
* |
| 5 |
* Five capabilities — inspect a pack, start an import, advance/poll it, retry it, |
| 6 |
* revert it — contributed to the shared registry in `modules/mcp-core/`. Whatever |
| 7 |
* transport is serving (the built-in server in `modules/mcp-server/`, the |
| 8 |
* WordPress Abilities API, the mcp-adapter plugin) picks them up from there; none |
| 9 |
* of them is named here and none of them needs to know FSI exists. |
| 10 |
* |
| 11 |
* ## Why these live inside full-site-import and not in an MCP module |
| 12 |
* |
| 13 |
* They are FSI code. They import `Utils\SessionData`, drive the frozen |
| 14 |
* `wp_ajax_templately_pack_*` action contract, parse FSI's own SSE event stream |
| 15 |
* (`Support\FsiStatusNormalizer`), and hook `templately_fsi_before_import`. A |
| 16 |
* change to any of that breaks them, so they belong in the same module and the |
| 17 |
* same review as the thing they wrap. |
| 18 |
* |
| 19 |
* That is the general rule, not an exception carved for FSI: **a feature module |
| 20 |
* owns its agent capabilities whenever it is genuinely coupled to them.** |
| 21 |
* `modules/mcp-abilities/` keeps the nine template-library capabilities precisely |
| 22 |
* because they are NOT coupled — they reach their features by route string |
| 23 |
* through `McpCore\Support\RestDispatcher` and import nothing but |
| 24 |
* `Templately\Utils\*`, so they are a standalone pack with no owning feature to |
| 25 |
* live in. |
| 26 |
* |
| 27 |
* @package Templately\Modules\FullSiteImport\Abilities |
| 28 |
*/ |
| 29 |
|
| 30 |
namespace Templately\Modules\FullSiteImport\Abilities; |
| 31 |
|
| 32 |
use Templately\Modules\McpCore\Registry\ToolRegistry; |
| 33 |
|
| 34 |
class AgentCapabilities { |
| 35 |
|
| 36 |
/** |
| 37 |
* The ONE list. Adding an FSI capability means adding its class here and |
| 38 |
* nothing else (FR-011). |
| 39 |
* |
| 40 |
* @var string[] |
| 41 |
*/ |
| 42 |
const ABILITY_CLASSES = [ |
| 43 |
InspectFullSitePackAbility::class, |
| 44 |
StartFullSiteImportAbility::class, |
| 45 |
FullSiteImportStatusAbility::class, |
| 46 |
RetryFullSiteImportAbility::class, |
| 47 |
RevertFullSiteImportAbility::class, |
| 48 |
]; |
| 49 |
|
| 50 |
/** |
| 51 |
* PHP time limit (seconds) imposed on an FSI import slice driven through the |
| 52 |
* MCP admin-ajax loopback. Chosen so the importer's own |
| 53 |
* `Helper::fsi_should_exit()` yields the runner slice (at ~max_time minus its |
| 54 |
* built-in 20% delay, i.e. ~32s here) comfortably under common 60s gateway/ |
| 55 |
* proxy read timeouts, keeping every `full-site-import-status` poll responsive. |
| 56 |
*/ |
| 57 |
const LOOPBACK_TIME_LIMIT = 40; |
| 58 |
|
| 59 |
public static function register(): void { |
| 60 |
// Hooked, not called directly, so the registry can rebuild itself from |
| 61 |
// every contributor at any time — see ToolRegistry::COLLECT_ACTION. |
| 62 |
add_action( ToolRegistry::COLLECT_ACTION, [ self::class, 'declare_capabilities' ] ); |
| 63 |
|
| 64 |
// Bound the slice duration for imports driven through the loopback so each |
| 65 |
// slice returns before an upstream gateway/proxy timeout. Done entirely |
| 66 |
// through the importer's EXISTING `templately_fsi_before_import` action — |
| 67 |
// no runner code is modified. The browser flow carries no marker and is |
| 68 |
// untouched. |
| 69 |
add_action( 'templately_fsi_before_import', [ self::class, 'cap_loopback_slice_time' ] ); |
| 70 |
} |
| 71 |
|
| 72 |
/** |
| 73 |
* Hands over CLASS NAMES only — descriptors resolve lazily on first use. |
| 74 |
* Resolving them eagerly would call each descriptor's `__()` during |
| 75 |
* `plugins_loaded`, i.e. before `init` loads the textdomain, which makes |
| 76 |
* WP 6.7+ emit a "textdomain triggered too early" notice. Under |
| 77 |
* WP_DEBUG_DISPLAY that notice is echoed during bootstrap and every later |
| 78 |
* REST error status collapses to 200, because headers are already sent. |
| 79 |
* See ToolRegistry::$pending_classes. |
| 80 |
* |
| 81 |
* @param ToolRegistry $registry |
| 82 |
* @return void |
| 83 |
*/ |
| 84 |
public static function declare_capabilities( $registry ): void { |
| 85 |
$registry->register_classes( self::ABILITY_CLASSES ); |
| 86 |
} |
| 87 |
|
| 88 |
/** |
| 89 |
* For an FSI import driven by the loopback (marked by |
| 90 |
* `McpCore\Support\AjaxLoopbackDispatcher` via `templately_mcp_driven`), |
| 91 |
* impose a finite PHP time limit. |
| 92 |
* |
| 93 |
* FSI runs under `set_time_limit(0)`, which makes `Helper::fsi_should_exit()`'s |
| 94 |
* max_execution_time check a no-op; restoring a finite limit here re-enables |
| 95 |
* that existing per-item time-box so a heavy runner splits into short slices. |
| 96 |
* Hooked after the importer's own `set_time_limit(0)` and before the runner |
| 97 |
* loop. |
| 98 |
* |
| 99 |
* @return void |
| 100 |
*/ |
| 101 |
public static function cap_loopback_slice_time(): void { |
| 102 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the underlying ajax handler already verifies the nonce; this only reads a boolean routing flag. |
| 103 |
if ( ! empty( $_REQUEST['templately_mcp_driven'] ) ) { |
| 104 |
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- set_time_limit may be disabled on some hosts; a warning there is harmless and the cap is best-effort. |
| 105 |
@set_time_limit( self::LOOPBACK_TIME_LIMIT ); |
| 106 |
} |
| 107 |
} |
| 108 |
} |
| 109 |
|