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 calledcommand, 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. |
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. |
shell handler — worked example
Running a nightly TW2002 external-events script via dosemu2:
Params (JSON object):
{"command": "/opt/anetbbs/doors/dos/tw/event.sh", "timeout": 120}
event.sh:
#!/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.
Troubleshooting
- "Params (JSON object)" rejects what I typed — it must parse as
a JSON object./path/to/script.shis not valid JSON; wrap it as
{"command": "/path/to/script.sh"}. shellhandler crashes withUnicodeDecodeError— 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; exportTERMand pass
-tdin your script. - An event always fails the same way — click Run now and
read the captured output; it's the samestdout/stderryou'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).