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