@edgehero/pi-dispatch 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/.env.example +160 -0
  2. package/deploy/com.pi-dispatch.worker.plist +66 -0
  3. package/deploy/nssm-install.cmd +59 -0
  4. package/deploy/receiver.service +36 -0
  5. package/deploy/worker-env-wrapper.cmd +50 -0
  6. package/deploy/worker-env-wrapper.sh +63 -0
  7. package/deploy/worker.service +55 -0
  8. package/package.json +83 -0
  9. package/src/azure-auth.mjs +61 -0
  10. package/src/azure-host.mjs +236 -0
  11. package/src/azure-identity.mjs +63 -0
  12. package/src/azure-prompt.mjs +118 -0
  13. package/src/branch.mjs +80 -0
  14. package/src/budget.mjs +179 -0
  15. package/src/cli.mjs +208 -0
  16. package/src/config.mjs +329 -0
  17. package/src/connection.mjs +40 -0
  18. package/src/cron.mjs +94 -0
  19. package/src/docker-run.mjs +119 -0
  20. package/src/doctor.mjs +1127 -0
  21. package/src/env-allowlist.mjs +198 -0
  22. package/src/env-file.mjs +153 -0
  23. package/src/exit-code.mjs +32 -0
  24. package/src/flow-gate.mjs +82 -0
  25. package/src/forgejo-auth.mjs +77 -0
  26. package/src/forgejo-host.mjs +172 -0
  27. package/src/forgejo-identity.mjs +74 -0
  28. package/src/forgejo-prompt.mjs +123 -0
  29. package/src/forges.mjs +148 -0
  30. package/src/get-token.mjs +226 -0
  31. package/src/git-dirty.mjs +16 -0
  32. package/src/github-app-setup.mjs +517 -0
  33. package/src/github-host.mjs +159 -0
  34. package/src/github-prompt.mjs +286 -0
  35. package/src/gitlab-auth.mjs +72 -0
  36. package/src/gitlab-host.mjs +200 -0
  37. package/src/gitlab-identity.mjs +61 -0
  38. package/src/gitlab-prompt.mjs +123 -0
  39. package/src/identity.mjs +57 -0
  40. package/src/image-preflight.mjs +180 -0
  41. package/src/import-pi.mjs +451 -0
  42. package/src/index.mjs +177 -0
  43. package/src/init.mjs +77 -0
  44. package/src/job-id.mjs +100 -0
  45. package/src/materialize.mjs +138 -0
  46. package/src/outbox.mjs +179 -0
  47. package/src/packages.mjs +188 -0
  48. package/src/pause-windows.mjs +218 -0
  49. package/src/prepare-github.mjs +260 -0
  50. package/src/prepare-local.mjs +76 -0
  51. package/src/prepare.mjs +199 -0
  52. package/src/pricing.mjs +168 -0
  53. package/src/processor.mjs +360 -0
  54. package/src/queue.mjs +152 -0
  55. package/src/run-container.mjs +133 -0
  56. package/src/run-history.mjs +534 -0
  57. package/src/runtime-settings.mjs +188 -0
  58. package/src/sandbox-cli.mjs +156 -0
  59. package/src/sandbox-store.mjs +269 -0
  60. package/src/sandbox.mjs +171 -0
  61. package/src/scheduler-stall-guard.mjs +67 -0
  62. package/src/schedules.mjs +62 -0
  63. package/src/service.mjs +677 -0
  64. package/src/session-key.mjs +108 -0
  65. package/src/session-store.mjs +249 -0
  66. package/src/start.mjs +502 -0
  67. package/src/subscriptions.mjs +208 -0
  68. package/src/triggers.mjs +491 -0
  69. package/src/up.mjs +315 -0
