| 1 |
<?php |
| 2 |
/** |
| 3 |
* Delay / Wait Action |
| 4 |
* |
| 5 |
* Pauses workflow execution for a configurable duration using WordPress Cron |
| 6 |
* (wp_schedule_single_event). The remaining nodes and trigger data are |
| 7 |
* persisted to the wpbot_automator_scheduled_tasks DB table so they can be |
| 8 |
* resumed accurately after the delay fires. |
| 9 |
* |
| 10 |
* @package WPbot_Automator |
| 11 |
*/ |
| 12 |
|
| 13 |
namespace WPbot_Automator\Actions; |
| 14 |
|
| 15 |
use WPbot_Automator\Core\Database; |
| 16 |
use WPbot_Automator\Engine\Workflow_Runner; |
| 17 |
|
| 18 |
if ( ! defined( 'ABSPATH' ) ) { |
| 19 |
exit; |
| 20 |
} |
| 21 |
|
| 22 |
/** |
| 23 |
* Class Delay_Actions |
| 24 |
*/ |
| 25 |
class Delay_Actions extends Action { |
| 26 |
|
| 27 |
/** |
| 28 |
* WP Cron hook name used to resume deferred workflows. |
| 29 |
*/ |
| 30 |
const CRON_HOOK = 'wpbot_automator_resume_delayed_workflow'; |
| 31 |
|
| 32 |
/** |
| 33 |
* Constructor |
| 34 |
*/ |
| 35 |
public function __construct() { |
| 36 |
$this->id = 'delay'; |
| 37 |
$this->group = __( 'Flow Control', 'wpbot-automator' ); |
| 38 |
|
| 39 |
// Register the cron callback once per request. |
| 40 |
add_action( self::CRON_HOOK, array( $this, 'resume_workflow' ), 10, 1 ); |
| 41 |
} |
| 42 |
|
| 43 |
/** |
| 44 |
* Execute the action. |
| 45 |
* |
| 46 |
* When the workflow runner hits this node it will call execute() with |
| 47 |
* extra context that the runner passes via $action_data['__context']. |
| 48 |
* |
| 49 |
* Because the runner doesn't natively support "pausing", we intercept |
| 50 |
* execution here by: |
| 51 |
* 1. Resolving the delay in seconds. |
| 52 |
* 2. Collecting the remaining nodes (everything downstream of this node). |
| 53 |
* 3. Persisting the checkpoint to the DB. |
| 54 |
* 4. Scheduling a single WP Cron event. |
| 55 |
* 5. Returning a special '__halt' flag so the runner stops this branch. |
| 56 |
* |
| 57 |
* @param array $action_data Configuration for this action from the workflow. |
| 58 |
* @param array $trigger_data Data passed from the trigger. |
| 59 |
* @return array |
| 60 |
*/ |
| 61 |
public function execute( $action_data, $trigger_data ) { |
| 62 |
$action_id = isset( $action_data['actionId'] ) ? $action_data['actionId'] : ''; |
| 63 |
|
| 64 |
if ( 'delay_execution' !== $action_id ) { |
| 65 |
return array( 'success' => false, 'message' => 'Unknown delay action.' ); |
| 66 |
} |
| 67 |
|
| 68 |
$cfg = isset( $action_data['config'] ) ? $action_data['config'] : array(); |
| 69 |
$delay_amount = isset( $cfg['delay_amount'] ) ? absint( $cfg['delay_amount'] ) : 0; |
| 70 |
$delay_unit = isset( $cfg['delay_unit'] ) ? sanitize_key( $cfg['delay_unit'] ) : 'minutes'; |
| 71 |
|
| 72 |
// Convert to seconds. |
| 73 |
$multiplier = array( |
| 74 |
'minutes' => MINUTE_IN_SECONDS, |
| 75 |
'hours' => HOUR_IN_SECONDS, |
| 76 |
'days' => DAY_IN_SECONDS, |
| 77 |
); |
| 78 |
$delay_seconds = $delay_amount * ( isset( $multiplier[ $delay_unit ] ) ? $multiplier[ $delay_unit ] : MINUTE_IN_SECONDS ); |
| 79 |
|
| 80 |
if ( $delay_seconds <= 0 ) { |
| 81 |
return array( 'success' => false, 'message' => 'Delay: invalid delay amount or unit.' ); |
| 82 |
} |
| 83 |
|
| 84 |
// Pull the workflow context the runner embeds when it calls us. |
| 85 |
// This is set via $action_data['__workflow_context'] by the modified runner. |
| 86 |
$context = isset( $action_data['__workflow_context'] ) ? $action_data['__workflow_context'] : array(); |
| 87 |
|
| 88 |
$workflow_id = isset( $context['workflow_id'] ) ? absint( $context['workflow_id'] ) : 0; |
| 89 |
$trigger_id = isset( $context['trigger_id'] ) ? $context['trigger_id'] : ''; |
| 90 |
$remaining_nodes = isset( $context['remaining_nodes'] ) ? $context['remaining_nodes'] : array(); |
| 91 |
$connections = isset( $context['connections'] ) ? $context['connections'] : array(); |
| 92 |
|
| 93 |
if ( empty( $remaining_nodes ) || ! $workflow_id ) { |
| 94 |
// Nothing to schedule — just return success (no downstream nodes). |
| 95 |
return array( |
| 96 |
'success' => true, |
| 97 |
'message' => sprintf( |
| 98 |
'Delay: %d %s queued but no downstream nodes found; nothing to schedule.', |
| 99 |
$delay_amount, |
| 100 |
$delay_unit |
| 101 |
), |
| 102 |
); |
| 103 |
} |
| 104 |
|
| 105 |
// Persist the checkpoint. |
| 106 |
$scheduled_at = gmdate( 'Y-m-d H:i:s', time() + $delay_seconds ); |
| 107 |
$task_id = Database::add_scheduled_task( |
| 108 |
array( |
| 109 |
'workflow_id' => $workflow_id, |
| 110 |
'trigger_id' => (string) $trigger_id, |
| 111 |
'trigger_data' => $trigger_data, |
| 112 |
'remaining_nodes' => $remaining_nodes, |
| 113 |
'connections' => $connections, |
| 114 |
'scheduled_at' => $scheduled_at, |
| 115 |
) |
| 116 |
); |
| 117 |
|
| 118 |
if ( ! $task_id ) { |
| 119 |
return array( 'success' => false, 'message' => 'Delay: failed to persist scheduled task to DB.' ); |
| 120 |
} |
| 121 |
|
| 122 |
// Schedule the WP Cron event. |
| 123 |
$scheduled = wp_schedule_single_event( |
| 124 |
time() + $delay_seconds, |
| 125 |
self::CRON_HOOK, |
| 126 |
array( $task_id ) |
| 127 |
); |
| 128 |
|
| 129 |
if ( false === $scheduled ) { |
| 130 |
// Cron scheduling failed — clean up and bail. |
| 131 |
Database::delete_scheduled_task( $task_id ); |
| 132 |
return array( 'success' => false, 'message' => 'Delay: wp_schedule_single_event() failed.' ); |
| 133 |
} |
| 134 |
|
| 135 |
$human_delay = $delay_amount . ' ' . $delay_unit; |
| 136 |
|
| 137 |
// error_log( sprintf( |
| 138 |
// 'WPbot Automator - Delay: Workflow %d paused for %s. Task ID: %d. Resumes at %s.', |
| 139 |
// $workflow_id, |
| 140 |
// $human_delay, |
| 141 |
// $task_id, |
| 142 |
// $scheduled_at |
| 143 |
// ) ); |
| 144 |
|
| 145 |
// Signal the workflow runner to halt further synchronous execution on |
| 146 |
// this branch by returning the special __halt key. |
| 147 |
return array( |
| 148 |
'success' => true, |
| 149 |
'__halt' => true, |
| 150 |
'task_id' => $task_id, |
| 151 |
'message' => sprintf( 'Delay: workflow paused for %s. Will resume at %s.', $human_delay, $scheduled_at ), |
| 152 |
); |
| 153 |
} |
| 154 |
|
| 155 |
/** |
| 156 |
* WP Cron callback: resume the deferred workflow. |
| 157 |
* |
| 158 |
* Called by WordPress Cron after the delay has elapsed. |
| 159 |
* |
| 160 |
* @param int $task_id Scheduled task row ID. |
| 161 |
*/ |
| 162 |
public function resume_workflow( $task_id ) { |
| 163 |
$task = Database::get_scheduled_task( absint( $task_id ) ); |
| 164 |
|
| 165 |
if ( ! $task ) { |
| 166 |
//error_log( sprintf( 'WPbot Automator - Delay: resume_workflow called with unknown task ID %d.', $task_id ) ); |
| 167 |
return; |
| 168 |
} |
| 169 |
|
| 170 |
if ( 'pending' !== $task['status'] ) { |
| 171 |
// Already processed (guard against duplicate cron fires). |
| 172 |
return; |
| 173 |
} |
| 174 |
|
| 175 |
// Mark as completed immediately to prevent double execution. |
| 176 |
Database::complete_scheduled_task( $task_id ); |
| 177 |
|
| 178 |
$workflow_id = (int) $task['workflow_id']; |
| 179 |
$trigger_id = $task['trigger_id']; |
| 180 |
$trigger_data = is_array( $task['trigger_data'] ) ? $task['trigger_data'] : array(); |
| 181 |
$remaining_nodes = is_array( $task['remaining_nodes'] ) ? $task['remaining_nodes'] : array(); |
| 182 |
$connections = is_array( $task['connections'] ) ? $task['connections'] : array(); |
| 183 |
|
| 184 |
// error_log( sprintf( |
| 185 |
// 'WPbot Automator - Delay: Resuming workflow %d from task %d. Remaining nodes: %d.', |
| 186 |
// $workflow_id, |
| 187 |
// $task_id, |
| 188 |
// count( $remaining_nodes ) |
| 189 |
// ) ); |
| 190 |
|
| 191 |
if ( empty( $remaining_nodes ) ) { |
| 192 |
return; |
| 193 |
} |
| 194 |
|
| 195 |
// Re-use the runner to execute remaining nodes directly. |
| 196 |
// We grab the first remaining node id and pass the rest as the full set. |
| 197 |
$first_node_id = $remaining_nodes[0]; |
| 198 |
$executed_nodes = array(); // Fresh slate — all remaining nodes are eligible. |
| 199 |
|
| 200 |
// The runner's run_next_nodes is private, so we build a minimal fake |
| 201 |
// workflow structure and call execute_workflow with only the remaining nodes. |
| 202 |
$fake_workflow = array( |
| 203 |
'id' => $workflow_id, |
| 204 |
'workflow_data' => wp_json_encode( |
| 205 |
array( |
| 206 |
'nodes' => $remaining_nodes, |
| 207 |
'connections' => $connections, |
| 208 |
) |
| 209 |
), |
| 210 |
); |
| 211 |
|
| 212 |
// The first remaining_nodes entry is the node immediately after the |
| 213 |
// delay node. We mark it as a "trigger" type with a wildcard so the |
| 214 |
// runner's trigger-matching logic accepts it as the starting point. |
| 215 |
$fake_workflow['workflow_data'] = wp_json_encode( |
| 216 |
array( |
| 217 |
'nodes' => array_map( |
| 218 |
function( $node, $idx ) { |
| 219 |
if ( 0 === $idx ) { |
| 220 |
// Re-type as trigger so execute_workflow starts here. |
| 221 |
$node['type'] = 'trigger'; |
| 222 |
} |
| 223 |
return $node; |
| 224 |
}, |
| 225 |
$remaining_nodes, |
| 226 |
array_keys( $remaining_nodes ) |
| 227 |
), |
| 228 |
'connections' => $connections, |
| 229 |
) |
| 230 |
); |
| 231 |
|
| 232 |
Workflow_Runner::execute_workflow( $fake_workflow, $trigger_data, $first_node_id ); |
| 233 |
|
| 234 |
// error_log( sprintf( |
| 235 |
// 'WPbot Automator - Delay: Workflow %d resumed and completed from task %d.', |
| 236 |
// $workflow_id, |
| 237 |
// $task_id |
| 238 |
// ) ); |
| 239 |
} |
| 240 |
} |
| 241 |
|