feat(changelog): Hook lit_mcp_changed
ChangeLog::record() löst nach jeder erfolgreichen oder teilweise gespeicherten Änderung lit_mcp_changed(ability, objectType, objectId, logId) aus, nie bei abgelehnten. Die Werte sind gekürzt wie im Protokoll; Ausnahmen von Listenern werden protokolliert und ändern das Ergebnis des Tools nicht. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1a294b7881
commit
c383b99e7d
@@ -18,6 +18,9 @@ class ChangeLog
|
|||||||
{
|
{
|
||||||
public const TABLE = 'lit_mcp_log';
|
public const TABLE = 'lit_mcp_log';
|
||||||
|
|
||||||
|
/** Fires after every write that changed something (result ok or partial), see announce(). */
|
||||||
|
public const CHANGED_ACTION = 'lit_mcp_changed';
|
||||||
|
|
||||||
public function __construct(private ?\wpdb $db = null)
|
public function __construct(private ?\wpdb $db = null)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -42,15 +45,19 @@ class ChangeLog
|
|||||||
string $result = 'ok'
|
string $result = 'ok'
|
||||||
): ?int {
|
): ?int {
|
||||||
$wpdb = $this->db();
|
$wpdb = $this->db();
|
||||||
|
// Shortened once: the row and the announcement carry the same values.
|
||||||
|
$ability = mb_substr($ability, 0, 100);
|
||||||
|
$objectType = mb_substr($objectType, 0, 50);
|
||||||
|
$objectId = mb_substr($objectId, 0, 191);
|
||||||
$inserted = $wpdb->insert(
|
$inserted = $wpdb->insert(
|
||||||
$this->tableName(),
|
$this->tableName(),
|
||||||
[
|
[
|
||||||
'created_at' => current_time('mysql', true),
|
'created_at' => current_time('mysql', true),
|
||||||
'user_id' => get_current_user_id(),
|
'user_id' => get_current_user_id(),
|
||||||
'client' => $this->sanitizedClientName(),
|
'client' => $this->sanitizedClientName(),
|
||||||
'ability' => mb_substr($ability, 0, 100),
|
'ability' => $ability,
|
||||||
'object_type' => mb_substr($objectType, 0, 50),
|
'object_type' => $objectType,
|
||||||
'object_id' => mb_substr($objectId, 0, 191),
|
'object_id' => $objectId,
|
||||||
'summary' => mb_substr($summary, 0, 500),
|
'summary' => mb_substr($summary, 0, 500),
|
||||||
'revision_id' => $revisionId,
|
'revision_id' => $revisionId,
|
||||||
'snapshot_id' => $snapshotId,
|
'snapshot_id' => $snapshotId,
|
||||||
@@ -59,12 +66,36 @@ class ChangeLog
|
|||||||
['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s']
|
['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s']
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$logId = null;
|
||||||
if ($inserted === false) {
|
if ($inserted === false) {
|
||||||
Log::line(sprintf('change log insert failed for %s %s:%s', $ability, $objectType, $objectId));
|
Log::line(sprintf('change log insert failed for %s %s:%s', $ability, $objectType, $objectId));
|
||||||
return null;
|
} else {
|
||||||
|
$logId = (int) $wpdb->insert_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (int) $wpdb->insert_id;
|
if ($result !== 'refused') {
|
||||||
|
self::announce($ability, $objectType, $objectId, $logId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $logId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* do_action('lit_mcp_changed', ability, objectType, objectId, logId): a write happened (result ok or partial,
|
||||||
|
* never refused), even if its log row did not (logId null).
|
||||||
|
*
|
||||||
|
* ability, objectType and objectId are shortened like the log row (100/50/191 characters). objectId is an
|
||||||
|
* identifier or name from the tool input (post ID, option name, menu location ...): untrusted text, validate
|
||||||
|
* before use. A listener that throws is caught and logged, the caller must not see a failed write; exit and
|
||||||
|
* wp_die in a listener are not caught and end the request.
|
||||||
|
*/
|
||||||
|
private static function announce(string $ability, string $objectType, string $objectId, ?int $logId): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
do_action(self::CHANGED_ACTION, $ability, $objectType, $objectId, $logId);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::failure(self::CHANGED_ACTION, $e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Tests\Unit\ChangeLog;
|
namespace LIT\MCP\Tests\Unit\ChangeLog;
|
||||||
|
|
||||||
|
use Brain\Monkey\Actions;
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
use LIT\MCP\ChangeLog\ChangeLog;
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
use LIT\MCP\Server\RequestContext;
|
use LIT\MCP\Server\RequestContext;
|
||||||
@@ -67,6 +68,106 @@ final class ChangeLogTest extends TestCase
|
|||||||
unlink($log);
|
unlink($log);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function dbInserting(int|false $outcome): \wpdb
|
||||||
|
{
|
||||||
|
Functions\when('current_time')->justReturn('2026-09-25 08:00:00');
|
||||||
|
Functions\when('get_current_user_id')->justReturn(3);
|
||||||
|
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||||
|
$db->allows('insert')->andReturnUsing(static function () use ($db, $outcome): int|false {
|
||||||
|
$db->insert_id = 77;
|
||||||
|
|
||||||
|
return $outcome;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnOkEntryAnnouncesTheChange(): void
|
||||||
|
{
|
||||||
|
$seen = [];
|
||||||
|
Actions\expectDone('lit_mcp_changed')->once()->whenHappen(static function (...$args) use (&$seen): void {
|
||||||
|
$seen = $args;
|
||||||
|
});
|
||||||
|
|
||||||
|
(new ChangeLog($this->dbInserting(1)))->record('lit/content-update', 'page', '12', 'x');
|
||||||
|
|
||||||
|
self::assertSame(['lit/content-update', 'page', '12', 77], $seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testARefusedEntryDoesNotAnnounceAChange(): void
|
||||||
|
{
|
||||||
|
Actions\expectDone('lit_mcp_changed')->never();
|
||||||
|
|
||||||
|
(new ChangeLog($this->dbInserting(1)))->record('lit/x', 'page', '1', 'x', result: 'refused');
|
||||||
|
$this->addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A partly executed change changed something: listeners (cache purge) must hear of it. */
|
||||||
|
public function testAPartialEntryAnnouncesTheChange(): void
|
||||||
|
{
|
||||||
|
$seen = [];
|
||||||
|
Actions\expectDone('lit_mcp_changed')->once()->whenHappen(static function (...$args) use (&$seen): void {
|
||||||
|
$seen = $args;
|
||||||
|
});
|
||||||
|
|
||||||
|
(new ChangeLog($this->dbInserting(1)))->record('lit/x', 'page', '1', 'x', result: 'partial');
|
||||||
|
|
||||||
|
self::assertSame(['lit/x', 'page', '1', 77], $seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Listeners get the same shortened values as the log row (ability 100, type 50, id 191). */
|
||||||
|
public function testTheAnnouncementCarriesTheShortenedValuesOfTheRow(): void
|
||||||
|
{
|
||||||
|
$seen = [];
|
||||||
|
Actions\expectDone('lit_mcp_changed')->once()->whenHappen(static function (...$args) use (&$seen): void {
|
||||||
|
$seen = $args;
|
||||||
|
});
|
||||||
|
|
||||||
|
(new ChangeLog($this->dbInserting(1)))
|
||||||
|
->record(str_repeat('a', 150), str_repeat('t', 80), str_repeat('ä', 300), 'x');
|
||||||
|
|
||||||
|
self::assertSame([str_repeat('a', 100), str_repeat('t', 50), str_repeat('ä', 191), 77], $seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The write happened even when its log row did not: listeners (cache purge) still need to know. */
|
||||||
|
public function testAFailedInsertStillAnnouncesTheChangeWithoutALogId(): void
|
||||||
|
{
|
||||||
|
$seen = [];
|
||||||
|
Actions\expectDone('lit_mcp_changed')->once()->whenHappen(static function (...$args) use (&$seen): void {
|
||||||
|
$seen = $args;
|
||||||
|
});
|
||||||
|
$previous = ini_set('error_log', (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$id = (new ChangeLog($this->dbInserting(false)))->record('lit/options', 'option', 'blogname', 'x');
|
||||||
|
} finally {
|
||||||
|
ini_set('error_log', (string) $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertNull($id);
|
||||||
|
self::assertSame(['lit/options', 'option', 'blogname', null], $seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAThrowingListenerChangesNothingAndIsLogged(): void
|
||||||
|
{
|
||||||
|
Actions\expectDone('lit_mcp_changed')->once()->whenHappen(static function (): void {
|
||||||
|
throw new \RuntimeException('listener broke');
|
||||||
|
});
|
||||||
|
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
|
||||||
|
$previous = ini_set('error_log', $log);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$id = (new ChangeLog($this->dbInserting(1)))->record('lit/content-update', 'page', '12', 'x');
|
||||||
|
} finally {
|
||||||
|
ini_set('error_log', (string) $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertSame(77, $id);
|
||||||
|
self::assertStringContainsString('lit_mcp_changed failed', (string) file_get_contents($log));
|
||||||
|
self::assertStringContainsString('listener broke', (string) file_get_contents($log));
|
||||||
|
unlink($log);
|
||||||
|
}
|
||||||
|
|
||||||
public function testRecordTruncatesObjectIdAndAbilityToTheColumnSizes(): void
|
public function testRecordTruncatesObjectIdAndAbilityToTheColumnSizes(): void
|
||||||
{
|
{
|
||||||
Functions\when('current_time')->justReturn('2026-09-25 08:00:00');
|
Functions\when('current_time')->justReturn('2026-09-25 08:00:00');
|
||||||
|
|||||||
Reference in New Issue
Block a user