PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.4.3
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.4.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 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 11 months ago tasks-examples.php 6 months ago tasks.php 3 months ago wand.php 3 months ago
tasks.php
1490 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 // Ensure cleanup_files task exists
1031 $this->ensure( [
1032 'name' => 'cleanup_files',
1033 'category' => 'system',
1034 'description' => 'Delete expired files based on expiration dates.',
1035 'schedule' => '0 4 * * *', // Daily at 4 AM UTC
1036 ] );
1037
1038 // Ensure cleanup_tasks exists
1039 $this->ensure( [
1040 'name' => 'cleanup_tasks',
1041 'description' => 'Clean old task logs and failed tasks.',
1042 'category' => 'system',
1043 'schedule' => '0 13 * * *', // Daily at 1 PM UTC
1044 'deletable' => 0, // System task, not deletable
1045 ] );
1046 }
1047
1048 /**
1049 * REST: List tasks
1050 */
1051 public function rest_tasks_list( $request ) {
1052 // Make sure table exists
1053 $this->check_db();
1054
1055 $tasks = $this->wpdb->get_results(
1056 "SELECT * FROM {$this->table_tasks} ORDER BY task_name ASC"
1057 );
1058
1059 if ( $tasks === false ) {
1060 return new WP_REST_Response( [ 'success' => false, 'message' => 'Database error', 'tasks' => [] ], 500 );
1061 }
1062
1063 if ( empty( $tasks ) ) {
1064 $tasks = [];
1065 }
1066
1067 // Add computed fields
1068 foreach ( $tasks as &$task ) {
1069 $task->data = json_decode( $task->data, true );
1070 $task->meta = json_decode( $task->meta, true );
1071 $task->step_data = $task->step_data ? json_decode( $task->step_data, true ) : null;
1072
1073 // Ensure integers are properly cast
1074 $task->step = (int) $task->step;
1075 $task->error_count = (int) $task->error_count;
1076 $task->max_retries = (int) $task->max_retries;
1077
1078 // Fix tasks that should be in error status but aren't
1079 if ( $task->error_count >= $task->max_retries && $task->status === 'pending' ) {
1080 $task->status = 'error';
1081 $task->next_run = null;
1082 }
1083
1084 // Determine if task is deletable (system tasks cannot be deleted)
1085 $task->deletable = !in_array( $task->task_name, ['cleanup_discussions', 'cleanup_files'] ) ? 1 : 0;
1086
1087 // Get last message from most recent log
1088 $last_log = $this->wpdb->get_row( $this->wpdb->prepare(
1089 "SELECT message FROM {$this->table_tasklogs} WHERE task_id = %d ORDER BY started DESC LIMIT 1",
1090 $task->id
1091 ) );
1092 $task->last_message = $last_log ? $last_log->message : null;
1093
1094 // Get log count for this task
1095 $log_count = $this->wpdb->get_var( $this->wpdb->prepare(
1096 "SELECT COUNT(*) FROM {$this->table_tasklogs} WHERE task_id = %d",
1097 $task->id
1098 ) );
1099 $task->log_count = (int) $log_count;
1100
1101 // Calculate next 3 run times for preview
1102 if ( $task->schedule !== 'once' && $task->status === 'pending' ) {
1103 $next_runs = [];
1104 $check_ts = $task->next_run ? strtotime( $task->next_run ) : time();
1105
1106 for ( $i = 0; $i < 3; $i++ ) {
1107 $check_ts = $this->cron_next( $task->schedule, $check_ts );
1108 $next_runs[] = gmdate( 'Y-m-d H:i:s', $check_ts );
1109 }
1110
1111 $task->next_runs_preview = $next_runs;
1112 }
1113 else {
1114 $task->next_runs_preview = [];
1115 }
1116
1117 // Format times for display
1118 if ( $task->last_run ) {
1119 $task->last_run_human = $this->human_time_diff( strtotime( $task->last_run ) );
1120 }
1121 else {
1122 $task->last_run_human = 'Never';
1123 }
1124
1125 // Only show next_run for tasks that are actually scheduled to run
1126 // Don't show next_run for error, done, or expired tasks
1127 if ( $task->next_run && in_array( $task->status, ['pending', 'running', 'paused'] ) ) {
1128 $task->next_run_human = $this->human_time_diff( strtotime( $task->next_run ) );
1129 }
1130 else {
1131 $task->next_run_human = null;
1132 $task->next_run = null; // Clear it so frontend doesn't try to display it
1133 }
1134 }
1135
1136 return new WP_REST_Response( [ 'success' => true, 'tasks' => $tasks ], 200 );
1137 }
1138
1139 /**
1140 * REST: Run task now
1141 */
1142 public function rest_task_run( $request ) {
1143 $params = $request->get_json_params();
1144 $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
1145
1146 if ( !$task_name ) {
1147 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1148 }
1149
1150 $result = $this->run_now( $task_name );
1151
1152 if ( $result ) {
1153 return new WP_REST_Response( [ 'success' => true, 'message' => 'Task scheduled to run' ], 200 );
1154 }
1155
1156 return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to run task' ], 500 );
1157 }
1158
1159 /**
1160 * REST: Pause task
1161 */
1162 public function rest_task_pause( $request ) {
1163 $params = $request->get_json_params();
1164 $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
1165
1166 if ( !$task_name ) {
1167 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1168 }
1169
1170 $result = $this->pause( $task_name );
1171
1172 if ( $result ) {
1173 return new WP_REST_Response( [ 'success' => true, 'message' => 'Task paused' ], 200 );
1174 }
1175
1176 return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to pause task' ], 500 );
1177 }
1178
1179 /**
1180 * REST: Resume task
1181 */
1182 public function rest_task_resume( $request ) {
1183 $params = $request->get_json_params();
1184 $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
1185
1186 if ( !$task_name ) {
1187 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1188 }
1189
1190 $result = $this->resume( $task_name );
1191
1192 if ( $result ) {
1193 return new WP_REST_Response( [ 'success' => true, 'message' => 'Task resumed' ], 200 );
1194 }
1195
1196 return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to resume task' ], 500 );
1197 }
1198
1199 /**
1200 * REST: Delete task
1201 */
1202 public function rest_task_delete( $request ) {
1203 $params = $request->get_json_params();
1204 $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
1205 $delete_logs = isset( $params['delete_logs'] ) ? $params['delete_logs'] : true;
1206
1207 if ( !$task_name ) {
1208 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1209 }
1210
1211 $result = $this->remove( $task_name, [ 'delete_logs' => $delete_logs ] );
1212
1213 if ( $result ) {
1214 return new WP_REST_Response( [ 'success' => true, 'message' => 'Task deleted' ], 200 );
1215 }
1216
1217 return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to delete task' ], 500 );
1218 }
1219
1220 /**
1221 * REST: Get task logs
1222 */
1223 public function rest_task_logs( $request ) {
1224 $task_name = $request->get_param( 'task_name' );
1225
1226 if ( !$task_name ) {
1227 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1228 }
1229
1230 // Get task ID
1231 $task_id = $this->wpdb->get_var( $this->wpdb->prepare(
1232 "SELECT id FROM {$this->table_tasks} WHERE task_name = %s",
1233 $task_name
1234 ) );
1235
1236 if ( !$task_id ) {
1237 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task not found' ], 404 );
1238 }
1239
1240 // Get logs
1241 $logs = $this->wpdb->get_results( $this->wpdb->prepare(
1242 "SELECT * FROM {$this->table_tasklogs}
1243 WHERE task_id = %d
1244 ORDER BY started DESC
1245 LIMIT 50",
1246 $task_id
1247 ) );
1248
1249 return new WP_REST_Response( [ 'success' => true, 'logs' => $logs ], 200 );
1250 }
1251
1252 /**
1253 * REST: Delete task logs
1254 */
1255 public function rest_task_logs_delete( $request ) {
1256 $params = $request->get_json_params();
1257 $task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
1258
1259 if ( !$task_name ) {
1260 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
1261 }
1262
1263 // Get task ID
1264 $task_id = $this->wpdb->get_var( $this->wpdb->prepare(
1265 "SELECT id FROM {$this->table_tasks} WHERE task_name = %s",
1266 $task_name
1267 ) );
1268
1269 if ( !$task_id ) {
1270 return new WP_REST_Response( [ 'success' => false, 'message' => 'Task not found' ], 404 );
1271 }
1272
1273 // Delete logs for this task
1274 $result = $this->wpdb->delete(
1275 $this->table_tasklogs,
1276 [ 'task_id' => $task_id ],
1277 [ '%d' ]
1278 );
1279
1280 if ( $result !== false ) {
1281 return new WP_REST_Response( [ 'success' => true, 'message' => 'Logs deleted successfully' ], 200 );
1282 }
1283
1284 return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to delete logs' ], 500 );
1285 }
1286
1287 /**
1288 * REST: Reset all tasks
1289 */
1290 public function rest_tasks_reset( $request ) {
1291 // Clear all WordPress cron jobs related to tasks
1292 wp_clear_scheduled_hook( 'mwai_tasks_internal_run' );
1293 wp_clear_scheduled_hook( 'mwai_tasks_internal_dev_run' );
1294
1295 // Clear all transients
1296 delete_transient( 'mwai_cron_last_run' );
1297 delete_transient( 'mwai_cron_running_mwai_tasks_internal_run' );
1298 delete_transient( 'mwai_cron_running_mwai_tasks_internal_dev_run' );
1299
1300 // Truncate task logs table
1301 $this->wpdb->query( "TRUNCATE TABLE {$this->table_tasklogs}" );
1302
1303 // Delete all tasks
1304 $this->wpdb->query( "TRUNCATE TABLE {$this->table_tasks}" );
1305
1306 // Re-initialize the Tasks Runner cron
1307 $dev_mode = $this->core->get_option( 'dev_mode' );
1308 if ( $dev_mode ) {
1309 wp_schedule_event( time() + 5, 'five_seconds', 'mwai_tasks_internal_dev_run' );
1310 }
1311 else {
1312 wp_schedule_event( time() + 60, 'one_minute', 'mwai_tasks_internal_run' );
1313 }
1314
1315 // Re-create system tasks
1316 $this->ensure_system_tasks();
1317
1318 return new WP_REST_Response( [
1319 'success' => true,
1320 'message' => 'Tasks system has been reset. All tasks and logs have been cleared, and system tasks have been re-created.'
1321 ], 200 );
1322 }
1323
1324 /**
1325 * Helper: Human-readable time difference (abbreviated)
1326 */
1327 private function human_time_diff( $timestamp ) {
1328 // Use current time consistently
1329 $now = time();
1330 $diff = $timestamp - $now;
1331
1332 if ( $diff < 0 ) {
1333 // Past
1334 $diff = abs( $diff );
1335 $suffix = ' ago';
1336 }
1337 else {
1338 // Future
1339 $suffix = '';
1340 }
1341
1342 // Use abbreviated format
1343 if ( $diff < 60 ) {
1344 return $diff . 's' . $suffix;
1345 }
1346 else if ( $diff < 3600 ) {
1347 $minutes = round( $diff / 60 );
1348 return $minutes . 'm' . $suffix;
1349 }
1350 else if ( $diff < 86400 ) {
1351 $hours = round( $diff / 3600 );
1352 return $hours . 'h' . $suffix;
1353 }
1354 else {
1355 $days = round( $diff / 86400 );
1356 return $days . 'd' . $suffix;
1357 }
1358 }
1359
1360 /**
1361 * Check and create database tables
1362 */
1363 public function check_db() {
1364 // Don't run multiple times
1365 if ( $this->db_check ) {
1366 return true;
1367 }
1368
1369 // Per-module version check: skip SHOW TABLES if already verified for this version.
1370 if ( get_option( 'mwai_db_version_tasks' ) === MWAI_VERSION ) {
1371 $this->db_check = true;
1372 return true;
1373 }
1374
1375 // Check if tables exist
1376 $tasks_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_tasks'" );
1377 $logs_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_tasklogs'" );
1378
1379 if ( !$tasks_exists || !$logs_exists ) {
1380 $this->create_db();
1381 $tasks_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_tasks'" );
1382 $logs_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_tasklogs'" );
1383 }
1384
1385 $this->db_check = $tasks_exists && $logs_exists;
1386
1387 if ( $this->db_check ) {
1388 // Check for database upgrades
1389 $this->upgrade_db();
1390 update_option( 'mwai_db_version_tasks', MWAI_VERSION, true );
1391 }
1392
1393 return $this->db_check;
1394 }
1395
1396 /**
1397 * Upgrade database schema if needed
1398 */
1399 private function upgrade_db() {
1400 // Add category column if it doesn't exist
1401 $category_exists = $this->wpdb->get_var(
1402 "SHOW COLUMNS FROM {$this->table_tasks} LIKE 'category'"
1403 );
1404
1405 if ( !$category_exists ) {
1406 $this->wpdb->query(
1407 "ALTER TABLE {$this->table_tasks}
1408 ADD COLUMN category VARCHAR(32) NOT NULL DEFAULT 'general' AFTER description"
1409 );
1410 }
1411
1412 // Remove deprecated columns if they exist
1413 $columns_to_remove = ['auto_delete', 'deletable', 'is_multistep', 'last_message'];
1414
1415 foreach ( $columns_to_remove as $column ) {
1416 $column_exists = $this->wpdb->get_var(
1417 "SHOW COLUMNS FROM {$this->table_tasks} LIKE '$column'"
1418 );
1419
1420 if ( $column_exists ) {
1421 $this->wpdb->query( "ALTER TABLE {$this->table_tasks} DROP COLUMN $column" );
1422 }
1423 }
1424
1425 // Add step_data column if it doesn't exist
1426 $step_data_exists = $this->wpdb->get_var(
1427 "SHOW COLUMNS FROM {$this->table_tasks} LIKE 'step_data'"
1428 );
1429
1430 if ( !$step_data_exists ) {
1431 $this->wpdb->query(
1432 "ALTER TABLE {$this->table_tasks}
1433 ADD COLUMN step_data LONGTEXT NULL AFTER step_name"
1434 );
1435 }
1436 }
1437
1438 /**
1439 * Create database tables
1440 */
1441 public function create_db() {
1442 $charset_collate = $this->wpdb->get_charset_collate();
1443
1444 $sql_tasks = "CREATE TABLE $this->table_tasks (
1445 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
1446 task_name VARCHAR(100) NOT NULL,
1447 description TEXT NULL,
1448 category VARCHAR(32) NOT NULL DEFAULT 'general',
1449 schedule VARCHAR(128) NOT NULL,
1450 status VARCHAR(16) NOT NULL DEFAULT 'pending',
1451 next_run DATETIME NULL,
1452 last_run DATETIME NULL,
1453 expires_at DATETIME NULL,
1454 step INT NOT NULL DEFAULT 0,
1455 step_name VARCHAR(64) NULL,
1456 step_data LONGTEXT NULL,
1457 data LONGTEXT NULL,
1458 meta LONGTEXT NULL,
1459 error_count INT NOT NULL DEFAULT 0,
1460 max_retries INT NOT NULL DEFAULT 3,
1461 created DATETIME NOT NULL,
1462 updated DATETIME NOT NULL,
1463 PRIMARY KEY (id),
1464 UNIQUE KEY task_name (task_name),
1465 KEY status_next (status, next_run),
1466 KEY category (category),
1467 KEY expires (expires_at)
1468 ) $charset_collate;";
1469
1470 $sql_logs = "CREATE TABLE $this->table_tasklogs (
1471 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
1472 task_id BIGINT UNSIGNED NOT NULL,
1473 started DATETIME NOT NULL,
1474 ended DATETIME NULL,
1475 status VARCHAR(16) NOT NULL,
1476 message TEXT NULL,
1477 time_taken FLOAT NULL,
1478 memory_peak BIGINT NULL,
1479 step INT NULL,
1480 created DATETIME NOT NULL,
1481 PRIMARY KEY (id),
1482 KEY task_id_started (task_id, started)
1483 ) $charset_collate;";
1484
1485 require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
1486 dbDelta( $sql_tasks );
1487 dbDelta( $sql_logs );
1488 }
1489 }
1490