Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:*` |
Expand Down
2 changes: 2 additions & 0 deletions resources/boost/guidelines/core.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand All @@ -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
54 changes: 48 additions & 6 deletions resources/boost/skills/pollora-scheduling/SKILL.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions src/Mcp/Nectar.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -36,6 +37,7 @@ class Nectar extends Server
DiscoveredComponents::class,
WordPressRoutes::class,
ModulesInfo::class,
ScheduledEvents::class,
WpOption::class,
];

Expand Down
198 changes: 198 additions & 0 deletions src/Mcp/Tools/ScheduledEvents.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
<?php

declare(strict_types=1);

namespace Pollora\Nectar\Mcp\Tools;

use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;

#[IsReadOnly]
class ScheduledEvents extends Tool
{
protected string $name = 'scheduled_events';

protected string $description = 'List the scheduled events (WP-Cron, Action Scheduler or the Laravel queue) with the driver that keeps each, and the state of the scheduling setup: configured driver, whether it is available, events left to migrate, late events and recent failures. Read from the "cron" option; no query reaches the driver.';

private const array BUILT_IN_PROTECTED = ['action_scheduler_run_queue', 'pollora/async/run', 'pollora/async/recover'];

/**
* @return array<string, Type>
*/
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<string>
*/
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<array<string, mixed>>
*/
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<string, mixed> $event
* @param list<string> $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<array<string, mixed>>
*/
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));
}
}
6 changes: 4 additions & 2 deletions tests/Feature/NectarServerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -29,6 +30,7 @@
->toContain(DiscoveredComponents::class)
->toContain(WordPressRoutes::class)
->toContain(ModulesInfo::class)
->toContain(ScheduledEvents::class)
->toContain(WpOption::class);
});

Expand Down
Loading
Loading