reenter = $reenter; } /** * Schedule or immediately run the first step. * * @param AutomationRun $run Run. * @param Automation $automation Automation. * * @since 0.1.0 */ public function start( AutomationRun $run, Automation $automation ): void { $step = $automation->step_at( 0 ); if ( null === $step ) { $run->mark_completed( $this->clock->now() ); $this->runs->save( $run ); return; } if ( 0 === $step->delay_minutes() ) { $this->process( (int) $run->id(), $automation ); return; } $this->queue_step( $run, $step ); } /** * Process the current step of a run. * * @param int $run_id Run id. * @param Automation $automation Automation. * * @since 0.1.0 */ public function process( int $run_id, Automation $automation ): void { $run = $this->runs->find_by_id( $run_id ); if ( null === $run || ! $run->is_open() ) { return; } $now = $this->clock->now(); $step = $automation->step_at( $run->current_step() ); if ( null === $step ) { $run->mark_completed( $now ); $this->runs->save( $run ); return; } if ( 'flow_layout' === $step->type() ) { $run->skip_layout_step( $now ); $this->runs->save( $run ); $this->process( (int) $run->id(), $automation ); return; } $run->mark_running( $now ); $this->runs->save( $run ); $ctx = $run->context(); $action = $this->actions->get( $step->type() ); $mode = $step->context_mode(); if ( null !== $action && $action->requires_fresh_context() ) { $mode = ContextMode::Fresh; } if ( ContextMode::Fresh === $mode ) { if ( null !== $this->refresher ) { $fresh = $this->refresher->refresh( $ctx ); if ( null === $fresh ) { $run->mark_skipped( SkipReason::ConditionsFailed, $now ); $this->runs->save( $run ); $this->emit_terminal( $run ); return; } $ctx = $fresh; } if ( ! $this->conditions_pass( $automation, $ctx ) ) { $run->mark_skipped( SkipReason::ConditionsFailed, $now ); $this->runs->save( $run ); $this->emit_terminal( $run ); return; } } if ( null === $action ) { $result = ActionResult::failed( 'Unknown action: ' . $step->type() ); } else { $result = $action->execute( $ctx, $step ); } $meta = array_merge( AutomationStepSummary::meta( $step, $result, $action ), $result->extra() ); $run->record_step( $result, $now, $meta ); if ( ! $result->ok() || $result->halt_rest() ) { if ( ! $result->ok() ) { $run->mark_failed( (string) $result->error(), $now ); } elseif ( 'skipped' === $result->status() && $result->halt_rest() ) { $run->mark_skipped( $result->skip_reason() ?? SkipReason::ConditionsFailed, $now ); } else { $run->mark_completed( $now ); } $this->runs->save( $run ); $this->emit_terminal( $run ); return; } if ( 'skipped' === $result->status() && SkipReason::NoMarketingConsent === $result->skip_reason() ) { $run->mark_skipped( SkipReason::NoMarketingConsent, $now ); $this->runs->save( $run ); $this->emit_terminal( $run ); return; } if ( 'skipped' === $result->status() && SkipReason::NoRecipient === $result->skip_reason() ) { $run->mark_skipped( SkipReason::NoRecipient, $now ); $this->runs->save( $run ); $this->emit_terminal( $run ); return; } $this->maybe_reenter( $step, $ctx, (int) $automation->id() ); $next = $automation->step_at( $run->current_step() ); if ( null === $next ) { $run->mark_completed( $now ); $this->runs->save( $run ); $this->emit_terminal( $run ); return; } if ( 0 === $next->delay_minutes() ) { $this->runs->save( $run ); $this->process( (int) $run->id(), $automation ); return; } $this->queue_step( $run, $next ); } /** * Cancel open runs for a subject when a cancel-if event fires. * * @param AutomationRun $run Run. * @param SkipReason $reason Reason. * * @since 0.1.0 */ public function cancel_run( AutomationRun $run, SkipReason $reason ): void { if ( ! $run->is_open() ) { return; } $now = $this->clock->now(); $run->mark_cancelled( $reason, $now ); $this->runs->save( $run ); $this->unschedule( $run ); } /** * Run the current queued step immediately (operator override). * * @param AutomationRun $run Run. * @param Automation $automation Automation. * * @since 0.1.0 */ public function process_now( AutomationRun $run, Automation $automation ): void { if ( ! $run->is_open() || AutomationRunStatus::Running === $run->status() ) { return; } $this->unschedule( $run ); $now = $this->clock->now(); $run->clear_schedule( $now ); $this->runs->save( $run ); $this->process( (int) $run->id(), $automation ); } /** * Skip a pending wait step and continue the chain. * * @param AutomationRun $run Run. * @param Automation $automation Automation. * * @since 0.1.0 */ public function skip_wait( AutomationRun $run, Automation $automation ): void { if ( ! $run->is_open() || AutomationRunStatus::Running === $run->status() ) { return; } $step = $automation->step_at( $run->current_step() ); if ( null === $step || 'wait' !== $step->type() ) { return; } $this->unschedule( $run ); $now = $this->clock->now(); $run->clear_schedule( $now ); $wait = ActionResult::completed( 'Wait skipped by operator.' ); $run->record_step( $wait, $now, AutomationStepSummary::meta_for_type( 'wait', 'Wait skipped by operator', $wait->status() ) ); $this->runs->save( $run ); $next = $automation->step_at( $run->current_step() ); if ( null === $next ) { $run->mark_completed( $now ); $this->runs->save( $run ); $this->emit_terminal( $run ); return; } if ( 0 === $next->delay_minutes() ) { $this->process( (int) $run->id(), $automation ); return; } $this->queue_step( $run, $next ); } /** * Cancel any scheduled step job for a run. * * @param AutomationRun $run Run. */ private function unschedule( AutomationRun $run ): void { $this->scheduler->cancel( self::STEP_HOOK, [ 'run_id' => (int) $run->id() ], 'bookline' ); } /** * Queue a step (immediate when delay is zero). * * @param AutomationRun $run Run. * @param ActionStep $step Step. */ private function queue_step( AutomationRun $run, ActionStep $step ): void { $when = $this->clock->now(); if ( $step->delay_minutes() > 0 ) { $when = $when->add( new DateInterval( 'PT' . $step->delay_minutes() . 'M' ) ); } $run->schedule_next( $when ); $this->runs->save( $run ); if ( 0 === $step->delay_minutes() ) { return; } $job_id = $this->scheduler->schedule_single( self::STEP_HOOK, [ 'run_id' => (int) $run->id() ], $when, 'bookline', 'automation-run-' . (int) $run->id() . '-step-' . $run->current_step() ); if ( $job_id > 0 ) { $run->bind_scheduler( $job_id ); $this->runs->save( $run ); } } /** * After start() with zero delay, the caller should process immediately. * * @param Automation $automation Automation. * @param AutomationContext $ctx Context. */ private function conditions_pass( Automation $automation, AutomationContext $ctx ): bool { foreach ( $automation->conditions() as $definition ) { $condition = $this->conditions->get( $definition->identifier() ); if ( null === $condition || ! $condition->evaluate( $ctx, $definition->config() ) ) { return false; } } return true; } /** * Re-enter the matcher when a status-changing action fires. * * @param ActionStep $step Step. * @param AutomationContext $ctx Context. * @param int $automation_id Automation id. */ private function maybe_reenter( ActionStep $step, AutomationContext $ctx, int $automation_id ): void { if ( 'change_booking_status' !== $step->type() || ! is_callable( $this->reenter ) ) { return; } $status = isset( $step->config()['status'] ) ? (string) $step->config()['status'] : 'status_changed'; $event = str_starts_with( $status, 'booking.' ) ? $status : 'booking.' . $status; ( $this->reenter )( $event, $ctx->descend( $automation_id ) ); } /** * WordPress terminal hooks (docs/20 §11.1). * * @param AutomationRun $run Run. */ private function emit_terminal( AutomationRun $run ): void { if ( ! function_exists( 'do_action' ) ) { return; } if ( AutomationRunStatus::Skipped === $run->status() ) { do_action( 'bookline/automation/run_skipped', $run ); return; } if ( AutomationRunStatus::Completed === $run->status() ) { do_action( 'bookline/automation/run_completed', $run ); } } }