From f32c7c23654dd617823df3f1130f8e81128555b8 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 13 Oct 2026 19:09:21 +0200 Subject: [PATCH] feat: scheduled_events tool, scheduling skill for the drivers --- CHANGELOG.md | 5 + README.md | 3 +- resources/boost/guidelines/core.blade.php | 2 + .../boost/skills/pollora-scheduling/SKILL.md | 54 ++++- src/Mcp/Nectar.php | 2 + src/Mcp/Tools/ScheduledEvents.php | 198 ++++++++++++++++++ tests/Feature/NectarServerTest.php | 6 +- tests/Feature/Tools/ScheduledEventsTest.php | 81 +++++++ tests/Helpers/wordpress-stubs.php | 9 +- 9 files changed, 350 insertions(+), 10 deletions(-) create mode 100644 src/Mcp/Tools/ScheduledEvents.php create mode 100644 tests/Feature/Tools/ScheduledEventsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 29d2e65..028ceae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Tool** `scheduled_events`: the scheduled events with the driver that keeps each (WP-Cron, Action Scheduler, Laravel queue), where they stand against the configured driver (`active`, `protected`, `fallback`, `to migrate`), whether the driver is available, late events and the failures of the last week. Read from the `cron` option, with `type`, `driver` and `limit` filters; it needs no framework class, so it answers on any 13.x +- **Skill** `pollora-scheduling`: the fluent API, the three drivers, `pollora:schedule:list|migrate|recover`, and the replacement of `wordpress.use_laravel_scheduler` +- **Core guidelines**: the `pollora:schedule:*` commands and the `scheduled_events` tool + ## [1.6.0] - 2026-10-09 ### Added diff --git a/README.md b/README.md index 8cb1aad..018f367 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ Or register it in your `.mcp.json`: | `discovered_components` | All auto-discovered components grouped by type (post types, taxonomies, hooks, schedules, REST routes) | | `wordpress_routes` | All routes including `Route::wp()` with WordPress conditions and middleware | | `modules_info` | Installed Laravel Modules with status | +| `scheduled_events` | Scheduled events with the driver that keeps each (WP-Cron, Action Scheduler, Laravel queue), events left to migrate, late events, recent failures | | `wp_option` | Read any WordPress option by key | ## AI Guidelines @@ -113,7 +114,7 @@ Skills are activated on-demand when working on specific tasks: | `pollora-hooks` | Registering WordPress actions & filters | | `pollora-blocks` | Gutenberg blocks with JSX, Blade rendering & Tailwind | | `pollora-rest-api` | REST API endpoints with `#[WpRestRoute]`, AJAX handlers with `#[Ajax]` | -| `pollora-scheduling` | Scheduled tasks with `#[Schedule]` | +| `pollora-scheduling` | Scheduled tasks with `#[Schedule]` and the fluent API, and the WP-Cron, Action Scheduler and Laravel queue drivers | | `pollora-abilities` | WordPress Abilities API with `#[Ability]` and the `Ability` facade | | `pollora-typed-meta` | Post, term, user and comment meta as typed properties with `#[Meta]`, `Meta::of()`, `pollora:meta:audit` | | `pollora-roles` | Capabilities through Laravel's Gate, roles declared with `#[Role]`, `#[ModifyRole]`, `#[CapabilitySet]`, `pollora:roles:*` | diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php index fc017a1..fd2bc22 100644 --- a/resources/boost/guidelines/core.blade.php +++ b/resources/boost/guidelines/core.blade.php @@ -156,6 +156,7 @@ class InternalHelper {} - `pollora:roles:prune` / `pollora:roles:import {role}` / `pollora:roles:dump` — Clean up roles removed from the code, turn a stored role into a `#[Role]` class, write the code's roles to the database; dry run unless `--force` - `pollora:binding:list` — Binding sources, their fields, and the blocks WordPress lets bind - `discovery:run` / `discovery:clear` — Manage component discovery cache +- `pollora:schedule:list` / `pollora:schedule:migrate` / `pollora:schedule:recover` — Scheduled events and the driver that keeps each (v13.36+); move them between drivers; send again the events a queue lost (`laravel` driver) - `pollora:status` — Show framework status - `pollora:doctor` — Check the project for silent failures (core patch, patches lock, `.env`, discovery cache, theme build, patterns…) and print the fix for each, including bindings that can never show a value, typed meta that cannot register, users left with a removed role, and asynchronous actions nothing will run (ignored `#[Async]`, unavailable driver, overdue WP-Cron, jobs without a worker); the same checks are in Tools › Site Health. **Run it first when something fails without an error** @@ -173,4 +174,5 @@ class InternalHelper {} - `discovered_components` — All auto-discovered components - `wordpress_routes` — Route::wp() routes and conditions - `modules_info` — Installed Laravel Modules +- `scheduled_events` — Scheduled events, their driver, what is left to migrate, late events - `wp_option` — Read WordPress options \ No newline at end of file diff --git a/resources/boost/skills/pollora-scheduling/SKILL.md b/resources/boost/skills/pollora-scheduling/SKILL.md index af60250..442e584 100644 --- a/resources/boost/skills/pollora-scheduling/SKILL.md +++ b/resources/boost/skills/pollora-scheduling/SKILL.md @@ -1,12 +1,12 @@ --- name: pollora-scheduling -description: Schedule recurring tasks using Pollora Schedule attributes with WordPress cron integration. +description: Schedule recurring and single tasks with Pollora's #[Schedule] attribute or fluent API, on WP-Cron, Action Scheduler or the Laravel queue. --- # Pollora Scheduling Development ## When to use this skill -Use this skill when creating scheduled/recurring tasks that run via WordPress cron, using Pollora's attribute-based scheduling system. +Use this skill when creating scheduled tasks (recurring or single), choosing where they run (WP-Cron, Action Scheduler, the Laravel queue), or finding out why one did not run. The engine is the `pollora/schedule` package (framework v13.36+). ## Creating Scheduled Tasks @@ -129,21 +129,63 @@ Place schedule classes where the discovery system scans: - `app/Cms/Schedule/` in themes - `app/Schedule/` in the main application or modules +## Fluent API + +When the schedule depends on a condition or a computed value. Declare it on every request: it is scheduled once. + +```php +use Pollora\Schedule\Interval; +use Pollora\Schedule\Schedule; + +Schedule::event('reports_compile')->daily()->at('02:00')->call([Reports::class, 'compile']); +Schedule::event('cache_warm')->every(new Interval(minutes: 15))->call(WarmCache::class); +Schedule::once('send_digest')->in(minutes: 10)->with(['user' => 42])->call(SendDigest::class); + +Schedule::cancel('send_digest', ['user' => 42]); +Schedule::next('reports_compile'); // DateTimeImmutable|null +Schedule::fake(); // in tests: Schedule::assertScheduled('send_digest') +``` + +`at('02:00')` follows the timezone of the site. `call()` takes an invokable class, `[Class::class, 'method']`, a closure or a hook name; classes are built by the container. + +## Drivers + +`config/schedule.php` (`POLLORA_SCHEDULE_DRIVER`) decides where **every** event goes, including the `wp_schedule_event()` calls of WordPress, plugins and themes: + +| Driver | Runs events | Needs | +|---|---|---| +| `wp-cron` (default) | WP-Cron | A system cron requesting `wp-cron.php` (Pollora sets `DISABLE_WP_CRON`) | +| `action-scheduler` | Action Scheduler's queue runner | A plugin that bundles it (WooCommerce) | +| `laravel` | A queue worker: one delayed job per event | A connection that accepts delays (not `sync`/`null`) and `php artisan queue:work` | + +The `cron` option indexes every event, with a `driver` key on those another driver keeps. Changing the driver moves nothing: events already scheduled stay put until `php artisan pollora:schedule:migrate` (`--dry-run`, `--from`, `--to`). An unavailable driver leaves events on WP-Cron. + +The removed `wordpress.use_laravel_scheduler` is replaced by `POLLORA_SCHEDULE_DRIVER=laravel` and a worker (no more `schedule:run`). + ## Managing Scheduled Events ```bash -# List scheduled cron events -ddev wp cron event list +# Every event, with its type and driver (--type, --driver, --site, --network, --json) +ddev php artisan pollora:schedule:list +ddev wp cron event list --fields=hook,type,driver + +# Move events to the driver of the site +ddev php artisan pollora:schedule:migrate --dry-run -# Run all due cron events +# laravel driver: send again events whose job was lost +ddev php artisan pollora:schedule:recover --dry-run + +# Run all due cron events (runs every due event, whoever keeps it) ddev wp cron event run --due-now ``` +`pollora:doctor` reports an unknown or unavailable driver, events left to migrate, late events (no worker) and events that failed for good. The `scheduled_events` Nectar tool returns the same from the index. + ## Important Notes - **Auto-discovered** — no manual `wp_schedule_event()` calls needed - Hook names are auto-generated from the method name if not specified - `Every::MONTH` and `Every::YEAR` automatically register custom WordPress cron schedules - The `Interval` class allows arbitrary timing combinations -- WordPress cron is "pseudo-cron" — it runs on page visits. For real cron, disable `WP_CRON` and use system cron +- WordPress cron is "pseudo-cron" — it runs on page visits. Pollora sets `DISABLE_WP_CRON`: with the `wp-cron` and `action-scheduler` drivers something must request `wp-cron.php`; with `laravel` a worker must run - Run `php artisan discovery:clear` after adding new schedule classes \ No newline at end of file diff --git a/src/Mcp/Nectar.php b/src/Mcp/Nectar.php index 8fc4a45..f1c8793 100644 --- a/src/Mcp/Nectar.php +++ b/src/Mcp/Nectar.php @@ -13,6 +13,7 @@ use Pollora\Nectar\Mcp\Tools\PolloraStatus; use Pollora\Nectar\Mcp\Tools\PostTypesInfo; use Pollora\Nectar\Mcp\Tools\RegisteredHooks; +use Pollora\Nectar\Mcp\Tools\ScheduledEvents; use Pollora\Nectar\Mcp\Tools\TaxonomiesInfo; use Pollora\Nectar\Mcp\Tools\WordPressInfo; use Pollora\Nectar\Mcp\Tools\WordPressRoutes; @@ -36,6 +37,7 @@ class Nectar extends Server DiscoveredComponents::class, WordPressRoutes::class, ModulesInfo::class, + ScheduledEvents::class, WpOption::class, ]; diff --git a/src/Mcp/Tools/ScheduledEvents.php b/src/Mcp/Tools/ScheduledEvents.php new file mode 100644 index 0000000..8efff85 --- /dev/null +++ b/src/Mcp/Tools/ScheduledEvents.php @@ -0,0 +1,198 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'type' => $schema->string()->description('Only "once" or "recurring" events.'), + 'driver' => $schema->string()->description('Only events kept by this driver ("wp-cron", "action-scheduler", "laravel").'), + 'limit' => $schema->integer()->description('Maximum number of events listed, soonest first. Default 100.'), + ]; + } + + public function handle(Request $request): Response + { + if (! function_exists('_get_cron_array')) { + return Response::text('WordPress is not loaded in this context.'); + } + + $type = $request->get('type'); + $driver = $request->get('driver'); + $limit = max(1, (int) ($request->get('limit') ?: 100)); + + if ($type !== null && ! in_array($type, ['once', 'recurring'], true)) { + return Response::text('"type" is "once" or "recurring".'); + } + + $configured = $this->configured(); + $available = $this->available($configured); + $protected = $this->protectedHooks(); + $events = $this->events(); + + $toMigrate = []; + $late = 0; + $lateAfter = max(60, (int) config('schedule.laravel.late_after', 600)); + + foreach ($events as &$event) { + $event['placement'] = $this->placement($event, $configured, $available, $protected); + + if ($event['placement'] === 'to migrate') { + $toMigrate[$event['driver']] = ($toMigrate[$event['driver']] ?? 0) + 1; + } + + if ($event['driver'] === 'laravel' && $event['timestamp'] < time() - $lateAfter) { + $late++; + } + } + unset($event); + + $listed = array_values(array_filter($events, static fn (array $event): bool => ($type === null || $event['type'] === $type) && ($driver === null || $event['driver'] === $driver))); + + return Response::json([ + 'configured_driver' => $configured, + 'known_driver' => in_array($configured, ['wp-cron', 'action-scheduler', 'laravel'], true), + 'driver_available' => $available, + 'protected_hooks' => $protected, + 'to_migrate' => $toMigrate, + 'late_events' => $late, + 'recent_failures' => $this->failures(), + 'count' => count($listed), + 'truncated' => count($listed) > $limit, + 'events' => array_map(static function (array $event): array { + unset($event['timestamp']); + + return $event; + }, array_slice($listed, 0, $limit)), + ]); + } + + private function configured(): string + { + $driver = config('schedule.driver', 'wp-cron'); + + return is_string($driver) && $driver !== '' ? $driver : 'wp-cron'; + } + + private function available(string $driver): bool + { + return match ($driver) { + 'action-scheduler' => function_exists('as_schedule_single_action'), + 'laravel' => $this->queueRuns(), + default => true, + }; + } + + private function queueRuns(): bool + { + $connection = config('schedule.laravel.connection') ?: config('queue.default', 'sync'); + $driver = config(sprintf('queue.connections.%s.driver', $connection)); + + return is_string($driver) && ! in_array($driver, ['sync', 'null'], true); + } + + /** + * @return list + */ + private function protectedHooks(): array + { + $hooks = config('schedule.protected_hooks', []); + + return array_values(array_unique([...self::BUILT_IN_PROTECTED, ...array_map(strval(...), is_array($hooks) ? $hooks : [])])); + } + + /** + * @return list> + */ + private function events(): array + { + $crons = _get_cron_array(); + $events = []; + + foreach ($crons as $timestamp => $hooks) { + if (! is_int($timestamp) || ! is_array($hooks)) { + continue; + } + + foreach ($hooks as $hook => $entries) { + foreach ((array) $entries as $entry) { + if (! is_array($entry)) { + continue; + } + + $recurring = ! empty($entry['schedule']); + + $events[] = [ + 'hook' => (string) $hook, + 'type' => $recurring ? 'recurring' : 'once', + 'recurrence' => $recurring ? (string) $entry['schedule'] : null, + 'interval' => isset($entry['interval']) ? (int) $entry['interval'] : null, + 'next_run' => gmdate('c', $timestamp), + 'next_run_in' => $timestamp - time(), + 'driver' => (string) ($entry['driver'] ?? 'wp-cron'), + 'args' => (array) ($entry['args'] ?? []), + 'timestamp' => $timestamp, + ]; + } + } + } + + return $events; + } + + /** + * Where an event is against where the configuration wants it: "active", "protected" or "fallback" (on WP-Cron on purpose), "to migrate". + * + * @param array $event + * @param list $protected + */ + private function placement(array $event, string $configured, bool $available, array $protected): string + { + if ($event['driver'] === $configured) { + return 'active'; + } + + if ($event['driver'] === 'wp-cron' && in_array($event['hook'], $protected, true)) { + return 'protected'; + } + + if ($event['driver'] === 'wp-cron' && ! $available) { + return 'fallback'; + } + + return 'to migrate'; + } + + /** + * Failures of the "laravel" driver in the last week. + * + * @return list> + */ + private function failures(): array + { + $stored = get_option('pollora_schedule_failures', []); + + return array_values(array_filter(is_array($stored) ? $stored : [], static fn (mixed $failure): bool => is_array($failure) && ($failure['time'] ?? 0) > time() - 604800)); + } +} diff --git a/tests/Feature/NectarServerTest.php b/tests/Feature/NectarServerTest.php index 8e0a39b..6f415d9 100644 --- a/tests/Feature/NectarServerTest.php +++ b/tests/Feature/NectarServerTest.php @@ -9,17 +9,18 @@ use Pollora\Nectar\Mcp\Tools\PolloraStatus; use Pollora\Nectar\Mcp\Tools\PostTypesInfo; use Pollora\Nectar\Mcp\Tools\RegisteredHooks; +use Pollora\Nectar\Mcp\Tools\ScheduledEvents; use Pollora\Nectar\Mcp\Tools\TaxonomiesInfo; use Pollora\Nectar\Mcp\Tools\WordPressInfo; use Pollora\Nectar\Mcp\Tools\WordPressRoutes; use Pollora\Nectar\Mcp\Tools\WpOption; -it('registers all 10 tools', function () { +it('registers all 11 tools', function () { $reflection = new ReflectionClass(Nectar::class); $property = $reflection->getProperty('tools'); $tools = $property->getDefaultValue(); - expect($tools)->toHaveCount(10) + expect($tools)->toHaveCount(11) ->toContain(PolloraStatus::class) ->toContain(WordPressInfo::class) ->toContain(PostTypesInfo::class) @@ -29,6 +30,7 @@ ->toContain(DiscoveredComponents::class) ->toContain(WordPressRoutes::class) ->toContain(ModulesInfo::class) + ->toContain(ScheduledEvents::class) ->toContain(WpOption::class); }); diff --git a/tests/Feature/Tools/ScheduledEventsTest.php b/tests/Feature/Tools/ScheduledEventsTest.php new file mode 100644 index 0000000..49ac885 --- /dev/null +++ b/tests/Feature/Tools/ScheduledEventsTest.php @@ -0,0 +1,81 @@ + $arguments + * @return array + */ +function scheduledEvents(array $arguments = []): array +{ + return json_decode((string) (new ScheduledEvents)->handle(new Request($arguments))->content(), true); +} + +beforeEach(function () { + $key = md5(serialize([])); + $GLOBALS['nectar_test_cron'] = [ + time() + 3600 => [ + 'reports_compile' => [$key => ['schedule' => 'daily', 'args' => [], 'interval' => 86400, 'driver' => 'laravel', 'id' => 'abc']], + 'wp_version_check' => [$key => ['schedule' => 'twicedaily', 'args' => [], 'interval' => 43200]], + ], + time() + 7200 => [ + 'send_digest' => [md5(serialize(['u' => 1])) => ['schedule' => false, 'args' => ['u' => 1], 'driver' => 'laravel', 'id' => 'def']], + 'action_scheduler_run_queue' => [$key => ['schedule' => 'every_minute', 'args' => [], 'interval' => 60]], + ], + ]; + $GLOBALS['nectar_test_options'] = []; + + config([ + 'schedule.driver' => 'laravel', + 'schedule.protected_hooks' => [], + 'queue.default' => 'database', + 'queue.connections.database' => ['driver' => 'database'], + ]); +}); + +afterEach(function () { + unset($GLOBALS['nectar_test_cron'], $GLOBALS['nectar_test_options']); +}); + +it('lists the events with their driver and where they stand against the configured driver', function () { + $payload = scheduledEvents(); + $events = array_column($payload['events'], null, 'hook'); + + expect($payload['configured_driver'])->toBe('laravel') + ->and($payload['driver_available'])->toBeTrue() + ->and($payload['count'])->toBe(4) + ->and($events['reports_compile'])->toMatchArray(['type' => 'recurring', 'recurrence' => 'daily', 'driver' => 'laravel', 'placement' => 'active']) + ->and($events['send_digest'])->toMatchArray(['type' => 'once', 'args' => ['u' => 1]]) + ->and($events['wp_version_check']['placement'])->toBe('to migrate') + ->and($events['action_scheduler_run_queue']['placement'])->toBe('protected') + ->and($payload['to_migrate'])->toBe(['wp-cron' => 1]); +}); + +it('filters by type and by driver', function () { + Nectar::tool(ScheduledEvents::class, ['type' => 'once'])->assertOk()->assertSee('send_digest')->assertDontSee('reports_compile'); + Nectar::tool(ScheduledEvents::class, ['driver' => 'wp-cron'])->assertOk()->assertSee('wp_version_check')->assertDontSee('send_digest'); +}); + +it('says when the queue cannot run the laravel driver, and counts late events', function () { + config(['queue.default' => 'sync', 'queue.connections.sync' => ['driver' => 'sync']]); + $GLOBALS['nectar_test_cron'] = [time() - 3000 => ['lost' => [md5(serialize([])) => ['schedule' => false, 'args' => [], 'driver' => 'laravel', 'id' => 'x']]]]; + + expect(scheduledEvents())->toMatchArray(['driver_available' => false, 'late_events' => 1]); +}); + +it('reports the failures of the last week', function () { + $GLOBALS['nectar_test_options'] = ['pollora_schedule_failures' => [ + ['hook' => 'digest', 'time' => time() - 60, 'message' => 'boom'], + ['hook' => 'old', 'time' => time() - 86400 * 30, 'message' => 'ancient'], + ]]; + + Nectar::tool(ScheduledEvents::class)->assertOk()->assertSee('boom')->assertDontSee('ancient'); +}); + +it('refuses an unknown type', function () { + Nectar::tool(ScheduledEvents::class, ['type' => 'sometimes'])->assertOk()->assertSee('once'); +}); diff --git a/tests/Helpers/wordpress-stubs.php b/tests/Helpers/wordpress-stubs.php index aedf0f9..1da2ab4 100644 --- a/tests/Helpers/wordpress-stubs.php +++ b/tests/Helpers/wordpress-stubs.php @@ -77,7 +77,14 @@ function get_option(string $option, mixed $default = false): mixed 'active_plugins' => ['woocommerce/woocommerce.php'], ]; - return $options[$option] ?? $default; + return $GLOBALS['nectar_test_options'][$option] ?? $options[$option] ?? $default; + } +} + +if (! function_exists('_get_cron_array')) { + function _get_cron_array(): array + { + return $GLOBALS['nectar_test_cron'] ?? []; } }