File: /var/www/html/wp-content/plugins/ai-engine/classes/modules/tasks.php
<?php
class Meow_MWAI_Modules_Tasks {
private $wpdb = null;
private $core = null;
public $table_tasks = null;
public $table_tasklogs = null;
private $db_check = false;
private $namespace = 'mwai/v1';
private $max_tasks_per_tick = 5;
private $max_retries = 3;
public function __construct( $core ) {
global $wpdb;
$this->wpdb = $wpdb;
$this->core = $core;
$this->table_tasks = $wpdb->prefix . 'mwai_tasks';
$this->table_tasklogs = $wpdb->prefix . 'mwai_tasklogs';
// Initialize database
$this->check_db();
// Register REST API
add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
// Custom cron schedules - MUST be registered before using them
add_filter( 'cron_schedules', [ $this, 'custom_cron_schedule' ], 5 );
// Always register the action hooks
add_action( 'mwai_tasks_internal_run', [ $this, 'tick' ] );
add_action( 'mwai_tasks_internal_dev_run', [ $this, 'tick' ] );
// Register cleanup tasks handler
add_filter( 'mwai_task_cleanup_tasks', [ $this, 'handle_cleanup_tasks' ], 10, 2 );
if ( is_admin() ) {
add_action( 'init', [ $this, 'ensure_system_tasks' ], 20 );
add_action( 'admin_init', [ $this, 'fix_overdue_cron' ] );
}
// Schedule crons on init (after custom schedules are registered)
add_action( 'init', [ $this, 'ensure_cron_scheduled' ], 15 );
// Load the Tasks Examples module (includes test task functionality)
require_once( __DIR__ . '/tasks-examples.php' );
new Meow_MWAI_Modules_Tasks_Examples( $core );
}
/**
* Ensure cron is scheduled properly
*/
public function ensure_cron_scheduled() {
$dev_mode = $this->core->get_option( 'dev_mode' );
$hook = $dev_mode ? 'mwai_tasks_internal_dev_run' : 'mwai_tasks_internal_run';
$opposite_hook = $dev_mode ? 'mwai_tasks_internal_run' : 'mwai_tasks_internal_dev_run';
// Clear opposite hook
wp_clear_scheduled_hook( $opposite_hook );
// Check if current hook is scheduled and not overdue
$next = wp_next_scheduled( $hook );
// If not scheduled or overdue by more than 5 minutes, reschedule
if ( !$next || $next < ( time() - 300 ) ) {
wp_clear_scheduled_hook( $hook );
if ( $dev_mode ) {
wp_schedule_event( time() + 5, 'five_seconds', $hook );
}
else {
wp_schedule_event( time() + 60, 'one_minute', $hook );
}
}
}
/**
* Fix overdue cron events
*/
public function fix_overdue_cron() {
$dev_mode = $this->core->get_option( 'dev_mode' );
if ( $dev_mode ) {
// Clear production cron if it exists
wp_clear_scheduled_hook( 'mwai_tasks_internal_run' );
// Ensure dev cron is scheduled
if ( !wp_next_scheduled( 'mwai_tasks_internal_dev_run' ) ) {
wp_schedule_event( time() + 5, 'five_seconds', 'mwai_tasks_internal_dev_run' );
}
}
else {
// Clear dev cron if it exists
wp_clear_scheduled_hook( 'mwai_tasks_internal_dev_run' );
// Ensure production cron is scheduled
if ( !wp_next_scheduled( 'mwai_tasks_internal_run' ) ) {
wp_schedule_event( time() + 60, 'one_minute', 'mwai_tasks_internal_run' );
}
}
}
public function custom_cron_schedule( $schedules ) {
$schedules['one_minute'] = [ 'display' => __( 'Every Minute' ), 'interval' => 60 ];
$schedules['five_seconds'] = [ 'display' => __( 'Every 5 Seconds' ), 'interval' => 5 ];
return $schedules;
}
public function rest_api_init() {
register_rest_route( $this->namespace, '/helpers/tasks_list', [
'methods' => 'GET',
'callback' => [ $this, 'rest_tasks_list' ],
'permission_callback' => [ $this->core, 'can_access_settings' ],
] );
register_rest_route( $this->namespace, '/helpers/task_run', [
'methods' => 'POST',
'callback' => [ $this, 'rest_task_run' ],
'permission_callback' => [ $this->core, 'can_access_settings' ],
] );
register_rest_route( $this->namespace, '/helpers/task_pause', [
'methods' => 'POST',
'callback' => [ $this, 'rest_task_pause' ],
'permission_callback' => [ $this->core, 'can_access_settings' ],
] );
register_rest_route( $this->namespace, '/helpers/task_resume', [
'methods' => 'POST',
'callback' => [ $this, 'rest_task_resume' ],
'permission_callback' => [ $this->core, 'can_access_settings' ],
] );
register_rest_route( $this->namespace, '/helpers/task_delete', [
'methods' => 'POST',
'callback' => [ $this, 'rest_task_delete' ],
'permission_callback' => [ $this->core, 'can_access_settings' ],
] );
register_rest_route( $this->namespace, '/helpers/task_logs', [
'methods' => 'GET',
'callback' => [ $this, 'rest_task_logs' ],
'permission_callback' => [ $this->core, 'can_access_settings' ],
] );
register_rest_route( $this->namespace, '/helpers/task_logs_delete', [
'methods' => 'POST',
'callback' => [ $this, 'rest_task_logs_delete' ],
'permission_callback' => [ $this->core, 'can_access_settings' ],
] );
register_rest_route( $this->namespace, '/helpers/tasks_reset', [
'methods' => 'POST',
'callback' => [ $this, 'rest_tasks_reset' ],
'permission_callback' => [ $this->core, 'can_access_settings' ],
] );
}
/**
* Ensure a task exists or update its configuration
*/
public function ensure( $args ) {
$defaults = [
'name' => '',
'description' => '',
'category' => 'general',
'schedule' => 'once',
'next_run' => null, // Allow specifying when a one-time task should run
'is_multistep' => 0,
'expires_at' => null,
'auto_delete' => 0,
'deletable' => 1,
'data' => null,
'step_name' => null,
];
$args = wp_parse_args( $args, $defaults );
if ( empty( $args['name'] ) ) {
return new WP_Error( 'invalid_name', 'Task name is required' );
}
// Check if task exists
$existing = $this->wpdb->get_row( $this->wpdb->prepare(
"SELECT * FROM {$this->table_tasks} WHERE task_name = %s",
$args['name']
) );
$now = gmdate( 'Y-m-d H:i:s' );
if ( $existing ) {
// Update existing task
$update_data = [
'description' => $args['description'],
'category' => $args['category'],
'updated' => $now,
];
// Only update these if they've changed
if ( $args['schedule'] !== $existing->schedule ) {
$update_data['schedule'] = $args['schedule'];
$update_data['next_run'] = $this->calculate_next_run( $args['schedule'] );
}
if ( $args['expires_at'] !== $existing->expires_at ) {
$update_data['expires_at'] = $args['expires_at'];
}
if ( $args['data'] !== null ) {
$existing_data = json_decode( $existing->data, true ) ?: [];
$merged_data = array_merge( $existing_data, $args['data'] );
$update_data['data'] = json_encode( $merged_data );
}
if ( $args['step_name'] !== null ) {
$update_data['step_name'] = $args['step_name'];
}
$result = $this->wpdb->update(
$this->table_tasks,
$update_data,
[ 'task_name' => $args['name'] ]
);
return $result !== false;
}
else {
// Create new task
// Use provided next_run for one-time tasks, otherwise calculate from schedule
if ( $args['schedule'] === 'once' && $args['next_run'] ) {
$next_run = $args['next_run'];
}
else {
$next_run = $this->calculate_next_run( $args['schedule'] );
}
$insert_data = [
'task_name' => $args['name'],
'description' => $args['description'],
'category' => $args['category'],
'schedule' => $args['schedule'],
'status' => 'pending',
'next_run' => $next_run,
'expires_at' => $args['expires_at'],
'step' => 0,
'step_name' => $args['step_name'],
'step_data' => isset( $args['step_data'] ) ? json_encode( $args['step_data'] ) : null,
'data' => json_encode( $args['data'] ?: [] ),
'meta' => json_encode( [] ),
'error_count' => 0,
'max_retries' => $this->max_retries,
'created' => $now,
'updated' => $now,
];
$result = $this->wpdb->insert( $this->table_tasks, $insert_data );
return $result !== false;
}
}
/**
* Get a specific task by name
*/
public function get_task( $task_name ) {
return $this->wpdb->get_row( $this->wpdb->prepare(
"SELECT * FROM {$this->table_tasks} WHERE task_name = %s",
$task_name
) );
}
/**
* Create a new task directly
*/
public function create_task( $task_data ) {
$defaults = [
'category' => 'general',
'status' => 'pending', // Changed from 'active' to 'pending' to match tick() selection
'next_run' => null,
'expires_at' => null,
'step' => 0,
'step_name' => null,
'step_data' => null,
'data' => [],
'meta' => [],
'error_count' => 0,
'max_retries' => 3,
'description' => null
];
$task_data = array_merge( $defaults, $task_data );
$now = gmdate( 'Y-m-d H:i:s' );
// Calculate next run if schedule provided and next_run not set
if ( !empty( $task_data['schedule'] ) && empty( $task_data['next_run'] ) ) {
$task_data['next_run'] = $this->calculate_next_run( $task_data['schedule'] );
}
// Ensure next_run is in proper datetime format if it's a timestamp
if ( !empty( $task_data['next_run'] ) && is_numeric( $task_data['next_run'] ) ) {
$task_data['next_run'] = gmdate( 'Y-m-d H:i:s', $task_data['next_run'] );
}
// If still no next_run, set to now
if ( empty( $task_data['next_run'] ) ) {
$task_data['next_run'] = $now;
}
$insert_data = [
'task_name' => $task_data['task_name'],
'description' => $task_data['description'],
'category' => $task_data['category'],
'schedule' => $task_data['schedule'],
'status' => $task_data['status'],
'next_run' => $task_data['next_run'],
'expires_at' => $task_data['expires_at'],
'step' => $task_data['step'],
'step_name' => $task_data['step_name'],
'step_data' => is_array( $task_data['step_data'] ) ? json_encode( $task_data['step_data'] ) : $task_data['step_data'],
'data' => is_array( $task_data['data'] ) ? json_encode( $task_data['data'] ) : $task_data['data'],
'meta' => is_array( $task_data['meta'] ) ? json_encode( $task_data['meta'] ) : $task_data['meta'],
'error_count' => $task_data['error_count'],
'max_retries' => $task_data['max_retries'],
'created' => $now,
'updated' => $now,
];
return $this->wpdb->insert( $this->table_tasks, $insert_data ) !== false;
}
/**
* Update a task by name
*/
public function update_task( $task_name, $fields ) {
$update_data = [ 'updated' => gmdate( 'Y-m-d H:i:s' ) ];
foreach ( $fields as $key => $value ) {
if ( in_array( $key, [ 'data', 'meta', 'step_data' ] ) && is_array( $value ) ) {
$update_data[$key] = json_encode( $value );
}
else {
$update_data[$key] = $value;
}
}
return $this->wpdb->update(
$this->table_tasks,
$update_data,
[ 'task_name' => $task_name ]
) !== false;
}
/**
* Update task fields
*/
public function update( $task_name, $fields ) {
$allowed_fields = [ 'schedule', 'description', 'data', 'expires_at', 'step', 'step_name', 'step_data' ];
$update_data = [ 'updated' => gmdate( 'Y-m-d H:i:s' ) ];
foreach ( $fields as $key => $value ) {
if ( in_array( $key, $allowed_fields ) ) {
if ( $key === 'data' || $key === 'step_data' ) {
$update_data[$key] = json_encode( $value );
}
else if ( $key === 'schedule' ) {
$update_data[$key] = $value;
$update_data['next_run'] = $this->calculate_next_run( $value );
}
else {
$update_data[$key] = $value;
}
}
}
$result = $this->wpdb->update(
$this->table_tasks,
$update_data,
[ 'task_name' => $task_name ]
);
return $result !== false;
}
/**
* Remove a task
*/
public function remove( $task_name, $opts = [] ) {
$delete_logs = isset( $opts['delete_logs'] ) ? $opts['delete_logs'] : false;
// Get task ID for logs deletion
if ( $delete_logs ) {
$task_id = $this->wpdb->get_var( $this->wpdb->prepare(
"SELECT id FROM {$this->table_tasks} WHERE task_name = %s",
$task_name
) );
if ( $task_id ) {
$this->wpdb->delete( $this->table_tasklogs, [ 'task_id' => $task_id ] );
}
}
$result = $this->wpdb->delete( $this->table_tasks, [ 'task_name' => $task_name ] );
return $result !== false;
}
/**
* Pause a task
*/
public function pause( $task_name ) {
$result = $this->wpdb->update(
$this->table_tasks,
[ 'status' => 'paused', 'updated' => gmdate( 'Y-m-d H:i:s' ) ],
[ 'task_name' => $task_name ]
);
return $result !== false;
}
/**
* Resume a task
*/
public function resume( $task_name ) {
// Get the task to determine schedule
$task = $this->wpdb->get_row( $this->wpdb->prepare(
"SELECT * FROM {$this->table_tasks} WHERE task_name = %s",
$task_name
) );
if ( !$task ) {
return false;
}
$next_run = $this->calculate_next_run( $task->schedule );
$result = $this->wpdb->update(
$this->table_tasks,
[
'status' => 'pending',
'next_run' => $next_run,
'updated' => gmdate( 'Y-m-d H:i:s' )
],
[ 'task_name' => $task_name ]
);
return $result !== false;
}
/**
* Run a task immediately
*/
public function run_now( $task_name ) {
// First check if task is stuck and reset it
$task = $this->wpdb->get_row( $this->wpdb->prepare(
"SELECT * FROM {$this->table_tasks} WHERE task_name = %s",
$task_name
) );
if ( $task && $task->status === 'running' ) {
// Reset stuck task - be more aggressive (1 minute instead of 10)
$this->reset_stuck_tasks( 1 );
}
$result = $this->wpdb->update(
$this->table_tasks,
[
'status' => 'pending',
'next_run' => gmdate( 'Y-m-d H:i:s' ),
'updated' => gmdate( 'Y-m-d H:i:s' )
],
[ 'task_name' => $task_name ]
);
if ( $result !== false ) {
// Optionally run tick once (but keep it light)
$this->tick();
return true;
}
return false;
}
/**
* Reset tasks that are stuck in running state
*/
public function reset_stuck_tasks( $minutes_threshold = 10 ) {
$now = gmdate( 'Y-m-d H:i:s' );
$stuck_cutoff = gmdate( 'Y-m-d H:i:s', strtotime( "-{$minutes_threshold} minutes" ) );
$count = $this->wpdb->query( $this->wpdb->prepare(
"UPDATE {$this->table_tasks}
SET status = 'pending',
error_count = error_count + 1,
updated = %s
WHERE status = 'running'
AND updated < %s",
$now,
$stuck_cutoff
) );
return $count;
}
/**
* Main execution loop - called by cron
*/
public function tick() {
// Track cron execution for proper "last run" display
// Determine which hook is actually running
$dev_mode = $this->core->get_option( 'dev_mode' );
$hook_name = $dev_mode ? 'mwai_tasks_internal_dev_run' : 'mwai_tasks_internal_run';
$this->core->track_cron_start( $hook_name );
// Use UTC consistently
$now = gmdate( 'Y-m-d H:i:s' );
// First, reset any stuck tasks (running for more than 10 minutes)
$this->reset_stuck_tasks();
// Get due tasks
$tasks = $this->wpdb->get_results( $this->wpdb->prepare(
"SELECT * FROM {$this->table_tasks}
WHERE status IN ('pending', 'error')
AND next_run <= %s
AND (expires_at IS NULL OR expires_at > %s)
ORDER BY next_run ASC
LIMIT %d",
$now,
$now,
$this->max_tasks_per_tick
) );
foreach ( $tasks as $task ) {
$this->execute_task( $task );
}
// Track cron completion
$this->core->track_cron_end( $hook_name );
}
/**
* Execute a single task
*/
private function execute_task( $task ) {
// Atomically claim the task
$claimed = $this->wpdb->update(
$this->table_tasks,
[ 'status' => 'running', 'updated' => gmdate( 'Y-m-d H:i:s' ) ],
[
'id' => $task->id,
'status' => $task->status // Ensure it hasn't changed
]
);
if ( !$claimed ) {
return; // Another process got it
}
// Start logging
$log_id = $this->log_start( $task->id );
$start_time = microtime( true );
// Build job array
$job = [
'name' => $task->task_name,
'schedule' => $task->schedule,
'step' => $task->step,
'step_name' => $task->step_name,
'data' => json_decode( $task->data, true ) ?: [],
'meta' => json_decode( $task->meta, true ) ?: [],
];
// Call the filter with error handling
try {
$result = apply_filters( "mwai_task_{$task->task_name}", null, $job );
// Fallback to generic filter if specific one returns null
if ( $result === null ) {
$result = apply_filters( 'mwai_task_run', null, $job );
}
// Default result if nothing handles it
if ( $result === null ) {
$result = [
'ok' => true,
'message' => "No handler for '{$task->task_name}'",
];
}
}
catch ( Exception $e ) {
$result = [
'ok' => false,
'message' => 'Exception: ' . $e->getMessage(),
];
}
catch ( Error $e ) {
$result = [
'ok' => false,
'message' => 'Fatal error: ' . $e->getMessage(),
];
}
// Normalize result
$result = $this->normalize_result( $result );
// Log the end
$time_taken = microtime( true ) - $start_time;
$this->log_end( $log_id, $result, $time_taken );
// Update task based on result
$this->finish_from_result( $task, $result );
}
/**
* Normalize task result
*/
private function normalize_result( $result ) {
if ( !is_array( $result ) ) {
return [
'ok' => false,
'message' => 'Invalid result format',
];
}
$defaults = [
'ok' => false,
'done' => true,
'message' => '',
'step' => null,
'step_name' => null,
'data' => null,
'meta' => null,
'next_run_delay' => null,
];
return wp_parse_args( $result, $defaults );
}
/**
* Update task after execution
*/
private function finish_from_result( $task, $result ) {
$now_ts = time();
$now = gmdate( 'Y-m-d H:i:s', $now_ts );
// Merge data and meta if provided
$data = json_decode( $task->data, true ) ?: [];
$meta = json_decode( $task->meta, true ) ?: [];
if ( $result['data'] !== null && is_array( $result['data'] ) ) {
$data = array_merge( $data, $result['data'] );
}
if ( $result['meta'] !== null && is_array( $result['meta'] ) ) {
$meta = array_merge( $meta, $result['meta'] );
}
$update_data = [
'data' => json_encode( $data ),
'meta' => json_encode( $meta ),
'last_run' => $now,
'updated' => $now,
];
// Update step if provided
if ( $result['step'] !== null ) {
$update_data['step'] = $result['step'];
}
if ( $result['step_name'] !== null ) {
$update_data['step_name'] = $result['step_name'];
}
if ( $result['ok'] ) {
// Success path
$update_data['error_count'] = 0;
if ( !$result['done'] ) {
// Multi-step task not finished - continue quickly
$update_data['status'] = 'pending';
$delay = isset( $result['next_run_delay'] ) ? (int) $result['next_run_delay'] : 10;
if ( $delay < 1 ) {
$delay = 1;
}
$update_data['next_run'] = gmdate( 'Y-m-d H:i:s', $now_ts + $delay );
}
else if ( $task->schedule === 'once' ) {
// One-off task completed
$update_data['status'] = 'done';
$update_data['next_run'] = null;
}
else {
// Recurring task completed this cycle
$update_data['status'] = 'pending';
$update_data['next_run'] = $this->calculate_next_run( $task->schedule, $now_ts );
$update_data['step'] = 0;
$update_data['step_name'] = null;
}
}
else {
// Error path
$update_data['error_count'] = $task->error_count + 1;
if ( $update_data['error_count'] >= $task->max_retries ) {
// Max retries reached or exceeded
$update_data['status'] = 'error';
$update_data['next_run'] = null;
}
else {
// Retry with backoff
$update_data['status'] = 'pending';
$update_data['next_run'] = gmdate( 'Y-m-d H:i:s', $now_ts + 300 ); // 5 minutes
}
}
// Check expiration
if ( $task->expires_at && strtotime( $task->expires_at ) <= $now_ts ) {
$update_data['status'] = 'expired';
$update_data['next_run'] = null;
}
// Update the task
$this->wpdb->update(
$this->table_tasks,
$update_data,
[ 'id' => $task->id ]
);
// Auto-delete expired tasks that have an expiration date
if ( $task->expires_at && $update_data['status'] === 'expired' ) {
$this->wpdb->delete( $this->table_tasks, [ 'id' => $task->id ] );
// Also delete logs for expired tasks
$this->wpdb->delete( $this->table_tasklogs, [ 'task_id' => $task->id ] );
}
}
/**
* Handle cleanup tasks - remove old logs and failed tasks
*/
public function handle_cleanup_tasks( $result, $job ) {
try {
$now = gmdate( 'Y-m-d H:i:s' );
$stats = [
'logs_deleted' => 0,
'failed_tasks_deleted' => 0,
'expired_tasks_deleted' => 0,
];
// 1. Delete task logs older than 7 days
$week_ago = gmdate( 'Y-m-d H:i:s', strtotime( '-7 days' ) );
$logs_deleted = $this->wpdb->query( $this->wpdb->prepare(
"DELETE FROM {$this->table_tasklogs} WHERE created < %s",
$week_ago
) );
$stats['logs_deleted'] = $logs_deleted ? $logs_deleted : 0;
// 2. Delete failed tasks that have been in error state for over 30 days
$month_ago = gmdate( 'Y-m-d H:i:s', strtotime( '-30 days' ) );
$failed_tasks = $this->wpdb->get_results( $this->wpdb->prepare(
"SELECT id, task_name FROM {$this->table_tasks}
WHERE status = 'error' AND updated < %s",
$month_ago
) );
foreach ( $failed_tasks as $task ) {
// Delete the task and its logs
$this->wpdb->delete( $this->table_tasks, [ 'id' => $task->id ] );
$this->wpdb->delete( $this->table_tasklogs, [ 'task_id' => $task->id ] );
$stats['failed_tasks_deleted']++;
}
// 3. Delete expired tasks that have been expired for over 7 days
$expired_tasks = $this->wpdb->get_results( $this->wpdb->prepare(
"SELECT id, task_name FROM {$this->table_tasks}
WHERE status = 'expired' AND updated < %s",
$week_ago
) );
foreach ( $expired_tasks as $task ) {
// Delete the task and its logs
$this->wpdb->delete( $this->table_tasks, [ 'id' => $task->id ] );
$this->wpdb->delete( $this->table_tasklogs, [ 'task_id' => $task->id ] );
$stats['expired_tasks_deleted']++;
}
// 4. Clean up orphaned logs (logs without corresponding tasks)
$orphaned_logs = $this->wpdb->query(
"DELETE tl FROM {$this->table_tasklogs} tl
LEFT JOIN {$this->table_tasks} t ON tl.task_id = t.id
WHERE t.id IS NULL"
);
$message = sprintf(
'Cleaned: %d logs, %d failed tasks, %d expired tasks',
$stats['logs_deleted'],
$stats['failed_tasks_deleted'],
$stats['expired_tasks_deleted']
);
return [
'ok' => true,
'message' => $message,
'data' => $stats
];
}
catch ( Exception $e ) {
return [
'ok' => false,
'message' => 'Cleanup failed: ' . $e->getMessage()
];
}
}
/**
* Calculate next run time
*/
private function calculate_next_run( $schedule, $after_ts = null ) {
if ( $schedule === 'once' ) {
// For one-time tasks without a specific time, run immediately
return gmdate( 'Y-m-d H:i:s' );
}
if ( $after_ts === null ) {
$after_ts = time();
}
$next_ts = $this->cron_next( $schedule, $after_ts );
return gmdate( 'Y-m-d H:i:s', $next_ts );
}
/**
* Parse cron expression and get next run time
*/
private function cron_next( $expr, $after_ts ) {
$parts = $this->parse_cron( $expr );
if ( !$parts ) {
// Invalid expression, return next hour
return $after_ts + 3600;
}
// Start from the next minute
$check_ts = $after_ts - ( $after_ts % 60 ) + 60;
// Check up to 2 years in the future (should be more than enough)
$max_ts = $after_ts + ( 2 * 365 * 24 * 60 * 60 );
while ( $check_ts < $max_ts ) {
$time = getdate( $check_ts );
if ( $this->cron_matches( $parts, $time ) ) {
return $check_ts;
}
// Move to next minute
$check_ts += 60;
}
// Fallback to next hour if no match found
return $after_ts + 3600;
}
/**
* Parse cron expression into parts
*/
private function parse_cron( $expr ) {
if ( empty( $expr ) ) {
return false;
}
$fields = preg_split( '/\s+/', trim( $expr ) );
if ( count( $fields ) !== 5 ) {
return false;
}
return [
'minute' => $this->parse_cron_field( $fields[0], 0, 59 ),
'hour' => $this->parse_cron_field( $fields[1], 0, 23 ),
'dom' => $this->parse_cron_field( $fields[2], 1, 31 ),
'month' => $this->parse_cron_field( $fields[3], 1, 12 ),
'dow' => $this->parse_cron_field( $fields[4], 0, 7 ),
];
}
/**
* Parse a single cron field
*/
private function parse_cron_field( $field, $min, $max ) {
if ( $field === '*' ) {
return range( $min, $max );
}
$values = [];
// Handle step values (*/N)
if ( strpos( $field, '/' ) !== false ) {
list( $range, $step ) = explode( '/', $field );
$step = (int) $step;
if ( $range === '*' ) {
for ( $i = $min; $i <= $max; $i += $step ) {
$values[] = $i;
}
}
else if ( strpos( $range, '-' ) !== false ) {
list( $start, $end ) = explode( '-', $range );
$start = (int) $start;
$end = (int) $end;
for ( $i = $start; $i <= $end && $i <= $max; $i += $step ) {
$values[] = $i;
}
}
return $values;
}
// Handle ranges (N-M)
if ( strpos( $field, '-' ) !== false ) {
list( $start, $end ) = explode( '-', $field );
return range( (int) $start, min( (int) $end, $max ) );
}
// Handle lists (N,M,...)
if ( strpos( $field, ',' ) !== false ) {
$parts = explode( ',', $field );
foreach ( $parts as $part ) {
$values[] = (int) $part;
}
return $values;
}
// Single value
return [ (int) $field ];
}
/**
* Check if time matches cron expression
*/
private function cron_matches( $parts, $time ) {
// Check minute
if ( !in_array( (int) $time['minutes'], $parts['minute'] ) ) {
return false;
}
// Check hour
if ( !in_array( (int) $time['hours'], $parts['hour'] ) ) {
return false;
}
// Check month
if ( !in_array( (int) $time['mon'], $parts['month'] ) ) {
return false;
}
// Check day of month OR day of week (standard cron behavior)
$dom_match = in_array( (int) $time['mday'], $parts['dom'] );
$dow_match = in_array( (int) $time['wday'], $parts['dow'] );
// Handle Sunday as both 0 and 7
if ( in_array( 7, $parts['dow'] ) && $time['wday'] == 0 ) {
$dow_match = true;
}
// If both dom and dow are restricted (*not* wildcards), either can match
// If one is wildcard, only the restricted one needs to match
$dom_restricted = !( count( $parts['dom'] ) === 31 );
$dow_restricted = !( count( $parts['dow'] ) === 8 || count( $parts['dow'] ) === 7 );
if ( $dom_restricted && $dow_restricted ) {
return $dom_match || $dow_match;
}
else if ( $dom_restricted ) {
return $dom_match;
}
else if ( $dow_restricted ) {
return $dow_match;
}
return true;
}
/**
* Log task start
*/
private function log_start( $task_id ) {
$this->wpdb->insert(
$this->table_tasklogs,
[
'task_id' => $task_id,
'started' => gmdate( 'Y-m-d H:i:s' ),
'status' => 'running',
'created' => gmdate( 'Y-m-d H:i:s' ),
]
);
return $this->wpdb->insert_id;
}
/**
* Log task end
*/
private function log_end( $log_id, $result, $time_taken = null ) {
$status = 'error';
if ( $result['ok'] && $result['done'] ) {
$status = 'success';
}
else if ( $result['ok'] && !$result['done'] ) {
$status = 'partial';
}
$this->wpdb->update(
$this->table_tasklogs,
[
'ended' => gmdate( 'Y-m-d H:i:s' ),
'status' => $status,
'message' => substr( $result['message'], 0, 255 ),
'time_taken' => $time_taken,
'memory_peak' => memory_get_peak_usage(),
'step' => $result['step'],
],
[ 'id' => $log_id ]
);
}
public function ensure_system_tasks() {
$this->ensure( [
'name' => 'cleanup_discussions',
'description' => 'Remove old discussions beyond retention period.',
'category' => 'system',
'schedule' => '0 3 * * *', // Daily at 3 AM UTC
] );
$this->ensure( [
'name' => 'cleanup_statistics',
'description' => 'Remove old query logs beyond Insights retention period.',
'category' => 'system',
'schedule' => '0 2 * * *', // Daily at 2 AM UTC
] );
// Ensure cleanup_files task exists
$this->ensure( [
'name' => 'cleanup_files',
'category' => 'system',
'description' => 'Delete expired files based on expiration dates.',
'schedule' => '0 4 * * *', // Daily at 4 AM UTC
] );
// Ensure cleanup_tasks exists
$this->ensure( [
'name' => 'cleanup_tasks',
'description' => 'Clean old task logs and failed tasks.',
'category' => 'system',
'schedule' => '0 13 * * *', // Daily at 1 PM UTC
'deletable' => 0, // System task, not deletable
] );
}
/**
* REST: List tasks
*/
public function rest_tasks_list( $request ) {
// Make sure table exists
$this->check_db();
$tasks = $this->wpdb->get_results(
"SELECT * FROM {$this->table_tasks} ORDER BY task_name ASC"
);
if ( $tasks === false ) {
return new WP_REST_Response( [ 'success' => false, 'message' => 'Database error', 'tasks' => [] ], 500 );
}
if ( empty( $tasks ) ) {
$tasks = [];
}
// Add computed fields
foreach ( $tasks as &$task ) {
$task->data = json_decode( $task->data, true );
$task->meta = json_decode( $task->meta, true );
$task->step_data = $task->step_data ? json_decode( $task->step_data, true ) : null;
// Ensure integers are properly cast
$task->step = (int) $task->step;
$task->error_count = (int) $task->error_count;
$task->max_retries = (int) $task->max_retries;
// Fix tasks that should be in error status but aren't
if ( $task->error_count >= $task->max_retries && $task->status === 'pending' ) {
$task->status = 'error';
$task->next_run = null;
}
// Determine if task is deletable (system tasks cannot be deleted)
$task->deletable = !in_array( $task->task_name, ['cleanup_discussions', 'cleanup_files'] ) ? 1 : 0;
// Get last message from most recent log
$last_log = $this->wpdb->get_row( $this->wpdb->prepare(
"SELECT message FROM {$this->table_tasklogs} WHERE task_id = %d ORDER BY started DESC LIMIT 1",
$task->id
) );
$task->last_message = $last_log ? $last_log->message : null;
// Get log count for this task
$log_count = $this->wpdb->get_var( $this->wpdb->prepare(
"SELECT COUNT(*) FROM {$this->table_tasklogs} WHERE task_id = %d",
$task->id
) );
$task->log_count = (int) $log_count;
// Calculate next 3 run times for preview
if ( $task->schedule !== 'once' && $task->status === 'pending' ) {
$next_runs = [];
$check_ts = $task->next_run ? strtotime( $task->next_run ) : time();
for ( $i = 0; $i < 3; $i++ ) {
$check_ts = $this->cron_next( $task->schedule, $check_ts );
$next_runs[] = gmdate( 'Y-m-d H:i:s', $check_ts );
}
$task->next_runs_preview = $next_runs;
}
else {
$task->next_runs_preview = [];
}
// Format times for display
if ( $task->last_run ) {
$task->last_run_human = $this->human_time_diff( strtotime( $task->last_run ) );
}
else {
$task->last_run_human = 'Never';
}
// Only show next_run for tasks that are actually scheduled to run
// Don't show next_run for error, done, or expired tasks
if ( $task->next_run && in_array( $task->status, ['pending', 'running', 'paused'] ) ) {
$task->next_run_human = $this->human_time_diff( strtotime( $task->next_run ) );
}
else {
$task->next_run_human = null;
$task->next_run = null; // Clear it so frontend doesn't try to display it
}
}
return new WP_REST_Response( [ 'success' => true, 'tasks' => $tasks ], 200 );
}
/**
* REST: Run task now
*/
public function rest_task_run( $request ) {
$params = $request->get_json_params();
$task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
if ( !$task_name ) {
return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
}
$result = $this->run_now( $task_name );
if ( $result ) {
return new WP_REST_Response( [ 'success' => true, 'message' => 'Task scheduled to run' ], 200 );
}
return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to run task' ], 500 );
}
/**
* REST: Pause task
*/
public function rest_task_pause( $request ) {
$params = $request->get_json_params();
$task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
if ( !$task_name ) {
return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
}
$result = $this->pause( $task_name );
if ( $result ) {
return new WP_REST_Response( [ 'success' => true, 'message' => 'Task paused' ], 200 );
}
return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to pause task' ], 500 );
}
/**
* REST: Resume task
*/
public function rest_task_resume( $request ) {
$params = $request->get_json_params();
$task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
if ( !$task_name ) {
return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
}
$result = $this->resume( $task_name );
if ( $result ) {
return new WP_REST_Response( [ 'success' => true, 'message' => 'Task resumed' ], 200 );
}
return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to resume task' ], 500 );
}
/**
* REST: Delete task
*/
public function rest_task_delete( $request ) {
$params = $request->get_json_params();
$task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
$delete_logs = isset( $params['delete_logs'] ) ? $params['delete_logs'] : true;
if ( !$task_name ) {
return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
}
$result = $this->remove( $task_name, [ 'delete_logs' => $delete_logs ] );
if ( $result ) {
return new WP_REST_Response( [ 'success' => true, 'message' => 'Task deleted' ], 200 );
}
return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to delete task' ], 500 );
}
/**
* REST: Get task logs
*/
public function rest_task_logs( $request ) {
$task_name = $request->get_param( 'task_name' );
if ( !$task_name ) {
return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
}
// Get task ID
$task_id = $this->wpdb->get_var( $this->wpdb->prepare(
"SELECT id FROM {$this->table_tasks} WHERE task_name = %s",
$task_name
) );
if ( !$task_id ) {
return new WP_REST_Response( [ 'success' => false, 'message' => 'Task not found' ], 404 );
}
// Get logs
$logs = $this->wpdb->get_results( $this->wpdb->prepare(
"SELECT * FROM {$this->table_tasklogs}
WHERE task_id = %d
ORDER BY started DESC
LIMIT 50",
$task_id
) );
return new WP_REST_Response( [ 'success' => true, 'logs' => $logs ], 200 );
}
/**
* REST: Delete task logs
*/
public function rest_task_logs_delete( $request ) {
$params = $request->get_json_params();
$task_name = isset( $params['task_name'] ) ? $params['task_name'] : null;
if ( !$task_name ) {
return new WP_REST_Response( [ 'success' => false, 'message' => 'Task name required' ], 400 );
}
// Get task ID
$task_id = $this->wpdb->get_var( $this->wpdb->prepare(
"SELECT id FROM {$this->table_tasks} WHERE task_name = %s",
$task_name
) );
if ( !$task_id ) {
return new WP_REST_Response( [ 'success' => false, 'message' => 'Task not found' ], 404 );
}
// Delete logs for this task
$result = $this->wpdb->delete(
$this->table_tasklogs,
[ 'task_id' => $task_id ],
[ '%d' ]
);
if ( $result !== false ) {
return new WP_REST_Response( [ 'success' => true, 'message' => 'Logs deleted successfully' ], 200 );
}
return new WP_REST_Response( [ 'success' => false, 'message' => 'Failed to delete logs' ], 500 );
}
/**
* REST: Reset all tasks
*/
public function rest_tasks_reset( $request ) {
// Clear all WordPress cron jobs related to tasks
wp_clear_scheduled_hook( 'mwai_tasks_internal_run' );
wp_clear_scheduled_hook( 'mwai_tasks_internal_dev_run' );
// Clear all transients
delete_transient( 'mwai_cron_last_run' );
delete_transient( 'mwai_cron_running_mwai_tasks_internal_run' );
delete_transient( 'mwai_cron_running_mwai_tasks_internal_dev_run' );
// Truncate task logs table
$this->wpdb->query( "TRUNCATE TABLE {$this->table_tasklogs}" );
// Delete all tasks
$this->wpdb->query( "TRUNCATE TABLE {$this->table_tasks}" );
// Re-initialize the Tasks Runner cron
$dev_mode = $this->core->get_option( 'dev_mode' );
if ( $dev_mode ) {
wp_schedule_event( time() + 5, 'five_seconds', 'mwai_tasks_internal_dev_run' );
}
else {
wp_schedule_event( time() + 60, 'one_minute', 'mwai_tasks_internal_run' );
}
// Re-create system tasks
$this->ensure_system_tasks();
return new WP_REST_Response( [
'success' => true,
'message' => 'Tasks system has been reset. All tasks and logs have been cleared, and system tasks have been re-created.'
], 200 );
}
/**
* Helper: Human-readable time difference (abbreviated)
*/
private function human_time_diff( $timestamp ) {
// Use current time consistently
$now = time();
$diff = $timestamp - $now;
if ( $diff < 0 ) {
// Past
$diff = abs( $diff );
$suffix = ' ago';
}
else {
// Future
$suffix = '';
}
// Use abbreviated format
if ( $diff < 60 ) {
return $diff . 's' . $suffix;
}
else if ( $diff < 3600 ) {
$minutes = round( $diff / 60 );
return $minutes . 'm' . $suffix;
}
else if ( $diff < 86400 ) {
$hours = round( $diff / 3600 );
return $hours . 'h' . $suffix;
}
else {
$days = round( $diff / 86400 );
return $days . 'd' . $suffix;
}
}
/**
* Check and create database tables
*/
public function check_db() {
// Don't run multiple times
if ( $this->db_check ) {
return true;
}
// Per-module version check: skip SHOW TABLES if already verified for this version.
if ( get_option( 'mwai_db_version_tasks' ) === MWAI_VERSION ) {
$this->db_check = true;
return true;
}
// Check if tables exist
$tasks_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_tasks'" );
$logs_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_tasklogs'" );
if ( !$tasks_exists || !$logs_exists ) {
$this->create_db();
$tasks_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_tasks'" );
$logs_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_tasklogs'" );
}
$this->db_check = $tasks_exists && $logs_exists;
if ( $this->db_check ) {
// Check for database upgrades
$this->upgrade_db();
update_option( 'mwai_db_version_tasks', MWAI_VERSION, true );
}
return $this->db_check;
}
/**
* Upgrade database schema if needed
*/
private function upgrade_db() {
// Add category column if it doesn't exist
$category_exists = $this->wpdb->get_var(
"SHOW COLUMNS FROM {$this->table_tasks} LIKE 'category'"
);
if ( !$category_exists ) {
$this->wpdb->query(
"ALTER TABLE {$this->table_tasks}
ADD COLUMN category VARCHAR(32) NOT NULL DEFAULT 'general' AFTER description"
);
}
// Remove deprecated columns if they exist
$columns_to_remove = ['auto_delete', 'deletable', 'is_multistep', 'last_message'];
foreach ( $columns_to_remove as $column ) {
$column_exists = $this->wpdb->get_var(
"SHOW COLUMNS FROM {$this->table_tasks} LIKE '$column'"
);
if ( $column_exists ) {
$this->wpdb->query( "ALTER TABLE {$this->table_tasks} DROP COLUMN $column" );
}
}
// Add step_data column if it doesn't exist
$step_data_exists = $this->wpdb->get_var(
"SHOW COLUMNS FROM {$this->table_tasks} LIKE 'step_data'"
);
if ( !$step_data_exists ) {
$this->wpdb->query(
"ALTER TABLE {$this->table_tasks}
ADD COLUMN step_data LONGTEXT NULL AFTER step_name"
);
}
}
/**
* Create database tables
*/
public function create_db() {
$charset_collate = $this->wpdb->get_charset_collate();
$sql_tasks = "CREATE TABLE $this->table_tasks (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
task_name VARCHAR(100) NOT NULL,
description TEXT NULL,
category VARCHAR(32) NOT NULL DEFAULT 'general',
schedule VARCHAR(128) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
next_run DATETIME NULL,
last_run DATETIME NULL,
expires_at DATETIME NULL,
step INT NOT NULL DEFAULT 0,
step_name VARCHAR(64) NULL,
step_data LONGTEXT NULL,
data LONGTEXT NULL,
meta LONGTEXT NULL,
error_count INT NOT NULL DEFAULT 0,
max_retries INT NOT NULL DEFAULT 3,
created DATETIME NOT NULL,
updated DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY task_name (task_name),
KEY status_next (status, next_run),
KEY category (category),
KEY expires (expires_at)
) $charset_collate;";
$sql_logs = "CREATE TABLE $this->table_tasklogs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
task_id BIGINT UNSIGNED NOT NULL,
started DATETIME NOT NULL,
ended DATETIME NULL,
status VARCHAR(16) NOT NULL,
message TEXT NULL,
time_taken FLOAT NULL,
memory_peak BIGINT NULL,
step INT NULL,
created DATETIME NOT NULL,
PRIMARY KEY (id),
KEY task_id_started (task_id, started)
) $charset_collate;";
require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
dbDelta( $sql_tasks );
dbDelta( $sql_logs );
}
}