fix: typed write failures, template rollback, parent and term cleanup
WriteFailed maps to lit_write_failed / lit_partial_write (partial writes are logged with result partial); old template read before the update; row marker set before wp_update_post; created terms deleted on rollback; parent must be a readable non-descendant of a hierarchical type; featured image must be readable; snapshot keeps empty values; meta comparison after sanitize_meta; follow-up failures (listener, change log) only add warnings. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -6,12 +6,13 @@ namespace LIT\MCP\Integrations\WordPress\Content;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Abilities\FollowUp;
|
||||
use LIT\MCP\Abilities\Rev;
|
||||
use LIT\MCP\Abilities\ToolResult;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use WP_Error;
|
||||
use WP_Post;
|
||||
@@ -19,11 +20,15 @@ use WP_Post;
|
||||
/**
|
||||
* lit/content-create: new entry, always a draft. All or nothing (spec §8.2): input is
|
||||
* validated before the insert; if fields, terms or the featured image cannot be written
|
||||
* afterwards, the brand-new draft is deleted again and the error is rethrown (the
|
||||
* AbilityRegistrar logs it and reports lit_internal_error).
|
||||
* afterwards, created terms and the brand-new draft are deleted again and a WriteFailed
|
||||
* is thrown (AbilityRegistrar → lit_write_failed, or lit_partial_write if that cleanup
|
||||
* failed or the insert itself threw; partial writes are logged with result "partial").
|
||||
*/
|
||||
final class ContentCreateAbility implements Ability
|
||||
{
|
||||
private const REASON_INSERT = 'WordPress hat beim Anlegen des Eintrags einen Fehler gemeldet.';
|
||||
private const REASON_EXTRAS = 'Felder, Begriffe oder Beitragsbild konnten nicht gespeichert werden.';
|
||||
|
||||
public function __construct(
|
||||
private ContentSchema $schema,
|
||||
private PostWriter $writer,
|
||||
@@ -101,7 +106,14 @@ final class ContentCreateAbility implements Ability
|
||||
'post_status' => 'draft',
|
||||
'post_author' => get_current_user_id(),
|
||||
]);
|
||||
$postId = wp_insert_post(wp_slash($data), true);
|
||||
try {
|
||||
$postId = wp_insert_post(wp_slash($data), true);
|
||||
} catch (Throwable $e) {
|
||||
// A hook threw – the row may or may not exist and its ID is unknown; nothing is
|
||||
// searched or deleted, the client is told to check (lit_partial_write without id).
|
||||
$this->logPartial($postType, '', 'Anlegen abgebrochen; Entwurf möglicherweise vorhanden');
|
||||
throw new WriteFailed(self::REASON_INSERT, false, null, null, $e);
|
||||
}
|
||||
if ($postId instanceof WP_Error) {
|
||||
return Errors::invalidInput('WordPress hat den Eintrag abgelehnt: ' . $postId->get_error_message());
|
||||
}
|
||||
@@ -110,42 +122,94 @@ final class ContentCreateAbility implements Ability
|
||||
try {
|
||||
$this->writer->writeExtras($postId, $postType, $prepared, $input);
|
||||
} catch (Throwable $e) {
|
||||
$this->discard($postId, $e);
|
||||
$this->discard($postId, $postType, $e);
|
||||
}
|
||||
do_action('lit_mcp_after_save', $postId, $this->name(), $input);
|
||||
|
||||
return $this->result($postId, $postType, $input, $prepared['warnings']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @param list<string> $warnings
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function result(int $postId, string $postType, array $input, array $warnings): array
|
||||
{
|
||||
// The draft is saved – failures from here on are logged and reported as warnings only.
|
||||
FollowUp::run(
|
||||
fn () => do_action('lit_mcp_after_save', $postId, $this->name(), $input),
|
||||
$this->name() . ' lit_mcp_after_save',
|
||||
'Eine Erweiterung ist nach dem Speichern auf einen Fehler gestoßen; der Entwurf ist gespeichert.',
|
||||
$warnings
|
||||
);
|
||||
|
||||
$post = get_post($postId);
|
||||
$title = $post instanceof WP_Post ? (string) $post->post_title : (string) $input['title'];
|
||||
$summary = sprintf('Entwurf "%s" angelegt', $title);
|
||||
$logId = $this->log->record($this->name(), $postType, (string) $postId, $summary);
|
||||
$logId = FollowUp::run(
|
||||
fn () => $this->log->record($this->name(), $postType, (string) $postId, $summary),
|
||||
$this->name() . ' change log',
|
||||
'Änderungsprotokoll konnte nicht geschrieben werden; der Entwurf ist gespeichert.',
|
||||
$warnings
|
||||
);
|
||||
|
||||
return ToolResult::ok(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId))
|
||||
->changed((string) $postId, $postType, 'created', $title)
|
||||
->rev($post instanceof WP_Post ? Rev::ofPost($post) : null)
|
||||
->logId($logId)
|
||||
->logId(is_int($logId) ? $logId : null)
|
||||
->urls((string) get_preview_post_link($postId), (string) get_edit_post_link($postId, 'raw'))
|
||||
->warnings($prepared['warnings'])
|
||||
->warnings($warnings)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the draft this call just created (it cannot have been used anywhere yet).
|
||||
* Rolls the brand-new draft back: terms it created, then the draft itself (force –
|
||||
* it cannot have been used anywhere yet).
|
||||
*
|
||||
* @throws RuntimeException Always – the original failure, noting whether the draft is gone.
|
||||
* @throws WriteFailed Always.
|
||||
*/
|
||||
private function discard(int $postId, Throwable $cause): never
|
||||
private function discard(int $postId, string $postType, Throwable $cause): never
|
||||
{
|
||||
$deleted = wp_delete_post($postId, true);
|
||||
$problems = [];
|
||||
try {
|
||||
$this->writer->deleteCreatedTerms();
|
||||
} catch (Throwable $e) {
|
||||
$problems[] = $e->getMessage();
|
||||
}
|
||||
|
||||
throw new RuntimeException(
|
||||
sprintf(
|
||||
'Extras of new post %d could not be written (%s); draft %s.',
|
||||
$postId,
|
||||
$cause->getMessage(),
|
||||
$deleted ? 'deleted again' : 'could NOT be deleted'
|
||||
),
|
||||
0,
|
||||
$cause
|
||||
try {
|
||||
if (! wp_delete_post($postId, true)) {
|
||||
$problems[] = sprintf('draft %d could not be deleted', $postId);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$problems[] = sprintf('draft %d could not be deleted: %s', $postId, $e->getMessage());
|
||||
}
|
||||
|
||||
$detail = sprintf('Extras of new post %d could not be written: %s', $postId, $cause->getMessage());
|
||||
if ($problems === []) {
|
||||
throw new WriteFailed(self::REASON_EXTRAS, true, null, null, $cause, $detail);
|
||||
}
|
||||
|
||||
$summary = sprintf('Entwurf %d: Anlegen nur teilweise zurückgenommen', $postId);
|
||||
$this->logPartial($postType, (string) $postId, $summary);
|
||||
throw new WriteFailed(
|
||||
self::REASON_EXTRAS,
|
||||
false,
|
||||
$postId,
|
||||
null,
|
||||
$cause,
|
||||
$detail . '; rollback: ' . implode('; ', $problems)
|
||||
);
|
||||
}
|
||||
|
||||
private function logPartial(string $postType, string $postId, string $summary): void
|
||||
{
|
||||
$ignored = [];
|
||||
FollowUp::run(
|
||||
fn () => $this->log->record($this->name(), $postType, $postId, $summary, null, null, 'partial'),
|
||||
$this->name() . ' change log',
|
||||
'',
|
||||
$ignored
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user