diff --git a/services/nginx/app/classes/cron_schedule.php b/services/nginx/app/classes/cron_schedule.php new file mode 100644 index 00000000..e030a880 --- /dev/null +++ b/services/nginx/app/classes/cron_schedule.php @@ -0,0 +1,58 @@ + 2678400) { + throw new InvalidArgumentException('Cron interval must be between 30 seconds and 31 days.'); + } + + return [ + 'type' => 'interval', + 'seconds' => $seconds, + ]; + } + + public static function nextRunAt(array $schedule, ?string $anchorDateTime, int $now): string + { + $normalized = self::normalize($schedule); + $anchor = $anchorDateTime !== null && trim($anchorDateTime) !== '' + ? strtotime($anchorDateTime) + : false; + $base = $anchor !== false ? (int)$anchor : $now; + $next = $base + (int)$normalized['seconds']; + + if ($next <= $now) { + $missed = (int)floor(($now - $next) / (int)$normalized['seconds']) + 1; + $next += $missed * (int)$normalized['seconds']; + } + + return date('Y-m-d H:i:s', $next); + } + + public static function dueAt(array $schedule, ?string $lastRunAt, int $now, ?int $legacyLastRun = null): string + { + $normalized = self::normalize($schedule); + + if ($lastRunAt !== null && trim($lastRunAt) !== '') { + return self::nextRunAt($normalized, $lastRunAt, $now); + } + + if ($legacyLastRun !== null && $legacyLastRun > 0) { + return date('Y-m-d H:i:s', $legacyLastRun + (int)$normalized['seconds']); + } + + return date('Y-m-d H:i:s', $now); + } +} diff --git a/services/nginx/app/classes/cron_scheduler.php b/services/nginx/app/classes/cron_scheduler.php new file mode 100644 index 00000000..d3c2917b --- /dev/null +++ b/services/nginx/app/classes/cron_scheduler.php @@ -0,0 +1,464 @@ +registry = $registry ?? new cron_task_registry(); + $this->lock_owner = gethostname() . ':' . getmypid() . ':' . bin2hex(random_bytes(4)); + } + + public function listTasks(): array + { + $this->ensureReady(); + $this->syncDefinitions(); + + $states = $this->stateRows(); + $estimates = $this->durationEstimates(); + $tasks = []; + $now = time(); + + foreach ($this->registry->definitions() as $definition) { + $state = $states[$definition->id] ?? []; + $schedule = is_array($state['schedule'] ?? null) && $state['schedule'] !== [] + ? $state['schedule'] + : $definition->schedule; + $nextRunAt = $state['next_run_at'] ?? null; + if ($nextRunAt === null || trim((string)$nextRunAt) === '') { + $nextRunAt = cron_schedule::dueAt($schedule, $state['last_run_at'] ?? null, $now); + } + + $task = $definition->asArray($state + ['next_run_at' => $nextRunAt], $estimates[$definition->id] ?? null); + $task['due'] = strtotime($nextRunAt) !== false && strtotime($nextRunAt) <= $now; + $task['seconds_until_due'] = max(0, (int)strtotime($nextRunAt) - $now); + $tasks[] = $task; + } + + return [ + 'tasks' => $tasks, + 'summary' => [ + 'total' => count($tasks), + 'enabled' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['enabled'])), + 'due' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['due'] && (bool)$task['enabled'])), + ], + ]; + } + + public function listRuns(?string $task_id = null, int $limit = 50): array + { + $this->ensureReady(); + $limit = max(1, min(200, $limit)); + + $where = ''; + if ($task_id !== null && trim($task_id) !== '') { + $where = "WHERE task_id = " . $this->sql($task_id); + } + + return $this->fetchAll( + "SELECT * FROM cron_task_runs $where ORDER BY id DESC LIMIT $limit" + ); + } + + public function runDue(string $source = 'automatic'): array + { + $this->ensureReady(); + $this->syncDefinitions(); + + $ran = []; + $now = time(); + $states = $this->stateRows(); + + foreach ($this->registry->definitions() as $definition) { + $state = $states[$definition->id] ?? []; + if (!(bool)($state['enabled'] ?? $definition->enabled)) { + continue; + } + + $nextRunAt = (string)($state['next_run_at'] ?? ''); + if ($nextRunAt === '' || strtotime($nextRunAt) === false || strtotime($nextRunAt) > $now) { + continue; + } + + try { + $ran[] = $this->runTask($definition->id, $source, null, false, $nextRunAt); + } catch (Throwable $throwable) { + $ran[] = [ + 'task_id' => $definition->id, + 'module' => $definition->module, + 'source' => $source, + 'status' => 'skipped', + 'error_message' => $throwable->getMessage(), + ]; + } + } + + return [ + 'ran' => $ran, + 'count' => count($ran), + ]; + } + + public function runTask( + string $task_id_or_legacy_name, + string $source = 'manual', + ?int $actor_user_id = null, + bool $force = false, + ?string $scheduled_for = null + ): array { + $this->ensureReady(); + $this->syncDefinitions(); + + $definition = $this->registry->get($task_id_or_legacy_name); + if ($definition === null) { + throw new RuntimeException('Cron task not found.'); + } + + $state = $this->stateRows()[$definition->id] ?? []; + $enabled = (bool)($state['enabled'] ?? $definition->enabled); + if (!$enabled && !$force) { + throw new RuntimeException('Cron task is disabled.'); + } + + if (!$this->claimLock($definition)) { + throw new RuntimeException('Cron task is already running.'); + } + + $started = microtime(true); + $started_at = date('Y-m-d H:i:s', (int)$started); + $run_id = $this->createRun($definition, $source, $actor_user_id, $scheduled_for, $started_at); + $this->query( + "UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id) + ); + + $status = 'succeeded'; + $summary = []; + $error_message = null; + $output = ''; + + try { + if (function_exists('set_time_limit')) { + @set_time_limit($definition->timeout_seconds + 30); + } + + $this->ensureLegacyFunctionsLoaded($definition); + if (!is_callable($definition->handler)) { + throw new RuntimeException('Cron task handler is not callable: ' . $definition->handler); + } + + ob_start(); + $result = call_user_func($definition->handler); + $output = (string)ob_get_clean(); + $summary = is_array($result) ? $result : []; + } catch (Throwable $throwable) { + if (ob_get_level() > 0) { + $output .= (string)ob_get_clean(); + } + $status = 'failed'; + $error_message = $throwable->getMessage(); + } + + $completed = microtime(true); + $duration_ms = (int)round(($completed - $started) * 1000); + if ($duration_ms > ($definition->timeout_seconds * 1000) && $status === 'succeeded') { + $status = 'timed_out'; + $error_message = 'Task exceeded its configured timeout window.'; + } + + if ($output !== '') { + $summary['output'] = substr($output, 0, 8000); + } + + $completed_at = date('Y-m-d H:i:s', (int)$completed); + $this->completeRun($run_id, $status, $completed_at, $duration_ms, $summary, $error_message); + $this->releaseLock($definition, $status, $error_message, $completed_at); + + $run = $this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []; + $run['summary'] = $this->decodeJson($run['summary_json'] ?? null); + return $run; + } + + public function updateTaskConfig(string $task_id, array $config): array + { + $this->ensureReady(); + $this->syncDefinitions(); + + $definition = $this->registry->get($task_id); + if ($definition === null) { + throw new RuntimeException('Cron task not found.'); + } + + $updates = []; + if (array_key_exists('enabled', $config)) { + $updates[] = 'enabled = ' . ((bool)$config['enabled'] ? '1' : '0'); + } + + if (array_key_exists('schedule', $config)) { + $schedule = $config['schedule'] === null ? null : cron_schedule::normalize((array)$config['schedule']); + $updates[] = 'schedule_json = ' . ($schedule === null ? 'NULL' : $this->sql(json_encode($schedule))); + $anchor = (string)($this->fetchOne("SELECT last_run_at FROM cron_task_state WHERE task_id = " . $this->sql($definition->id))['last_run_at'] ?? ''); + $updates[] = 'next_run_at = ' . $this->sql(cron_schedule::dueAt($schedule ?? $definition->schedule, $anchor !== '' ? $anchor : null, time())); + } + + if ($updates !== []) { + $this->query( + "UPDATE cron_task_state SET " . implode(', ', $updates) . " WHERE task_id = " . $this->sql($definition->id) + ); + } + + return $this->listTasks(); + } + + private function ensureReady(): void + { + cron_schema_bootstrap::ensureTables(); + } + + private function syncDefinitions(): void + { + $now = time(); + foreach ($this->registry->definitions() as $definition) { + $row = $this->fetchOne( + "SELECT * FROM cron_task_state WHERE task_id = " . $this->sql($definition->id) + ); + if ($row !== null) { + continue; + } + + $legacyLastRun = $this->legacyLastRun($definition); + $nextRunAt = cron_schedule::dueAt($definition->schedule, null, $now, $legacyLastRun); + $this->query( + "INSERT INTO cron_task_state (task_id, module, enabled, schedule_json, next_run_at) + VALUES (" + . $this->sql($definition->id) . ', ' + . $this->sql($definition->module) . ', ' + . ($definition->enabled ? '1' : '0') . ', NULL, ' + . $this->sql($nextRunAt) + . ")" + ); + } + } + + private function legacyLastRun(cron_task_definition $definition): ?int + { + if ($definition->legacy_name === null || !defined('redis')) { + return null; + } + + try { + $last_run = redis->get_last_crond_run($definition->legacy_name); + return $last_run !== null ? (int)$last_run : null; + } catch (Throwable) { + return null; + } + } + + private function claimLock(cron_task_definition $definition): bool + { + $now = date('Y-m-d H:i:s'); + $locked_until = date('Y-m-d H:i:s', time() + $definition->timeout_seconds + 60); + $this->query( + "UPDATE cron_task_state + SET locked_until = " . $this->sql($locked_until) . ", + lock_owner = " . $this->sql($this->lock_owner) . " + WHERE task_id = " . $this->sql($definition->id) . " + AND (locked_until IS NULL OR locked_until < " . $this->sql($now) . ")" + ); + + return $this->affectedRows() === 1; + } + + private function releaseLock(cron_task_definition $definition, string $status, ?string $error_message, string $completed_at): void + { + $state = $this->fetchOne("SELECT schedule_json FROM cron_task_state WHERE task_id = " . $this->sql($definition->id)); + $schedule = $this->decodeJson($state['schedule_json'] ?? null); + if ($schedule === []) { + $schedule = $definition->schedule; + } + + $nextRunAt = cron_schedule::nextRunAt($schedule, $completed_at, time()); + if ($status !== 'succeeded') { + $retrySeconds = min(300, max(60, (int)$schedule['seconds'])); + $nextRunAt = date('Y-m-d H:i:s', time() + $retrySeconds); + } + + $this->query( + "UPDATE cron_task_state + SET last_run_at = " . $this->sql($completed_at) . ", + next_run_at = " . $this->sql($nextRunAt) . ", + locked_until = NULL, + lock_owner = NULL, + current_run_id = NULL, + last_status = " . $this->sql($status) . ", + last_error = " . $this->nullableSql($error_message) . " + WHERE task_id = " . $this->sql($definition->id) . " + AND lock_owner = " . $this->sql($this->lock_owner) + ); + + if ($definition->legacy_name !== null && defined('redis')) { + try { + redis->set_last_crond_run($definition->legacy_name, time()); + } catch (Throwable) { + } + } + } + + private function createRun( + cron_task_definition $definition, + string $source, + ?int $actor_user_id, + ?string $scheduled_for, + string $started_at + ): int { + $this->query( + "INSERT INTO cron_task_runs + (task_id, module, source, status, actor_user_id, scheduled_for, started_at, lock_owner) + VALUES (" + . $this->sql($definition->id) . ', ' + . $this->sql($definition->module) . ', ' + . $this->sql($source) . ", 'running', " + . ($actor_user_id === null ? 'NULL' : (string)(int)$actor_user_id) . ', ' + . $this->nullableSql($scheduled_for) . ', ' + . $this->sql($started_at) . ', ' + . $this->sql($this->lock_owner) + . ")" + ); + + return $this->insertId(); + } + + private function completeRun( + int $run_id, + string $status, + string $completed_at, + int $duration_ms, + array $summary, + ?string $error_message + ): void { + $this->query( + "UPDATE cron_task_runs + SET status = " . $this->sql($status) . ", + completed_at = " . $this->sql($completed_at) . ", + duration_ms = " . (string)$duration_ms . ", + summary_json = " . $this->sql(json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . ", + error_message = " . $this->nullableSql($error_message) . " + WHERE id = " . (string)$run_id + ); + } + + private function ensureLegacyFunctionsLoaded(cron_task_definition $definition): void + { + if (function_exists($definition->handler)) { + return; + } + + if (!defined('WD')) { + return; + } + + if (!defined('CRON_LOAD_LEGACY_FUNCTIONS_ONLY')) { + define('CRON_LOAD_LEGACY_FUNCTIONS_ONLY', true); + } + + require_once WD . '/cron/Cron.php'; + } + + /** + * @return array> + */ + private function stateRows(): array + { + $rows = $this->fetchAll("SELECT * FROM cron_task_state"); + $states = []; + foreach ($rows as $row) { + $row['enabled'] = (bool)$row['enabled']; + $row['schedule'] = $this->decodeJson($row['schedule_json'] ?? null); + $states[(string)$row['task_id']] = $row; + } + return $states; + } + + /** + * @return array + */ + private function durationEstimates(): array + { + $rows = $this->fetchAll( + "SELECT task_id, AVG(duration_ms) AS avg_duration_ms + FROM ( + SELECT task_id, duration_ms + FROM cron_task_runs + WHERE status = 'succeeded' AND duration_ms IS NOT NULL + ORDER BY id DESC + LIMIT 500 + ) recent_runs + GROUP BY task_id" + ); + + $estimates = []; + foreach ($rows as $row) { + $estimates[(string)$row['task_id']] = (int)round((float)$row['avg_duration_ms']); + } + return $estimates; + } + + private function decodeJson(mixed $json): array + { + if (!is_string($json) || trim($json) === '') { + return []; + } + $decoded = json_decode($json, true); + return is_array($decoded) ? $decoded : []; + } + + private function fetchOne(string $sql): ?array + { + $rows = $this->fetchAll($sql); + return $rows[0] ?? null; + } + + private function fetchAll(string $sql): array + { + $result = $this->query($sql); + if ($result === false || $result === true) { + return []; + } + return $result->fetch_all(MYSQLI_ASSOC); + } + + private function query(string $sql): \mysqli_result|bool + { + global $db; + return $db->query($sql); + } + + private function sql(string $value): string + { + global $db; + return "'" . $db->escape_string($value) . "'"; + } + + private function nullableSql(?string $value): string + { + return $value === null ? 'NULL' : $this->sql($value); + } + + private function affectedRows(): int + { + global $db; + return (int)$db->conn()->affected_rows; + } + + private function insertId(): int + { + global $db; + return (int)$db->insert_id(); + } +} diff --git a/services/nginx/app/classes/cron_schema_bootstrap.php b/services/nginx/app/classes/cron_schema_bootstrap.php new file mode 100644 index 00000000..01be4820 --- /dev/null +++ b/services/nginx/app/classes/cron_schema_bootstrap.php @@ -0,0 +1,66 @@ +query( + "CREATE TABLE IF NOT EXISTS cron_task_state ( + task_id VARCHAR(191) NOT NULL PRIMARY KEY, + module VARCHAR(64) NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + schedule_json LONGTEXT NULL, + last_run_at DATETIME NULL, + next_run_at DATETIME NULL, + locked_until DATETIME NULL, + lock_owner VARCHAR(191) NULL, + current_run_id BIGINT UNSIGNED NULL, + last_status VARCHAR(32) NULL, + last_error TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + KEY idx_cron_task_state_next_run (enabled, next_run_at), + KEY idx_cron_task_state_lock (locked_until) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + $db->query( + "CREATE TABLE IF NOT EXISTS cron_task_runs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + task_id VARCHAR(191) NOT NULL, + module VARCHAR(64) NOT NULL, + source VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'running', + actor_user_id INT NULL, + scheduled_for DATETIME NULL, + started_at DATETIME NULL, + completed_at DATETIME NULL, + duration_ms INT UNSIGNED NULL, + summary_json LONGTEXT NULL, + error_message TEXT NULL, + lock_owner VARCHAR(191) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + KEY idx_cron_task_runs_task_created (task_id, created_at), + KEY idx_cron_task_runs_status_created (status, created_at), + KEY idx_cron_task_runs_module_created (module, created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + self::$initialized = true; + } +} diff --git a/services/nginx/app/classes/cron_task_definition.php b/services/nginx/app/classes/cron_task_definition.php new file mode 100644 index 00000000..cd69ed76 --- /dev/null +++ b/services/nginx/app/classes/cron_task_definition.php @@ -0,0 +1,85 @@ +id = self::requiredString($definition, 'id'); + $this->name = self::requiredString($definition, 'name'); + $this->description = (string)($definition['description'] ?? ''); + $this->module = self::requiredString($definition, 'module'); + $this->handler = self::requiredString($definition, 'handler'); + $this->schedule = cron_schedule::normalize($definition['schedule'] ?? []); + $this->enabled = (bool)($definition['enabled'] ?? true); + $this->timeout_seconds = max(30, (int)($definition['timeout_seconds'] ?? 600)); + $this->estimated_duration_ms = max(0, (int)($definition['estimated_duration_ms'] ?? 0)); + $this->priority = (int)($definition['priority'] ?? 100); + $legacy_name = trim((string)($definition['legacy_name'] ?? '')); + $this->legacy_name = $legacy_name !== '' ? $legacy_name : null; + + if (!preg_match('/^[a-z0-9][a-z0-9_.-]{1,190}$/', $this->id)) { + throw new InvalidArgumentException('Invalid cron task id: ' . $this->id); + } + if (!preg_match('/^[a-z0-9][a-z0-9_-]{1,63}$/', $this->module)) { + throw new InvalidArgumentException('Invalid cron task module: ' . $this->module); + } + } + + public function asArray(?array $state = null, ?int $estimatedDurationMs = null): array + { + $schedule = is_array($state['schedule'] ?? null) && ($state['schedule'] ?? []) !== [] + ? $state['schedule'] + : $this->schedule; + $enabled = array_key_exists('enabled', $state ?? []) + ? (bool)$state['enabled'] + : $this->enabled; + + return [ + 'id' => $this->id, + 'name' => $this->name, + 'description' => $this->description, + 'module' => $this->module, + 'handler' => $this->handler, + 'schedule' => $schedule, + 'default_schedule' => $this->schedule, + 'enabled' => $enabled, + 'default_enabled' => $this->enabled, + 'timeout_seconds' => $this->timeout_seconds, + 'estimated_duration_ms' => $estimatedDurationMs ?? $this->estimated_duration_ms, + 'priority' => $this->priority, + 'legacy_name' => $this->legacy_name, + 'last_run_at' => $state['last_run_at'] ?? null, + 'next_run_at' => $state['next_run_at'] ?? null, + 'locked_until' => $state['locked_until'] ?? null, + 'lock_owner' => $state['lock_owner'] ?? null, + 'current_run_id' => $state['current_run_id'] ?? null, + 'last_status' => $state['last_status'] ?? null, + 'last_error' => $state['last_error'] ?? null, + ]; + } + + private static function requiredString(array $definition, string $key): string + { + $value = trim((string)($definition[$key] ?? '')); + if ($value === '') { + throw new InvalidArgumentException('Missing cron task definition field: ' . $key); + } + return $value; + } +} diff --git a/services/nginx/app/classes/cron_task_registry.php b/services/nginx/app/classes/cron_task_registry.php new file mode 100644 index 00000000..f278cb37 --- /dev/null +++ b/services/nginx/app/classes/cron_task_registry.php @@ -0,0 +1,85 @@ +|null */ + private ?array $definitions = null; + + public function __construct(?string $modules_root = null) + { + $this->modules_root = $modules_root ?? (defined('WD') ? WD . '/modules' : dirname(__DIR__) . '/modules'); + } + + /** + * @return array + */ + public function definitions(): array + { + if ($this->definitions !== null) { + return $this->definitions; + } + + $definitions = []; + foreach ($this->definitionFiles() as $file) { + $module_definitions = require $file; + if (!is_array($module_definitions)) { + throw new InvalidArgumentException('Cron definition file must return an array: ' . $file); + } + + foreach ($module_definitions as $definition) { + $task = new cron_task_definition($definition); + if (isset($definitions[$task->id])) { + throw new InvalidArgumentException('Duplicate cron task id: ' . $task->id); + } + $definitions[$task->id] = $task; + } + } + + uasort($definitions, static function (cron_task_definition $left, cron_task_definition $right): int { + if ($left->priority !== $right->priority) { + return $left->priority <=> $right->priority; + } + return strcmp($left->id, $right->id); + }); + + $this->definitions = $definitions; + return $definitions; + } + + public function get(string $id_or_legacy_name): ?cron_task_definition + { + $normalized = trim($id_or_legacy_name); + if ($normalized === '') { + return null; + } + + $definitions = $this->definitions(); + if (isset($definitions[$normalized])) { + return $definitions[$normalized]; + } + + foreach ($definitions as $definition) { + if ($definition->legacy_name !== null && hash_equals($definition->legacy_name, $normalized)) { + return $definition; + } + } + + return null; + } + + /** + * @return array + */ + private function definitionFiles(): array + { + $files = glob($this->modules_root . '/*/cron/tasks.php') ?: []; + sort($files, SORT_STRING); + return $files; + } +} diff --git a/services/nginx/app/cron.php b/services/nginx/app/cron.php index 3e96b9e2..a5c92187 100644 --- a/services/nginx/app/cron.php +++ b/services/nginx/app/cron.php @@ -39,8 +39,11 @@ $htaccess = " # php -- END cPanel-generated handler, do not edit "; -// Write the .htaccess file (This is not really ideal, but it works for now. This is because the .htaccess file is changed by cPanel, and it's not going to be kept there anyway.) -file_put_contents(__DIR__ . '/.htaccess', $htaccess); +// Keep the compatibility rewrite file stable without rewriting it every minute. +$htaccessPath = __DIR__ . '/.htaccess'; +if (!is_file($htaccessPath) || file_get_contents($htaccessPath) !== $htaccess) { + file_put_contents($htaccessPath, $htaccess); +} // Log the time of the cron job file_put_contents(__DIR__ . '/cron.log', date('Y-m-d H:i:s', $now) . ' - Cron job executed in ' . (time() - $now) . ' seconds ( ' . (microtime(true) - $now) . 'ms )' . PHP_EOL, FILE_APPEND); diff --git a/services/nginx/app/cron/Cron.php b/services/nginx/app/cron/Cron.php index d3c0918f..162c3683 100644 --- a/services/nginx/app/cron/Cron.php +++ b/services/nginx/app/cron/Cron.php @@ -40,6 +40,11 @@ const DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH = 1600; require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php'; require_once __DIR__ . '/../classes/economic_transfer_queue.php'; require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php'; +require_once __DIR__ . '/../classes/cron_schedule.php'; +require_once __DIR__ . '/../classes/cron_task_definition.php'; +require_once __DIR__ . '/../classes/cron_task_registry.php'; +require_once __DIR__ . '/../classes/cron_schema_bootstrap.php'; +require_once __DIR__ . '/../classes/cron_scheduler.php'; if (!defined('WD')) { exit; @@ -1469,17 +1474,17 @@ function goalsProgressAlertDue(goals_criteria $criteria, DateTimeImmutable $nowU } } -foreach ( $cron_tasks as $task => $data ) { - $lastRun = redis->get_last_crond_run($task) === null ? 0 : redis->get_last_crond_run($task); - $nextRun = $lastRun + $data['interval']; - $cron_tasks[$task]['last_run'] = $lastRun; - $cron_tasks[$task]['next_run'] = $nextRun; - if ($nextRun <= time()) { - echo "[" . date('Y-m-d H:i:s') . "][CRON] Running task: " . $task . "\n"; - $data['function'](); - redis->set_last_crond_run($task, time()); - echo "[" . date('Y-m-d H:i:s') . "][CRON] Completed task: " . $task . "\n"; - } else { - $response_cron[] = $task . ' is not due to run yet, next run is at ' . date('Y-m-d H:i:s', $nextRun) . ' (' . ($nextRun - time()) . ' seconds)'; - } +if (defined('CRON_LOAD_LEGACY_FUNCTIONS_ONLY') && CRON_LOAD_LEGACY_FUNCTIONS_ONLY) { + return; +} + +try { + $response_cron = (new \classes\cron_scheduler())->runDue('automatic'); +} catch (Throwable $throwable) { + warn('Cron scheduler failed: ' . $throwable->getMessage()); + $response_cron = [ + 'ran' => [], + 'count' => 0, + 'error' => $throwable->getMessage(), + ]; } diff --git a/services/nginx/app/modules/backups/cron/tasks.php b/services/nginx/app/modules/backups/cron/tasks.php new file mode 100644 index 00000000..8450f27e --- /dev/null +++ b/services/nginx/app/modules/backups/cron/tasks.php @@ -0,0 +1,16 @@ + 'backups.create_backup', + 'legacy_name' => 'backup', + 'name' => 'Create backup', + 'description' => 'Creates the scheduled backup bundle through the configured backup store.', + 'module' => 'backups', + 'handler' => 'backup', + 'schedule' => ['type' => 'interval', 'seconds' => 43200], + 'timeout_seconds' => 1800, + 'estimated_duration_ms' => 60000, + 'priority' => 115, + ], +]; diff --git a/services/nginx/app/modules/coolify/cron/tasks.php b/services/nginx/app/modules/coolify/cron/tasks.php new file mode 100644 index 00000000..e5898a30 --- /dev/null +++ b/services/nginx/app/modules/coolify/cron/tasks.php @@ -0,0 +1,28 @@ + 'coolify.availability_monitor', + 'legacy_name' => 'CoolifyAvailabilityMonitorCron', + 'name' => 'Coolify availability monitor', + 'description' => 'Checks registered Coolify-managed infrastructure targets.', + 'module' => 'coolify', + 'handler' => 'CoolifyAvailabilityMonitorCron', + 'schedule' => ['type' => 'interval', 'seconds' => 60], + 'timeout_seconds' => 300, + 'estimated_duration_ms' => 2000, + 'priority' => 25, + ], + [ + 'id' => 'coolify.load_balancer_reconcile', + 'legacy_name' => 'CoolifyLoadBalancerReconcileCron', + 'name' => 'Coolify load balancer reconcile', + 'description' => 'Reconciles public gateway load balancer targets when automation is enabled.', + 'module' => 'coolify', + 'handler' => 'CoolifyLoadBalancerReconcileCron', + 'schedule' => ['type' => 'interval', 'seconds' => 60], + 'timeout_seconds' => 300, + 'estimated_duration_ms' => 3000, + 'priority' => 26, + ], +]; diff --git a/services/nginx/app/modules/dynamicimages/cron/tasks.php b/services/nginx/app/modules/dynamicimages/cron/tasks.php new file mode 100644 index 00000000..41b57cc7 --- /dev/null +++ b/services/nginx/app/modules/dynamicimages/cron/tasks.php @@ -0,0 +1,16 @@ + 'dynamicimages.pre_render', + 'legacy_name' => 'PreRenderDynamicImagesCron', + 'name' => 'Pre-render dynamic images', + 'description' => 'Pre-renders common self-serve dynamic image variants into Redis.', + 'module' => 'dynamicimages', + 'handler' => 'PreRenderDynamicImagesCron', + 'schedule' => ['type' => 'interval', 'seconds' => 900], + 'timeout_seconds' => 900, + 'estimated_duration_ms' => 30000, + 'priority' => 90, + ], +]; diff --git a/services/nginx/app/modules/economic/cron/tasks.php b/services/nginx/app/modules/economic/cron/tasks.php new file mode 100644 index 00000000..4811a9c3 --- /dev/null +++ b/services/nginx/app/modules/economic/cron/tasks.php @@ -0,0 +1,64 @@ + 'economic.sync_user_customer_discounts', + 'legacy_name' => 'SyncUserEconomicCustomerDiscounts', + 'name' => 'Sync e-conomic customer discounts', + 'description' => 'Clears cached customer discounts so e-conomic discount data can refresh.', + 'module' => 'economic', + 'handler' => 'SyncUserEconomicCustomerDiscounts', + 'schedule' => ['type' => 'interval', 'seconds' => 180], + 'timeout_seconds' => 300, + 'estimated_duration_ms' => 2000, + 'priority' => 30, + ], + [ + 'id' => 'economic.sync_user_customer_details', + 'legacy_name' => 'SyncUserEconomicCustomerDetails', + 'name' => 'Sync e-conomic customer details', + 'description' => 'Refreshes customer details and search documents from e-conomic.', + 'module' => 'economic', + 'handler' => 'SyncUserEconomicCustomerDetails', + 'schedule' => ['type' => 'interval', 'seconds' => 43200], + 'timeout_seconds' => 1800, + 'estimated_duration_ms' => 30000, + 'priority' => 110, + ], + [ + 'id' => 'economic.sync_system_search_customer_index', + 'legacy_name' => 'SyncSystemSearchEconomicCustomerIndex', + 'name' => 'Sync e-conomic customer search index', + 'description' => 'Refreshes the system search e-conomic customer index.', + 'module' => 'economic', + 'handler' => 'SyncSystemSearchEconomicCustomerIndex', + 'schedule' => ['type' => 'interval', 'seconds' => 900], + 'timeout_seconds' => 900, + 'estimated_duration_ms' => 10000, + 'priority' => 75, + ], + [ + 'id' => 'economic.sync_invoice_status', + 'legacy_name' => 'SyncEconomicInvoiceStatus', + 'name' => 'Sync e-conomic invoice status', + 'description' => 'Checks e-conomic invoice errors and draft status.', + 'module' => 'economic', + 'handler' => 'SyncEconomicInvoiceStatus', + 'schedule' => ['type' => 'interval', 'seconds' => 120], + 'timeout_seconds' => 300, + 'estimated_duration_ms' => 3000, + 'priority' => 35, + ], + [ + 'id' => 'economic.transfer_queue', + 'legacy_name' => 'EconomicTransferQueueCron', + 'name' => 'Process e-conomic transfer queue', + 'description' => 'Processes pending e-conomic transfer queue jobs in bounded batches.', + 'module' => 'economic', + 'handler' => 'EconomicTransferQueueCron', + 'schedule' => ['type' => 'interval', 'seconds' => 30], + 'timeout_seconds' => 300, + 'estimated_duration_ms' => 3000, + 'priority' => 20, + ], +]; diff --git a/services/nginx/app/modules/failover/cron/tasks.php b/services/nginx/app/modules/failover/cron/tasks.php new file mode 100644 index 00000000..3ae0d93d --- /dev/null +++ b/services/nginx/app/modules/failover/cron/tasks.php @@ -0,0 +1,16 @@ + 'failover.replica_monitor', + 'legacy_name' => 'ReplicaFailoverMonitorCron', + 'name' => 'Replica failover monitor', + 'description' => 'Checks replicated services and promotes eligible replicas during automatic failover.', + 'module' => 'failover', + 'handler' => 'ReplicaFailoverMonitorCron', + 'schedule' => ['type' => 'interval', 'seconds' => 60], + 'timeout_seconds' => 300, + 'estimated_duration_ms' => 3000, + 'priority' => 24, + ], +]; diff --git a/services/nginx/app/modules/goals/cron/tasks.php b/services/nginx/app/modules/goals/cron/tasks.php new file mode 100644 index 00000000..42a78291 --- /dev/null +++ b/services/nginx/app/modules/goals/cron/tasks.php @@ -0,0 +1,16 @@ + 'goals.progress_alerts', + 'legacy_name' => 'GoalsProgressAlertsCron', + 'name' => 'Goals progress alerts', + 'description' => 'Evaluates department goal alert schedules and sends due progress alerts.', + 'module' => 'goals', + 'handler' => 'GoalsProgressAlertsCron', + 'schedule' => ['type' => 'interval', 'seconds' => 60], + 'timeout_seconds' => 600, + 'estimated_duration_ms' => 10000, + 'priority' => 45, + ], +]; diff --git a/services/nginx/app/modules/system/cron/tasks.php b/services/nginx/app/modules/system/cron/tasks.php new file mode 100644 index 00000000..a0d12f03 --- /dev/null +++ b/services/nginx/app/modules/system/cron/tasks.php @@ -0,0 +1,64 @@ + 'system.sync_logs', + 'legacy_name' => 'SyncLogs', + 'name' => 'Sync logs', + 'description' => 'Flush Redis-backed application logs into the database.', + 'module' => 'system', + 'handler' => 'syncLogsToDatabase', + 'schedule' => ['type' => 'interval', 'seconds' => 300], + 'timeout_seconds' => 120, + 'estimated_duration_ms' => 1000, + 'priority' => 10, + ], + [ + 'id' => 'system.search_cache_maintenance', + 'legacy_name' => 'SystemSearchCacheMaintenanceCron', + 'name' => 'System search cache maintenance', + 'description' => 'Processes dirty-table and full rebuild requests for system search caches.', + 'module' => 'system', + 'handler' => 'SystemSearchCacheMaintenanceCron', + 'schedule' => ['type' => 'interval', 'seconds' => 300], + 'timeout_seconds' => 600, + 'estimated_duration_ms' => 3000, + 'priority' => 70, + ], + [ + 'id' => 'system.prune_session_activity', + 'legacy_name' => 'PruneSystemSessionActivityCron', + 'name' => 'Prune system session activity', + 'description' => 'Deletes stale system session activity rows.', + 'module' => 'system', + 'handler' => 'PruneSystemSessionActivityCron', + 'schedule' => ['type' => 'interval', 'seconds' => 86400], + 'timeout_seconds' => 300, + 'estimated_duration_ms' => 1000, + 'priority' => 120, + ], + [ + 'id' => 'system.invoice_flags_manual_cache', + 'legacy_name' => 'WarmInvoicePeriodManualFlagsCron', + 'name' => 'Warm manual invoice flag cache', + 'description' => 'Warms superuser invoice-period manual flag counters.', + 'module' => 'system', + 'handler' => 'WarmInvoicePeriodManualFlagsCron', + 'schedule' => ['type' => 'interval', 'seconds' => 300], + 'timeout_seconds' => 600, + 'estimated_duration_ms' => 2000, + 'priority' => 80, + ], + [ + 'id' => 'system.invoice_flags_automatic_cache', + 'legacy_name' => 'WarmInvoicePeriodAutomaticFlagsCron', + 'name' => 'Warm automatic invoice flag cache', + 'description' => 'Warms current, previous, and queued invoice-period automatic flag caches.', + 'module' => 'system', + 'handler' => 'WarmInvoicePeriodAutomaticFlagsCron', + 'schedule' => ['type' => 'interval', 'seconds' => 300], + 'timeout_seconds' => 900, + 'estimated_duration_ms' => 5000, + 'priority' => 85, + ], +]; diff --git a/services/nginx/app/modules/weatherapi/cron/tasks.php b/services/nginx/app/modules/weatherapi/cron/tasks.php new file mode 100644 index 00000000..65a61c68 --- /dev/null +++ b/services/nginx/app/modules/weatherapi/cron/tasks.php @@ -0,0 +1,16 @@ + 'weatherapi.preload_department_responses', + 'legacy_name' => 'PreloadDepartmentWeatherResponsesCron', + 'name' => 'Preload department weather responses', + 'description' => 'Warms department weather timeline responses for visible and active departments.', + 'module' => 'weatherapi', + 'handler' => 'PreloadDepartmentWeatherResponsesCron', + 'schedule' => ['type' => 'interval', 'seconds' => 60], + 'timeout_seconds' => 300, + 'estimated_duration_ms' => 5000, + 'priority' => 40, + ], +]; diff --git a/services/nginx/app/modules/workfeed/cron/tasks.php b/services/nginx/app/modules/workfeed/cron/tasks.php new file mode 100644 index 00000000..ff89cea6 --- /dev/null +++ b/services/nginx/app/modules/workfeed/cron/tasks.php @@ -0,0 +1,16 @@ + 'workfeed.warm_employee_names', + 'legacy_name' => 'WarmWorkfeedEmployeeNamesCron', + 'name' => 'Warm Workfeed employee names', + 'description' => 'Warms Redis mappings for Workfeed employee identifiers and display names.', + 'module' => 'workfeed', + 'handler' => 'WarmWorkfeedEmployeeNamesCron', + 'schedule' => ['type' => 'interval', 'seconds' => 21600], + 'timeout_seconds' => 600, + 'estimated_duration_ms' => 5000, + 'priority' => 100, + ], +]; diff --git a/services/nginx/app/modules/xlvask/cron/tasks.php b/services/nginx/app/modules/xlvask/cron/tasks.php new file mode 100644 index 00000000..5c6c7bb2 --- /dev/null +++ b/services/nginx/app/modules/xlvask/cron/tasks.php @@ -0,0 +1,16 @@ + 'xlvask.sync_module', + 'legacy_name' => 'SyncXLVaskModuleCron', + 'name' => 'Sync XL Vask module', + 'description' => 'Runs scheduled XL Vask synchronization tasks when the module is enabled.', + 'module' => 'xlvask', + 'handler' => 'SyncXLVaskModuleCron', + 'schedule' => ['type' => 'interval', 'seconds' => 3600], + 'timeout_seconds' => 900, + 'estimated_duration_ms' => 10000, + 'priority' => 95, + ], +]; diff --git a/services/nginx/app/routes/cronRoute.php b/services/nginx/app/routes/cronRoute.php index 4862d554..1730975c 100644 --- a/services/nginx/app/routes/cronRoute.php +++ b/services/nginx/app/routes/cronRoute.php @@ -3,7 +3,9 @@ namespace routes; use classes\authentication; +use classes\cron_scheduler; use objects\logs_o; +use Throwable; use traits\route_t; class cronRoute @@ -12,39 +14,152 @@ class cronRoute public function run(): void { - $this->post('/superuser/cron', function () { - // Get the post data + $this->get('/superuser/cron', function () { global $response; - // Make sure the user has the SUPERUSER_RUN_CRON permission - $this->requirePermission('SUPERUSER_RUN_CRON'); - // Get the user object - $user = (new authentication())->get_user(); - // Get the post data - $data = json_decode(file_get_contents('php://input'), true); - // Check if a specific cron job is requested - if (isset($data['job'])) { - // Check if the cron job exists - if (!file_exists(WD . '/cron/' . $data['job'] . '.php')) { - $response->error('Cron job not found', 404); - } - // Include the cron job - require_once WD . '/cron/' . $data['job'] . '.php'; - // Log the incident - (new logs_o())->add('cron', 'global', 1, $user->id, 'CRON_JOB_RUN', 'Ran cron job: ' . $data['job']); - $response->success('Cron job ran successfully'); - } - // If no specific cron job is requested, run all cron jobs (through the cron.php file) - require_once WD . '/cron/Cron.php'; - // Log the incident - (new logs_o())->add('cron', 'global', 1, $user->id, 'CRON_RUN', 'Ran all cron jobs'); + + $this->requireClassicSuperuserPermission('superuser_cron_view'); + $response->success((new cron_scheduler())->listTasks()); + }, [ + 'superuser_cron_view' => 'View cron task schedule, run status, estimates, and history', + ]); + + $this->get('/superuser/cron/runs', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_cron_view'); + $task_id = $this->getParameter('task_id'); + $limit = (int)($this->getParameter('limit') ?? 50); $response->success([ - 'message' => 'All cron jobs ran successfully', - 'data' => $response_cron ?? [] + 'runs' => (new cron_scheduler())->listRuns(is_string($task_id) ? $task_id : null, $limit), ]); + }, [ + 'superuser_cron_view' => 'View cron task schedule, run status, estimates, and history', + ]); + + $this->post('/superuser/cron/run', function () { + global $response; + + $this->requireClassicSuperuserPermission('SUPERUSER_RUN_CRON'); + $parameters = $this->getParametersAsArray(); + $task_id = trim((string)($parameters['task_id'] ?? '')); + if ($task_id === '') { + $response->error('Missing cron task id.', 400); + } + + try { + $run = (new cron_scheduler())->runTask( + $task_id, + 'manual', + $this->actorUserId(), + $this->toBool($parameters['force'] ?? false, false) + ); + (new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_JOB_RUN', 'Ran cron task: ' . $task_id); + $response->success($run); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'SUPERUSER_RUN_CRON' => 'Run cron jobs', + ]); + + $this->patch('/superuser/cron/config', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_cron_manage'); + $parameters = $this->getParametersAsArray(); + $task_id = trim((string)($parameters['task_id'] ?? '')); + if ($task_id === '') { + $response->error('Missing cron task id.', 400); + } + + try { + $config = []; + if (array_key_exists('enabled', $parameters)) { + $config['enabled'] = $this->toBool($parameters['enabled'], true); + } + if (array_key_exists('schedule', $parameters)) { + $config['schedule'] = $parameters['schedule']; + } + $result = (new cron_scheduler())->updateTaskConfig($task_id, $config); + (new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_TASK_CONFIG_UPDATED', 'Updated cron task: ' . $task_id); + $response->success($result); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_cron_manage' => 'Configure cron task schedules and enabled state', + ]); + + $this->post('/superuser/cron', function () { + global $response; + + $this->requireClassicSuperuserPermission('SUPERUSER_RUN_CRON'); + $parameters = $this->getParametersAsArray(); + $scheduler = new cron_scheduler(); + + try { + if (isset($parameters['job']) && trim((string)$parameters['job']) !== '') { + $job = trim((string)$parameters['job']); + $run = $scheduler->runTask($job, 'manual', $this->actorUserId(), true); + (new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_JOB_RUN', 'Ran cron job: ' . $job); + $response->success([ + 'message' => 'Cron job ran successfully', + 'run' => $run, + ]); + } + + $result = $scheduler->runDue('manual'); + (new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_RUN', 'Ran due cron jobs'); + $response->success([ + 'message' => 'Due cron jobs ran successfully', + 'data' => $result, + ]); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } }, [ 'SUPERUSER_RUN_CRON' => 'Run cron jobs' ] ); } + + private function requireClassicSuperuserPermission(string $permission): bool + { + global $response; + + if ((new authentication())->get_subuser() !== false) { + $response->error('Subuser sessions cannot manage cron tasks.', 403); + } + + return $this->requirePermission($permission); + } + + private function actorUserId(): ?int + { + try { + $user = (new authentication())->get_user(); + return $user !== false && isset($user->id) ? (int)$user->id : null; + } catch (Throwable) { + return null; + } + } + + private function toBool(mixed $value, bool $default): bool + { + if (is_bool($value)) { + return $value; + } + if ($value === null) { + return $default; + } + $normalized = strtolower(trim((string)$value)); + if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) { + return true; + } + if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) { + return false; + } + return $default; + } } diff --git a/services/nginx/app/tests/Unit/Cron/CronCompatibilityEntrypointTest.php b/services/nginx/app/tests/Unit/Cron/CronCompatibilityEntrypointTest.php new file mode 100644 index 00000000..1fc48b4c --- /dev/null +++ b/services/nginx/app/tests/Unit/Cron/CronCompatibilityEntrypointTest.php @@ -0,0 +1,19 @@ +not->toBeFalse(); + expect($content)->toContain('CRON_LOAD_LEGACY_FUNCTIONS_ONLY'); + expect($content)->toContain("new \\classes\\cron_scheduler())->runDue('automatic')"); + expect($content)->toContain('function EconomicTransferQueueCron(): void'); + expect($content)->toContain('function GoalsProgressAlertsCron(): void'); +}); + +it('only rewrites htaccess when the compatibility file changes', function (): void { + $content = file_get_contents(app_path('cron.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('file_get_contents($htaccessPath) !== $htaccess'); + expect($content)->toContain('file_put_contents($htaccessPath, $htaccess)'); +}); diff --git a/services/nginx/app/tests/Unit/Cron/CronRouteWiringTest.php b/services/nginx/app/tests/Unit/Cron/CronRouteWiringTest.php new file mode 100644 index 00000000..38cf2141 --- /dev/null +++ b/services/nginx/app/tests/Unit/Cron/CronRouteWiringTest.php @@ -0,0 +1,31 @@ +not->toBeFalse(); + expect($content)->toContain('/superuser/cron'); + expect($content)->toContain('/superuser/cron/runs'); + expect($content)->toContain('/superuser/cron/run'); + expect($content)->toContain('/superuser/cron/config'); + expect($content)->toContain('new cron_scheduler()'); + expect($content)->toContain("requireClassicSuperuserPermission('superuser_cron_view')"); + expect($content)->toContain("requireClassicSuperuserPermission('SUPERUSER_RUN_CRON')"); + expect($content)->toContain("requireClassicSuperuserPermission('superuser_cron_manage')"); +}); + +it('does not include caller-controlled cron php files', function (): void { + $content = file_get_contents(app_path('routes/cronRoute.php')); + + expect($content)->not->toContain("WD . '/cron/'"); + expect($content)->not->toContain("file_exists(WD . '/cron/'"); + expect($content)->not->toContain("require_once WD . '/cron/'"); +}); + +it('keeps the legacy cron post endpoint as a scheduler bridge', function (): void { + $content = file_get_contents(app_path('routes/cronRoute.php')); + + expect($content)->toContain('runTask($job, \'manual\''); + expect($content)->toContain("runDue('manual')"); + expect($content)->toContain('Cron job ran successfully'); +}); diff --git a/services/nginx/app/tests/Unit/Cron/CronTaskRegistryTest.php b/services/nginx/app/tests/Unit/Cron/CronTaskRegistryTest.php new file mode 100644 index 00000000..81ad0c12 --- /dev/null +++ b/services/nginx/app/tests/Unit/Cron/CronTaskRegistryTest.php @@ -0,0 +1,56 @@ +definitions(); + + expect($definitions)->toHaveCount(19); + expect(array_keys($definitions))->toContain( + 'system.sync_logs', + 'economic.transfer_queue', + 'dynamicimages.pre_render', + 'weatherapi.preload_department_responses', + 'goals.progress_alerts' + ); + + $transferQueue = $registry->get('EconomicTransferQueueCron'); + expect($transferQueue)->not->toBeNull(); + expect($transferQueue->id)->toBe('economic.transfer_queue'); + expect($transferQueue->module)->toBe('economic'); + expect($transferQueue->schedule)->toBe(['type' => 'interval', 'seconds' => 30]); +}); + +it('keeps every discovered cron task in a module cron folder', function (): void { + $files = glob(app_path('modules/*/cron/tasks.php')) ?: []; + $modules = array_map( + static fn(string $file): string => basename(dirname(dirname($file))), + $files + ); + + $registry = new cron_task_registry(app_path('modules')); + foreach ($registry->definitions() as $definition) { + expect($modules)->toContain($definition->module); + } +}); + +it('normalizes and advances interval schedules without tight loops', function (): void { + expect(cron_schedule::normalize(['type' => 'interval', 'seconds' => 60])) + ->toBe(['type' => 'interval', 'seconds' => 60]); + + $now = strtotime('2026-07-09 12:10:00'); + $next = cron_schedule::nextRunAt( + ['type' => 'interval', 'seconds' => 300], + '2026-07-09 12:00:00', + $now + ); + + expect($next)->toBe('2026-07-09 12:15:00'); +}); + +it('rejects unsafe cron intervals', function (): void { + expect(fn() => cron_schedule::normalize(['type' => 'interval', 'seconds' => 5])) + ->toThrow(InvalidArgumentException::class); +});