feat: central Divi release lock for the Divi tools

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 01:54:36 +02:00
co-authored by Claude Opus 5.5
parent cf3c5dfe76
commit adfe0adfc2
8 changed files with 334 additions and 18 deletions
+37 -11
View File
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace LIT\MCP\Abilities; namespace LIT\MCP\Abilities;
use LIT\MCP\Access; use LIT\MCP\Access;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
use LIT\MCP\Integrations\IntegrationRegistry; use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Log; use LIT\MCP\Log;
use LIT\MCP\Server\RequestContext; use LIT\MCP\Server\RequestContext;
@@ -14,15 +16,18 @@ use WP_Error;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** /**
* Registers all abilities of available integrations with the Abilities API and * Registers all abilities of available integrations with the Abilities API and wraps them
* wraps them with the role gate and neutral error handling. * with the role gate, the Divi release lock (DiviToolGate) and neutral error handling.
*/ */
final class AbilityRegistrar final class AbilityRegistrar
{ {
public const CATEGORY = 'lit-mcp'; public const CATEGORY = 'lit-mcp';
public function __construct(private IntegrationRegistry $registry, private ?ExposurePolicy $policy = null) public function __construct(
{ private IntegrationRegistry $registry,
private ?ExposurePolicy $policy = null,
private ?DiviToolGate $divi = null
) {
} }
public function register(): void public function register(): void
@@ -50,6 +55,7 @@ final class AbilityRegistrar
/** @return array<string, mixed> */ /** @return array<string, mixed> */
public function argsFor(Ability $ability): array public function argsFor(Ability $ability): array
{ {
$divi = $this->divi ?? new DiviToolGate($this->registry->groupMap(...));
$args = [ $args = [
'label' => $ability->label(), 'label' => $ability->label(),
'description' => $ability->description(), 'description' => $ability->description(),
@@ -71,15 +77,18 @@ final class AbilityRegistrar
return Errors::internal(); return Errors::internal();
} }
}, },
'execute_callback' => static function (mixed $input = null) use ($ability): mixed { 'execute_callback' => static function (mixed $input = null) use ($ability, $divi): mixed {
$refusal = $divi->refusal($ability);
if ($refusal !== null) {
return $refusal;
}
try { try {
return $ability->execute(is_array($input) ? $input : []); return $divi->annotate($ability, $ability->execute(is_array($input) ? $input : []));
} catch (WriteFailed $e) {
Log::failure($ability->name(), $e);
return $e->toError();
} catch (Throwable $e) { } catch (Throwable $e) {
Log::failure($ability->name(), $e); Log::failure($ability->name(), $e);
return Errors::internal();
return self::errorFor($e);
} }
}, },
'meta' => [ 'meta' => [
@@ -95,11 +104,28 @@ final class AbilityRegistrar
], ],
]; ];
$output = $ability->outputSchema(); $output = $divi->outputSchema($ability, $ability->outputSchema());
if ($output !== null) { if ($output !== null) {
$args['output_schema'] = $output; $args['output_schema'] = $output;
} }
return $args; return $args;
} }
/**
* A partial write stays partial_write; otherwise a Divi release lock anywhere in the cause chain is its refusal
* (spec §5.5); a rolled-back write is write_failed; anything else internal_error.
*/
private static function errorFor(Throwable $e): WP_Error
{
if ($e instanceof WriteFailed && ! $e->rolledBack) {
return $e->toError();
}
$locked = DiviLocked::in($e);
if ($locked !== null) {
return $locked->toError();
}
return $e instanceof WriteFailed ? $e->toError() : Errors::internal();
}
} }
+3 -2
View File
@@ -126,8 +126,8 @@ final class LogListTable extends \WP_List_Table
} }
/** /**
* Marks rows whose write did not complete (result "partial": failed after an * Marks rows whose write did not complete (result "partial": failed after an irreversible step,
* irreversible step, spec §2 principle 7/8.2) so admins notice them while scrolling. * spec §2 principle 7/8.2) or was refused (Divi release lock) so admins notice them while scrolling.
* *
* @param array<string, mixed> $item * @param array<string, mixed> $item
*/ */
@@ -150,6 +150,7 @@ final class LogListTable extends \WP_List_Table
return match ($result) { return match ($result) {
'ok' => null, 'ok' => null,
'partial' => ['warning', __('teilweise', 'wp-lit-mcp')], 'partial' => ['warning', __('teilweise', 'wp-lit-mcp')],
'refused' => ['warning', __('abgelehnt', 'wp-lit-mcp')],
'failed', 'error' => ['error', __('fehlgeschlagen', 'wp-lit-mcp')], 'failed', 'error' => ['error', __('fehlgeschlagen', 'wp-lit-mcp')],
default => ['error', __('unbekannt', 'wp-lit-mcp')], default => ['error', __('unbekannt', 'wp-lit-mcp')],
}; };
+6 -2
View File
@@ -22,7 +22,7 @@ final class LogTab implements AdminTab
/** Screen option "Einträge pro Seite" (user option; WordPress needs the "_per_page" end). */ /** Screen option "Einträge pro Seite" (user option; WordPress needs the "_per_page" end). */
public const PER_PAGE_OPTION = 'lit_mcp_log_per_page'; public const PER_PAGE_OPTION = 'lit_mcp_log_per_page';
public const PER_PAGE_DEFAULT = 20; public const PER_PAGE_DEFAULT = 20;
private const RESULTS = ['ok', 'partial']; private const RESULTS = ['ok', 'partial', 'refused'];
private AdminPostAction $action; private AdminPostAction $action;
@@ -233,7 +233,11 @@ final class LogTab implements AdminTab
self::filterLabel('result', __('Nach Ergebnis filtern', 'wp-lit-mcp')); self::filterLabel('result', __('Nach Ergebnis filtern', 'wp-lit-mcp'));
echo '<select name="result" id="lit-mcp-filter-result">'; echo '<select name="result" id="lit-mcp-filter-result">';
printf('<option value="">%s</option>', esc_html__('Alle Ergebnisse', 'wp-lit-mcp')); printf('<option value="">%s</option>', esc_html__('Alle Ergebnisse', 'wp-lit-mcp'));
$labels = ['ok' => __('erfolgreich', 'wp-lit-mcp'), 'partial' => __('teilweise', 'wp-lit-mcp')]; $labels = [
'ok' => __('erfolgreich', 'wp-lit-mcp'),
'partial' => __('teilweise', 'wp-lit-mcp'),
'refused' => __('abgelehnt', 'wp-lit-mcp'),
];
foreach (self::RESULTS as $result) { foreach (self::RESULTS as $result) {
printf( printf(
'<option value="%s"%s>%s</option>', '<option value="%s"%s>%s</option>',
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
use WP_Error;
defined('ABSPATH') || exit;
/**
* The central release lock of the Divi tools (spec §5.5): a writing tool of the groups Divi, Divi-Bibliothek or Divi
* Theme Builder is refused before it runs while Divi is not released (logged with result "refused"); a reading tool
* runs and gets the notice in warnings. Other tools that reach Divi (lit/content-create with divi) meet the adapter
* lock (ReleaseGate::assertWritable()).
*/
final class DiviToolGate
{
public const GROUPS = ['divi', LibraryAccess::GROUP, ThemeBuilderAccess::GROUP];
/** @var Closure(): array<string, string> */
private Closure $groups;
/**
* @param Closure(): array<string, string> $groups Ability name => integration ID (IntegrationRegistry::groupMap).
*/
public function __construct(Closure $groups, private ?ChangeLog $log = null)
{
$this->groups = $groups;
}
public function covers(Ability $ability): bool
{
return in_array(($this->groups)()[$ability->name()] ?? '', self::GROUPS, true);
}
public function refusal(Ability $ability): ?WP_Error
{
if (! $this->covers($ability) || self::readsOnly($ability)) {
return null;
}
$status = ReleaseGate::status();
if ($status->writable()) {
return null;
}
$this->log?->record(
$ability->name(),
'divi',
$status->version,
sprintf(
'Abgelehnt: %s ist %s.',
$status->versionLabel(),
$status->state === ReleaseState::Incompatible ? 'inkompatibel' : 'nicht freigegeben'
),
result: 'refused'
);
return $status->refusal();
}
public function annotate(Ability $ability, mixed $result): mixed
{
if (! is_array($result) || array_is_list($result) || ! $this->covers($ability) || ! self::readsOnly($ability)) {
return $result;
}
$status = ReleaseGate::status();
if ($status->writable()) {
return $result;
}
$warnings = is_array($result['warnings'] ?? null) ? array_values($result['warnings']) : [];
$warnings[] = $status->notice();
$result['warnings'] = $warnings;
return $result;
}
/**
* @param array<string, mixed>|null $schema
* @return array<string, mixed>|null
*/
public function outputSchema(Ability $ability, ?array $schema): ?array
{
if ($schema === null || ($schema['type'] ?? null) !== 'object' || ! $this->covers($ability)) {
return $schema;
}
$properties = is_array($schema['properties'] ?? null) ? $schema['properties'] : [];
$properties['warnings'] ??= ['type' => 'array', 'items' => ['type' => 'string']];
$schema['properties'] = $properties;
return $schema;
}
private static function readsOnly(Ability $ability): bool
{
return ($ability->annotations()['readonly'] ?? false) === true;
}
}
+2 -1
View File
@@ -24,6 +24,7 @@ use LIT\MCP\Guides\InstructionsBuilder;
use LIT\MCP\Integrations\Core\CoreIntegration; use LIT\MCP\Integrations\Core\CoreIntegration;
use LIT\MCP\Integrations\Divi\DiviIntegration; use LIT\MCP\Integrations\Divi\DiviIntegration;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess; use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess; use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
use LIT\MCP\Integrations\IntegrationRegistry; use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Integrations\Media\MediaIntegration; use LIT\MCP\Integrations\Media\MediaIntegration;
@@ -128,7 +129,7 @@ final class Plugin
); );
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...)); $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
(new AbilityRegistrar($registry, $policy))->register(); (new AbilityRegistrar($registry, $policy, new DiviToolGate($registry->groupMap(...), $changeLog)))->register();
(new ServerRegistrar( (new ServerRegistrar(
$registry, $registry,
new InstructionsBuilder($registry, static fn (): array => $schema->postTypeNames(), $policy), new InstructionsBuilder($registry, static fn (): array => $schema->postTypeNames(), $policy),
+72 -2
View File
@@ -9,6 +9,11 @@ use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\AbilityRegistrar; use LIT\MCP\Abilities\AbilityRegistrar;
use LIT\MCP\Abilities\ExposurePolicy; use LIT\MCP\Abilities\ExposurePolicy;
use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Integrations\Integration; use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\IntegrationRegistry; use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Server\RequestContext; use LIT\MCP\Server\RequestContext;
@@ -43,7 +48,18 @@ final class AbilityRegistrarTest extends TestCase
return new AbilityRegistrar(new IntegrationRegistry($integration)); return new AbilityRegistrar(new IntegrationRegistry($integration));
} }
private function ability(): Mockery\MockInterface private function diviRegistrar(Ability $ability): AbilityRegistrar
{
$integration = Mockery::mock(Integration::class);
$integration->allows('id')->andReturn('divi');
$integration->allows('isAvailable')->andReturn(true);
$integration->allows('abilities')->andReturn([$ability]);
$registry = new IntegrationRegistry($integration);
return new AbilityRegistrar($registry, null, new DiviToolGate($registry->groupMap(...)));
}
private function ability(bool $readonly = true): Mockery\MockInterface
{ {
$ability = Mockery::mock(Ability::class); $ability = Mockery::mock(Ability::class);
$ability->allows('name')->andReturn('lit/test'); $ability->allows('name')->andReturn('lit/test');
@@ -51,7 +67,7 @@ final class AbilityRegistrarTest extends TestCase
$ability->allows('description')->andReturn('Test ability'); $ability->allows('description')->andReturn('Test ability');
$ability->allows('inputSchema')->andReturn(['type' => 'object', 'properties' => []]); $ability->allows('inputSchema')->andReturn(['type' => 'object', 'properties' => []]);
$ability->allows('outputSchema')->andReturn(['type' => 'object']); $ability->allows('outputSchema')->andReturn(['type' => 'object']);
$ability->allows('annotations')->andReturn(['readonly' => true]); $ability->allows('annotations')->andReturn(['readonly' => $readonly]);
$ability->allows('isDirect')->andReturn(true); $ability->allows('isDirect')->andReturn(true);
return $ability; return $ability;
@@ -202,6 +218,60 @@ final class AbilityRegistrarTest extends TestCase
unlink($log); unlink($log);
} }
public function testUntestedDiviRefusesAWritingDiviToolBeforeItRuns(): void
{
ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
$ability = $this->ability(false);
$ability->expects('execute')->never();
$args = $this->diviRegistrar($ability)->argsFor($ability);
self::assertToolError(($args['execute_callback'])([]), 'divi_untested');
}
public function testALockThrownInsideAWriteBecomesTheLockError(): void
{
$locked = new DiviLocked(new ReleaseStatus(ReleaseState::Incompatible, '5.3.0', null, ['x']));
$ability = $this->ability();
$ability->allows('execute')->andThrow(WriteFailed::rolledBack('Nicht gespeichert.', $locked));
self::assertToolError($this->executeLogged($ability), 'divi_incompatible');
}
public function testAPartialWriteStaysPartialEvenWithALockInside(): void
{
$locked = new DiviLocked(new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
$ability = $this->ability();
$ability->allows('execute')->andThrow(WriteFailed::partial('Teilweise gespeichert.', $locked));
self::assertToolError($this->executeLogged($ability), 'partial_write');
}
/**
* Runs the execute callback with error_log() redirected (a PHP internal, not stubbable – like the tests above).
*/
private function executeLogged(Ability $ability): mixed
{
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
try {
return ($this->registrarFor($ability)->argsFor($ability)['execute_callback'])([]);
} finally {
ini_set('error_log', (string) $previous);
unlink($log);
}
}
public function testDiviReadToolsGetWarningsInTheirSchema(): void
{
$ability = $this->ability();
$args = $this->diviRegistrar($ability)->argsFor($ability);
self::assertArrayHasKey('warnings', $args['output_schema']['properties']);
}
public function testRegisterAbilitiesCallsWordPressForEachAbility(): void public function testRegisterAbilitiesCallsWordPressForEachAbility(): void
{ {
$ability = $this->ability(); $ability = $this->ability();
+1
View File
@@ -141,6 +141,7 @@ final class LogListTableTest extends TestCase
{ {
return [ return [
['partial', 'warning', 'teilweise'], ['partial', 'warning', 'teilweise'],
['refused', 'warning', 'abgelehnt'],
['failed', 'error', 'fehlgeschlagen'], ['failed', 'error', 'fehlgeschlagen'],
['<b>kaputt', 'error', 'unbekannt'], ['<b>kaputt', 'error', 'unbekannt'],
]; ];
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Error;
final class DiviToolGateTest extends TestCase
{
private const GROUPS = [
'lit/divi-insert' => 'divi',
'lit/divi-outline' => 'divi',
'lit/library-save' => 'divi-library',
'lit/tb-create-template' => 'divi-theme-builder',
'lit/content-create' => 'wordpress',
];
private function ability(string $name, bool $readonly): Ability
{
$ability = Mockery::mock(Ability::class);
$ability->allows('name')->andReturn($name);
$ability->allows('annotations')->andReturn(['readonly' => $readonly]);
return $ability;
}
private function gate(?FakeChangeLog $log = null): DiviToolGate
{
return new DiviToolGate(static fn (): array => self::GROUPS, $log);
}
private function untested(): void
{
ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
}
public function testReleasedDiviRefusesNothingAndAddsNothing(): void
{
$gate = $this->gate(FakeChangeLog::forbidden());
self::assertNull($gate->refusal($this->ability('lit/divi-insert', false)));
self::assertSame(['ok' => true], $gate->annotate($this->ability('lit/divi-outline', true), ['ok' => true]));
}
public function testWriteToolOfEveryDiviGroupIsRefused(): void
{
$this->untested();
$log = new FakeChangeLog();
$gate = $this->gate($log);
foreach (['lit/divi-insert', 'lit/library-save', 'lit/tb-create-template'] as $name) {
self::assertToolError($gate->refusal($this->ability($name, false)), 'divi_untested', $name);
}
self::assertNull($gate->refusal($this->ability('lit/content-create', false)), 'not a Divi tool (adapter lock)');
self::assertNull($gate->refusal($this->ability('lit/divi-outline', true)), 'reading stays allowed');
self::assertSame(['refused', 'refused', 'refused'], array_column($log->records, 'result'));
self::assertSame('Abgelehnt: Divi 5.3.0 ist nicht freigegeben.', $log->records[0]['summary']);
}
public function testReadResultsKeepTheirShapeAndGetTheNotice(): void
{
$this->untested();
$gate = $this->gate();
$read = $this->ability('lit/divi-outline', true);
$notice = 'Achtung: Divi 5.3.0 ist für LIT MCP ungetestet; Lesen funktioniert, Schreiben ist gesperrt.';
self::assertSame(['post_id' => 7, 'warnings' => [$notice]], $gate->annotate($read, ['post_id' => 7]));
self::assertSame(
['warnings' => ['alt', $notice]],
$gate->annotate($read, ['warnings' => ['alt']])
);
self::assertSame([1, 2], $gate->annotate($read, [1, 2]), 'a list stays a list');
$error = new WP_Error('lit_not_found', 'x');
self::assertSame($error, $gate->annotate($read, $error));
$other = $this->ability('lit/content-create', true);
self::assertSame(['post_id' => 7], $gate->annotate($other, ['post_id' => 7]));
}
public function testOutputSchemaAllowsWarnings(): void
{
$gate = $this->gate();
$read = $this->ability('lit/divi-outline', true);
$properties = ['post_id' => ['type' => 'integer']];
$schema = $gate->outputSchema($read, ['type' => 'object', 'properties' => $properties]);
$warnings = ['type' => 'array', 'items' => ['type' => 'string']];
self::assertSame($warnings, $schema['properties']['warnings'] ?? null);
$own = ['type' => 'object', 'properties' => ['warnings' => ['type' => 'array']]];
self::assertSame($own, $gate->outputSchema($read, $own), 'an existing warnings property stays');
self::assertNull($gate->outputSchema($read, null));
self::assertSame(
['type' => 'object'],
$gate->outputSchema($this->ability('lit/content-create', true), ['type' => 'object'])
);
}
}