feat: adapter-level Divi release lock, content-create and site status

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 02:04:49 +02:00
co-authored by Claude Opus 5.5
parent adfe0adfc2
commit c6a1996be6
12 changed files with 192 additions and 6 deletions
+8 -1
View File
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Core;
use LIT\MCP\Integrations\Divi\DiviCompatibility;
use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
defined('ABSPATH') || exit;
@@ -19,7 +20,10 @@ class EnvironmentProbe
{
}
/** @return list<array{id: string, active: bool, version: ?string, problems: list<string>}> */
/**
* @return list<array{id: string, active: bool, version: ?string, problems: list<string>,
* release: ?array<string, mixed>}>
*/
public function components(): array
{
$diviVersion = DiviEnvironment::version();
@@ -33,18 +37,21 @@ class EnvironmentProbe
'active' => $isDivi,
'version' => $diviVersion,
'problems' => $isDivi ? ($this->divi ?? new DiviCompatibility())->problems() : [],
'release' => $isDivi ? ReleaseGate::status()->toArray() : null,
],
[
'id' => 'pods',
'active' => defined('PODS_VERSION'),
'version' => defined('PODS_VERSION') ? (string) PODS_VERSION : null,
'problems' => [],
'release' => null,
],
[
'id' => 'the-events-calendar',
'active' => $tecActive,
'version' => $tecActive ? (string) constant('Tribe__Events__Main::VERSION') : null,
'problems' => [],
'release' => null,
],
];
}
+2 -1
View File
@@ -31,7 +31,8 @@ final class SiteStatusAbility implements Ability
{
return 'Überblick: Website, WordPress-Version, eigener Benutzer und Rollen, '
. 'erkannte Integrationen (Divi, Pods, The Events Calendar). '
. 'Bei Divi nennt problems Divi-Funktionen, die nach einem Update fehlen.';
. 'Bei Divi nennt problems Divi-Stellen, die nach einem Update fehlen oder sich geändert haben, und '
. 'release, ob LIT MCP mit dieser Divi-Version schreiben darf (released, untested, incompatible).';
}
public function inputSchema(): array
@@ -7,8 +7,10 @@ namespace LIT\MCP\Integrations\Divi\Library;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\Mirror;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
use RuntimeException;
use Throwable;
@@ -211,9 +213,11 @@ class DiviLibraryStore implements DiviAdapter
* when that delete fails; a throwing insert hook is a partial write without ID.
*
* @throws WriteFailed
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
*/
public function create(LibraryItemSpec $spec, NewDraft $draft): int
{
ReleaseGate::assertWritable();
$missing = $this->missingTaxonomies();
if ($missing !== []) {
throw WriteFailed::rolledBack(
@@ -10,6 +10,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
@@ -59,6 +60,13 @@ final class DiviContentComposer implements ContentComposer
return ($this->writable)();
}
public function refusal(): ?WP_Error
{
$status = ReleaseGate::status();
return $status->writable() ? null : $status->refusal();
}
public function check(string $postType, array $sections): ?WP_Error
{
if (! $this->schema->diviEnabled($postType)) {
@@ -8,6 +8,8 @@ use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\Rollback;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\RevisionPoint;
@@ -69,10 +71,12 @@ class DiviSaver
/**
* @return array{revision_id: ?int, warnings: list<string>}
* @throws WriteFailed
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
*/
public function save(WP_Post $post, string $content, SaveScope $scope = SaveScope::Page): array
{
$postId = (int) $post->ID;
ReleaseGate::assertWritable($postId);
$revisionId = null;
try {
@@ -138,9 +142,11 @@ class DiviSaver
*
* @return list<string> Warnings (CHANGED_ON_SAVE).
* @throws RuntimeException
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
*/
public function saveNew(int $postId, string $content, SaveScope $scope = SaveScope::Page): array
{
ReleaseGate::assertWritable($postId);
$refusal = self::refusal($this->writeContent($postId, $content));
if ($refusal !== null) {
throw new RuntimeException(sprintf('wp_update_post(%d) refused the Divi layout: %s', $postId, $refusal));
@@ -6,8 +6,10 @@ namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\Mirror;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use RuntimeException;
use Throwable;
use WP_Error;
@@ -229,9 +231,13 @@ class DiviThemeBuilder implements DiviAdapter
return $found !== null;
}
/** @throws RuntimeException */
/**
* @throws RuntimeException
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
*/
public function insertLayout(string $area, string $title): int
{
ReleaseGate::assertWritable();
$type = self::LAYOUT_TYPES[$area] ?? throw new RuntimeException('unknown Theme Builder area ' . $area);
$this->assertCanWrite('et_theme_builder_insert_layout');
$id = $this->guarded(
@@ -253,9 +259,11 @@ class DiviThemeBuilder implements DiviAdapter
* @param array{title: string, layouts: array<string, array{id: int, enabled: bool}>, use_on: list<string>,
* exclude_from: list<string>} $template
* @throws RuntimeException
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
*/
public function storeTemplate(int $themeBuilderId, array $template): int
{
ReleaseGate::assertWritable();
$this->assertCanWrite('et_theme_builder_store_template', 'et_');
$payload = [
'title' => $template['title'],
@@ -285,9 +293,11 @@ class DiviThemeBuilder implements DiviAdapter
* template unused (stage3b README finding 10) – detachedIds() finds it, callers unmarkUnused() on reattach.
*
* @throws RuntimeException
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
*/
public function attach(int $themeBuilderId, int $templateId): void
{
ReleaseGate::assertWritable();
$existing = get_post_meta($themeBuilderId, self::TEMPLATE_LIST, false);
if (is_array($existing) && in_array((string) $templateId, array_map('strval', $existing), true)) {
return;
@@ -304,8 +314,10 @@ class DiviThemeBuilder implements DiviAdapter
}
}
/** @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. */
public function detach(int $themeBuilderId, int $templateId): void
{
ReleaseGate::assertWritable();
delete_post_meta($themeBuilderId, self::TEMPLATE_LIST, (string) $templateId);
}
@@ -316,9 +328,11 @@ class DiviThemeBuilder implements DiviAdapter
* @param list<string> $useOn
* @param list<string> $excludeFrom
* @throws RuntimeException
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
*/
public function setConditions(int $templateId, array $useOn, array $excludeFrom): void
{
ReleaseGate::assertWritable();
foreach ([self::USE_ON => $useOn, self::EXCLUDE_FROM => $excludeFrom] as $key => $conditions) {
delete_post_meta($templateId, $key);
foreach (array_values(array_unique($conditions)) as $condition) {
@@ -329,9 +343,13 @@ class DiviThemeBuilder implements DiviAdapter
}
}
/** @throws RuntimeException */
/**
* @throws RuntimeException
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
*/
public function setEnabled(int $templateId, bool $enabled): void
{
ReleaseGate::assertWritable();
$value = $enabled ? '1' : '0';
update_post_meta($templateId, self::ENABLED, $value);
if ((string) get_post_meta($templateId, self::ENABLED, true) !== $value) {
@@ -339,9 +357,14 @@ class DiviThemeBuilder implements DiviAdapter
}
}
/** Removes Divi's "unused" mark (F19), as Divi's own save does for every listed post. */
/**
* Removes Divi's "unused" mark (F19), as Divi's own save does for every listed post.
*
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
*/
public function unmarkUnused(int $postId): void
{
ReleaseGate::assertWritable();
delete_post_meta($postId, self::UNUSED);
}
@@ -29,6 +29,12 @@ interface ContentComposer
*/
public function writable(): bool;
/**
* Why writing such content is refused right now although it is enabled (e.g. the installed Divi version is not
* released for LIT MCP, spec §5.5); null = allowed.
*/
public function refusal(): ?WP_Error;
/**
* Validates the description for a new entry of $postType. Nothing is written.
*
@@ -133,6 +133,11 @@ final class ContentCreateAbility implements Ability
);
}
$locked = $withDivi ? $this->divi->refusal() : null;
if ($locked !== null) {
return $locked;
}
$prepared = $this->writer->prepare($postType, $input, true);
$problem = self::combined($prepared, $withDivi ? $this->checkDivi($postType, $input) : null);
if ($problem !== null) {