PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.1.6
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.1.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 9 months ago chatbot.php 8 months ago discussions.php 8 months ago files.php 8 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 9 months ago tasks.php 8 months ago wand.php 10 months ago
tasks.php
1478 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' => false,
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 ];
629
630 return wp_parse_args( $result, $defaults );
631 }
632
633 /**
634 * Update task after execution
635 */
636 private function finish_from_result( $task, $result ) {
637 $now_ts = time();
638 $now = gmdate( 'Y-m-d H:i:s', $now_ts );
639
640 // Merge data and meta if provided
641 $data = json_decode( $task->data, true ) ?: [];
642 $meta = json_decode( $task->meta, true ) ?: [];
643
644 if ( $result['data'] !== null && is_array( $result['data'] ) ) {
645 $data = array_merge( $data, $result['data'] );
646 }
647
648 if ( $result['meta'] !== null && is_array( $result['meta'] ) ) {
649 $meta = array_merge( $meta, $result['meta'] );
650 }
651
652 $update_data = [
653 'data' => json_encode( $data ),
654 'meta' => json_encode( $meta ),
655 'last_run' => $now,
656 'updated' => $now,
657 ];
658
659 // Update step if provided
660 if ( $result['step'] !== null ) {
661 $update_data['step'] = $result['step'];
662 }
663 if ( $result['step_name'] !== null ) {
664 $update_data['step_name'] = $result['step_name'];
665 }
666
667 if ( $result['ok'] ) {
668 // Success path
669 $update_data['error_count'] = 0;
670
671 if ( !$result['done'] ) {
672 // Multi-step task not finished - continue quickly
673 $update_data['status'] = 'pending';
674 $update_data['next_run'] = gmdate( 'Y-m-d H:i:s', $now_ts + 10 );
675 }
676 else if ( $task->schedule === 'once' ) {
677 // One-off task completed
678 $update_data['status'] = 'done';
679 $update_data['next_run'] = null;
680 }
681 else {
682 // Recurring task completed this cycle
683 $update_data['status'] = 'pending';
684 $update_data['next_run'] = $this->calculate_next_run( $task->schedule, $now_ts );
685 $update_data['step'] = 0;
686 $update_data['step_name'] = null;
687 }
688 }
689 else {
690 // Error path
691 $update_data['error_count'] = $task->error_count + 1;
692
693 if ( $update_data['error_count'] >= $task->max_retries ) {
694 // Max retries reached or exceeded
695 $update_data['status'] = 'error';
696 $update_data['next_run'] = null;
697 }
698 else {
699 // Retry with backoff
700 $update_data['status'] = 'pending';
701 $update_data['next_run'] = gmdate( 'Y-m-d H:i:s', $now_ts + 300 ); // 5 minutes
702 }
703 }
704
705 // Check expiration
706 if ( $task->expires_at && strtotime( $task->expires_at ) <= $now_ts ) {
707 $update_data['status'] = 'expired';
708 $update_data['next_run'] = null;
709 }
710
711 // Update the task
712 $this->wpdb->update(
713 $this->table_tasks,
714 $update_data,
715 [ 'id' => $task->id ]
716 );
717
718 // Auto-delete expired tasks that have an expiration date
719 if ( $task->expires_at && $update_data['status'] === 'expired' ) {
720 $this->wpdb->delete( $this->table_tasks, [ 'id' => $task->id ] );
721 // Also delete logs for expired tasks
722 $this->wpdb->delete( $this->table_tasklogs, [ 'task_id' => $task->id ] );
723 }
724 }
725
726 /**
727 * Handle cleanup tasks - remove old logs and failed tasks
728 */
729 public function handle_cleanup_tasks( $result, $job ) {
730 try {
731 $now = gmdate( 'Y-m-d H:i:s' );
732 $stats = [
733 'logs_deleted' => 0,
734 'failed_tasks_deleted' => 0,
735 'expired_tasks_deleted' => 0,
736 ];
737
738 // 1. Delete task logs older than 7 days
739 $week_ago = gmdate( 'Y-m-d H:i:s', strtotime( '-7 days' ) );
740 $logs_deleted = $this->wpdb->query( $this->wpdb->prepare(
741 "DELETE FROM {$this->table_tasklogs} WHERE created < %s",
742 $week_ago
743 ) );
744 $stats['logs_deleted'] = $logs_deleted ? $logs_deleted : 0;
745
746 // 2. Delete failed tasks that have been in error state for over 30 days
747 $month_ago = gmdate( 'Y-m-d H:i:s', strtotime( '-30 days' ) );
748 $failed_tasks = $this->wpdb->get_results( $this->wpdb->prepare(
749 "SELECT id, task_name FROM {$this->table_tasks}
750 WHERE status = 'error' AND updated < %s",
751 $month_ago
752 ) );
753
754 foreach ( $failed_tasks as $task ) {
755 // Delete the task and its logs
756 $this->wpdb->delete( $this->table_tasks, [ 'id' => $task->id ] );
757 $this->wpdb->delete( $this->table_tasklogs, [ 'task_id' => $task->id ] );
758 $stats['failed_tasks_deleted']++;
759 }
760
761 // 3. Delete expired tasks that have been expired for over 7 days
762 $expired_tasks = $this->wpdb->get_results( $this->wpdb->prepare(
763 "SELECT id, task_name FROM {$this->table_tasks}
764 WHERE status = 'expired' AND updated < %s",
765 $week_ago
766 ) );
767
768 foreach ( $expired_tasks as $task ) {
769 // Delete the task and its logs
770 $this->wpdb->delete( $this->table_tasks, [ 'id' => $task->id ] );
771 $this->wpdb->delete( $this->table_tasklogs, [ 'task_id' => $task->id ] );
772 $stats['expired_tasks_deleted']++;
773 }
774
775 // 4. Clean up orphaned logs (logs without corresponding tasks)
776 $orphaned_logs = $this->wpdb->query(
777 "DELETE tl FROM {$this->table_tasklogs} tl
778 LEFT JOIN {$this->table_tasks} t ON tl.task_id = t.id
779 WHERE t.id IS NULL"
780 );
781
782 $message = sprintf(
783 'Cleaned: %d logs, %d failed tasks, %d expired tasks',
784 $stats['logs_deleted'],
785 $stats['failed_tasks_deleted'],
786 $stats['expired_tasks_deleted']
787 );
788
789 return [
790 'ok' => true,
791 'message' => $message,
792 'data' => $stats
793 ];
794
795 } catch ( Exception $e ) {
796 return [
797 'ok' => false,
798 'message' => 'Cleanup failed: ' . $e->getMessage()
799 ];
800 }
801 }
802
803 /**
804 * Calculate next run time
805 */
806 private function calculate_next_run( $schedule, $after_ts = null ) {
807 if ( $schedule === 'once' ) {
808 // For one-time tasks without a specific time, run immediately
809 return gmdate( 'Y-m-d H:i:s' );
810 }
811
812 if ( $after_ts === null ) {
813 $after_ts = time();
814 }
815
816 $next_ts = $this->cron_next( $schedule, $after_ts );
817 return gmdate( 'Y-m-d H:i:s', $next_ts );
818 }
819
820 /**
821 * Parse cron expression and get next run time
822 */
823 private function cron_next( $expr, $after_ts ) {
824 $parts = $this->parse_cron( $expr );
825 if ( !$parts ) {
826 // Invalid expression, return next hour
827 return $after_ts + 3600;
828 }
829
830 // Start from the next minute
831 $check_ts = $after_ts - ( $after_ts % 60 ) + 60;
832
833 // Check up to 2 years in the future (should be more than enough)
834 $max_ts = $after_ts + ( 2 * 365 * 24 * 60 * 60 );
835
836 while ( $check_ts < $max_ts ) {
837 $time = getdate( $check_ts );
838
839 if ( $this->cron_matches( $parts, $time ) ) {
840 return $check_ts;
841 }
842
843 // Move to next minute
844 $check_ts += 60;
845 }
846
847 // Fallback to next hour if no match found
848 return $after_ts + 3600;
849 }
850
851 /**
852 * Parse cron expression into parts
853 */
854 private function parse_cron( $expr ) {
855 if ( empty( $expr ) ) {
856 return false;
857 }
858
859 $fields = preg_split( '/\s+/', trim( $expr ) );
860 if ( count( $fields ) !== 5 ) {
861 return false;
862 }
863
864 return [
865 'minute' => $this->parse_cron_field( $fields[0], 0, 59 ),
866 'hour' => $this->parse_cron_field( $fields[1], 0, 23 ),
867 'dom' => $this->parse_cron_field( $fields[2], 1, 31 ),
868 'month' => $this->parse_cron_field( $fields[3], 1, 12 ),
869 'dow' => $this->parse_cron_field( $fields[4], 0, 7 ),
870 ];
871 }
872
873 /**
874 * Parse a single cron field
875 */
876 private function parse_cron_field( $field, $min, $max ) {
877 if ( $field === '*' ) {
878 return range( $min, $max );
879 }
880
881 $values = [];
882
883 // Handle step values (*/N)
884 if ( strpos( $field, '/' ) !== false ) {
885 list( $range, $step ) = explode( '/', $field );
886 $step = (int) $step;
887
888 if ( $range === '*' ) {
889 for ( $i = $min; $i <= $max; $i += $step ) {
890 $values[] = $i;
891 }
892 }
893 else if ( strpos( $range, '-' ) !== false ) {
894 list( $start, $end ) = explode( '-', $range );
895 $start = (int) $start;
896 $end = (int) $end;
897 for ( $i = $start; $i <= $end && $i <= $max; $i += $step ) {
898 $values[] = $i;
899 }
900 }
901 return $values;
902 }
903
904 // Handle ranges (N-M)
905 if ( strpos( $field, '-' ) !== false ) {
906 list( $start, $end ) = explode( '-', $field );
907 return range( (int) $start, min( (int) $end, $max ) );
908 }
909
910 // Handle lists (N,M,...)
911 if ( strpos( $field, ',' ) !== false ) {
912 $parts = explode( ',', $field );
913 foreach ( $parts as $part ) {
914 $values[] = (int) $part;
915 }
916 return $values;
917 }
918
919 // Single value
920 return [ (int) $field ];
921 }
922
923 /**
924 * Check if time matches cron expression
925 */
926 private function cron_matches( $parts, $time ) {
927 // Check minute
928 if ( !in_array( (int) $time['minutes'], $parts['minute'] ) ) {
929 return false;
930 }
931
932 // Check hour
933 if ( !in_array( (int) $time['hours'], $parts['hour'] ) ) {
934 return false;
935 }
936
937 // Check month
938 if ( !in_array( (int) $time['mon'], $parts['month'] ) ) {
939 return false;
940 }
941
942 // Check day of month OR day of week (standard cron behavior)
943 $dom_match = in_array( (int) $time['mday'], $parts['dom'] );
944 $dow_match = in_array( (int) $time['wday'], $parts['dow'] );
945
946 // Handle Sunday as both 0 and 7
947 if ( in_array( 7, $parts['dow'] ) && $time['wday'] == 0 ) {
948 $dow_match = true;
949 }
950
951 // If both dom and dow are restricted (*not* wildcards), either can match
952 // If one is wildcard, only the restricted one needs to match
953 $dom_restricted = !( count( $parts['dom'] ) === 31 );
954 $dow_restricted = !( count( $parts['dow'] ) === 8 || count( $parts['dow'] ) === 7 );
955
956 if ( $dom_restricted && $dow_restricted ) {
957 return $dom_match || $dow_match;
958 }
959 else if ( $dom_restricted ) {
960 return $dom_match;
961 }
962 else if ( $dow_restricted ) {
963 return $dow_match;
964 }
965
966 return true;
967 }
968
969 /**
970 * Log task start
971 */
972 private function log_start( $task_id ) {
973 $this->wpdb->insert(
974 $this->table_tasklogs,
975 [
976 'task_id' => $task_id,
977 'started' => gmdate( 'Y-m-d H:i:s' ),
978 'status' => 'running',
979 'created' => gmdate( 'Y-m-d H:i:s' ),
980 ]
981 );
982
983 return $this->wpdb->insert_id;
984 }
985
986 /**
987 * Log task end
988 */
989 private function log_end( $log_id, $result, $time_taken = null ) {
990 $status = 'error';
991 if ( $result['ok'] && $result['done'] ) {
992 $status = 'success';
993 }
994 else if ( $result['ok'] && !$result['done'] ) {
995 $status = 'partial';
996 }
997
998 $this->wpdb->update(
999 $this->table_tasklogs,
1000 [
1001 'ended' => gmdate( 'Y-m-d H:i:s' ),
1002 'status' => $status,
1003 'message' => substr( $result['message'], 0, 255 ),
1004 'time_taken' => $time_taken,
1005 'memory_peak' => memory_get_peak_usage(),
1006 'step' => $result['step'],
1007 ],
1008 [ 'id' => $log_id ]
1009 );
1010 }
1011
1012 /**
1013 * Migrate existing crons to tasks
1014 * TODO: Remove after January 2026 - This entire migration can be removed
1015 */
1016 public function migrate_existing_crons() {
1017 // Remove old cron hooks that may still exist
1018 wp_clear_scheduled_hook( 'mwai_discussions' );
1019 wp_clear_scheduled_hook( 'mwai_files_cleanup' );
1020 wp_clear_scheduled_hook( 'mwai_files' ); // In case this was used before
1021
1022 // Ensure cleanup_discussions task exists
1023 $this->ensure( [
1024 'name' => 'cleanup_discussions',
1025 'description' => 'Remove old discussions beyond retention period.',
1026 'category' => 'system',
1027 'schedule' => '0 3 * * *', // Daily at 3 AM UTC
1028 ] );
1029
1030 // Ensure cleanup_files task exists
1031 $this->ensure( [
1032 'name' => 'cleanup_files',
1033 'category' => 'system',
1034 'description' => 'Delete expired files based on expiration dates.',
1035 'schedule' => '0 4 * * *', // Daily at 4 AM UTC
1036 ] );
1037
1038 // Ensure cleanup_tasks exists
1039 $this->ensure( [
1040 'name' => 'cleanup_tasks',
1041 'description' => 'Clean old task logs and failed tasks.',
1042 'category' => 'system',
1043 'schedule' => '0 13 * * *', // Daily at 1 PM UTC
1044 'deletable' => 0, // System task, not deletable
1045 ] );
1046 }
1047
1048 /**
1049 * REST: List tasks
1050 */
1051 public function rest_tasks_list( $request ) {
1052 // Make sure table exists
1053 $this->check_db();
1054
1055 $tasks = $this->wpdb->get_results(
1056 "SELECT * FROM {$this->table_tasks} ORDER BY task_name ASC"
1057 );
1058
1059 if ( $tasks === false ) {
1060 return new WP_REST_Response( [ 'success' => false, 'message' => 'Database error', 'tasks' => [] ], 500 );
1061 }
1062
1063 if ( empty( $tasks ) ) {
1064 $tasks = [];
1065 }
1066
1067 // Add computed fields
1068 foreach ( $tasks as &$task ) {
1069 $task->data = json_decode( $task->data, true );
1070 $task->meta = json_decode( $task->meta, true );
1071 $task->step_data = $task->step_data ? json_decode( $task->step_data, true ) : null;
1072
1073 // Ensure integers are properly cast
1074 $task->step = (int) $task->step;
1075 $task->error_count = (int) $task->error_count;
1076 $task->max_retries = (int) $task->max_retries;
1077
1078 // Fix tasks that should be in error status but aren't
1079 if ( $task->error_count >= $task->max_retries && $task->status === 'pending' ) {
1080 $task->status = 'error';
1081 $task->next_run = null;
1082 }
1083
1084 // Determine if task is deletable (system tasks cannot be deleted)
1085 $task->deletable = !in_array( $task->task_name, ['cleanup_discussions', 'cleanup_files'] ) ? 1 : 0;
1086
1087 // Get last message from most recent log
1088 $last_log = $this->wpdb->get_row( $this->wpdb->prepare(
1089 "SELECT message FROM {$this->table_tasklogs} WHERE task_id = %d ORDER BY started DESC LIMIT 1",
1090 $task->id
1091 ) );
1092 $task->last_message = $last_log ? $last_log->message : null;
1093
1094 // Get log count for this task
1095 $log_count = $this->wpdb->get_var( $this->wpdb->prepare(
1096 "SELECT COUNT(*) FROM {$this->table_tasklogs} WHERE task_id = %d",
1097 $task->id
1098 ) );
1099 $task->log_count = (int) $log_count;
1100
1101 // Calculate next 3 run times for preview
1102 if ( $task->schedule !== 'once' && $task->status === 'pending' ) {
1103 $next_runs = [];
1104 $check_ts = $task->next_run ? strtotime( $task->next_run ) : time();
1105
1106 for ( $i = 0; $i < 3; $i++ ) {
1107 $check_ts = $this->cron_next( $task->schedule, $check_ts );
1108 $next_runs[] = gmdate( 'Y-m-d H:i:s', $check_ts );
1109 }
1110
1111 $task->next_runs_preview = $next_runs;
1112 }
1113 else {
1114 $task->next_runs_preview = [];
1115 }
1116
1117 // Format times for display
1118 if ( $task->last_run ) {
1119 $task->last_run_human = $this->human_time_diff( strtotime( $task->last_run ) );
1120 }
1121 else {
1122 $task->last_run_human = 'Never';
1123 }
1124
1125 // Only show next_run for tasks that are actually scheduled to run
1126 // Don't show next_run for error, done, or expired tasks
1127 if ( $task->next_run && in_array( $task->status, ['pending', 'running', 'paused'] ) ) {
1128 $task->next_run_human = $this->human_time_diff( strtotime( $task->next_run ) );
1129 }
1130 else {
1131 $task->next_run_human = null;
1132 $task->next_run = null; // Clear it so frontend doesn't try to display it
1133 }
1134 }
1135
1136 return new WP_REST_Response( [ 'success' => true, 'tasks' => $tasks ], 200 );
1137 }
1138
1139 /**
1140 * REST: Run task now
1141 */
1142 public function rest_task_run( $request ) {
1143 $params = $request->get_json_params();
1144 $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
1145
1146 if ( !$task_name ) {
1147 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1148 }
1149
1150 $result = $this->run_now( $task_name );
1151
1152 if ( $result ) {
1153 return new WP_REST_Response( [ 'success' => true, 'message' => 'Task scheduled to run' ], 200 );
1154 }
1155
1156 return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to run task' ], 500 );
1157 }
1158
1159 /**
1160 * REST: Pause task
1161 */
1162 public function rest_task_pause( $request ) {
1163 $params = $request->get_json_params();
1164 $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
1165
1166 if ( !$task_name ) {
1167 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1168 }
1169
1170 $result = $this->pause( $task_name );
1171
1172 if ( $result ) {
1173 return new WP_REST_Response( [ 'success' => true, 'message' => 'Task paused' ], 200 );
1174 }
1175
1176 return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to pause task' ], 500 );
1177 }
1178
1179 /**
1180 * REST: Resume task
1181 */
1182 public function rest_task_resume( $request ) {
1183 $params = $request->get_json_params();
1184 $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
1185
1186 if ( !$task_name ) {
1187 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1188 }
1189
1190 $result = $this->resume( $task_name );
1191
1192 if ( $result ) {
1193 return new WP_REST_Response( [ 'success' => true, 'message' => 'Task resumed' ], 200 );
1194 }
1195
1196 return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to resume task' ], 500 );
1197 }
1198
1199 /**
1200 * REST: Delete task
1201 */
1202 public function rest_task_delete( $request ) {
1203 $params = $request->get_json_params();
1204 $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
1205 $delete_logs = isset( $params['delete_logs'] ) ? $params['delete_logs'] : true;
1206
1207 if ( !$task_name ) {
1208 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1209 }
1210
1211 $result = $this->remove( $task_name, [ 'delete_logs' => $delete_logs ] );
1212
1213 if ( $result ) {
1214 return new WP_REST_Response( [ 'success' => true, 'message' => 'Task deleted' ], 200 );
1215 }
1216
1217 return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to delete task' ], 500 );
1218 }
1219
1220 /**
1221 * REST: Get task logs
1222 */
1223 public function rest_task_logs( $request ) {
1224 $task_name = $request->get_param( 'task_name' );
1225
1226 if ( !$task_name ) {
1227 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1228 }
1229
1230 // Get task ID
1231 $task_id = $this->wpdb->get_var( $this->wpdb->prepare(
1232 "SELECT id FROM {$this->table_tasks} WHERE task_name = %s",
1233 $task_name
1234 ) );
1235
1236 if ( !$task_id ) {
1237 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task not found' ], 404 );
1238 }
1239
1240 // Get logs
1241 $logs = $this->wpdb->get_results( $this->wpdb->prepare(
1242 "SELECT * FROM {$this->table_tasklogs}
1243 WHERE task_id = %d
1244 ORDER BY started DESC
1245 LIMIT 50",
1246 $task_id
1247 ) );
1248
1249 return new WP_REST_Response( [ 'success' => true, 'logs' => $logs ], 200 );
1250 }
1251
1252 /**
1253 * REST: Delete task logs
1254 */
1255 public function rest_task_logs_delete( $request ) {
1256 $params = $request->get_json_params();
1257 $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
1258
1259 if ( !$task_name ) {
1260 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1261 }
1262
1263 // Get task ID
1264 $task_id = $this->wpdb->get_var( $this->wpdb->prepare(
1265 "SELECT id FROM {$this->table_tasks} WHERE task_name = %s",
1266 $task_name
1267 ) );
1268
1269 if ( !$task_id ) {
1270 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task not found' ], 404 );
1271 }
1272
1273 // Delete logs for this task
1274 $result = $this->wpdb->delete(
1275 $this->table_tasklogs,
1276 [ 'task_id' => $task_id ],
1277 [ '%d' ]
1278 );
1279
1280 if ( $result !== false ) {
1281 return new WP_REST_Response( [ 'success' => true, 'message' => 'Logs deleted successfully' ], 200 );
1282 }
1283
1284 return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to delete logs' ], 500 );
1285 }
1286
1287 /**
1288 * REST: Reset all tasks
1289 */
1290 public function rest_tasks_reset( $request ) {
1291 // Clear all WordPress cron jobs related to tasks
1292 wp_clear_scheduled_hook( 'mwai_tasks_internal_run' );
1293 wp_clear_scheduled_hook( 'mwai_tasks_internal_dev_run' );
1294
1295 // Clear all transients
1296 delete_transient( 'mwai_cron_last_run' );
1297 delete_transient( 'mwai_cron_running_mwai_tasks_internal_run' );
1298 delete_transient( 'mwai_cron_running_mwai_tasks_internal_dev_run' );
1299
1300 // Truncate task logs table
1301 $this->wpdb->query( "TRUNCATE TABLE {$this->table_tasklogs}" );
1302
1303 // Delete all tasks
1304 $this->wpdb->query( "TRUNCATE TABLE {$this->table_tasks}" );
1305
1306 // Re-initialize the Tasks Runner cron
1307 $dev_mode = $this->core->get_option( 'dev_mode' );
1308 if ( $dev_mode ) {
1309 wp_schedule_event( time() + 5, 'five_seconds', 'mwai_tasks_internal_dev_run' );
1310 }
1311 else {
1312 wp_schedule_event( time() + 60, 'one_minute', 'mwai_tasks_internal_run' );
1313 }
1314
1315 // Re-create system tasks
1316 $this->migrate_existing_crons();
1317
1318 return new WP_REST_Response( [
1319 'success' => true,
1320 'message' => 'Tasks system has been reset. All tasks and logs have been cleared, and system tasks have been re-created.'
1321 ], 200 );
1322 }
1323
1324 /**
1325 * Helper: Human-readable time difference (abbreviated)
1326 */
1327 private function human_time_diff( $timestamp ) {
1328 // Use current time consistently
1329 $now = time();
1330 $diff = $timestamp - $now;
1331
1332 if ( $diff < 0 ) {
1333 // Past
1334 $diff = abs( $diff );
1335 $suffix = ' ago';
1336 }
1337 else {
1338 // Future
1339 $suffix = '';
1340 }
1341
1342 // Use abbreviated format
1343 if ( $diff < 60 ) {
1344 return $diff . 's' . $suffix;
1345 }
1346 else if ( $diff < 3600 ) {
1347 $minutes = round( $diff / 60 );
1348 return $minutes . 'm' . $suffix;
1349 }
1350 else if ( $diff < 86400 ) {
1351 $hours = round( $diff / 3600 );
1352 return $hours . 'h' . $suffix;
1353 }
1354 else {
1355 $days = round( $diff / 86400 );
1356 return $days . 'd' . $suffix;
1357 }
1358 }
1359
1360 /**
1361 * Check and create database tables
1362 */
1363 public function check_db() {
1364 // Don't run multiple times
1365 if ( $this->db_check ) {
1366 return true;
1367 }
1368
1369 // Check if tables exist
1370 $tasks_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_tasks'" );
1371 $logs_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_tasklogs'" );
1372
1373 if ( !$tasks_exists || !$logs_exists ) {
1374 $this->create_db();
1375 }
1376
1377 // Check for database upgrades
1378 $this->upgrade_db();
1379
1380 $this->db_check = true;
1381 return true;
1382 }
1383
1384 /**
1385 * Upgrade database schema if needed
1386 */
1387 private function upgrade_db() {
1388 // Add category column if it doesn't exist
1389 $category_exists = $this->wpdb->get_var(
1390 "SHOW COLUMNS FROM {$this->table_tasks} LIKE 'category'"
1391 );
1392
1393 if ( !$category_exists ) {
1394 $this->wpdb->query(
1395 "ALTER TABLE {$this->table_tasks}
1396 ADD COLUMN category VARCHAR(32) NOT NULL DEFAULT 'general' AFTER description"
1397 );
1398 }
1399
1400 // Remove deprecated columns if they exist
1401 $columns_to_remove = ['auto_delete', 'deletable', 'is_multistep', 'last_message'];
1402
1403 foreach ( $columns_to_remove as $column ) {
1404 $column_exists = $this->wpdb->get_var(
1405 "SHOW COLUMNS FROM {$this->table_tasks} LIKE '$column'"
1406 );
1407
1408 if ( $column_exists ) {
1409 $this->wpdb->query( "ALTER TABLE {$this->table_tasks} DROP COLUMN $column" );
1410 }
1411 }
1412
1413 // Add step_data column if it doesn't exist
1414 $step_data_exists = $this->wpdb->get_var(
1415 "SHOW COLUMNS FROM {$this->table_tasks} LIKE 'step_data'"
1416 );
1417
1418 if ( !$step_data_exists ) {
1419 $this->wpdb->query(
1420 "ALTER TABLE {$this->table_tasks}
1421 ADD COLUMN step_data LONGTEXT NULL AFTER step_name"
1422 );
1423 }
1424 }
1425
1426 /**
1427 * Create database tables
1428 */
1429 public function create_db() {
1430 $charset_collate = $this->wpdb->get_charset_collate();
1431
1432 $sql_tasks = "CREATE TABLE $this->table_tasks (
1433 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
1434 task_name VARCHAR(100) NOT NULL,
1435 description TEXT NULL,
1436 category VARCHAR(32) NOT NULL DEFAULT 'general',
1437 schedule VARCHAR(128) NOT NULL,
1438 status VARCHAR(16) NOT NULL DEFAULT 'pending',
1439 next_run DATETIME NULL,
1440 last_run DATETIME NULL,
1441 expires_at DATETIME NULL,
1442 step INT NOT NULL DEFAULT 0,
1443 step_name VARCHAR(64) NULL,
1444 step_data LONGTEXT NULL,
1445 data LONGTEXT NULL,
1446 meta LONGTEXT NULL,
1447 error_count INT NOT NULL DEFAULT 0,
1448 max_retries INT NOT NULL DEFAULT 3,
1449 created DATETIME NOT NULL,
1450 updated DATETIME NOT NULL,
1451 PRIMARY KEY (id),
1452 UNIQUE KEY task_name (task_name),
1453 KEY status_next (status, next_run),
1454 KEY category (category),
1455 KEY expires (expires_at)
1456 ) $charset_collate;";
1457
1458 $sql_logs = "CREATE TABLE $this->table_tasklogs (
1459 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
1460 task_id BIGINT UNSIGNED NOT NULL,
1461 started DATETIME NOT NULL,
1462 ended DATETIME NULL,
1463 status VARCHAR(16) NOT NULL,
1464 message TEXT NULL,
1465 time_taken FLOAT NULL,
1466 memory_peak BIGINT NULL,
1467 step INT NULL,
1468 created DATETIME NOT NULL,
1469 PRIMARY KEY (id),
1470 KEY task_id_started (task_id, started)
1471 ) $charset_collate;";
1472
1473 require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
1474 dbDelta( $sql_tasks );
1475 dbDelta( $sql_logs );
1476 }
1477 }
1478