# Scheduled events

**Admin → Scheduled Events** (`/admin/events/`). A built-in cron
replacement — runs maintenance jobs inside the same process as the
web app, on a one-minute tick, with results visible in the admin UI
(no need to grep syslog).

## How it works

A background thread wakes every 60 seconds, loads all rows where
**Enabled** is checked, and fires any whose schedule says they're
due. Each row records `last_run_at`, `last_status` (`ok`/`fail`),
`last_duration_ms`, and the first 4KB of output — all shown in the
admin list. Editing or disabling a row takes effect on the next tick
(within a minute), no restart needed.

There's no per-event timeout enforced by the scheduler — a handler
that hangs blocks the next tick's events. Handlers that shell out to
something that could run long (like `shell`) enforce their own
timeout instead (see below).

## Creating an event

Fields on the New/Edit form:

- **Name** — free text, shown in the admin list only.
- **Handler** — picks the built-in function that runs (see table
  below). Each option shows its description and expected params
  once selected.
- **Schedule** — one of:
  - **Daily** — pick a time (`HH:MM`, **UTC**).
  - **Hourly** — pick a minute-of-hour (0–59).
  - **Weekly** — pick a day (Mon–Sun) + time, UTC.
  - **Every N min** ("interval") — repeats N minutes after the
    *previous run finished*, not wall-clock-aligned.
- **Params (JSON object)** — handler-specific arguments. Must be a
  JSON object, even if empty (`{}`). This trips people up most
  often — the box does **not** accept a bare string or path. If
  your handler takes one argument called `command`, you write
  `{"command": "/path/to/script.sh"}`, not just the path.
- **Enabled** — unchecked rows never fire, but stay in the list.

There's also a **Run now** button per row for testing without
waiting for the schedule — same code path either way, so output
shown after a manual run matches exactly what a scheduled fire would
produce.

## Built-in handlers

| Handler key | Label | Params | Notes |
|---|---|---|---|
| `noop` | No-op (test) | none | Does nothing; returns ok. Use to confirm the scheduler thread is alive. |
| `tw2_maint` | Trade Wars 2002 maint | none | Runs TW2002's headless daily maintenance (Cabal move, inactive-player sweep) via the existing `tw2_maint` runner. |
| `db_vacuum` | SQLite VACUUM | none | Reclaims free pages, defragments, refreshes planner stats. Skips (returns ok) on non-SQLite backends — Postgres autovacuums itself. |
| `log_rotate` | Rotate large logs | `max_mb` (default 50) | Any `logs/*.log` over the threshold is renamed to `.1` and a fresh empty file is created in its place. |
| `security_check` | Security update check | none | Scans `apt list --upgradable` and the venv's `pip list --outdated`, tags Ubuntu `-security` rows, writes `logs/security-report.json` consumed by **Admin → Security**. Always returns ok=True even on a non-Ubuntu box, so a missing `apt` doesn't permanently red-flag the row. |
| `hub_generate_nodelist` | ANotherNetwork: generate nodelist | none | Publishes the ANotherNetwork nodelist into the `ANN.FILES.NODELIST` file area via `anetbbs.echomail.nodelist.write_nodelist_to_area()`, replacing the prior copy so peers can pull it like any other file-echo entry. Only meaningful on the install designated as the ANotherNetwork hub (`REGISTRY_MODE_ENABLED`) — on any other install it still runs harmlessly, just publishing a nodelist with only the hub entry and no downstream nodes. See [`20-federation.md`](20-federation.md) for the hub role this supports. |
| `sync_wall_inbound` | InterBBS Wall: import inbound posts | none | Materializes new `ANET_WALL` echomail into local Wall posts. You won't add this by hand — it's auto-created when InterBBS Wall is enabled. |
| `sync_lastcallers_inbound` | InterBBS Last Callers: import inbound entries | none | Materializes new `ANET_LASTCALLERS` echomail into local Last Callers entries. Auto-created when InterBBS Last Callers is enabled. |
| `sync_scores_inbound` | InterBBS Game Scores: import inbound scores | none | Materializes new `ANET_GAMESCORES` echomail into local game high scores. Auto-created when InterBBS Score Sharing is enabled. |
| `shell` | Shell command | `command` (required), `timeout` (default 60s) | Runs an arbitrary command as the service user via `subprocess.run(..., shell=True)`. **No sudo** — anything needing root privileges will silently fail at that step. Output is captured as raw bytes and decoded with `errors='replace'`, so non-UTF8 output (e.g. CP437 from DOS programs) doesn't crash the handler — it just shows replacement characters in the log. |

The three `sync_*_inbound` handlers above aren't something you'd pick
from a blank slate — they show up in the New/Edit Event dropdown
already-created and enabled the moment you turn on the matching
InterBBS feature (Wall, Last Callers, or Game Score sharing) on a
network. Listed here so they aren't a mystery if you notice them.

### `shell` handler — worked example

Running a nightly TW2002 external-events script via dosemu2:

**Params (JSON object):**
```json
{"command": "/opt/anetbbs/doors/dos/tw/event.sh", "timeout": 120}
```

**`event.sh`:**
```bash
#!/bin/bash
export TERM=linux
cd /opt/anetbbs/doors/dos/tw
/usr/libexec/dosemu2/dosemu2.bin -td EXTERN.EXE
```

The `export TERM=linux` and `-td` (dumb-terminal mode) are required
because the scheduler runs the command with no controlling tty —
dosemu2's normal door launches get a real PTY from `door_runner.py`,
but a scheduled shell command doesn't. Without these, dosemu2 fails
with `ERROR: TERM environment variable needs set.` before it ever
reaches your DOS program.

## Default events on a fresh install

Seeded once (idempotent — re-running the seeder skips handler keys
that already have a row):

| Name | Schedule | Handler |
|---|---|---|
| TW2 daily maintenance | Daily 03:30 UTC | `tw2_maint` |
| Weekly SQLite VACUUM | Weekly, Sunday 04:15 UTC | `db_vacuum` |
| Rotate oversize logs | Daily 04:45 UTC | `log_rotate` (`max_mb: 50`) |
| Daily security update check | Daily 04:00 UTC | `security_check` |

Any of these can be disabled, deleted, or have their schedule
changed — they're normal rows, not special-cased.

On installs with `REGISTRY_MODE_ENABLED=true` (the designated
ANotherNetwork hub), a fifth event is also seeded:

| Name | Schedule | Handler |
|---|---|---|
| ANotherNetwork: weekly nodelist | Weekly, Sunday 05:00 UTC | `hub_generate_nodelist` |

This one is skipped on regular (non-hub) installs — there's nothing
useful for it to publish there.

## Troubleshooting

- **"Params (JSON object)" rejects what I typed** — it must parse as
  a JSON object. `/path/to/script.sh` is not valid JSON; wrap it as
  `{"command": "/path/to/script.sh"}`.
- **`shell` handler crashes with `UnicodeDecodeError`** — fixed as
  of v1.0a2.116+; older builds decoded captured output as strict
  UTF-8 and crashed on non-UTF8 bytes (e.g. CP437 from DOS programs
  run via dosemu2/dosbox). Update if you still see this.
- **dosemu2 commands fail with `TERM environment variable needs
  set`** — see the worked example above; export `TERM` and pass
  `-td` in your script.
- **An event always fails the same way** — click **Run now** and
  read the captured output; it's the same `stdout`/`stderr` you'd
  get running the command by hand over SSH, just minus your
  interactive shell's environment (PATH, TERM, etc. may differ from
  your login shell — set them explicitly in the script if needed).
