database = $database; $this->state_machine = $state_machine; $this->table = Schema::tables( $database )['runs']; } public function create( string $profile, string $language, string $registry_version, Budget $budget, int $actor_id = 0 ): RunRecord { $this->assert_profile( $profile ); $this->assert_language( $language ); $this->assert_actor( $actor_id ); if ( 1 !== preg_match( '/^[0-9]+\.[0-9]+\.[0-9]+$/', $registry_version ) ) { throw new InvalidArgumentException( 'invalid_registry_version' ); } $uuid = wp_generate_uuid4(); $now = current_time( 'mysql', true ); $events = array( $this->event( 'run_created', RunStatus::DRAFT, $actor_id, $now ) ); $inserted = $this->database->insert( $this->table, array( 'run_uuid' => $uuid, 'status' => RunStatus::DRAFT, 'profile' => $profile, 'language' => $language, 'registry_version' => $registry_version, 'budgets' => $this->encode( $budget->to_array() ), 'coverage' => $this->encode( array( 'state' => 'none', 'observed' => 0, 'total' => 0, ) ), 'event_log' => $this->encode( $events ), 'created_at' => $now, 'updated_at' => $now, ), array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ) ); if ( false === $inserted ) { throw new StorageException( 'run_create_failed' ); } return new RunRecord( (int) $this->database->insert_id, $uuid, RunStatus::DRAFT, $profile, $language ); } public function get_by_uuid( string $uuid ): ?RunRecord { $row = $this->fetch_row( $uuid ); return null === $row ? null : $this->hydrate( $row ); } /** * @return array{uuid:string,status:string,profile:string,language:string,registry_version:string,coverage:array{state:string,observed:int,total:int},created_at:string,updated_at:string,completed_at:string|null}|null */ public function summary( string $uuid ): ?array { if ( ! wp_is_uuid( $uuid, 4 ) ) { return null; } // Table name is selected from the compiled Schema allowlist. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $row = $this->database->get_row( $this->database->prepare( "SELECT run_uuid, status, profile, language, registry_version, coverage, created_at, updated_at, completed_at FROM {$this->table} WHERE run_uuid = %s LIMIT 1", $uuid ), ARRAY_A ); return is_array( $row ) ? $this->summary_from_row( $row ) : null; } /** * @return array{uuid:string,status:string,profile:string,language:string,registry_version:string,coverage:array{state:string,observed:int,total:int},created_at:string,updated_at:string,completed_at:string|null}|null */ public function latest_summary(): ?array { // Table name is selected from the compiled Schema allowlist. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $row = $this->database->get_row( "SELECT run_uuid, status, profile, language, registry_version, coverage, created_at, updated_at, completed_at FROM {$this->table} ORDER BY id DESC LIMIT 1", ARRAY_A ); return is_array( $row ) ? $this->summary_from_row( $row ) : null; } /** * Find only the immediately preceding completed run of the same profile. * * @return array{id:int,completed_at:string}|null */ public function previous_completed( RunRecord $run ): ?array { if ( $run->id() < 1 ) { throw new InvalidArgumentException( 'invalid_run_id' ); } $this->assert_profile( $run->profile() ); // Table name is selected from the compiled Schema allowlist and values are parameterized. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $row = $this->database->get_row( $this->database->prepare( "SELECT id, completed_at FROM {$this->table} WHERE id < %d AND profile = %s AND status = %s AND completed_at IS NOT NULL ORDER BY id DESC LIMIT 1", $run->id(), $run->profile(), RunStatus::COMPLETED ), ARRAY_A ); if ( ! is_array( $row ) ) { return null; } if ( ! isset( $row['id'], $row['completed_at'] ) || (int) $row['id'] < 1 || ! is_string( $row['completed_at'] ) || 1 !== preg_match( '/^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$/D', $row['completed_at'] ) ) { throw new StorageException( 'invalid_previous_run' ); } return array( 'id' => (int) $row['id'], 'completed_at' => $row['completed_at'], ); } public function transition( string $uuid, string $target, int $actor_id = 0 ): RunRecord { $this->assert_actor( $actor_id ); $row = $this->fetch_row( $uuid ); if ( null === $row ) { throw new StorageException( 'run_not_found' ); } $run = $this->hydrate( $row ); $next = $this->state_machine->transition( $run->status(), $target ); if ( $next === $run->status() ) { return $run; } $now = current_time( 'mysql', true ); $events = $this->decode_events( (string) $row['event_log'] ); $events[] = $this->event( $this->transition_event( $run->status(), $next ), $next, $actor_id, $now ); $events = array_slice( $events, -self::EVENT_LIMIT ); $values = array( 'status' => $next, 'event_log' => $this->encode( $events ), 'updated_at' => $now, ); $formats = array( '%s', '%s', '%s' ); if ( RunStatus::COMPLETED === $next ) { $values['completed_at'] = $now; $formats[] = '%s'; } $updated = $this->database->update( $this->table, $values, array( 'run_uuid' => $uuid, 'status' => $run->status(), ), $formats, array( '%s', '%s' ) ); if ( 1 !== $updated ) { throw new StorageException( 'run_transition_conflict' ); } return new RunRecord( $run->id(), $uuid, $next, $run->profile(), $run->language() ); } /** * @return list */ public function events( string $uuid ): array { $row = $this->fetch_row( $uuid ); if ( null === $row ) { throw new StorageException( 'run_not_found' ); } return $this->decode_events( (string) $row['event_log'] ); } public function record_same_site_disclosure( string $uuid, int $actor_id ): void { $this->record_confirmation_event( $uuid, $actor_id, 'same_site_disclosure_confirmed' ); } public function record_vendor_selection_confirmation( string $uuid, int $actor_id ): void { $this->record_confirmation_event( $uuid, $actor_id, 'vendor_selection_confirmed' ); } public function record_rendered_disclosure( string $uuid, int $actor_id ): void { $this->record_confirmation_event( $uuid, $actor_id, 'rendered_disclosure_confirmed' ); } private function record_confirmation_event( string $uuid, int $actor_id, string $action ): void { $this->assert_actor( $actor_id ); if ( ! in_array( $action, array( 'same_site_disclosure_confirmed', 'vendor_selection_confirmed', 'rendered_disclosure_confirmed' ), true ) ) { throw new InvalidArgumentException( 'invalid_confirmation_event' ); } $row = $this->fetch_row( $uuid ); if ( null === $row ) { throw new StorageException( 'run_not_found' ); } $now = current_time( 'mysql', true ); $events = $this->decode_events( (string) $row['event_log'] ); $events[] = $this->event( $action, (string) $row['status'], $actor_id, $now ); $events = array_slice( $events, -self::EVENT_LIMIT ); $updated = $this->database->update( $this->table, array( 'event_log' => $this->encode( $events ), 'updated_at' => $now, ), array( 'run_uuid' => $uuid, 'event_log' => (string) $row['event_log'], ), array( '%s', '%s' ), array( '%s', '%s' ) ); if ( 1 !== $updated ) { throw new StorageException( 'run_event_conflict' ); } } public function update_coverage( int $run_id, int $observed, int $total ): void { if ( $run_id < 1 || $observed < 0 || $total < 0 || $observed > $total ) { throw new InvalidArgumentException( 'invalid_coverage' ); } $state = 0 === $observed ? 'none' : ( $observed === $total ? 'complete' : 'partial' ); $result = $this->database->update( $this->table, array( 'coverage' => $this->encode( array( 'state' => $state, 'observed' => $observed, 'total' => $total, ) ), 'updated_at' => current_time( 'mysql', true ), ), array( 'id' => $run_id ), array( '%s', '%s' ), array( '%d' ) ); if ( false === $result ) { throw new StorageException( 'coverage_update_failed' ); } } /** * Compare-and-swap cumulative run budget consumption. * * @param array{milliseconds:int,items:int,requests:int,bytes:int} $usage Bounded chunk usage. */ public function consume_budget( int $run_id, array $usage ): Budget { if ( $run_id < 1 ) { throw new InvalidArgumentException( 'invalid_run_id' ); } // Table name is selected from the compiled Schema allowlist. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $current_json = $this->database->get_var( $this->database->prepare( "SELECT budgets FROM {$this->table} WHERE id = %d LIMIT 1", $run_id ) ); if ( ! is_string( $current_json ) ) { throw new StorageException( 'run_not_found' ); } $budget = $this->decode_budget( $current_json ); foreach ( array( 'milliseconds', 'items', 'requests', 'bytes' ) as $dimension ) { $budget = $budget->consume( $dimension, $usage[ $dimension ] ); } $next_json = $this->encode( $budget->to_array() ); // The previous JSON value provides an optimistic concurrency guard. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $updated = $this->database->query( $this->database->prepare( "UPDATE {$this->table} SET budgets = %s, updated_at = %s WHERE id = %d AND budgets = %s", $next_json, current_time( 'mysql', true ), $run_id, $current_json ) ); if ( 1 !== $updated ) { throw new StorageException( 'run_budget_conflict' ); } return $budget; } public function budget( int $run_id ): Budget { if ( $run_id < 1 ) { throw new InvalidArgumentException( 'invalid_run_id' ); } // Table name is selected from the compiled Schema allowlist. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $current_json = $this->database->get_var( $this->database->prepare( "SELECT budgets FROM {$this->table} WHERE id = %d LIMIT 1", $run_id ) ); if ( ! is_string( $current_json ) ) { throw new StorageException( 'run_not_found' ); } return $this->decode_budget( $current_json ); } private function decode_budget( string $json ): Budget { $current = json_decode( $json, true, 32 ); if ( ! is_array( $current ) || ! isset( $current['limits'], $current['used'] ) || ! is_array( $current['limits'] ) || ! is_array( $current['used'] ) ) { throw new StorageException( 'invalid_run_budget' ); } /** @var array $limits */ $limits = $current['limits']; /** @var array $used */ $used = $current['used']; return new Budget( $limits, $used ); } /** * @param array $row Database row. */ private function hydrate( array $row ): RunRecord { return new RunRecord( (int) $row['id'], (string) $row['run_uuid'], (string) $row['status'], (string) $row['profile'], (string) $row['language'] ); } /** * @param array $row Database row. * @return array{uuid:string,status:string,profile:string,language:string,registry_version:string,coverage:array{state:string,observed:int,total:int},created_at:string,updated_at:string,completed_at:string|null} */ private function summary_from_row( array $row ): array { $uuid = isset( $row['run_uuid'] ) ? (string) $row['run_uuid'] : ''; $status = isset( $row['status'] ) ? (string) $row['status'] : ''; $profile = isset( $row['profile'] ) ? (string) $row['profile'] : ''; $language = isset( $row['language'] ) ? (string) $row['language'] : ''; $registry = isset( $row['registry_version'] ) ? (string) $row['registry_version'] : ''; if ( ! wp_is_uuid( $uuid, 4 ) || ! in_array( $status, RunStatus::all(), true ) || 1 !== preg_match( '/^[0-9]+\.[0-9]+\.[0-9]+$/', $registry ) ) { throw new StorageException( 'invalid_run_summary' ); } try { $this->assert_profile( $profile ); $this->assert_language( $language ); } catch ( InvalidArgumentException $exception ) { // The previous exception is retained for local debugging, never rendered. // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped throw new StorageException( 'invalid_run_summary', 0, $exception ); } $coverage = isset( $row['coverage'] ) && is_string( $row['coverage'] ) ? json_decode( $row['coverage'], true, 8 ) : null; if ( ! is_array( $coverage ) || ! isset( $coverage['state'], $coverage['observed'], $coverage['total'] ) || ! is_string( $coverage['state'] ) || ! in_array( $coverage['state'], array( 'none', 'partial', 'complete' ), true ) || ! is_int( $coverage['observed'] ) || ! is_int( $coverage['total'] ) || $coverage['observed'] < 0 || $coverage['total'] < 0 || $coverage['observed'] > $coverage['total'] ) { throw new StorageException( 'invalid_run_coverage' ); } $created_at = isset( $row['created_at'] ) && is_string( $row['created_at'] ) ? $this->rfc3339_timestamp( $row['created_at'] ) : ''; $updated_at = isset( $row['updated_at'] ) && is_string( $row['updated_at'] ) ? $this->rfc3339_timestamp( $row['updated_at'] ) : ''; $completed_at = isset( $row['completed_at'] ) && is_string( $row['completed_at'] ) && '' !== $row['completed_at'] ? $this->rfc3339_timestamp( $row['completed_at'] ) : null; if ( '' === $created_at || '' === $updated_at ) { throw new StorageException( 'invalid_run_timestamp' ); } return array( 'uuid' => $uuid, 'status' => $status, 'profile' => $profile, 'language' => $language, 'registry_version' => $registry, 'coverage' => array( 'state' => $coverage['state'], 'observed' => $coverage['observed'], 'total' => $coverage['total'], ), 'created_at' => $created_at, 'updated_at' => $updated_at, 'completed_at' => $completed_at, ); } private function rfc3339_timestamp( string $timestamp ): string { $date = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $timestamp, new DateTimeZone( 'UTC' ) ); if ( false === $date || $date->format( 'Y-m-d H:i:s' ) !== $timestamp ) { return ''; } return $date->format( DATE_RFC3339 ); } /** * @return array|null */ private function fetch_row( string $uuid ): ?array { if ( ! wp_is_uuid( $uuid, 4 ) ) { return null; } // Table name is selected from the compiled Schema allowlist. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $row = $this->database->get_row( $this->database->prepare( "SELECT id, run_uuid, status, profile, language, event_log FROM {$this->table} WHERE run_uuid = %s LIMIT 1", $uuid ), ARRAY_A ); return is_array( $row ) ? $row : null; } private function assert_profile( string $profile ): void { if ( ! in_array( $profile, self::PROFILES, true ) ) { throw new InvalidArgumentException( 'invalid_profile' ); } } private function assert_language( string $language ): void { if ( 1 !== preg_match( '/^[a-z]{2}(?:_[A-Z]{2})?$/', $language ) ) { throw new InvalidArgumentException( 'invalid_language' ); } } private function assert_actor( int $actor_id ): void { if ( $actor_id < 0 ) { throw new InvalidArgumentException( 'invalid_actor' ); } } /** * @return array{action:string,status:string,actor_id:int,at:string} */ private function event( string $action, string $status, int $actor_id, string $at ): array { return array( 'action' => $action, 'status' => $status, 'actor_id' => $actor_id, 'at' => mysql_to_rfc3339( $at ), ); } private function transition_event( string $from, string $to ): string { if ( RunStatus::QUEUED === $to && RunStatus::PAUSED === $from ) { return 'run_resumed'; } $events = array( RunStatus::PREFLIGHT => 'preflight_started', RunStatus::QUEUED => 'run_queued', RunStatus::RUNNING => 'run_started', RunStatus::PAUSED => 'run_paused', RunStatus::FAILED => 'run_failed', RunStatus::CANCELLED => 'run_cancelled', RunStatus::COMPLETED => 'run_completed', ); return $events[ $to ] ?? 'run_changed'; } /** * @return list */ private function decode_events( string $json ): array { if ( '' === trim( $json ) ) { return array(); } $events = json_decode( $json, true, 64 ); if ( ! is_array( $events ) ) { throw new StorageException( 'invalid_event_log' ); } $normalized = array(); foreach ( $events as $event ) { if ( ! is_array( $event ) || ! isset( $event['action'], $event['status'], $event['actor_id'], $event['at'] ) ) { throw new StorageException( 'invalid_event_log' ); } $normalized[] = array( 'action' => (string) $event['action'], 'status' => (string) $event['status'], 'actor_id' => (int) $event['actor_id'], 'at' => (string) $event['at'], ); } return $normalized; } /** * @param array $value Structured plugin-owned value. */ private function encode( array $value ): string { $json = wp_json_encode( $value, JSON_UNESCAPED_SLASHES ); if ( false === $json ) { throw new StorageException( 'json_encode_failed' ); } return $json; } }