diff --git a/CHANGELOG.md b/CHANGELOG.md index 78fb170..e3e81f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.14.0] - 2026-09-05 + +### Added +- **`hkm service` — run a project's queue worker as a supervised service.** + `hkm worker --queue=mails` is a foreground process: it dies with the terminal, + it does not come back after a crash or a reboot, and nothing collects its + output. Every deployment therefore hand-wrote the same unit file. The command + generates it for whichever supervisor the host runs — systemd or launchd, with + `--platform` to override so a Mac can produce the Linux unit it will deploy — + in four verbs: preview (the default, which writes nothing), `write`, + `install [--start]` and `remove`. Scope is `--system` or `--user`, defaulting + to system on Linux and to a user agent on macOS, where a LaunchDaemon running + as root is the wrong answer on a developer machine. `--dry-run` (`-n`) reports + every write and every command for the three mutating verbs and performs none + of them. + + Three things the generated unit gets right that a hand-written one usually + does not: + + - **`ExecStart` runs the LAUNCHER** — `hkm worker -p `, not `php` plus an + absolute `vendor/autoload.php`. The launcher self-locates the kernel, so a + kernel upgrade that moves a version-stamped install directory cannot + silently break the queue. `--exec=php` emits the direct form for a server + with no launcher installed, and states the pinned autoload's cost in the + unit itself. + - **`TimeoutStopSec` / `ExitTimeOut` is 90s.** The worker traps SIGTERM and + finishes the job in flight before exiting — that is what makes a redeploy + safe — and launchd's 20s default SIGKILLs it mid-transaction instead. + - **`PATH` and `HKM_PHP_BIN` are pinned.** A service inherits none of a login + shell's PATH, and `/opt/homebrew/bin` is on neither manager's default. The + entire diagnostic without the pin is `error: FileNotFound`, with nothing + anywhere naming php. Found by running the generated unit, not by reading it. + + Values reaching the unit are validated rather than interpolated: a queue name + may hold only `[A-Za-z0-9._:-]`, so nothing can append an argument or a + directive; `ExecStart` tokens containing whitespace are quoted; plist strings + are XML-escaped. + +### Fixed +- **The worker entry point ignored every command-line flag.** `hkm worker` and + `hkm run --worker` forward their arguments verbatim to `app/worker/run.php`, + which read only `WORKER_QUEUE` from the environment — so + `hkm worker --queue=mails` was accepted in silence and drained `default` + instead. That is the failure mode with no signal at all: no error, no warning, + a running worker, and the wrong queue. The entry point (and the scaffolding + template new projects get) now parses `-q/--queue`, `-n/--max-iterations`, + `--memory` and `-h/--help`, each overriding the matching environment variable, + and **rejects an argument it does not recognise** rather than ignoring it. The + environment fallbacks moved from `getenv()` to `env()` at the same time: the + loader injects `.env` into `$_ENV` and deliberately skips `putenv()`, so + `getenv('WORKER_QUEUE')` could not see a value set in the project's `.env`. + ## [1.13.1] - 2026-09-04 ### Changed diff --git a/tools/README.md b/tools/README.md index 19a2596..d1707fa 100644 --- a/tools/README.md +++ b/tools/README.md @@ -223,6 +223,7 @@ tools/ ├── commands/ # one file per subcommand — each exposes `run(...)` │ ├── new.zig # hkm new — scaffold a project │ ├── run.zig # hkm run — serve / swoole / cli / worker (+ --pick) + │ ├── service.zig # hkm service — worker as a systemd / launchd unit │ ├── list.zig # hkm list — list registered projects │ └── update.zig # hkm update — refresh a registry entry ├── lib/ # shared modules used by the commands diff --git a/tools/docs/hkm-cli-usage.md b/tools/docs/hkm-cli-usage.md index dc9c0fa..64ee10d 100755 --- a/tools/docs/hkm-cli-usage.md +++ b/tools/docs/hkm-cli-usage.md @@ -24,6 +24,7 @@ hkm install [path|name] register a project, restore var/userdata/plugins af hkm run [path|name] run a project locally (PHP dev server / Swoole) hkm cli [command] run a project's console interactively hkm worker [args] run a project's queue worker +hkm service [verb] run that worker as a systemd/launchd service hkm list list registered projects (alias: ls) hkm update refresh a project's registry entry hkm plugins [subcommand] analyse / manage plugins (alias: modules) @@ -243,8 +244,128 @@ hkm cli route:list --json # machine-readable output hkm cli -p shop migrate:run # target a registered project by name hkm worker hkm worker -p shop --queue=emails +hkm worker --queue=emails --max-iterations=100 --memory=256 ``` +The worker entry point parses its own flags — `-q/--queue`, `-n/--max-iterations`, +`--memory`, `-h/--help` — and each one overrides the matching environment +variable (`WORKER_QUEUE`, `WORKER_MAX_ITERATIONS`, `WORKER_MEMORY_LIMIT_MB`). An +argument it does not recognise is an error, not something it ignores. + +--- + +## hkm service — supervise the worker + +`hkm worker` is a foreground process: it dies with the terminal, it does not come +back after a crash or a reboot, and nothing collects its output. `hkm service` +generates the unit that fixes all three, for whichever supervisor the host runs. + +``` +hkm service [path|name] show the unit that would be generated (writes nothing) +hkm service write write it to /var/service/ +hkm service install place it in the system and reload the manager +hkm service remove stop, disable and delete the installed unit + +-q, --queue=NAME queue to drain (default: WORKER_QUEUE from .env, else 'default') + --name=UNIT unit name (default: hkm-worker-[-]) + --run-as=USER[:GROUP] systemd User=/Group= (system scope; default: the invoking user) + --max=N pass --max-iterations=N to the worker + --memory=MB pass --memory=MB to the worker + --system | --user install scope (default: system on Linux, user on macOS) + --platform=systemd|launchd override host detection + --hkm-bin=PATH launcher the unit executes (default: hkm on PATH) + --php-bin=PATH php the unit pins (default: php on PATH) + --exec=hkm|php ExecStart runs the launcher (default) or php directly + --out=DIR write: put the file here instead of var/service/ + --start install: enable and start it immediately + --force overwrite an existing unit file +-y, --yes do not ask before writing to a system location +-n, --dry-run report every write and command, perform none of them +``` + +```bash +hkm service --queue=mails # preview, change nothing +hkm service install --queue=mails --start -n # what install would do, done to nothing +hkm service install --queue=mails --start # install and run it now +hkm service install shop --queue=mails --run-as=deploy:www-data +hkm service write --platform=systemd --out=./deploy # a Linux unit, from a Mac +hkm service remove --queue=mails +``` + +`--dry-run` (`-n`) applies to `write`, `install` and `remove`: each reports every +file it would write and every command it would run, and does none of it. + +``` +$ hkm service install --platform=systemd --queue=mails -n +would write /srv/shop/var/service/hkm-worker-shop-mails.service (1104 bytes) +would run sudo mkdir -p /etc/systemd/system +would run sudo cp -f /srv/shop/var/service/… /etc/systemd/system/… +would run sudo chmod 644 /etc/systemd/system/hkm-worker-shop-mails.service +would run sudo systemctl daemon-reload +``` + +It still refuses over an existing unit without `--force`, because that is what +the real run would do. The one filesystem touch it makes is a create-and-delete +write probe in the destination directory — that is how it knows whether to tell +you `sudo`, and on a directory needing root the probe writes nothing at all. + +| | systemd | launchd | +|---|---|---| +| `--system` | `/etc/systemd/system/.service` | `/Library/LaunchDaemons/