feat(admin): Prüfroute je Profil und Reiter „Compatibility“
Die generische CheckRoute (POST lit-mcp/v1/<id>-check, für Divi weiter /divi-check) ersetzt DiviCheckRoute: Lock je Profil mit Besitzer-Token, Bericht je Profil, Rücksprung auf den neuen Reiter und Hook lit_mcp_compat_changed (Divi lädt darauf seinen Freigabe-Status neu). Der Reiter „Compatibility“ zeigt je Plugin Zustand (Wort und Icon), Hinweis zur Stufe, Fähigkeiten, getestete und freigegebene Versionen und den letzten Bericht, mit Prüfen, Freigeben und Zurückziehen (Recht manage_options, Allowlist der Profil-ID, Nonce je Profil). Freigeben serverseitig nur mit frischem grünem Bericht. Der Load-Hook lit_mcp_compat_profile_load bereitet den Abbau des DiviTab vor, der bis dahin parallel läuft. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7d6a97a54e
commit
85f4138b36
@@ -7,12 +7,12 @@ namespace LIT\MCP\Integrations\Divi\Admin;
|
||||
use Closure;
|
||||
use LIT\MCP\Admin\AdminPostAction;
|
||||
use LIT\MCP\Admin\AdminTab;
|
||||
use LIT\MCP\Admin\CheckRoute;
|
||||
use LIT\MCP\Admin\SettingsPage;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Compatibility\ReleaseState;
|
||||
use LIT\MCP\Compatibility\CheckReport;
|
||||
use LIT\MCP\Compatibility\CheckReportStore;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviCheckSteps;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
|
||||
@@ -29,7 +29,7 @@ defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Tab "Divi" (spec §6): release status of the installed Divi, the table of its features (state, source, problems,
|
||||
* affected tools – E3), the latest check report, "Prüfung starten" (REST, DiviCheckRoute), "Divi <Version>
|
||||
* affected tools – E3), the latest check report, "Prüfung starten" (REST, Admin\CheckRoute), "Divi <Version>
|
||||
* freigeben" (only after a fresh green report) and "Freigabe zurückziehen" (whenever a database release applies,
|
||||
* whatever the overall state). Loading the tab removes check pages a dead check left behind – in the page's load
|
||||
* hook (removeOrphans()), not while rendering. renderNotice() is the admin notice on every admin page while a
|
||||
@@ -460,7 +460,7 @@ final class DiviTab implements AdminTab
|
||||
&& $report->freshFor($status->version, Plugin::VERSION, $this->stateKey($status->version));
|
||||
$untested = $status->state === ReleaseState::Untested && $status->version !== '';
|
||||
|
||||
printf('<form method="post" action="%s">', esc_url(DiviCheckRoute::url()));
|
||||
printf('<form method="post" action="%s">', esc_url(CheckRoute::url(DiviProfile::ID)));
|
||||
printf('<input type="hidden" name="_wpnonce" value="%s">', esc_attr(wp_create_nonce('wp_rest')));
|
||||
// The check is the one thing to do while untested without a usable report; otherwise the release leads.
|
||||
submit_button(
|
||||
|
||||
@@ -12,7 +12,7 @@ defined('ABSPATH') || exit;
|
||||
* One confirmed change per lock at a time (final review I-5). Colors and theme options share ONE lock (audit L5):
|
||||
* both rewrite the whole et_divi array through et_update_option(). The lock is a row lit_mcp_lock_<name> (never
|
||||
* autoloaded) in the options table holding "<time>:<token>" – taken with an atomic INSERT IGNORE like
|
||||
* DiviCheckRoute (add_option() reads first and writes after, so two requests could both win; audit L6). A lock
|
||||
* Admin\CheckRoute (add_option() reads first and writes after, so two requests could both win; audit L6). A lock
|
||||
* older than STALE_AFTER seconds (a request that died) is taken over with a conditional UPDATE on the value just
|
||||
* read, so only one taker wins; release() deletes the row only while it still holds this instance's token, so a
|
||||
* holder that lost a stale lock cannot free the new holder's. A taken lock drops the options cache, so the
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi;
|
||||
use Closure;
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\ToolAvailability;
|
||||
use LIT\MCP\Admin\CheckRoute;
|
||||
use LIT\MCP\Admin\SettingsPage;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Compatibility\CompatibilityCheck;
|
||||
@@ -75,7 +76,6 @@ use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
|
||||
use LIT\MCP\Integrations\Divi\Release\FeatureOverview;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||||
@@ -260,12 +260,18 @@ final class DiviIntegration implements Integration, Bootable, ProvidesCompatibil
|
||||
ReleaseGate::invalidate(mirrors: true);
|
||||
});
|
||||
|
||||
// Spec §6: the admin check runs as a REST request (same context as MCP calls). Its short form has no
|
||||
// Tools-tab gate: the check tests Divi, not this site's tool switches.
|
||||
// The admin check runs as a generic REST route (Admin\CheckRoute, registered by Plugin); a stored check or a
|
||||
// release changes the state, so the gate forgets its status of this request.
|
||||
add_action(CheckRoute::CHANGED_ACTION, static function (string $profileId): void {
|
||||
if ($profileId === DiviProfile::ID) {
|
||||
ReleaseGate::invalidate();
|
||||
}
|
||||
});
|
||||
|
||||
// Spec §7: the same check as WP-CLI command. Its short form has no Tools-tab gate: the check tests Divi,
|
||||
// not this site's tool switches.
|
||||
$profile = $this->profile();
|
||||
$check = static fn (): CompatibilityCheck => CompatibilityCheck::forProfile($profile);
|
||||
(new DiviCheckRoute($check))->register();
|
||||
DiviCheckCommand::register($check);
|
||||
DiviCheckCommand::register(static fn (): CompatibilityCheck => CompatibilityCheck::forProfile($profile));
|
||||
|
||||
add_filter(
|
||||
SettingsPage::TABS_FILTER,
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Release;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Admin\SettingsPage;
|
||||
use LIT\MCP\Compatibility\CheckReportStore;
|
||||
use LIT\MCP\Compatibility\CompatibilityCheck;
|
||||
use LIT\MCP\Integrations\Divi\Admin\DiviTab;
|
||||
use LIT\MCP\Log;
|
||||
use Throwable;
|
||||
use WP_REST_Response;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* "Prüfung starten" of the Divi tab (spec §6.1): POST /wp-json/lit-mcp/v1/divi-check. A REST request on purpose –
|
||||
* not admin-post – so the check runs in the same context as MCP calls (Divi renders its blocks in REST requests,
|
||||
* lit-page-render-summary). The form sends the wp_rest nonce as _wpnonce (cookie authentication); only manage_options.
|
||||
* One check at a time: an atomic "INSERT IGNORE" on the options table (self::LOCK) is the lock – add_option() reads
|
||||
* then writes (wp-includes/option.php) and is not atomic, so two simultaneous requests could both "acquire" it. A
|
||||
* lock older than LOCK_TTL is a dead check's, taken over with a conditional UPDATE (compare-and-swap on the value
|
||||
* just read) so only one of several concurrent takers wins. A check that throws still releases the lock (finally)
|
||||
* and redirects with divi_check_failed (logged, never shown to the browser as an unhandled 500). Answers 303 back
|
||||
* to the tab.
|
||||
*/
|
||||
final class DiviCheckRoute
|
||||
{
|
||||
public const NAMESPACE = 'lit-mcp/v1';
|
||||
public const ROUTE = '/divi-check';
|
||||
public const LOCK = 'lit_mcp_divi_check_lock';
|
||||
|
||||
private const LOCK_TTL = 600;
|
||||
|
||||
/** @param Closure(): CompatibilityCheck $check */
|
||||
public function __construct(private Closure $check, private ?CheckReportStore $reports = null)
|
||||
{
|
||||
}
|
||||
|
||||
public function register(): void
|
||||
{
|
||||
add_action('rest_api_init', [$this, 'registerRoute']);
|
||||
}
|
||||
|
||||
public function registerRoute(): void
|
||||
{
|
||||
register_rest_route(self::NAMESPACE, self::ROUTE, [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'handle'],
|
||||
'permission_callback' => static fn (): bool => current_user_can('manage_options'),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function url(): string
|
||||
{
|
||||
return rest_url(self::NAMESPACE . self::ROUTE);
|
||||
}
|
||||
|
||||
public function handle(): WP_REST_Response
|
||||
{
|
||||
$notice = 'divi_check_running';
|
||||
if (self::lock()) {
|
||||
try {
|
||||
$report = ($this->check)()->run(get_current_user_id());
|
||||
$reports = $this->reports ?? new CheckReportStore(DiviProfile::ID, DiviProfile::REPORT_VERSION_KEY);
|
||||
$reports->save($report);
|
||||
ReleaseGate::invalidate();
|
||||
$notice = 'divi_checked';
|
||||
} catch (Throwable $e) {
|
||||
Log::failure('divi check', $e);
|
||||
$notice = 'divi_check_failed';
|
||||
} finally {
|
||||
delete_option(self::LOCK);
|
||||
}
|
||||
}
|
||||
|
||||
return new WP_REST_Response(
|
||||
null,
|
||||
303,
|
||||
['Location' => SettingsPage::url(DiviTab::SLUG, ['lit_mcp_notice' => $notice])]
|
||||
);
|
||||
}
|
||||
|
||||
private static function lock(): bool
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
if (self::insertLock($wpdb, time())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// get_var(), not get_option(): the row was written with raw SQL, bypassing the options cache.
|
||||
$since = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
|
||||
self::LOCK
|
||||
));
|
||||
if ($since === null) {
|
||||
// The lock was released between our failed insert and this read; try once more.
|
||||
return self::insertLock($wpdb, time());
|
||||
}
|
||||
if (time() - (int) $since < self::LOCK_TTL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$taken = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
|
||||
(string) time(),
|
||||
self::LOCK,
|
||||
$since
|
||||
));
|
||||
|
||||
return (int) $taken === 1;
|
||||
}
|
||||
|
||||
/** INSERT IGNORE: 1 row affected = the lock did not exist and is now ours; 0 = someone else holds it. */
|
||||
private static function insertLock(\wpdb $wpdb, int $now): bool
|
||||
{
|
||||
$inserted = $wpdb->query($wpdb->prepare(
|
||||
"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
|
||||
self::LOCK,
|
||||
(string) $now
|
||||
));
|
||||
|
||||
return (int) $inserted === 1;
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ final class DiviProfile
|
||||
))->run()
|
||||
),
|
||||
manifestCheck: DiviManifestCheck::create(...),
|
||||
stepLabel: DiviCheckSteps::label(...),
|
||||
reportVersionKey: self::REPORT_VERSION_KEY
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user