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