summaryrefslogtreecommitdiff
path: root/AGENTS.md
diff options
context:
space:
mode:
Diffstat (limited to 'AGENTS.md')
-rw-r--r--AGENTS.md190
1 files changed, 190 insertions, 0 deletions
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..6ddfe28
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,190 @@
+# Umbrella — AI Agent Context
+
+This file is for AI coding assistants. Read it before touching any code.
+
+## What this is
+
+A C11 daemon (`umbrella`) and CLI client (`umbrella-cli`) for managing
+game server processes. The daemon tails log files, dispatches RCON
+commands, and streams output to connected CLI clients over a Unix socket.
+
+Current targets: TF2, GMod, Minecraft, Terraria.
+
+## Hard design rules — never violate these
+
+These are not stylistic. They exist because this runs unattended on
+a production game server.
+
+1. **No dynamic memory allocation** — no `malloc`/`calloc`/`realloc`/`free`
+ except in `unit.c::ring_init()` (one calloc per unit at load time,
+ never freed while running). All buffers are statically sized by
+ constants in `umbrella.h`.
+
+2. **No recursion** — all loops are bounded by compile-time constants
+ (`MAX_UNITS`, `MAX_CLIENTS`, `RING_BUF_LINES`, etc.).
+
+3. **No function pointers** — dispatch is done with explicit `if/switch`.
+
+4. **Minimum 2 `assert()` calls per function** — at entry, checking
+ pointer arguments and invariants.
+
+5. **All return values checked** — `read()`, `write()`, `epoll_ctl()`,
+ `fcntl()` etc. must have their return values inspected and logged
+ on error. No silent discards.
+
+6. **Functions under 60 lines** — split if needed.
+
+7. **Bounded loops only** — every `for`/`while` has a clear upper bound.
+
+## File map
+
+```
+src/
+ umbrella.h Central types and limits (Unit, Client, Daemon, RingBuffer).
+ Edit constants here, not in source files.
+ main.c Entry point. epoll event loop. Signal handling (SIGTERM,
+ SIGCHLD, SIGHUP). Process stdout handler.
+ daemon.c/h Daemonize, PID file, signal fd setup.
+ unit.c/h YAML parser (libyaml event API), unit loading, ring buffer
+ ops (ring_init, ring_push, ring_free).
+ client.c/h Unix socket listener, client lifecycle, JSON command
+ dispatch (list, status, attach, detach, input, action,
+ tail, broadcast). systemd D-Bus state queries here.
+ proto.c/h Length-prefixed JSON wire protocol. 4-byte BE length + JSON.
+ log.c/h File/stderr logger with timestamps and level filter.
+ log_tail.c/h inotify-based log file tailing. One inotify_fd per watched
+ file. Resolves symlinks at init via realpath(). Handles
+ log rotation (IN_MOVE_SELF / IN_DELETE_SELF).
+ filter.c/h Persistent filter subprocess per unit. fork/exec with
+ stdin/stdout pipes. filter_apply() writes chunk, polls
+ 250ms for output, falls back to pass-through on timeout.
+ console/
+ rcon.c/h Valve RCON TCP protocol (auth + command).
+ a2s.c/h Valve A2S_INFO UDP health probe with challenge handling.
+
+clients/
+ umbrella-cli/main.c CLI client. JSON over Unix socket. Interactive
+ attach mode (epoll on socket + stdin).
+
+filters/
+ source.py Source engine filter (TF2, GMod): strips server_cvar,
+ stuck/path_goal spam, strips timestamp prefix.
+ minecraft.py Minecraft filter: strips keepAlive, autosave, internal
+ class logs, strips [HH:MM:SS] [thread] prefix.
+ terraria.py Terraria filter: strips blank lines and tModLoader
+ startup noise.
+```
+
+## Key data flows
+
+### Log tail → client
+```
+srcds writes to log file
+ → inotify IN_MODIFY fires on epoll
+ → log_tail_handle() reads new bytes into char buf[1024]
+ → filter_apply() pipes buf through filter subprocess (if configured)
+ → ring_push() stores in unit's RingBuffer (500 × 1024)
+ → client_broadcast_output() sends to all clients with c->attached == unit
+```
+
+### Client attach → history replay
+```
+client sends {"cmd":"attach","unit":"tf2"}
+ → cmd_attach() finds unit, iterates ring buffer
+ → sends each stored line as {"type":"output","history":true,...}
+ → sends {"type":"ok"}
+ → future output arrives via client_broadcast_output()
+```
+
+### RCON command
+```
+client sends {"cmd":"input","unit":"tf2","data":"status\n"}
+ → cmd_input() calls rcon_exec() → TCP connect → auth → send → read
+ → response echoed back as {"type":"output","history":false,...}
+```
+
+## Wire protocol
+
+```
+[4 bytes big-endian length][JSON payload]
+```
+
+Client → daemon commands:
+```json
+{"cmd":"attach", "unit":"tf2"}
+{"cmd":"detach"}
+{"cmd":"input", "unit":"tf2", "data":"say hello\n"}
+{"cmd":"tail", "unit":"tf2"}
+{"cmd":"list"}
+{"cmd":"status", "unit":"tf2"}
+{"cmd":"action", "unit":"tf2", "name":"update"}
+{"cmd":"broadcast", "message":"Server restarting"}
+```
+
+Daemon → client responses:
+```json
+{"type":"ok"}
+{"type":"error", "message":"unit not found"}
+{"type":"output", "unit":"tf2", "data":"...", "history":false}
+{"type":"list", "units":[{"name":"tf2","display":"...","state":"running"}]}
+{"type":"status", "name":"tf2", "state":"running", "players":4, "max":24, "map":"cp_badlands"}
+```
+
+## YAML parser gotcha
+
+`unit.c` uses libyaml's event API with a hand-rolled state machine.
+Sequence items (the `logs:` list) arrive as `YAML_SCALAR_EVENT` with
+`in_value == 0` — they have no preceding key. The parser handles this
+by checking `section == SECTION_LOGS` before the normal key/value path.
+If you add another sequence field, you must do the same.
+
+## Log tail setup (important for operators)
+
+Most game servers create new timestamped log files per session. The
+inotify watch is set up once (at start or SIGHUP) against the resolved
+real path. The recommended pattern:
+
+1. Point unit YAML `logs:` at a stable symlink, e.g. `current.log`
+2. Startup script does: `ln -sf /path/to/actual.log /path/to/current.log`
+3. SIGHUP umbrella — `log_tail_init` calls `realpath()` to resolve the
+ symlink to the real file before opening and watching it
+4. inotify then watches the actual inode, so `IN_MODIFY` fires correctly
+
+Source engine: use `sv_log_onefile 1` so the file is stable within a
+session; only update the symlink on server restart.
+
+## Filter contract
+
+A log filter is any executable that:
+- Reads lines from **stdin**
+- Writes transformed output to **stdout**
+- **Flushes stdout after each write** (critical — Python: `sys.stdout.flush()`)
+- Can suppress lines by writing nothing for a given input
+
+The daemon gives it 250ms to respond per chunk. On timeout, the raw
+data passes through unchanged. On write error, the filter is stopped
+and that unit runs unfiltered.
+
+## Limits (all in umbrella.h)
+
+| Constant | Value | Meaning |
+|----------|-------|---------|
+| `MAX_UNITS` | 64 | Max loaded unit descriptors |
+| `MAX_CLIENTS` | 32 | Max concurrent CLI connections |
+| `RING_BUF_LINES` | 500 | Buffered output lines per unit |
+| `RING_BUF_LINE_MAX` | 1024 | Max bytes per ring buffer entry |
+| `PROTO_MAX_MSG` | 65536 | Max wire message size |
+| `MAX_PATH` | 256 | Max filesystem path length |
+| `MAX_ACTIONS` | 16 | Named actions per unit |
+
+## Build
+
+```bash
+# Deps: libyaml-dev libsystemd-dev
+make && sudo make install
+```
+
+Reload after config changes (no restart needed):
+```bash
+kill -HUP $(cat /run/umbrella/umbrella.pid)
+```