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:
co-authored by
Claude Sonnet 5.5
parent
bfd3840eb1
commit
bb4d9b3baf
@@ -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 === [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user