diff --git a/config/config.php b/config/config.php index 957a9e1d..eb9e6e84 100644 --- a/config/config.php +++ b/config/config.php @@ -9,7 +9,6 @@ use App\Commands\State\PushCommand; use App\Commands\System\IndexCommand; use App\Commands\System\PruneCommand; use App\Libs\Mappers\Import\MemoryMapper; -use App\Libs\Scheduler\Task; use App\Libs\Servers\EmbyServer; use App\Libs\Servers\JellyfinServer; use App\Libs\Servers\PlexServer; @@ -185,47 +184,54 @@ return (function () { $config['tasks'] = [ 'logfile' => ag($config, 'tmpDir') . '/logs/task.' . $logDateFormat . '.log', - 'commands' => [ + 'list' => [ ImportCommand::TASK_NAME => [ - Task::NAME => ImportCommand::TASK_NAME, - Task::ENABLED => (bool)env('WS_CRON_IMPORT', false), - Task::RUN_AT => (string)env('WS_CRON_IMPORT_AT', '0 */1 * * *'), - Task::COMMAND => '@' . ImportCommand::ROUTE, - Task::ARGS => env('WS_CRON_IMPORT_ARGS', '-v'), + 'command' => ImportCommand::ROUTE, + 'name' => ImportCommand::TASK_NAME, + 'info' => 'Import play state and metadata from backends.', + 'enabled' => (bool)env('WS_CRON_IMPORT', false), + 'timer' => (string)env('WS_CRON_IMPORT_AT', '0 */1 * * *'), + 'args' => env('WS_CRON_IMPORT_ARGS', '-v'), ], ExportCommand::TASK_NAME => [ - Task::NAME => ExportCommand::TASK_NAME, - Task::ENABLED => (bool)env('WS_CRON_EXPORT', false), - Task::RUN_AT => (string)env('WS_CRON_EXPORT_AT', '30 */1 * * *'), - Task::COMMAND => '@' . ExportCommand::ROUTE, - Task::ARGS => env('WS_CRON_EXPORT_ARGS', '-v'), + 'command' => ExportCommand::ROUTE, + 'name' => ExportCommand::TASK_NAME, + 'info' => 'Export play state to backends.', + 'enabled' => (bool)env('WS_CRON_EXPORT', false), + 'timer' => (string)env('WS_CRON_EXPORT_AT', '30 */1 * * *'), + 'args' => env('WS_CRON_EXPORT_ARGS', '-v'), ], PushCommand::TASK_NAME => [ - Task::NAME => PushCommand::TASK_NAME, - Task::ENABLED => (bool)env('WS_CRON_PUSH', false), - Task::RUN_AT => (string)env('WS_CRON_PUSH_AT', '*/10 * * * *'), - Task::COMMAND => '@' . PushCommand::ROUTE, - Task::ARGS => env('WS_CRON_PUSH_ARGS', '-v'), - ], - PruneCommand::TASK_NAME => [ - Task::NAME => PruneCommand::TASK_NAME, - Task::ENABLED => (bool)env('WS_CRON_PRUNE', true), - Task::RUN_AT => (string)env('WS_CRON_PRUNE_AT', '0 */12 * * *'), - Task::COMMAND => '@' . PruneCommand::ROUTE, - Task::ARGS => '-v', - ], - IndexCommand::TASK_NAME => [ - Task::NAME => IndexCommand::TASK_NAME, - Task::ENABLED => true, - Task::RUN_AT => '0 3 * * 3', - Task::COMMAND => '@' . IndexCommand::ROUTE, + 'command' => PushCommand::ROUTE, + 'name' => PushCommand::TASK_NAME, + 'info' => 'Push Webhook play states to backends.', + 'enabled' => (bool)env('WS_CRON_PUSH', false), + 'timer' => (string)env('WS_CRON_PUSH_AT', '*/10 * * * *'), + 'args' => env('WS_CRON_PUSH_ARGS', '-v'), ], BackupCommand::TASK_NAME => [ - Task::NAME => BackupCommand::TASK_NAME, - Task::ENABLED => (bool)env('WS_CRON_BACKUP', false), - Task::RUN_AT => (string)env('WS_CRON_BACKUP_AT', '0 6 */3 * *'), - Task::COMMAND => '@' . BackupCommand::ROUTE, - Task::ARGS => env('WS_CRON_EXPORT_ARGS', '-v'), + 'command' => BackupCommand::ROUTE, + 'name' => BackupCommand::TASK_NAME, + 'info' => 'Backup backends play states.', + 'enabled' => (bool)env('WS_CRON_BACKUP', false), + 'timer' => (string)env('WS_CRON_BACKUP_AT', '0 6 */3 * *'), + 'args' => env('WS_CRON_BACKUP_ARGS', '-v'), + ], + PruneCommand::TASK_NAME => [ + 'command' => PruneCommand::ROUTE, + 'name' => PruneCommand::TASK_NAME, + 'info' => 'Delete old logs and backups.', + 'enabled' => (bool)env('WS_CRON_PRUNE', true), + 'timer' => (string)env('WS_CRON_PRUNE_AT', '0 */12 * * *'), + 'args' => env('WS_CRON_PRUNE_ARGS', '-v'), + ], + IndexCommand::TASK_NAME => [ + 'command' => IndexCommand::ROUTE, + 'name' => IndexCommand::TASK_NAME, + 'info' => 'Check database for optimal indexes.', + 'enabled' => (bool)env('WS_CRON_INDEXES', true), + 'timer' => (string)env('WS_CRON_INDEXES_AT', '0 3 * * 3'), + 'args' => env('WS_CRON_INDEXES_ARGS', '-v'), ], ], ]; diff --git a/docker/files/cron.sh b/docker/files/cron.sh index 8e5d660c..df7620ca 100755 --- a/docker/files/cron.sh +++ b/docker/files/cron.sh @@ -1,10 +1,9 @@ #!/usr/bin/env sh UID=$(id -u) -WS_CRON_DEBUG=${WS_CRON_DEBUG:-v} if [ 0 == "${UID}" ]; then - runuser -u www-data -- /usr/bin/console scheduler:run --save-log -${WS_CRON_DEBUG} + runuser -u www-data -- /usr/bin/console system:tasks --save-log else - /usr/bin/console scheduler:run --save-log -${WS_CRON_DEBUG} + /usr/bin/console system:tasks --save-log fi diff --git a/src/Commands/Scheduler/ListCommand.php b/src/Commands/Scheduler/ListCommand.php deleted file mode 100644 index b71c4ba3..00000000 --- a/src/Commands/Scheduler/ListCommand.php +++ /dev/null @@ -1,105 +0,0 @@ -setName(self::ROUTE) - ->addOption('timezone', 't', InputOption::VALUE_REQUIRED, 'Set Timezone.', Config::get('tz', 'UTC')) - ->setDescription('List Scheduled Tasks.'); - } - - /** - * @throws Exception - */ - protected function runCommand(InputInterface $input, OutputInterface $output): int - { - $list = []; - - $table = new Table($output); - $table->setHeaders( - [ - 'Name', - 'Command', - 'Run As', - 'Run At', - 'In Background' - ] - ); - - foreach (RunCommand::getTasks() as $task) { - $task = RunCommand::fixTask($task); - - $timer = $task[Task::RUN_AT] ?? TaskTimer::everyMinute(5); - if ((!$timer instanceof CronExpression)) { - $timer = TaskTimer::at($timer); - } - - $task[Task::RUN_AT] = $timer->getNextRunDate('now'); - $task[Task::RUN_IN_FOREGROUND] = (bool)($task[Task::RUN_IN_FOREGROUND] ?? false); - - if (false === $task[Task::USE_CLOSURE_AS_COMMAND] && ($task[Task::COMMAND] instanceof Closure)) { - $task['Type'] = 'PHP Sub Process'; - $task[Task::COMMAND] = 'Closure'; - } else { - if (($task[Task::COMMAND] instanceof Closure)) { - $task[Task::COMMAND] = RunClosureCommand::runClosure($task[Task::COMMAND], $task[Task::CONFIG]); - } - - if (!is_string($task[Task::COMMAND])) { - throw new RuntimeException( - sprintf('Task \'%s\' Command did not evaluated to a string.', $task['name']) - ); - } - - if (str_starts_with($task[Task::COMMAND], '@')) { - $task[Task::COMMAND] = substr($task[Task::COMMAND], 1); - $task['Type'] = 'App Command'; - } else { - $task['Type'] = 'Raw Shell'; - } - } - - $list[] = [ - $task[Task::NAME], - $task[Task::COMMAND], - $task['Type'], - $task[Task::ENABLED] ? $task[Task::RUN_AT]->setTimezone( - new DateTimeZone($input->getOption('timezone')) - )->format( - DateTimeInterface::ATOM - ) : 'Disabled via config', - $task[Task::RUN_IN_FOREGROUND] ? 'No' : 'Yes', - ]; - } - - $table->setRows($list); - - $table->render(); - - return self::SUCCESS; - } -} diff --git a/src/Commands/Scheduler/RunClosureCommand.php b/src/Commands/Scheduler/RunClosureCommand.php deleted file mode 100644 index 7a80ab67..00000000 --- a/src/Commands/Scheduler/RunClosureCommand.php +++ /dev/null @@ -1,88 +0,0 @@ -setName(self::ROUTE) - ->addArgument('task', InputArgument::REQUIRED, 'Run task closure.', null) - ->setDescription('Run task closure.'); - } - - protected function runCommand(InputInterface $input, OutputInterface $output): int - { - $runTask = $input->getArgument('task'); - - if (!is_string($runTask)) { - throw new RuntimeException('Invalid Task name was given.'); - } - - $tasks = RunCommand::getTasks(); - - if (!array_key_exists($runTask, $tasks)) { - throw new RuntimeException(sprintf('Task \'%s\' was not found in Tasks config file.', $runTask)); - } - - $task = RunCommand::fixTask($tasks[$runTask]); - - $isRunnable = ($task[Task::COMMAND] instanceof Closure) && false === $task[Task::USE_CLOSURE_AS_COMMAND]; - - if (false === $isRunnable) { - throw new RuntimeException( - sprintf( - 'Was Expecting Command to be \'Closure\' for \'%s\'. But got \'%s\' instead.', - $runTask, - gettype($task[Task::COMMAND]) - ) - ); - } - - try { - $commandOutput = (string)self::runClosure($task[Task::COMMAND], $task[Task::CONFIG]); - } catch (Throwable $e) { - $commandOutput = sprintf( - 'Task \'%s\' has thrown unhandled exception. \'%s\'. (%s:%d)', - $task[Task::NAME], - $e->getMessage(), - $e->getFile(), - $e->getLine() - ); - - $this->logger->error($e->getMessage(), $e->getTrace()); - } - - $output->write($commandOutput); - - return self::SUCCESS; - } - - public static function runClosure(Closure $fn, array $config = []): mixed - { - return Container::get(ReflectionContainer::class)->call($fn, $config); - } -} diff --git a/src/Commands/Scheduler/RunCommand.php b/src/Commands/Scheduler/RunCommand.php deleted file mode 100644 index f94200ce..00000000 --- a/src/Commands/Scheduler/RunCommand.php +++ /dev/null @@ -1,253 +0,0 @@ -scheduler = $scheduler; - - parent::__construct(); - } - - protected function configure(): void - { - $this->setName(self::ROUTE) - ->addOption('no-headers', 'g', InputOption::VALUE_NONE, 'Do not prefix output with headers.') - ->addOption('save-log', null, InputOption::VALUE_NONE, 'Save Tasks Output to file.') - ->addArgument('task_name', InputArgument::OPTIONAL, 'Run specific task.', null) - ->setDescription('Run Scheduled Tasks.'); - } - - protected function runCommand(InputInterface $input, OutputInterface $output): int - { - $runSpecificTask = $input->getArgument('task_name'); - - $tasks = self::getTasks(); - - if (is_string($runSpecificTask)) { - if (!array_key_exists($runSpecificTask, $tasks)) { - throw new RuntimeException( - sprintf('Task \'%s\' was not found in Tasks config file.', $runSpecificTask) - ); - } - - $tasks[$runSpecificTask][Task::RUN_AT] = TaskTimer::everyMinute(); - - $tasks = [$tasks[$runSpecificTask]]; - } - - foreach ($tasks as $task) { - $newTask = $this->makeTask($task); - - if (true !== $task[Task::ENABLED] && $task[Task::NAME] !== $runSpecificTask) { - continue; - } - - $this->scheduler->queueTask($newTask); - } - - $this->scheduler->run(new DateTimeImmutable('now')); - - $executedTasks = $this->scheduler->getExecutedTasks(); - - $count = count($executedTasks); - - if (0 === $count) { - $this->write( - sprintf('[%s] No Tasks Scheduled to run at this time.', makeDate()), - $input, - $output, - OutputInterface::VERBOSITY_VERY_VERBOSE - ); - } - - $tasks = array_reverse($executedTasks); - $noHeaders = (bool)$input->getOption('no-headers'); - - foreach ($tasks as $task) { - $taskOutput = trim($task->getOutput()); - - if (empty($taskOutput)) { - continue; - } - - if (false === $noHeaders) { - $this->write('--------------------------', $input, $output); - $this->write('Command: ' . $task->getCommand() . ' ' . $task->getArgs(), $input, $output); - $this->write('Date: ' . makeDate(), $input, $output); - $this->write('--------------------------', $input, $output); - $this->write(sprintf('Task [%s] Output.', $task->getName()), $input, $output); - $this->write('--------------------------', $input, $output); - $this->write('', $input, $output); - } - - $this->write($taskOutput, $input, $output); - - if (false === $noHeaders) { - $this->write('', $input, $output); - } - } - - if ($input->getOption('save-log')) { - file_put_contents( - Config::get('tasks.logfile'), - preg_replace('#\R+#', PHP_EOL, implode(PHP_EOL, $this->logs)), - FILE_APPEND - ); - } - - return self::SUCCESS; - } - - private function write( - string $text, - InputInterface $input, - OutputInterface $output, - int $level = OutputInterface::OUTPUT_NORMAL - ): void { - assert($output instanceof ConsoleOutput); - $output->writeln($text, $level); - - if ($input->getOption('save-log')) { - $this->logs[] = $output->getLastMessage(); - } - } - - public static function getTasks(): array - { - $tasks = []; - - foreach (Config::get('tasks.commands', []) as $i => $task) { - $task[Task::NAME] = $task[Task::NAME] ?? 'task_' . ((int)($i) + 1); - $tasks[$task[Task::NAME]] = $task; - } - - return $tasks; - } - - private function makeTask(array $task): Task - { - $task = self::fixTask($task); - $cli = env('IN_DOCKER') ? 'console' : 'php ' . ROOT_PATH . '/console'; - - if (null === $task[Task::COMMAND]) { - throw new RuntimeException(sprintf('Task \'%s\' does not have any execute command.', $task[Task::NAME])); - } - - if (false === $task[Task::USE_CLOSURE_AS_COMMAND] && ($task[Task::COMMAND] instanceof Closure)) { - $task[Task::COMMAND] = $cli . ' scheduler:closure ' . escapeshellarg($task[Task::NAME]); - } else { - if (($task[Task::COMMAND] instanceof Closure)) { - $task[Task::COMMAND] = RunClosureCommand::runClosure($task[Task::COMMAND], $task[Task::CONFIG]); - } - - if (!is_string($task[Task::COMMAND])) { - throw new RuntimeException( - sprintf('Task \'%s\' Command did not evaluate to a string.', $task[Task::NAME]) - ); - } - - if (str_starts_with($task[Task::COMMAND], '@')) { - $task[Task::COMMAND] = substr($task[Task::COMMAND], 1); - $task[Task::COMMAND] = $cli . ' ' . $task[Task::COMMAND]; - } - } - - if (in_array($task[Task::NAME], $this->registered)) { - throw new RuntimeException( - sprintf('There are already task registered with the same name \'%s\'.', $task[Task::NAME]) - ); - } - - $this->registered[] = $task[Task::NAME]; - - $obj = Task::newTask($task[Task::NAME], $task[Task::COMMAND], $task[Task::ARGS], $task[Task::CONFIG]); - - $timer = $task[Task::RUN_AT] ?? TaskTimer::everyMinute(5); - - if ((!$timer instanceof CronExpression)) { - $timer = TaskTimer::at($timer); - } - - $obj->runAt($timer); - - if (true === $task[Task::RUN_IN_FOREGROUND]) { - $obj->inForeground(); - } - - if (null !== $task[Task::BEFORE_CALL]) { - $obj->setBeforeCall($task[Task::BEFORE_CALL]); - } - - if (null !== $task[Task::WHEN_OVER_LAPPING_CALL]) { - $obj->whenOverlapping($task[Task::WHEN_OVER_LAPPING_CALL]); - } - - return $obj; - } - - public static function fixTask(array $task): array - { - $task[Task::CONFIG] = $task[Task::CONFIG] ?? []; - - return [ - Task::NAME => (string)$task[Task::NAME], - Task::COMMAND => $task[Task::COMMAND] ?? null, - Task::ARGS => $task[Task::ARGS] ?? [], - Task::RUN_AT => $task[Task::RUN_AT] ?? null, - Task::USE_CLOSURE_AS_COMMAND => (bool)($task[Task::USE_CLOSURE_AS_COMMAND] ?? false), - Task::BEFORE_CALL => $task[Task::BEFORE_CALL] ?? null, - Task::RUN_IN_FOREGROUND => (bool)($task[Task::RUN_IN_FOREGROUND] ?? false), - Task::WHEN_OVER_LAPPING_CALL => $task[Task::WHEN_OVER_LAPPING_CALL] ?? null, - Task::CONFIG => $task[Task::CONFIG], - Task::ENABLED => $task[Task::ENABLED], - ]; - } - - public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void - { - parent::complete($input, $suggestions); - - if ($input->mustSuggestArgumentValuesFor('task_name')) { - $currentValue = $input->getCompletionValue(); - - $suggest = []; - - foreach (array_keys(self::getTasks()) as $name) { - if (empty($currentValue) || str_starts_with($name, $currentValue)) { - $suggest[] = $name; - } - } - - $suggestions->suggestValues($suggest); - } - } -} diff --git a/src/Commands/System/TasksCommand.php b/src/Commands/System/TasksCommand.php new file mode 100644 index 00000000..78a30790 --- /dev/null +++ b/src/Commands/System/TasksCommand.php @@ -0,0 +1,302 @@ + '' . strtoupper($val) . '', + array_keys(Config::get('tasks.list', []))) + ); + + $this->setName(self::ROUTE) + ->addOption('run', null, InputOption::VALUE_NONE, 'Run scheduled tasks.') + ->addOption('task', 't', InputOption::VALUE_REQUIRED, 'Run the specified task only.') + ->addOption('save-log', null, InputOption::VALUE_NONE, 'Save tasks output to file.') + ->addOption('live', null, InputOption::VALUE_NONE, 'See output in real time.') + ->setDescription('List & Run scheduled tasks.') + ->setHelp( + <<# How run scheduled tasks? +-------------------------- + +To run scheduled tasks, Do the following + +{$cmdContext} --run + +--------------------------------- +# How to force run specific task? +--------------------------------- + +You have to combine both [--run] and [--task task_name], For example: + +{$cmdContext} --run --task import + +Running task in force mode, bypass the task enabled check. + +------------------------- +# How to configure tasks? +------------------------- + +All Prebuilt tasks have 3 environment variables assoicated with them. + +## WS_CRON_{TASK}: + +This environment variable control whether the task is enabled or not, it auto cast the value to bool. For example, +to enable import task simply add new environment varaible called WS_CRON_IMPORT with value of true or 1. + +## WS_CRON_{TASK}_AT: + +This environment variable control when the task should run, it accepts valid cron expression timer. For example, +to run import every two hours add new environment variable called WS_CRON_IMPORT_AT with value of 0 */2 * * *. + + +## WS_CRON_{TASK}_ARGS: + +This environment variable control the options passed to the executed command, For example to expand the information +logged during import run, add new environment variable called WS_CRON_IMPORT_ARGS with value of -vvv --context --trace. +Simply put, run help on the assoicated command, and you can use any Options listed there in this variable. + +------------------------------------- + +Replace {TASK} which one of the following [ $tasksName ] +environment variables are in ALL CAPITAL LETTERS + +HELP + ); + } + + /** + * @throws Exception + */ + protected function runCommand(iInput $input, iOutput $output): int + { + if ($input->getOption('run')) { + return $this->runTasks($input, $output); + } + + $this->listTasks($input, $output); + return self::SUCCESS; + } + + private function runTasks(iInput $input, iOutput $output): int + { + $run = []; + $tasks = $this->getTasks(); + + if (null !== ($task = $input->getOption('task'))) { + $task = strtolower($task); + + if (false === ag_exists($tasks, $task)) { + $output->writeln( + replacer('There are no task named [{task}].', [ + 'task' => $task + ]) + ); + + return self::FAILURE; + } + + $run[] = ag($tasks, $task); + } else { + foreach ($tasks as $task) { + if (false === (bool)ag($task, 'enabled')) { + continue; + } + + assert($task['timer'] instanceof CronExpression); + + if ($task['timer']->isDue('now')) { + $run[] = $task; + } + } + } + + if (count($run) < 1) { + $output->writeln( + replacer('[{datetime}] No task scheduled to run at this time.', [ + 'datetime' => makeDate(), + ]), + iOutput::VERBOSITY_VERBOSE + ); + } + + foreach ($run as $task) { + $cmd = []; + + $cmd[] = env('IN_DOCKER') ? 'console' : 'php ' . ROOT_PATH . '/console'; + $cmd[] = ag($task, 'command'); + + if (null !== ($args = ag($task, 'args'))) { + $cmd[] = $args; + } + + $process = Process::fromShellCommandline(implode(' ', $cmd), timeout: null); + + $process->start(function ($std, $out) use ($input, $output) { + assert($output instanceof ConsoleOutputInterface); + + if (empty($out)) { + return; + } + + $this->taskOutput[] = trim($out); + + if (!$input->getOption('live')) { + return; + } + + ('err' === $std ? $output->getErrorOutput() : $output)->writeln(trim($out)); + }); + + if ($process->isRunning()) { + $process->wait(); + } + + if (count($this->taskOutput) < 1) { + continue; + } + + $this->write('--------------------------', $input, $output); + $this->write(replacer('Task: {name}', ['name' => $task['name']]), $input, $output); + $this->write( + replacer('Date: {datetime}', ['datetime' => makeDate()->format('Y-m-d H:i:s T')]), + $input, + $output + ); + $this->write(replacer('Command: {cmd}', ['cmd' => $process->getCommandLine()]), $input, $output); + $this->write(replacer('Exit Code: {code}', ['code' => $process->getExitCode()]), $input, $output); + $this->write('--------------------------' . PHP_EOL, $input, $output); + + foreach ($this->taskOutput as $line) { + $this->write($line, $input, $output); + } + + $this->taskOutput = []; + } + + + if ($input->getOption('save-log') && count($this->logs) >= 1) { + file_put_contents( + Config::get('tasks.logfile'), + preg_replace('#\R+#', PHP_EOL, implode(PHP_EOL, $this->logs)), + FILE_APPEND + ); + } + + return self::SUCCESS; + } + + private function write(string $text, iInput $input, iOutput $output, int $level = iOutput::OUTPUT_NORMAL): void + { + assert($output instanceof ConsoleOutput); + $output->writeln($text, $level); + + if ($input->getOption('save-log')) { + $this->logs[] = $output->getLastMessage(); + } + } + + /** + * @throws Exception + */ + private function listTasks(iInput $input, iOutput $output): void + { + $list = []; + + foreach ($this->getTasks() as $task) { + $list[] = [ + 'name' => $task['name'], + 'command' => $task['command'], + 'Options' => $task['args'] ?? '', + 'description' => $task['description'] ?? '', + 'NextRun' => $task['next'], + ]; + } + + $this->displayContent($list, $output, $input->getOption('output')); + } + + private function getTasks(): array + { + $list = []; + + foreach (Config::get('tasks.list', []) as $task) { + $timer = new CronExpression($task['timer'] ?? '5 * * * *'); + + $list[$task['name']] = [ + 'name' => $task['name'], + 'command' => $task['command'], + 'args' => $task['args'] ?? '', + 'description' => $task['info'] ?? '', + 'enabled' => (bool)$task['enabled'], + 'timer' => $timer, + ]; + + try { + $list[$task['name']]['next'] = $task['enabled'] ? $timer->getNextRunDate('now')->format( + 'Y-m-d H:i:s T' + ) : 'Disabled'; + } catch (Exception $e) { + $list[$task['name']]['next'] = $e->getMessage(); + } + } + + return $list; + } + + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + parent::complete($input, $suggestions); + + if ($input->mustSuggestOptionValuesFor('task')) { + $currentValue = $input->getCompletionValue(); + + $suggest = []; + + foreach (array_keys(Config::get('tasks.list', [])) as $name) { + if (empty($currentValue) || str_starts_with($name, $currentValue)) { + $suggest[] = $name; + } + } + + $suggestions->suggestValues($suggest); + } + } +} diff --git a/src/Libs/Scheduler/Scheduler.php b/src/Libs/Scheduler/Scheduler.php deleted file mode 100644 index 8e460ff0..00000000 --- a/src/Libs/Scheduler/Scheduler.php +++ /dev/null @@ -1,77 +0,0 @@ - - */ - private array $queuedTasks = []; - - /** - * @return array - */ - private array $executedTasks = []; - - /** - * Queue a task for execution in the correct queue. - */ - public function queueTask(Task $task): void - { - $this->queuedTasks[] = $task; - } - - /** - * Prioritise tasks in background. - * - * @return array - */ - private function getQueuedTasks(): array - { - $background = []; - $foreground = []; - - foreach ($this->queuedTasks as $task) { - if ($task->canRunInBackground()) { - $background[] = $task; - } else { - $foreground[] = $task; - } - } - - return array_merge($background, $foreground); - } - - /** - * Run the scheduler. - * - * @param DateTimeInterface $runTime Run at specific moment. - * @return array Executed tasks - */ - public function run(DateTimeInterface $runTime): array - { - foreach ($this->getQueuedTasks() as $task) { - if (!$task->isDue($runTime)) { - continue; - } - $task->run(); - $this->executedTasks[$task->getName()] = $task; - } - - return $this->getExecutedTasks(); - } - - /** - * @return array - * @psalm-return array - */ - public function getExecutedTasks(): array - { - return $this->executedTasks; - } -} diff --git a/src/Libs/Scheduler/Task.php b/src/Libs/Scheduler/Task.php deleted file mode 100644 index 3d3ec0da..00000000 --- a/src/Libs/Scheduler/Task.php +++ /dev/null @@ -1,366 +0,0 @@ -command = $command; - $this->name = $name; - $this->config = $config; - $this->args = $args; - - $this->creationTime = new DateTimeImmutable('now'); - $this->whenOverlappingCall = fn(string $Lockfile = ''): bool => false; - - if (array_key_exists(self::CONFIG_TMP_DIR, $config) && is_dir($config[self::CONFIG_TMP_DIR]) - && is_writable($config[self::CONFIG_TMP_DIR])) { - $tempDir = $config[self::CONFIG_TMP_DIR]; - } else { - $tempDir = sys_get_temp_dir(); - } - - $this->lockFile = rtrim($tempDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . "{$name}.lock"; - } - - public static function newTask(string $name, string $command, array|string $args = [], array $config = []): self - { - return new self($name, $command, $args, $config); - } - - public function runAt(CronExpression $at): self - { - $this->executionTime = $at; - - return $this; - } - - public function getName(): string - { - return $this->name; - } - - /** - * Check if the task is due to run. - * It accepts as input a DateTimeInterface used to check if - * the task is due. Defaults to task creation time. - * It also defaults the execution time if not previously defined. - */ - public function isDue(DateTimeInterface|null $date = null): bool - { - $date = $date ?? $this->creationTime; - - return null !== $this->executionTime && $this->executionTime->isDue($date); - } - - public function isOverlapping(): bool - { - if (array_key_exists(self::CONFIG_NO_LOCK, $this->config) && true === $this->config[self::CONFIG_NO_LOCK]) { - return false; - } - - $func = $this->whenOverlappingCall; - - return $this->lockFile && - file_exists($this->lockFile) && - (null !== $func && false === $func($this->lockFile)); - } - - public function inForeground(): self - { - $this->runInBackground = false; - - return $this; - } - - public function canRunInBackground(): bool - { - return true === $this->runInBackground; - } - - public function getCommand(): string - { - return $this->command; - } - - public function getArgs(): string - { - if (false === is_array($this->args)) { - return trim($this->args); - } - - $args = ''; - - foreach ($this->args as $key => $value) { - $args .= ' ' . $key; - if ($value !== null) { - $args .= ' ' . escapeshellarg($value); - } - } - - return trim($args); - } - - public function run(): bool - { - // If overlapping, don't run - if ($this->isOverlapping()) { - return false; - } - - try { - if (null !== $this->beforeCall) { - $fn = $this->beforeCall; - if (true !== $fn($this)) { - $this->output = 'Task did not execute as beforeCall returned value other than true.'; - return false; - } - } - - $cmd = $this->getCommand(); - - $args = $this->getArgs(); - if (!empty($args)) { - $cmd .= ' ' . $args; - } - - $this->process = Process::fromShellCommandline( - command: $cmd, - cwd: $this->config[self::CONFIG_CWD] ?? null, - env: $this->config[self::CONFIG_ENV] ?? null, - input: $this->config[self::CONFIG_INPUT] ?? null, - timeout: $this->config[self::CONFIG_TIMEOUT] ?? null, - ); - - if (array_key_exists('tty', $this->config) && true === $this->config['tty']) { - $this->process->setTty(true); - } - - $this->acquireLock(); - - if (!($this->process instanceof Process)) { - throw new RuntimeException(sprintf('Unable to create child process for \'%s\'.', $this->getName())); - } - - if ($this->canRunInBackground()) { - $this->process->start(); - } else { - $this->process->run(); - $this->output = $this->getOutput(); - } - - return true; - } catch (Throwable $e) { - $this->output .= sprintf( - 'Task \'%s\' has thrown unhandled exception. (%s). (%s:%d)', - 'Task-' . $this->getName(), - $e->getMessage(), - $e->getFile(), - $e->getLine() - ); - $this->logger?->error($e->getMessage(), $e->getTrace()); - } - - return false; - } - - public function getLockFile(): string - { - if (array_key_exists(self::CONFIG_NO_LOCK, $this->config) && true !== $this->config[self::CONFIG_NO_LOCK]) { - return ''; - } - - return $this->lockFile; - } - - public function acquireLock(): void - { - if (array_key_exists(self::CONFIG_NO_LOCK, $this->config) && true !== $this->config[self::CONFIG_NO_LOCK]) { - return; - } - - if (!$this->lockFile) { - return; - } - - file_put_contents($this->getLockFile(), $this->getName()); - } - - /** - * Remove the task lock file. - */ - public function releaseLock(): void - { - if (array_key_exists(self::CONFIG_NO_LOCK, $this->config) && true !== $this->config[self::CONFIG_NO_LOCK]) { - return; - } - - if ($this->lockFile && file_exists($this->lockFile)) { - unlink($this->lockFile); - } - } - - /** - * Get the task output. - */ - public function getOutput(): mixed - { - if (null === $this->process) { - return $this->output; - } - - if (array_key_exists(self::CONFIG_NO_OUTPUT, $this->config) && true === $this->config[self::CONFIG_NO_OUTPUT]) { - return ''; - } - - if ($this->process->isRunning()) { - $this->process->wait(); - } - - $this->exitCode = $this->process->getExitCode(); - $this->exitCodeText = $this->process->getExitCodeText(); - - $stdout = $this->process->getOutput(); - $stderr = $this->process->getErrorOutput(); - - if (!empty($stderr)) { - $this->output .= $stderr; - } - - if (!empty($stdout)) { - if (!empty($stderr)) { - $this->output .= PHP_EOL; - } - $this->output .= $stdout; - } - - $this->process = null; - - return $this->output; - } - - /** - * Set function to be called if task is overlapping. - */ - public function whenOverlapping(Closure $fn): self - { - $this->whenOverlappingCall = $fn; - - return $this; - } - - public function setBeforeCall(?Closure $fn = null): self - { - $this->beforeCall = $fn; - - return $this; - } - - public function getExitCode(): ?int - { - return $this->exitCode; - } - - public function getExitCodeText(): ?string - { - return $this->exitCodeText; - } - - public function __destruct() - { - $this->releaseLock(); - } -} diff --git a/src/Libs/Scheduler/TaskTimer.php b/src/Libs/Scheduler/TaskTimer.php deleted file mode 100644 index 6beb9498..00000000 --- a/src/Libs/Scheduler/TaskTimer.php +++ /dev/null @@ -1,256 +0,0 @@ -format('i')} {$date->format('H')} {$date->format('d')} {$date->format('m')} *"); - } - - /** - * Set the execution time to every minute. - * - * @param int|string|null $minute When set, specifies that the Task will be run every $minute minutes - */ - public static function everyMinute(int|string|null $minute = null): CronExpression - { - $minuteExpression = '*'; - if ($minute !== null) { - $c = self::validateCronSequence($minute); - $minuteExpression = '*/' . $c['minute']; - } - - return self::at($minuteExpression . ' * * * *'); - } - - /** - * Set the execution time to every hour. - * - * @param int|string $minute default [0]. - */ - public static function hourly(int|string $minute = 0): CronExpression - { - $c = self::validateCronSequence($minute); - - return self::at("{$c['minute']} * * * *"); - } - - /** - * Set the execution time to once a day. - * - * @param int|string $hour Default [0] - * @param int|string $minute Default [0] - */ - public static function daily(string|int $hour = 0, string|int $minute = 0): CronExpression - { - if (is_string($hour)) { - $parts = explode(':', $hour); - $hour = $parts[0]; - $minute = $parts[1] ?? '0'; - } - - $c = self::validateCronSequence($minute, $hour); - - return self::at("{$c['minute']} {$c['hour']} * * *"); - } - - /** - * Set the execution time to once a week. - * - * @param int|string $weekday Default [0] - * @param int|string $hour Default [0] - * @param int|string $minute Default [0] - */ - public static function weekly(int|string $weekday = 0, int|string $hour = 0, int|string $minute = 0): CronExpression - { - if (is_string($hour)) { - $parts = explode(':', $hour); - $hour = $parts[0]; - $minute = $parts[1] ?? '0'; - } - - $c = self::validateCronSequence($minute, $hour, null, null, $weekday); - - return self::at("{$c['minute']} {$c['hour']} * * {$c['weekday']}"); - } - - /** - * Set the execution time to once a month. - * - * @param int|string $month Default [*] - * @param int|string $day Default [1] - * @param int|string $hour Default [0] - * @param int|string $minute Default [0] - */ - public static function monthly( - int|string $month = '*', - int|string $day = 1, - int|string $hour = 0, - int|string $minute = 0 - ): CronExpression { - if (is_string($hour)) { - $parts = explode(':', $hour); - $hour = $parts[0]; - $minute = $parts[1] ?? '0'; - } - $c = self::validateCronSequence($minute, $hour, $day, $month); - return self::at("{$c['minute']} {$c['hour']} {$c['day']} {$c['month']} *"); - } - - /** - * Set the execution time to every Sunday. - * - * @param int|string $hour Default [0] - * @param int|string $minute Default [0] - */ - public static function sunday(int|string $hour = 0, int|string $minute = 0): CronExpression - { - return self::weekly(0, $hour, $minute); - } - - /** - * Set the execution time to every Monday. - * - * @param int|string $hour Default [0] - * @param int|string $minute Default [0] - */ - public static function monday(int|string $hour = 0, int|string $minute = 0): CronExpression - { - return self::weekly(1, $hour, $minute); - } - - /** - * Set the execution time to every Tuesday. - * - * @param int|string $hour Default [0] - * @param int|string $minute Default [0] - */ - public static function tuesday(int|string $hour = 0, int|string $minute = 0): CronExpression - { - return self::weekly(2, $hour, $minute); - } - - /** - * Set the execution time to every Wednesday. - * - * @param int|string $hour Default [0] - * @param int|string $minute Default [0] - */ - public static function wednesday(int|string $hour = 0, int|string $minute = 0): CronExpression - { - return self::weekly(3, $hour, $minute); - } - - /** - * Set the execution time to every Thursday. - * - * @param int|string $hour Default [0] - * @param int|string $minute Default [0] - */ - public static function thursday(int|string $hour = 0, int|string $minute = 0): CronExpression - { - return self::weekly(4, $hour, $minute); - } - - /** - * Set the execution time to every Friday. - * - * @param int|string $hour Default [0] - * @param int|string $minute Default [0] - */ - public static function friday(int|string $hour = 0, int|string $minute = 0): CronExpression - { - return self::weekly(5, $hour, $minute); - } - - /** - * Set the execution time to every Saturday. - * - * @param int|string $hour Default [0] - * @param int|string $minute Default [0] - */ - public static function saturday(int|string $hour = 0, int|string $minute = 0): CronExpression - { - return self::weekly(6, $hour, $minute); - } - - /** - * Validate sequence of cron expression. - * - * @param int|string|null $minute - * @param int|string|null $hour - * @param int|string|null $day - * @param int|string|null $month - * @param int|string|null $weekday - * @return array - */ - private static function validateCronSequence( - int|string|null $minute = null, - int|string|null $hour = null, - int|string|null $day = null, - int|string|null $month = null, - int|string|null $weekday = null - ): array { - return [ - 'minute' => self::validateCronRange($minute, 0, 59), - 'hour' => self::validateCronRange($hour, 0, 23), - 'day' => self::validateCronRange($day, 1, 31), - 'month' => self::validateCronRange($month, 1, 12), - 'weekday' => self::validateCronRange($weekday, 0, 6), - ]; - } - - /** - * Validate sequence of cron expression. - * - * @param int|string|null $value - * @param int $min - * @param int $max - * @return int|string - */ - private static function validateCronRange(int|string|null $value, int $min, int $max): int|string - { - if (null === $value || '*' === $value) { - return '*'; - } - - if (!is_numeric($value) || !($value >= $min && $value <= $max)) { - throw new InvalidArgumentException( - "Invalid value: it should be '*' or between {$min} and {$max}." - ); - } - - return (int)$value; - } - - private function __construct() - { - } -}