package/src/budget.mjs ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * CONST-BUDGET-BEFORE-TOKENS. The ordering IS the mechanism.
3
+ *
4
+ * Reserve a slot against the spend caps BEFORE a container starts. Check-after-spend means fifty
5
+ * junk triggers cost fifty jobs of real money before the cap engages -- the exact scenario the cap
6
+ * exists for. So: atomically INCR each active window's counter, and only start the container if every
7
+ * window is within its cap and outside its soft-hold band.
8
+ *
9
+ * INCR is atomic -- verified against a real Valkey under 20 parallel increments yielding exactly
10
+ * 20, no lost updates -- so this needs no lock even at concurrency 3. Three jobs racing at count 9
11
+ * with cap 10 reserve 10, 11, 12; exactly one proceeds, the others are refused, and no container
12
+ * starts over budget.
13
+ *
14
+ * Three windows bound spend on different horizons: a mandatory DAY cap, plus OPTIONAL WEEK and MONTH
15
+ * ceilings (REQ-SPEND-CAPS-MULTI-WINDOW). A window whose cap is null/absent is disabled -- not counted,
16
+ * not evaluated. On top of the hard caps a single SOFT-HOLD percentage refuses new starts once any
17
+ * window enters its band (e.g. >= 80% of that window's cap), a distinct operator-visible brake before
18
+ * the hard wall; in-flight containers are untouched because the reservation happens pre-container.
19
+ *
20
+ * `redis` is any ioredis-compatible client (BullMQ bundles ioredis). Injected so the logic is
21
+ * testable without a running server.
22
+ */
23
+
24
+ /** UTC day key: `budget:YYYY-MM-DD`. */
25
+ export function dayKey(now = new Date(), prefix = "budget") {
26
+ return `${prefix}:${now.toISOString().slice(0, 10)}`; // YYYY-MM-DD, UTC
27
+ }
28
+
29
+ /**
30
+ * UTC week key, bucketed by the Monday of the run's week: `budget:w:YYYY-MM-DD` (that Monday's date).
31
+ * Keying by an actual date sidesteps ISO week-number edge cases (week 53, year rollovers) while still
32
+ * mapping every run in one Mon-Sun week to one key. The `w:` sub-namespace never collides with the day
33
+ * key (`budget:YYYY-MM-DD`).
34
+ */
35
+ export function weekKey(now = new Date(), prefix = "budget") {
36
+ const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
37
+ const sinceMonday = (d.getUTCDay() + 6) % 7; // Mon=0 .. Sun=6
38
+ d.setUTCDate(d.getUTCDate() - sinceMonday);
39
+ return `${prefix}:w:${d.toISOString().slice(0, 10)}`;
40
+ }
41
+
42
+ /** UTC month key: `budget:m:YYYY-MM`. The `m:` sub-namespace never collides with the day/week keys. */
43
+ export function monthKey(now = new Date(), prefix = "budget") {
44
+ return `${prefix}:m:${now.toISOString().slice(0, 7)}`; // YYYY-MM, UTC
45
+ }
46
+
47
+ /**
48
+ * UTC day key for the daily TOKEN counter: `budget:t:YYYY-MM-DD`. The `t:` sub-namespace never collides
49
+ * with the job-count day/week/month keys -- token spend and job count are separate ledgers.
50
+ */
51
+ export function tokenDayKey(now = new Date(), prefix = "budget") {
52
+ return `${prefix}:t:${now.toISOString().slice(0, 10)}`; // YYYY-MM-DD, UTC
53
+ }
54
+
55
+ // Each window's TTL outlives its bucket with slack, so a stale counter is reclaimed shortly after the
56
+ // window rolls over. Set once, on first reservation only (reserved === 1), so a busy window cannot push
57
+ // its own expiry forward indefinitely.
58
+ const DAY_TTL_SECONDS = 2 * 24 * 60 * 60; // outlives the UTC day
59
+ const WEEK_TTL_SECONDS = 9 * 24 * 60 * 60; // outlives the Mon-Sun week
60
+ const MONTH_TTL_SECONDS = 40 * 24 * 60 * 60; // outlives the longest month
61
+
62
+ /**
63
+ * Classify one window's reserved count against its cap and the soft-hold band. PURE, and exported so the
64
+ * worker's `reserveBudget` and the admin panel compute the same state from the same inputs and cannot
65
+ * drift on where the band and the wall begin (the read-model already imports `dayKey` for the same reason).
66
+ *
67
+ * reserved > cap -> "over" (hard cap exceeded; also fails closed for cap <= 0)
68
+ * reserved > floor(cap * pct / 100) -> "soft-hold" (inside the band; only when pct is set)
69
+ * otherwise -> "ok"
70
+ *
71
+ * `softHoldPct` null/absent disables the band. Callers only classify ACTIVE windows (cap is a real number);
72
+ * a disabled window (cap null) is never passed here.
73
+ */
74
+ export function windowState(reserved, cap, softHoldPct = null) {
75
+ if (reserved > cap) return "over";
76
+ if (Number.isInteger(softHoldPct) && reserved > Math.floor((cap * softHoldPct) / 100)) return "soft-hold";
77
+ return "ok";
78
+ }
79
+
80
+ /** The active windows for a `caps` set: day plus week/month only when their cap is set (not null/undefined). */
81
+ function activeWindows(caps, now, keyPrefix) {
82
+ return [
83
+ { name: "day", key: dayKey(now, keyPrefix), cap: caps?.day, ttl: DAY_TTL_SECONDS },
84
+ { name: "week", key: weekKey(now, keyPrefix), cap: caps?.week, ttl: WEEK_TTL_SECONDS },
85
+ { name: "month", key: monthKey(now, keyPrefix), cap: caps?.month, ttl: MONTH_TTL_SECONDS },
86
+ ].filter((w) => w.cap !== null && w.cap !== undefined);
87
+ }
88
+
89
+ /**
90
+ * Atomically reserve one slot across every ACTIVE window. Returns
91
+ * `{ allowed, reason, blockedWindow, windows }` where `windows[name]` is `{ reserved, cap, state }` for an
92
+ * active window or `null` for a disabled one, and `reason` is the worst window state:
93
+ * any "over" -> "over-budget"
94
+ * else any "soft-hold" -> "soft-hold"
95
+ * else -> "ok" (allowed)
96
+ * `blockedWindow` names the day>week>month-first window carrying that worst state, so the caller can say
97
+ * WHICH ceiling blocked. Both refusals are pre-container, so a refused job spends nothing; a soft-hold is
98
+ * refused exactly like over-budget, only under a distinct reason and an amber panel state.
99
+ *
100
+ * Every active window is INCR'd UNCONDITIONALLY -- the same "a refused reservation still counts, no
101
+ * give-back" invariant the daily cap has always had, now per window. This is deliberate: conditional
102
+ * increments would reintroduce the read-then-write race the atomic INCR design exists to avoid, and
103
+ * unconditional INCR keeps `releaseBudget` a clean symmetric DECR of the same windows. It fails closed --
104
+ * a burst that exhausts one window counts against the others too -- which is the money-safe direction.
105
+ *
106
+ * A cap of 0/negative on an ACTIVE window fails closed (every job "over") rather than meaning "unlimited";
107
+ * a DISABLED window (cap null/absent) is not counted or evaluated at all.
108
+ */
109
+ export async function reserveBudget(redis, { caps, softHoldPct = null, now = new Date(), keyPrefix = "budget" } = {}) {
110
+ const windows = { day: null, week: null, month: null };
111
+ for (const w of activeWindows(caps, now, keyPrefix)) {
112
+ const reserved = Number(await redis.incr(w.key));
113
+ // Set the TTL only when the key is first created, so a long window cannot push its expiry forward.
114
+ if (reserved === 1) await redis.expire(w.key, w.ttl);
115
+ windows[w.name] = { reserved, cap: w.cap, state: windowState(reserved, w.cap, softHoldPct) };
116
+ }
117
+
118
+ const order = ["day", "week", "month"];
119
+ for (const [state, reason] of [
120
+ ["over", "over-budget"],
121
+ ["soft-hold", "soft-hold"],
122
+ ]) {
123
+ const hit = order.find((name) => windows[name]?.state === state);
124
+ if (hit) return { allowed: false, reason, blockedWindow: hit, windows };
125
+ }
126
+ return { allowed: true, reason: "ok", blockedWindow: null, windows };
127
+ }
128
+
129
+ /**
130
+ * Give a reservation back in every window `reserveBudget` would have reserved. Used ONLY when the container
131
+ * never started because of an INFRA fault AFTER reserving (e.g. the docker daemon was unreachable) -- an
132
+ * infra failure that spent nothing should not permanently consume a slot. DECRs exactly the ACTIVE windows,
133
+ * mirroring `reserveBudget` so the refund cannot drift from the reservation. NOT used for a completed run
134
+ * (which really consumed its slot), an exit-1 infra retry (the container ran and spent), or an
135
+ * over-budget/soft-hold refusal (which never gives back its slot, deliberately).
136
+ */
137
+ export async function releaseBudget(redis, { caps, now = new Date(), keyPrefix = "budget" } = {}) {
138
+ for (const w of activeWindows(caps, now, keyPrefix)) {
139
+ await redis.decr(w.key);
140
+ }
141
+ }
142
+
143
+ /**
144
+ * The daily TOKEN cap check -- read-only, no increment. Returns `{ allowed, reason, spent, cap }`.
145
+ *
146
+ * This is the deliberate ASYMMETRY to `reserveBudget` (issue #25 / OQ-010). A job's token cost is
147
+ * knowable only AFTER it runs, so a token cap cannot check-and-increment BEFORE the spend the way the
148
+ * job-COUNT cap does (CONST-BUDGET-BEFORE-TOKENS, which governs only the ordering of the job-count cap
149
+ * and stays unchanged). Instead the token count is a check-BEFORE read of PRIOR jobs' recorded spend
150
+ * (this function) paired with a record-AFTER `INCRBY` (`recordTokenSpend`). It can only stop the NEXT
151
+ * job once a day's accumulated spend has reached the cap -- a lagging backstop, not a before-the-spend
152
+ * cap; `maxTurns` remains the one proactive per-job lever. Being read-only it consumes nothing, so it
153
+ * safely precedes the job-count reservation and a refusal here spends no job-count slot.
154
+ *
155
+ * `cap` null/absent disables the cap (always allowed). A cap <= 0 fails closed (spent >= 0 always
156
+ * blocks), matching `reserveBudget`'s fail-closed posture on a nonsensical cap.
157
+ */
158
+ export async function checkTokenCap(redis, { cap, now = new Date(), keyPrefix = "budget" } = {}) {
159
+ if (cap === null || cap === undefined) return { allowed: true, reason: "ok", spent: 0, cap: null };
160
+ const spent = Number(await redis.get(tokenDayKey(now, keyPrefix))) || 0;
161
+ if (spent >= cap) return { allowed: false, reason: "daily-token-cap", spent, cap };
162
+ return { allowed: true, reason: "ok", spent, cap };
163
+ }
164
+
165
+ /**
166
+ * Record a job's token spend against the daily counter -- the record-AFTER half of the lagging token cap
167
+ * (see `checkTokenCap`). `INCRBY` by the job's total tokens; set the day TTL once, on first write, so a
168
+ * busy day cannot push its own expiry forward (mirrors `reserveBudget`'s set-once TTL). Under concurrency
169
+ * the counter is best-effort: N in-flight jobs each passed `checkTokenCap` before any recorded, so the
170
+ * daily total can overshoot the cap by up to N per-job budgets -- expected and acceptable for a lagging
171
+ * backstop (OQ-010). Returns the new counter value. `tokens` is assumed a non-negative integer (the
172
+ * caller guards `> 0`), so the first-write TTL test `total === tokens` holds.
173
+ */
174
+ export async function recordTokenSpend(redis, tokens, { now = new Date(), keyPrefix = "budget" } = {}) {
175
+ const key = tokenDayKey(now, keyPrefix);
176
+ const total = Number(await redis.incrby(key, tokens));
177
+ if (total === tokens) await redis.expire(key, DAY_TTL_SECONDS);
178
+ return total;
179
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { parseArgs } from "node:util";
5
+ import { loadConfig } from "./config.mjs";
6
+ import { EXIT_POLICY } from "./exit-code.mjs";
7
+ import { gitDirty } from "./git-dirty.mjs";
8
+
9
+ const USAGE = `pi-dispatch — run pi coding-agent flows on your own folders
10
+
11
+ pi-dispatch init scaffold .env + triggers.json + pause-windows.json + pi-packages.json + subscriptions.json here
12
+ pi-dispatch doctor [--fix] preflight Docker, Valkey, the job image, and your provider key; --fix offers to run each fix (y/N per action)
13
+ pi-dispatch up [--yes] one consented pass: pull+tag the job image, start Valkey, init, doctor
14
+ pi-dispatch setup github mint GitHub App credentials in one browser click (App Manifest flow);
15
+ every write shown first and individually consented — no --yes here
16
+ (--webhook-url <URL> | --no-webhook) [--org <org>] [--name <appName>]
17
+ pi-dispatch import-pi stage your host pi setup (models/skills/persona) into a global overlay
18
+ [--no-extensions] [--with-packages] [--packages-file <path>]
19
+ [--from <agentDir>] [--to <overlayDir>]
20
+
21
+ pi-dispatch run <folder> --task "<what to do>" [--flow <name>]
22
+ [--provider <p>] [--model <m>] [--max-turns <n>] [--image <ref>] [--force]
23
+ pi-dispatch sandbox <jobId>
24
+ re-open a finished run's sandbox as a shell — same image, same workspace,
25
+ no credentials [--publish <port>[:<containerPort>]] [--pin]
26
+ pi-dispatch sandbox --list
27
+ what is still re-openable, for how long, and what is running now
28
+
29
+ pi-dispatch worker drain the queue (run this in another terminal, or as a service)
30
+ pi-dispatch-receiver webhook receiver for forge triggers — its own bin (see the GitHub section of the README)
31
+ pi-dispatch service <render|install|uninstall|status|start|stop|restart> [--receiver] [--user|--system] [--force]
32
+ run the worker (or --receiver) as an OS service — the deploy/ templates
33
+ rendered with this host's real paths, installed user-level;
34
+ \`service restart --drain\` lets the in-flight job finish first
35
+ pi-dispatch pause stop taking new jobs (durable; survives worker restart)
36
+ pi-dispatch resume resume taking jobs
37
+ pi-dispatch status show paused state + job counts
38
+
39
+ Config comes from the environment (see .env.example); flags override it per run.`;
40
+
41
+ export async function main(argv = process.argv.slice(2), env = process.env) {
42
+ const cmd = argv[0];
43
+
44
+ if (cmd === "init") {
45
+ const { runInit } = await import("./init.mjs");
46
+ return runInit(process.cwd());
47
+ }
48
+
49
+ if (cmd === "doctor") {
50
+ const { runDoctor } = await import("./doctor.mjs");
51
+ // `fix` rides in the deps position (runDoctor(env, depsOrOpts)) — one options bag, no third arg.
52
+ return runDoctor(env, { fix: argv.slice(1).includes("--fix") });
53
+ }
54
+
55
+ if (cmd === "up") {
56
+ const { runUp } = await import("./up.mjs");
57
+ return runUp(argv.slice(1), { env });
58
+ }
59
+
60
+ if (cmd === "setup") {
61
+ // Subcommand shape (`setup <forge>`) so future forges can land beside github without a new
62
+ // top-level verb; an unknown or missing target prints guidance rather than guessing.
63
+ if (argv[1] === "github") {
64
+ const { runGithubAppSetup } = await import("./github-app-setup.mjs");
65
+ return runGithubAppSetup(argv.slice(2), { env });
66
+ }
67
+ process.stdout.write(`pi-dispatch setup <target> — guided credential setup\n\n targets: github\n\n pi-dispatch setup github (--webhook-url <URL> | --no-webhook) [--org <org>] [--name <appName>]\n mint GitHub App credentials via the App Manifest flow — one browser click returns the app id,\n private key, and webhook secret; every write is shown first and individually consented\n`);
68
+ return argv[1] ? 1 : 0;
69
+ }
70
+
71
+ if (cmd === "import-pi") {
72
+ const { runImportPi } = await import("./import-pi.mjs");
73
+ return runImportPi(argv.slice(1), { env });
74
+ }
75
+
76
+ if (cmd === "sandbox") {
77
+ const { runSandbox } = await import("./sandbox-cli.mjs");
78
+ return runSandbox(argv.slice(1), { env });
79
+ }
80
+
81
+ if (cmd === "worker") {
82
+ const { startWorker } = await import("./start.mjs");
83
+ await startWorker(env);
84
+ return 0; // the worker keeps the process alive until SIGTERM
85
+ }
86
+
87
+ if (cmd === "service") {
88
+ const { runService } = await import("./service.mjs");
89
+ return runService(argv.slice(1), { env });
90
+ }
91
+
92
+ if (cmd === "run") {
93
+ const { values, positionals } = parseArgs({
94
+ args: argv.slice(1),
95
+ allowPositionals: true,
96
+ options: {
97
+ task: { type: "string" },
98
+ flow: { type: "string" },
99
+ provider: { type: "string" },
100
+ model: { type: "string" },
101
+ "max-turns": { type: "string" },
102
+ image: { type: "string" }, // the container image for this one job; blank/absent = PI_JOB_IMAGE
103
+ force: { type: "boolean", default: false },
104
+ },
105
+ });
106
+ const folder = positionals[0] && resolve(positionals[0]);
107
+ if (!folder || !existsSync(folder)) return fail(`folder not found: ${positionals[0] ?? "(none given)"}`);
108
+ if (!values.task) return fail("a --task is required");
109
+
110
+ // A local job edits the folder IN PLACE with no undo (SECURITY.md). Refuse a dirty working
111
+ // tree unless --force, so a bad run cannot mix with uncommitted work the operator can't
112
+ // cleanly separate. A non-git folder is caught later by prepare (v1 requires a git repo).
113
+ if (existsSync(`${folder}/.git`) && !values.force) {
114
+ const dirty = gitDirty(folder);
115
+ if (dirty === null) return fail(`${folder} is not a usable git repository`);
116
+ if (dirty) return fail(`${folder} has uncommitted changes. Commit or stash them, or pass --force.`);
117
+ }
118
+
119
+ const config = loadConfig(env);
120
+ const { parseConnection } = await import("./connection.mjs");
121
+ const { makeQueue, enqueueLocalJob } = await import("./queue.mjs");
122
+ // failFast: a one-shot enqueue must not hang forever if Valkey is down -- error clearly.
123
+ const queue = makeQueue(parseConnection(config.valkeyUrl, { failFast: true }));
124
+ try {
125
+ // Absent flags stay absent (undefined) so the value resolves at job start against the
126
+ // settings overlay/env, not a default frozen here (INT-CONFIG-OVERLAY-CONTRACT).
127
+ const jobId = await enqueueLocalJob(queue, {
128
+ folder,
129
+ task: values.task,
130
+ flow: values.flow,
131
+ provider: values.provider,
132
+ model: values.model,
133
+ maxTurns: values["max-turns"] ? Number(values["max-turns"]) : undefined,
134
+ // || not the raw value: `--image ""` must collapse to absent rather than becoming a falsy string that
135
+ // throws inside buildDockerRunArgs after a budget slot is reserved.
136
+ image: values.image || undefined,
137
+ });
138
+ process.stdout.write(`queued ${jobId} — folder ${folder}\nrun \`pi-dispatch worker\` to process it.\n`);
139
+ } catch (error) {
140
+ return fail(`could not reach Valkey at ${config.valkeyUrl} — is it running? (docker compose up)\n ${error.message}`);
141
+ } finally {
142
+ await queue.close().catch(() => {});
143
+ }
144
+ return 0;
145
+ }
146
+
147
+ if (cmd === "pause" || cmd === "resume" || cmd === "status") {
148
+ // The kill switch reads ONLY VALKEY_URL, not the full loadConfig -- it must work even when
149
+ // GitHub auth is misconfigured, so an operator can always stop the queue.
150
+ const url = env.VALKEY_URL ?? "redis://127.0.0.1:6379";
151
+ const { parseConnection } = await import("./connection.mjs");
152
+ const { makeQueue } = await import("./queue.mjs");
153
+ // failFast: reach the kill switch in seconds when Valkey is down, not a hang. makeQueue pins
154
+ // the "pi-jobs" name -- a different name would pause an empty queue (silent no-op).
155
+ const queue = makeQueue(parseConnection(url, { failFast: true }));
156
+ try {
157
+ if (cmd === "pause") {
158
+ await queue.pause();
159
+ process.stdout.write("paused — worker will stop taking new jobs (jobs still enqueue)\n");
160
+ } else if (cmd === "resume") {
161
+ await queue.resume();
162
+ process.stdout.write("resumed\n");
163
+ } else {
164
+ // "paused" is included in the counts because jobs enqueued while paused land in the
165
+ // `paused` list, not `wait` -- omitting it would report backlog 0 in the exact state
166
+ // the pause switch creates. `pausedState` (the boolean) is named apart from the
167
+ // `paused` count `getJobCounts` returns, so the two do not collide in the output.
168
+ const pausedState = await queue.isPaused();
169
+ const counts = await queue.getJobCounts("waiting", "active", "paused", "delayed", "failed");
170
+ process.stdout.write(`${JSON.stringify({ pausedState, ...counts })}\n`);
171
+ }
172
+ } catch (error) {
173
+ return fail(`could not reach Valkey at ${url} — is it running? (docker compose up)\n ${error.message}`);
174
+ } finally {
175
+ await queue.close().catch(() => {});
176
+ }
177
+ return 0;
178
+ }
179
+
180
+ process.stdout.write(`${USAGE}\n`);
181
+ return cmd ? 1 : 0;
182
+ }
183
+
184
+ function fail(message) {
185
+ process.stderr.write(`error: ${message}\n`);
186
+ return 1;
187
+ }
188
+
189
+ /**
190
+ * Exit code for an error that escaped main() as a rejection. A tagged config error (loadConfig's
191
+ * `piDispatchConfig`) is a determinate refusal -> EXIT_POLICY (2, never retried); anything else is
192
+ * infra -> 1 (retryable). Mirrors INT-RUNNER-EXIT-CODE-PROTOCOL for the CLI's own exit space.
193
+ */
194
+ export function entryExitCode(err) {
195
+ return err?.piDispatchConfig ? EXIT_POLICY : 1;
196
+ }
197
+
198
+ // Entry point when run as a bin. Kept out of the exported main so tests can call main() directly.
199
+ if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("cli.mjs")) {
200
+ main()
201
+ .then((code) => {
202
+ if (code) process.exitCode = code;
203
+ })
204
+ .catch((err) => {
205
+ process.stderr.write(`error: ${err.message}\n`);
206
+ process.exitCode = entryExitCode(err);
207
+ });
208
+ }