PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.5.3
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.5.3
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 3 months ago chatbot.php 1 month ago discussions.php 2 months ago editor-assistant.php 3 months ago files.php 3 months ago forms-manager.php 3 months ago gdpr.php 4 months ago search.php 3 months ago security.php 11 months ago tasks-examples.php 6 months ago tasks.php 1 month ago wand.php 3 months ago
tasks.php
1497 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 if ( is_admin() ) {
37 add_action( 'init', [ $this, 'ensure_system_tasks' ], 20 );
38 add_action( 'admin_init', [ $this, 'fix_overdue_cron' ] );
39 }
40
41 // Schedule crons on init (after custom schedules are registered)
42 add_action( 'init', [ $this, 'ensure_cron_scheduled' ], 15 );
43
44 // Load the Tasks Examples module (includes test task functionality)
45 require_once( __DIR__ . '/tasks-examples.php' );
46 new Meow_MWAI_Modules_Tasks_Examples( $core );
47 }
48
49 /**
50 * Ensure cron is scheduled properly
51 */
52 public function ensure_cron_scheduled() {
53 $dev_mode = $this->core->get_option( 'dev_mode' );
54 $hook = $dev_mode ? 'mwai_tasks_internal_dev_run' : 'mwai_tasks_internal_run';
55 $opposite_hook = $dev_mode ? 'mwai_tasks_internal_run' : 'mwai_tasks_internal_dev_run';
56
57 // Clear opposite hook
58 wp_clear_scheduled_hook( $opposite_hook );
59
60 // Check if current hook is scheduled and not overdue
61 $next = wp_next_scheduled( $hook );
62
63 // If not scheduled or overdue by more than 5 minutes, reschedule
64 if ( !$next || $next < ( time() - 300 ) ) {
65 wp_clear_scheduled_hook( $hook );
66
67 if ( $dev_mode ) {
68 wp_schedule_event( time() + 5, 'five_seconds', $hook );
69 }
70 else {
71 wp_schedule_event( time() + 60, 'one_minute', $hook );
72 }
73 }
74 }
75
76 /**
77 * Fix overdue cron events
78 */
79 public function fix_overdue_cron() {
80 $dev_mode = $this->core->get_option( 'dev_mode' );
81
82 if ( $dev_mode ) {
83 // Clear production cron if it exists
84 wp_clear_scheduled_hook( 'mwai_tasks_internal_run' );
85
86 // Ensure dev cron is scheduled
87 if ( !wp_next_scheduled( 'mwai_tasks_internal_dev_run' ) ) {
88 wp_schedule_event( time() + 5, 'five_seconds', 'mwai_tasks_internal_dev_run' );
89 }
90 }
91 else {
92 // Clear dev cron if it exists
93 wp_clear_scheduled_hook( 'mwai_tasks_internal_dev_run' );
94
95 // Ensure production cron is scheduled
96 if ( !wp_next_scheduled( 'mwai_tasks_internal_run' ) ) {
97 wp_schedule_event( time() + 60, 'one_minute', 'mwai_tasks_internal_run' );
98 }
99 }
100 }
101
102 public function custom_cron_schedule( $schedules ) {
103 $schedules['one_minute'] = [ 'display' => __( 'Every Minute' ), 'interval' => 60 ];
104 $schedules['five_seconds'] = [ 'display' => __( 'Every 5 Seconds' ), 'interval' => 5 ];
105 return $schedules;
106 }
107
108 public function rest_api_init() {
109 register_rest_route( $this->namespace, '/helpers/tasks_list', [
110 'methods' => 'GET',
111 'callback' => [ $this, 'rest_tasks_list' ],
112 'permission_callback' => [ $this->core, 'can_access_settings' ],
113 ] );
114
115 register_rest_route( $this->namespace, '/helpers/task_run', [
116 'methods' => 'POST',
117 'callback' => [ $this, 'rest_task_run' ],
118 'permission_callback' => [ $this->core, 'can_access_settings' ],
119 ] );
120
121 register_rest_route( $this->namespace, '/helpers/task_pause', [
122 'methods' => 'POST',
123 'callback' => [ $this, 'rest_task_pause' ],
124 'permission_callback' => [ $this->core, 'can_access_settings' ],
125 ] );
126
127 register_rest_route( $this->namespace, '/helpers/task_resume', [
128 'methods' => 'POST',
129 'callback' => [ $this, 'rest_task_resume' ],
130 'permission_callback' => [ $this->core, 'can_access_settings' ],
131 ] );
132
133 register_rest_route( $this->namespace, '/helpers/task_delete', [
134 'methods' => 'POST',
135 'callback' => [ $this, 'rest_task_delete' ],
136 'permission_callback' => [ $this->core, 'can_access_settings' ],
137 ] );
138
139 register_rest_route( $this->namespace, '/helpers/task_logs', [
140 'methods' => 'GET',
141 'callback' => [ $this, 'rest_task_logs' ],
142 'permission_callback' => [ $this->core, 'can_access_settings' ],
143 ] );
144
145 register_rest_route( $this->namespace, '/helpers/task_logs_delete', [
146 'methods' => 'POST',
147 'callback' => [ $this, 'rest_task_logs_delete' ],
148 'permission_callback' => [ $this->core, 'can_access_settings' ],
149 ] );
150
151 register_rest_route( $this->namespace, '/helpers/tasks_reset', [
152 'methods' => 'POST',
153 'callback' => [ $this, 'rest_tasks_reset' ],
154 'permission_callback' => [ $this->core, 'can_access_settings' ],
155 ] );
156 }
157
158 /**
159 * Ensure a task exists or update its configuration
160 */
161 public function ensure( $args ) {
162 $defaults = [
163 'name' => '',
164 'description' => '',
165 'category' => 'general',
166 'schedule' => 'once',
167 'next_run' => null, // Allow specifying when a one-time task should run
168 'is_multistep' => 0,
169 'expires_at' => null,
170 'auto_delete' => 0,
171 'deletable' => 1,
172 'data' => null,
173 'step_name' => null,
174 ];
175
176 $args = wp_parse_args( $args, $defaults );
177
178 if ( empty( $args['name'] ) ) {
179 return new WP_Error( 'invalid_name', 'Task name is required' );
180 }
181
182 // Check if task exists
183 $existing = $this->wpdb->get_row( $this->wpdb->prepare(
184 "SELECT * FROM {$this->table_tasks} WHERE task_name = %s",
185 $args['name']
186 ) );
187
188 $now = gmdate( 'Y-m-d H:i:s' );
189
190 if ( $existing ) {
191 // Update existing task
192 $update_data = [
193 'description' => $args['description'],
194 'category' => $args['category'],
195 'updated' => $now,
196 ];
197
198 // Only update these if they've changed
199 if ( $args['schedule'] !== $existing->schedule ) {
200 $update_data['schedule'] = $args['schedule'];
201 $update_data['next_run'] = $this->calculate_next_run( $args['schedule'] );
202 }
203
204 if ( $args['expires_at'] !== $existing->expires_at ) {
205 $update_data['expires_at'] = $args['expires_at'];
206 }
207
208 if ( $args['data'] !== null ) {
209 $existing_data = json_decode( $existing->data, true ) ?: [];
210 $merged_data = array_merge( $existing_data, $args['data'] );
211 $update_data['data'] = json_encode( $merged_data );
212 }
213
214 if ( $args['step_name'] !== null ) {
215 $update_data['step_name'] = $args['step_name'];
216 }
217
218 $result = $this->wpdb->update(
219 $this->table_tasks,
220 $update_data,
221 [ 'task_name' => $args['name'] ]
222 );
223
224 return $result !== false;
225 }
226 else {
227 // Create new task
228 // Use provided next_run for one-time tasks, otherwise calculate from schedule
229 if ( $args['schedule'] === 'once' && $args['next_run'] ) {
230 $next_run = $args['next_run'];
231 }
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 }
340 else {
341 $update_data[$key] = $value;
342 }
343 }
344
345 return $this->wpdb->update(
346 $this->table_tasks,
347 $update_data,
348 [ 'task_name' => $task_name ]
349 ) !== false;
350 }
351
352 /**
353 * Update task fields
354 */
355 public function update( $task_name, $fields ) {
356 $allowed_fields = [ 'schedule', 'description', 'data', 'expires_at', 'step', 'step_name', 'step_data' ];
357 $update_data = [ 'updated' => gmdate( 'Y-m-d H:i:s' ) ];
358
359 foreach ( $fields as $key => $value ) {
360 if ( in_array( $key, $allowed_fields ) ) {
361 if ( $key === 'data' || $key === 'step_data' ) {
362 $update_data[$key] = json_encode( $value );
363 }
364 else if ( $key === 'schedule' ) {
365 $update_data[$key] = $value;
366 $update_data['next_run'] = $this->calculate_next_run( $value );
367 }
368 else {
369 $update_data[$key] = $value;
370 }
371 }
372 }
373
374 $result = $this->wpdb->update(
375 $this->table_tasks,
376 $update_data,
377 [ 'task_name' => $task_name ]
378 );
379
380 return $result !== false;
381 }
382
383 /**
384 * Remove a task
385 */
386 public function remove( $task_name, $opts = [] ) {
387 $delete_logs = isset( $opts['delete_logs'] ) ? $opts['delete_logs'] : false;
388
389 // Get task ID for logs deletion
390 if ( $delete_logs ) {
391 $task_id = $this->wpdb->get_var( $this->wpdb->prepare(
392 "SELECT id FROM {$this->table_tasks} WHERE task_name = %s",
393 $task_name
394 ) );
395
396 if ( $task_id ) {
397 $this->wpdb->delete( $this->table_tasklogs, [ 'task_id' => $task_id ] );
398 }
399 }
400
401 $result = $this->wpdb->delete( $this->table_tasks, [ 'task_name' => $task_name ] );
402
403 return $result !== false;
404 }
405
406 /**
407 * Pause a task
408 */
409 public function pause( $task_name ) {
410 $result = $this->wpdb->update(
411 $this->table_tasks,
412 [ 'status' => 'paused', 'updated' => gmdate( 'Y-m-d H:i:s' ) ],
413 [ 'task_name' => $task_name ]
414 );
415
416 return $result !== false;
417 }
418
419 /**
420 * Resume a task
421 */
422 public function resume( $task_name ) {
423 // Get the task to determine schedule
424 $task = $this->wpdb->get_row( $this->wpdb->prepare(
425 "SELECT * FROM {$this->table_tasks} WHERE task_name = %s",
426 $task_name
427 ) );
428
429 if ( !$task ) {
430 return false;
431 }
432
433 $next_run = $this->calculate_next_run( $task->schedule );
434
435 $result = $this->wpdb->update(
436 $this->table_tasks,
437 [
438 'status' => 'pending',
439 'next_run' => $next_run,
440 'updated' => gmdate( 'Y-m-d H:i:s' )
441 ],
442 [ 'task_name' => $task_name ]
443 );
444
445 return $result !== false;
446 }
447
448 /**
449 * Run a task immediately
450 */
451 public function run_now( $task_name ) {
452 // First check if task is stuck and reset it
453 $task = $this->wpdb->get_row( $this->wpdb->prepare(
454 "SELECT * FROM {$this->table_tasks} WHERE task_name = %s",
455 $task_name
456 ) );
457
458 if ( $task && $task->status === 'running' ) {
459 // Reset stuck task - be more aggressive (1 minute instead of 10)
460 $this->reset_stuck_tasks( 1 );
461 }
462
463 $result = $this->wpdb->update(
464 $this->table_tasks,
465 [
466 'status' => 'pending',
467 'next_run' => gmdate( 'Y-m-d H:i:s' ),
468 'updated' => gmdate( 'Y-m-d H:i:s' )
469 ],
470 [ 'task_name' => $task_name ]
471 );
472
473 if ( $result !== false ) {
474 // Optionally run tick once (but keep it light)
475 $this->tick();
476 return true;
477 }
478
479 return false;
480 }
481
482 /**
483 * Reset tasks that are stuck in running state
484 */
485 public function reset_stuck_tasks( $minutes_threshold = 10 ) {
486 $now = gmdate( 'Y-m-d H:i:s' );
487 $stuck_cutoff = gmdate( 'Y-m-d H:i:s', strtotime( "-{$minutes_threshold} minutes" ) );
488
489 $count = $this->wpdb->query( $this->wpdb->prepare(
490 "UPDATE {$this->table_tasks}
491 SET status = 'pending',
492 error_count = error_count + 1,
493 updated = %s
494 WHERE status = 'running'
495 AND updated < %s",
496 $now,
497 $stuck_cutoff
498 ) );
499
500 return $count;
501 }
502
503 /**
504 * Main execution loop - called by cron
505 */
506 public function tick() {
507 // Track cron execution for proper "last run" display
508 // Determine which hook is actually running
509 $dev_mode = $this->core->get_option( 'dev_mode' );
510 $hook_name = $dev_mode ? 'mwai_tasks_internal_dev_run' : 'mwai_tasks_internal_run';
511 $this->core->track_cron_start( $hook_name );
512
513 // Use UTC consistently
514 $now = gmdate( 'Y-m-d H:i:s' );
515
516 // First, reset any stuck tasks (running for more than 10 minutes)
517 $this->reset_stuck_tasks();
518
519 // Get due tasks
520 $tasks = $this->wpdb->get_results( $this->wpdb->prepare(
521 "SELECT * FROM {$this->table_tasks}
522 WHERE status IN ('pending', 'error')
523 AND next_run <= %s
524 AND (expires_at IS NULL OR expires_at > %s)
525 ORDER BY next_run ASC
526 LIMIT %d",
527 $now,
528 $now,
529 $this->max_tasks_per_tick
530 ) );
531
532 foreach ( $tasks as $task ) {
533 $this->execute_task( $task );
534 }
535
536 // Track cron completion
537 $this->core->track_cron_end( $hook_name );
538 }
539
540 /**
541 * Execute a single task
542 */
543 private function execute_task( $task ) {
544 // Atomically claim the task
545 $claimed = $this->wpdb->update(
546 $this->table_tasks,
547 [ 'status' => 'running', 'updated' => gmdate( 'Y-m-d H:i:s' ) ],
548 [
549 'id' => $task->id,
550 'status' => $task->status // Ensure it hasn't changed
551 ]
552 );
553
554 if ( !$claimed ) {
555 return; // Another process got it
556 }
557
558 // Start logging
559 $log_id = $this->log_start( $task->id );
560 $start_time = microtime( true );
561
562 // Build job array
563 $job = [
564 'name' => $task->task_name,
565 'schedule' => $task->schedule,
566 'step' => $task->step,
567 'step_name' => $task->step_name,
568 'data' => json_decode( $task->data, true ) ?: [],
569 'meta' => json_decode( $task->meta, true ) ?: [],
570 ];
571
572 // Call the filter with error handling
573 try {
574 $result = apply_filters( "mwai_task_{$task->task_name}", null, $job );
575
576 // Fallback to generic filter if specific one returns null
577 if ( $result === null ) {
578 $result = apply_filters( 'mwai_task_run', null, $job );
579 }
580
581 // Default result if nothing handles it
582 if ( $result === null ) {
583 $result = [
584 'ok' => true,
585 'message' => "No handler for '{$task->task_name}'",
586 ];
587 }
588 }
589 catch ( Exception $e ) {
590 $result = [
591 'ok' => false,
592 'message' => 'Exception: ' . $e->getMessage(),
593 ];
594 }
595 catch ( Error $e ) {
596 $result = [
597 'ok' => false,
598 'message' => 'Fatal error: ' . $e->getMessage(),
599 ];
600 }
601
602 // Normalize result
603 $result = $this->normalize_result( $result );
604
605 // Log the end
606 $time_taken = microtime( true ) - $start_time;
607 $this->log_end( $log_id, $result, $time_taken );
608
609 // Update task based on result
610 $this->finish_from_result( $task, $result );
611 }
612
613 /**
614 * Normalize task result
615 */
616 private function normalize_result( $result ) {
617 if ( !is_array( $result ) ) {
618 return [
619 'ok' => false,
620 'message' => 'Invalid result format',
621 ];
622 }
623
624 $defaults = [
625 'ok' => false,
626 'done' => true,
627 'message' => '',
628 'step' => null,
629 'step_name' => null,
630 'data' => null,
631 'meta' => null,
632 'next_run_delay' => null,
633 ];
634
635 return wp_parse_args( $result, $defaults );
636 }
637
638 /**
639 * Update task after execution
640 */
641 private function finish_from_result( $task, $result ) {
642 $now_ts = time();
643 $now = gmdate( 'Y-m-d H:i:s', $now_ts );
644
645 // Merge data and meta if provided
646 $data = json_decode( $task->data, true ) ?: [];
647 $meta = json_decode( $task->meta, true ) ?: [];
648
649 if ( $result['data'] !== null && is_array( $result['data'] ) ) {
650 $data = array_merge( $data, $result['data'] );
651 }
652
653 if ( $result['meta'] !== null && is_array( $result['meta'] ) ) {
654 $meta = array_merge( $meta, $result['meta'] );
655 }
656
657 $update_data = [
658 'data' => json_encode( $data ),
659 'meta' => json_encode( $meta ),
660 'last_run' => $now,
661 'updated' => $now,
662 ];
663
664 // Update step if provided
665 if ( $result['step'] !== null ) {
666 $update_data['step'] = $result['step'];
667 }
668 if ( $result['step_name'] !== null ) {
669 $update_data['step_name'] = $result['step_name'];
670 }
671
672 if ( $result['ok'] ) {
673 // Success path
674 $update_data['error_count'] = 0;
675
676 if ( !$result['done'] ) {
677 // Multi-step task not finished - continue quickly
678 $update_data['status'] = 'pending';
679 $delay = isset( $result['next_run_delay'] ) ? (int) $result['next_run_delay'] : 10;
680 if ( $delay < 1 ) {
681 $delay = 1;
682 }
683 $update_data['next_run'] = gmdate( 'Y-m-d H:i:s', $now_ts + $delay );
684 }
685 else if ( $task->schedule === 'once' ) {
686 // One-off task completed
687 $update_data['status'] = 'done';
688 $update_data['next_run'] = null;
689 }
690 else {
691 // Recurring task completed this cycle
692 $update_data['status'] = 'pending';
693 $update_data['next_run'] = $this->calculate_next_run( $task->schedule, $now_ts );
694 $update_data['step'] = 0;
695 $update_data['step_name'] = null;
696 }
697 }
698 else {
699 // Error path
700 $update_data['error_count'] = $task->error_count + 1;
701
702 if ( $update_data['error_count'] >= $task->max_retries ) {
703 // Max retries reached or exceeded
704 $update_data['status'] = 'error';
705 $update_data['next_run'] = null;
706 }
707 else {
708 // Retry with backoff
709 $update_data['status'] = 'pending';
710 $update_data['next_run'] = gmdate( 'Y-m-d H:i:s', $now_ts + 300 ); // 5 minutes
711 }
712 }
713
714 // Check expiration
715 if ( $task->expires_at && strtotime( $task->expires_at ) <= $now_ts ) {
716 $update_data['status'] = 'expired';
717 $update_data['next_run'] = null;
718 }
719
720 // Update the task
721 $this->wpdb->update(
722 $this->table_tasks,
723 $update_data,
724 [ 'id' => $task->id ]
725 );
726
727 // Auto-delete expired tasks that have an expiration date
728 if ( $task->expires_at && $update_data['status'] === 'expired' ) {
729 $this->wpdb->delete( $this->table_tasks, [ 'id' => $task->id ] );
730 // Also delete logs for expired tasks
731 $this->wpdb->delete( $this->table_tasklogs, [ 'task_id' => $task->id ] );
732 }
733 }
734
735 /**
736 * Handle cleanup tasks - remove old logs and failed tasks
737 */
738 public function handle_cleanup_tasks( $result, $job ) {
739 try {
740 $now = gmdate( 'Y-m-d H:i:s' );
741 $stats = [
742 'logs_deleted' => 0,
743 'failed_tasks_deleted' => 0,
744 'expired_tasks_deleted' => 0,
745 ];
746
747 // 1. Delete task logs older than 7 days
748 $week_ago = gmdate( 'Y-m-d H:i:s', strtotime( '-7 days' ) );
749 $logs_deleted = $this->wpdb->query( $this->wpdb->prepare(
750 "DELETE FROM {$this->table_tasklogs} WHERE created < %s",
751 $week_ago
752 ) );
753 $stats['logs_deleted'] = $logs_deleted ? $logs_deleted : 0;
754
755 // 2. Delete failed tasks that have been in error state for over 30 days
756 $month_ago = gmdate( 'Y-m-d H:i:s', strtotime( '-30 days' ) );
757 $failed_tasks = $this->wpdb->get_results( $this->wpdb->prepare(
758 "SELECT id, task_name FROM {$this->table_tasks}
759 WHERE status = 'error' AND updated < %s",
760 $month_ago
761 ) );
762
763 foreach ( $failed_tasks as $task ) {
764 // Delete the task and its logs
765 $this->wpdb->delete( $this->table_tasks, [ 'id' => $task->id ] );
766 $this->wpdb->delete( $this->table_tasklogs, [ 'task_id' => $task->id ] );
767 $stats['failed_tasks_deleted']++;
768 }
769
770 // 3. Delete expired tasks that have been expired for over 7 days
771 $expired_tasks = $this->wpdb->get_results( $this->wpdb->prepare(
772 "SELECT id, task_name FROM {$this->table_tasks}
773 WHERE status = 'expired' AND updated < %s",
774 $week_ago
775 ) );
776
777 foreach ( $expired_tasks as $task ) {
778 // Delete the task and its logs
779 $this->wpdb->delete( $this->table_tasks, [ 'id' => $task->id ] );
780 $this->wpdb->delete( $this->table_tasklogs, [ 'task_id' => $task->id ] );
781 $stats['expired_tasks_deleted']++;
782 }
783
784 // 4. Clean up orphaned logs (logs without corresponding tasks)
785 $orphaned_logs = $this->wpdb->query(
786 "DELETE tl FROM {$this->table_tasklogs} tl
787 LEFT JOIN {$this->table_tasks} t ON tl.task_id = t.id
788 WHERE t.id IS NULL"
789 );
790
791 $message = sprintf(
792 'Cleaned: %d logs, %d failed tasks, %d expired tasks',
793 $stats['logs_deleted'],
794 $stats['failed_tasks_deleted'],
795 $stats['expired_tasks_deleted']
796 );
797
798 return [
799 'ok' => true,
800 'message' => $message,
801 'data' => $stats
802 ];
803
804 }
805 catch ( Exception $e ) {
806 return [
807 'ok' => false,
808 'message' => 'Cleanup failed: ' . $e->getMessage()
809 ];
810 }
811 }
812
813 /**
814 * Calculate next run time
815 */
816 private function calculate_next_run( $schedule, $after_ts = null ) {
817 if ( $schedule === 'once' ) {
818 // For one-time tasks without a specific time, run immediately
819 return gmdate( 'Y-m-d H:i:s' );
820 }
821
822 if ( $after_ts === null ) {
823 $after_ts = time();
824 }
825
826 $next_ts = $this->cron_next( $schedule, $after_ts );
827 return gmdate( 'Y-m-d H:i:s', $next_ts );
828 }
829
830 /**
831 * Parse cron expression and get next run time
832 */
833 private function cron_next( $expr, $after_ts ) {
834 $parts = $this->parse_cron( $expr );
835 if ( !$parts ) {
836 // Invalid expression, return next hour
837 return $after_ts + 3600;
838 }
839
840 // Start from the next minute
841 $check_ts = $after_ts - ( $after_ts % 60 ) + 60;
842
843 // Check up to 2 years in the future (should be more than enough)
844 $max_ts = $after_ts + ( 2 * 365 * 24 * 60 * 60 );
845
846 while ( $check_ts < $max_ts ) {
847 $time = getdate( $check_ts );
848
849 if ( $this->cron_matches( $parts, $time ) ) {
850 return $check_ts;
851 }
852
853 // Move to next minute
854 $check_ts += 60;
855 }
856
857 // Fallback to next hour if no match found
858 return $after_ts + 3600;
859 }
860
861 /**
862 * Parse cron expression into parts
863 */
864 private function parse_cron( $expr ) {
865 if ( empty( $expr ) ) {
866 return false;
867 }
868
869 $fields = preg_split( '/\s+/', trim( $expr ) );
870 if ( count( $fields ) !== 5 ) {
871 return false;
872 }
873
874 return [
875 'minute' => $this->parse_cron_field( $fields[0], 0, 59 ),
876 'hour' => $this->parse_cron_field( $fields[1], 0, 23 ),
877 'dom' => $this->parse_cron_field( $fields[2], 1, 31 ),
878 'month' => $this->parse_cron_field( $fields[3], 1, 12 ),
879 'dow' => $this->parse_cron_field( $fields[4], 0, 7 ),
880 ];
881 }
882
883 /**
884 * Parse a single cron field
885 */
886 private function parse_cron_field( $field, $min, $max ) {
887 if ( $field === '*' ) {
888 return range( $min, $max );
889 }
890
891 $values = [];
892
893 // Handle step values (*/N)
894 if ( strpos( $field, '/' ) !== false ) {
895 list( $range, $step ) = explode( '/', $field );
896 $step = (int) $step;
897
898 if ( $range === '*' ) {
899 for ( $i = $min; $i <= $max; $i += $step ) {
900 $values[] = $i;
901 }
902 }
903 else if ( strpos( $range, '-' ) !== false ) {
904 list( $start, $end ) = explode( '-', $range );
905 $start = (int) $start;
906 $end = (int) $end;
907 for ( $i = $start; $i <= $end && $i <= $max; $i += $step ) {
908 $values[] = $i;
909 }
910 }
911 return $values;
912 }
913
914 // Handle ranges (N-M)
915 if ( strpos( $field, '-' ) !== false ) {
916 list( $start, $end ) = explode( '-', $field );
917 return range( (int) $start, min( (int) $end, $max ) );
918 }
919
920 // Handle lists (N,M,...)
921 if ( strpos( $field, ',' ) !== false ) {
922 $parts = explode( ',', $field );
923 foreach ( $parts as $part ) {
924 $values[] = (int) $part;
925 }
926 return $values;
927 }
928
929 // Single value
930 return [ (int) $field ];
931 }
932
933 /**
934 * Check if time matches cron expression
935 */
936 private function cron_matches( $parts, $time ) {
937 // Check minute
938 if ( !in_array( (int) $time['minutes'], $parts['minute'] ) ) {
939 return false;
940 }
941
942 // Check hour
943 if ( !in_array( (int) $time['hours'], $parts['hour'] ) ) {
944 return false;
945 }
946
947 // Check month
948 if ( !in_array( (int) $time['mon'], $parts['month'] ) ) {
949 return false;
950 }
951
952 // Check day of month OR day of week (standard cron behavior)
953 $dom_match = in_array( (int) $time['mday'], $parts['dom'] );
954 $dow_match = in_array( (int) $time['wday'], $parts['dow'] );
955
956 // Handle Sunday as both 0 and 7
957 if ( in_array( 7, $parts['dow'] ) && $time['wday'] == 0 ) {
958 $dow_match = true;
959 }
960
961 // If both dom and dow are restricted (*not* wildcards), either can match
962 // If one is wildcard, only the restricted one needs to match
963 $dom_restricted = !( count( $parts['dom'] ) === 31 );
964 $dow_restricted = !( count( $parts['dow'] ) === 8 || count( $parts['dow'] ) === 7 );
965
966 if ( $dom_restricted && $dow_restricted ) {
967 return $dom_match || $dow_match;
968 }
969 else if ( $dom_restricted ) {
970 return $dom_match;
971 }
972 else if ( $dow_restricted ) {
973 return $dow_match;
974 }
975
976 return true;
977 }
978
979 /**
980 * Log task start
981 */
982 private function log_start( $task_id ) {
983 $this->wpdb->insert(
984 $this->table_tasklogs,
985 [
986 'task_id' => $task_id,
987 'started' => gmdate( 'Y-m-d H:i:s' ),
988 'status' => 'running',
989 'created' => gmdate( 'Y-m-d H:i:s' ),
990 ]
991 );
992
993 return $this->wpdb->insert_id;
994 }
995
996 /**
997 * Log task end
998 */
999 private function log_end( $log_id, $result, $time_taken = null ) {
1000 $status = 'error';
1001 if ( $result['ok'] && $result['done'] ) {
1002 $status = 'success';
1003 }
1004 else if ( $result['ok'] && !$result['done'] ) {
1005 $status = 'partial';
1006 }
1007
1008 $this->wpdb->update(
1009 $this->table_tasklogs,
1010 [
1011 'ended' => gmdate( 'Y-m-d H:i:s' ),
1012 'status' => $status,
1013 'message' => substr( $result['message'], 0, 255 ),
1014 'time_taken' => $time_taken,
1015 'memory_peak' => memory_get_peak_usage(),
1016 'step' => $result['step'],
1017 ],
1018 [ 'id' => $log_id ]
1019 );
1020 }
1021
1022 public function ensure_system_tasks() {
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 $this->ensure( [
1031 'name' => 'cleanup_statistics',
1032 'description' => 'Remove old query logs beyond Insights retention period.',
1033 'category' => 'system',
1034 'schedule' => '0 2 * * *', // Daily at 2 AM UTC
1035 ] );
1036
1037 // Ensure cleanup_files task exists
1038 $this->ensure( [
1039 'name' => 'cleanup_files',
1040 'category' => 'system',
1041 'description' => 'Delete expired files based on expiration dates.',
1042 'schedule' => '0 4 * * *', // Daily at 4 AM UTC
1043 ] );
1044
1045 // Ensure cleanup_tasks exists
1046 $this->ensure( [
1047 'name' => 'cleanup_tasks',
1048 'description' => 'Clean old task logs and failed tasks.',
1049 'category' => 'system',
1050 'schedule' => '0 13 * * *', // Daily at 1 PM UTC
1051 'deletable' => 0, // System task, not deletable
1052 ] );
1053 }
1054
1055 /**
1056 * REST: List tasks
1057 */
1058 public function rest_tasks_list( $request ) {
1059 // Make sure table exists
1060 $this->check_db();
1061
1062 $tasks = $this->wpdb->get_results(
1063 "SELECT * FROM {$this->table_tasks} ORDER BY task_name ASC"
1064 );
1065
1066 if ( $tasks === false ) {
1067 return new WP_REST_Response( [ 'success' => false, 'message' => 'Database error', 'tasks' => [] ], 500 );
1068 }
1069
1070 if ( empty( $tasks ) ) {
1071 $tasks = [];
1072 }
1073
1074 // Add computed fields
1075 foreach ( $tasks as &$task ) {
1076 $task->data = json_decode( $task->data, true );
1077 $task->meta = json_decode( $task->meta, true );
1078 $task->step_data = $task->step_data ? json_decode( $task->step_data, true ) : null;
1079
1080 // Ensure integers are properly cast
1081 $task->step = (int) $task->step;
1082 $task->error_count = (int) $task->error_count;
1083 $task->max_retries = (int) $task->max_retries;
1084
1085 // Fix tasks that should be in error status but aren't
1086 if ( $task->error_count >= $task->max_retries && $task->status === 'pending' ) {
1087 $task->status = 'error';
1088 $task->next_run = null;
1089 }
1090
1091 // Determine if task is deletable (system tasks cannot be deleted)
1092 $task->deletable = !in_array( $task->task_name, ['cleanup_discussions', 'cleanup_files'] ) ? 1 : 0;
1093
1094 // Get last message from most recent log
1095 $last_log = $this->wpdb->get_row( $this->wpdb->prepare(
1096 "SELECT message FROM {$this->table_tasklogs} WHERE task_id = %d ORDER BY started DESC LIMIT 1",
1097 $task->id
1098 ) );
1099 $task->last_message = $last_log ? $last_log->message : null;
1100
1101 // Get log count for this task
1102 $log_count = $this->wpdb->get_var( $this->wpdb->prepare(
1103 "SELECT COUNT(*) FROM {$this->table_tasklogs} WHERE task_id = %d",
1104 $task->id
1105 ) );
1106 $task->log_count = (int) $log_count;
1107
1108 // Calculate next 3 run times for preview
1109 if ( $task->schedule !== 'once' && $task->status === 'pending' ) {
1110 $next_runs = [];
1111 $check_ts = $task->next_run ? strtotime( $task->next_run ) : time();
1112
1113 for ( $i = 0; $i < 3; $i++ ) {
1114 $check_ts = $this->cron_next( $task->schedule, $check_ts );
1115 $next_runs[] = gmdate( 'Y-m-d H:i:s', $check_ts );
1116 }
1117
1118 $task->next_runs_preview = $next_runs;
1119 }
1120 else {
1121 $task->next_runs_preview = [];
1122 }
1123
1124 // Format times for display
1125 if ( $task->last_run ) {
1126 $task->last_run_human = $this->human_time_diff( strtotime( $task->last_run ) );
1127 }
1128 else {
1129 $task->last_run_human = 'Never';
1130 }
1131
1132 // Only show next_run for tasks that are actually scheduled to run
1133 // Don't show next_run for error, done, or expired tasks
1134 if ( $task->next_run && in_array( $task->status, ['pending', 'running', 'paused'] ) ) {
1135 $task->next_run_human = $this->human_time_diff( strtotime( $task->next_run ) );
1136 }
1137 else {
1138 $task->next_run_human = null;
1139 $task->next_run = null; // Clear it so frontend doesn't try to display it
1140 }
1141 }
1142
1143 return new WP_REST_Response( [ 'success' => true, 'tasks' => $tasks ], 200 );
1144 }
1145
1146 /**
1147 * REST: Run task now
1148 */
1149 public function rest_task_run( $request ) {
1150 $params = $request->get_json_params();
1151 $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
1152
1153 if ( !$task_name ) {
1154 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1155 }
1156
1157 $result = $this->run_now( $task_name );
1158
1159 if ( $result ) {
1160 return new WP_REST_Response( [ 'success' => true, 'message' => 'Task scheduled to run' ], 200 );
1161 }
1162
1163 return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to run task' ], 500 );
1164 }
1165
1166 /**
1167 * REST: Pause task
1168 */
1169 public function rest_task_pause( $request ) {
1170 $params = $request->get_json_params();
1171 $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
1172
1173 if ( !$task_name ) {
1174 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1175 }
1176
1177 $result = $this->pause( $task_name );
1178
1179 if ( $result ) {
1180 return new WP_REST_Response( [ 'success' => true, 'message' => 'Task paused' ], 200 );
1181 }
1182
1183 return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to pause task' ], 500 );
1184 }
1185
1186 /**
1187 * REST: Resume task
1188 */
1189 public function rest_task_resume( $request ) {
1190 $params = $request->get_json_params();
1191 $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
1192
1193 if ( !$task_name ) {
1194 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1195 }
1196
1197 $result = $this->resume( $task_name );
1198
1199 if ( $result ) {
1200 return new WP_REST_Response( [ 'success' => true, 'message' => 'Task resumed' ], 200 );
1201 }
1202
1203 return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to resume task' ], 500 );
1204 }
1205
1206 /**
1207 * REST: Delete task
1208 */
1209 public function rest_task_delete( $request ) {
1210 $params = $request->get_json_params();
1211 $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
1212 $delete_logs = isset( $params['delete_logs'] ) ? $params['delete_logs'] : true;
1213
1214 if ( !$task_name ) {
1215 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1216 }
1217
1218 $result = $this->remove( $task_name, [ 'delete_logs' => $delete_logs ] );
1219
1220 if ( $result ) {
1221 return new WP_REST_Response( [ 'success' => true, 'message' => 'Task deleted' ], 200 );
1222 }
1223
1224 return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to delete task' ], 500 );
1225 }
1226
1227 /**
1228 * REST: Get task logs
1229 */
1230 public function rest_task_logs( $request ) {
1231 $task_name = $request->get_param( 'task_name' );
1232
1233 if ( !$task_name ) {
1234 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1235 }
1236
1237 // Get task ID
1238 $task_id = $this->wpdb->get_var( $this->wpdb->prepare(
1239 "SELECT id FROM {$this->table_tasks} WHERE task_name = %s",
1240 $task_name
1241 ) );
1242
1243 if ( !$task_id ) {
1244 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task not found' ], 404 );
1245 }
1246
1247 // Get logs
1248 $logs = $this->wpdb->get_results( $this->wpdb->prepare(
1249 "SELECT * FROM {$this->table_tasklogs}
1250 WHERE task_id = %d
1251 ORDER BY started DESC
1252 LIMIT 50",
1253 $task_id
1254 ) );
1255
1256 return new WP_REST_Response( [ 'success' => true, 'logs' => $logs ], 200 );
1257 }
1258
1259 /**
1260 * REST: Delete task logs
1261 */
1262 public function rest_task_logs_delete( $request ) {
1263 $params = $request->get_json_params();
1264 $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
1265
1266 if ( !$task_name ) {
1267 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1268 }
1269
1270 // Get task ID
1271 $task_id = $this->wpdb->get_var( $this->wpdb->prepare(
1272 "SELECT id FROM {$this->table_tasks} WHERE task_name = %s",
1273 $task_name
1274 ) );
1275
1276 if ( !$task_id ) {
1277 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task not found' ], 404 );
1278 }
1279
1280 // Delete logs for this task
1281 $result = $this->wpdb->delete(
1282 $this->table_tasklogs,
1283 [ 'task_id' => $task_id ],
1284 [ '%d' ]
1285 );
1286
1287 if ( $result !== false ) {
1288 return new WP_REST_Response( [ 'success' => true, 'message' => 'Logs deleted successfully' ], 200 );
1289 }
1290
1291 return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to delete logs' ], 500 );
1292 }
1293
1294 /**
1295 * REST: Reset all tasks
1296 */
1297 public function rest_tasks_reset( $request ) {
1298 // Clear all WordPress cron jobs related to tasks
1299 wp_clear_scheduled_hook( 'mwai_tasks_internal_run' );
1300 wp_clear_scheduled_hook( 'mwai_tasks_internal_dev_run' );
1301
1302 // Clear all transients
1303 delete_transient( 'mwai_cron_last_run' );
1304 delete_transient( 'mwai_cron_running_mwai_tasks_internal_run' );
1305 delete_transient( 'mwai_cron_running_mwai_tasks_internal_dev_run' );
1306
1307 // Truncate task logs table
1308 $this->wpdb->query( "TRUNCATE TABLE {$this->table_tasklogs}" );
1309
1310 // Delete all tasks
1311 $this->wpdb->query( "TRUNCATE TABLE {$this->table_tasks}" );
1312
1313 // Re-initialize the Tasks Runner cron
1314 $dev_mode = $this->core->get_option( 'dev_mode' );
1315 if ( $dev_mode ) {
1316 wp_schedule_event( time() + 5, 'five_seconds', 'mwai_tasks_internal_dev_run' );
1317 }
1318 else {
1319 wp_schedule_event( time() + 60, 'one_minute', 'mwai_tasks_internal_run' );
1320 }
1321
1322 // Re-create system tasks
1323 $this->ensure_system_tasks();
1324
1325 return new WP_REST_Response( [
1326 'success' => true,
1327 'message' => 'Tasks system has been reset. All tasks and logs have been cleared, and system tasks have been re-created.'
1328 ], 200 );
1329 }
1330
1331 /**
1332 * Helper: Human-readable time difference (abbreviated)
1333 */
1334 private function human_time_diff( $timestamp ) {
1335 // Use current time consistently
1336 $now = time();
1337 $diff = $timestamp - $now;
1338
1339 if ( $diff < 0 ) {
1340 // Past
1341 $diff = abs( $diff );
1342 $suffix = ' ago';
1343 }
1344 else {
1345 // Future
1346 $suffix = '';
1347 }
1348
1349 // Use abbreviated format
1350 if ( $diff < 60 ) {
1351 return $diff . 's' . $suffix;
1352 }
1353 else if ( $diff < 3600 ) {
1354 $minutes = round( $diff / 60 );
1355 return $minutes . 'm' . $suffix;
1356 }
1357 else if ( $diff < 86400 ) {
1358 $hours = round( $diff / 3600 );
1359 return $hours . 'h' . $suffix;
1360 }
1361 else {
1362 $days = round( $diff / 86400 );
1363 return $days . 'd' . $suffix;
1364 }
1365 }
1366
1367 /**
1368 * Check and create database tables
1369 */
1370 public function check_db() {
1371 // Don't run multiple times
1372 if ( $this->db_check ) {
1373 return true;
1374 }
1375
1376 // Per-module version check: skip SHOW TABLES if already verified for this version.
1377 if ( get_option( 'mwai_db_version_tasks' ) === MWAI_VERSION ) {
1378 $this->db_check = true;
1379 return true;
1380 }
1381
1382 // Check if tables exist
1383 $tasks_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_tasks'" );
1384 $logs_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_tasklogs'" );
1385
1386 if ( !$tasks_exists || !$logs_exists ) {
1387 $this->create_db();
1388 $tasks_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_tasks'" );
1389 $logs_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_tasklogs'" );
1390 }
1391
1392 $this->db_check = $tasks_exists && $logs_exists;
1393
1394 if ( $this->db_check ) {
1395 // Check for database upgrades
1396 $this->upgrade_db();
1397 update_option( 'mwai_db_version_tasks', MWAI_VERSION, true );
1398 }
1399
1400 return $this->db_check;
1401 }
1402
1403 /**
1404 * Upgrade database schema if needed
1405 */
1406 private function upgrade_db() {
1407 // Add category column if it doesn't exist
1408 $category_exists = $this->wpdb->get_var(
1409 "SHOW COLUMNS FROM {$this->table_tasks} LIKE 'category'"
1410 );
1411
1412 if ( !$category_exists ) {
1413 $this->wpdb->query(
1414 "ALTER TABLE {$this->table_tasks}
1415 ADD COLUMN category VARCHAR(32) NOT NULL DEFAULT 'general' AFTER description"
1416 );
1417 }
1418
1419 // Remove deprecated columns if they exist
1420 $columns_to_remove = ['auto_delete', 'deletable', 'is_multistep', 'last_message'];
1421
1422 foreach ( $columns_to_remove as $column ) {
1423 $column_exists = $this->wpdb->get_var(
1424 "SHOW COLUMNS FROM {$this->table_tasks} LIKE '$column'"
1425 );
1426
1427 if ( $column_exists ) {
1428 $this->wpdb->query( "ALTER TABLE {$this->table_tasks} DROP COLUMN $column" );
1429 }
1430 }
1431
1432 // Add step_data column if it doesn't exist
1433 $step_data_exists = $this->wpdb->get_var(
1434 "SHOW COLUMNS FROM {$this->table_tasks} LIKE 'step_data'"
1435 );
1436
1437 if ( !$step_data_exists ) {
1438 $this->wpdb->query(
1439 "ALTER TABLE {$this->table_tasks}
1440 ADD COLUMN step_data LONGTEXT NULL AFTER step_name"
1441 );
1442 }
1443 }
1444
1445 /**
1446 * Create database tables
1447 */
1448 public function create_db() {
1449 $charset_collate = $this->wpdb->get_charset_collate();
1450
1451 $sql_tasks = "CREATE TABLE $this->table_tasks (
1452 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
1453 task_name VARCHAR(100) NOT NULL,
1454 description TEXT NULL,
1455 category VARCHAR(32) NOT NULL DEFAULT 'general',
1456 schedule VARCHAR(128) NOT NULL,
1457 status VARCHAR(16) NOT NULL DEFAULT 'pending',
1458 next_run DATETIME NULL,
1459 last_run DATETIME NULL,
1460 expires_at DATETIME NULL,
1461 step INT NOT NULL DEFAULT 0,
1462 step_name VARCHAR(64) NULL,
1463 step_data LONGTEXT NULL,
1464 data LONGTEXT NULL,
1465 meta LONGTEXT NULL,
1466 error_count INT NOT NULL DEFAULT 0,
1467 max_retries INT NOT NULL DEFAULT 3,
1468 created DATETIME NOT NULL,
1469 updated DATETIME NOT NULL,
1470 PRIMARY KEY (id),
1471 UNIQUE KEY task_name (task_name),
1472 KEY status_next (status, next_run),
1473 KEY category (category),
1474 KEY expires (expires_at)
1475 ) $charset_collate;";
1476
1477 $sql_logs = "CREATE TABLE $this->table_tasklogs (
1478 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
1479 task_id BIGINT UNSIGNED NOT NULL,
1480 started DATETIME NOT NULL,
1481 ended DATETIME NULL,
1482 status VARCHAR(16) NOT NULL,
1483 message TEXT NULL,
1484 time_taken FLOAT NULL,
1485 memory_peak BIGINT NULL,
1486 step INT NULL,
1487 created DATETIME NOT NULL,
1488 PRIMARY KEY (id),
1489 KEY task_id_started (task_id, started)
1490 ) $charset_collate;";
1491
1492 require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
1493 dbDelta( $sql_tasks );
1494 dbDelta( $sql_logs );
1495 }
1496 }
1497