workflow_engine = $workflow_engine ?? new WorkflowEngine(); $this->queue = $queue ?? $this->workflow_engine->getQueue(); $this->repository = $repository ?? new WorkflowRepository(); $this->order_snapshot_builder = $order_snapshot_builder ?? new OrderSnapshotBuilder(); $this->action_dispatcher = $action_dispatcher ?? new ActionDispatcher(); } /** * Get the workflow queue used by this engine. * * @return WorkflowQueue */ public function getQueue(): WorkflowQueue { return $this->queue; } /** * Match active workflows for an event and start a local execution for each. * * @param Event $event Domain event. * @return array{started: int, matched: int} Started executions and matching workflow count. */ public function start_from_event( Event $event ): array { $plan = $this->build_local_plan( $event ); if ( empty( $plan['workflows'] ) ) { return array( 'started' => 0, 'matched' => 0, ); } $started = 0; foreach ( $plan['workflows'] as $workflow ) { if ( ! $workflow instanceof Workflow ) { continue; } if ( $this->queue->findLocalByEventAndWorkflow( $plan['event_id'], $workflow->getId() ) ) { Log::info( 'ExecutionEngine: skipping duplicate local execution', array( 'event_id' => $plan['event_id'], 'workflow_id' => $workflow->getId(), ) ); continue; } $context = array_merge( $plan['context'], array( 'event_id' => $plan['event_id'], 'workflow_id' => $workflow->getId(), Execution::CONTEXT_KEY => array( 'current_step_index' => 0, 'initial_runtime_steps' => StepNormalizer::normalize_list( $workflow->getSteps() ), ), ) ); $job_id = $this->queue->enqueueLocalExecution( $workflow, $context ); if ( ! $job_id ) { Log::error( 'ExecutionEngine: failed to enqueue local execution', array( 'workflow_id' => $workflow->getId(), 'event_id' => $plan['event_id'], ) ); continue; } $job = $this->queue->findById( (int) $job_id ); if ( null === $job ) { continue; } $this->process_execution( new Execution( $job ) ); $started++; } return array( 'started' => $started, 'matched' => count( $plan['workflows'] ), ); } /** * Process a single execution from its current step index. * * @param Execution $execution Execution. * @param string|null $wait_anchor_time MySQL datetime used as wait origin (resume). * @return Execution */ public function process_execution( Execution $execution, ?string $wait_anchor_time = null ): Execution { try { $execution->append_log( max( $execution->current_step_index(), 0 ), 'engine', ExecutionStatuses::ACCEPTED, 'Execution accepted.' ); $execution->set_status( ExecutionStatuses::RUNNING ); $this->persist( $execution ); Log::info( 'ExecutionEngine: running', array( 'execution_id' => $execution->id() ) ); $execution->append_log( max( $execution->current_step_index(), 0 ), 'engine', ExecutionStatuses::RUNNING, 'Execution running.' ); $this->persist( $execution ); $steps = $execution->get_steps(); $result = $this->execute_step_list( $execution, $steps, $execution->current_step_index(), $wait_anchor_time ); if ( null !== $result ) { $this->persist( $execution ); return $execution; } $execution->set_terminal_state( ExecutionStatuses::COMPLETED ); $execution->set_current_step_index( count( $steps ) ); $execution->append_log( max( count( $steps ) - 1, 0 ), 'engine', ExecutionStatuses::COMPLETED, 'Execution completed.' ); Log::info( 'ExecutionEngine: completed', array( 'execution_id' => $execution->id() ) ); $this->persist( $execution ); return $execution; } catch ( \Throwable $e ) { $execution->append_log( $execution->current_step_index(), 'engine', ExecutionStatuses::FAILED, $e->getMessage() ); $execution->set_terminal_state( ExecutionStatuses::FAILED, $e->getMessage() ); $this->persist( $execution ); Log::error( 'ExecutionEngine: crashed', array( 'execution_id' => $execution->id(), 'error' => $e->getMessage(), ) ); return $execution; } } /** * Claim and resume waiting (and leftover pending) local executions that are due. * * @param int $limit Max jobs. * @return int[] Resumed job IDs. */ public function resume_due_executions( int $limit = 20 ): array { $claimed = $this->queue->claimDueLocalExecutions( $limit ); if ( empty( $claimed ) ) { return array(); } $resumed = array(); foreach ( $claimed as $job ) { if ( ! $job instanceof WorkflowJob ) { continue; } $execution = new Execution( $job ); Log::info( 'ExecutionEngine: resuming waiting execution', array( 'execution_id' => $execution->id() ) ); try { $this->refresh_execution_context( $execution ); $anchor = $execution->next_run_at(); $this->process_execution( $execution, $anchor ); $resumed[] = $execution->id(); } catch ( \Throwable $e ) { Log::error( 'ExecutionEngine: resume failed', array( 'execution_id' => $execution->id(), 'error' => $e->getMessage(), ) ); try { $execution->append_log( $execution->current_step_index(), 'engine', ExecutionStatuses::FAILED, $e->getMessage(), array( 'phase' => 'resume' ) ); $execution->set_terminal_state( ExecutionStatuses::FAILED, $e->getMessage() ); $this->persist( $execution ); } catch ( \Throwable $persist_error ) { Log::error( 'ExecutionEngine: failed to persist terminal state after resume failure', array( 'execution_id' => $execution->id(), 'error' => $persist_error->getMessage(), ) ); } } } return $resumed; } /** * Walk a step list from a start index. * * @param Execution $execution Execution. * @param array> $steps Steps. * @param int $start_index Start index. * @param string|null $wait_anchor_time Wait origin. * @return ExecutionStepResult|null Null when the list finishes. */ private function execute_step_list( Execution $execution, array $steps, int $start_index, ?string $wait_anchor_time = null ): ?ExecutionStepResult { $count = count( $steps ); for ( $step_index = $start_index; $step_index < $count; $step_index++ ) { $step = $steps[ $step_index ]; $step_type = isset( $step['type'] ) ? (string) $step['type'] : 'unknown'; $step_conditions = ConditionEvaluator::get_step_conditions( $step ); if ( 'branch' === $step_type ) { $branch_result = $this->execute_branch_step( $execution, $steps, $step_index, $step, $step_conditions, $wait_anchor_time ); if ( $branch_result instanceof ExecutionStepResult ) { return $branch_result; } if ( true === $branch_result ) { // Expanded branch list ran to completion. return null; } continue; } if ( $step_conditions && ! ConditionEvaluator::evaluate_conditions( $execution->context(), $step_conditions ) ) { $message = 'Step condition failed.'; $execution->append_log( $step_index, $step_type, ExecutionStatuses::CONDITION_FAILED, $message, array( 'conditions' => array_map( static function ( array $condition ) use ( $execution ) { return ConditionEvaluator::describe_condition( $condition, $execution->context() ); }, $step_conditions ), ) ); $execution->set_current_step_index( $step_index + 1 ); $this->persist( $execution ); Log::info( 'ExecutionEngine: step condition not met; skipping', array( 'execution_id' => $execution->id(), 'step_index' => $step_index, ) ); continue; } if ( 'wait' === $step_type ) { $wait_result = $this->execute_wait_step( $execution, $step_index, $step, $wait_anchor_time ); if ( null === $wait_result || $wait_result->should_continue() ) { continue; } return $wait_result; } if ( 'stop' === $step_type ) { return $this->execute_stop_step( $execution, $step_index, $step ); } if ( in_array( $step_type, array( 'send_email', 'send_sms', 'send_whatsapp' ), true ) ) { $action_result = $this->execute_action_step( $execution, $step_index, $step ); if ( ! $action_result->should_continue() ) { $this->persist( $execution ); return $action_result; } continue; } $message = sprintf( 'Unsupported step type: %s', $step_type ); $execution->append_log( $step_index, $step_type, ExecutionStatuses::FAILED, $message ); $execution->set_terminal_state( ExecutionStatuses::FAILED, $message ); $execution->set_current_step_index( $step_index ); $this->persist( $execution ); Log::error( 'ExecutionEngine: unsupported step type', array( 'execution_id' => $execution->id(), 'step_type' => $step_type, ) ); return new ExecutionStepResult( ExecutionStatuses::FAILED, $message, $step_index ); } return null; } /** * @param Execution $execution Execution. * @param array> $steps Current step list. * @param int $step_index Branch index. * @param array $step Branch step. * @param array> $step_conditions Conditions. * @param string|null $wait_anchor_time Wait origin. * @return ExecutionStepResult|true|null Result to bubble up, true when expanded list finished, null to continue. */ private function execute_branch_step( Execution $execution, array $steps, int $step_index, array $step, array $step_conditions, ?string $wait_anchor_time ) { $true_steps = isset( $step['true_steps'] ) && is_array( $step['true_steps'] ) ? $step['true_steps'] : array(); $false_steps = isset( $step['false_steps'] ) && is_array( $step['false_steps'] ) ? $step['false_steps'] : array(); $evaluated_as = ConditionEvaluator::evaluate_conditions( $execution->context(), $step_conditions ); $selected = $evaluated_as ? $true_steps : $false_steps; $selected_path = $evaluated_as ? 'true_steps' : 'false_steps'; $execution->append_log( $step_index, 'branch', ExecutionStatuses::BRANCHED, 'Branch evaluated to: ' . $selected_path, array( 'conditions' => array_map( static function ( array $condition ) use ( $execution ) { return ConditionEvaluator::describe_condition( $condition, $execution->context() ); }, $step_conditions ), 'evaluated_as' => $evaluated_as, 'selected_path' => $selected_path, 'selected_step_count' => count( $selected ), ) ); $execution->set_current_step_index( $step_index + 1 ); if ( ! empty( $selected ) ) { $expanded = $this->expand_branch_steps( $steps, $step_index, $selected ); $execution->set_runtime_steps( $expanded ); $this->persist( $execution ); Log::info( 'ExecutionEngine: branched', array( 'execution_id' => $execution->id(), 'step_index' => $step_index, 'selected_path' => $selected_path, ) ); $nested = $this->execute_step_list( $execution, $expanded, $step_index + 1, $wait_anchor_time ); return $nested instanceof ExecutionStepResult ? $nested : true; } $this->persist( $execution ); Log::info( 'ExecutionEngine: branched with empty path', array( 'execution_id' => $execution->id(), 'step_index' => $step_index, 'selected_path' => $selected_path, ) ); return null; } /** * @param Execution $execution Execution. * @param int $step_index Step index. * @param array $step Wait step. * @param string|null $wait_anchor_time Anchor. * @return ExecutionStepResult|null Null to continue the step list. */ private function execute_wait_step( Execution $execution, int $step_index, array $step, ?string $wait_anchor_time ): ?ExecutionStepResult { $wait_seconds = isset( $step['duration_seconds'] ) ? (int) $step['duration_seconds'] : 0; if ( $wait_seconds < 1 ) { $execution->set_current_step_index( $step_index + 1 ); $execution->append_log( $step_index, 'wait', ExecutionStatuses::COMPLETED, 'Wait skipped (duration is 0).' ); $this->persist( $execution ); return null; } $anchor_ts = $this->parse_datetime_timestamp( $wait_anchor_time ); if ( null === $anchor_ts ) { $anchor_ts = time(); } $next_run = wp_date( 'Y-m-d H:i:s', $anchor_ts + $wait_seconds ); $execution->set_status( ExecutionStatuses::WAITING ); $execution->set_next_run_at( $next_run ); $execution->set_current_step_index( $step_index + 1 ); $message = sprintf( 'Waiting %d seconds', $wait_seconds ); $execution->append_log( $step_index, 'wait', ExecutionStatuses::WAITING, $message, array( 'duration_seconds' => $wait_seconds ) ); $this->persist( $execution ); Log::info( 'ExecutionEngine: waiting', array( 'execution_id' => $execution->id(), 'next_run_at' => $next_run, ) ); return new ExecutionStepResult( ExecutionStatuses::WAITING, $message, $step_index ); } /** * @param Execution $execution Execution. * @param int $step_index Step index. * @param array $step Stop step. * @return ExecutionStepResult */ private function execute_stop_step( Execution $execution, int $step_index, array $step ): ExecutionStepResult { $message = isset( $step['reason'] ) && '' !== trim( (string) $step['reason'] ) ? (string) $step['reason'] : 'Execution stopped.'; $execution->append_log( $step_index, 'stop', ExecutionStatuses::STOPPED, $message ); $execution->set_terminal_state( ExecutionStatuses::STOPPED, $message ); $execution->set_current_step_index( $step_index ); $this->persist( $execution ); Log::info( 'ExecutionEngine: stopped', array( 'execution_id' => $execution->id(), 'step_index' => $step_index, ) ); return new ExecutionStepResult( ExecutionStatuses::STOPPED, $message, $step_index ); } /** * @param Execution $execution Execution. * @param int $step_index Step index. * @param array $step Action step. * @return ExecutionStepResult */ private function execute_action_step( Execution $execution, int $step_index, array $step ): ExecutionStepResult { $step_type = (string) $step['type']; $context = $execution->context(); $rendered_to = TemplateRenderer::render( (string) ( $step['to'] ?? '' ), $context ); $rendered_message = TemplateRenderer::render( (string) ( $step['message'] ?? '' ), $context ); $rendered_metadata = TemplateRenderer::render_step_metadata( $step['metadata'] ?? array(), $context ); $result = $this->action_dispatcher->send( $step, $context, $execution->job() ); $log_status = $result->success ? ExecutionStatuses::COMPLETED : ExecutionStatuses::ACTION_FAILED; $execution->append_log( $step_index, $step_type, $log_status, $result->message, array_merge( $result->details, array( 'rendered_to' => $rendered_to, 'rendered_message' => $rendered_message, 'rendered_metadata' => $rendered_metadata, ) ) ); if ( $result->success ) { $execution->set_current_step_index( $step_index + 1 ); $this->persist( $execution ); Log::info( 'ExecutionEngine: action completed', array( 'execution_id' => $execution->id(), 'step_index' => $step_index, 'step_type' => $step_type, ) ); return new ExecutionStepResult( ExecutionStepResult::STATE_CONTINUE, $result->message, $step_index ); } $execution->set_terminal_state( ExecutionStatuses::ACTION_FAILED, $result->message ); $execution->set_current_step_index( $step_index ); Log::warning( 'ExecutionEngine: action failed', array( 'execution_id' => $execution->id(), 'step_index' => $step_index, 'details' => $result->details, ) ); return new ExecutionStepResult( ExecutionStatuses::ACTION_FAILED, $result->message, $step_index ); } /** * @param array> $steps Steps. * @param int $branch_index Branch step index. * @param array> $selected_steps Selected branch body. * @return array> */ private function expand_branch_steps( array $steps, int $branch_index, array $selected_steps ): array { $head = array_slice( $steps, 0, $branch_index + 1 ); $tail = array_slice( $steps, $branch_index + 1 ); return array_merge( $head, $selected_steps, $tail ); } /** * Refresh order snapshot on resume. * * @param Execution $execution Execution. * @return void */ private function refresh_execution_context( Execution $execution ): void { $context = $execution->context(); $order_id = isset( $context['order_id'] ) ? absint( $context['order_id'] ) : 0; if ( $order_id <= 0 ) { return; } $snapshot = $this->order_snapshot_builder->build_from_order_id( $order_id ); if ( ! is_array( $snapshot ) ) { return; } $execution->set_context( array_merge( $context, $snapshot, array( 'order_id' => $order_id ) ) ); $this->persist( $execution ); } /** * Persist execution status, context, logs, and next run time. * * @param Execution $execution Execution. * @return void */ private function persist( Execution $execution ): void { $this->queue->updateJobState( $execution->job(), array( 'status' => $execution->status(), 'run_at' => $execution->next_run_at() ?? current_time( 'mysql' ), 'context_json' => $execution->context_for_storage(), 'execution_json' => $execution->logs(), ) ); } /** * Build context + matching workflows for a domain event (local, no SaaS). * * @param Event $event Event. * @return array{event_id: string, context: array, workflows: Workflow[]} */ private function build_local_plan( Event $event ): array { $trigger = $event->getName(); $payload = $event->getPayload(); $occurred_at = $event->getOccurredAt()->setTimezone( new \DateTimeZone( 'UTC' ) )->format( 'c' ); $context = array_merge( $payload, array( 'event' => $trigger, 'event_class' => get_class( $event ), 'occurred_at' => $occurred_at, ) ); $order_id = isset( $payload['order_id'] ) ? absint( $payload['order_id'] ) : 0; if ( $order_id > 0 ) { $snapshot = $this->order_snapshot_builder->build_from_order_id( $order_id ); if ( is_array( $snapshot ) ) { $context = array_merge( $context, $snapshot ); } } elseif ( $event instanceof CartAbandonedEvent && isset( $payload['abandoned_cart_id'] ) && (int) $payload['abandoned_cart_id'] > 0 ) { $snapshot = $this->order_snapshot_builder->build_from_abandoned_cart_id( (int) $payload['abandoned_cart_id'] ); if ( is_array( $snapshot ) ) { $context = array_merge( $context, $snapshot ); } } $workflows = array(); foreach ( $this->repository->findByTrigger( $trigger ) as $workflow ) { if ( ! $workflow instanceof Workflow || ! $workflow->isActive() ) { continue; } $workflows[] = $workflow; } $event_hash = md5( wp_json_encode( $payload ) . '|' . $occurred_at ); $event_id = sprintf( 'evt_%s', $event_hash ); $context['event_id'] = $event_id; return array( 'event_id' => $event_id, 'context' => $context, 'workflows' => $workflows, ); } /** * @param string|null $value Datetime string. * @return int|null Unix timestamp. */ private function parse_datetime_timestamp( ?string $value ): ?int { if ( null === $value || '' === trim( $value ) ) { return null; } $timestamp = strtotime( $value ); return false !== $timestamp ? $timestamp : null; } }