feat: Änderungs-Pipeline des Designsystems (Probelauf, design_rev, Snapshot, Rollback) und Bereich presets

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-16 17:39:58 +02:00
co-authored by Claude Sonnet 5.5
parent bfd3840eb1
commit bb4d9b3baf
6 changed files with 698 additions and 0 deletions
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
defined('ABSPATH') || exit;
/**
* One area of the design system for DesignChange (spec §2, §4): colors, variables, presets or theme options – its
* raw inventory as Divi stores it (also the snapshot), its design_rev, how it is written through Divi and how a
* read-back is judged. write() throws on failure; it is also how a snapshot is written back ($mayRemove = true).
*/
interface DesignArea
{
/** One of SnapshotStore::AREAS. */
public function name(): string;
/** German name for messages, e.g. "Presets". */
public function label(): string;
/** @return array<array-key, mixed> */
public function raw(): array;
/** @param array<array-key, mixed> $raw */
public function rev(array $raw): string;
/**
* @param array<array-key, mixed> $raw
* @return array<array-key, mixed> The inventory as read back after writing.
*/
public function write(array $raw, bool $mayRemove): array;
/**
* Whether Divi stored what was written – sanitizing may change values, never which entries exist.
*
* @param array<array-key, mixed> $expected
* @param array<array-key, mixed> $actual
*/
public function verify(array $expected, array $actual): bool;
}
@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rollback;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use RuntimeException;
use Throwable;
use WP_Error;
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).
*/
final class DesignChange
{
public const OBJECT_TYPE = 'divi_design';
private const LISTED_IDS = 10;
public function __construct(private SnapshotStore $snapshots, private ChangeLog $log)
{
}
/** @return array<string, array<string, mixed>> Input properties every design write tool takes. */
public static function inputProperties(): array
{
return [
'confirm' => [
'type' => 'boolean',
'description' => 'Erst nach dem Probelauf und der Rückfrage beim Nutzer.',
],
'design_rev' => [
'type' => 'string',
'description' => 'design_rev aus dem Probelauf (Pflicht mit confirm).',
],
];
}
/** @return array<string, mixed> */
public static function outputSchema(): array
{
return ToolResult::schema([
'diff' => ['type' => 'array', 'items' => ['type' => 'object']],
'design_rev' => ['type' => 'string'],
], ['diff', 'design_rev']);
}
/**
* @param array<string, mixed> $input The tool input (confirm, design_rev).
* @param callable(array<array-key, mixed>): (DesignPlan|WP_Error) $plan
* @return array<string, mixed>|WP_Error
* @throws WriteFailed
* @throws DiviLocked
*/
public function run(string $ability, DesignArea $area, array $input, callable $plan): array|WP_Error
{
$current = $area->raw();
$rev = $area->rev($current);
$planned = $plan($current);
if ($planned instanceof WP_Error) {
return $planned;
}
if ($planned->isEmpty()) {
return ToolResult::ok('Keine Änderung – ' . $planned->summary)
->with('diff', [])
->with('design_rev', $rev)
->warnings($planned->warnings)
->toArray();
}
if (($input['confirm'] ?? false) !== true) {
return Errors::confirmRequired(
$planned->summary . ' Nichts wurde gespeichert (Probelauf).',
['diff' => $planned->diff, 'design_rev' => $rev, 'warnings' => $planned->warnings]
+ ($planned->usages?->listing() ?? ['affected' => [], 'affected_count' => 0])
+ ($planned->usages !== null && $planned->usages->blocked !== []
? ['blocked_ids' => array_slice($planned->usages->blocked, 0, self::LISTED_IDS)] : [])
);
}
if (($input['design_rev'] ?? null) !== $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 '
. 'design_rev bestätigen.'
);
}
$blocked = $this->blocked($planned);
if ($blocked !== null) {
return $blocked;
}
ReleaseGate::assertWritable();
try {
$snapshotId = $this->snapshots->save($area->name(), $current, $ability);
} catch (SnapshotTooLarge $e) {
return Errors::make(
'snapshot_too_large',
sprintf(
'Die %s sind zu groß für eine Sicherung (%d Bytes, höchstens %d); nichts wurde gespeichert.',
$area->label(),
$e->size,
SnapshotStore::MAX_BYTES
),
'Ohne Sicherung schreibt LIT MCP nicht. Die Änderung im Divi-Builder vornehmen oder unbenutzte '
. 'Einträge dort entfernen.',
413
);
}
$saved = $this->write($ability, $area, $planned, $current, $snapshotId);
$logId = $this->log->record(
$ability,
self::OBJECT_TYPE,
$area->name(),
$planned->summary,
snapshotId: $snapshotId
);
$this->snapshots->attachLog($area->name(), $snapshotId, $logId);
return ToolResult::ok($planned->summary)
->snapshotId($snapshotId)
->logId($logId)
->warnings($planned->warnings)
->with('diff', $planned->diff)
->with('design_rev', $area->rev($saved))
->toArray();
}
/** Spec §4 / rulings P8, P8a: a confirmed change is refused while the caller may not edit a live post it changes. */
private function blocked(DesignPlan $plan): ?WP_Error
{
$usages = $plan->usages;
if ($usages === null || ! $usages->blocks()) {
return null;
}
return Errors::make(
'forbidden',
$usages->tooMany
? 'Die Änderung betrifft zu viele veröffentlichte Seiten, um sie einzeln zu prüfen; nichts wurde '
. 'gespeichert.'
: 'Die Änderung wirkt auf veröffentlichte Seiten, die dieses Konto nicht bearbeiten darf; nichts wurde '
. 'gespeichert.',
'Einen Administrator die Änderung vornehmen lassen oder sie im Divi-Builder ausführen.',
403,
['blocked_ids' => array_slice($usages->blocked, 0, self::LISTED_IDS)]
+ ($usages->blockedHidden > 0 ? ['blocked_hidden_count' => $usages->blockedHidden] : [])
);
}
/**
* @param array<array-key, mixed> $current
* @return array<array-key, mixed>
* @throws WriteFailed
*/
private function write(
string $ability,
DesignArea $area,
DesignPlan $plan,
array $current,
string $snapshotId
): array {
try {
$saved = $area->write($plan->raw, $plan->mayRemove);
if (! $area->verify($plan->raw, $saved)) {
throw new RuntimeException(sprintf('%s read back differs from what was written', $area->name()));
}
return $saved;
} catch (Throwable $e) {
if (DiviLocked::in($e) !== null) {
throw $e;
}
$this->rollBack($ability, $area, $current, $snapshotId, $e);
}
}
/**
* @param array<array-key, mixed> $current
* @throws WriteFailed Always.
*/
private function rollBack(
string $ability,
DesignArea $area,
array $current,
string $snapshotId,
Throwable $cause
): never {
$reason = sprintf('Die %s konnten nicht gespeichert werden.', $area->label());
$failures = Rollback::attempt(static fn () => $area->write($current, true));
if ($failures === []) {
throw WriteFailed::rolledBack(
$reason,
$cause,
sprintf('design %s write failed: %s', $area->name(), $cause->getMessage()),
details: ['snapshot_id' => $snapshotId]
);
}
$this->log->recordPartial(
$ability,
self::OBJECT_TYPE,
$area->name(),
sprintf('%s nicht zurückgenommen (%s); Snapshot %s', $area->label(), $failures[0], $snapshotId)
);
throw WriteFailed::partial(
$reason,
$cause,
sprintf('design %s rollback failed: %s', $area->name(), $failures[0]),
state: sprintf(
'Die %s sind möglicherweise teilweise geändert (Stand vorher: Snapshot %s).',
$area->label(),
$snapshotId
),
hint: sprintf(
'Den Aufruf nicht wiederholen. Mit lit/divi-design-restore und snapshot_id %s (Probelauf, dann '
. 'confirm: true) den vorherigen Stand wiederherstellen und dem Nutzer Bescheid geben.',
$snapshotId
),
details: ['snapshot_id' => $snapshotId]
);
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Library\UsageReport;
defined('ABSPATH') || exit;
/**
* What a design change would do (spec §4 step 3–4): the new raw inventory, the diff, a one-sentence German summary,
* the posts it affects (null = none to report), warnings and whether entries are removed (delete/restore).
*/
final class DesignPlan
{
/**
* @param array<array-key, mixed> $raw
* @param list<array<string, mixed>> $diff
* @param list<string> $warnings
*/
public function __construct(
public readonly array $raw,
public readonly array $diff,
public readonly string $summary,
public readonly ?UsageReport $usages = null,
public readonly array $warnings = [],
public readonly bool $mayRemove = false
) {
}
public function isEmpty(): bool
{
return $this->diff === [];
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
defined('ABSPATH') || exit;
/**
* The area "presets" (spec §6): the inventory in Divi's stored format (PresetBook), written as REST format through
* DesignSystemWriter::savePresets(). Divi's prepare_data() may sanitize values; verify() only insists on the same
* presets and defaults (PresetDiff without "changed").
*/
final class PresetArea implements DesignArea
{
public function __construct(private DesignSystemWriter $writer)
{
}
public function name(): string
{
return 'presets';
}
public function label(): string
{
return 'Presets';
}
public function raw(): array
{
return PresetBook::fromDivi($this->writer->presets())->data();
}
public function rev(array $raw): string
{
return PresetBook::fromDivi($raw)->rev();
}
public function write(array $raw, bool $mayRemove): array
{
return PresetBook::fromDivi($this->writer->savePresets(PresetBook::fromDivi($raw)->toRest(), $mayRemove))
->data();
}
public function verify(array $expected, array $actual): bool
{
$structural = array_filter(
PresetDiff::between(PresetBook::fromDivi($expected), PresetBook::fromDivi($actual)),
static fn (array $row): bool => $row['action'] !== 'changed'
);
return $structural === [];
}
}