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