From 2f9ea2e4730ea76485489a9901950ea2b8e85006 Mon Sep 17 00:00:00 2001 From: Foo Bender Bot Date: Sat, 21 Feb 2026 19:15:55 +0000 Subject: [PATCH 01/13] feat(runtime): add DB-backed agent session/run store with locking --- includes/class-plugin.php | 4 + includes/helpers/class-agent-run-store.php | 332 ++++++++++++++++++ .../helpers/class-agent-session-store.php | 220 ++++++++++++ tests/Unit/AgentRunStoreTest.php | 243 +++++++++++++ 4 files changed, 799 insertions(+) create mode 100644 includes/helpers/class-agent-run-store.php create mode 100644 includes/helpers/class-agent-session-store.php create mode 100644 tests/Unit/AgentRunStoreTest.php diff --git a/includes/class-plugin.php b/includes/class-plugin.php index 85ccae2..960d59a 100644 --- a/includes/class-plugin.php +++ b/includes/class-plugin.php @@ -13,6 +13,8 @@ use ClawPress\AdminPage\Admin_Page; use ClawPress\Heartbeat\Heartbeat; use ClawPress\Helpers\Action_Log_Helper; +use ClawPress\Helpers\Agent_Run_Store; +use ClawPress\Helpers\Agent_Session_Store; use ClawPress\Helpers\Panel_Helper; use ClawPress\Panel\Panel; use ClawPress\PostTypes\Post_Types; @@ -62,6 +64,8 @@ public static function get_instance(): self { */ public static function activate(): void { Action_Log_Helper::get_instance()->create_table(); + Agent_Session_Store::get_instance()->create_table(); + Agent_Run_Store::get_instance()->create_table(); $user_id = get_current_user_id(); if ( $user_id <= 0 ) { diff --git a/includes/helpers/class-agent-run-store.php b/includes/helpers/class-agent-run-store.php new file mode 100644 index 0000000..fc9db3c --- /dev/null +++ b/includes/helpers/class-agent-run-store.php @@ -0,0 +1,332 @@ +prefix ) ) { + return self::TABLE_SUFFIX; + } + + return $wpdb->prefix . self::TABLE_SUFFIX; + } + + /** + * Create/update run table schema. + */ + public function create_table(): bool { + global $wpdb; + + if ( ! is_object( $wpdb ) || ! isset( $wpdb->prefix ) ) { + return false; + } + + $charset_collate = method_exists( $wpdb, 'get_charset_collate' ) + ? (string) $wpdb->get_charset_collate() + : ''; + + $sql = "CREATE TABLE {$this->get_table_name()} ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + session_id bigint(20) unsigned NOT NULL, + run_uuid char(36) NOT NULL, + status varchar(32) NOT NULL DEFAULT 'queued', + claimed_by varchar(64) NULL, + lock_token char(64) NULL, + lock_acquired_at_gmt datetime NULL, + lock_expires_at_gmt datetime NULL, + attempt int(11) NOT NULL DEFAULT 1, + started_at_gmt datetime NULL, + finished_at_gmt datetime NULL, + error_code varchar(128) NULL, + error_message text NULL, + meta_json longtext NULL, + created_at_gmt datetime NOT NULL, + updated_at_gmt datetime NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY run_uuid (run_uuid), + KEY session_status (session_id, status), + KEY status_lock_expires_at_gmt (status, lock_expires_at_gmt), + KEY claimed_by (claimed_by) + ) {$charset_collate};"; + + if ( ! function_exists( 'dbDelta' ) ) { + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + } + + if ( ! function_exists( 'dbDelta' ) ) { + return false; + } + + dbDelta( $sql ); + return true; + } + + /** + * Create a queued run. + * + * @param int $session_id Parent session identifier. + */ + public function create_run( int $session_id ): int { + global $wpdb; + + if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'insert' ) ) { + return 0; + } + + $now = gmdate( 'Y-m-d H:i:s' ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Centralized repository insert. + $wpdb->insert( + $this->get_table_name(), + [ + 'session_id' => $session_id, + 'run_uuid' => $this->generate_uuid(), + 'status' => 'queued', + 'attempt' => 1, + 'created_at_gmt' => $now, + 'updated_at_gmt' => $now, + ], + [ '%d', '%s', '%s', '%d', '%s', '%s' ] + ); + + return isset( $wpdb->insert_id ) ? (int) $wpdb->insert_id : 0; + } + + /** + * Claim one run for a worker. + * + * @param int $run_id Run identifier. + * @param string $worker_id Worker claim id. + * @param int $lease_ttl_seconds Lease TTL in seconds. + * @return array + */ + public function claim_run( int $run_id, string $worker_id, int $lease_ttl_seconds = 120 ): array { + global $wpdb; + + if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'update' ) ) { + return [ + 'claimed' => false, + 'reason' => 'wpdb_unavailable', + ]; + } + + $run = $this->get_run( $run_id ); + if ( [] === $run ) { + return [ + 'claimed' => false, + 'reason' => 'run_not_found', + ]; + } + + $now = gmdate( 'Y-m-d H:i:s' ); + $lock_token = hash( 'sha256', uniqid( $worker_id . ':', true ) ); + $expires_at = gmdate( 'Y-m-d H:i:s', strtotime( $now ) + max( 1, $lease_ttl_seconds ) ); + + $is_stale = 'running' === (string) $run['status'] + && isset( $run['lock_expires_at_gmt'] ) + && is_string( $run['lock_expires_at_gmt'] ) + && '' !== $run['lock_expires_at_gmt'] + && strtotime( $run['lock_expires_at_gmt'] ) < strtotime( $now ); + + if ( 'queued' !== (string) $run['status'] && ! $is_stale ) { + return [ + 'claimed' => false, + 'reason' => 'not_claimable', + ]; + } + + $next_attempt = (int) ( $run['attempt'] ?? 1 ); + if ( $is_stale ) { + ++$next_attempt; + } + + $where = [ + 'id' => $run_id, + 'status' => (string) $run['status'], + ]; + if ( $is_stale ) { + $where['lock_expires_at_gmt'] = (string) $run['lock_expires_at_gmt']; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded compare-and-swap style update. + $updated = $wpdb->update( + $this->get_table_name(), + [ + 'status' => 'running', + 'claimed_by' => $worker_id, + 'lock_token' => $lock_token, + 'lock_acquired_at_gmt' => $now, + 'lock_expires_at_gmt' => $expires_at, + 'attempt' => $next_attempt, + 'started_at_gmt' => $now, + 'updated_at_gmt' => $now, + ], + $where, + [ '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s' ], + array_fill( 0, count( $where ), '%s' ) + ); + + if ( false === $updated || 0 === $updated ) { + return [ + 'claimed' => false, + 'reason' => 'claim_collision', + ]; + } + + return [ + 'claimed' => true, + 'run_id' => $run_id, + 'lock_token' => $lock_token, + 'attempt' => $next_attempt, + 'reclaimed' => $is_stale, + ]; + } + + /** + * Complete a run and update parent session state. + * + * @param int $run_id Run identifier. + * @param string $lock_token Lock token from claim response. + * @param string $status Terminal status. + * @param array $args Optional completion details. + */ + public function complete_run( int $run_id, string $lock_token, string $status, array $args = [] ): bool { + global $wpdb; + + if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'update' ) ) { + return false; + } + + $run = $this->get_run( $run_id ); + if ( [] === $run || 'running' !== (string) $run['status'] ) { + return false; + } + + if ( (string) ( $run['lock_token'] ?? '' ) !== $lock_token ) { + return false; + } + + $meta_json = null; + if ( isset( $args['meta'] ) && is_array( $args['meta'] ) ) { + $encoded = wp_json_encode( $args['meta'] ); + $meta_json = false === $encoded ? null : $encoded; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded repository completion update. + $updated = $wpdb->update( + $this->get_table_name(), + [ + 'status' => $status, + 'lock_token' => null, + 'claimed_by' => null, + 'lock_acquired_at_gmt' => null, + 'lock_expires_at_gmt' => null, + 'finished_at_gmt' => gmdate( 'Y-m-d H:i:s' ), + 'error_code' => isset( $args['error_code'] ) ? (string) $args['error_code'] : null, + 'error_message' => isset( $args['error_message'] ) ? (string) $args['error_message'] : null, + 'meta_json' => $meta_json, + 'updated_at_gmt' => gmdate( 'Y-m-d H:i:s' ), + ], + [ + 'id' => $run_id, + 'lock_token' => $lock_token, + ], + [ '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ], + [ '%d', '%s' ] + ); + + if ( false === $updated || 0 === $updated ) { + return false; + } + + return Agent_Session_Store::get_instance()->apply_run_completion( + (int) $run['session_id'], + $status, + isset( $args['next_run_at_gmt'] ) ? (string) $args['next_run_at_gmt'] : null + ); + } + + /** + * Fetch one run by id. + * + * @param int $run_id Run identifier. + * @return array + */ + public function get_run( int $run_id ): array { + global $wpdb; + + if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'prepare' ) || ! method_exists( $wpdb, 'get_row' ) ) { + return []; + } + + $table_name = $this->get_table_name(); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is fixed plugin-owned identifier. + $query = $wpdb->prepare( "SELECT * FROM {$table_name} WHERE id = %d", $run_id ); + if ( ! is_string( $query ) || '' === $query ) { + return []; + } + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded primary-key lookup. + $row = $wpdb->get_row( $query, 'ARRAY_A' ); + return is_array( $row ) ? $row : []; + } + + /** + * Generate uuid-like id without WP dependency. + */ + private function generate_uuid(): string { + $seed = md5( uniqid( '', true ) ); + return sprintf( + '%s-%s-%s-%s-%s', + substr( $seed, 0, 8 ), + substr( $seed, 8, 4 ), + substr( $seed, 12, 4 ), + substr( $seed, 16, 4 ), + substr( $seed, 20, 12 ) + ); + } +} diff --git a/includes/helpers/class-agent-session-store.php b/includes/helpers/class-agent-session-store.php new file mode 100644 index 0000000..4f9fc73 --- /dev/null +++ b/includes/helpers/class-agent-session-store.php @@ -0,0 +1,220 @@ +prefix ) ) { + return self::TABLE_SUFFIX; + } + + return $wpdb->prefix . self::TABLE_SUFFIX; + } + + /** + * Create/update session table schema. + */ + public function create_table(): bool { + global $wpdb; + + if ( ! is_object( $wpdb ) || ! isset( $wpdb->prefix ) ) { + return false; + } + + $charset_collate = method_exists( $wpdb, 'get_charset_collate' ) + ? (string) $wpdb->get_charset_collate() + : ''; + + $sql = "CREATE TABLE {$this->get_table_name()} ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + uuid char(36) NOT NULL, + status varchar(32) NOT NULL DEFAULT 'active', + trigger_type varchar(32) NOT NULL DEFAULT 'chat', + requesting_user_id bigint(20) unsigned NULL, + execution_user_id bigint(20) unsigned NULL, + policy_profile varchar(64) NULL, + last_run_at_gmt datetime NULL, + next_run_at_gmt datetime NULL, + last_run_status varchar(32) NULL, + consecutive_failures int(11) NOT NULL DEFAULT 0, + created_at_gmt datetime NOT NULL, + updated_at_gmt datetime NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uuid (uuid), + KEY status_next_run_at_gmt (status, next_run_at_gmt), + KEY trigger_type (trigger_type) + ) {$charset_collate};"; + + if ( ! function_exists( 'dbDelta' ) ) { + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + } + + if ( ! function_exists( 'dbDelta' ) ) { + return false; + } + + dbDelta( $sql ); + return true; + } + + /** + * Create one session row. + * + * @param array $args Session payload. + */ + public function create_session( array $args = [] ): int { + global $wpdb; + + if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'insert' ) ) { + return 0; + } + + $now = gmdate( 'Y-m-d H:i:s' ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Centralized repository insert. + $wpdb->insert( + $this->get_table_name(), + [ + 'uuid' => isset( $args['uuid'] ) ? (string) $args['uuid'] : $this->generate_uuid(), + 'status' => isset( $args['status'] ) ? (string) $args['status'] : 'active', + 'trigger_type' => isset( $args['trigger_type'] ) ? (string) $args['trigger_type'] : 'chat', + 'requesting_user_id' => isset( $args['requesting_user_id'] ) ? (int) $args['requesting_user_id'] : null, + 'execution_user_id' => isset( $args['execution_user_id'] ) ? (int) $args['execution_user_id'] : null, + 'policy_profile' => isset( $args['policy_profile'] ) ? (string) $args['policy_profile'] : null, + 'last_run_at_gmt' => null, + 'next_run_at_gmt' => isset( $args['next_run_at_gmt'] ) ? (string) $args['next_run_at_gmt'] : null, + 'last_run_status' => null, + 'consecutive_failures' => 0, + 'created_at_gmt' => $now, + 'updated_at_gmt' => $now, + ], + [ + '%s', + '%s', + '%s', + '%d', + '%d', + '%s', + '%s', + '%s', + '%s', + '%d', + '%s', + '%s', + ] + ); + + if ( ! isset( $wpdb->insert_id ) ) { + return 0; + } + + return (int) $wpdb->insert_id; + } + + /** + * Update parent session state after run completion. + * + * @param int $session_id Session identifier. + * @param string $run_status Terminal run status. + * @param string|null $next_run_at_gmt Optional next run timestamp. + */ + public function apply_run_completion( int $session_id, string $run_status, ?string $next_run_at_gmt = null ): bool { + global $wpdb; + + if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'get_row' ) || ! method_exists( $wpdb, 'update' ) ) { + return false; + } + + $table_name = $this->get_table_name(); + $query = 'SELECT consecutive_failures FROM ' . $table_name . ' WHERE id = ' . (int) $session_id; + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded primary-key lookup by sanitized integer id. + $row = $wpdb->get_row( $query, 'ARRAY_A' ); + if ( ! is_array( $row ) ) { + return false; + } + + $failures = (int) ( $row['consecutive_failures'] ?? 0 ); + if ( 'success' === $run_status ) { + $failures = 0; + } else { + ++$failures; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded repository update. + $updated = $wpdb->update( + $this->get_table_name(), + [ + 'last_run_at_gmt' => gmdate( 'Y-m-d H:i:s' ), + 'last_run_status' => $run_status, + 'consecutive_failures' => $failures, + 'next_run_at_gmt' => $next_run_at_gmt, + 'updated_at_gmt' => gmdate( 'Y-m-d H:i:s' ), + ], + [ 'id' => $session_id ], + [ '%s', '%s', '%d', '%s', '%s' ], + [ '%d' ] + ); + + return false !== $updated; + } + + /** + * Generate uuid-like id without WP dependency. + */ + private function generate_uuid(): string { + $seed = md5( uniqid( '', true ) ); + return sprintf( + '%s-%s-%s-%s-%s', + substr( $seed, 0, 8 ), + substr( $seed, 8, 4 ), + substr( $seed, 12, 4 ), + substr( $seed, 16, 4 ), + substr( $seed, 20, 12 ) + ); + } +} diff --git a/tests/Unit/AgentRunStoreTest.php b/tests/Unit/AgentRunStoreTest.php new file mode 100644 index 0000000..911cfc4 --- /dev/null +++ b/tests/Unit/AgentRunStoreTest.php @@ -0,0 +1,243 @@ + + */ + function dbDelta( string $queries ): array { + if ( ! isset( $GLOBALS['clawpress_test_dbdelta_queries'] ) || ! is_array( $GLOBALS['clawpress_test_dbdelta_queries'] ) ) { + $GLOBALS['clawpress_test_dbdelta_queries'] = []; + } + + $GLOBALS['clawpress_test_dbdelta_queries'][] = $queries; + return []; + } + } +} + +namespace ClawPress\Tests\Unit { + +use ClawPress\Helpers\Agent_Run_Store; +use ClawPress\Helpers\Agent_Session_Store; +use ClawPress\Plugin; +use ClawPress\Tests\Support\TestCase; + +/** + * Minimal in-memory wpdb stub for run/session store tests. + */ +final class AgentRunStoreTestWpdb { + public string $prefix = 'wp_'; + + public int $insert_id = 0; + + /** @var array> */ + public array $sessions = []; + + /** @var array> */ + public array $runs = []; + + /** @var array */ + public array $last_prepare_args = []; + + public function get_charset_collate(): string { + return 'DEFAULT CHARSET=utf8mb4'; + } + + /** + * @param string $table Table name. + * @param array $data Data payload. + * @param array $format Format list. + */ + public function insert( string $table, array $data, array $format ) { + unset( $format ); + + $this->insert_id++; + $data['id'] = $this->insert_id; + + if ( false !== strpos( $table, 'agent_sessions' ) ) { + $this->sessions[ $this->insert_id ] = $data; + return 1; + } + + if ( false !== strpos( $table, 'agent_runs' ) ) { + $this->runs[ $this->insert_id ] = $data; + return 1; + } + + return false; + } + + /** + * @param string $table Table name. + * @param array $data Data payload. + * @param array $where Where payload. + * @param array $format Formats. + * @param array $where_format Where formats. + */ + public function update( string $table, array $data, array $where, ?array $format = null, ?array $where_format = null ) { + unset( $format, $where_format ); + + $target = false !== strpos( $table, 'agent_sessions' ) ? 'sessions' : ( false !== strpos( $table, 'agent_runs' ) ? 'runs' : '' ); + if ( '' === $target ) { + return false; + } + + $rows = $this->{$target}; + $updated = 0; + + foreach ( $rows as $id => $row ) { + $matches = true; + foreach ( $where as $key => $value ) { + $current = $row[ $key ] ?? null; + if ( (string) $current !== (string) $value ) { + $matches = false; + break; + } + } + + if ( ! $matches ) { + continue; + } + + $this->{$target}[ $id ] = array_merge( $row, $data ); + ++$updated; + } + + return $updated; + } + + /** + * @param string $query Query string. + * @param array|mixed ...$args Prepare args. + */ + public function prepare( string $query, ...$args ): string { + if ( 1 === count( $args ) && is_array( $args[0] ) ) { + $args = $args[0]; + } + + $this->last_prepare_args = $args; + return $query; + } + + /** + * @return array|null + */ + public function get_row( string $query, string $output ) { + unset( $output ); + + $id = 0; + if ( preg_match( '/WHERE id =\s*(\d+)/', $query, $matches ) ) { + $id = (int) $matches[1]; + } + if ( $id <= 0 ) { + $id = isset( $this->last_prepare_args[0] ) ? (int) $this->last_prepare_args[0] : 0; + } + + if ( $id <= 0 ) { + return null; + } + + if ( false !== strpos( $query, 'agent_sessions' ) ) { + return $this->sessions[ $id ] ?? null; + } + + if ( false !== strpos( $query, 'agent_runs' ) ) { + return $this->runs[ $id ] ?? null; + } + + return null; + } +} + +final class AgentRunStoreTest extends TestCase { + protected function setUp(): void { + parent::setUp(); + $GLOBALS['clawpress_test_dbdelta_queries'] = []; + $GLOBALS['wpdb'] = new AgentRunStoreTestWpdb(); + } + + protected function tearDown(): void { + unset( $GLOBALS['wpdb'], $GLOBALS['clawpress_test_dbdelta_queries'] ); + parent::tearDown(); + } + + public function test_plugin_activation_registers_session_and_run_tables(): void { + Plugin::activate(); + + $all_queries = implode( "\n", $GLOBALS['clawpress_test_dbdelta_queries'] ); + $this->assertStringContainsString( 'clawpress_agent_sessions', $all_queries ); + $this->assertStringContainsString( 'clawpress_agent_runs', $all_queries ); + } + + public function test_claim_run_success(): void { + $session_id = Agent_Session_Store::get_instance()->create_session(); + $run_id = Agent_Run_Store::get_instance()->create_run( $session_id ); + + $result = Agent_Run_Store::get_instance()->claim_run( $run_id, 'worker-a', 120 ); + + $this->assertTrue( $result['claimed'] ); + $this->assertSame( 'running', $GLOBALS['wpdb']->runs[ $run_id ]['status'] ); + $this->assertSame( 'worker-a', $GLOBALS['wpdb']->runs[ $run_id ]['claimed_by'] ); + $this->assertNotEmpty( $GLOBALS['wpdb']->runs[ $run_id ]['lock_token'] ); + } + + public function test_claim_collision_fails_for_second_worker(): void { + $session_id = Agent_Session_Store::get_instance()->create_session(); + $run_id = Agent_Run_Store::get_instance()->create_run( $session_id ); + + $first = Agent_Run_Store::get_instance()->claim_run( $run_id, 'worker-a', 120 ); + $second = Agent_Run_Store::get_instance()->claim_run( $run_id, 'worker-b', 120 ); + + $this->assertTrue( $first['claimed'] ); + $this->assertFalse( $second['claimed'] ); + $this->assertSame( 'not_claimable', $second['reason'] ); + } + + public function test_stale_lock_can_be_reclaimed_and_attempt_increments(): void { + $session_id = Agent_Session_Store::get_instance()->create_session(); + $run_id = Agent_Run_Store::get_instance()->create_run( $session_id ); + $GLOBALS['wpdb']->runs[ $run_id ]['status'] = 'running'; + $GLOBALS['wpdb']->runs[ $run_id ]['attempt'] = 1; + $GLOBALS['wpdb']->runs[ $run_id ]['lock_expires_at_gmt'] = '2000-01-01 00:00:00'; + + $result = Agent_Run_Store::get_instance()->claim_run( $run_id, 'worker-reclaim', 120 ); + + $this->assertTrue( $result['claimed'] ); + $this->assertTrue( $result['reclaimed'] ); + $this->assertSame( 2, $result['attempt'] ); + $this->assertSame( 2, (int) $GLOBALS['wpdb']->runs[ $run_id ]['attempt'] ); + } + + public function test_complete_run_clears_lock_and_updates_session_state(): void { + $session_id = Agent_Session_Store::get_instance()->create_session(); + $run_id = Agent_Run_Store::get_instance()->create_run( $session_id ); + $claim = Agent_Run_Store::get_instance()->claim_run( $run_id, 'worker-a', 120 ); + + $completed = Agent_Run_Store::get_instance()->complete_run( + $run_id, + (string) $claim['lock_token'], + 'success', + [ + 'meta' => [ 'tools' => 3 ], + ] + ); + + $this->assertTrue( $completed ); + $this->assertSame( 'success', $GLOBALS['wpdb']->runs[ $run_id ]['status'] ); + $this->assertNull( $GLOBALS['wpdb']->runs[ $run_id ]['lock_token'] ); + $this->assertSame( 'success', $GLOBALS['wpdb']->sessions[ $session_id ]['last_run_status'] ); + $this->assertSame( 0, (int) $GLOBALS['wpdb']->sessions[ $session_id ]['consecutive_failures'] ); + } +} +} From 224738a18102e009e55330ba4b5f95a10c26a263 Mon Sep 17 00:00:00 2001 From: Brad Vincent Date: Mon, 23 Feb 2026 20:04:19 +0000 Subject: [PATCH 02/13] fix(runtime): make run completion transactional --- includes/helpers/class-agent-run-store.php | 65 +++++++++++++++++++++- tests/Unit/AgentRunStoreTest.php | 59 ++++++++++++++++++++ 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/includes/helpers/class-agent-run-store.php b/includes/helpers/class-agent-run-store.php index fc9db3c..1d69a06 100644 --- a/includes/helpers/class-agent-run-store.php +++ b/includes/helpers/class-agent-run-store.php @@ -237,16 +237,22 @@ public function claim_run( int $run_id, string $worker_id, int $lease_ttl_second public function complete_run( int $run_id, string $lock_token, string $status, array $args = [] ): bool { global $wpdb; - if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'update' ) ) { + if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'update' ) || ! method_exists( $wpdb, 'query' ) ) { + return false; + } + + if ( ! $this->begin_transaction() ) { return false; } $run = $this->get_run( $run_id ); if ( [] === $run || 'running' !== (string) $run['status'] ) { + $this->rollback_transaction(); return false; } if ( (string) ( $run['lock_token'] ?? '' ) !== $lock_token ) { + $this->rollback_transaction(); return false; } @@ -280,14 +286,27 @@ public function complete_run( int $run_id, string $lock_token, string $status, a ); if ( false === $updated || 0 === $updated ) { + $this->rollback_transaction(); return false; } - return Agent_Session_Store::get_instance()->apply_run_completion( + $session_updated = Agent_Session_Store::get_instance()->apply_run_completion( (int) $run['session_id'], $status, isset( $args['next_run_at_gmt'] ) ? (string) $args['next_run_at_gmt'] : null ); + + if ( ! $session_updated ) { + $this->rollback_transaction(); + return false; + } + + if ( ! $this->commit_transaction() ) { + $this->rollback_transaction(); + return false; + } + + return true; } /** @@ -329,4 +348,46 @@ private function generate_uuid(): string { substr( $seed, 20, 12 ) ); } + + /** + * Begin transaction for multi-table completion updates. + */ + private function begin_transaction(): bool { + global $wpdb; + + if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'query' ) ) { + return false; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- bounded transaction control statement. + return false !== $wpdb->query( 'START TRANSACTION' ); + } + + /** + * Commit transaction for completion updates. + */ + private function commit_transaction(): bool { + global $wpdb; + + if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'query' ) ) { + return false; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- bounded transaction control statement. + return false !== $wpdb->query( 'COMMIT' ); + } + + /** + * Roll back completion transaction. + */ + private function rollback_transaction(): void { + global $wpdb; + + if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'query' ) ) { + return; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- bounded transaction control statement. + $wpdb->query( 'ROLLBACK' ); + } } diff --git a/tests/Unit/AgentRunStoreTest.php b/tests/Unit/AgentRunStoreTest.php index 911cfc4..3adc7d4 100644 --- a/tests/Unit/AgentRunStoreTest.php +++ b/tests/Unit/AgentRunStoreTest.php @@ -50,6 +50,13 @@ final class AgentRunStoreTestWpdb { /** @var array */ public array $last_prepare_args = []; + public bool $fail_session_update = false; + + private bool $in_transaction = false; + + /** @var array{sessions:array>,runs:array>,insert_id:int}|null */ + private ?array $transaction_snapshot = null; + public function get_charset_collate(): string { return 'DEFAULT CHARSET=utf8mb4'; } @@ -92,6 +99,9 @@ public function update( string $table, array $data, array $where, ?array $format if ( '' === $target ) { return false; } + if ( $this->fail_session_update && 'sessions' === $target ) { + return false; + } $rows = $this->{$target}; $updated = 0; @@ -117,6 +127,40 @@ public function update( string $table, array $data, array $where, ?array $format return $updated; } + public function query( string $sql ) { + $sql = strtoupper( trim( $sql ) ); + + if ( 'START TRANSACTION' === $sql ) { + $this->in_transaction = true; + $this->transaction_snapshot = [ + 'sessions' => $this->sessions, + 'runs' => $this->runs, + 'insert_id' => $this->insert_id, + ]; + return 1; + } + + if ( 'COMMIT' === $sql ) { + $this->in_transaction = false; + $this->transaction_snapshot = null; + return 1; + } + + if ( 'ROLLBACK' === $sql ) { + if ( $this->in_transaction && is_array( $this->transaction_snapshot ) ) { + $this->sessions = $this->transaction_snapshot['sessions']; + $this->runs = $this->transaction_snapshot['runs']; + $this->insert_id = $this->transaction_snapshot['insert_id']; + } + + $this->in_transaction = false; + $this->transaction_snapshot = null; + return 1; + } + + return false; + } + /** * @param string $query Query string. * @param array|mixed ...$args Prepare args. @@ -239,5 +283,20 @@ public function test_complete_run_clears_lock_and_updates_session_state(): void $this->assertSame( 'success', $GLOBALS['wpdb']->sessions[ $session_id ]['last_run_status'] ); $this->assertSame( 0, (int) $GLOBALS['wpdb']->sessions[ $session_id ]['consecutive_failures'] ); } + + public function test_complete_run_rolls_back_when_session_update_fails(): void { + $session_id = Agent_Session_Store::get_instance()->create_session(); + $run_id = Agent_Run_Store::get_instance()->create_run( $session_id ); + $claim = Agent_Run_Store::get_instance()->claim_run( $run_id, 'worker-a', 120 ); + $lock_token = (string) $claim['lock_token']; + + $GLOBALS['wpdb']->fail_session_update = true; + $completed = Agent_Run_Store::get_instance()->complete_run( $run_id, $lock_token, 'success' ); + + $this->assertFalse( $completed ); + $this->assertSame( 'running', $GLOBALS['wpdb']->runs[ $run_id ]['status'] ); + $this->assertSame( $lock_token, $GLOBALS['wpdb']->runs[ $run_id ]['lock_token'] ); + $this->assertNull( $GLOBALS['wpdb']->sessions[ $session_id ]['last_run_status'] ); + } } } From 6af92236f079fa147183e1d8a9f8ccd59464535c Mon Sep 17 00:00:00 2001 From: Brad Vincent Date: Mon, 23 Feb 2026 20:05:24 +0000 Subject: [PATCH 03/13] fix(runtime): make session failure updates atomic --- .../helpers/class-agent-session-store.php | 52 +++++++++---------- tests/Unit/AgentRunStoreTest.php | 39 ++++++++++++++ 2 files changed, 65 insertions(+), 26 deletions(-) diff --git a/includes/helpers/class-agent-session-store.php b/includes/helpers/class-agent-session-store.php index 4f9fc73..ecb5894 100644 --- a/includes/helpers/class-agent-session-store.php +++ b/includes/helpers/class-agent-session-store.php @@ -165,40 +165,40 @@ public function create_session( array $args = [] ): int { public function apply_run_completion( int $session_id, string $run_status, ?string $next_run_at_gmt = null ): bool { global $wpdb; - if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'get_row' ) || ! method_exists( $wpdb, 'update' ) ) { + if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'prepare' ) || ! method_exists( $wpdb, 'query' ) ) { return false; } $table_name = $this->get_table_name(); - $query = 'SELECT consecutive_failures FROM ' . $table_name . ' WHERE id = ' . (int) $session_id; + $now = gmdate( 'Y-m-d H:i:s' ); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is fixed plugin-owned identifier. + $query = $wpdb->prepare( + "UPDATE {$table_name} + SET + last_run_at_gmt = %s, + last_run_status = %s, + consecutive_failures = CASE + WHEN %s = 'success' THEN 0 + ELSE consecutive_failures + 1 + END, + next_run_at_gmt = %s, + updated_at_gmt = %s + WHERE id = %d", + $now, + $run_status, + $run_status, + $next_run_at_gmt, + $now, + $session_id + ); - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded primary-key lookup by sanitized integer id. - $row = $wpdb->get_row( $query, 'ARRAY_A' ); - if ( ! is_array( $row ) ) { + if ( ! is_string( $query ) || '' === $query ) { return false; } - $failures = (int) ( $row['consecutive_failures'] ?? 0 ); - if ( 'success' === $run_status ) { - $failures = 0; - } else { - ++$failures; - } - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded repository update. - $updated = $wpdb->update( - $this->get_table_name(), - [ - 'last_run_at_gmt' => gmdate( 'Y-m-d H:i:s' ), - 'last_run_status' => $run_status, - 'consecutive_failures' => $failures, - 'next_run_at_gmt' => $next_run_at_gmt, - 'updated_at_gmt' => gmdate( 'Y-m-d H:i:s' ), - ], - [ 'id' => $session_id ], - [ '%s', '%s', '%d', '%s', '%s' ], - [ '%d' ] - ); + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- prepared bounded update on primary-key id. + $updated = $wpdb->query( $query ); return false !== $updated; } diff --git a/tests/Unit/AgentRunStoreTest.php b/tests/Unit/AgentRunStoreTest.php index 3adc7d4..49080d4 100644 --- a/tests/Unit/AgentRunStoreTest.php +++ b/tests/Unit/AgentRunStoreTest.php @@ -158,6 +158,32 @@ public function query( string $sql ) { return 1; } + if ( str_starts_with( $sql, 'UPDATE' ) && false !== strpos( $sql, 'AGENT_SESSIONS' ) ) { + if ( $this->fail_session_update ) { + return false; + } + + $session_id = isset( $this->last_prepare_args[5] ) ? (int) $this->last_prepare_args[5] : 0; + if ( $session_id <= 0 || ! isset( $this->sessions[ $session_id ] ) ) { + return 0; + } + + $run_status = isset( $this->last_prepare_args[1] ) ? (string) $this->last_prepare_args[1] : ''; + $failures = (int) ( $this->sessions[ $session_id ]['consecutive_failures'] ?? 0 ); + if ( 'success' === $run_status ) { + $failures = 0; + } else { + ++$failures; + } + + $this->sessions[ $session_id ]['last_run_at_gmt'] = isset( $this->last_prepare_args[0] ) ? (string) $this->last_prepare_args[0] : null; + $this->sessions[ $session_id ]['last_run_status'] = $run_status; + $this->sessions[ $session_id ]['consecutive_failures'] = $failures; + $this->sessions[ $session_id ]['next_run_at_gmt'] = $this->last_prepare_args[3] ?? null; + $this->sessions[ $session_id ]['updated_at_gmt'] = isset( $this->last_prepare_args[4] ) ? (string) $this->last_prepare_args[4] : null; + return 1; + } + return false; } @@ -298,5 +324,18 @@ public function test_complete_run_rolls_back_when_session_update_fails(): void { $this->assertSame( $lock_token, $GLOBALS['wpdb']->runs[ $run_id ]['lock_token'] ); $this->assertNull( $GLOBALS['wpdb']->sessions[ $session_id ]['last_run_status'] ); } + + public function test_apply_run_completion_increments_failures_and_resets_on_success(): void { + $session_id = Agent_Session_Store::get_instance()->create_session(); + + $this->assertTrue( Agent_Session_Store::get_instance()->apply_run_completion( $session_id, 'failed', null ) ); + $this->assertSame( 1, (int) $GLOBALS['wpdb']->sessions[ $session_id ]['consecutive_failures'] ); + + $this->assertTrue( Agent_Session_Store::get_instance()->apply_run_completion( $session_id, 'failed', null ) ); + $this->assertSame( 2, (int) $GLOBALS['wpdb']->sessions[ $session_id ]['consecutive_failures'] ); + + $this->assertTrue( Agent_Session_Store::get_instance()->apply_run_completion( $session_id, 'success', null ) ); + $this->assertSame( 0, (int) $GLOBALS['wpdb']->sessions[ $session_id ]['consecutive_failures'] ); + } } } From 95596ddd2a847d77440aa84ef7574c91fb03b2fc Mon Sep 17 00:00:00 2001 From: Brad Vincent Date: Mon, 23 Feb 2026 20:06:01 +0000 Subject: [PATCH 04/13] fix(runtime): reject non-terminal completion statuses --- includes/helpers/class-agent-run-store.php | 10 ++++++++++ tests/Unit/AgentRunStoreTest.php | 13 +++++++++++++ 2 files changed, 23 insertions(+) diff --git a/includes/helpers/class-agent-run-store.php b/includes/helpers/class-agent-run-store.php index 1d69a06..d273e39 100644 --- a/includes/helpers/class-agent-run-store.php +++ b/includes/helpers/class-agent-run-store.php @@ -20,6 +20,13 @@ final class Agent_Run_Store { */ private const TABLE_SUFFIX = 'clawpress_agent_runs'; + /** + * Allowed terminal run statuses. + * + * @var array + */ + private const TERMINAL_STATUSES = [ 'success', 'failed', 'cancelled', 'canceled' ]; + /** * Singleton instance. * @@ -240,6 +247,9 @@ public function complete_run( int $run_id, string $lock_token, string $status, a if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'update' ) || ! method_exists( $wpdb, 'query' ) ) { return false; } + if ( ! in_array( $status, self::TERMINAL_STATUSES, true ) ) { + return false; + } if ( ! $this->begin_transaction() ) { return false; diff --git a/tests/Unit/AgentRunStoreTest.php b/tests/Unit/AgentRunStoreTest.php index 49080d4..9287687 100644 --- a/tests/Unit/AgentRunStoreTest.php +++ b/tests/Unit/AgentRunStoreTest.php @@ -337,5 +337,18 @@ public function test_apply_run_completion_increments_failures_and_resets_on_succ $this->assertTrue( Agent_Session_Store::get_instance()->apply_run_completion( $session_id, 'success', null ) ); $this->assertSame( 0, (int) $GLOBALS['wpdb']->sessions[ $session_id ]['consecutive_failures'] ); } + + public function test_complete_run_rejects_non_terminal_status(): void { + $session_id = Agent_Session_Store::get_instance()->create_session(); + $run_id = Agent_Run_Store::get_instance()->create_run( $session_id ); + $claim = Agent_Run_Store::get_instance()->claim_run( $run_id, 'worker-a', 120 ); + $lock_token = (string) $claim['lock_token']; + + $completed = Agent_Run_Store::get_instance()->complete_run( $run_id, $lock_token, 'running' ); + + $this->assertFalse( $completed ); + $this->assertSame( 'running', $GLOBALS['wpdb']->runs[ $run_id ]['status'] ); + $this->assertSame( $lock_token, $GLOBALS['wpdb']->runs[ $run_id ]['lock_token'] ); + } } } From 6d19f8a5df569e36513fb8ccc7e1024592ebab03 Mon Sep 17 00:00:00 2001 From: Brad Vincent Date: Mon, 23 Feb 2026 20:14:11 +0000 Subject: [PATCH 05/13] refactor(action-log): extract db persistence into Action_Log_Store --- includes/helpers/class-action-log-helper.php | 142 ++---------- includes/stores/class-action-log-store.php | 214 +++++++++++++++++++ 2 files changed, 236 insertions(+), 120 deletions(-) create mode 100644 includes/stores/class-action-log-store.php diff --git a/includes/helpers/class-action-log-helper.php b/includes/helpers/class-action-log-helper.php index 799a782..541cfd9 100644 --- a/includes/helpers/class-action-log-helper.php +++ b/includes/helpers/class-action-log-helper.php @@ -9,17 +9,14 @@ namespace ClawPress\Helpers; +use ClawPress\Stores\Action_Log_Store; + defined( 'ABSPATH' ) || exit; /** * Central action log helper for writing/querying action/event records. */ final class Action_Log_Helper { - /** - * Database table suffix. - */ - private const TABLE_SUFFIX = 'clawpress_action_logs'; - /** * Supported log status values. * @@ -34,10 +31,17 @@ final class Action_Log_Helper { */ private static ?self $instance = null; + /** + * Store instance for DB access. + */ + private Action_Log_Store $store; + /** * Constructor. */ - private function __construct() {} + private function __construct() { + $this->store = Action_Log_Store::get_instance(); + } /** * Get singleton instance. @@ -54,59 +58,14 @@ public static function get_instance(): self { * Resolve full action log table name. */ public function get_table_name(): string { - global $wpdb; - - if ( ! $this->is_wpdb_ready( $wpdb ) ) { - return self::TABLE_SUFFIX; - } - - return $wpdb->prefix . self::TABLE_SUFFIX; + return $this->store->get_table_name(); } /** * Create/update action log table schema. */ public function create_table(): bool { - global $wpdb; - - if ( ! $this->is_wpdb_ready( $wpdb ) ) { - return false; - } - - $table_name = $this->get_table_name(); - $charset_collate = method_exists( $wpdb, 'get_charset_collate' ) - ? (string) $wpdb->get_charset_collate() - : ''; - - $sql = "CREATE TABLE {$table_name} ( - id bigint(20) unsigned NOT NULL AUTO_INCREMENT, - event_type varchar(64) NOT NULL DEFAULT 'event', - action_name varchar(191) NOT NULL, - status varchar(20) NOT NULL DEFAULT 'info', - message text NULL, - requesting_user_id bigint(20) unsigned NULL, - execution_user_id bigint(20) unsigned NULL, - context longtext NULL, - created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (id), - KEY event_type (event_type), - KEY action_name (action_name), - KEY status (status), - KEY requesting_user_id (requesting_user_id), - KEY execution_user_id (execution_user_id), - KEY created_at (created_at) - ) {$charset_collate};"; - - if ( ! function_exists( 'dbDelta' ) ) { - require_once ABSPATH . 'wp-admin/includes/upgrade.php'; - } - - if ( ! function_exists( 'dbDelta' ) ) { - return false; - } - - dbDelta( $sql ); - return true; + return $this->store->create_table(); } /** @@ -116,12 +75,6 @@ public function create_table(): bool { * @param array $args Optional log payload. */ public function log_event( string $action_name, array $args = [] ): bool { - global $wpdb; - - if ( ! $this->is_wpdb_ready( $wpdb ) || ! method_exists( $wpdb, 'insert' ) ) { - return false; - } - $normalized_action_name = $this->sanitize_action_name( $action_name ); if ( '' === $normalized_action_name ) { return false; @@ -156,9 +109,7 @@ public function log_event( string $action_name, array $args = [] ): bool { } } - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- centralized logging insert for plugin action ledger. - $inserted = $wpdb->insert( - $this->get_table_name(), + return $this->store->insert_log( [ 'event_type' => $event_type, 'action_name' => $normalized_action_name, @@ -167,19 +118,8 @@ public function log_event( string $action_name, array $args = [] ): bool { 'requesting_user_id' => $requesting_user_id > 0 ? $requesting_user_id : null, 'execution_user_id' => $execution_user_id > 0 ? $execution_user_id : null, 'context' => $encoded_context, - ], - [ - '%s', - '%s', - '%s', - '%s', - '%d', - '%d', - '%s', ] ); - - return false !== $inserted; } /** @@ -189,68 +129,39 @@ public function log_event( string $action_name, array $args = [] ): bool { * @return array> */ public function get_recent_logs( array $args = [] ): array { - global $wpdb; - - if ( ! $this->is_wpdb_ready( $wpdb ) || ! method_exists( $wpdb, 'prepare' ) || ! method_exists( $wpdb, 'get_results' ) ) { - return []; - } - $limit = isset( $args['limit'] ) ? (int) $args['limit'] : 50; $offset = isset( $args['offset'] ) ? (int) $args['offset'] : 0; $limit = $limit > 0 ? min( $limit, 500 ) : 50; $offset = $offset >= 0 ? $offset : 0; - $where_clauses = []; - $where_values = []; + $query_args = [ + 'limit' => $limit, + 'offset' => $offset, + ]; if ( isset( $args['event_type'] ) ) { $event_type = $this->sanitize_event_type( (string) $args['event_type'] ); if ( '' !== $event_type ) { - $where_clauses[] = 'event_type = %s'; - $where_values[] = $event_type; + $query_args['event_type'] = $event_type; } } if ( isset( $args['status'] ) ) { $status = $this->sanitize_status( (string) $args['status'] ); if ( '' !== $status ) { - $where_clauses[] = 'status = %s'; - $where_values[] = $status; + $query_args['status'] = $status; } } if ( isset( $args['requesting_user_id'] ) && (int) $args['requesting_user_id'] > 0 ) { - $where_clauses[] = 'requesting_user_id = %d'; - $where_values[] = (int) $args['requesting_user_id']; + $query_args['requesting_user_id'] = (int) $args['requesting_user_id']; } if ( isset( $args['execution_user_id'] ) && (int) $args['execution_user_id'] > 0 ) { - $where_clauses[] = 'execution_user_id = %d'; - $where_values[] = (int) $args['execution_user_id']; + $query_args['execution_user_id'] = (int) $args['execution_user_id']; } - $where_sql = [] !== $where_clauses ? 'WHERE ' . implode( ' AND ', $where_clauses ) : ''; - $where_values[] = $limit; - $where_values[] = $offset; - - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is fixed plugin-owned identifier. - $query = "SELECT id, event_type, action_name, status, message, requesting_user_id, execution_user_id, context, created_at - FROM {$this->get_table_name()} - {$where_sql} - ORDER BY id DESC - LIMIT %d OFFSET %d"; - - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- query is prepared via `$wpdb->prepare()` on this line. - $prepared_query = $wpdb->prepare( $query, $where_values ); - if ( ! is_string( $prepared_query ) || '' === $prepared_query ) { - return []; - } - - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- log queries are intentional and already bounded. - $rows = $wpdb->get_results( $prepared_query, 'ARRAY_A' ); - if ( ! is_array( $rows ) ) { - return []; - } + $rows = $this->store->get_recent_logs( $query_args ); return array_values( array_map( [ $this, 'normalize_log_row' ], $rows ) @@ -325,13 +236,4 @@ private function sanitize_status( string $status ): string { return 'info'; } - - /** - * Check whether a usable `$wpdb` object is present. - * - * @param mixed $wpdb Candidate wpdb object. - */ - private function is_wpdb_ready( $wpdb ): bool { - return is_object( $wpdb ) && isset( $wpdb->prefix ); - } } diff --git a/includes/stores/class-action-log-store.php b/includes/stores/class-action-log-store.php new file mode 100644 index 0000000..e658523 --- /dev/null +++ b/includes/stores/class-action-log-store.php @@ -0,0 +1,214 @@ +is_wpdb_ready( $wpdb ) ) { + return self::TABLE_SUFFIX; + } + + return $wpdb->prefix . self::TABLE_SUFFIX; + } + + /** + * Create/update action log table schema. + */ + public function create_table(): bool { + global $wpdb; + + if ( ! $this->is_wpdb_ready( $wpdb ) ) { + return false; + } + + $table_name = $this->get_table_name(); + $charset_collate = method_exists( $wpdb, 'get_charset_collate' ) + ? (string) $wpdb->get_charset_collate() + : ''; + + $sql = "CREATE TABLE {$table_name} ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + event_type varchar(64) NOT NULL DEFAULT 'event', + action_name varchar(191) NOT NULL, + status varchar(20) NOT NULL DEFAULT 'info', + message text NULL, + requesting_user_id bigint(20) unsigned NULL, + execution_user_id bigint(20) unsigned NULL, + context longtext NULL, + created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY event_type (event_type), + KEY action_name (action_name), + KEY status (status), + KEY requesting_user_id (requesting_user_id), + KEY execution_user_id (execution_user_id), + KEY created_at (created_at) + ) {$charset_collate};"; + + if ( ! function_exists( 'dbDelta' ) ) { + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + } + + if ( ! function_exists( 'dbDelta' ) ) { + return false; + } + + dbDelta( $sql ); + return true; + } + + /** + * Persist one action/event log record. + * + * @param array $data Insert payload. + */ + public function insert_log( array $data ): bool { + global $wpdb; + + if ( ! $this->is_wpdb_ready( $wpdb ) || ! method_exists( $wpdb, 'insert' ) ) { + return false; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- centralized logging insert for plugin action ledger. + $inserted = $wpdb->insert( + $this->get_table_name(), + [ + 'event_type' => isset( $data['event_type'] ) ? (string) $data['event_type'] : 'event', + 'action_name' => isset( $data['action_name'] ) ? (string) $data['action_name'] : '', + 'status' => isset( $data['status'] ) ? (string) $data['status'] : 'info', + 'message' => $data['message'] ?? null, + 'requesting_user_id' => $data['requesting_user_id'] ?? null, + 'execution_user_id' => $data['execution_user_id'] ?? null, + 'context' => $data['context'] ?? null, + ], + [ + '%s', + '%s', + '%s', + '%s', + '%d', + '%d', + '%s', + ] + ); + + return false !== $inserted; + } + + /** + * Fetch recent log rows. + * + * @param array $args Query filters. + * @return array> + */ + public function get_recent_logs( array $args = [] ): array { + global $wpdb; + + if ( ! $this->is_wpdb_ready( $wpdb ) || ! method_exists( $wpdb, 'prepare' ) || ! method_exists( $wpdb, 'get_results' ) ) { + return []; + } + + $limit = isset( $args['limit'] ) ? (int) $args['limit'] : 50; + $offset = isset( $args['offset'] ) ? (int) $args['offset'] : 0; + $limit = $limit > 0 ? min( $limit, 500 ) : 50; + $offset = $offset >= 0 ? $offset : 0; + + $where_clauses = []; + $where_values = []; + + if ( isset( $args['event_type'] ) && '' !== (string) $args['event_type'] ) { + $where_clauses[] = 'event_type = %s'; + $where_values[] = (string) $args['event_type']; + } + + if ( isset( $args['status'] ) && '' !== (string) $args['status'] ) { + $where_clauses[] = 'status = %s'; + $where_values[] = (string) $args['status']; + } + + if ( isset( $args['requesting_user_id'] ) && (int) $args['requesting_user_id'] > 0 ) { + $where_clauses[] = 'requesting_user_id = %d'; + $where_values[] = (int) $args['requesting_user_id']; + } + + if ( isset( $args['execution_user_id'] ) && (int) $args['execution_user_id'] > 0 ) { + $where_clauses[] = 'execution_user_id = %d'; + $where_values[] = (int) $args['execution_user_id']; + } + + $where_sql = [] !== $where_clauses ? 'WHERE ' . implode( ' AND ', $where_clauses ) : ''; + $where_values[] = $limit; + $where_values[] = $offset; + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is fixed plugin-owned identifier. + $query = "SELECT id, event_type, action_name, status, message, requesting_user_id, execution_user_id, context, created_at + FROM {$this->get_table_name()} + {$where_sql} + ORDER BY id DESC + LIMIT %d OFFSET %d"; + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- query is prepared via `$wpdb->prepare()` on this line. + $prepared_query = $wpdb->prepare( $query, $where_values ); + if ( ! is_string( $prepared_query ) || '' === $prepared_query ) { + return []; + } + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- log queries are intentional and already bounded. + $rows = $wpdb->get_results( $prepared_query, 'ARRAY_A' ); + return is_array( $rows ) ? array_values( $rows ) : []; + } + + /** + * Check whether a usable `$wpdb` object is present. + * + * @param mixed $wpdb Candidate wpdb object. + */ + private function is_wpdb_ready( $wpdb ): bool { + return is_object( $wpdb ) && isset( $wpdb->prefix ); + } +} From e6c4a795e18e5833745b49291078ab68f270fc15 Mon Sep 17 00:00:00 2001 From: Brad Vincent Date: Mon, 23 Feb 2026 20:21:53 +0000 Subject: [PATCH 06/13] refactor(agent-run): rename helper and extract run store --- includes/class-plugin.php | 4 +- includes/helpers/class-agent-run-helper.php | 249 ++++++++++++++++++ .../class-agent-run-store.php | 227 +++++----------- ...unStoreTest.php => AgentRunHelperTest.php} | 40 +-- 4 files changed, 330 insertions(+), 190 deletions(-) create mode 100644 includes/helpers/class-agent-run-helper.php rename includes/{helpers => stores}/class-agent-run-store.php (51%) rename tests/Unit/{AgentRunStoreTest.php => AgentRunHelperTest.php} (87%) diff --git a/includes/class-plugin.php b/includes/class-plugin.php index 960d59a..203533f 100644 --- a/includes/class-plugin.php +++ b/includes/class-plugin.php @@ -13,7 +13,7 @@ use ClawPress\AdminPage\Admin_Page; use ClawPress\Heartbeat\Heartbeat; use ClawPress\Helpers\Action_Log_Helper; -use ClawPress\Helpers\Agent_Run_Store; +use ClawPress\Helpers\Agent_Run_Helper; use ClawPress\Helpers\Agent_Session_Store; use ClawPress\Helpers\Panel_Helper; use ClawPress\Panel\Panel; @@ -65,7 +65,7 @@ public static function get_instance(): self { public static function activate(): void { Action_Log_Helper::get_instance()->create_table(); Agent_Session_Store::get_instance()->create_table(); - Agent_Run_Store::get_instance()->create_table(); + Agent_Run_Helper::get_instance()->create_table(); $user_id = get_current_user_id(); if ( $user_id <= 0 ) { diff --git a/includes/helpers/class-agent-run-helper.php b/includes/helpers/class-agent-run-helper.php new file mode 100644 index 0000000..84b6d06 --- /dev/null +++ b/includes/helpers/class-agent-run-helper.php @@ -0,0 +1,249 @@ + + */ + private const TERMINAL_STATUSES = [ 'success', 'failed', 'cancelled', 'canceled' ]; + + /** + * Singleton instance. + * + * @var ?self + */ + private static ?self $instance = null; + + /** + * Run store instance for DB access. + */ + private Agent_Run_DB_Store $store; + + /** + * Constructor. + */ + private function __construct() { + $this->store = Agent_Run_DB_Store::get_instance(); + } + + /** + * Get singleton instance. + */ + public static function get_instance(): self { + if ( null === self::$instance ) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * Resolve full run table name. + */ + public function get_table_name(): string { + return $this->store->get_table_name(); + } + + /** + * Create/update run table schema. + */ + public function create_table(): bool { + return $this->store->create_table(); + } + + /** + * Create a queued run. + * + * @param int $session_id Parent session identifier. + */ + public function create_run( int $session_id ): int { + $now = gmdate( 'Y-m-d H:i:s' ); + return $this->store->insert_run( $session_id, $this->generate_uuid(), $now ); + } + + /** + * Claim one run for a worker. + * + * @param int $run_id Run identifier. + * @param string $worker_id Worker claim id. + * @param int $lease_ttl_seconds Lease TTL in seconds. + * @return array + */ + public function claim_run( int $run_id, string $worker_id, int $lease_ttl_seconds = 120 ): array { + $run = $this->get_run( $run_id ); + if ( [] === $run ) { + return [ + 'claimed' => false, + 'reason' => 'run_not_found', + ]; + } + + $now = gmdate( 'Y-m-d H:i:s' ); + $lock_token = hash( 'sha256', uniqid( $worker_id . ':', true ) ); + $expires_at = gmdate( 'Y-m-d H:i:s', strtotime( $now ) + max( 1, $lease_ttl_seconds ) ); + + $is_stale = 'running' === (string) $run['status'] + && isset( $run['lock_expires_at_gmt'] ) + && is_string( $run['lock_expires_at_gmt'] ) + && '' !== $run['lock_expires_at_gmt'] + && strtotime( $run['lock_expires_at_gmt'] ) < strtotime( $now ); + + if ( 'queued' !== (string) $run['status'] && ! $is_stale ) { + return [ + 'claimed' => false, + 'reason' => 'not_claimable', + ]; + } + + $next_attempt = (int) ( $run['attempt'] ?? 1 ); + if ( $is_stale ) { + ++$next_attempt; + } + + $updated = $this->store->update_claim( + $run_id, + (string) $run['status'], + $is_stale ? (string) $run['lock_expires_at_gmt'] : null, + [ + 'status' => 'running', + 'claimed_by' => $worker_id, + 'lock_token' => $lock_token, + 'lock_acquired_at_gmt' => $now, + 'lock_expires_at_gmt' => $expires_at, + 'attempt' => $next_attempt, + 'started_at_gmt' => $now, + 'updated_at_gmt' => $now, + ], + $is_stale + ); + + if ( false === $updated || 0 === $updated ) { + return [ + 'claimed' => false, + 'reason' => 'claim_collision', + ]; + } + + return [ + 'claimed' => true, + 'run_id' => $run_id, + 'lock_token' => $lock_token, + 'attempt' => $next_attempt, + 'reclaimed' => $is_stale, + ]; + } + + /** + * Complete a run and update parent session state. + * + * @param int $run_id Run identifier. + * @param string $lock_token Lock token from claim response. + * @param string $status Terminal status. + * @param array $args Optional completion details. + */ + public function complete_run( int $run_id, string $lock_token, string $status, array $args = [] ): bool { + if ( ! in_array( $status, self::TERMINAL_STATUSES, true ) ) { + return false; + } + + if ( ! $this->store->begin_transaction() ) { + return false; + } + + $run = $this->get_run( $run_id ); + if ( [] === $run || 'running' !== (string) $run['status'] ) { + $this->store->rollback_transaction(); + return false; + } + + if ( (string) ( $run['lock_token'] ?? '' ) !== $lock_token ) { + $this->store->rollback_transaction(); + return false; + } + + $meta_json = null; + if ( isset( $args['meta'] ) && is_array( $args['meta'] ) ) { + $encoded = wp_json_encode( $args['meta'] ); + $meta_json = false === $encoded ? null : $encoded; + } + + $updated = $this->store->update_completion( + $run_id, + $lock_token, + [ + 'status' => $status, + 'finished_at_gmt' => gmdate( 'Y-m-d H:i:s' ), + 'error_code' => isset( $args['error_code'] ) ? (string) $args['error_code'] : null, + 'error_message' => isset( $args['error_message'] ) ? (string) $args['error_message'] : null, + 'meta_json' => $meta_json, + 'updated_at_gmt' => gmdate( 'Y-m-d H:i:s' ), + ] + ); + + if ( false === $updated || 0 === $updated ) { + $this->store->rollback_transaction(); + return false; + } + + $session_updated = Agent_Session_Store::get_instance()->apply_run_completion( + (int) $run['session_id'], + $status, + isset( $args['next_run_at_gmt'] ) ? (string) $args['next_run_at_gmt'] : null + ); + + if ( ! $session_updated ) { + $this->store->rollback_transaction(); + return false; + } + + if ( ! $this->store->commit_transaction() ) { + $this->store->rollback_transaction(); + return false; + } + + return true; + } + + /** + * Fetch one run by id. + * + * @param int $run_id Run identifier. + * @return array + */ + public function get_run( int $run_id ): array { + return $this->store->get_run( $run_id ); + } + + /** + * Generate uuid-like id without WP dependency. + */ + private function generate_uuid(): string { + $seed = md5( uniqid( '', true ) ); + return sprintf( + '%s-%s-%s-%s-%s', + substr( $seed, 0, 8 ), + substr( $seed, 8, 4 ), + substr( $seed, 12, 4 ), + substr( $seed, 16, 4 ), + substr( $seed, 20, 12 ) + ); + } + +} diff --git a/includes/helpers/class-agent-run-store.php b/includes/stores/class-agent-run-store.php similarity index 51% rename from includes/helpers/class-agent-run-store.php rename to includes/stores/class-agent-run-store.php index d273e39..3400e53 100644 --- a/includes/helpers/class-agent-run-store.php +++ b/includes/stores/class-agent-run-store.php @@ -1,18 +1,18 @@ - */ - private const TERMINAL_STATUSES = [ 'success', 'failed', 'cancelled', 'canceled' ]; - /** * Singleton instance. * @@ -114,28 +107,25 @@ public function create_table(): bool { } /** - * Create a queued run. - * - * @param int $session_id Parent session identifier. + * Insert a queued run row. */ - public function create_run( int $session_id ): int { + public function insert_run( int $session_id, string $run_uuid, string $created_at_gmt ): int { global $wpdb; if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'insert' ) ) { return 0; } - $now = gmdate( 'Y-m-d H:i:s' ); - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Centralized repository insert. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- centralized repository insert. $wpdb->insert( $this->get_table_name(), [ 'session_id' => $session_id, - 'run_uuid' => $this->generate_uuid(), + 'run_uuid' => $run_uuid, 'status' => 'queued', 'attempt' => 1, - 'created_at_gmt' => $now, - 'updated_at_gmt' => $now, + 'created_at_gmt' => $created_at_gmt, + 'updated_at_gmt' => $created_at_gmt, ], [ '%d', '%s', '%s', '%d', '%s', '%s' ] ); @@ -144,148 +134,87 @@ public function create_run( int $session_id ): int { } /** - * Claim one run for a worker. + * Compare-and-swap claim update for a run. * - * @param int $run_id Run identifier. - * @param string $worker_id Worker claim id. - * @param int $lease_ttl_seconds Lease TTL in seconds. - * @return array + * @param int $run_id Run identifier. + * @param string $current_status Expected status. + * @param string|null $current_lock_expires_at_gmt Expected lock expiry when reclaiming. + * @param array $data Update data. + * @param bool $is_stale Whether this claim is a stale reclaim. + * @return int|false */ - public function claim_run( int $run_id, string $worker_id, int $lease_ttl_seconds = 120 ): array { + public function update_claim( + int $run_id, + string $current_status, + ?string $current_lock_expires_at_gmt, + array $data, + bool $is_stale + ) { global $wpdb; if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'update' ) ) { - return [ - 'claimed' => false, - 'reason' => 'wpdb_unavailable', - ]; - } - - $run = $this->get_run( $run_id ); - if ( [] === $run ) { - return [ - 'claimed' => false, - 'reason' => 'run_not_found', - ]; - } - - $now = gmdate( 'Y-m-d H:i:s' ); - $lock_token = hash( 'sha256', uniqid( $worker_id . ':', true ) ); - $expires_at = gmdate( 'Y-m-d H:i:s', strtotime( $now ) + max( 1, $lease_ttl_seconds ) ); - - $is_stale = 'running' === (string) $run['status'] - && isset( $run['lock_expires_at_gmt'] ) - && is_string( $run['lock_expires_at_gmt'] ) - && '' !== $run['lock_expires_at_gmt'] - && strtotime( $run['lock_expires_at_gmt'] ) < strtotime( $now ); - - if ( 'queued' !== (string) $run['status'] && ! $is_stale ) { - return [ - 'claimed' => false, - 'reason' => 'not_claimable', - ]; - } - - $next_attempt = (int) ( $run['attempt'] ?? 1 ); - if ( $is_stale ) { - ++$next_attempt; + return false; } - $where = [ + $where = [ 'id' => $run_id, - 'status' => (string) $run['status'], + 'status' => $current_status, ]; + $where_format = [ '%d', '%s' ]; + if ( $is_stale ) { - $where['lock_expires_at_gmt'] = (string) $run['lock_expires_at_gmt']; + $where['lock_expires_at_gmt'] = $current_lock_expires_at_gmt; + $where_format[] = '%s'; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded compare-and-swap style update. - $updated = $wpdb->update( + return $wpdb->update( $this->get_table_name(), [ - 'status' => 'running', - 'claimed_by' => $worker_id, - 'lock_token' => $lock_token, - 'lock_acquired_at_gmt' => $now, - 'lock_expires_at_gmt' => $expires_at, - 'attempt' => $next_attempt, - 'started_at_gmt' => $now, - 'updated_at_gmt' => $now, + 'status' => isset( $data['status'] ) ? (string) $data['status'] : 'running', + 'claimed_by' => isset( $data['claimed_by'] ) ? (string) $data['claimed_by'] : null, + 'lock_token' => isset( $data['lock_token'] ) ? (string) $data['lock_token'] : null, + 'lock_acquired_at_gmt' => isset( $data['lock_acquired_at_gmt'] ) ? (string) $data['lock_acquired_at_gmt'] : null, + 'lock_expires_at_gmt' => isset( $data['lock_expires_at_gmt'] ) ? (string) $data['lock_expires_at_gmt'] : null, + 'attempt' => isset( $data['attempt'] ) ? (int) $data['attempt'] : 1, + 'started_at_gmt' => isset( $data['started_at_gmt'] ) ? (string) $data['started_at_gmt'] : null, + 'updated_at_gmt' => isset( $data['updated_at_gmt'] ) ? (string) $data['updated_at_gmt'] : null, ], $where, [ '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s' ], - array_fill( 0, count( $where ), '%s' ) + $where_format ); - - if ( false === $updated || 0 === $updated ) { - return [ - 'claimed' => false, - 'reason' => 'claim_collision', - ]; - } - - return [ - 'claimed' => true, - 'run_id' => $run_id, - 'lock_token' => $lock_token, - 'attempt' => $next_attempt, - 'reclaimed' => $is_stale, - ]; } /** - * Complete a run and update parent session state. + * Complete a run with lock-token guard. * * @param int $run_id Run identifier. - * @param string $lock_token Lock token from claim response. - * @param string $status Terminal status. - * @param array $args Optional completion details. + * @param string $lock_token Lock token guard. + * @param array $data Completion data. + * @return int|false */ - public function complete_run( int $run_id, string $lock_token, string $status, array $args = [] ): bool { + public function update_completion( int $run_id, string $lock_token, array $data ) { global $wpdb; - if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'update' ) || ! method_exists( $wpdb, 'query' ) ) { - return false; - } - if ( ! in_array( $status, self::TERMINAL_STATUSES, true ) ) { - return false; - } - - if ( ! $this->begin_transaction() ) { - return false; - } - - $run = $this->get_run( $run_id ); - if ( [] === $run || 'running' !== (string) $run['status'] ) { - $this->rollback_transaction(); - return false; - } - - if ( (string) ( $run['lock_token'] ?? '' ) !== $lock_token ) { - $this->rollback_transaction(); + if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'update' ) ) { return false; } - $meta_json = null; - if ( isset( $args['meta'] ) && is_array( $args['meta'] ) ) { - $encoded = wp_json_encode( $args['meta'] ); - $meta_json = false === $encoded ? null : $encoded; - } - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded repository completion update. - $updated = $wpdb->update( + return $wpdb->update( $this->get_table_name(), [ - 'status' => $status, + 'status' => isset( $data['status'] ) ? (string) $data['status'] : null, 'lock_token' => null, 'claimed_by' => null, 'lock_acquired_at_gmt' => null, 'lock_expires_at_gmt' => null, - 'finished_at_gmt' => gmdate( 'Y-m-d H:i:s' ), - 'error_code' => isset( $args['error_code'] ) ? (string) $args['error_code'] : null, - 'error_message' => isset( $args['error_message'] ) ? (string) $args['error_message'] : null, - 'meta_json' => $meta_json, - 'updated_at_gmt' => gmdate( 'Y-m-d H:i:s' ), + 'finished_at_gmt' => isset( $data['finished_at_gmt'] ) ? (string) $data['finished_at_gmt'] : null, + 'error_code' => $data['error_code'] ?? null, + 'error_message' => $data['error_message'] ?? null, + 'meta_json' => $data['meta_json'] ?? null, + 'updated_at_gmt' => isset( $data['updated_at_gmt'] ) ? (string) $data['updated_at_gmt'] : null, ], [ 'id' => $run_id, @@ -294,29 +223,6 @@ public function complete_run( int $run_id, string $lock_token, string $status, a [ '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ], [ '%d', '%s' ] ); - - if ( false === $updated || 0 === $updated ) { - $this->rollback_transaction(); - return false; - } - - $session_updated = Agent_Session_Store::get_instance()->apply_run_completion( - (int) $run['session_id'], - $status, - isset( $args['next_run_at_gmt'] ) ? (string) $args['next_run_at_gmt'] : null - ); - - if ( ! $session_updated ) { - $this->rollback_transaction(); - return false; - } - - if ( ! $this->commit_transaction() ) { - $this->rollback_transaction(); - return false; - } - - return true; } /** @@ -345,24 +251,9 @@ public function get_run( int $run_id ): array { } /** - * Generate uuid-like id without WP dependency. - */ - private function generate_uuid(): string { - $seed = md5( uniqid( '', true ) ); - return sprintf( - '%s-%s-%s-%s-%s', - substr( $seed, 0, 8 ), - substr( $seed, 8, 4 ), - substr( $seed, 12, 4 ), - substr( $seed, 16, 4 ), - substr( $seed, 20, 12 ) - ); - } - - /** - * Begin transaction for multi-table completion updates. + * Begin transaction. */ - private function begin_transaction(): bool { + public function begin_transaction(): bool { global $wpdb; if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'query' ) ) { @@ -374,9 +265,9 @@ private function begin_transaction(): bool { } /** - * Commit transaction for completion updates. + * Commit transaction. */ - private function commit_transaction(): bool { + public function commit_transaction(): bool { global $wpdb; if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'query' ) ) { @@ -388,9 +279,9 @@ private function commit_transaction(): bool { } /** - * Roll back completion transaction. + * Roll back transaction. */ - private function rollback_transaction(): void { + public function rollback_transaction(): void { global $wpdb; if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'query' ) ) { diff --git a/tests/Unit/AgentRunStoreTest.php b/tests/Unit/AgentRunHelperTest.php similarity index 87% rename from tests/Unit/AgentRunStoreTest.php rename to tests/Unit/AgentRunHelperTest.php index 9287687..3432d26 100644 --- a/tests/Unit/AgentRunStoreTest.php +++ b/tests/Unit/AgentRunHelperTest.php @@ -28,7 +28,7 @@ function dbDelta( string $queries ): array { namespace ClawPress\Tests\Unit { -use ClawPress\Helpers\Agent_Run_Store; +use ClawPress\Helpers\Agent_Run_Helper; use ClawPress\Helpers\Agent_Session_Store; use ClawPress\Plugin; use ClawPress\Tests\Support\TestCase; @@ -36,7 +36,7 @@ function dbDelta( string $queries ): array { /** * Minimal in-memory wpdb stub for run/session store tests. */ -final class AgentRunStoreTestWpdb { +final class AgentRunHelperTestWpdb { public string $prefix = 'wp_'; public int $insert_id = 0; @@ -230,11 +230,11 @@ public function get_row( string $query, string $output ) { } } -final class AgentRunStoreTest extends TestCase { +final class AgentRunHelperTest extends TestCase { protected function setUp(): void { parent::setUp(); $GLOBALS['clawpress_test_dbdelta_queries'] = []; - $GLOBALS['wpdb'] = new AgentRunStoreTestWpdb(); + $GLOBALS['wpdb'] = new AgentRunHelperTestWpdb(); } protected function tearDown(): void { @@ -252,9 +252,9 @@ public function test_plugin_activation_registers_session_and_run_tables(): void public function test_claim_run_success(): void { $session_id = Agent_Session_Store::get_instance()->create_session(); - $run_id = Agent_Run_Store::get_instance()->create_run( $session_id ); + $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id ); - $result = Agent_Run_Store::get_instance()->claim_run( $run_id, 'worker-a', 120 ); + $result = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-a', 120 ); $this->assertTrue( $result['claimed'] ); $this->assertSame( 'running', $GLOBALS['wpdb']->runs[ $run_id ]['status'] ); @@ -264,10 +264,10 @@ public function test_claim_run_success(): void { public function test_claim_collision_fails_for_second_worker(): void { $session_id = Agent_Session_Store::get_instance()->create_session(); - $run_id = Agent_Run_Store::get_instance()->create_run( $session_id ); + $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id ); - $first = Agent_Run_Store::get_instance()->claim_run( $run_id, 'worker-a', 120 ); - $second = Agent_Run_Store::get_instance()->claim_run( $run_id, 'worker-b', 120 ); + $first = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-a', 120 ); + $second = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-b', 120 ); $this->assertTrue( $first['claimed'] ); $this->assertFalse( $second['claimed'] ); @@ -276,12 +276,12 @@ public function test_claim_collision_fails_for_second_worker(): void { public function test_stale_lock_can_be_reclaimed_and_attempt_increments(): void { $session_id = Agent_Session_Store::get_instance()->create_session(); - $run_id = Agent_Run_Store::get_instance()->create_run( $session_id ); + $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id ); $GLOBALS['wpdb']->runs[ $run_id ]['status'] = 'running'; $GLOBALS['wpdb']->runs[ $run_id ]['attempt'] = 1; $GLOBALS['wpdb']->runs[ $run_id ]['lock_expires_at_gmt'] = '2000-01-01 00:00:00'; - $result = Agent_Run_Store::get_instance()->claim_run( $run_id, 'worker-reclaim', 120 ); + $result = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-reclaim', 120 ); $this->assertTrue( $result['claimed'] ); $this->assertTrue( $result['reclaimed'] ); @@ -291,10 +291,10 @@ public function test_stale_lock_can_be_reclaimed_and_attempt_increments(): void public function test_complete_run_clears_lock_and_updates_session_state(): void { $session_id = Agent_Session_Store::get_instance()->create_session(); - $run_id = Agent_Run_Store::get_instance()->create_run( $session_id ); - $claim = Agent_Run_Store::get_instance()->claim_run( $run_id, 'worker-a', 120 ); + $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id ); + $claim = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-a', 120 ); - $completed = Agent_Run_Store::get_instance()->complete_run( + $completed = Agent_Run_Helper::get_instance()->complete_run( $run_id, (string) $claim['lock_token'], 'success', @@ -312,12 +312,12 @@ public function test_complete_run_clears_lock_and_updates_session_state(): void public function test_complete_run_rolls_back_when_session_update_fails(): void { $session_id = Agent_Session_Store::get_instance()->create_session(); - $run_id = Agent_Run_Store::get_instance()->create_run( $session_id ); - $claim = Agent_Run_Store::get_instance()->claim_run( $run_id, 'worker-a', 120 ); + $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id ); + $claim = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-a', 120 ); $lock_token = (string) $claim['lock_token']; $GLOBALS['wpdb']->fail_session_update = true; - $completed = Agent_Run_Store::get_instance()->complete_run( $run_id, $lock_token, 'success' ); + $completed = Agent_Run_Helper::get_instance()->complete_run( $run_id, $lock_token, 'success' ); $this->assertFalse( $completed ); $this->assertSame( 'running', $GLOBALS['wpdb']->runs[ $run_id ]['status'] ); @@ -340,11 +340,11 @@ public function test_apply_run_completion_increments_failures_and_resets_on_succ public function test_complete_run_rejects_non_terminal_status(): void { $session_id = Agent_Session_Store::get_instance()->create_session(); - $run_id = Agent_Run_Store::get_instance()->create_run( $session_id ); - $claim = Agent_Run_Store::get_instance()->claim_run( $run_id, 'worker-a', 120 ); + $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id ); + $claim = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-a', 120 ); $lock_token = (string) $claim['lock_token']; - $completed = Agent_Run_Store::get_instance()->complete_run( $run_id, $lock_token, 'running' ); + $completed = Agent_Run_Helper::get_instance()->complete_run( $run_id, $lock_token, 'running' ); $this->assertFalse( $completed ); $this->assertSame( 'running', $GLOBALS['wpdb']->runs[ $run_id ]['status'] ); From 7558c3baf506d44b0aa870e1c7ab3a8a4c952818 Mon Sep 17 00:00:00 2001 From: Brad Vincent Date: Mon, 23 Feb 2026 20:25:13 +0000 Subject: [PATCH 07/13] refactor(agent-session): rename helper and extract session store --- includes/class-plugin.php | 4 +- includes/helpers/class-agent-run-helper.php | 2 +- .../helpers/class-agent-session-helper.php | 120 ++++++++++++++++++ .../class-agent-session-store.php | 63 +++------ tests/Unit/AgentRunHelperTest.php | 22 ++-- 5 files changed, 155 insertions(+), 56 deletions(-) create mode 100644 includes/helpers/class-agent-session-helper.php rename includes/{helpers => stores}/class-agent-session-store.php (65%) diff --git a/includes/class-plugin.php b/includes/class-plugin.php index 203533f..3043d75 100644 --- a/includes/class-plugin.php +++ b/includes/class-plugin.php @@ -14,7 +14,7 @@ use ClawPress\Heartbeat\Heartbeat; use ClawPress\Helpers\Action_Log_Helper; use ClawPress\Helpers\Agent_Run_Helper; -use ClawPress\Helpers\Agent_Session_Store; +use ClawPress\Helpers\Agent_Session_Helper; use ClawPress\Helpers\Panel_Helper; use ClawPress\Panel\Panel; use ClawPress\PostTypes\Post_Types; @@ -64,7 +64,7 @@ public static function get_instance(): self { */ public static function activate(): void { Action_Log_Helper::get_instance()->create_table(); - Agent_Session_Store::get_instance()->create_table(); + Agent_Session_Helper::get_instance()->create_table(); Agent_Run_Helper::get_instance()->create_table(); $user_id = get_current_user_id(); diff --git a/includes/helpers/class-agent-run-helper.php b/includes/helpers/class-agent-run-helper.php index 84b6d06..f0a3fde 100644 --- a/includes/helpers/class-agent-run-helper.php +++ b/includes/helpers/class-agent-run-helper.php @@ -202,7 +202,7 @@ public function complete_run( int $run_id, string $lock_token, string $status, a return false; } - $session_updated = Agent_Session_Store::get_instance()->apply_run_completion( + $session_updated = Agent_Session_Helper::get_instance()->apply_run_completion( (int) $run['session_id'], $status, isset( $args['next_run_at_gmt'] ) ? (string) $args['next_run_at_gmt'] : null diff --git a/includes/helpers/class-agent-session-helper.php b/includes/helpers/class-agent-session-helper.php new file mode 100644 index 0000000..a55bd84 --- /dev/null +++ b/includes/helpers/class-agent-session-helper.php @@ -0,0 +1,120 @@ +store = Agent_Session_DB_Store::get_instance(); + } + + /** + * Get singleton instance. + */ + public static function get_instance(): self { + if ( null === self::$instance ) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * Resolve full session table name. + */ + public function get_table_name(): string { + return $this->store->get_table_name(); + } + + /** + * Create/update session table schema. + */ + public function create_table(): bool { + return $this->store->create_table(); + } + + /** + * Create one session row. + * + * @param array $args Session payload. + */ + public function create_session( array $args = [] ): int { + $now = gmdate( 'Y-m-d H:i:s' ); + + return $this->store->insert_session( + [ + 'uuid' => isset( $args['uuid'] ) ? (string) $args['uuid'] : $this->generate_uuid(), + 'status' => isset( $args['status'] ) ? (string) $args['status'] : 'active', + 'trigger_type' => isset( $args['trigger_type'] ) ? (string) $args['trigger_type'] : 'chat', + 'requesting_user_id' => isset( $args['requesting_user_id'] ) ? (int) $args['requesting_user_id'] : null, + 'execution_user_id' => isset( $args['execution_user_id'] ) ? (int) $args['execution_user_id'] : null, + 'policy_profile' => isset( $args['policy_profile'] ) ? (string) $args['policy_profile'] : null, + 'last_run_at_gmt' => null, + 'next_run_at_gmt' => isset( $args['next_run_at_gmt'] ) ? (string) $args['next_run_at_gmt'] : null, + 'last_run_status' => null, + 'consecutive_failures' => 0, + 'created_at_gmt' => $now, + 'updated_at_gmt' => $now, + ] + ); + } + + /** + * Update parent session state after run completion. + * + * @param int $session_id Session identifier. + * @param string $run_status Terminal run status. + * @param string|null $next_run_at_gmt Optional next run timestamp. + */ + public function apply_run_completion( int $session_id, string $run_status, ?string $next_run_at_gmt = null ): bool { + return $this->store->update_run_completion( + $session_id, + $run_status, + $next_run_at_gmt, + gmdate( 'Y-m-d H:i:s' ) + ); + } + + /** + * Generate uuid-like id without WP dependency. + */ + private function generate_uuid(): string { + $seed = md5( uniqid( '', true ) ); + return sprintf( + '%s-%s-%s-%s-%s', + substr( $seed, 0, 8 ), + substr( $seed, 8, 4 ), + substr( $seed, 12, 4 ), + substr( $seed, 16, 4 ), + substr( $seed, 20, 12 ) + ); + } +} diff --git a/includes/helpers/class-agent-session-store.php b/includes/stores/class-agent-session-store.php similarity index 65% rename from includes/helpers/class-agent-session-store.php rename to includes/stores/class-agent-session-store.php index ecb5894..01f0957 100644 --- a/includes/helpers/class-agent-session-store.php +++ b/includes/stores/class-agent-session-store.php @@ -1,18 +1,18 @@ $args Session payload. + * @param array $data Session payload. */ - public function create_session( array $args = [] ): int { + public function insert_session( array $data ): int { global $wpdb; if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'insert' ) ) { return 0; } - $now = gmdate( 'Y-m-d H:i:s' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Centralized repository insert. $wpdb->insert( $this->get_table_name(), [ - 'uuid' => isset( $args['uuid'] ) ? (string) $args['uuid'] : $this->generate_uuid(), - 'status' => isset( $args['status'] ) ? (string) $args['status'] : 'active', - 'trigger_type' => isset( $args['trigger_type'] ) ? (string) $args['trigger_type'] : 'chat', - 'requesting_user_id' => isset( $args['requesting_user_id'] ) ? (int) $args['requesting_user_id'] : null, - 'execution_user_id' => isset( $args['execution_user_id'] ) ? (int) $args['execution_user_id'] : null, - 'policy_profile' => isset( $args['policy_profile'] ) ? (string) $args['policy_profile'] : null, - 'last_run_at_gmt' => null, - 'next_run_at_gmt' => isset( $args['next_run_at_gmt'] ) ? (string) $args['next_run_at_gmt'] : null, - 'last_run_status' => null, - 'consecutive_failures' => 0, - 'created_at_gmt' => $now, - 'updated_at_gmt' => $now, + 'uuid' => isset( $data['uuid'] ) ? (string) $data['uuid'] : '', + 'status' => isset( $data['status'] ) ? (string) $data['status'] : 'active', + 'trigger_type' => isset( $data['trigger_type'] ) ? (string) $data['trigger_type'] : 'chat', + 'requesting_user_id' => $data['requesting_user_id'] ?? null, + 'execution_user_id' => $data['execution_user_id'] ?? null, + 'policy_profile' => $data['policy_profile'] ?? null, + 'last_run_at_gmt' => $data['last_run_at_gmt'] ?? null, + 'next_run_at_gmt' => $data['next_run_at_gmt'] ?? null, + 'last_run_status' => $data['last_run_status'] ?? null, + 'consecutive_failures' => isset( $data['consecutive_failures'] ) ? (int) $data['consecutive_failures'] : 0, + 'created_at_gmt' => isset( $data['created_at_gmt'] ) ? (string) $data['created_at_gmt'] : gmdate( 'Y-m-d H:i:s' ), + 'updated_at_gmt' => isset( $data['updated_at_gmt'] ) ? (string) $data['updated_at_gmt'] : gmdate( 'Y-m-d H:i:s' ), ], [ '%s', @@ -157,12 +156,8 @@ public function create_session( array $args = [] ): int { /** * Update parent session state after run completion. - * - * @param int $session_id Session identifier. - * @param string $run_status Terminal run status. - * @param string|null $next_run_at_gmt Optional next run timestamp. */ - public function apply_run_completion( int $session_id, string $run_status, ?string $next_run_at_gmt = null ): bool { + public function update_run_completion( int $session_id, string $run_status, ?string $next_run_at_gmt, string $updated_at_gmt ): bool { global $wpdb; if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'prepare' ) || ! method_exists( $wpdb, 'query' ) ) { @@ -170,7 +165,6 @@ public function apply_run_completion( int $session_id, string $run_status, ?stri } $table_name = $this->get_table_name(); - $now = gmdate( 'Y-m-d H:i:s' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is fixed plugin-owned identifier. $query = $wpdb->prepare( @@ -185,11 +179,11 @@ public function apply_run_completion( int $session_id, string $run_status, ?stri next_run_at_gmt = %s, updated_at_gmt = %s WHERE id = %d", - $now, + $updated_at_gmt, $run_status, $run_status, $next_run_at_gmt, - $now, + $updated_at_gmt, $session_id ); @@ -202,19 +196,4 @@ public function apply_run_completion( int $session_id, string $run_status, ?stri return false !== $updated; } - - /** - * Generate uuid-like id without WP dependency. - */ - private function generate_uuid(): string { - $seed = md5( uniqid( '', true ) ); - return sprintf( - '%s-%s-%s-%s-%s', - substr( $seed, 0, 8 ), - substr( $seed, 8, 4 ), - substr( $seed, 12, 4 ), - substr( $seed, 16, 4 ), - substr( $seed, 20, 12 ) - ); - } } diff --git a/tests/Unit/AgentRunHelperTest.php b/tests/Unit/AgentRunHelperTest.php index 3432d26..6f29b2a 100644 --- a/tests/Unit/AgentRunHelperTest.php +++ b/tests/Unit/AgentRunHelperTest.php @@ -29,7 +29,7 @@ function dbDelta( string $queries ): array { namespace ClawPress\Tests\Unit { use ClawPress\Helpers\Agent_Run_Helper; -use ClawPress\Helpers\Agent_Session_Store; +use ClawPress\Helpers\Agent_Session_Helper; use ClawPress\Plugin; use ClawPress\Tests\Support\TestCase; @@ -251,7 +251,7 @@ public function test_plugin_activation_registers_session_and_run_tables(): void } public function test_claim_run_success(): void { - $session_id = Agent_Session_Store::get_instance()->create_session(); + $session_id = Agent_Session_Helper::get_instance()->create_session(); $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id ); $result = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-a', 120 ); @@ -263,7 +263,7 @@ public function test_claim_run_success(): void { } public function test_claim_collision_fails_for_second_worker(): void { - $session_id = Agent_Session_Store::get_instance()->create_session(); + $session_id = Agent_Session_Helper::get_instance()->create_session(); $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id ); $first = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-a', 120 ); @@ -275,7 +275,7 @@ public function test_claim_collision_fails_for_second_worker(): void { } public function test_stale_lock_can_be_reclaimed_and_attempt_increments(): void { - $session_id = Agent_Session_Store::get_instance()->create_session(); + $session_id = Agent_Session_Helper::get_instance()->create_session(); $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id ); $GLOBALS['wpdb']->runs[ $run_id ]['status'] = 'running'; $GLOBALS['wpdb']->runs[ $run_id ]['attempt'] = 1; @@ -290,7 +290,7 @@ public function test_stale_lock_can_be_reclaimed_and_attempt_increments(): void } public function test_complete_run_clears_lock_and_updates_session_state(): void { - $session_id = Agent_Session_Store::get_instance()->create_session(); + $session_id = Agent_Session_Helper::get_instance()->create_session(); $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id ); $claim = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-a', 120 ); @@ -311,7 +311,7 @@ public function test_complete_run_clears_lock_and_updates_session_state(): void } public function test_complete_run_rolls_back_when_session_update_fails(): void { - $session_id = Agent_Session_Store::get_instance()->create_session(); + $session_id = Agent_Session_Helper::get_instance()->create_session(); $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id ); $claim = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-a', 120 ); $lock_token = (string) $claim['lock_token']; @@ -326,20 +326,20 @@ public function test_complete_run_rolls_back_when_session_update_fails(): void { } public function test_apply_run_completion_increments_failures_and_resets_on_success(): void { - $session_id = Agent_Session_Store::get_instance()->create_session(); + $session_id = Agent_Session_Helper::get_instance()->create_session(); - $this->assertTrue( Agent_Session_Store::get_instance()->apply_run_completion( $session_id, 'failed', null ) ); + $this->assertTrue( Agent_Session_Helper::get_instance()->apply_run_completion( $session_id, 'failed', null ) ); $this->assertSame( 1, (int) $GLOBALS['wpdb']->sessions[ $session_id ]['consecutive_failures'] ); - $this->assertTrue( Agent_Session_Store::get_instance()->apply_run_completion( $session_id, 'failed', null ) ); + $this->assertTrue( Agent_Session_Helper::get_instance()->apply_run_completion( $session_id, 'failed', null ) ); $this->assertSame( 2, (int) $GLOBALS['wpdb']->sessions[ $session_id ]['consecutive_failures'] ); - $this->assertTrue( Agent_Session_Store::get_instance()->apply_run_completion( $session_id, 'success', null ) ); + $this->assertTrue( Agent_Session_Helper::get_instance()->apply_run_completion( $session_id, 'success', null ) ); $this->assertSame( 0, (int) $GLOBALS['wpdb']->sessions[ $session_id ]['consecutive_failures'] ); } public function test_complete_run_rejects_non_terminal_status(): void { - $session_id = Agent_Session_Store::get_instance()->create_session(); + $session_id = Agent_Session_Helper::get_instance()->create_session(); $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id ); $claim = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-a', 120 ); $lock_token = (string) $claim['lock_token']; From 34d9729f27233b70cd2b0d16df7361365034338e Mon Sep 17 00:00:00 2001 From: Brad Vincent Date: Mon, 23 Feb 2026 20:34:45 +0000 Subject: [PATCH 08/13] refactor(helpers): remove schema APIs and activate stores directly --- includes/class-plugin.php | 12 ++++++------ includes/helpers/class-action-log-helper.php | 14 -------------- includes/helpers/class-agent-run-helper.php | 14 -------------- includes/helpers/class-agent-session-helper.php | 14 -------------- tests/Unit/ActionLogHelperTest.php | 11 ++++++++--- tests/Unit/AgentRunHelperTest.php | 7 +++++++ 6 files changed, 21 insertions(+), 51 deletions(-) diff --git a/includes/class-plugin.php b/includes/class-plugin.php index 3043d75..7bbaa1a 100644 --- a/includes/class-plugin.php +++ b/includes/class-plugin.php @@ -12,13 +12,13 @@ use ClawPress\Abilities\Abilities; use ClawPress\AdminPage\Admin_Page; use ClawPress\Heartbeat\Heartbeat; -use ClawPress\Helpers\Action_Log_Helper; -use ClawPress\Helpers\Agent_Run_Helper; -use ClawPress\Helpers\Agent_Session_Helper; use ClawPress\Helpers\Panel_Helper; use ClawPress\Panel\Panel; use ClawPress\PostTypes\Post_Types; use ClawPress\RestAPI\Rest_API; +use ClawPress\Stores\Action_Log_Store; +use ClawPress\Stores\Agent_Run_Store; +use ClawPress\Stores\Agent_Session_Store; defined( 'ABSPATH' ) || exit; @@ -63,9 +63,9 @@ public static function get_instance(): self { * Plugin activation callback. */ public static function activate(): void { - Action_Log_Helper::get_instance()->create_table(); - Agent_Session_Helper::get_instance()->create_table(); - Agent_Run_Helper::get_instance()->create_table(); + Action_Log_Store::get_instance()->create_table(); + Agent_Session_Store::get_instance()->create_table(); + Agent_Run_Store::get_instance()->create_table(); $user_id = get_current_user_id(); if ( $user_id <= 0 ) { diff --git a/includes/helpers/class-action-log-helper.php b/includes/helpers/class-action-log-helper.php index 541cfd9..83f23a1 100644 --- a/includes/helpers/class-action-log-helper.php +++ b/includes/helpers/class-action-log-helper.php @@ -54,20 +54,6 @@ public static function get_instance(): self { return self::$instance; } - /** - * Resolve full action log table name. - */ - public function get_table_name(): string { - return $this->store->get_table_name(); - } - - /** - * Create/update action log table schema. - */ - public function create_table(): bool { - return $this->store->create_table(); - } - /** * Persist one action/event log record. * diff --git a/includes/helpers/class-agent-run-helper.php b/includes/helpers/class-agent-run-helper.php index f0a3fde..317627a 100644 --- a/includes/helpers/class-agent-run-helper.php +++ b/includes/helpers/class-agent-run-helper.php @@ -54,20 +54,6 @@ public static function get_instance(): self { return self::$instance; } - /** - * Resolve full run table name. - */ - public function get_table_name(): string { - return $this->store->get_table_name(); - } - - /** - * Create/update run table schema. - */ - public function create_table(): bool { - return $this->store->create_table(); - } - /** * Create a queued run. * diff --git a/includes/helpers/class-agent-session-helper.php b/includes/helpers/class-agent-session-helper.php index a55bd84..d6f2854 100644 --- a/includes/helpers/class-agent-session-helper.php +++ b/includes/helpers/class-agent-session-helper.php @@ -47,20 +47,6 @@ public static function get_instance(): self { return self::$instance; } - /** - * Resolve full session table name. - */ - public function get_table_name(): string { - return $this->store->get_table_name(); - } - - /** - * Create/update session table schema. - */ - public function create_table(): bool { - return $this->store->create_table(); - } - /** * Create one session row. * diff --git a/tests/Unit/ActionLogHelperTest.php b/tests/Unit/ActionLogHelperTest.php index e3c385c..1a42084 100644 --- a/tests/Unit/ActionLogHelperTest.php +++ b/tests/Unit/ActionLogHelperTest.php @@ -30,6 +30,7 @@ function dbDelta( string $queries ): array { use ClawPress\Helpers\Action_Log_Helper; use ClawPress\Plugin; +use ClawPress\Stores\Action_Log_Store; use ClawPress\Tests\Support\TestCase; /** @@ -128,9 +129,8 @@ protected function tearDown(): void { parent::tearDown(); } - public function test_create_table_registers_clawpress_action_logs_schema(): void { - $helper = Action_Log_Helper::get_instance(); - $result = $helper->create_table(); + public function test_store_create_table_registers_clawpress_action_logs_schema(): void { + $result = Action_Log_Store::get_instance()->create_table(); $this->assertTrue( $result ); $this->assertIsArray( $GLOBALS['clawpress_test_dbdelta_queries'] ); @@ -139,6 +139,11 @@ public function test_create_table_registers_clawpress_action_logs_schema(): void $this->assertStringContainsString( 'action_name', (string) $GLOBALS['clawpress_test_dbdelta_queries'][0] ); } + public function test_helper_does_not_expose_schema_methods(): void { + $this->assertFalse( method_exists( Action_Log_Helper::class, 'create_table' ) ); + $this->assertFalse( method_exists( Action_Log_Helper::class, 'get_table_name' ) ); + } + public function test_plugin_activation_creates_action_log_table(): void { Plugin::activate(); diff --git a/tests/Unit/AgentRunHelperTest.php b/tests/Unit/AgentRunHelperTest.php index 6f29b2a..8cb22b4 100644 --- a/tests/Unit/AgentRunHelperTest.php +++ b/tests/Unit/AgentRunHelperTest.php @@ -350,5 +350,12 @@ public function test_complete_run_rejects_non_terminal_status(): void { $this->assertSame( 'running', $GLOBALS['wpdb']->runs[ $run_id ]['status'] ); $this->assertSame( $lock_token, $GLOBALS['wpdb']->runs[ $run_id ]['lock_token'] ); } + + public function test_helpers_do_not_expose_schema_methods(): void { + $this->assertFalse( method_exists( Agent_Run_Helper::class, 'create_table' ) ); + $this->assertFalse( method_exists( Agent_Run_Helper::class, 'get_table_name' ) ); + $this->assertFalse( method_exists( Agent_Session_Helper::class, 'create_table' ) ); + $this->assertFalse( method_exists( Agent_Session_Helper::class, 'get_table_name' ) ); + } } } From 1c3dd89d93904b8e1661be4586a3ce8f46c62309 Mon Sep 17 00:00:00 2001 From: Brad Vincent Date: Mon, 23 Feb 2026 20:38:13 +0000 Subject: [PATCH 09/13] added docs for the store helpers --- docs/action-log-helper.md | 66 ++++++++++++++++++++++++++++ docs/agent-run-helper.md | 83 ++++++++++++++++++++++++++++++++++++ docs/agent-session-helper.md | 63 +++++++++++++++++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 docs/action-log-helper.md create mode 100644 docs/agent-run-helper.md create mode 100644 docs/agent-session-helper.md diff --git a/docs/action-log-helper.md b/docs/action-log-helper.md new file mode 100644 index 0000000..9136afe --- /dev/null +++ b/docs/action-log-helper.md @@ -0,0 +1,66 @@ +# Action Log Helper + +`Action_Log_Helper` is the public integration surface for writing and reading action/event logs. + +- Class: `ClawPress\Helpers\Action_Log_Helper` +- DB store behind it: `ClawPress\Stores\Action_Log_Store` + +Use the helper from controllers/services. Do not call the store directly unless you are working on persistence internals. + +## Common Usage + +```php +use ClawPress\Helpers\Action_Log_Helper; + +$log_helper = Action_Log_Helper::get_instance(); + +$log_helper->log_event( + 'tool.execute', + [ + 'event_type' => 'tool_call', + 'status' => 'success', + 'message' => 'Workspace created.', + 'requesting_user_id' => get_current_user_id(), + 'execution_user_id' => 123, + 'context' => [ 'ability' => 'create_workspace' ], + ] +); +``` + +## API + +### `get_instance(): Action_Log_Helper` +Singleton accessor. + +### `log_event( string $action_name, array $args = [] ): bool` +Writes one log row. + +Supported `$args` keys: + +- `event_type` (string, default: `event`) +- `status` (string, allowed: `debug`, `info`, `success`, `warning`, `error`; invalid values become `info`) +- `message` (string) +- `requesting_user_id` (int; defaults to current user when available) +- `execution_user_id` (int) +- `context` (array; JSON-encoded for storage) + +### `get_recent_logs( array $args = [] ): array` +Reads recent rows and returns normalized records. + +Supported filters: + +- `event_type` (string) +- `status` (string) +- `requesting_user_id` (int) +- `execution_user_id` (int) +- `limit` (int, default `50`, max `500`) +- `offset` (int, default `0`) + +Returned `context` is decoded back to an array. + +## Notes + +- Input is sanitized by the helper before persistence. +- Status and event/action identifiers are normalized to predictable values. +- The helper is safe to use across admin, REST, and background execution paths. +- Schema/table management for action logs is owned by `Action_Log_Store` and called from plugin activation. diff --git a/docs/agent-run-helper.md b/docs/agent-run-helper.md new file mode 100644 index 0000000..802cf03 --- /dev/null +++ b/docs/agent-run-helper.md @@ -0,0 +1,83 @@ +# Agent Run Helper + +`Agent_Run_Helper` manages run lifecycle and lock semantics for background agent execution. + +- Class: `ClawPress\Helpers\Agent_Run_Helper` +- DB store behind it: `ClawPress\Stores\Agent_Run_Store` + +Use this helper from workers/executors. It handles claim rules, stale lock reclaim, completion, and session rollup orchestration. + +## Common Usage + +```php +use ClawPress\Helpers\Agent_Run_Helper; + +$run_helper = Agent_Run_Helper::get_instance(); + +$run_id = $run_helper->create_run( $session_id ); +$claim = $run_helper->claim_run( $run_id, 'worker-a', 120 ); + +if ( ! empty( $claim['claimed'] ) ) { + $run_helper->complete_run( + $run_id, + (string) $claim['lock_token'], + 'success', + [ + 'meta' => [ 'tools' => 3 ], + 'next_run_at_gmt' => null, + ] + ); +} +``` + +## API + +### `get_instance(): Agent_Run_Helper` +Singleton accessor. + +### `create_run( int $session_id ): int` +Creates a queued run row and returns the run ID (or `0` on failure). + +### `claim_run( int $run_id, string $worker_id, int $lease_ttl_seconds = 120 ): array` +Attempts to claim a queued run (or reclaim a stale running lock). + +Success payload includes: + +- `claimed` (`true`) +- `run_id` +- `lock_token` +- `attempt` +- `reclaimed` (`true` when stale lock was reclaimed) + +Failure payload includes `claimed => false` and a `reason`: + +- `run_not_found` +- `not_claimable` +- `claim_collision` + +### `complete_run( int $run_id, string $lock_token, string $status, array $args = [] ): bool` +Completes a claimed run and updates session rollup state in one transaction. + +Allowed terminal statuses: + +- `success` +- `failed` +- `cancelled` +- `canceled` + +Supported `$args` keys: + +- `error_code` (string|null) +- `error_message` (string|null) +- `meta` (array; JSON-encoded) +- `next_run_at_gmt` (string|null; passed to session helper) + +### `get_run( int $run_id ): array` +Returns a run row as an associative array, or `[]` when not found. + +## Notes + +- `complete_run()` enforces lock-token ownership before writing completion state. +- Completion rolls back if run or session update fails, preventing partial state. +- For new behavior, put workflow logic in the helper and keep SQL-only logic in the store. +- Schema/table management for runs is owned by `Agent_Run_Store` and called from plugin activation. diff --git a/docs/agent-session-helper.md b/docs/agent-session-helper.md new file mode 100644 index 0000000..f894b23 --- /dev/null +++ b/docs/agent-session-helper.md @@ -0,0 +1,63 @@ +# Agent Session Helper + +`Agent_Session_Helper` manages session-level lifecycle state for the agent runtime. + +- Class: `ClawPress\Helpers\Agent_Session_Helper` +- DB store behind it: `ClawPress\Stores\Agent_Session_Store` + +Use this helper for session creation and run-completion rollups. It encapsulates defaults and delegates persistence to the store. + +## Common Usage + +```php +use ClawPress\Helpers\Agent_Session_Helper; + +$session_helper = Agent_Session_Helper::get_instance(); + +$session_id = $session_helper->create_session( + [ + 'trigger_type' => 'chat', + 'requesting_user_id' => get_current_user_id(), + 'execution_user_id' => 123, + 'policy_profile' => 'default', + ] +); +``` + +## API + +### `get_instance(): Agent_Session_Helper` +Singleton accessor. + +### `create_session( array $args = [] ): int` +Creates one session row and returns the session ID (or `0` on failure). + +Supported `$args` keys: + +- `uuid` (string; auto-generated when omitted) +- `status` (string; default `active`) +- `trigger_type` (string; default `chat`) +- `requesting_user_id` (int|null) +- `execution_user_id` (int|null) +- `policy_profile` (string|null) +- `next_run_at_gmt` (string|null) + +### `apply_run_completion( int $session_id, string $run_status, ?string $next_run_at_gmt = null ): bool` +Updates session rollup fields after a run completes: + +- `last_run_at_gmt` +- `last_run_status` +- `next_run_at_gmt` +- `updated_at_gmt` +- `consecutive_failures` + +Failure counter behavior: + +- resets to `0` when `$run_status === 'success'` +- increments by `1` for all other statuses + +## Notes + +- This helper is intended to be called by `Agent_Run_Helper` after run completion. +- Session persistence details are isolated in `includes/stores/class-agent-session-store.php`. +- Schema/table management for sessions is owned by `Agent_Session_Store` and called from plugin activation. From 712f8992bce87ef5642facb0db92b8a37fcabcbf Mon Sep 17 00:00:00 2001 From: Brad Vincent Date: Mon, 23 Feb 2026 20:43:10 +0000 Subject: [PATCH 10/13] create final spec from original and edit --- docs/agent-loop-spec-final.md | 314 ++++++++++++++++++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 docs/agent-loop-spec-final.md diff --git a/docs/agent-loop-spec-final.md b/docs/agent-loop-spec-final.md new file mode 100644 index 0000000..b75e5a3 --- /dev/null +++ b/docs/agent-loop-spec-final.md @@ -0,0 +1,314 @@ +## Agent Loop Spec (Final) + +## Summary +Refactor the current chat-bound agent execution into a reusable, transport-agnostic **Agent Loop runtime** that can be called from: + +1. synchronous chat requests, +2. heartbeat/background jobs, +3. future spawned-agent APIs. + +The loop must work reliably with **non-streaming providers now** and support **streaming later** without rewriting core execution logic. + +--- + +## Current State + +### What exists +- Chat execution path currently drives model/tool loop: + - `includes/rest/class-chat-controller.php` -> `Chat_Helper::generate_ai_reply()` + - core loop logic currently in `includes/helpers/class-chat-helper.php` +- Heartbeat scheduler exists: + - `includes/class-heartbeat.php` + - schedules `clawpress_heartbeat_tick` every 15 minutes via Action Scheduler + - tick triggers: `do_action( 'clawpress_run_scheduled_tasks' )` + +### What is missing +- No production-grade consumer attached to `clawpress_run_scheduled_tasks` for autonomous runs. +- No fully independent runtime loop surface shared by chat/background/spawn adapters. +- No completed run coordinator across triggers with robust claim/lock/retry lifecycle for all paths. + +Result: execution is still effectively request-bound in practice. + +--- + +## Constraints + +### Non-streaming first +- Current LLM integration uses WP AI Client, which does not yet provide streaming support. +- Long run-to-completion turns inside a single HTTP request are unreliable. + +### Non-negotiable design rule +The agent loop must be deterministic and resumable independent of delivery transport. + +- Streaming is a transport optimization. +- Streaming must not require control-plane rewrites. + +--- + +## Final Goal +Build one reusable loop runtime that provides: + +- identical core behavior across chat, heartbeat, and spawned agents, +- safe asynchronous/background execution via time-sliced runs, +- durable state and event persistence for polling-based progress now, +- a direct upgrade path to streaming transport later. + +--- + +## Final Architecture + +### 1) Explicit layers +Implement four clear layers: + +1. **Loop Engine**: pure execution logic; no DB, no HTTP, no scheduler coupling. +2. **Stores**: DB-backed persistence for threads/sessions/runs/events and locking metadata. +3. **Runner**: Action Scheduler tick executor that claims work and runs slices. +4. **Transport**: delivery channel for progress/events (`polling` now, `streaming` later). + +### 2) Loop Engine responsibilities +Extract loop responsibilities from `Chat_Helper` into `includes/helpers/class-agent-loop-helper.php` (name flexible): + +- provider/model resolution, +- context assembly and prompt preparation, +- model call orchestration, +- tool-call loop with bounded limits, +- confirmation batching behavior, +- usage/context metadata collection, +- normalized result payloads, +- event emission hooks. + +`Chat_Helper` becomes a thin adapter. + +### 3) Step-based and slice-based execution +Support both: + +- `run_turn(...)`: full turn execution for synchronous contexts when budget permits. +- `run_slice(...)`: bounded execution chunk (for runner/background). + +A slice should do bounded work (for example, one model call or a limited tool batch), then return resumable state. + +### 4) Transport abstraction +Define internal transport interface: + +- `emit( AgentEvent $event ): void` +- `close(): void` + +Implementations: + +- **PollingTransport** (default now): append events to persistent event store. +- **StreamingTransport** (future): emits deltas live; may still persist events for observability. + +The loop engine must not branch by transport-specific behavior. + +--- + +## Contracts + +### TurnRequest +Required/common fields: + +- `thread_id` (or session id) +- `trigger` (`chat`, `heartbeat`, `spawned_agent`, ...) +- `message` (optional for heartbeat-driven turns) +- `requesting_user_id` +- `execution_user_id` +- policy knobs (`allow_tools`, `require_confirmation`, limits/timeouts) + +Additions for resumability and slicing: + +- `run_id` (unique per attempt) +- `attempt` (int) +- `slice_budget_ms` (hard per-tick budget) +- `max_steps_per_slice` (hard cap per slice) +- `transport_mode` (`polling` | `streaming`) +- `resume_cursor` (opaque engine state token; optional) + +### TurnResult +Core fields: + +- `assistant_text` +- `tool_calls` trace +- optional `card` +- usage/context metadata + +Status values: + +- `success` +- `requires_confirmation` +- `error` +- `timeout` +- `in_progress` (slice paused due to budget) + +Next-action hints: + +- `continue_now` +- `continue_later` +- `stop` + +Additions for resumability and UI polling: + +- `resume_cursor` (when `status=in_progress`) +- `events_cursor` (optional incremental cursor) + +--- + +## Persistence Model + +### agent_threads (long-lived) +- `thread_id` +- `status` (`idle|running|paused|error|dead`) +- policy profile +- schedule fields (`last_run_at`, `next_run_at`) +- lock/lease metadata (or lock table) + +### agent_runs (per attempt) +- `run_id`, `thread_id` +- `trigger` +- `status` (`queued|running|waiting_llm|waiting_tools|paused|done|error`) +- `attempt`, retry/backoff fields +- `resume_cursor` +- usage totals and error classification + +### agent_events (append-only) +- monotonic `event_id` +- `run_id`, `thread_id` +- `type` +- JSON `payload` +- `created_at` + +UI polls event stream incrementally via cursor. + +--- + +## Runner (Action Scheduler) + +Runner algorithm per tick: + +1. Find and claim runnable threads/runs. +2. Acquire lock/lease. +3. Load or create run state. +4. Execute one bounded slice. +5. Persist updated run/session state and emitted events. +6. If needed, enqueue follow-up tick immediately or with backoff. +7. Release lock/lease. + +### Lock semantics +- Lock scope is one slice execution, not entire multi-slice lifecycle. +- Lease renewed per tick. +- Stale lease recovery requeues safely. +- Idempotency checks prevent duplicate progress on repeated ticks. + +--- + +## Adapter Behavior + +### Chat adapter +Keep synchronous for small turns, but remain thin: + +1. persist inbound user message, +2. call loop runtime, +3. persist outputs, +4. return response. + +If request budget is exceeded, return `run_id` + `in_progress`, and client switches to polling. + +### Heartbeat/background adapter +Consume `clawpress_run_scheduled_tasks` and execute slices through runner. + +### Spawn adapter +Spawn endpoint should: + +1. create thread/session, +2. seed initial context/message, +3. enqueue first run, +4. return thread/run identifiers. + +No loop internals in spawn endpoint. + +--- + +## Policy and Safety + +### Policy by trigger +- `chat`: interactive confirmation behavior. +- `heartbeat` / `spawned`: destructive tools denied or queued by default. +- optional per-thread policy profiles. + +### Guardrails +- max wall time per run/slice, +- max tool calls per run, +- bounded retries with exponential backoff, +- dead-letter terminal state after N failures, +- idempotency keys for run attempts. + +--- + +## Observability +Log structured run data (reuse/extend action log + run/event records): + +- `run_id`, `thread_id`, trigger, +- tool trace + statuses, +- provider/model and usage, +- error type + retry count, +- final outcome. + +Polling endpoint must expose incremental run events. + +--- + +## API Implications +Minimum endpoints: + +- `POST /agent/runs` -> create run and return `run_id` +- `POST /agent/runs/{run_id}/enqueue` (optional) +- `GET /agent/runs/{run_id}` -> status summary +- `GET /agent/runs/{run_id}/events?after={event_id}` -> incremental events + +--- + +## Implementation Phases + +### Phase 1: Internal extraction (no behavior regression) +- Extract Loop Engine from `Chat_Helper`. +- Add transport interface with polling implementation. +- Emit structured events. + +### Phase 2: Persistence + lock manager +- Finalize thread/run/event persistence and lock/lease handling. +- Ensure stale recovery and idempotency. + +### Phase 3: Time-sliced runner +- Implement Action Scheduler slice executor. +- Enforce time/step budgets. +- Re-enqueue until completion. + +### Phase 4: Spawn support + hardening +- Add spawn adapter/endpoint. +- Add retry/backoff/dead-letter policies. +- Add trigger-based policy profiles. + +### Phase 5: Streaming transport (future) +- Add `StreamingTransport` once provider supports deltas. +- Keep polling as fallback. +- No loop/state-machine rewrite required. + +--- + +## Acceptance Criteria + +- Chat uses shared loop runtime (no duplicated loop logic). +- Runner supports multi-slice execution with `in_progress` and `resume_cursor`. +- Background runs complete safely without long-lived HTTP requests. +- Per-thread concurrency safety: no duplicate concurrent execution. +- Event persistence supports incremental UI polling. +- Destructive tool behavior is explicitly policy-controlled by trigger. +- Transport mode is pluggable (`polling` now, `streaming` later) without core loop rewrite. + +--- + +## Why this is worth doing + +- One agent brain across all transports/triggers. +- Clean async/autonomous execution path. +- Safe path to spawned parallel threads. +- Lower long-term maintenance cost than duplicating chat logic in background adapters. From 1a03780f8a628806ffd95eb2be369661c84ed55e Mon Sep 17 00:00:00 2001 From: Brad Vincent Date: Mon, 23 Feb 2026 21:42:07 +0000 Subject: [PATCH 11/13] Add agent event store/helper and route tool-call logging to events --- includes/class-plugin.php | 2 + includes/helpers/class-abilities-helper.php | 66 +++-- includes/helpers/class-agent-event-helper.php | 225 ++++++++++++++++++ includes/stores/class-agent-event-store.php | 196 +++++++++++++++ tests/Unit/AbilitiesHelperTest.php | 9 +- tests/Unit/AgentEventHelperTest.php | 216 +++++++++++++++++ 6 files changed, 676 insertions(+), 38 deletions(-) create mode 100644 includes/helpers/class-agent-event-helper.php create mode 100644 includes/stores/class-agent-event-store.php create mode 100644 tests/Unit/AgentEventHelperTest.php diff --git a/includes/class-plugin.php b/includes/class-plugin.php index 7bbaa1a..35e549e 100644 --- a/includes/class-plugin.php +++ b/includes/class-plugin.php @@ -17,6 +17,7 @@ use ClawPress\PostTypes\Post_Types; use ClawPress\RestAPI\Rest_API; use ClawPress\Stores\Action_Log_Store; +use ClawPress\Stores\Agent_Event_Store; use ClawPress\Stores\Agent_Run_Store; use ClawPress\Stores\Agent_Session_Store; @@ -66,6 +67,7 @@ public static function activate(): void { Action_Log_Store::get_instance()->create_table(); Agent_Session_Store::get_instance()->create_table(); Agent_Run_Store::get_instance()->create_table(); + Agent_Event_Store::get_instance()->create_table(); $user_id = get_current_user_id(); if ( $user_id <= 0 ) { diff --git a/includes/helpers/class-abilities-helper.php b/includes/helpers/class-abilities-helper.php index 69f5037..fd5efb0 100644 --- a/includes/helpers/class-abilities-helper.php +++ b/includes/helpers/class-abilities-helper.php @@ -58,19 +58,19 @@ final class Abilities_Helper { private Security $security; /** - * Action log helper. + * Agent event helper. * - * @var Action_Log_Helper + * @var Agent_Event_Helper */ - private Action_Log_Helper $action_log_helper; + private Agent_Event_Helper $agent_event_helper; /** * Constructor. */ private function __construct() { - $this->settings_helper = Settings_Helper::get_instance(); - $this->security = Security::get_instance(); - $this->action_log_helper = Action_Log_Helper::get_instance(); + $this->settings_helper = Settings_Helper::get_instance(); + $this->security = Security::get_instance(); + $this->agent_event_helper = Agent_Event_Helper::get_instance(); } /** @@ -194,6 +194,10 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e $allowed_confirmation_tokens = $this->normalize_allowed_confirmation_tokens( $execution_context['allowed_confirmation_tokens'] ?? null ); + $event_context = [ + 'session_id' => isset( $execution_context['session_id'] ) ? (int) $execution_context['session_id'] : 0, + 'run_id' => isset( $execution_context['run_id'] ) ? (int) $execution_context['run_id'] : 0, + ]; $args_json = wp_json_encode( $args ); $args_hash = false !== $args_json ? hash( 'sha256', (string) $args_json ) : ''; @@ -207,7 +211,7 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e ], 'tool' => $normalized_tool_name, ]; - $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'error', $args_hash, $payload ); + $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'error', $args_hash, $payload, $event_context ); return $payload; } @@ -221,7 +225,7 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e 'tool' => $normalized_tool_name, 'ability' => $ability_name, ]; - $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'error', $args_hash, $payload ); + $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'error', $args_hash, $payload, $event_context ); return $payload; } @@ -236,7 +240,7 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e 'tool' => $normalized_tool_name, 'ability' => $ability_name, ]; - $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'error', $args_hash, $payload ); + $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'error', $args_hash, $payload, $event_context ); return $payload; } @@ -251,7 +255,7 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e 'tool' => $normalized_tool_name, 'ability' => $ability_name, ]; - $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'error', $args_hash, $payload ); + $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'error', $args_hash, $payload, $event_context ); return $payload; } @@ -269,7 +273,7 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e 'ability' => $ability_name, 'safety_class' => $safety_class, ]; - $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'warning', $args_hash, $payload ); + $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'warning', $args_hash, $payload, $event_context ); return $payload; } @@ -296,7 +300,7 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e 'ability' => $ability_name, 'safety_class' => $safety_class, ]; - $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'warning', $args_hash, $payload ); + $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'warning', $args_hash, $payload, $event_context ); return $payload; } } @@ -319,7 +323,7 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e 'ability' => $ability_name, 'safety_class' => $safety_class, ]; - $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'error', $args_hash, $payload ); + $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'error', $args_hash, $payload, $event_context ); return $payload; } @@ -331,7 +335,7 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e 'result' => $result, ]; - $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'success', $args_hash, $payload ); + $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'success', $args_hash, $payload, $event_context ); return $payload; } @@ -470,7 +474,7 @@ private function infer_safety_class( \WP_Ability $ability ): string { } /** - * Write one tool-call action ledger row. + * Emit one tool-call event row. * * @param string $tool_name Tool name. * @param string $ability_name Ability ID. @@ -479,6 +483,7 @@ private function infer_safety_class( \WP_Ability $ability ): string { * @param string $status Log status. * @param string $args_hash Hash of arguments. * @param array $payload Tool payload. + * @param array $event_context Optional run/session context. */ private function log_tool_call( string $tool_name, @@ -487,27 +492,18 @@ private function log_tool_call( int $execution_user_id, string $status, string $args_hash, - array $payload + array $payload, + array $event_context ): void { - $this->action_log_helper->log_event( - 'tool/' . $tool_name, - [ - 'event_type' => 'tool_call', - 'status' => $status, - 'message' => isset( $payload['error']['message'] ) - ? (string) $payload['error']['message'] - : __( 'Tool execution completed.', 'clawpress' ), - 'requesting_user_id' => $requesting_user_id > 0 ? $requesting_user_id : null, - 'execution_user_id' => $execution_user_id > 0 ? $execution_user_id : null, - 'context' => [ - 'tool_name' => $tool_name, - 'ability_name' => $ability_name, - 'args_hash' => $args_hash, - 'success' => ! empty( $payload['success'] ), - 'result' => isset( $payload['result'] ) ? $payload['result'] : null, - 'error' => isset( $payload['error'] ) ? $payload['error'] : null, - ], - ] + $this->agent_event_helper->emit_tool_call( + $tool_name, + $ability_name, + $requesting_user_id, + $execution_user_id, + $status, + $args_hash, + $payload, + $event_context ); } } diff --git a/includes/helpers/class-agent-event-helper.php b/includes/helpers/class-agent-event-helper.php new file mode 100644 index 0000000..86f260b --- /dev/null +++ b/includes/helpers/class-agent-event-helper.php @@ -0,0 +1,225 @@ +store = Agent_Event_DB_Store::get_instance(); + } + + /** + * Get singleton instance. + */ + public static function get_instance(): self { + if ( null === self::$instance ) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * Emit one append-only event. + * + * @param string $event_type Event type name. + * @param array $args Optional event payload. + */ + public function emit( string $event_type, array $args = [] ): int { + $normalized_event_type = $this->sanitize_event_type( $event_type ); + if ( '' === $normalized_event_type ) { + return 0; + } + + $payload = isset( $args['payload'] ) && is_array( $args['payload'] ) + ? $args['payload'] + : []; + $encoded_payload = null; + + if ( [] !== $payload ) { + $payload_json = wp_json_encode( $payload ); + if ( false !== $payload_json ) { + $encoded_payload = $payload_json; + } + } + + $run_id = isset( $args['run_id'] ) ? (int) $args['run_id'] : 0; + $session_id = isset( $args['session_id'] ) ? (int) $args['session_id'] : 0; + + return $this->store->insert_event( + [ + 'run_id' => $run_id > 0 ? $run_id : null, + 'session_id' => $session_id > 0 ? $session_id : null, + 'event_type' => $normalized_event_type, + 'payload_json' => $encoded_payload, + 'created_at_gmt' => isset( $args['created_at_gmt'] ) ? (string) $args['created_at_gmt'] : gmdate( 'Y-m-d H:i:s' ), + ] + ); + } + + /** + * Emit a standardized tool-call event. + * + * @param string $tool_name Tool name. + * @param string $ability_name Ability name. + * @param int $requesting_user_id Requesting user ID. + * @param int $execution_user_id Execution user ID. + * @param string $status Tool execution status. + * @param string $args_hash Hash of tool arguments. + * @param array $payload Tool result payload. + * @param array $context Optional event context. + */ + public function emit_tool_call( + string $tool_name, + string $ability_name, + int $requesting_user_id, + int $execution_user_id, + string $status, + string $args_hash, + array $payload, + array $context = [] + ): int { + return $this->emit( + 'tool_call', + [ + 'run_id' => isset( $context['run_id'] ) ? (int) $context['run_id'] : 0, + 'session_id' => isset( $context['session_id'] ) ? (int) $context['session_id'] : 0, + 'payload' => [ + 'tool_name' => $tool_name, + 'ability_name' => $ability_name, + 'status' => strtolower( trim( sanitize_text_field( $status ) ) ), + 'args_hash' => $args_hash, + 'success' => ! empty( $payload['success'] ), + 'result' => $payload['result'] ?? null, + 'error' => $payload['error'] ?? null, + 'requesting_user_id' => $requesting_user_id > 0 ? $requesting_user_id : null, + 'execution_user_id' => $execution_user_id > 0 ? $execution_user_id : null, + ], + ] + ); + } + + /** + * Get run-scoped incremental events. + * + * @param int $run_id Run identifier. + * @param int $after_event_id Cursor of last delivered event. + * @param int $limit Maximum rows to return. + * @return array> + */ + public function get_run_events( int $run_id, int $after_event_id = 0, int $limit = 100 ): array { + if ( $run_id <= 0 ) { + return []; + } + + return $this->normalize_event_rows( + $this->store->get_events( + [ + 'run_id' => $run_id, + 'after_event_id' => $after_event_id, + 'limit' => $limit, + ] + ) + ); + } + + /** + * Get session-scoped incremental events. + * + * @param int $session_id Session identifier. + * @param int $after_event_id Cursor of last delivered event. + * @param int $limit Maximum rows to return. + * @return array> + */ + public function get_session_events( int $session_id, int $after_event_id = 0, int $limit = 100 ): array { + if ( $session_id <= 0 ) { + return []; + } + + return $this->normalize_event_rows( + $this->store->get_events( + [ + 'session_id' => $session_id, + 'after_event_id' => $after_event_id, + 'limit' => $limit, + ] + ) + ); + } + + /** + * Normalize one event row. + * + * @param array $row DB row. + * @return array + */ + private function normalize_event_row( array $row ): array { + $payload = []; + if ( isset( $row['payload_json'] ) && is_string( $row['payload_json'] ) && '' !== trim( $row['payload_json'] ) ) { + $decoded = json_decode( $row['payload_json'], true ); + if ( is_array( $decoded ) ) { + $payload = $decoded; + } + } + + return [ + 'event_id' => isset( $row['id'] ) ? (int) $row['id'] : 0, + 'run_id' => isset( $row['run_id'] ) && null !== $row['run_id'] ? (int) $row['run_id'] : null, + 'session_id' => isset( $row['session_id'] ) && null !== $row['session_id'] ? (int) $row['session_id'] : null, + 'event_type' => isset( $row['event_type'] ) ? (string) $row['event_type'] : 'event', + 'payload' => $payload, + 'created_at_gmt' => isset( $row['created_at_gmt'] ) ? (string) $row['created_at_gmt'] : '', + ]; + } + + /** + * Normalize DB rows into API-facing event payloads. + * + * @param array> $rows Raw DB rows. + * @return array> + */ + private function normalize_event_rows( array $rows ): array { + return array_values( + array_map( [ $this, 'normalize_event_row' ], $rows ) + ); + } + + /** + * Normalize event type. + * + * @param string $event_type Raw event type. + */ + private function sanitize_event_type( string $event_type ): string { + $event_type = strtolower( trim( sanitize_text_field( $event_type ) ) ); + $event_type = preg_replace( '/[^a-z0-9._:-]/', '', $event_type ); + return '' !== (string) $event_type ? (string) $event_type : ''; + } +} diff --git a/includes/stores/class-agent-event-store.php b/includes/stores/class-agent-event-store.php new file mode 100644 index 0000000..79055fb --- /dev/null +++ b/includes/stores/class-agent-event-store.php @@ -0,0 +1,196 @@ +is_wpdb_ready( $wpdb ) ) { + return self::TABLE_SUFFIX; + } + + return $wpdb->prefix . self::TABLE_SUFFIX; + } + + /** + * Create/update event table schema. + */ + public function create_table(): bool { + global $wpdb; + + if ( ! $this->is_wpdb_ready( $wpdb ) ) { + return false; + } + + $charset_collate = method_exists( $wpdb, 'get_charset_collate' ) + ? (string) $wpdb->get_charset_collate() + : ''; + + $sql = "CREATE TABLE {$this->get_table_name()} ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + run_id bigint(20) unsigned NULL, + session_id bigint(20) unsigned NULL, + event_type varchar(64) NOT NULL, + payload_json longtext NULL, + created_at_gmt datetime NOT NULL, + PRIMARY KEY (id), + KEY run_id_id (run_id, id), + KEY session_id_id (session_id, id), + KEY event_type (event_type), + KEY created_at_gmt (created_at_gmt) + ) {$charset_collate};"; + + if ( ! function_exists( 'dbDelta' ) ) { + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + } + + if ( ! function_exists( 'dbDelta' ) ) { + return false; + } + + dbDelta( $sql ); + return true; + } + + /** + * Insert one event row. + * + * @param array $data Event payload. + */ + public function insert_event( array $data ): int { + global $wpdb; + + if ( ! $this->is_wpdb_ready( $wpdb ) || ! method_exists( $wpdb, 'insert' ) ) { + return 0; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- centralized append-only event insert. + $inserted = $wpdb->insert( + $this->get_table_name(), + [ + 'run_id' => $data['run_id'] ?? null, + 'session_id' => $data['session_id'] ?? null, + 'event_type' => isset( $data['event_type'] ) ? (string) $data['event_type'] : 'event', + 'payload_json' => $data['payload_json'] ?? null, + 'created_at_gmt' => isset( $data['created_at_gmt'] ) ? (string) $data['created_at_gmt'] : gmdate( 'Y-m-d H:i:s' ), + ], + [ '%d', '%d', '%s', '%s', '%s' ] + ); + + if ( false === $inserted || ! isset( $wpdb->insert_id ) ) { + return 0; + } + + return (int) $wpdb->insert_id; + } + + /** + * Fetch event rows incrementally. + * + * @param array $args Query filters. + * @return array> + */ + public function get_events( array $args = [] ): array { + global $wpdb; + + if ( ! $this->is_wpdb_ready( $wpdb ) || ! method_exists( $wpdb, 'prepare' ) || ! method_exists( $wpdb, 'get_results' ) ) { + return []; + } + + $limit = isset( $args['limit'] ) ? (int) $args['limit'] : 100; + $limit = $limit > 0 ? min( $limit, 500 ) : 100; + $after = isset( $args['after_event_id'] ) ? (int) $args['after_event_id'] : 0; + $after = $after > 0 ? $after : 0; + + $where_clauses = [ 'id > %d' ]; + $where_values = [ $after ]; + + if ( isset( $args['run_id'] ) && (int) $args['run_id'] > 0 ) { + $where_clauses[] = 'run_id = %d'; + $where_values[] = (int) $args['run_id']; + } + + if ( isset( $args['session_id'] ) && (int) $args['session_id'] > 0 ) { + $where_clauses[] = 'session_id = %d'; + $where_values[] = (int) $args['session_id']; + } + + if ( isset( $args['event_type'] ) && '' !== (string) $args['event_type'] ) { + $where_clauses[] = 'event_type = %s'; + $where_values[] = (string) $args['event_type']; + } + + $where_values[] = $limit; + $where_sql = 'WHERE ' . implode( ' AND ', $where_clauses ); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is fixed plugin-owned identifier. + $query = "SELECT id, run_id, session_id, event_type, payload_json, created_at_gmt + FROM {$this->get_table_name()} + {$where_sql} + ORDER BY id ASC + LIMIT %d"; + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- query is prepared via `$wpdb->prepare()` on this line. + $prepared_query = $wpdb->prepare( $query, $where_values ); + if ( ! is_string( $prepared_query ) || '' === $prepared_query ) { + return []; + } + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded incremental event read. + $rows = $wpdb->get_results( $prepared_query, 'ARRAY_A' ); + return is_array( $rows ) ? array_values( $rows ) : []; + } + + /** + * Check whether a usable `$wpdb` object is present. + * + * @param mixed $wpdb Candidate wpdb object. + */ + private function is_wpdb_ready( $wpdb ): bool { + return is_object( $wpdb ) && isset( $wpdb->prefix ); + } +} diff --git a/tests/Unit/AbilitiesHelperTest.php b/tests/Unit/AbilitiesHelperTest.php index 1ef0a45..3842f89 100644 --- a/tests/Unit/AbilitiesHelperTest.php +++ b/tests/Unit/AbilitiesHelperTest.php @@ -15,7 +15,7 @@ use WordPress\AiClient\Tools\DTO\FunctionDeclaration; /** - * Minimal wpdb stub for abilities helper action-log writes. + * Minimal wpdb stub for abilities helper agent-event writes. */ final class AbilitiesHelperTestWpdb { /** @@ -101,9 +101,12 @@ public function test_tool_execution_logs_requesting_and_execution_actors(): void $this->assertTrue( $result['success'] ); $this->assertNotEmpty( $GLOBALS['wpdb']->insert_calls ); - $this->assertSame( 12, $GLOBALS['wpdb']->insert_calls[0]['data']['requesting_user_id'] ); - $this->assertSame( 9, $GLOBALS['wpdb']->insert_calls[0]['data']['execution_user_id'] ); + $this->assertSame( 'wp_clawpress_agent_events', $GLOBALS['wpdb']->insert_calls[0]['table'] ); $this->assertSame( 'tool_call', $GLOBALS['wpdb']->insert_calls[0]['data']['event_type'] ); + $payload = json_decode( (string) $GLOBALS['wpdb']->insert_calls[0]['data']['payload_json'], true ); + $this->assertIsArray( $payload ); + $this->assertSame( 12, $payload['requesting_user_id'] ); + $this->assertSame( 9, $payload['execution_user_id'] ); } public function test_destructive_confirmation_token_must_be_allowlisted_by_execution_context(): void { diff --git a/tests/Unit/AgentEventHelperTest.php b/tests/Unit/AgentEventHelperTest.php new file mode 100644 index 0000000..ac81c71 --- /dev/null +++ b/tests/Unit/AgentEventHelperTest.php @@ -0,0 +1,216 @@ + + */ + function dbDelta( string $queries ): array { + if ( ! isset( $GLOBALS['clawpress_test_dbdelta_queries'] ) || ! is_array( $GLOBALS['clawpress_test_dbdelta_queries'] ) ) { + $GLOBALS['clawpress_test_dbdelta_queries'] = []; + } + + $GLOBALS['clawpress_test_dbdelta_queries'][] = $queries; + return []; + } + } +} + +namespace ClawPress\Tests\Unit { + +use ClawPress\Helpers\Agent_Event_Helper; +use ClawPress\Plugin; +use ClawPress\Stores\Agent_Event_Store; +use ClawPress\Tests\Support\TestCase; + +/** + * Minimal wpdb stub for agent event helper tests. + */ +final class AgentEventHelperTestWpdb { + /** + * Table prefix. + * + * @var string + */ + public string $prefix = 'wp_'; + + /** + * Captured insert calls. + * + * @var array> + */ + public array $insert_calls = []; + + /** + * Captured prepared args. + * + * @var array + */ + public array $last_prepare_args = []; + + /** + * Prepared query result set. + * + * @var array> + */ + public array $results = []; + + /** + * Insert id stub. + */ + public int $insert_id = 0; + + /** + * Get charset/collation SQL. + */ + public function get_charset_collate(): string { + return 'DEFAULT CHARSET=utf8mb4'; + } + + /** + * Capture insert operation. + * + * @param string $table Table name. + * @param array $data Insert row. + * @param array $format Insert formats. + */ + public function insert( string $table, array $data, array $format ) { + $this->insert_id++; + $this->insert_calls[] = [ + 'table' => $table, + 'data' => $data, + 'format' => $format, + ]; + + return 1; + } + + /** + * Capture prepared SQL call. + * + * @param string $query Query string. + * @param array|mixed ...$args Prepare args. + */ + public function prepare( string $query, ...$args ): string { + if ( 1 === count( $args ) && is_array( $args[0] ) ) { + $args = $args[0]; + } + + $this->last_prepare_args = $args; + return $query; + } + + /** + * Return prepared query rows. + * + * @param string $query Query string. + * @param string $output Output mode. + * @return array> + */ + public function get_results( string $query, string $output ): array { + unset( $query, $output ); + return $this->results; + } +} + +final class AgentEventHelperTest extends TestCase { + protected function setUp(): void { + parent::setUp(); + $GLOBALS['clawpress_test_dbdelta_queries'] = []; + $GLOBALS['wpdb'] = new AgentEventHelperTestWpdb(); + } + + protected function tearDown(): void { + unset( $GLOBALS['wpdb'], $GLOBALS['clawpress_test_dbdelta_queries'] ); + parent::tearDown(); + } + + public function test_store_create_table_registers_clawpress_agent_events_schema(): void { + $result = Agent_Event_Store::get_instance()->create_table(); + + $this->assertTrue( $result ); + $this->assertIsArray( $GLOBALS['clawpress_test_dbdelta_queries'] ); + $this->assertNotEmpty( $GLOBALS['clawpress_test_dbdelta_queries'] ); + $this->assertStringContainsString( 'clawpress_agent_events', (string) $GLOBALS['clawpress_test_dbdelta_queries'][0] ); + $this->assertStringContainsString( 'payload_json', (string) $GLOBALS['clawpress_test_dbdelta_queries'][0] ); + } + + public function test_helper_does_not_expose_schema_methods(): void { + $this->assertFalse( method_exists( Agent_Event_Helper::class, 'create_table' ) ); + $this->assertFalse( method_exists( Agent_Event_Helper::class, 'get_table_name' ) ); + } + + public function test_plugin_activation_creates_agent_event_table(): void { + Plugin::activate(); + + $this->assertNotEmpty( $GLOBALS['clawpress_test_dbdelta_queries'] ); + $all_queries = implode( "\n", array_map( 'strval', $GLOBALS['clawpress_test_dbdelta_queries'] ) ); + $this->assertStringContainsString( 'clawpress_agent_events', $all_queries ); + } + + public function test_emit_tool_call_persists_row_into_agent_event_table(): void { + $event_id = Agent_Event_Helper::get_instance()->emit_tool_call( + 'file_list', + 'clawpress/file-list', + 12, + 9, + 'success', + 'abc123', + [ + 'success' => true, + 'result' => [ 'items' => [ 'README.md' ] ], + ], + [ + 'run_id' => 77, + 'session_id' => 11, + ] + ); + + $this->assertSame( 1, $event_id ); + $this->assertCount( 1, $GLOBALS['wpdb']->insert_calls ); + $this->assertSame( 'wp_clawpress_agent_events', $GLOBALS['wpdb']->insert_calls[0]['table'] ); + $this->assertSame( 'tool_call', $GLOBALS['wpdb']->insert_calls[0]['data']['event_type'] ); + $this->assertSame( 77, $GLOBALS['wpdb']->insert_calls[0]['data']['run_id'] ); + $this->assertSame( 11, $GLOBALS['wpdb']->insert_calls[0]['data']['session_id'] ); + + $payload = json_decode( (string) $GLOBALS['wpdb']->insert_calls[0]['data']['payload_json'], true ); + $this->assertIsArray( $payload ); + $this->assertSame( 'file_list', $payload['tool_name'] ); + $this->assertSame( 'clawpress/file-list', $payload['ability_name'] ); + $this->assertSame( 12, $payload['requesting_user_id'] ); + $this->assertSame( 9, $payload['execution_user_id'] ); + } + + public function test_get_run_events_returns_normalized_incremental_rows(): void { + $GLOBALS['wpdb']->results = [ + [ + 'id' => '14', + 'run_id' => '77', + 'session_id' => '11', + 'event_type' => 'tool_call', + 'payload_json' => '{"tool_name":"file_list","success":true}', + 'created_at_gmt' => '2026-02-20 10:00:00', + ], + ]; + + $rows = Agent_Event_Helper::get_instance()->get_run_events( 77, 10, 25 ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 14, $rows[0]['event_id'] ); + $this->assertSame( 77, $rows[0]['run_id'] ); + $this->assertSame( 11, $rows[0]['session_id'] ); + $this->assertSame( true, $rows[0]['payload']['success'] ); + $this->assertSame( [ 10, 77, 25 ], $GLOBALS['wpdb']->last_prepare_args ); + } +} +} From 4d831feca801143493b038275254bc7d57c483b0 Mon Sep 17 00:00:00 2001 From: Brad Vincent Date: Mon, 23 Feb 2026 21:42:20 +0000 Subject: [PATCH 12/13] Consolidate agent loop spec and document helper/store boundaries --- docs/agent-loop-spec-edits.md | 270 ---------------------------------- docs/agent-loop-spec-final.md | 56 ++++--- docs/agent-loop-spec.md | 201 ------------------------- 3 files changed, 38 insertions(+), 489 deletions(-) delete mode 100644 docs/agent-loop-spec-edits.md delete mode 100644 docs/agent-loop-spec.md diff --git a/docs/agent-loop-spec-edits.md b/docs/agent-loop-spec-edits.md deleted file mode 100644 index b4771d9..0000000 --- a/docs/agent-loop-spec-edits.md +++ /dev/null @@ -1,270 +0,0 @@ -## Issue #12 Spec Update: Non-Streaming Now, Streaming Later (Transport-Agnostic + Time-Sliced Runner) - -### Background / Constraint (NEW) - -* Current LLM integration uses **WP AI Client**, which **does not support streaming yet**. -* Therefore, long-running “run-to-completion” turns inside a single web request are **not reliable** (timeouts / proxy buffering / request aborts). -* The agent loop must support: - - 1. **non-streaming execution** with **polling-based progress**, and - 2. an **upgrade path** to streaming **without rewriting** core loop logic. - -### Non-Negotiable Design Rule (NEW) - -> The **agent loop logic** must be deterministic and resumable **independent of delivery transport**. -> Streaming is an optimization layer (transport), not a control-plane rewrite. - ---- - -## Updated Goal - -Keep the original goal (“one core loop used by chat + heartbeat + spawning”). ([GitHub][1]) -**Add**: the loop must support **time-sliced execution** and **event persistence** so it can run safely under Action Scheduler with non-streaming providers. - ---- - -## Architecture Changes / Additions - -### 0) Introduce explicit layers (NEW) - -1. **Loop Engine** (pure logic; no DB; no HTTP; no Action Scheduler assumptions) -2. **Session Store** (DB-backed, already implied by issue) -3. **Runner** (Action Scheduler tick executor; uses store + lock) -4. **Transport** (how progress is delivered: polling now, streaming later) - -This clarifies “transport-agnostic” in a concrete way. - ---- - -### 1) Agent Loop Helper becomes a “Loop Engine” (UPDATED) - -Current proposal: `class-agent-loop-helper.php` extracted from `Chat_Helper`. ([GitHub][1]) -Adjust it to: - -#### A) Provide **step-based execution** (NEW) - -Instead of “run loop until done”, expose: - -* `run_turn(TurnRequest $req, AgentSession $session, LoopOptions $opts, AgentTransport $transport): TurnResult` -* `run_slice(RunSliceRequest $req): RunSliceResult` - - * Executes **bounded work** (one LLM call OR a limited batch of tool calls), then returns a resumable state marker. - -**Why:** enables safe background execution even when LLM calls/tool runs are slow. - -#### B) Make the engine emit events (NEW) - -The engine must emit structured events as it progresses (even non-streaming): - -* `agent_start`, `turn_start`, `message_start`, `message_end` -* `tool_execution_start`, `tool_execution_update`, `tool_execution_end` -* `turn_end`, `agent_end` -* optionally `message_update` (no-op for non-streaming today, real deltas later) - -These events go to the **Transport** layer. - ---- - -### 2) Expand TurnRequest / TurnResult contracts (UPDATED) - -Existing contracts are good. ([GitHub][1]) -Add the fields needed for slicing + resumability: - -#### `TurnRequest` additions - -* `run_id` (unique per attempt) -* `attempt` (int) -* `slice_budget_ms` (hard per-tick time budget; e.g., 2000–5000ms) -* `max_steps_per_slice` (hard cap; e.g., 1 LLM call or N tool calls) -* `transport_mode` (`polling` | `streaming`) -* `resume_cursor` (opaque engine cursor/state token; optional) - -#### `TurnResult` additions - -* `status` expands to include: - - * `success`, `requires_confirmation`, `error`, `timeout`, - * **`in_progress`** (paused due to slice budget) -* `next_action` expands to: - - * `continue_now` (enqueue next tick ASAP) - * `continue_later` (backoff/retry scheduling) - * `stop` -* `resume_cursor` (present when `status=in_progress`) -* `events_cursor` (cursor for UI polling; optional) - ---- - -### 3) Formalize Store Models: Thread vs Run vs Event (NEW) - -Issue already calls for thread/session state + locking. ([GitHub][1]) -Make it explicit: - -#### A) `agent_threads` (long-lived) - -* `thread_id` -* `status` (idle|running|paused|error|dead) -* `policy_profile` (by trigger) -* scheduling: `last_run_at`, `next_run_at` -* lock fields (or separate lock table; see below) - -#### B) `agent_runs` (per attempt) - -* `run_id`, `thread_id` -* `trigger` (chat|heartbeat|spawned_agent|...) -* `status` (queued|running|waiting_llm|waiting_tools|paused|done|error) -* `attempt`, `retry_at`, `error_code`, `error_message` -* `resume_cursor` (opaque engine cursor) -* usage totals (tokens/cost if available) - -#### C) `agent_events` (append-only log for polling + debugging) - -* `event_id` (monotonic) -* `run_id`, `thread_id` -* `type` -* `payload` (json) -* `created_at` - -**Polling UI reads events**: `GET /runs/{run_id}/events?after={event_id}` - ---- - -### 4) Transport abstraction (NEW) - -Add an internal interface: - -* `AgentTransport::emit(AgentEvent $event): void` -* `AgentTransport::close(): void` - -Implementations: - -1. **PollingTransport** (default today): writes events to `agent_events` table -2. **StreamingTransport** (future): emits SSE/websocket updates *and optionally also persists events* (debug mode) - -**Core loop engine must never care which transport is in use.** - ---- - -### 5) Runner: Action Scheduler ticks become first-class (UPDATED) - -Issue already proposes a heartbeat consumer that claims runnable threads and runs the loop helper. ([GitHub][1]) -Adjust so that the heartbeat worker executes **run slices**: - -#### Runner algorithm (per tick) - -1. Claim runnable `agent_threads` -2. Acquire lock/lease (existing requirement) -3. Load or create `agent_run` in `running` state -4. Execute **one slice**: - - * time budget enforcement - * either: perform the next LLM call OR next tool batch -5. Persist: - - * updated `resume_cursor` - * `agent_run.status` + `next_action` - * emitted events (via PollingTransport) -6. If `next_action=continue_now`, enqueue another AS action immediately -7. Release lock/lease - ---- - -## Lock/Lease Semantics (UPDATED) - -Issue already calls for lock/lease + stale recovery. ([GitHub][1]) -Clarify: - -* Lock covers a **single slice execution**, not “the entire multi-slice run”. -* Lease must be renewed each tick; stale lease recovery should requeue the run safely. -* Store must support idempotency: - - * if a tick repeats (duplicate AS run), it should detect already-advanced `resume_cursor`/status and no-op. - ---- - -## UI / API Implications (NEW) - -Because WP AI Client is non-streaming today, “real time” must be achieved via polling: - -### Endpoints (internal or REST) - -* `POST /agent/runs` (chat/spawn) -> returns `run_id` -* `POST /agent/runs/{run_id}/enqueue` (optional) -> enqueue tick -* `GET /agent/runs/{run_id}` -> status + summary -* `GET /agent/runs/{run_id}/events?after=...` -> incremental event feed - -Chat can remain synchronous for small turns, but must have a fallback: - -* if request budget exceeded, return `{ run_id, status: in_progress }` and client switches to polling. - ---- - -## Streaming Upgrade Path (NEW) - -When WP AI Client supports streaming: - -* Implement `StreamingTransport` that emits `message_update` events live. -* Update the LLM adapter to emit deltas to the transport. -* **No change** to: - - * Run/session store schemas - * Lock/lease mechanism - * Tool execution logic - * State machine - * Runner (still valid; streaming can be used for UI only) - -Optional: allow a “streaming-only” immediate path for chat if hosting supports it, but do not remove the slice runner. - ---- - -## Updated Implementation Phases - -### Phase 1: Extract Loop Engine + Events (behavior-preserving) - -* Extract core loop out of `Chat_Helper` into Loop Engine -* Introduce `AgentTransport` and implement `PollingTransport` -* Emit events, even if chat endpoint doesn’t use them yet - -### Phase 2: Session Store + Run/Event tables + Lock manager - -* Implement DB-backed thread/run/event storage -* Implement lock/lease with stale recovery + idempotency keys -* Wire minimal admin debugging (at least inspect by run_id) - -### Phase 3: Runner (Action Scheduler) with slice execution - -* Implement slice/tick runner -* Enqueue follow-up ticks until run completes -* Ensure time budget enforcement + retry/backoff - -### Phase 4: Spawn adapter + hardening - -* Spawn endpoint creates thread + run and enqueues tick -* Dead-letter state after N failures -* Policy profiles by trigger (chat vs heartbeat vs spawned) - -### Phase 5: Streaming transport (future) - -* Add `StreamingTransport` + LLM delta emission when WP AI Client supports it -* Keep polling mode as config fallback - ---- - -## Acceptance Criteria (UPDATED) - -Keep existing acceptance criteria, and add: - -* Engine supports **time-sliced** execution (`status=in_progress` + `resume_cursor`). -* Background runner can complete multi-step runs without a long-lived HTTP request. -* Event log exists and UI can poll incremental progress (minimum viable observability). -* Transport is configurable: `polling` now; `streaming` later, without loop rewrite. -* Lock/lease is safe across multiple slices and resilient to duplicate scheduler invocations. - ---- - -### Notes / Definitions - -* “Slice” = bounded unit of work (one LLM call OR bounded tool batch). -* “Transport” = how progress events are delivered (persisted polling vs streaming). - -[1]: https://github.com/bradvin/clawpress/issues/12 "Refactor to reusable Agent Loop Helper for chat + heartbeat + future spawning · Issue #12 · bradvin/clawpress · GitHub" diff --git a/docs/agent-loop-spec-final.md b/docs/agent-loop-spec-final.md index b75e5a3..51ac005 100644 --- a/docs/agent-loop-spec-final.md +++ b/docs/agent-loop-spec-final.md @@ -58,12 +58,20 @@ Build one reusable loop runtime that provides: ## Final Architecture ### 1) Explicit layers -Implement four clear layers: +Implement clear layers: 1. **Loop Engine**: pure execution logic; no DB, no HTTP, no scheduler coupling. -2. **Stores**: DB-backed persistence for threads/sessions/runs/events and locking metadata. -3. **Runner**: Action Scheduler tick executor that claims work and runs slices. -4. **Transport**: delivery channel for progress/events (`polling` now, `streaming` later). +2. **Stores**: DB-backed persistence for sessions/runs/events and locking metadata. + - session store: `ClawPress\Stores\Agent_Session_Store` + - run store: `ClawPress\Stores\Agent_Run_Store` + - event store: `ClawPress\Stores\Agent_Event_Store` for append-only run/session events +3. **Helpers**: orchestration-facing APIs over stores. + - session helper: `ClawPress\Helpers\Agent_Session_Helper` + - run helper: `ClawPress\Helpers\Agent_Run_Helper` + - event helper: `ClawPress\Helpers\Agent_Event_Helper` + - rule: loop/runner/transport/controllers call helpers; helpers call stores; stores own DB logic. +4. **Runner**: Action Scheduler tick executor that claims work and runs slices. +5. **Transport**: delivery channel for progress/events (`polling` now, `streaming` later). ### 2) Loop Engine responsibilities Extract loop responsibilities from `Chat_Helper` into `includes/helpers/class-agent-loop-helper.php` (name flexible): @@ -95,7 +103,7 @@ Define internal transport interface: Implementations: -- **PollingTransport** (default now): append events to persistent event store. +- **PollingTransport** (default now): append events via `ClawPress\Helpers\Agent_Event_Helper` backed by `ClawPress\Stores\Agent_Event_Store`. - **StreamingTransport** (future): emits deltas live; may still persist events for observability. The loop engine must not branch by transport-specific behavior. @@ -107,7 +115,7 @@ The loop engine must not branch by transport-specific behavior. ### TurnRequest Required/common fields: -- `thread_id` (or session id) +- `session_id` - `trigger` (`chat`, `heartbeat`, `spawned_agent`, ...) - `message` (optional for heartbeat-driven turns) - `requesting_user_id` @@ -154,15 +162,21 @@ Additions for resumability and UI polling: ## Persistence Model -### agent_threads (long-lived) -- `thread_id` +### agent_sessions (long-lived) +Backed by: `ClawPress\Stores\Agent_Session_Store` +Accessed via: `ClawPress\Helpers\Agent_Session_Helper` + +- `session_id` - `status` (`idle|running|paused|error|dead`) - policy profile - schedule fields (`last_run_at`, `next_run_at`) - lock/lease metadata (or lock table) ### agent_runs (per attempt) -- `run_id`, `thread_id` +Backed by: `ClawPress\Stores\Agent_Run_Store` +Accessed via: `ClawPress\Helpers\Agent_Run_Helper` + +- `run_id`, `session_id` - `trigger` - `status` (`queued|running|waiting_llm|waiting_tools|paused|done|error`) - `attempt`, retry/backoff fields @@ -170,8 +184,11 @@ Additions for resumability and UI polling: - usage totals and error classification ### agent_events (append-only) +Backed by: `ClawPress\Stores\Agent_Event_Store` +Accessed via: `ClawPress\Helpers\Agent_Event_Helper` + - monotonic `event_id` -- `run_id`, `thread_id` +- `run_id`, `session_id` - `type` - JSON `payload` - `created_at` @@ -184,7 +201,7 @@ UI polls event stream incrementally via cursor. Runner algorithm per tick: -1. Find and claim runnable threads/runs. +1. Find and claim runnable sessions/runs. 2. Acquire lock/lease. 3. Load or create run state. 4. Execute one bounded slice. @@ -218,10 +235,10 @@ Consume `clawpress_run_scheduled_tasks` and execute slices through runner. ### Spawn adapter Spawn endpoint should: -1. create thread/session, +1. create session, 2. seed initial context/message, 3. enqueue first run, -4. return thread/run identifiers. +4. return session/run identifiers. No loop internals in spawn endpoint. @@ -232,7 +249,7 @@ No loop internals in spawn endpoint. ### Policy by trigger - `chat`: interactive confirmation behavior. - `heartbeat` / `spawned`: destructive tools denied or queued by default. -- optional per-thread policy profiles. +- optional per-session policy profiles. ### Guardrails - max wall time per run/slice, @@ -246,7 +263,7 @@ No loop internals in spawn endpoint. ## Observability Log structured run data (reuse/extend action log + run/event records): -- `run_id`, `thread_id`, trigger, +- `run_id`, `session_id`, trigger, - tool trace + statuses, - provider/model and usage, - error type + retry count, @@ -274,7 +291,10 @@ Minimum endpoints: - Emit structured events. ### Phase 2: Persistence + lock manager -- Finalize thread/run/event persistence and lock/lease handling. +- Extend/finalize `Agent_Session_Helper` + `Agent_Session_Store` for full session requirements. +- Extend/finalize `Agent_Run_Helper` + `Agent_Run_Store` for full run requirements. +- Use `Agent_Event_Helper` + `Agent_Event_Store` for append-only run/session events. +- Finalize lock/lease handling across stores. - Ensure stale recovery and idempotency. ### Phase 3: Time-sliced runner @@ -299,7 +319,7 @@ Minimum endpoints: - Chat uses shared loop runtime (no duplicated loop logic). - Runner supports multi-slice execution with `in_progress` and `resume_cursor`. - Background runs complete safely without long-lived HTTP requests. -- Per-thread concurrency safety: no duplicate concurrent execution. +- Per-session concurrency safety: no duplicate concurrent execution. - Event persistence supports incremental UI polling. - Destructive tool behavior is explicitly policy-controlled by trigger. - Transport mode is pluggable (`polling` now, `streaming` later) without core loop rewrite. @@ -310,5 +330,5 @@ Minimum endpoints: - One agent brain across all transports/triggers. - Clean async/autonomous execution path. -- Safe path to spawned parallel threads. +- Safe path to spawned parallel sessions. - Lower long-term maintenance cost than duplicating chat logic in background adapters. diff --git a/docs/agent-loop-spec.md b/docs/agent-loop-spec.md deleted file mode 100644 index fae18cf..0000000 --- a/docs/agent-loop-spec.md +++ /dev/null @@ -1,201 +0,0 @@ -## Summary -Refactor current chat-bound agent execution into a reusable **Agent Loop Helper** (runtime service) that can be called from: - -1. current synchronous chat requests, -2. heartbeat/background jobs, -3. future agent spawning APIs. - -This should make agent execution transport-agnostic and enable true async/background runs without duplicating core loop logic. - ---- - -## Current State (as of now) - -### What exists -- Chat execution path currently drives model/tool loop: - - `includes/rest/class-chat-controller.php` → `Chat_Helper::generate_ai_reply()` - - Core loop currently in `includes/helpers/class-chat-helper.php` -- Heartbeat scheduler exists: - - `includes/class-heartbeat.php` - - Schedules `clawpress_heartbeat_tick` every 15 min using Action Scheduler - - Tick triggers: `do_action( 'clawpress_run_scheduled_tasks' )` - -### What is missing -- No consumer/handler currently attached to `clawpress_run_scheduled_tasks` in plugin code. -- No independent agent thread/session runtime (outside chat request path). -- No spawn manager / spawn endpoint that creates and runs separate agent threads. -- No async run coordinator (claim/lock/retry/failure lifecycle) for autonomous runs. - -Result: loop logic is effectively request-bound to chat right now. - ---- - -## Goal -Create a reusable **Agent Loop Helper** so one core loop can be used by multiple entry points (chat, heartbeat, spawning) with consistent behavior and policy. - ---- - -## Proposed Architecture - -### 1) Extract a core runtime service -Create e.g. `includes/helpers/class-agent-loop-helper.php` (name flexible) and move loop responsibilities out of `Chat_Helper`: - -- provider/model resolution -- context assembly + prompt prep -- model call -- tool-call loop (`MAX_TOOL_ROUNDS`, `MAX_TOOL_CALLS_PER_ROUND`) -- confirmation batching behavior -- context/token usage collection -- normalized result payload - -`Chat_Helper` should become an adapter that invokes this service. - ---- - -### 2) Define canonical request/response contracts -Introduce internal DTO-like arrays/classes: - -#### `TurnRequest` -- `thread_id` (or session id) -- `trigger` (`chat`, `heartbeat`, `spawned_agent`, etc.) -- `message` (optional for heartbeat-driven turns) -- `requesting_user_id` -- `execution_user_id` -- policy knobs: `allow_tools`, `require_confirmation`, limits/timeouts - -#### `TurnResult` -- `assistant_text` -- `tool_calls` trace -- `card` payload (optional) -- `status` (`success`, `requires_confirmation`, `error`, `timeout`) -- usage/context metadata -- optional `next_action` hint (`continue`, `stop`, `reschedule`) - -This contract is key to reusability across adapters. - ---- - -### 3) Separate state persistence from execution -Add explicit agent-thread/session state (rather than implicitly relying on chat history only). - -Minimum state needed: -- thread/session identity -- lifecycle status -- `last_run_at`, `next_run_at` -- lock/lease metadata (owner + expiry) -- failure/retry counters -- trigger metadata - -Storage can be CPT-based or custom table (table likely better for concurrency/locking). - ---- - -### 4) Add background runner adapter (heartbeat path) -Implement a consumer for `clawpress_run_scheduled_tasks` that: - -1. finds/claims runnable agent threads -2. acquires lock/lease -3. builds `TurnRequest` -4. calls Agent Loop Helper -5. persists run outputs/logs/state -6. schedules follow-up if required -7. releases lock - -This enables async operation without rewriting loop logic. - ---- - -### 5) Keep chat path synchronous but thin -`Chat_Controller` flow should be: -1. persist inbound user message -2. call Agent Loop Helper synchronously -3. persist assistant response/meta -4. return response - -No duplicated loop logic in chat layer. - ---- - -### 6) Add spawn entry point later as another adapter -Future spawn endpoint should: -- create a new thread/session record, -- seed initial context/message, -- enqueue first run via Action Scheduler, -- return spawned thread id. - -Spawn endpoint should not contain loop internals. - ---- - -## Policy & Safety Considerations - -### Confirmation/destructive tools by trigger -Define policy by trigger source: -- `chat`: current confirmation behavior acceptable -- `heartbeat` / `spawned`: likely deny or queue destructive calls by default -- optionally allow policy profiles per agent/thread - -### Runtime guardrails -- max wall time per run -- max tool calls per run -- bounded retries + exponential backoff -- dead-letter/failure terminal state after N failures -- idempotency key per run attempt - -### Concurrency controls -- at-most-one active run per thread/session (lock/lease) -- stale lock recovery -- avoid duplicate processing by concurrent scheduler invocations - ---- - -## Observability / Debuggability -Add structured run logging (reuse/extend action log): -- `run_id`, `thread_id`, trigger source -- tool trace + per-call status -- provider/model + token/context usage -- error classification + retry count -- final run outcome - -Without this, async failures will be hard to diagnose. - ---- - -## Suggested Implementation Phases - -### Phase 1: Internal refactor (no behavior change) -- Introduce Agent Loop Helper -- Move loop logic from `Chat_Helper` into helper -- Keep current chat behavior identical - -### Phase 2: Background execution wiring -- Implement `clawpress_run_scheduled_tasks` consumer -- Add minimal thread/run state + locking -- Run one background thread safely - -### Phase 3: Spawn support -- Add spawn API/service -- Create separate threads and schedule independent runs - -### Phase 4: Hardening -- retries/backoff/dead-letter -- policy profiles by trigger -- richer observability and admin inspection UI - ---- - -## Acceptance Criteria -- Chat uses Agent Loop Helper (no duplicated loop logic in chat layer). -- Heartbeat can run at least one agent thread asynchronously. -- Background runs are lock-safe (no duplicate concurrent turn execution per thread). -- Destructive tool behavior is explicitly policy-controlled per trigger. -- Run status/errors are inspectable via logs. - ---- - -## Why this is worth doing -This makes ClawPress extensible: -- one “agent brain,” many transports/triggers, -- clean path to autonomous agents, -- clean path to true spawned parallel threads, -- less tech debt than cloning chat logic into heartbeat/spawn flows. From 769847ce03521d4d30961def6e20bb08e574962a Mon Sep 17 00:00:00 2001 From: Brad Vincent Date: Mon, 23 Feb 2026 21:57:40 +0000 Subject: [PATCH 13/13] Resolve abilities merge and clean helper/store lint issues --- includes/helpers/class-abilities-helper.php | 4 +-- includes/helpers/class-action-log-helper.php | 2 ++ includes/helpers/class-agent-event-helper.php | 10 ++++--- includes/helpers/class-agent-run-helper.php | 21 ++++++------- .../helpers/class-agent-session-helper.php | 8 +++-- includes/stores/class-agent-run-store.php | 20 ++++++++----- includes/stores/class-agent-session-store.php | 30 +++++++++++-------- 7 files changed, 55 insertions(+), 40 deletions(-) diff --git a/includes/helpers/class-abilities-helper.php b/includes/helpers/class-abilities-helper.php index 0eae032..47452b2 100644 --- a/includes/helpers/class-abilities-helper.php +++ b/includes/helpers/class-abilities-helper.php @@ -216,10 +216,10 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e isset( $execution_context['session_metadata'] ) && is_array( $execution_context['session_metadata'] ) ? $execution_context['session_metadata'] : [], - isset( $execution_context['policy_overrides'] ) && is_array( $execution_context['policy_overrides'] ) + isset( $execution_context['policy_overrides'] ) && is_array( $execution_context['policy_overrides'] ) ? $execution_context['policy_overrides'] : [] - ); + ); $args_json = wp_json_encode( $args ); $args_hash = false !== $args_json ? hash( 'sha256', (string) $args_json ) : ''; diff --git a/includes/helpers/class-action-log-helper.php b/includes/helpers/class-action-log-helper.php index 83f23a1..c10c27f 100644 --- a/includes/helpers/class-action-log-helper.php +++ b/includes/helpers/class-action-log-helper.php @@ -33,6 +33,8 @@ final class Action_Log_Helper { /** * Store instance for DB access. + * + * @var Action_Log_Store */ private Action_Log_Store $store; diff --git a/includes/helpers/class-agent-event-helper.php b/includes/helpers/class-agent-event-helper.php index 86f260b..dd2cae4 100644 --- a/includes/helpers/class-agent-event-helper.php +++ b/includes/helpers/class-agent-event-helper.php @@ -9,7 +9,7 @@ namespace ClawPress\Helpers; -use ClawPress\Stores\Agent_Event_Store as Agent_Event_DB_Store; +use ClawPress\Stores\Agent_Event_Store; defined( 'ABSPATH' ) || exit; @@ -26,14 +26,16 @@ final class Agent_Event_Helper { /** * Event store instance for DB access. + * + * @var Agent_Event_Store */ - private Agent_Event_DB_Store $store; + private Agent_Event_Store $store; /** * Constructor. */ private function __construct() { - $this->store = Agent_Event_DB_Store::get_instance(); + $this->store = Agent_Event_Store::get_instance(); } /** @@ -59,7 +61,7 @@ public function emit( string $event_type, array $args = [] ): int { return 0; } - $payload = isset( $args['payload'] ) && is_array( $args['payload'] ) + $payload = isset( $args['payload'] ) && is_array( $args['payload'] ) ? $args['payload'] : []; $encoded_payload = null; diff --git a/includes/helpers/class-agent-run-helper.php b/includes/helpers/class-agent-run-helper.php index 317627a..cf74e32 100644 --- a/includes/helpers/class-agent-run-helper.php +++ b/includes/helpers/class-agent-run-helper.php @@ -9,7 +9,7 @@ namespace ClawPress\Helpers; -use ClawPress\Stores\Agent_Run_Store as Agent_Run_DB_Store; +use ClawPress\Stores\Agent_Run_Store; defined( 'ABSPATH' ) || exit; @@ -33,14 +33,16 @@ final class Agent_Run_Helper { /** * Run store instance for DB access. + * + * @var Agent_Run_Store */ - private Agent_Run_DB_Store $store; + private Agent_Run_Store $store; /** * Constructor. */ private function __construct() { - $this->store = Agent_Run_DB_Store::get_instance(); + $this->store = Agent_Run_Store::get_instance(); } /** @@ -174,12 +176,12 @@ public function complete_run( int $run_id, string $lock_token, string $status, a $run_id, $lock_token, [ - 'status' => $status, - 'finished_at_gmt' => gmdate( 'Y-m-d H:i:s' ), - 'error_code' => isset( $args['error_code'] ) ? (string) $args['error_code'] : null, - 'error_message' => isset( $args['error_message'] ) ? (string) $args['error_message'] : null, - 'meta_json' => $meta_json, - 'updated_at_gmt' => gmdate( 'Y-m-d H:i:s' ), + 'status' => $status, + 'finished_at_gmt' => gmdate( 'Y-m-d H:i:s' ), + 'error_code' => isset( $args['error_code'] ) ? (string) $args['error_code'] : null, + 'error_message' => isset( $args['error_message'] ) ? (string) $args['error_message'] : null, + 'meta_json' => $meta_json, + 'updated_at_gmt' => gmdate( 'Y-m-d H:i:s' ), ] ); @@ -231,5 +233,4 @@ private function generate_uuid(): string { substr( $seed, 20, 12 ) ); } - } diff --git a/includes/helpers/class-agent-session-helper.php b/includes/helpers/class-agent-session-helper.php index d6f2854..3946a2e 100644 --- a/includes/helpers/class-agent-session-helper.php +++ b/includes/helpers/class-agent-session-helper.php @@ -9,7 +9,7 @@ namespace ClawPress\Helpers; -use ClawPress\Stores\Agent_Session_Store as Agent_Session_DB_Store; +use ClawPress\Stores\Agent_Session_Store; defined( 'ABSPATH' ) || exit; @@ -26,14 +26,16 @@ final class Agent_Session_Helper { /** * Session store instance for DB access. + * + * @var Agent_Session_Store */ - private Agent_Session_DB_Store $store; + private Agent_Session_Store $store; /** * Constructor. */ private function __construct() { - $this->store = Agent_Session_DB_Store::get_instance(); + $this->store = Agent_Session_Store::get_instance(); } /** diff --git a/includes/stores/class-agent-run-store.php b/includes/stores/class-agent-run-store.php index 3400e53..36df87e 100644 --- a/includes/stores/class-agent-run-store.php +++ b/includes/stores/class-agent-run-store.php @@ -108,6 +108,10 @@ public function create_table(): bool { /** * Insert a queued run row. + * + * @param int $session_id Session identifier. + * @param string $run_uuid Run UUID. + * @param string $created_at_gmt Created-at timestamp (UTC). */ public function insert_run( int $session_id, string $run_uuid, string $created_at_gmt ): int { global $wpdb; @@ -136,11 +140,11 @@ public function insert_run( int $session_id, string $run_uuid, string $created_a /** * Compare-and-swap claim update for a run. * - * @param int $run_id Run identifier. - * @param string $current_status Expected status. - * @param string|null $current_lock_expires_at_gmt Expected lock expiry when reclaiming. - * @param array $data Update data. - * @param bool $is_stale Whether this claim is a stale reclaim. + * @param int $run_id Run identifier. + * @param string $current_status Expected status. + * @param string|null $current_lock_expires_at_gmt Expected lock expiry when reclaiming. + * @param array $data Update data. + * @param bool $is_stale Whether this claim is a stale reclaim. * @return int|false */ public function update_claim( @@ -260,7 +264,7 @@ public function begin_transaction(): bool { return false; } - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- bounded transaction control statement. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded transaction control statement. return false !== $wpdb->query( 'START TRANSACTION' ); } @@ -274,7 +278,7 @@ public function commit_transaction(): bool { return false; } - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- bounded transaction control statement. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded transaction control statement. return false !== $wpdb->query( 'COMMIT' ); } @@ -288,7 +292,7 @@ public function rollback_transaction(): void { return; } - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- bounded transaction control statement. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded transaction control statement. $wpdb->query( 'ROLLBACK' ); } } diff --git a/includes/stores/class-agent-session-store.php b/includes/stores/class-agent-session-store.php index 01f0957..12384dd 100644 --- a/includes/stores/class-agent-session-store.php +++ b/includes/stores/class-agent-session-store.php @@ -156,6 +156,11 @@ public function insert_session( array $data ): int { /** * Update parent session state after run completion. + * + * @param int $session_id Session identifier. + * @param string $run_status Terminal run status. + * @param string|null $next_run_at_gmt Optional next-run timestamp. + * @param string $updated_at_gmt Update timestamp (UTC). */ public function update_run_completion( int $session_id, string $run_status, ?string $next_run_at_gmt, string $updated_at_gmt ): bool { global $wpdb; @@ -165,20 +170,19 @@ public function update_run_completion( int $session_id, string $run_status, ?str } $table_name = $this->get_table_name(); - - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is fixed plugin-owned identifier. - $query = $wpdb->prepare( + $query = $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is fixed plugin-owned identifier. "UPDATE {$table_name} - SET - last_run_at_gmt = %s, - last_run_status = %s, - consecutive_failures = CASE - WHEN %s = 'success' THEN 0 - ELSE consecutive_failures + 1 - END, - next_run_at_gmt = %s, - updated_at_gmt = %s - WHERE id = %d", + SET + last_run_at_gmt = %s, + last_run_status = %s, + consecutive_failures = CASE + WHEN %s = 'success' THEN 0 + ELSE consecutive_failures + 1 + END, + next_run_at_gmt = %s, + updated_at_gmt = %s + WHERE id = %d", $updated_at_gmt, $run_status, $run_status,