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:
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace LIT\MCP\Abilities;
|
||||
|
||||
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\Log;
|
||||
use LIT\MCP\Server\RequestContext;
|
||||
@@ -14,15 +16,18 @@ use WP_Error;
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Registers all abilities of available integrations with the Abilities API and
|
||||
* wraps them with the role gate and neutral error handling.
|
||||
* Registers all abilities of available integrations with the Abilities API and wraps them
|
||||
* with the role gate, the Divi release lock (DiviToolGate) and neutral error handling.
|
||||
*/
|
||||
final class AbilityRegistrar
|
||||
{
|
||||
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
|
||||
@@ -50,6 +55,7 @@ final class AbilityRegistrar
|
||||
/** @return array<string, mixed> */
|
||||
public function argsFor(Ability $ability): array
|
||||
{
|
||||
$divi = $this->divi ?? new DiviToolGate($this->registry->groupMap(...));
|
||||
$args = [
|
||||
'label' => $ability->label(),
|
||||
'description' => $ability->description(),
|
||||
@@ -71,15 +77,18 @@ final class AbilityRegistrar
|
||||
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 {
|
||||
return $ability->execute(is_array($input) ? $input : []);
|
||||
} catch (WriteFailed $e) {
|
||||
Log::failure($ability->name(), $e);
|
||||
return $e->toError();
|
||||
return $divi->annotate($ability, $ability->execute(is_array($input) ? $input : []));
|
||||
} catch (Throwable $e) {
|
||||
Log::failure($ability->name(), $e);
|
||||
return Errors::internal();
|
||||
|
||||
return self::errorFor($e);
|
||||
}
|
||||
},
|
||||
'meta' => [
|
||||
@@ -95,11 +104,28 @@ final class AbilityRegistrar
|
||||
],
|
||||
];
|
||||
|
||||
$output = $ability->outputSchema();
|
||||
$output = $divi->outputSchema($ability, $ability->outputSchema());
|
||||
if ($output !== null) {
|
||||
$args['output_schema'] = $output;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,8 +126,8 @@ final class LogListTable extends \WP_List_Table
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks rows whose write did not complete (result "partial": failed after an
|
||||
* irreversible step, spec §2 principle 7/8.2) so admins notice them while scrolling.
|
||||
* Marks rows whose write did not complete (result "partial": failed after an irreversible step,
|
||||
* spec §2 principle 7/8.2) or was refused (Divi release lock) so admins notice them while scrolling.
|
||||
*
|
||||
* @param array<string, mixed> $item
|
||||
*/
|
||||
@@ -150,6 +150,7 @@ final class LogListTable extends \WP_List_Table
|
||||
return match ($result) {
|
||||
'ok' => null,
|
||||
'partial' => ['warning', __('teilweise', 'wp-lit-mcp')],
|
||||
'refused' => ['warning', __('abgelehnt', 'wp-lit-mcp')],
|
||||
'failed', 'error' => ['error', __('fehlgeschlagen', 'wp-lit-mcp')],
|
||||
default => ['error', __('unbekannt', 'wp-lit-mcp')],
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ final class LogTab implements AdminTab
|
||||
/** 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_DEFAULT = 20;
|
||||
private const RESULTS = ['ok', 'partial'];
|
||||
private const RESULTS = ['ok', 'partial', 'refused'];
|
||||
|
||||
private AdminPostAction $action;
|
||||
|
||||
@@ -233,7 +233,11 @@ final class LogTab implements AdminTab
|
||||
self::filterLabel('result', __('Nach Ergebnis filtern', 'wp-lit-mcp'));
|
||||
echo '<select name="result" id="lit-mcp-filter-result">';
|
||||
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) {
|
||||
printf(
|
||||
'<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
@@ -24,6 +24,7 @@ use LIT\MCP\Guides\InstructionsBuilder;
|
||||
use LIT\MCP\Integrations\Core\CoreIntegration;
|
||||
use LIT\MCP\Integrations\Divi\DiviIntegration;
|
||||
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\IntegrationRegistry;
|
||||
use LIT\MCP\Integrations\Media\MediaIntegration;
|
||||
@@ -128,7 +129,7 @@ final class Plugin
|
||||
);
|
||||
|
||||
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
|
||||
(new AbilityRegistrar($registry, $policy))->register();
|
||||
(new AbilityRegistrar($registry, $policy, new DiviToolGate($registry->groupMap(...), $changeLog)))->register();
|
||||
(new ServerRegistrar(
|
||||
$registry,
|
||||
new InstructionsBuilder($registry, static fn (): array => $schema->postTypeNames(), $policy),
|
||||
|
||||
Reference in New Issue
Block a user