fix: DesignLock gegen parallele Bestätigungen; confirm ohne design_rev ist ein Eingabefehler

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-16 18:06:52 +02:00
co-authored by Claude Opus 5.5
parent fc8ffeba9e
commit 048cd9353d
10 changed files with 357 additions and 14 deletions
+62 -8
View File
@@ -19,10 +19,12 @@ defined('ABSPATH') || exit;
/**
* The change pipeline of every design system write (spec §4, G2, G6): plan on the live inventory → without
* confirm a dry run (diff, affected posts, design_rev; nothing saved) → with confirm the design_rev must still match
* → no live post may block (P8/P8a) → release lock → snapshot of the area → write through Divi → read back and
* verify → change log. A failed write or read-back writes the snapshot back (Rollback); when that fails too the
* error is partial_write with the snapshot to restore by hand. The rights are the tool's (spec §10).
* confirm a dry run (diff, affected posts, design_rev; nothing saved) → with confirm the design_rev is required, the
* area's DesignLock is taken and the plan made again on the inventory read under the lock, whose design_rev must
* still match → no live post may block (P8/P8a) → Divi release gate → snapshot of the area → write through Divi →
* read back and verify → change log; the lock is released at the end, also after a failure. A failed write or
* read-back writes the snapshot back (DesignArea::restore(), Rollback); when that fails too the error is
* partial_write with the snapshot to restore by hand. The rights are the tool's (spec §10).
*/
final class DesignChange
{
@@ -30,8 +32,11 @@ final class DesignChange
private const LISTED_IDS = 10;
public function __construct(private SnapshotStore $snapshots, private ChangeLog $log)
{
public function __construct(
private SnapshotStore $snapshots,
private ChangeLog $log,
private DesignLock $lock = new DesignLock()
) {
}
/** @return array<string, array<string, mixed>> Input properties every design write tool takes. */
@@ -66,6 +71,44 @@ final class DesignChange
* @throws DiviLocked
*/
public function run(string $ability, DesignArea $area, array $input, callable $plan): array|WP_Error
{
if (($input['confirm'] ?? false) !== true) {
return $this->attempt($ability, $area, null, $plan);
}
$rev = $input['design_rev'] ?? null;
if (! is_string($rev) || $rev === '') {
return Errors::invalidInput(
'design_rev fehlt; nichts wurde gespeichert.',
['design_rev' => 'aus dem Probelauf übernehmen']
);
}
if (! $this->lock->acquire($area->name())) {
return Errors::make(
'conflict',
sprintf('Gerade läuft eine andere Änderung an den %s; nichts wurde gespeichert.', $area->label()),
'Kurz warten und den Probelauf wiederholen.',
409
);
}
try {
return $this->attempt($ability, $area, $rev, $plan);
} finally {
$this->lock->release($area->name());
}
}
/**
* Plans on the inventory read now – under the lock when confirmed ($confirmedRev) – and runs the dry run or
* the confirmed change.
*
* @param callable(array<array-key, mixed>): (DesignPlan|WP_Error) $plan
* @return array<string, mixed>|WP_Error
* @throws WriteFailed
* @throws DiviLocked
*/
private function attempt(string $ability, DesignArea $area, ?string $confirmedRev, callable $plan): array|WP_Error
{
$current = $area->raw();
$rev = $area->rev($current);
@@ -82,7 +125,7 @@ final class DesignChange
->toArray();
}
if (($input['confirm'] ?? false) !== true) {
if ($confirmedRev === null) {
return Errors::confirmRequired(
$planned->summary . ' Nichts wurde gespeichert (Probelauf).',
['diff' => $planned->diff, 'design_rev' => $rev, 'warnings' => $planned->warnings]
@@ -92,7 +135,7 @@ final class DesignChange
);
}
if (($input['design_rev'] ?? null) !== $rev) {
if ($confirmedRev !== $rev) {
return Errors::revMismatch(
sprintf('Die %s wurden seit dem Probelauf geändert; nichts wurde gespeichert.', $area->label()),
'Erneut ohne confirm aufrufen (Probelauf), dem Nutzer die Änderung zeigen und mit der neuen '
@@ -100,6 +143,17 @@ final class DesignChange
);
}
return $this->apply($ability, $area, $planned, $current);
}
/**
* @param array<array-key, mixed> $current
* @return array<string, mixed>|WP_Error
* @throws WriteFailed
* @throws DiviLocked
*/
private function apply(string $ability, DesignArea $area, DesignPlan $planned, array $current): array|WP_Error
{
$blocked = $this->blocked($planned);
if ($blocked !== null) {
return $blocked;
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use InvalidArgumentException;
defined('ABSPATH') || exit;
/**
* One confirmed change per design area at a time (final review I-5): an option lit_mcp_lock_<area> (never
* autoloaded) holds the time the lock was taken. add_option() is atomic – it fails while the option exists. A lock
* older than STALE_AFTER seconds (a request that died) is removed and taken once more.
*/
final class DesignLock
{
public const STALE_AFTER = 120;
private const PREFIX = 'lit_mcp_lock_';
/** @throws InvalidArgumentException For an area outside SnapshotStore::AREAS. */
public static function option(string $area): string
{
if (! in_array($area, SnapshotStore::AREAS, true)) {
throw new InvalidArgumentException(sprintf('Unknown design area "%s".', $area));
}
return self::PREFIX . $area;
}
public function acquire(string $area): bool
{
$option = self::option($area);
if (add_option($option, time(), '', false)) {
return true;
}
$since = get_option($option, 0);
if (is_numeric($since) && time() - (int) $since <= self::STALE_AFTER) {
return false;
}
delete_option($option);
return add_option($option, time(), '', false);
}
public function release(string $area): void
{
delete_option(self::option($area));
}
}
+8 -6
View File
@@ -8,9 +8,9 @@ defined('ABSPATH') || exit;
/**
* Removes WP-LIT-MCP's own data (uninstall.php, Task 12): options (incl. the Snapshots des
* Designsystems, Etappe 4), the endpoint-check and Divi-release transients, the log-cleanup
* cron hook and the change log table – on every site of a network – and the Protokoll tab's
* per-user screen option.
* Designsystems and their change locks, Etappe 4), the endpoint-check and Divi-release
* transients, the log-cleanup cron hook and the change log table – on every site of a
* network – and the Protokoll tab's per-user screen option.
*
* Deliberately self-contained (no reference to any other LIT\MCP\* class): uninstall.php
* is executed by WordPress core standalone, without loading the plugin's main file first,
@@ -18,9 +18,10 @@ defined('ABSPATH') || exit;
* names below are therefore duplicated from Access::OPTION, Abilities\ExposurePolicy::OPTION,
* Limits::OPTION, ChangeLog\ChangeLogInstaller::VERSION_OPTION, Integrations\Divi\Release\
* ReleaseStore::OPTION, Integrations\Divi\Release\CheckReportStore::OPTION, Integrations\
* Divi\Release\DiviCheckRoute::LOCK, Admin\ConnectionStatus::ENDPOINT_TRANSIENT,
* Integrations\Divi\Release\ReleaseResolver::CACHE, ChangeLog\ChangeLogCleanup::HOOK,
* ChangeLog\ChangeLog::TABLE and Admin\LogTab::PER_PAGE_OPTION on purpose;
* Divi\Release\DiviCheckRoute::LOCK, Integrations\Divi\Design\DesignLock::option(),
* Admin\ConnectionStatus::ENDPOINT_TRANSIENT, Integrations\Divi\Release\ReleaseResolver::CACHE,
* ChangeLog\ChangeLogCleanup::HOOK, ChangeLog\ChangeLog::TABLE and Admin\LogTab::PER_PAGE_OPTION
* on purpose;
* tests/Unit/UninstallerTest.php and tests/Unit/UninstallTest.php keep them in sync with
* those constants.
*
@@ -36,6 +37,7 @@ final class Uninstaller
'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', 'lit_mcp_divi_check_lock',
'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets',
'lit_mcp_snapshots_theme_options',
'lit_mcp_lock_colors', 'lit_mcp_lock_variables', 'lit_mcp_lock_presets', 'lit_mcp_lock_theme_options',
];
private const TRANSIENTS = ['lit_mcp_endpoint_check', 'lit_mcp_divi_state'];
private const CRON_HOOK = 'lit_mcp_log_cleanup';