From 6edbfd1e33502696c815c6d6c76f4ba1c4226b7f Mon Sep 17 00:00:00 2001 From: hakeemRash Date: Sat, 5 Sep 2026 05:49:26 +0300 Subject: [PATCH 1/3] fix(worker): enhance command-line options for queue processing and add help documentation --- tools/src/templates/app/worker/run.php | 86 +++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/tools/src/templates/app/worker/run.php b/tools/src/templates/app/worker/run.php index 1dd830e..a512768 100644 --- a/tools/src/templates/app/worker/run.php +++ b/tools/src/templates/app/worker/run.php @@ -28,8 +28,16 @@ * Usage * ----- * php app/worker/run.php # drain 'default' forever + * php app/worker/run.php --queue=mails # drain 'mails' forever + * php app/worker/run.php --queue=mails --max-iterations=50 + * hkm worker --queue=mails # same, via the launcher * WORKER_QUEUE=indexing WORKER_MAX_ITERATIONS=50 php app/worker/run.php * + * Options (a flag always wins over the matching environment variable) + * -q, --queue=NAME queue name to consume + * -n, --max-iterations=N stop after N iterations (0 = run forever) + * -h, --help show this and exit + * * Environment * WORKER_QUEUE default queue name to consume * WORKER_MAX_ITERATIONS 0 stop after N iterations (0 = run forever) @@ -54,8 +62,80 @@ $kernel = require __DIR__ . '/../bootstrap/app.php'; // 3. Read which queue to drain and how many jobs to process before exiting. -$queue = (string) (getenv('WORKER_QUEUE') ?: 'default'); -$maxIterations = (int) (getenv('WORKER_MAX_ITERATIONS') ?: 0); +// +// Command-line flags come FIRST, environment second, defaults last. The flags +// exist because `hkm worker --queue=mails` forwards every argument verbatim to +// this file: without parsing them the flag was accepted in silence and the +// worker drained 'default' instead — the one failure mode where the operator +// gets no signal at all that they asked for something else. +$options = static function (array $argv): array { + $values = ['queue' => null, 'max-iterations' => null]; + $aliases = ['q' => 'queue', 'n' => 'max-iterations']; + + $arguments = array_slice($argv, 1); + for ($i = 0, $count = count($arguments); $i < $count; $i++) { + $argument = $arguments[$i]; + + if ($argument === '-h' || $argument === '--help') { + return ['help' => true]; + } + + if (!str_starts_with($argument, '-') || $argument === '-' || $argument === '--') { + fwrite(STDERR, "[{{PROJECT_NAME}}] Unexpected argument '{$argument}'. Try --help.\n"); + exit(2); + } + + // --name=value | --name value | -q value | -q=value + $name = ltrim($argument, '-'); + $value = null; + if (str_contains($name, '=')) { + [$name, $value] = explode('=', $name, 2); + } + $name = $aliases[$name] ?? $name; + + if (!array_key_exists($name, $values)) { + fwrite(STDERR, "[{{PROJECT_NAME}}] Unknown option '{$argument}'. Try --help.\n"); + exit(2); + } + + if ($value === null) { + $value = $arguments[$i + 1] ?? null; + if ($value === null || str_starts_with($value, '-')) { + fwrite(STDERR, "[{{PROJECT_NAME}}] Option '--{$name}' needs a value.\n"); + exit(2); + } + $i++; + } + + $values[$name] = $value; + } + + return $values; +}; + +$flags = $options($argv); + +if (($flags['help'] ?? false) === true) { + echo <<<'HELP' + worker — drain a queue + + Usage + php app/worker/run.php [options] + hkm worker [options] + + Options + -q, --queue=NAME queue to consume (env WORKER_QUEUE, default 'default') + -n, --max-iterations=N stop after N jobs, 0 = forever (env WORKER_MAX_ITERATIONS) + -h, --help show this + + HELP; + exit(0); +} + +// env(), not getenv(): the loader writes .env into $_ENV and deliberately skips +// putenv(), so getenv() cannot see a WORKER_QUEUE set in the project's .env. +$queue = (string) ($flags['queue'] ?? (env('WORKER_QUEUE') ?: 'default')); +$maxIterations = (int) ($flags['max-iterations'] ?? (env('WORKER_MAX_ITERATIONS') ?: 0)); // 4. Resolve the active QueuePort. This is whatever bootstrap/app.php bound — // FileQueue by default, or the Redis-backed adapter when RedisCache is active. @@ -107,6 +187,6 @@ . ($maxIterations > 0 ? " maxIterations={$maxIterations}" : ' (forever)') . "\n"; // 8. Run until stopped (signal) or until maxIterations jobs have been processed. -$loop->run($puller, $maxIterations); +$loop->run(puller: $puller, maxIterations: $maxIterations); echo "[{{PROJECT_NAME}}] Worker finished. Remaining in '{$queue}': " . $queueAdapter->size($queue) . "\n"; From 0b5a284b2253ee991a716879333f0d8c5b8eef8a Mon Sep 17 00:00:00 2001 From: hakeemRash Date: Sat, 5 Sep 2026 06:42:47 +0300 Subject: [PATCH 2/3] feat(service): supervise a project's worker with systemd or launchd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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-writes the same unit file, and hand-writing it is where the failures live. `hkm service` generates that unit for whichever supervisor the host runs — systemd or launchd, with --platform to override so a Mac can produce the Linux unit it will deploy. Four verbs: preview (the default, writes nothing), write, install [--start], remove. Scope is --system or --user, defaulting to system on Linux and a user agent on macOS, where a LaunchDaemon running as root is the wrong answer on a developer machine. 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, and states the pinned autoload's cost in the unit. - 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. This was found by running the generated unit, not by reading it. Values that reach the unit are validated rather than interpolated: a queue name may only contain [A-Za-z0-9._:-], so nothing can add an argument or a directive; ExecStart tokens containing whitespace are quoted; plist strings are XML-escaped. --dry-run (-n) reports every write and every command for write/install/ remove and performs none of them. Its one filesystem touch is the create-and-delete write probe in the destination directory, which is how it knows whether to tell you sudo. --- tools/README.md | 1 + tools/docs/hkm-cli-usage.md | 121 +++ tools/src/commands/service.zig | 1418 ++++++++++++++++++++++++++++++++ tools/src/main.zig | 7 + tools/src/tests.zig | 1 + 5 files changed, 1548 insertions(+) create mode 100644 tools/src/commands/service.zig 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/