PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.3.6
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.3.6
3.5.7 3.5.6 3.5.5 3.5.4 3.5.3 3.5.2 3.5.1 3.5.0 3.4.9 3.4.8 3.4.7 0.2.1 1.6.91 0.2.2 1.6.92 0.2.3 1.6.93 0.2.4 1.6.94 0.2.5 1.6.95 0.2.6 1.6.96 0.2.7 1.6.97 0.2.8 1.6.98 0.2.9 1.6.99 0.3.0 1.7.0 0.3.1 1.7.1 0.3.2 1.7.2 0.3.3 1.7.3 0.3.4 1.7.4 0.3.5 1.7.5 0.3.6 1.7.6 0.4.0 1.7.7 0.4.1 1.7.8 0.4.2 1.7.9 0.4.3 1.8.0 0.4.4 1.8.1 0.4.5 1.8.2 0.4.6 1.8.3 0.4.7 1.8.4 0.4.8 1.8.5 0.4.9 1.8.6 0.5.0 1.8.7 0.5.1 1.8.8 0.5.2 1.8.9 0.5.3 1.9.0 0.5.4 1.9.1 0.5.5 1.9.2 0.5.6 1.9.3 0.5.7 1.9.4 0.5.8 1.9.5 0.5.9 1.9.6 0.6.0 1.9.7 0.6.1 1.9.8 0.6.2 1.9.81 0.6.3 1.9.82 0.6.4 1.9.83 0.6.5 1.9.84 0.6.6 1.9.85 0.6.7 1.9.86 0.6.8 1.9.87 0.6.9 1.9.88 0.7.0 1.9.89 0.7.1 1.9.90 0.7.2 1.9.91 0.7.3 1.9.92 0.7.4 1.9.93 0.7.5 1.9.94 0.7.6 1.9.95 0.7.7 1.9.96 0.7.8 1.9.97 0.7.9 1.9.98 0.8.0 1.9.99 0.8.1 2.0.0 0.8.2 2.0.1 0.8.3 2.0.2 0.8.4 2.0.3 0.8.5 2.0.4 0.8.6 2.0.5 0.8.7 2.0.6 0.8.8 2.0.7 0.8.9 2.0.8 0.9.0 2.0.9 0.9.2 2.1.0 0.9.3 2.1.1 0.9.4 2.1.2 0.9.5 2.1.3 0.9.6 2.1.4 0.9.7 2.1.5 0.9.8 2.1.6 0.9.81 2.1.7 0.9.82 2.1.8 0.9.83 2.1.9 0.9.84 2.2.0 0.9.85 2.2.1 0.9.86 2.2.2 0.9.87 2.2.3 0.9.88 2.2.4 0.9.89 2.2.5 0.9.9 2.2.51 0.9.91 2.2.52 0.9.92 2.2.53 0.9.93 2.2.54 0.9.94 2.2.56 0.9.95 2.2.57 0.9.96 2.2.6 0.9.97 2.2.60 0.9.98 2.2.61 0.9.99 2.2.62 1.0.0 2.2.63 1.0.01 2.2.70 1.0.1 2.2.80 1.0.2 2.2.81 1.0.3 2.2.90 1.0.4 2.2.91 1.0.5 2.2.92 1.0.6 2.2.93 1.0.7 2.2.94 1.0.8 2.2.95 1.0.9 2.3.0 1.1.0 2.3.1 1.1.1 2.3.2 1.1.2 2.3.3 1.1.3 2.3.4 1.1.4 2.3.5 1.1.5 2.3.6 1.1.6 2.3.7 1.1.7 2.3.8 1.1.8 2.3.9 1.1.9 2.4.0 1.2.0 2.4.1 1.2.1 2.4.2 1.2.2 2.4.3 1.2.21 2.4.4 1.2.3 2.4.5 1.2.30 2.4.6 1.3.0 2.4.7 1.3.1 2.4.8 1.3.2 2.4.9 1.3.3 2.5.0 1.3.31 2.5.1 1.3.32 2.5.2 1.3.33 2.5.3 1.3.34 2.5.4 1.3.35 2.5.5 1.3.36 2.5.6 1.3.37 2.5.7 1.3.38 2.5.8 1.3.39 2.5.9 1.3.40 2.6.0 1.3.41 2.6.1 1.3.42 2.6.2 1.3.43 2.6.3 1.3.44 2.6.5 1.3.45 2.6.6 1.3.46 2.6.7 1.3.47 2.6.8 1.3.48 2.6.9 1.3.49 2.7.0 1.3.50 2.7.1 1.3.51 2.7.2 1.3.52 2.7.3 1.3.53 2.7.4 1.3.54 2.7.5 1.3.56 2.7.6 1.3.57 2.7.7 1.3.58 2.7.8 1.3.59 2.7.9 1.3.60 2.8.0 1.3.61 2.8.1 1.3.62 2.8.2 1.3.63 2.8.3 1.3.64 2.8.4 1.3.65 2.8.5 1.3.66 2.8.6 1.3.67 2.8.7 1.3.68 2.8.8 1.3.69 2.8.9 1.3.70 2.9.0 1.3.71 2.9.1 1.3.72 2.9.2 1.3.73 2.9.3 1.3.74 2.9.4 1.3.75 2.9.5 1.3.76 2.9.6 1.3.77 2.9.7 1.3.78 2.9.8 1.3.79 2.9.9 1.3.80 3.0.0 1.3.81 3.0.1 1.3.82 3.0.2 1.3.83 3.0.3 1.3.84 3.0.4 1.3.85 3.0.5 1.3.86 3.0.6 1.3.87 3.0.7 1.3.88 3.0.8 1.3.89 3.0.9 1.3.90 3.1.0 1.3.91 3.1.1 1.3.92 3.1.2 1.3.93 3.1.3 1.3.94 3.1.4 1.3.95 3.1.5 1.3.96 3.1.6 1.3.97 3.1.7 1.3.98 3.1.8 1.3.99 3.1.9 1.4.0 3.2.0 1.4.1 3.2.1 1.4.2 3.2.2 1.4.3 3.2.3 1.4.4 3.2.4 1.4.5 3.2.5 1.4.6 3.2.6 1.4.7 3.2.7 1.4.8 3.2.8 1.4.9 3.2.9 1.5.0 3.3.0 1.5.1 3.3.1 1.5.2 3.3.2 1.5.3 3.3.3 1.5.4 3.3.4 1.5.5 3.3.5 1.5.6 3.3.6 1.5.7 3.3.7 1.5.8 3.3.8 1.5.9 3.3.9 1.6.0 3.4.0 1.6.1 3.4.1 1.6.2 3.4.2 1.6.3 3.4.3 1.6.5 3.4.4 1.6.51 3.4.5 1.6.52 3.4.6 1.6.53 1.6.54 1.6.55 1.6.56 1.6.57 1.6.58 1.6.59 1.6.60 1.6.61 1.6.62 1.6.63 1.6.64 1.6.65 1.6.66 1.6.67 1.6.68 trunk 1.6.69 0.0.1 1.6.70 0.0.2 1.6.71 0.0.3 1.6.72 0.0.4 1.6.73 0.0.5 1.6.74 0.0.6 1.6.75 0.0.7 1.6.76 0.0.8 1.6.77 0.0.9 1.6.78 0.1.0 1.6.79 0.1.1 1.6.81 0.1.2 1.6.82 0.1.3 1.6.83 0.1.4 1.6.84 0.1.5 1.6.85 0.1.6 1.6.86 0.1.7 1.6.87 0.1.8 1.6.88 0.1.9 1.6.89 0.2.0 1.6.90
ai-engine / classes / modules / tasks.php
ai-engine / classes / modules Last commit date
advisor.php 7 months ago chatbot.php 4 months ago discussions.php 5 months ago files.php 6 months ago forms-manager.php 10 months ago gdpr.php 11 months ago search.php 11 months ago security.php 11 months ago tasks-examples.php 6 months ago tasks.php 5 months ago wand.php 5 months ago
tasks.php
1483 lines
1 <?php
2
3 class Meow_MWAI_Modules_Tasks {
4 private $wpdb = null;
5 private $core = null;
6 public $table_tasks = null;
7 public $table_tasklogs = null;
8 private $db_check = false;
9 private $namespace = 'mwai/v1';
10 private $max_tasks_per_tick = 5;
11 private $max_retries = 3;
12
13 public function __construct( $core ) {
14 global $wpdb;
15 $this->wpdb = $wpdb;
16 $this->core = $core;
17 $this->table_tasks = $wpdb->prefix . 'mwai_tasks';
18 $this->table_tasklogs = $wpdb->prefix . 'mwai_tasklogs';
19
20 // Initialize database
21 $this->check_db();
22
23 // Register REST API
24 add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
25
26 // Custom cron schedules - MUST be registered before using them
27 add_filter( 'cron_schedules', [ $this, 'custom_cron_schedule' ], 5 );
28
29 // Always register the action hooks
30 add_action( 'mwai_tasks_internal_run', [ $this, 'tick' ] );
31 add_action( 'mwai_tasks_internal_dev_run', [ $this, 'tick' ] );
32
33 // Register cleanup tasks handler
34 add_filter( 'mwai_task_cleanup_tasks', [ $this, 'handle_cleanup_tasks' ], 10, 2 );
35
36 // Migrate existing crons to tasks (only on admin requests to avoid overhead)
37 if ( is_admin() ) {
38 add_action( 'init', [ $this, 'migrate_existing_crons' ], 20 );
39 // Check and fix overdue cron on admin pages
40 add_action( 'admin_init', [ $this, 'fix_overdue_cron' ] );
41 }
42
43 // Schedule crons on init (after custom schedules are registered)
44 add_action( 'init', [ $this, 'ensure_cron_scheduled' ], 15 );
45
46 // Load the Tasks Examples module (includes test task functionality)
47 require_once( __DIR__ . '/tasks-examples.php' );
48 new Meow_MWAI_Modules_Tasks_Examples( $core );
49 }
50
51 /**
52 * Ensure cron is scheduled properly
53 */
54 public function ensure_cron_scheduled() {
55 $dev_mode = $this->core->get_option( 'dev_mode' );
56 $hook = $dev_mode ? 'mwai_tasks_internal_dev_run' : 'mwai_tasks_internal_run';
57 $opposite_hook = $dev_mode ? 'mwai_tasks_internal_run' : 'mwai_tasks_internal_dev_run';
58
59 // Clear opposite hook
60 wp_clear_scheduled_hook( $opposite_hook );
61
62 // Check if current hook is scheduled and not overdue
63 $next = wp_next_scheduled( $hook );
64
65 // If not scheduled or overdue by more than 5 minutes, reschedule
66 if ( !$next || $next < ( time() - 300 ) ) {
67 wp_clear_scheduled_hook( $hook );
68
69 if ( $dev_mode ) {
70 wp_schedule_event( time() + 5, 'five_seconds', $hook );
71 } else {
72 wp_schedule_event( time() + 60, 'one_minute', $hook );
73 }
74 }
75 }
76
77 /**
78 * Fix overdue cron events
79 */
80 public function fix_overdue_cron() {
81 $dev_mode = $this->core->get_option( 'dev_mode' );
82
83 if ( $dev_mode ) {
84 // Clear production cron if it exists
85 wp_clear_scheduled_hook( 'mwai_tasks_internal_run' );
86
87 // Ensure dev cron is scheduled
88 if ( !wp_next_scheduled( 'mwai_tasks_internal_dev_run' ) ) {
89 wp_schedule_event( time() + 5, 'five_seconds', 'mwai_tasks_internal_dev_run' );
90 }
91 }
92 else {
93 // Clear dev cron if it exists
94 wp_clear_scheduled_hook( 'mwai_tasks_internal_dev_run' );
95
96 // Ensure production cron is scheduled
97 if ( !wp_next_scheduled( 'mwai_tasks_internal_run' ) ) {
98 wp_schedule_event( time() + 60, 'one_minute', 'mwai_tasks_internal_run' );
99 }
100 }
101 }
102
103 public function custom_cron_schedule( $schedules ) {
104 $schedules['one_minute'] = [ 'display' => __( 'Every Minute' ), 'interval' => 60 ];
105 $schedules['five_seconds'] = [ 'display' => __( 'Every 5 Seconds' ), 'interval' => 5 ];
106 return $schedules;
107 }
108
109 public function rest_api_init() {
110 register_rest_route( $this->namespace, '/helpers/tasks_list', [
111 'methods' => 'GET',
112 'callback' => [ $this, 'rest_tasks_list' ],
113 'permission_callback' => [ $this->core, 'can_access_settings' ],
114 ] );
115
116 register_rest_route( $this->namespace, '/helpers/task_run', [
117 'methods' => 'POST',
118 'callback' => [ $this, 'rest_task_run' ],
119 'permission_callback' => [ $this->core, 'can_access_settings' ],
120 ] );
121
122 register_rest_route( $this->namespace, '/helpers/task_pause', [
123 'methods' => 'POST',
124 'callback' => [ $this, 'rest_task_pause' ],
125 'permission_callback' => [ $this->core, 'can_access_settings' ],
126 ] );
127
128 register_rest_route( $this->namespace, '/helpers/task_resume', [
129 'methods' => 'POST',
130 'callback' => [ $this, 'rest_task_resume' ],
131 'permission_callback' => [ $this->core, 'can_access_settings' ],
132 ] );
133
134 register_rest_route( $this->namespace, '/helpers/task_delete', [
135 'methods' => 'POST',
136 'callback' => [ $this, 'rest_task_delete' ],
137 'permission_callback' => [ $this->core, 'can_access_settings' ],
138 ] );
139
140 register_rest_route( $this->namespace, '/helpers/task_logs', [
141 'methods' => 'GET',
142 'callback' => [ $this, 'rest_task_logs' ],
143 'permission_callback' => [ $this->core, 'can_access_settings' ],
144 ] );
145
146 register_rest_route( $this->namespace, '/helpers/task_logs_delete', [
147 'methods' => 'POST',
148 'callback' => [ $this, 'rest_task_logs_delete' ],
149 'permission_callback' => [ $this->core, 'can_access_settings' ],
150 ] );
151
152 register_rest_route( $this->namespace, '/helpers/tasks_reset', [
153 'methods' => 'POST',
154 'callback' => [ $this, 'rest_tasks_reset' ],
155 'permission_callback' => [ $this->core, 'can_access_settings' ],
156 ] );
157 }
158
159 /**
160 * Ensure a task exists or update its configuration
161 */
162 public function ensure( $args ) {
163 $defaults = [
164 'name' => '',
165 'description' => '',
166 'category' => 'general',
167 'schedule' => 'once',
168 'next_run' => null, // Allow specifying when a one-time task should run
169 'is_multistep' => 0,
170 'expires_at' => null,
171 'auto_delete' => 0,
172 'deletable' => 1,
173 'data' => null,
174 'step_name' => null,
175 ];
176
177 $args = wp_parse_args( $args, $defaults );
178
179 if ( empty( $args['name'] ) ) {
180 return new WP_Error( 'invalid_name', 'Task name is required' );
181 }
182
183 // Check if task exists
184 $existing = $this->wpdb->get_row( $this->wpdb->prepare(
185 "SELECT * FROM {$this->table_tasks} WHERE task_name = %s",
186 $args['name']
187 ) );
188
189 $now = gmdate( 'Y-m-d H:i:s' );
190
191 if ( $existing ) {
192 // Update existing task
193 $update_data = [
194 'description' => $args['description'],
195 'category' => $args['category'],
196 'updated' => $now,
197 ];
198
199 // Only update these if they've changed
200 if ( $args['schedule'] !== $existing->schedule ) {
201 $update_data['schedule'] = $args['schedule'];
202 $update_data['next_run'] = $this->calculate_next_run( $args['schedule'] );
203 }
204
205 if ( $args['expires_at'] !== $existing->expires_at ) {
206 $update_data['expires_at'] = $args['expires_at'];
207 }
208
209 if ( $args['data'] !== null ) {
210 $existing_data = json_decode( $existing->data, true ) ?: [];
211 $merged_data = array_merge( $existing_data, $args['data'] );
212 $update_data['data'] = json_encode( $merged_data );
213 }
214
215 if ( $args['step_name'] !== null ) {
216 $update_data['step_name'] = $args['step_name'];
217 }
218
219 $result = $this->wpdb->update(
220 $this->table_tasks,
221 $update_data,
222 [ 'task_name' => $args['name'] ]
223 );
224
225 return $result !== false;
226 }
227 else {
228 // Create new task
229 // Use provided next_run for one-time tasks, otherwise calculate from schedule
230 if ( $args['schedule'] === 'once' && $args['next_run'] ) {
231 $next_run = $args['next_run'];
232 } else {
233 $next_run = $this->calculate_next_run( $args['schedule'] );
234 }
235
236 $insert_data = [
237 'task_name' => $args['name'],
238 'description' => $args['description'],
239 'category' => $args['category'],
240 'schedule' => $args['schedule'],
241 'status' => 'pending',
242 'next_run' => $next_run,
243 'expires_at' => $args['expires_at'],
244 'step' => 0,
245 'step_name' => $args['step_name'],
246 'step_data' => isset( $args['step_data'] ) ? json_encode( $args['step_data'] ) : null,
247 'data' => json_encode( $args['data'] ?: [] ),
248 'meta' => json_encode( [] ),
249 'error_count' => 0,
250 'max_retries' => $this->max_retries,
251 'created' => $now,
252 'updated' => $now,
253 ];
254
255 $result = $this->wpdb->insert( $this->table_tasks, $insert_data );
256
257 return $result !== false;
258 }
259 }
260
261 /**
262 * Get a specific task by name
263 */
264 public function get_task( $task_name ) {
265 return $this->wpdb->get_row( $this->wpdb->prepare(
266 "SELECT * FROM {$this->table_tasks} WHERE task_name = %s",
267 $task_name
268 ) );
269 }
270
271 /**
272 * Create a new task directly
273 */
274 public function create_task( $task_data ) {
275 $defaults = [
276 'category' => 'general',
277 'status' => 'pending', // Changed from 'active' to 'pending' to match tick() selection
278 'next_run' => null,
279 'expires_at' => null,
280 'step' => 0,
281 'step_name' => null,
282 'step_data' => null,
283 'data' => [],
284 'meta' => [],
285 'error_count' => 0,
286 'max_retries' => 3,
287 'description' => null
288 ];
289
290 $task_data = array_merge( $defaults, $task_data );
291 $now = gmdate( 'Y-m-d H:i:s' );
292
293 // Calculate next run if schedule provided and next_run not set
294 if ( !empty( $task_data['schedule'] ) && empty( $task_data['next_run'] ) ) {
295 $task_data['next_run'] = $this->calculate_next_run( $task_data['schedule'] );
296 }
297
298 // Ensure next_run is in proper datetime format if it's a timestamp
299 if ( !empty( $task_data['next_run'] ) && is_numeric( $task_data['next_run'] ) ) {
300 $task_data['next_run'] = gmdate( 'Y-m-d H:i:s', $task_data['next_run'] );
301 }
302
303 // If still no next_run, set to now
304 if ( empty( $task_data['next_run'] ) ) {
305 $task_data['next_run'] = $now;
306 }
307
308 $insert_data = [
309 'task_name' => $task_data['task_name'],
310 'description' => $task_data['description'],
311 'category' => $task_data['category'],
312 'schedule' => $task_data['schedule'],
313 'status' => $task_data['status'],
314 'next_run' => $task_data['next_run'],
315 'expires_at' => $task_data['expires_at'],
316 'step' => $task_data['step'],
317 'step_name' => $task_data['step_name'],
318 'step_data' => is_array( $task_data['step_data'] ) ? json_encode( $task_data['step_data'] ) : $task_data['step_data'],
319 'data' => is_array( $task_data['data'] ) ? json_encode( $task_data['data'] ) : $task_data['data'],
320 'meta' => is_array( $task_data['meta'] ) ? json_encode( $task_data['meta'] ) : $task_data['meta'],
321 'error_count' => $task_data['error_count'],
322 'max_retries' => $task_data['max_retries'],
323 'created' => $now,
324 'updated' => $now,
325 ];
326
327 return $this->wpdb->insert( $this->table_tasks, $insert_data ) !== false;
328 }
329
330 /**
331 * Update a task by name
332 */
333 public function update_task( $task_name, $fields ) {
334 $update_data = [ 'updated' => gmdate( 'Y-m-d H:i:s' ) ];
335
336 foreach ( $fields as $key => $value ) {
337 if ( in_array( $key, [ 'data', 'meta', 'step_data' ] ) && is_array( $value ) ) {
338 $update_data[$key] = json_encode( $value );
339 } else {
340 $update_data[$key] = $value;
341 }
342 }
343
344 return $this->wpdb->update(
345 $this->table_tasks,
346 $update_data,
347 [ 'task_name' => $task_name ]
348 ) !== false;
349 }
350
351 /**
352 * Update task fields
353 */
354 public function update( $task_name, $fields ) {
355 $allowed_fields = [ 'schedule', 'description', 'data', 'expires_at', 'step', 'step_name', 'step_data' ];
356 $update_data = [ 'updated' => gmdate( 'Y-m-d H:i:s' ) ];
357
358 foreach ( $fields as $key => $value ) {
359 if ( in_array( $key, $allowed_fields ) ) {
360 if ( $key === 'data' || $key === 'step_data' ) {
361 $update_data[$key] = json_encode( $value );
362 }
363 else if ( $key === 'schedule' ) {
364 $update_data[$key] = $value;
365 $update_data['next_run'] = $this->calculate_next_run( $value );
366 }
367 else {
368 $update_data[$key] = $value;
369 }
370 }
371 }
372
373 $result = $this->wpdb->update(
374 $this->table_tasks,
375 $update_data,
376 [ 'task_name' => $task_name ]
377 );
378
379 return $result !== false;
380 }
381
382 /**
383 * Remove a task
384 */
385 public function remove( $task_name, $opts = [] ) {
386 $delete_logs = isset( $opts['delete_logs'] ) ? $opts['delete_logs'] : false;
387
388 // Get task ID for logs deletion
389 if ( $delete_logs ) {
390 $task_id = $this->wpdb->get_var( $this->wpdb->prepare(
391 "SELECT id FROM {$this->table_tasks} WHERE task_name = %s",
392 $task_name
393 ) );
394
395 if ( $task_id ) {
396 $this->wpdb->delete( $this->table_tasklogs, [ 'task_id' => $task_id ] );
397 }
398 }
399
400 $result = $this->wpdb->delete( $this->table_tasks, [ 'task_name' => $task_name ] );
401
402 return $result !== false;
403 }
404
405 /**
406 * Pause a task
407 */
408 public function pause( $task_name ) {
409 $result = $this->wpdb->update(
410 $this->table_tasks,
411 [ 'status' => 'paused', 'updated' => gmdate( 'Y-m-d H:i:s' ) ],
412 [ 'task_name' => $task_name ]
413 );
414
415 return $result !== false;
416 }
417
418 /**
419 * Resume a task
420 */
421 public function resume( $task_name ) {
422 // Get the task to determine schedule
423 $task = $this->wpdb->get_row( $this->wpdb->prepare(
424 "SELECT * FROM {$this->table_tasks} WHERE task_name = %s",
425 $task_name
426 ) );
427
428 if ( !$task ) {
429 return false;
430 }
431
432 $next_run = $this->calculate_next_run( $task->schedule );
433
434 $result = $this->wpdb->update(
435 $this->table_tasks,
436 [
437 'status' => 'pending',
438 'next_run' => $next_run,
439 'updated' => gmdate( 'Y-m-d H:i:s' )
440 ],
441 [ 'task_name' => $task_name ]
442 );
443
444 return $result !== false;
445 }
446
447 /**
448 * Run a task immediately
449 */
450 public function run_now( $task_name ) {
451 // First check if task is stuck and reset it
452 $task = $this->wpdb->get_row( $this->wpdb->prepare(
453 "SELECT * FROM {$this->table_tasks} WHERE task_name = %s",
454 $task_name
455 ) );
456
457 if ( $task && $task->status === 'running' ) {
458 // Reset stuck task - be more aggressive (1 minute instead of 10)
459 $this->reset_stuck_tasks( 1 );
460 }
461
462 $result = $this->wpdb->update(
463 $this->table_tasks,
464 [
465 'status' => 'pending',
466 'next_run' => gmdate( 'Y-m-d H:i:s' ),
467 'updated' => gmdate( 'Y-m-d H:i:s' )
468 ],
469 [ 'task_name' => $task_name ]
470 );
471
472 if ( $result !== false ) {
473 // Optionally run tick once (but keep it light)
474 $this->tick();
475 return true;
476 }
477
478 return false;
479 }
480
481 /**
482 * Reset tasks that are stuck in running state
483 */
484 public function reset_stuck_tasks( $minutes_threshold = 10 ) {
485 $now = gmdate( 'Y-m-d H:i:s' );
486 $stuck_cutoff = gmdate( 'Y-m-d H:i:s', strtotime( "-{$minutes_threshold} minutes" ) );
487
488 $count = $this->wpdb->query( $this->wpdb->prepare(
489 "UPDATE {$this->table_tasks}
490 SET status = 'pending',
491 error_count = error_count + 1,
492 updated = %s
493 WHERE status = 'running'
494 AND updated < %s",
495 $now, $stuck_cutoff
496 ) );
497
498 return $count;
499 }
500
501 /**
502 * Main execution loop - called by cron
503 */
504 public function tick() {
505 // Track cron execution for proper "last run" display
506 // Determine which hook is actually running
507 $dev_mode = $this->core->get_option( 'dev_mode' );
508 $hook_name = $dev_mode ? 'mwai_tasks_internal_dev_run' : 'mwai_tasks_internal_run';
509 $this->core->track_cron_start( $hook_name );
510
511 // Use UTC consistently
512 $now = gmdate( 'Y-m-d H:i:s' );
513
514 // First, reset any stuck tasks (running for more than 10 minutes)
515 $this->reset_stuck_tasks();
516
517 // Get due tasks
518 $tasks = $this->wpdb->get_results( $this->wpdb->prepare(
519 "SELECT * FROM {$this->table_tasks}
520 WHERE status IN ('pending', 'error')
521 AND next_run <= %s
522 AND (expires_at IS NULL OR expires_at > %s)
523 ORDER BY next_run ASC
524 LIMIT %d",
525 $now, $now, $this->max_tasks_per_tick
526 ) );
527
528 foreach ( $tasks as $task ) {
529 $this->execute_task( $task );
530 }
531
532 // Track cron completion
533 $this->core->track_cron_end( $hook_name );
534 }
535
536 /**
537 * Execute a single task
538 */
539 private function execute_task( $task ) {
540 // Atomically claim the task
541 $claimed = $this->wpdb->update(
542 $this->table_tasks,
543 [ 'status' => 'running', 'updated' => gmdate( 'Y-m-d H:i:s' ) ],
544 [
545 'id' => $task->id,
546 'status' => $task->status // Ensure it hasn't changed
547 ]
548 );
549
550 if ( !$claimed ) {
551 return; // Another process got it
552 }
553
554 // Start logging
555 $log_id = $this->log_start( $task->id );
556 $start_time = microtime( true );
557
558 // Build job array
559 $job = [
560 'name' => $task->task_name,
561 'schedule' => $task->schedule,
562 'step' => $task->step,
563 'step_name' => $task->step_name,
564 'data' => json_decode( $task->data, true ) ?: [],
565 'meta' => json_decode( $task->meta, true ) ?: [],
566 ];
567
568 // Call the filter with error handling
569 try {
570 $result = apply_filters( "mwai_task_{$task->task_name}", null, $job );
571
572 // Fallback to generic filter if specific one returns null
573 if ( $result === null ) {
574 $result = apply_filters( 'mwai_task_run', null, $job );
575 }
576
577 // Default result if nothing handles it
578 if ( $result === null ) {
579 $result = [
580 'ok' => true,
581 'message' => "No handler for '{$task->task_name}'",
582 ];
583 }
584 }
585 catch ( Exception $e ) {
586 $result = [
587 'ok' => false,
588 'message' => 'Exception: ' . $e->getMessage(),
589 ];
590 }
591 catch ( Error $e ) {
592 $result = [
593 'ok' => false,
594 'message' => 'Fatal error: ' . $e->getMessage(),
595 ];
596 }
597
598 // Normalize result
599 $result = $this->normalize_result( $result );
600
601 // Log the end
602 $time_taken = microtime( true ) - $start_time;
603 $this->log_end( $log_id, $result, $time_taken );
604
605 // Update task based on result
606 $this->finish_from_result( $task, $result );
607 }
608
609 /**
610 * Normalize task result
611 */
612 private function normalize_result( $result ) {
613 if ( !is_array( $result ) ) {
614 return [
615 'ok' => false,
616 'message' => 'Invalid result format',
617 ];
618 }
619
620 $defaults = [
621 'ok' => false,
622 'done' => true,
623 'message' => '',
624 'step' => null,
625 'step_name' => null,
626 'data' => null,
627 'meta' => null,
628 'next_run_delay' => null,
629 ];
630
631 return wp_parse_args( $result, $defaults );
632 }
633
634 /**
635 * Update task after execution
636 */
637 private function finish_from_result( $task, $result ) {
638 $now_ts = time();
639 $now = gmdate( 'Y-m-d H:i:s', $now_ts );
640
641 // Merge data and meta if provided
642 $data = json_decode( $task->data, true ) ?: [];
643 $meta = json_decode( $task->meta, true ) ?: [];
644
645 if ( $result['data'] !== null && is_array( $result['data'] ) ) {
646 $data = array_merge( $data, $result['data'] );
647 }
648
649 if ( $result['meta'] !== null && is_array( $result['meta'] ) ) {
650 $meta = array_merge( $meta, $result['meta'] );
651 }
652
653 $update_data = [
654 'data' => json_encode( $data ),
655 'meta' => json_encode( $meta ),
656 'last_run' => $now,
657 'updated' => $now,
658 ];
659
660 // Update step if provided
661 if ( $result['step'] !== null ) {
662 $update_data['step'] = $result['step'];
663 }
664 if ( $result['step_name'] !== null ) {
665 $update_data['step_name'] = $result['step_name'];
666 }
667
668 if ( $result['ok'] ) {
669 // Success path
670 $update_data['error_count'] = 0;
671
672 if ( !$result['done'] ) {
673 // Multi-step task not finished - continue quickly
674 $update_data['status'] = 'pending';
675 $delay = isset( $result['next_run_delay'] ) ? (int) $result['next_run_delay'] : 10;
676 if ( $delay < 1 ) {
677 $delay = 1;
678 }
679 $update_data['next_run'] = gmdate( 'Y-m-d H:i:s', $now_ts + $delay );
680 }
681 else if ( $task->schedule === 'once' ) {
682 // One-off task completed
683 $update_data['status'] = 'done';
684 $update_data['next_run'] = null;
685 }
686 else {
687 // Recurring task completed this cycle
688 $update_data['status'] = 'pending';
689 $update_data['next_run'] = $this->calculate_next_run( $task->schedule, $now_ts );
690 $update_data['step'] = 0;
691 $update_data['step_name'] = null;
692 }
693 }
694 else {
695 // Error path
696 $update_data['error_count'] = $task->error_count + 1;
697
698 if ( $update_data['error_count'] >= $task->max_retries ) {
699 // Max retries reached or exceeded
700 $update_data['status'] = 'error';
701 $update_data['next_run'] = null;
702 }
703 else {
704 // Retry with backoff
705 $update_data['status'] = 'pending';
706 $update_data['next_run'] = gmdate( 'Y-m-d H:i:s', $now_ts + 300 ); // 5 minutes
707 }
708 }
709
710 // Check expiration
711 if ( $task->expires_at && strtotime( $task->expires_at ) <= $now_ts ) {
712 $update_data['status'] = 'expired';
713 $update_data['next_run'] = null;
714 }
715
716 // Update the task
717 $this->wpdb->update(
718 $this->table_tasks,
719 $update_data,
720 [ 'id' => $task->id ]
721 );
722
723 // Auto-delete expired tasks that have an expiration date
724 if ( $task->expires_at && $update_data['status'] === 'expired' ) {
725 $this->wpdb->delete( $this->table_tasks, [ 'id' => $task->id ] );
726 // Also delete logs for expired tasks
727 $this->wpdb->delete( $this->table_tasklogs, [ 'task_id' => $task->id ] );
728 }
729 }
730
731 /**
732 * Handle cleanup tasks - remove old logs and failed tasks
733 */
734 public function handle_cleanup_tasks( $result, $job ) {
735 try {
736 $now = gmdate( 'Y-m-d H:i:s' );
737 $stats = [
738 'logs_deleted' => 0,
739 'failed_tasks_deleted' => 0,
740 'expired_tasks_deleted' => 0,
741 ];
742
743 // 1. Delete task logs older than 7 days
744 $week_ago = gmdate( 'Y-m-d H:i:s', strtotime( '-7 days' ) );
745 $logs_deleted = $this->wpdb->query( $this->wpdb->prepare(
746 "DELETE FROM {$this->table_tasklogs} WHERE created < %s",
747 $week_ago
748 ) );
749 $stats['logs_deleted'] = $logs_deleted ? $logs_deleted : 0;
750
751 // 2. Delete failed tasks that have been in error state for over 30 days
752 $month_ago = gmdate( 'Y-m-d H:i:s', strtotime( '-30 days' ) );
753 $failed_tasks = $this->wpdb->get_results( $this->wpdb->prepare(
754 "SELECT id, task_name FROM {$this->table_tasks}
755 WHERE status = 'error' AND updated < %s",
756 $month_ago
757 ) );
758
759 foreach ( $failed_tasks as $task ) {
760 // Delete the task and its logs
761 $this->wpdb->delete( $this->table_tasks, [ 'id' => $task->id ] );
762 $this->wpdb->delete( $this->table_tasklogs, [ 'task_id' => $task->id ] );
763 $stats['failed_tasks_deleted']++;
764 }
765
766 // 3. Delete expired tasks that have been expired for over 7 days
767 $expired_tasks = $this->wpdb->get_results( $this->wpdb->prepare(
768 "SELECT id, task_name FROM {$this->table_tasks}
769 WHERE status = 'expired' AND updated < %s",
770 $week_ago
771 ) );
772
773 foreach ( $expired_tasks as $task ) {
774 // Delete the task and its logs
775 $this->wpdb->delete( $this->table_tasks, [ 'id' => $task->id ] );
776 $this->wpdb->delete( $this->table_tasklogs, [ 'task_id' => $task->id ] );
777 $stats['expired_tasks_deleted']++;
778 }
779
780 // 4. Clean up orphaned logs (logs without corresponding tasks)
781 $orphaned_logs = $this->wpdb->query(
782 "DELETE tl FROM {$this->table_tasklogs} tl
783 LEFT JOIN {$this->table_tasks} t ON tl.task_id = t.id
784 WHERE t.id IS NULL"
785 );
786
787 $message = sprintf(
788 'Cleaned: %d logs, %d failed tasks, %d expired tasks',
789 $stats['logs_deleted'],
790 $stats['failed_tasks_deleted'],
791 $stats['expired_tasks_deleted']
792 );
793
794 return [
795 'ok' => true,
796 'message' => $message,
797 'data' => $stats
798 ];
799
800 } catch ( Exception $e ) {
801 return [
802 'ok' => false,
803 'message' => 'Cleanup failed: ' . $e->getMessage()
804 ];
805 }
806 }
807
808 /**
809 * Calculate next run time
810 */
811 private function calculate_next_run( $schedule, $after_ts = null ) {
812 if ( $schedule === 'once' ) {
813 // For one-time tasks without a specific time, run immediately
814 return gmdate( 'Y-m-d H:i:s' );
815 }
816
817 if ( $after_ts === null ) {
818 $after_ts = time();
819 }
820
821 $next_ts = $this->cron_next( $schedule, $after_ts );
822 return gmdate( 'Y-m-d H:i:s', $next_ts );
823 }
824
825 /**
826 * Parse cron expression and get next run time
827 */
828 private function cron_next( $expr, $after_ts ) {
829 $parts = $this->parse_cron( $expr );
830 if ( !$parts ) {
831 // Invalid expression, return next hour
832 return $after_ts + 3600;
833 }
834
835 // Start from the next minute
836 $check_ts = $after_ts - ( $after_ts % 60 ) + 60;
837
838 // Check up to 2 years in the future (should be more than enough)
839 $max_ts = $after_ts + ( 2 * 365 * 24 * 60 * 60 );
840
841 while ( $check_ts < $max_ts ) {
842 $time = getdate( $check_ts );
843
844 if ( $this->cron_matches( $parts, $time ) ) {
845 return $check_ts;
846 }
847
848 // Move to next minute
849 $check_ts += 60;
850 }
851
852 // Fallback to next hour if no match found
853 return $after_ts + 3600;
854 }
855
856 /**
857 * Parse cron expression into parts
858 */
859 private function parse_cron( $expr ) {
860 if ( empty( $expr ) ) {
861 return false;
862 }
863
864 $fields = preg_split( '/\s+/', trim( $expr ) );
865 if ( count( $fields ) !== 5 ) {
866 return false;
867 }
868
869 return [
870 'minute' => $this->parse_cron_field( $fields[0], 0, 59 ),
871 'hour' => $this->parse_cron_field( $fields[1], 0, 23 ),
872 'dom' => $this->parse_cron_field( $fields[2], 1, 31 ),
873 'month' => $this->parse_cron_field( $fields[3], 1, 12 ),
874 'dow' => $this->parse_cron_field( $fields[4], 0, 7 ),
875 ];
876 }
877
878 /**
879 * Parse a single cron field
880 */
881 private function parse_cron_field( $field, $min, $max ) {
882 if ( $field === '*' ) {
883 return range( $min, $max );
884 }
885
886 $values = [];
887
888 // Handle step values (*/N)
889 if ( strpos( $field, '/' ) !== false ) {
890 list( $range, $step ) = explode( '/', $field );
891 $step = (int) $step;
892
893 if ( $range === '*' ) {
894 for ( $i = $min; $i <= $max; $i += $step ) {
895 $values[] = $i;
896 }
897 }
898 else if ( strpos( $range, '-' ) !== false ) {
899 list( $start, $end ) = explode( '-', $range );
900 $start = (int) $start;
901 $end = (int) $end;
902 for ( $i = $start; $i <= $end && $i <= $max; $i += $step ) {
903 $values[] = $i;
904 }
905 }
906 return $values;
907 }
908
909 // Handle ranges (N-M)
910 if ( strpos( $field, '-' ) !== false ) {
911 list( $start, $end ) = explode( '-', $field );
912 return range( (int) $start, min( (int) $end, $max ) );
913 }
914
915 // Handle lists (N,M,...)
916 if ( strpos( $field, ',' ) !== false ) {
917 $parts = explode( ',', $field );
918 foreach ( $parts as $part ) {
919 $values[] = (int) $part;
920 }
921 return $values;
922 }
923
924 // Single value
925 return [ (int) $field ];
926 }
927
928 /**
929 * Check if time matches cron expression
930 */
931 private function cron_matches( $parts, $time ) {
932 // Check minute
933 if ( !in_array( (int) $time['minutes'], $parts['minute'] ) ) {
934 return false;
935 }
936
937 // Check hour
938 if ( !in_array( (int) $time['hours'], $parts['hour'] ) ) {
939 return false;
940 }
941
942 // Check month
943 if ( !in_array( (int) $time['mon'], $parts['month'] ) ) {
944 return false;
945 }
946
947 // Check day of month OR day of week (standard cron behavior)
948 $dom_match = in_array( (int) $time['mday'], $parts['dom'] );
949 $dow_match = in_array( (int) $time['wday'], $parts['dow'] );
950
951 // Handle Sunday as both 0 and 7
952 if ( in_array( 7, $parts['dow'] ) && $time['wday'] == 0 ) {
953 $dow_match = true;
954 }
955
956 // If both dom and dow are restricted (*not* wildcards), either can match
957 // If one is wildcard, only the restricted one needs to match
958 $dom_restricted = !( count( $parts['dom'] ) === 31 );
959 $dow_restricted = !( count( $parts['dow'] ) === 8 || count( $parts['dow'] ) === 7 );
960
961 if ( $dom_restricted && $dow_restricted ) {
962 return $dom_match || $dow_match;
963 }
964 else if ( $dom_restricted ) {
965 return $dom_match;
966 }
967 else if ( $dow_restricted ) {
968 return $dow_match;
969 }
970
971 return true;
972 }
973
974 /**
975 * Log task start
976 */
977 private function log_start( $task_id ) {
978 $this->wpdb->insert(
979 $this->table_tasklogs,
980 [
981 'task_id' => $task_id,
982 'started' => gmdate( 'Y-m-d H:i:s' ),
983 'status' => 'running',
984 'created' => gmdate( 'Y-m-d H:i:s' ),
985 ]
986 );
987
988 return $this->wpdb->insert_id;
989 }
990
991 /**
992 * Log task end
993 */
994 private function log_end( $log_id, $result, $time_taken = null ) {
995 $status = 'error';
996 if ( $result['ok'] && $result['done'] ) {
997 $status = 'success';
998 }
999 else if ( $result['ok'] && !$result['done'] ) {
1000 $status = 'partial';
1001 }
1002
1003 $this->wpdb->update(
1004 $this->table_tasklogs,
1005 [
1006 'ended' => gmdate( 'Y-m-d H:i:s' ),
1007 'status' => $status,
1008 'message' => substr( $result['message'], 0, 255 ),
1009 'time_taken' => $time_taken,
1010 'memory_peak' => memory_get_peak_usage(),
1011 'step' => $result['step'],
1012 ],
1013 [ 'id' => $log_id ]
1014 );
1015 }
1016
1017 /**
1018 * Migrate existing crons to tasks
1019 * TODO: Remove after January 2026 - This entire migration can be removed
1020 */
1021 public function migrate_existing_crons() {
1022 // Remove old cron hooks that may still exist
1023 wp_clear_scheduled_hook( 'mwai_discussions' );
1024 wp_clear_scheduled_hook( 'mwai_files_cleanup' );
1025 wp_clear_scheduled_hook( 'mwai_files' ); // In case this was used before
1026
1027 // Ensure cleanup_discussions task exists
1028 $this->ensure( [
1029 'name' => 'cleanup_discussions',
1030 'description' => 'Remove old discussions beyond retention period.',
1031 'category' => 'system',
1032 'schedule' => '0 3 * * *', // Daily at 3 AM UTC
1033 ] );
1034
1035 // Ensure cleanup_files task exists
1036 $this->ensure( [
1037 'name' => 'cleanup_files',
1038 'category' => 'system',
1039 'description' => 'Delete expired files based on expiration dates.',
1040 'schedule' => '0 4 * * *', // Daily at 4 AM UTC
1041 ] );
1042
1043 // Ensure cleanup_tasks exists
1044 $this->ensure( [
1045 'name' => 'cleanup_tasks',
1046 'description' => 'Clean old task logs and failed tasks.',
1047 'category' => 'system',
1048 'schedule' => '0 13 * * *', // Daily at 1 PM UTC
1049 'deletable' => 0, // System task, not deletable
1050 ] );
1051 }
1052
1053 /**
1054 * REST: List tasks
1055 */
1056 public function rest_tasks_list( $request ) {
1057 // Make sure table exists
1058 $this->check_db();
1059
1060 $tasks = $this->wpdb->get_results(
1061 "SELECT * FROM {$this->table_tasks} ORDER BY task_name ASC"
1062 );
1063
1064 if ( $tasks === false ) {
1065 return new WP_REST_Response( [ 'success' => false, 'message' => 'Database error', 'tasks' => [] ], 500 );
1066 }
1067
1068 if ( empty( $tasks ) ) {
1069 $tasks = [];
1070 }
1071
1072 // Add computed fields
1073 foreach ( $tasks as &$task ) {
1074 $task->data = json_decode( $task->data, true );
1075 $task->meta = json_decode( $task->meta, true );
1076 $task->step_data = $task->step_data ? json_decode( $task->step_data, true ) : null;
1077
1078 // Ensure integers are properly cast
1079 $task->step = (int) $task->step;
1080 $task->error_count = (int) $task->error_count;
1081 $task->max_retries = (int) $task->max_retries;
1082
1083 // Fix tasks that should be in error status but aren't
1084 if ( $task->error_count >= $task->max_retries && $task->status === 'pending' ) {
1085 $task->status = 'error';
1086 $task->next_run = null;
1087 }
1088
1089 // Determine if task is deletable (system tasks cannot be deleted)
1090 $task->deletable = !in_array( $task->task_name, ['cleanup_discussions', 'cleanup_files'] ) ? 1 : 0;
1091
1092 // Get last message from most recent log
1093 $last_log = $this->wpdb->get_row( $this->wpdb->prepare(
1094 "SELECT message FROM {$this->table_tasklogs} WHERE task_id = %d ORDER BY started DESC LIMIT 1",
1095 $task->id
1096 ) );
1097 $task->last_message = $last_log ? $last_log->message : null;
1098
1099 // Get log count for this task
1100 $log_count = $this->wpdb->get_var( $this->wpdb->prepare(
1101 "SELECT COUNT(*) FROM {$this->table_tasklogs} WHERE task_id = %d",
1102 $task->id
1103 ) );
1104 $task->log_count = (int) $log_count;
1105
1106 // Calculate next 3 run times for preview
1107 if ( $task->schedule !== 'once' && $task->status === 'pending' ) {
1108 $next_runs = [];
1109 $check_ts = $task->next_run ? strtotime( $task->next_run ) : time();
1110
1111 for ( $i = 0; $i < 3; $i++ ) {
1112 $check_ts = $this->cron_next( $task->schedule, $check_ts );
1113 $next_runs[] = gmdate( 'Y-m-d H:i:s', $check_ts );
1114 }
1115
1116 $task->next_runs_preview = $next_runs;
1117 }
1118 else {
1119 $task->next_runs_preview = [];
1120 }
1121
1122 // Format times for display
1123 if ( $task->last_run ) {
1124 $task->last_run_human = $this->human_time_diff( strtotime( $task->last_run ) );
1125 }
1126 else {
1127 $task->last_run_human = 'Never';
1128 }
1129
1130 // Only show next_run for tasks that are actually scheduled to run
1131 // Don't show next_run for error, done, or expired tasks
1132 if ( $task->next_run && in_array( $task->status, ['pending', 'running', 'paused'] ) ) {
1133 $task->next_run_human = $this->human_time_diff( strtotime( $task->next_run ) );
1134 }
1135 else {
1136 $task->next_run_human = null;
1137 $task->next_run = null; // Clear it so frontend doesn't try to display it
1138 }
1139 }
1140
1141 return new WP_REST_Response( [ 'success' => true, 'tasks' => $tasks ], 200 );
1142 }
1143
1144 /**
1145 * REST: Run task now
1146 */
1147 public function rest_task_run( $request ) {
1148 $params = $request->get_json_params();
1149 $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
1150
1151 if ( !$task_name ) {
1152 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1153 }
1154
1155 $result = $this->run_now( $task_name );
1156
1157 if ( $result ) {
1158 return new WP_REST_Response( [ 'success' => true, 'message' => 'Task scheduled to run' ], 200 );
1159 }
1160
1161 return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to run task' ], 500 );
1162 }
1163
1164 /**
1165 * REST: Pause task
1166 */
1167 public function rest_task_pause( $request ) {
1168 $params = $request->get_json_params();
1169 $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
1170
1171 if ( !$task_name ) {
1172 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1173 }
1174
1175 $result = $this->pause( $task_name );
1176
1177 if ( $result ) {
1178 return new WP_REST_Response( [ 'success' => true, 'message' => 'Task paused' ], 200 );
1179 }
1180
1181 return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to pause task' ], 500 );
1182 }
1183
1184 /**
1185 * REST: Resume task
1186 */
1187 public function rest_task_resume( $request ) {
1188 $params = $request->get_json_params();
1189 $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
1190
1191 if ( !$task_name ) {
1192 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1193 }
1194
1195 $result = $this->resume( $task_name );
1196
1197 if ( $result ) {
1198 return new WP_REST_Response( [ 'success' => true, 'message' => 'Task resumed' ], 200 );
1199 }
1200
1201 return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to resume task' ], 500 );
1202 }
1203
1204 /**
1205 * REST: Delete task
1206 */
1207 public function rest_task_delete( $request ) {
1208 $params = $request->get_json_params();
1209 $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
1210 $delete_logs = isset( $params['delete_logs'] ) ? $params['delete_logs'] : true;
1211
1212 if ( !$task_name ) {
1213 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1214 }
1215
1216 $result = $this->remove( $task_name, [ 'delete_logs' => $delete_logs ] );
1217
1218 if ( $result ) {
1219 return new WP_REST_Response( [ 'success' => true, 'message' => 'Task deleted' ], 200 );
1220 }
1221
1222 return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to delete task' ], 500 );
1223 }
1224
1225 /**
1226 * REST: Get task logs
1227 */
1228 public function rest_task_logs( $request ) {
1229 $task_name = $request->get_param( 'task_name' );
1230
1231 if ( !$task_name ) {
1232 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1233 }
1234
1235 // Get task ID
1236 $task_id = $this->wpdb->get_var( $this->wpdb->prepare(
1237 "SELECT id FROM {$this->table_tasks} WHERE task_name = %s",
1238 $task_name
1239 ) );
1240
1241 if ( !$task_id ) {
1242 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task not found' ], 404 );
1243 }
1244
1245 // Get logs
1246 $logs = $this->wpdb->get_results( $this->wpdb->prepare(
1247 "SELECT * FROM {$this->table_tasklogs}
1248 WHERE task_id = %d
1249 ORDER BY started DESC
1250 LIMIT 50",
1251 $task_id
1252 ) );
1253
1254 return new WP_REST_Response( [ 'success' => true, 'logs' => $logs ], 200 );
1255 }
1256
1257 /**
1258 * REST: Delete task logs
1259 */
1260 public function rest_task_logs_delete( $request ) {
1261 $params = $request->get_json_params();
1262 $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
1263
1264 if ( !$task_name ) {
1265 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1266 }
1267
1268 // Get task ID
1269 $task_id = $this->wpdb->get_var( $this->wpdb->prepare(
1270 "SELECT id FROM {$this->table_tasks} WHERE task_name = %s",
1271 $task_name
1272 ) );
1273
1274 if ( !$task_id ) {
1275 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task not found' ], 404 );
1276 }
1277
1278 // Delete logs for this task
1279 $result = $this->wpdb->delete(
1280 $this->table_tasklogs,
1281 [ 'task_id' => $task_id ],
1282 [ '%d' ]
1283 );
1284
1285 if ( $result !== false ) {
1286 return new WP_REST_Response( [ 'success' => true, 'message' => 'Logs deleted successfully' ], 200 );
1287 }
1288
1289 return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to delete logs' ], 500 );
1290 }
1291
1292 /**
1293 * REST: Reset all tasks
1294 */
1295 public function rest_tasks_reset( $request ) {
1296 // Clear all WordPress cron jobs related to tasks
1297 wp_clear_scheduled_hook( 'mwai_tasks_internal_run' );
1298 wp_clear_scheduled_hook( 'mwai_tasks_internal_dev_run' );
1299
1300 // Clear all transients
1301 delete_transient( 'mwai_cron_last_run' );
1302 delete_transient( 'mwai_cron_running_mwai_tasks_internal_run' );
1303 delete_transient( 'mwai_cron_running_mwai_tasks_internal_dev_run' );
1304
1305 // Truncate task logs table
1306 $this->wpdb->query( "TRUNCATE TABLE {$this->table_tasklogs}" );
1307
1308 // Delete all tasks
1309 $this->wpdb->query( "TRUNCATE TABLE {$this->table_tasks}" );
1310
1311 // Re-initialize the Tasks Runner cron
1312 $dev_mode = $this->core->get_option( 'dev_mode' );
1313 if ( $dev_mode ) {
1314 wp_schedule_event( time() + 5, 'five_seconds', 'mwai_tasks_internal_dev_run' );
1315 }
1316 else {
1317 wp_schedule_event( time() + 60, 'one_minute', 'mwai_tasks_internal_run' );
1318 }
1319
1320 // Re-create system tasks
1321 $this->migrate_existing_crons();
1322
1323 return new WP_REST_Response( [
1324 'success' => true,
1325 'message' => 'Tasks system has been reset. All tasks and logs have been cleared, and system tasks have been re-created.'
1326 ], 200 );
1327 }
1328
1329 /**
1330 * Helper: Human-readable time difference (abbreviated)
1331 */
1332 private function human_time_diff( $timestamp ) {
1333 // Use current time consistently
1334 $now = time();
1335 $diff = $timestamp - $now;
1336
1337 if ( $diff < 0 ) {
1338 // Past
1339 $diff = abs( $diff );
1340 $suffix = ' ago';
1341 }
1342 else {
1343 // Future
1344 $suffix = '';
1345 }
1346
1347 // Use abbreviated format
1348 if ( $diff < 60 ) {
1349 return $diff . 's' . $suffix;
1350 }
1351 else if ( $diff < 3600 ) {
1352 $minutes = round( $diff / 60 );
1353 return $minutes . 'm' . $suffix;
1354 }
1355 else if ( $diff < 86400 ) {
1356 $hours = round( $diff / 3600 );
1357 return $hours . 'h' . $suffix;
1358 }
1359 else {
1360 $days = round( $diff / 86400 );
1361 return $days . 'd' . $suffix;
1362 }
1363 }
1364
1365 /**
1366 * Check and create database tables
1367 */
1368 public function check_db() {
1369 // Don't run multiple times
1370 if ( $this->db_check ) {
1371 return true;
1372 }
1373
1374 // Check if tables exist
1375 $tasks_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_tasks'" );
1376 $logs_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_tasklogs'" );
1377
1378 if ( !$tasks_exists || !$logs_exists ) {
1379 $this->create_db();
1380 }
1381
1382 // Check for database upgrades
1383 $this->upgrade_db();
1384
1385 $this->db_check = true;
1386 return true;
1387 }
1388
1389 /**
1390 * Upgrade database schema if needed
1391 */
1392 private function upgrade_db() {
1393 // Add category column if it doesn't exist
1394 $category_exists = $this->wpdb->get_var(
1395 "SHOW COLUMNS FROM {$this->table_tasks} LIKE 'category'"
1396 );
1397
1398 if ( !$category_exists ) {
1399 $this->wpdb->query(
1400 "ALTER TABLE {$this->table_tasks}
1401 ADD COLUMN category VARCHAR(32) NOT NULL DEFAULT 'general' AFTER description"
1402 );
1403 }
1404
1405 // Remove deprecated columns if they exist
1406 $columns_to_remove = ['auto_delete', 'deletable', 'is_multistep', 'last_message'];
1407
1408 foreach ( $columns_to_remove as $column ) {
1409 $column_exists = $this->wpdb->get_var(
1410 "SHOW COLUMNS FROM {$this->table_tasks} LIKE '$column'"
1411 );
1412
1413 if ( $column_exists ) {
1414 $this->wpdb->query( "ALTER TABLE {$this->table_tasks} DROP COLUMN $column" );
1415 }
1416 }
1417
1418 // Add step_data column if it doesn't exist
1419 $step_data_exists = $this->wpdb->get_var(
1420 "SHOW COLUMNS FROM {$this->table_tasks} LIKE 'step_data'"
1421 );
1422
1423 if ( !$step_data_exists ) {
1424 $this->wpdb->query(
1425 "ALTER TABLE {$this->table_tasks}
1426 ADD COLUMN step_data LONGTEXT NULL AFTER step_name"
1427 );
1428 }
1429 }
1430
1431 /**
1432 * Create database tables
1433 */
1434 public function create_db() {
1435 $charset_collate = $this->wpdb->get_charset_collate();
1436
1437 $sql_tasks = "CREATE TABLE $this->table_tasks (
1438 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
1439 task_name VARCHAR(100) NOT NULL,
1440 description TEXT NULL,
1441 category VARCHAR(32) NOT NULL DEFAULT 'general',
1442 schedule VARCHAR(128) NOT NULL,
1443 status VARCHAR(16) NOT NULL DEFAULT 'pending',
1444 next_run DATETIME NULL,
1445 last_run DATETIME NULL,
1446 expires_at DATETIME NULL,
1447 step INT NOT NULL DEFAULT 0,
1448 step_name VARCHAR(64) NULL,
1449 step_data LONGTEXT NULL,
1450 data LONGTEXT NULL,
1451 meta LONGTEXT NULL,
1452 error_count INT NOT NULL DEFAULT 0,
1453 max_retries INT NOT NULL DEFAULT 3,
1454 created DATETIME NOT NULL,
1455 updated DATETIME NOT NULL,
1456 PRIMARY KEY (id),
1457 UNIQUE KEY task_name (task_name),
1458 KEY status_next (status, next_run),
1459 KEY category (category),
1460 KEY expires (expires_at)
1461 ) $charset_collate;";
1462
1463 $sql_logs = "CREATE TABLE $this->table_tasklogs (
1464 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
1465 task_id BIGINT UNSIGNED NOT NULL,
1466 started DATETIME NOT NULL,
1467 ended DATETIME NULL,
1468 status VARCHAR(16) NOT NULL,
1469 message TEXT NULL,
1470 time_taken FLOAT NULL,
1471 memory_peak BIGINT NULL,
1472 step INT NULL,
1473 created DATETIME NOT NULL,
1474 PRIMARY KEY (id),
1475 KEY task_id_started (task_id, started)
1476 ) $charset_collate;";
1477
1478 require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
1479 dbDelta( $sql_tasks );
1480 dbDelta( $sql_logs );
1481 }
1482 }
1483