# BW Cron Hub — Spec for the mosiah-side runner

The **BW Cron module** in bw-dev (slug `bw_cron`, group `core`) is the **client half** that runs on every BW Dev install. This document specs the **hub half** that drives those clients from a central server (`mosiah`, 192.168.1.138).

Build separately at `/srv/bw-cron-hub/` (or as a srv-gw managed service). Do NOT bundle the hub with bw-dev — they're different runtime contexts.

## Why this exists

WordPress's built-in WP-Cron piggybacks on visitor page loads: each load checks if any cron events are due and runs them inline, stealing time from real requests. Under load this stalls visitors. The professional fix is to disable native WP-Cron and trigger it from an external timer. We do that across our whole fleet from one place instead of per-site cron-job.org accounts.

## Registry

A single JSON file at `/srv/.private/bw-cron-hub/sites.json`, chmod 600, owned by the hub service account:

```json
{
  "sites": [
    {
      "slug": "promptvictoria",
      "base_url": "https://promptvictoria.ca",
      "secret": "ba7a378c...8c527c45",
      "active": true,
      "notes": "Flywheel; rian's main"
    },
    ...
  ]
}
```

Add a CLI helper `bw-cron-hub add-site <slug> <base_url> <secret>` so adding a new client is one command.

## Trigger cron (every 1–5 min)

A systemd timer (preferred — built-in retry semantics, logs in journal) or a plain cron entry that runs every 1–5 minutes. POSTs each active site's `/wp-json/bw/v1/run-cron` with the `X-BW-Cron-Secret` header:

```bash
curl --max-time 30 \
     --header "X-BW-Cron-Secret: <secret>" \
     --request POST \
     "<base_url>/wp-json/bw/v1/run-cron"
```

Parallel fan-out (e.g. `xargs -P 8`) so a slow site doesn't block the others. Hard cap per request at 30 s.

## Logging

Per-site JSONL log at `/srv/.logging/bw-cron-hub/<slug>.log`:

```json
{"ts":"2026-06-09T18:14:03Z","status":200,"duration_ms":47,"ran":["wp_version_check","wp_update_plugins"]}
{"ts":"2026-06-09T18:15:03Z","status":200,"duration_ms":12,"ran":[]}
{"ts":"2026-06-09T18:16:03Z","status":500,"err":"http timeout"}
```

Rolling truncation at 10 MB per file (keep last 2 rotations).

## Alerting

A separate watchdog (daily systemd timer is fine) reads the per-site logs and alerts when:

- A site has had **any HTTP error** for more than 3 consecutive trigger attempts.
- A site has been **silent** (no successful trigger) for longer than its known fallback window — this is the case where the dead-man's-switch on the client has already kicked in, so we know the client is back on native cron but want a human to look.
- The hub host itself is down (covered by Home Assistant's existing ping monitor on mosiah).

Send to rian via the existing alert path (Home Assistant notify service, push).

## Onboarding a new site

1. Install/enable bw-dev on the client site. BW Cron is in the `core` group (default ON), so the module is active.
2. Site admin visits **Settings → BW Dev → BW Cron** and copies:
   - the endpoint URL (already shown on the page),
   - the secret (click Reveal → copy).
3. `bw-cron-hub add-site <slug> <base_url> <secret>` on mosiah.
4. Wait for the next trigger run (1–5 min). Confirm on the site's BW Cron tab that:
   - Last external trigger is recent
   - Mode badge is green ("Hub-managed — native WP-Cron is disabled")

If anything goes wrong, the client is fail-safe: native cron auto-resumes after the fallback threshold (default 60 min).

## Decommissioning a site

Either:
- **`bw-cron-hub remove-site <slug>`** — stop triggering. After the site's fallback window lapses, it's back on native cron automatically. The module can stay enabled; it just does nothing harmful.
- Or **disable the bw_cron module on the Modules tab** of bw-dev — immediate fallback to native cron.
- Or **deactivate / delete bw-dev** entirely — immediate fallback.

## Things NOT in scope for v1 of the hub

- Web UI. The CLI + JSON registry is enough for the BW fleet (dozen-ish sites).
- Per-site individual schedules. WordPress decides what's due; we just trigger.
- Backfilling missed runs. WP-Cron is idempotent enough — events that should have run will be "due" on the next trigger anyway.
- Multi-tenancy / multiple hubs. One mosiah, one registry, one timer.

## Decommissioning the whole hub (or BW)

If the hub host dies, the network breaks, the secret rotates without coordination, OR the business is wound down and the hub is decommissioned — every client site silently reverts to stock WordPress cron after the fallback window lapses. The dead-man's-switch in `BW_Dev_Module_BW_Cron::maybe_disable_native_cron()` makes this guarantee. No human action required on the client sites.

This is the property that makes the whole architecture safe to deploy on production client sites we don't fully control. See the comment block at the top of `includes/modules/class-bw-dev-module-bw-cron.php` for the implementation.
