ttl = self::ttl(); } /** * Persist an interrupted workflow. * * @param string $workflow_id Workflow identifier (resume token). * @param WorkflowInterrupt $interrupt The interrupt to persist. * @return void * @throws \Throwable When PHP cannot serialize the interrupt (e.g. contains a Closure). */ public function save( string $workflow_id, WorkflowInterrupt $interrupt ): void { try { $serialized = serialize( $interrupt ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize } catch ( \Throwable $e ) { Log::error( 'Failed to serialize WorkflowInterrupt — tool approval cannot be persisted', [ 'workflow_id' => $workflow_id, 'error' => $e->getMessage(), ] ); throw $e; } set_transient( self::PREFIX . $workflow_id, $serialized, $this->ttl ); } /** * Whether a resumable interrupt is currently stored for the given id. * * Lets callers verify a workflow is still resumable before mutating any * surrounding state, so an expired/evicted interrupt does not leave a * half-applied "approved" record behind. * * @param string $workflow_id Workflow identifier (resume token). * @return bool */ public function has( string $workflow_id ): bool { $raw = get_transient( self::PREFIX . $workflow_id ); if ( false === $raw ) { return false; // Transient does not exist or has expired — normal path. } if ( ! is_string( $raw ) ) { return false; } // @unserialize is intentional: unserialize() emits E_NOTICE on corrupt // input; the instanceof check below handles the failure case explicitly. $interrupt = @unserialize( $raw ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize if ( false === $interrupt ) { Log::error( 'WorkflowInterrupt transient exists but failed to unserialize — possible data corruption', [ 'workflow_id' => $workflow_id ] ); return false; } return $interrupt instanceof WorkflowInterrupt; } /** * Load a previously persisted interrupt. * * @param string $workflow_id Workflow identifier (resume token). * @return WorkflowInterrupt * @throws \RuntimeException When no interrupt is stored for the given id. */ public function load( string $workflow_id ): WorkflowInterrupt { $raw = get_transient( self::PREFIX . $workflow_id ); if ( false === $raw || ! is_string( $raw ) ) { throw new \RuntimeException( "Workflow interrupt not found or expired for id: {$workflow_id}" ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } // @unserialize is intentional: unserialize() emits E_NOTICE on corrupt // input; the instanceof check below handles the failure case explicitly. $interrupt = @unserialize( $raw ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize if ( ! $interrupt instanceof WorkflowInterrupt ) { Log::error( 'Stored WorkflowInterrupt failed to unserialize during load', [ 'workflow_id' => $workflow_id, 'raw_length' => strlen( $raw ), 'unserialize_result_type' => gettype( $interrupt ), ] ); throw new \RuntimeException( "Corrupt or unreadable workflow interrupt for id: {$workflow_id}" ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } return $interrupt; } /** * Delete a persisted interrupt. * * @param string $workflow_id Workflow identifier (resume token). * @return void */ public function delete( string $workflow_id ): void { delete_transient( self::PREFIX . $workflow_id ); } }