@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.
- package/.env.example +160 -0
- package/deploy/com.pi-dispatch.worker.plist +66 -0
- package/deploy/nssm-install.cmd +59 -0
- package/deploy/receiver.service +36 -0
- package/deploy/worker-env-wrapper.cmd +50 -0
- package/deploy/worker-env-wrapper.sh +63 -0
- package/deploy/worker.service +55 -0
- package/package.json +83 -0
- package/src/azure-auth.mjs +61 -0
- package/src/azure-host.mjs +236 -0
- package/src/azure-identity.mjs +63 -0
- package/src/azure-prompt.mjs +118 -0
- package/src/branch.mjs +80 -0
- package/src/budget.mjs +179 -0
- package/src/cli.mjs +208 -0
- package/src/config.mjs +329 -0
- package/src/connection.mjs +40 -0
- package/src/cron.mjs +94 -0
- package/src/docker-run.mjs +119 -0
- package/src/doctor.mjs +1127 -0
- package/src/env-allowlist.mjs +198 -0
- package/src/env-file.mjs +153 -0
- package/src/exit-code.mjs +32 -0
- package/src/flow-gate.mjs +82 -0
- package/src/forgejo-auth.mjs +77 -0
- package/src/forgejo-host.mjs +172 -0
- package/src/forgejo-identity.mjs +74 -0
- package/src/forgejo-prompt.mjs +123 -0
- package/src/forges.mjs +148 -0
- package/src/get-token.mjs +226 -0
- package/src/git-dirty.mjs +16 -0
- package/src/github-app-setup.mjs +517 -0
- package/src/github-host.mjs +159 -0
- package/src/github-prompt.mjs +286 -0
- package/src/gitlab-auth.mjs +72 -0
- package/src/gitlab-host.mjs +200 -0
- package/src/gitlab-identity.mjs +61 -0
- package/src/gitlab-prompt.mjs +123 -0
- package/src/identity.mjs +57 -0
- package/src/image-preflight.mjs +180 -0
- package/src/import-pi.mjs +451 -0
- package/src/index.mjs +177 -0
- package/src/init.mjs +77 -0
- package/src/job-id.mjs +100 -0
- package/src/materialize.mjs +138 -0
- package/src/outbox.mjs +179 -0
- package/src/packages.mjs +188 -0
- package/src/pause-windows.mjs +218 -0
- package/src/prepare-github.mjs +260 -0
- package/src/prepare-local.mjs +76 -0
- package/src/prepare.mjs +199 -0
- package/src/pricing.mjs +168 -0
- package/src/processor.mjs +360 -0
- package/src/queue.mjs +152 -0
- package/src/run-container.mjs +133 -0
- package/src/run-history.mjs +534 -0
- package/src/runtime-settings.mjs +188 -0
- package/src/sandbox-cli.mjs +156 -0
- package/src/sandbox-store.mjs +269 -0
- package/src/sandbox.mjs +171 -0
- package/src/scheduler-stall-guard.mjs +67 -0
- package/src/schedules.mjs +62 -0
- package/src/service.mjs +677 -0
- package/src/session-key.mjs +108 -0
- package/src/session-store.mjs +249 -0
- package/src/start.mjs +502 -0
- package/src/subscriptions.mjs +208 -0
- package/src/triggers.mjs +491 -0
- package/src/up.mjs +315 -0
package/src/service.mjs
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pi-dispatch service` — render and install the deploy/ daemon templates for THIS host (issue #80).
|
|
3
|
+
*
|
|
4
|
+
* Durable running used to mean hand-editing the per-OS examples in deploy/. This module reads those
|
|
5
|
+
* SAME files from the package and substitutes a documented table of their known literals —
|
|
6
|
+
* `/usr/bin/node` → `process.execPath`, `/opt/pi-dispatch` → the real repo root — rather than
|
|
7
|
+
* introducing a `{{placeholder}}` dialect. That keeps the deploy/ files byte-usable examples (and
|
|
8
|
+
* deploy-lint keeps parsing exactly what ships); TEMPLATE_PINS below is the table's enforcement — the
|
|
9
|
+
* test suite asserts every literal is still present in every template, so template drift breaks the
|
|
10
|
+
* build loudly instead of breaking the render silently.
|
|
11
|
+
*
|
|
12
|
+
* Scope doctrine:
|
|
13
|
+
* - User-level by default, everywhere. macOS REFUSES root outright (a LaunchAgent is per-user, and a
|
|
14
|
+
* root agent could not see the login session's Docker Desktop anyway — the svc.sh precedent).
|
|
15
|
+
* Linux `--system` never executes a privileged write: it stages the render and PRINTS the exact
|
|
16
|
+
* sudo commands (the pm2-startup pattern), so root actions only ever happen in the operator's own
|
|
17
|
+
* shell.
|
|
18
|
+
* - ONE worker per docker daemon (DES-CONCURRENCY-3): install refuses a worker unit when one exists
|
|
19
|
+
* in the OTHER scope, because the worker's boot reaper kills every pi-job container it did not
|
|
20
|
+
* start — a second worker would reap the first's live jobs on every restart. Receivers are exempt
|
|
21
|
+
* from the cross-scope check (a second receiver is pointless, not destructive) but still refuse
|
|
22
|
+
* same-scope duplicates.
|
|
23
|
+
*
|
|
24
|
+
* `restart --drain` composes the README's manual ritual (pause → poll active → restart → resume) with
|
|
25
|
+
* the same VALKEY_URL-only queue connection as cli.mjs's pause/resume: the drain must work even when
|
|
26
|
+
* the rest of the config is broken.
|
|
27
|
+
*/
|
|
28
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
29
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
30
|
+
import { homedir, tmpdir, userInfo } from "node:os";
|
|
31
|
+
import { dirname, join, resolve } from "node:path";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
33
|
+
import { parseArgs } from "node:util";
|
|
34
|
+
|
|
35
|
+
// Deploy templates resolved relative to this module (the init.mjs pattern): worker/deploy is SHIPPED
|
|
36
|
+
// in the npm tarball and kept byte-identical to the repo-root deploy/ (the documented source) by
|
|
37
|
+
// worker/test/publish.test.mjs — so `service` renders the same templates from a checkout and from an
|
|
38
|
+
// npm install, no matter where the CLI is invoked from.
|
|
39
|
+
const DEPLOY_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "deploy");
|
|
40
|
+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The whole substitution surface, template by template. The render replaces ONLY these literals (plus
|
|
44
|
+
* the scope-dependent `User=` / `WantedBy=` rewrites called out below); the pin test in
|
|
45
|
+
* worker/test/service.test.mjs asserts each one is still present in the real deploy/ file, so an edit
|
|
46
|
+
* to a template that would break the render fails the build instead of shipping a broken `service`.
|
|
47
|
+
*/
|
|
48
|
+
export const TEMPLATE_PINS = {
|
|
49
|
+
"worker.service": [
|
|
50
|
+
"ExecStart=/usr/bin/node worker/src/cli.mjs worker", // /usr/bin/node → process.execPath
|
|
51
|
+
"WorkingDirectory=/opt/pi-dispatch", // /opt/pi-dispatch → the real repo root
|
|
52
|
+
"EnvironmentFile=/opt/pi-dispatch/.env",
|
|
53
|
+
"\nUser=pi\n", // the DIRECTIVE line (the header comment also says User=pi mid-line, hence the \n anchors): stripped for --user scope; rewritten to the invoking user for --system
|
|
54
|
+
"WantedBy=multi-user.target", // → default.target in user scope (multi-user.target never runs there)
|
|
55
|
+
// Byte-for-byte survivors — semantics the render must not lose:
|
|
56
|
+
"RestartPreventExitStatus=2", // EXIT_POLICY is never restarted (a retry loop is a bill)
|
|
57
|
+
"StartLimitIntervalSec=60",
|
|
58
|
+
"StartLimitBurst=5",
|
|
59
|
+
"KillSignal=SIGTERM",
|
|
60
|
+
"TimeoutStopSec=30",
|
|
61
|
+
],
|
|
62
|
+
"receiver.service": [
|
|
63
|
+
"ExecStart=/usr/bin/node receiver/src/start.mjs",
|
|
64
|
+
"WorkingDirectory=/opt/pi-dispatch",
|
|
65
|
+
"EnvironmentFile=/opt/pi-dispatch/.env",
|
|
66
|
+
"\nUser=pi\n",
|
|
67
|
+
"WantedBy=multi-user.target",
|
|
68
|
+
"KillSignal=SIGTERM",
|
|
69
|
+
"TimeoutStopSec=30",
|
|
70
|
+
],
|
|
71
|
+
"com.pi-dispatch.worker.plist": [
|
|
72
|
+
"<string>com.pi-dispatch.worker</string>", // → com.pi-dispatch.receiver for --receiver
|
|
73
|
+
"<string>/opt/pi-dispatch/deploy/worker-env-wrapper.sh</string>", // gains a `receiver` argument for --receiver
|
|
74
|
+
"<key>WorkingDirectory</key>\n\t<string>/opt/pi-dispatch</string>", // anchor for the PATH injection below
|
|
75
|
+
"<string>/opt/pi-dispatch/logs/worker.out.log</string>",
|
|
76
|
+
"<string>/opt/pi-dispatch/logs/worker.err.log</string>",
|
|
77
|
+
"<key>SuccessfulExit</key>", // the KeepAlive shape the wrapper's exit-2 conversion pairs with
|
|
78
|
+
"<integer>30</integer>", // ExitTimeOut — room for the SIGTERM drain
|
|
79
|
+
],
|
|
80
|
+
// Windows is command-driven, not file-rendered: install SPAWNS nssmSequence() below instead of
|
|
81
|
+
// copying the .cmd. These pins hold the worked example to the same values the sequence uses, so
|
|
82
|
+
// the two cannot drift apart — especially the AppExit pair, which is the EXIT_POLICY never-retry.
|
|
83
|
+
"nssm-install.cmd": [
|
|
84
|
+
"pi-dispatch-worker",
|
|
85
|
+
"C:\\pi-dispatch", // the REPO placeholder → the real repo root
|
|
86
|
+
"deploy\\worker-env-wrapper.cmd",
|
|
87
|
+
"AppStopMethodConsole 15000",
|
|
88
|
+
"AppThrottle 5000",
|
|
89
|
+
"AppExit Default Restart",
|
|
90
|
+
"AppExit 2 Exit",
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const SUBCOMMANDS = new Set(["render", "install", "uninstall", "status", "start", "stop", "restart"]);
|
|
95
|
+
|
|
96
|
+
const SERVICE_USAGE = `pi-dispatch service — run the worker (or --receiver) as an OS service, rendered for THIS host
|
|
97
|
+
|
|
98
|
+
pi-dispatch service render the unit(s) with this host's real node + repo paths
|
|
99
|
+
pi-dispatch service install [--force] write + enable the user-level unit
|
|
100
|
+
(linux --system: prints the exact sudo commands, runs nothing)
|
|
101
|
+
pi-dispatch service uninstall stop, disable and remove the installed unit
|
|
102
|
+
pi-dispatch service status which unit exists in which scope, and whether it is active
|
|
103
|
+
pi-dispatch service start|stop thin launchctl / systemctl --user / nssm wrappers
|
|
104
|
+
pi-dispatch service restart [--drain] restart; --drain pauses the queue, waits for active jobs
|
|
105
|
+
to finish, restarts, resumes [--drain-timeout <s>, default 600]
|
|
106
|
+
|
|
107
|
+
flags: --receiver the webhook receiver instead of the worker
|
|
108
|
+
--user | --system linux scope (default --user; --system never executes root commands)
|
|
109
|
+
--force replace an existing unit in the same scope
|
|
110
|
+
--print also print the rendered unit before installing
|
|
111
|
+
`;
|
|
112
|
+
|
|
113
|
+
export async function runService(argv = [], deps = {}) {
|
|
114
|
+
const {
|
|
115
|
+
env = process.env,
|
|
116
|
+
platform = process.platform,
|
|
117
|
+
euid = typeof process.geteuid === "function" ? process.geteuid() : null,
|
|
118
|
+
execPath = process.execPath,
|
|
119
|
+
repoRoot = REPO_ROOT,
|
|
120
|
+
home = homedir(),
|
|
121
|
+
user = env.USER || userInfo().username,
|
|
122
|
+
tmp = tmpdir(),
|
|
123
|
+
fs = { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync },
|
|
124
|
+
spawn = nodeSpawn,
|
|
125
|
+
out = (s) => process.stdout.write(s),
|
|
126
|
+
err = (s) => process.stderr.write(s),
|
|
127
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
128
|
+
now = () => Date.now(),
|
|
129
|
+
queue = null, // test seam; production builds one lazily in doRestart from VALKEY_URL
|
|
130
|
+
} = deps;
|
|
131
|
+
|
|
132
|
+
let values, positionals;
|
|
133
|
+
try {
|
|
134
|
+
({ values, positionals } = parseArgs({
|
|
135
|
+
args: argv,
|
|
136
|
+
allowPositionals: true,
|
|
137
|
+
options: {
|
|
138
|
+
receiver: { type: "boolean", default: false },
|
|
139
|
+
user: { type: "boolean", default: false },
|
|
140
|
+
system: { type: "boolean", default: false },
|
|
141
|
+
force: { type: "boolean", default: false },
|
|
142
|
+
print: { type: "boolean", default: false },
|
|
143
|
+
drain: { type: "boolean", default: false },
|
|
144
|
+
"drain-timeout": { type: "string" },
|
|
145
|
+
},
|
|
146
|
+
}));
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return fail(err, error.message);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const cmd = positionals[0];
|
|
152
|
+
if (!cmd || !SUBCOMMANDS.has(cmd)) {
|
|
153
|
+
out(SERVICE_USAGE);
|
|
154
|
+
return cmd ? 1 : 0; // bare `service` is a usage view, an unknown subcommand is an error
|
|
155
|
+
}
|
|
156
|
+
if (values.user && values.system) return fail(err, "--user and --system are mutually exclusive");
|
|
157
|
+
if (platform === "darwin" && values.system) {
|
|
158
|
+
return fail(err, "macOS is user-scope only (a LaunchAgent): Docker Desktop lives in the login session, so a system daemon would wait on a docker socket that only exists once you log in. Drop --system.");
|
|
159
|
+
}
|
|
160
|
+
if (platform === "win32" && (values.user || values.system)) {
|
|
161
|
+
return fail(err, "Windows services via nssm are machine-scoped; --user/--system do not apply here");
|
|
162
|
+
}
|
|
163
|
+
if (!["darwin", "linux", "win32"].includes(platform)) return fail(err, `unsupported platform: ${platform}`);
|
|
164
|
+
|
|
165
|
+
const ctx = {
|
|
166
|
+
env,
|
|
167
|
+
platform,
|
|
168
|
+
euid,
|
|
169
|
+
execPath,
|
|
170
|
+
repoRoot,
|
|
171
|
+
home,
|
|
172
|
+
user,
|
|
173
|
+
tmp,
|
|
174
|
+
fs,
|
|
175
|
+
spawn,
|
|
176
|
+
out,
|
|
177
|
+
err,
|
|
178
|
+
sleep,
|
|
179
|
+
now,
|
|
180
|
+
queue,
|
|
181
|
+
which: values.receiver ? "receiver" : "worker",
|
|
182
|
+
scope: platform === "linux" && values.system ? "system" : "user",
|
|
183
|
+
force: values.force,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
switch (cmd) {
|
|
187
|
+
case "render":
|
|
188
|
+
return doRender(ctx);
|
|
189
|
+
case "install":
|
|
190
|
+
// --print is implied for render and opt-in here: see what will be written, then write it.
|
|
191
|
+
if (values.print) doRender(ctx);
|
|
192
|
+
return doInstall(ctx);
|
|
193
|
+
case "uninstall":
|
|
194
|
+
return doUninstall(ctx);
|
|
195
|
+
case "status":
|
|
196
|
+
return doStatus(ctx);
|
|
197
|
+
case "start":
|
|
198
|
+
return doStart(ctx);
|
|
199
|
+
case "stop":
|
|
200
|
+
return doStop(ctx);
|
|
201
|
+
case "restart":
|
|
202
|
+
return doRestart(ctx, values);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Where each unit lives (or is looked for) per platform. `otherScopeWorkerPaths` exists only to
|
|
208
|
+
* enforce DES-CONCURRENCY-3 at install time — those locations are READ, never written. On Linux the
|
|
209
|
+
* system-scope check covers both our canonical name and `worker.service`, the name the README's
|
|
210
|
+
* manual `sudo cp` instructions produce: a hand-installed worker is still a second worker.
|
|
211
|
+
*/
|
|
212
|
+
function unitPaths(ctx) {
|
|
213
|
+
if (ctx.platform === "darwin") {
|
|
214
|
+
const label = `com.pi-dispatch.${ctx.which}`;
|
|
215
|
+
return {
|
|
216
|
+
name: label,
|
|
217
|
+
installPath: join(ctx.home, "Library", "LaunchAgents", `${label}.plist`),
|
|
218
|
+
systemPath: join("/Library/LaunchDaemons", `${label}.plist`),
|
|
219
|
+
otherScopeWorkerPaths: ["/Library/LaunchDaemons/com.pi-dispatch.worker.plist"],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (ctx.platform === "linux") {
|
|
223
|
+
const unit = `pi-dispatch-${ctx.which}.service`;
|
|
224
|
+
const userPath = join(ctx.home, ".config", "systemd", "user", unit);
|
|
225
|
+
const systemPath = join("/etc/systemd/system", unit);
|
|
226
|
+
return {
|
|
227
|
+
name: unit,
|
|
228
|
+
userPath,
|
|
229
|
+
systemPath,
|
|
230
|
+
installPath: ctx.scope === "system" ? systemPath : userPath,
|
|
231
|
+
otherScopeWorkerPaths:
|
|
232
|
+
ctx.scope === "system"
|
|
233
|
+
? [join(ctx.home, ".config", "systemd", "user", "pi-dispatch-worker.service")]
|
|
234
|
+
: ["/etc/systemd/system/pi-dispatch-worker.service", "/etc/systemd/system/worker.service"],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
// win32: the unit is an nssm-registered service, not a file this tool addresses. Same-scope
|
|
238
|
+
// detection happens via `nssm status`, and there is no second scope to cross-check.
|
|
239
|
+
return { name: `pi-dispatch-${ctx.which}` };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function readTemplate(ctx, name) {
|
|
243
|
+
return ctx.fs.readFileSync(join(DEPLOY_DIR, name), "utf8");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Render worker.service / receiver.service for this host. Targeted substitution of the templates'
|
|
248
|
+
* known literals (see TEMPLATE_PINS); everything else — RestartPreventExitStatus=2, the StartLimit
|
|
249
|
+
* crash-loop bound, KillSignal, TimeoutStopSec — passes through byte-for-byte.
|
|
250
|
+
*/
|
|
251
|
+
function renderLinuxUnit(ctx) {
|
|
252
|
+
const template = ctx.which === "receiver" ? "receiver.service" : "worker.service";
|
|
253
|
+
// The banner outranks the template's own "TEMPLATE/UNTESTED EXAMPLE — set the PLACEHOLDERs" header,
|
|
254
|
+
// which renders through below (the no-markers design keeps templates byte-usable, so their prose
|
|
255
|
+
// survives): a reader of the rendered unit should know the placeholders are already substituted.
|
|
256
|
+
let unit = `# rendered by \`pi-dispatch service\` — paths computed for this host from deploy/${template};\n# the template's PLACEHOLDER prose below is already substituted.\n` +
|
|
257
|
+
readTemplate(ctx, template)
|
|
258
|
+
.replaceAll("/opt/pi-dispatch", ctx.repoRoot)
|
|
259
|
+
.replaceAll("/usr/bin/node", ctx.execPath);
|
|
260
|
+
if (ctx.scope === "user") {
|
|
261
|
+
// A systemd --user unit always runs as the invoking user, and systemd REJECTS a User= line in
|
|
262
|
+
// user scope ("Unknown lvalue"); the line must not survive the render. The replacement is a
|
|
263
|
+
// comment so the rendered unit explains its own difference from the shipped template. Anchored
|
|
264
|
+
// to the whole line (^…$) because the template's header comment ALSO says "User=pi" mid-line —
|
|
265
|
+
// a bare replace would rewrite the prose and leave the directive standing.
|
|
266
|
+
unit = unit.replace(
|
|
267
|
+
/^User=pi$/m,
|
|
268
|
+
"# User= stripped by `pi-dispatch service`: a --user unit always runs as the invoking user,\n# and systemd rejects User= in user scope.",
|
|
269
|
+
);
|
|
270
|
+
// multi-user.target exists only in the SYSTEM instance. A user unit enabled into it would
|
|
271
|
+
// symlink into a .wants/ directory no user-instance boot ever walks — enabled but never
|
|
272
|
+
// started. default.target is the user manager's boot target.
|
|
273
|
+
unit = unit.replace("WantedBy=multi-user.target", "WantedBy=default.target");
|
|
274
|
+
} else {
|
|
275
|
+
unit = unit.replace(/^User=pi$/m, `User=${ctx.user}`);
|
|
276
|
+
}
|
|
277
|
+
return unit;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Render the launchd plist for this host. For --receiver the worker plist is DERIVED, not a second
|
|
282
|
+
* template: same KeepAlive/ExitTimeOut shape, label and log names swapped, and the shared wrapper told
|
|
283
|
+
* (via its one argument) to run the receiver. The wrapper's exit-2 conversion is a no-op for the
|
|
284
|
+
* receiver — it has no EXIT_POLICY — and harmless.
|
|
285
|
+
*/
|
|
286
|
+
function renderPlist(ctx) {
|
|
287
|
+
let plist = readTemplate(ctx, "com.pi-dispatch.worker.plist");
|
|
288
|
+
if (ctx.which === "receiver") {
|
|
289
|
+
plist = plist
|
|
290
|
+
.replace("<string>com.pi-dispatch.worker</string>", "<string>com.pi-dispatch.receiver</string>")
|
|
291
|
+
.replace(
|
|
292
|
+
"<string>/opt/pi-dispatch/deploy/worker-env-wrapper.sh</string>",
|
|
293
|
+
"<string>/opt/pi-dispatch/deploy/worker-env-wrapper.sh</string>\n\t\t<!-- the argument that makes the shared .env wrapper run the receiver instead of the worker -->\n\t\t<string>receiver</string>",
|
|
294
|
+
)
|
|
295
|
+
.replaceAll("worker.out.log", "receiver.out.log")
|
|
296
|
+
.replaceAll("worker.err.log", "receiver.err.log");
|
|
297
|
+
}
|
|
298
|
+
// launchd's default PATH is /usr/bin:/bin — an nvm or Homebrew node is invisible to it, and the
|
|
299
|
+
// wrapper invokes bare `node`. Prepend the directory of the node that ran this render so the
|
|
300
|
+
// service runs the SAME binary, no guessing. PATH is configuration, not a secret: the template's
|
|
301
|
+
// deliberate no-EnvironmentVariables stance is about credentials, which still live only in .env.
|
|
302
|
+
plist = plist.replace(
|
|
303
|
+
"<key>WorkingDirectory</key>\n\t<string>/opt/pi-dispatch</string>",
|
|
304
|
+
"<key>WorkingDirectory</key>\n\t<string>/opt/pi-dispatch</string>\n\n\t<!-- Injected by `pi-dispatch service`: launchd's default PATH cannot see an nvm/Homebrew node,\n\t and the wrapper calls bare `node`. Not a secrets dict - credentials still live only in .env\n\t (see the header comment). -->\n\t<key>EnvironmentVariables</key>\n\t<dict>\n\t\t<key>PATH</key>\n\t\t<string>" +
|
|
305
|
+
`${dirname(ctx.execPath)}:/usr/bin:/bin:/usr/sbin:/sbin</string>\n\t</dict>`,
|
|
306
|
+
);
|
|
307
|
+
return plist.replaceAll("/opt/pi-dispatch", ctx.repoRoot);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The nssm command sequence — deploy/nssm-install.cmd's exact steps with computed paths. Values that
|
|
312
|
+
* carry semantics (AppStopMethodConsole 15000, AppThrottle 5000, AppExit Default Restart, AppExit 2
|
|
313
|
+
* Exit) mirror the template byte-for-byte and are pinned. Backslashes on purpose: this argv reaches
|
|
314
|
+
* nssm on a real Windows host, where repoRoot is already a Windows path.
|
|
315
|
+
*/
|
|
316
|
+
function nssmSequence(ctx) {
|
|
317
|
+
const service = `pi-dispatch-${ctx.which}`;
|
|
318
|
+
const logDir = `${ctx.repoRoot}\\logs`;
|
|
319
|
+
return {
|
|
320
|
+
service,
|
|
321
|
+
commands: [
|
|
322
|
+
["install", service, `${ctx.repoRoot}\\deploy\\worker-env-wrapper.cmd`, ...(ctx.which === "receiver" ? ["receiver"] : [])],
|
|
323
|
+
["set", service, "AppDirectory", ctx.repoRoot],
|
|
324
|
+
["set", service, "AppStdout", `${logDir}\\${ctx.which}.out.log`],
|
|
325
|
+
["set", service, "AppStderr", `${logDir}\\${ctx.which}.err.log`],
|
|
326
|
+
["set", service, "AppStopMethodConsole", "15000"],
|
|
327
|
+
["set", service, "AppThrottle", "5000"],
|
|
328
|
+
["set", service, "AppExit", "Default", "Restart"],
|
|
329
|
+
["set", service, "AppExit", "2", "Exit"],
|
|
330
|
+
],
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function doRender(ctx) {
|
|
335
|
+
const paths = unitPaths(ctx);
|
|
336
|
+
if (ctx.platform === "darwin") {
|
|
337
|
+
ctx.out(`# → ${paths.installPath}\n`);
|
|
338
|
+
ctx.out(renderPlist(ctx));
|
|
339
|
+
ctx.out(
|
|
340
|
+
"\n# note: ProgramArguments runs deploy/worker-env-wrapper.sh — launchd has no EnvironmentFile,\n# so the wrapper loads .env at runtime. The wrapper also converts a policy refusal (exit 2,\n# EXIT_POLICY) into a clean exit, so KeepAlive never relaunch-loops a refusal into a provider bill.\n",
|
|
341
|
+
);
|
|
342
|
+
return 0;
|
|
343
|
+
}
|
|
344
|
+
if (ctx.platform === "linux") {
|
|
345
|
+
ctx.out(`# → ${paths.installPath}\n`);
|
|
346
|
+
ctx.out(renderLinuxUnit(ctx));
|
|
347
|
+
return 0;
|
|
348
|
+
}
|
|
349
|
+
const { service, commands } = nssmSequence(ctx);
|
|
350
|
+
ctx.out("The nssm sequence for this host (nssm.exe: https://nssm.cc, or `winget install nssm`):\n\n");
|
|
351
|
+
for (const args of commands) ctx.out(` nssm ${quoteArgs(args)}\n`);
|
|
352
|
+
ctx.out(` nssm start ${service}\n`);
|
|
353
|
+
return 0;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function doInstall(ctx) {
|
|
357
|
+
const paths = unitPaths(ctx);
|
|
358
|
+
|
|
359
|
+
// THE refusal, worker only: one worker per docker daemon (DES-CONCURRENCY-3). The worker's boot
|
|
360
|
+
// reaper treats every running pi-job container as an orphan of its OWN previous life and kills it,
|
|
361
|
+
// so a second worker — even in the other scope — would reap the first worker's live jobs on every
|
|
362
|
+
// restart. Receivers are exempt: a second receiver is pointless, not destructive.
|
|
363
|
+
if (ctx.which === "worker") {
|
|
364
|
+
const other = paths.otherScopeWorkerPaths?.find((p) => ctx.fs.existsSync(p));
|
|
365
|
+
if (other) {
|
|
366
|
+
return fail(
|
|
367
|
+
ctx.err,
|
|
368
|
+
`a worker unit already exists in the other scope: ${other}\n` +
|
|
369
|
+
"one worker per docker daemon (DES-CONCURRENCY-3): the worker's boot reaper kills every pi-job container it did not start, so a second worker would kill the first's live jobs. Remove that unit first.",
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (ctx.platform === "darwin") return installDarwin(ctx, paths);
|
|
375
|
+
if (ctx.platform === "linux") return ctx.scope === "system" ? installLinuxSystem(ctx, paths) : installLinuxUser(ctx, paths);
|
|
376
|
+
return installWindows(ctx);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function installDarwin(ctx, paths) {
|
|
380
|
+
// Root refusal (the svc.sh darwin precedent): sudo would bootstrap into ROOT's gui domain and
|
|
381
|
+
// write root's LaunchAgents — a unit the operator's own session neither sees nor controls, running
|
|
382
|
+
// outside the login session that owns Docker Desktop.
|
|
383
|
+
if (ctx.euid === 0) {
|
|
384
|
+
return fail(
|
|
385
|
+
ctx.err,
|
|
386
|
+
"refusing to run as root on macOS: the unit belongs in YOUR ~/Library/LaunchAgents, bootstrapped into your gui domain — sudo would install it for root, outside the login session that owns Docker Desktop. Re-run without sudo.",
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
const existed = ctx.fs.existsSync(paths.installPath);
|
|
390
|
+
if (existed && !ctx.force) {
|
|
391
|
+
return fail(ctx.err, `${paths.installPath} already exists — pass --force to replace and re-bootstrap it (same non-clobber contract as init)`);
|
|
392
|
+
}
|
|
393
|
+
if (existed) {
|
|
394
|
+
// --force replaces a possibly-loaded unit: bootstrap refuses an already-loaded label, so boot
|
|
395
|
+
// the old copy out first. "Not loaded" is a fine answer — the nonzero exit is ignored.
|
|
396
|
+
await run(ctx, "launchctl", ["bootout", `gui/${ctx.euid}/${paths.name}`]);
|
|
397
|
+
}
|
|
398
|
+
ctx.fs.mkdirSync(dirname(paths.installPath), { recursive: true });
|
|
399
|
+
ctx.fs.writeFileSync(paths.installPath, renderPlist(ctx));
|
|
400
|
+
const bootstrap = await run(ctx, "launchctl", ["bootstrap", `gui/${ctx.euid}`, paths.installPath]);
|
|
401
|
+
if (bootstrap !== 0) {
|
|
402
|
+
return fail(ctx.err, `launchctl bootstrap failed (exit ${bootstrap}) — the plist is written; retry by hand: launchctl bootstrap gui/${ctx.euid} ${paths.installPath}`);
|
|
403
|
+
}
|
|
404
|
+
const enable = await run(ctx, "launchctl", ["enable", `gui/${ctx.euid}/${paths.name}`]);
|
|
405
|
+
if (enable !== 0) return fail(ctx.err, `launchctl enable gui/${ctx.euid}/${paths.name} failed (exit ${enable})`);
|
|
406
|
+
ctx.out(`installed ${paths.name} → ${paths.installPath} (bootstrapped into gui/${ctx.euid}; RunAtLoad starts it now and on login)\n`);
|
|
407
|
+
// The honest note: no pretending a LaunchAgent is a boot daemon. It is the right fit anyway.
|
|
408
|
+
ctx.out(
|
|
409
|
+
"note: a LaunchAgent is LOGIN-scoped — it runs while you are logged in, not from boot. That is the honest fit here: Docker Desktop is itself login-scoped, so a boot-time daemon would only wait on a docker socket that appears at login anyway.\n",
|
|
410
|
+
);
|
|
411
|
+
return 0;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function installLinuxUser(ctx, paths) {
|
|
415
|
+
if (ctx.fs.existsSync(paths.installPath) && !ctx.force) {
|
|
416
|
+
return fail(ctx.err, `${paths.installPath} already exists — pass --force to replace it (same non-clobber contract as init)`);
|
|
417
|
+
}
|
|
418
|
+
ctx.fs.mkdirSync(dirname(paths.installPath), { recursive: true });
|
|
419
|
+
ctx.fs.writeFileSync(paths.installPath, renderLinuxUnit(ctx));
|
|
420
|
+
const reload = await run(ctx, "systemctl", ["--user", "daemon-reload"]);
|
|
421
|
+
if (reload === null) return fail(ctx.err, `systemctl not found — is this a systemd host? The unit is written at ${paths.installPath}`);
|
|
422
|
+
const enable = await run(ctx, "systemctl", ["--user", "enable", "--now", paths.name]);
|
|
423
|
+
if (enable !== 0) {
|
|
424
|
+
return fail(ctx.err, `systemctl --user enable --now ${paths.name} failed (exit ${enable}) — the unit is written at ${paths.installPath}; \`systemctl --user status ${paths.name}\` has the details`);
|
|
425
|
+
}
|
|
426
|
+
ctx.out(`installed ${paths.name} → ${paths.installPath} (enabled and started in your user manager)\n`);
|
|
427
|
+
// Without linger a user manager only runs while a session exists — fine on a desktop, a silent
|
|
428
|
+
// no-worker-after-reboot on a headless box. Say so instead of letting the operator find out.
|
|
429
|
+
ctx.out(`note: user units run while you have a session. For a headless host that must start at boot: sudo loginctl enable-linger ${ctx.user}\n`);
|
|
430
|
+
return 0;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function installLinuxSystem(ctx, paths) {
|
|
434
|
+
if (ctx.fs.existsSync(paths.installPath) && !ctx.force) {
|
|
435
|
+
return fail(ctx.err, `${paths.installPath} already exists — pass --force to re-stage the render (the printed sudo commands would overwrite it)`);
|
|
436
|
+
}
|
|
437
|
+
// The pm2-startup pattern: this tool NEVER writes or spawns as root. The render is staged where
|
|
438
|
+
// the operator can read it, and the exact commands are printed — their shell is the consent gate.
|
|
439
|
+
const staged = join(ctx.tmp, paths.name);
|
|
440
|
+
ctx.fs.writeFileSync(staged, renderLinuxUnit(ctx));
|
|
441
|
+
ctx.out(`--system never runs root commands from this tool. The rendered unit is staged at:\n ${staged}\n\nInspect it, then run:\n sudo install -m 644 ${staged} ${paths.installPath}\n sudo systemctl daemon-reload\n sudo systemctl enable --now ${paths.name}\n`);
|
|
442
|
+
return 0;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function installWindows(ctx) {
|
|
446
|
+
const { service, commands } = nssmSequence(ctx);
|
|
447
|
+
// One probe answers two questions: is nssm on PATH (ENOENT → no), and does the service already
|
|
448
|
+
// exist (exit 0 → yes). Task Scheduler is deliberately never offered — it stops tasks with a hard
|
|
449
|
+
// kill, so the worker could never drain (see deploy/nssm-install.cmd's rationale).
|
|
450
|
+
const status = await runCapture(ctx, "nssm", ["status", service]);
|
|
451
|
+
if (status.code === null) {
|
|
452
|
+
return fail(ctx.err, "nssm.exe not found on PATH — download it from https://nssm.cc (or `winget install nssm`) and re-run. Task Scheduler is not a substitute: it kills instead of stopping, so the worker could never drain.");
|
|
453
|
+
}
|
|
454
|
+
if (status.code === 0 && !ctx.force) {
|
|
455
|
+
return fail(ctx.err, `service ${service} already exists (nssm status reports it) — pass --force to remove and re-install it`);
|
|
456
|
+
}
|
|
457
|
+
if (status.code === 0) {
|
|
458
|
+
await run(ctx, "nssm", ["stop", service]); // may already be stopped; nonzero is fine
|
|
459
|
+
const removed = await run(ctx, "nssm", ["remove", service, "confirm"]);
|
|
460
|
+
if (removed !== 0) return fail(ctx.err, `nssm remove ${service} confirm failed (exit ${removed})`);
|
|
461
|
+
}
|
|
462
|
+
for (const args of commands) {
|
|
463
|
+
const code = await run(ctx, "nssm", args);
|
|
464
|
+
if (code !== 0) return fail(ctx.err, `nssm ${args.join(" ")} failed (exit ${code})`);
|
|
465
|
+
}
|
|
466
|
+
// Mirrors the template's own last line: install registers, `nssm start` is the one visible step
|
|
467
|
+
// left to the operator (the service auto-starts on the next boot either way).
|
|
468
|
+
ctx.out(`installed service ${service}. Start it with: nssm start ${service}\n`);
|
|
469
|
+
return 0;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function doUninstall(ctx) {
|
|
473
|
+
const paths = unitPaths(ctx);
|
|
474
|
+
if (ctx.platform === "win32") {
|
|
475
|
+
const status = await runCapture(ctx, "nssm", ["status", paths.name]);
|
|
476
|
+
if (status.code === null) return fail(ctx.err, "nssm.exe not found on PATH — it is also how uninstall talks to the service manager (https://nssm.cc)");
|
|
477
|
+
if (status.code !== 0) return fail(ctx.err, `service ${paths.name} is not installed (\`nssm status ${paths.name}\` reports none)`);
|
|
478
|
+
await run(ctx, "nssm", ["stop", paths.name]); // already-stopped is fine
|
|
479
|
+
const removed = await run(ctx, "nssm", ["remove", paths.name, "confirm"]);
|
|
480
|
+
if (removed !== 0) return fail(ctx.err, `nssm remove ${paths.name} confirm failed (exit ${removed})`);
|
|
481
|
+
ctx.out(`uninstalled service ${paths.name}\n`);
|
|
482
|
+
return 0;
|
|
483
|
+
}
|
|
484
|
+
const userPath = ctx.platform === "darwin" ? paths.installPath : paths.userPath;
|
|
485
|
+
if (!ctx.fs.existsSync(userPath)) {
|
|
486
|
+
// Say where it looked — both scopes — and if the unit turns out to live in ROOT scope, print
|
|
487
|
+
// the removal commands instead of touching them (the same never-root doctrine as install).
|
|
488
|
+
if (ctx.fs.existsSync(paths.systemPath)) {
|
|
489
|
+
const rootCmds =
|
|
490
|
+
ctx.platform === "darwin"
|
|
491
|
+
? ` sudo launchctl bootout system/${paths.name}\n sudo rm ${paths.systemPath}`
|
|
492
|
+
: ` sudo systemctl disable --now ${paths.name}\n sudo rm ${paths.systemPath}\n sudo systemctl daemon-reload`;
|
|
493
|
+
return fail(ctx.err, `not installed in user scope (looked at ${userPath}); a SYSTEM-scope unit exists at ${paths.systemPath} — this tool never touches root scope. Remove it with:\n${rootCmds}`);
|
|
494
|
+
}
|
|
495
|
+
return fail(ctx.err, `${paths.name} is not installed — looked at ${userPath} (user scope) and ${paths.systemPath} (system scope)`);
|
|
496
|
+
}
|
|
497
|
+
if (ctx.platform === "darwin") {
|
|
498
|
+
await run(ctx, "launchctl", ["bootout", `gui/${ctx.euid}/${paths.name}`]); // not-loaded is fine
|
|
499
|
+
ctx.fs.unlinkSync(userPath);
|
|
500
|
+
ctx.out(`uninstalled ${paths.name} (booted out of gui/${ctx.euid}, plist removed)\n`);
|
|
501
|
+
return 0;
|
|
502
|
+
}
|
|
503
|
+
await run(ctx, "systemctl", ["--user", "disable", "--now", paths.name]); // not-enabled is fine
|
|
504
|
+
ctx.fs.unlinkSync(userPath);
|
|
505
|
+
await run(ctx, "systemctl", ["--user", "daemon-reload"]);
|
|
506
|
+
ctx.out(`uninstalled ${paths.name} (disabled, stopped, unit removed)\n`);
|
|
507
|
+
return 0;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Informational only — reports every scope it knows about and always exits 0. */
|
|
511
|
+
async function doStatus(ctx) {
|
|
512
|
+
const paths = unitPaths(ctx);
|
|
513
|
+
if (ctx.platform === "win32") {
|
|
514
|
+
const status = await runCapture(ctx, "nssm", ["status", paths.name]);
|
|
515
|
+
if (status.code === null) ctx.out(`${paths.name}: cannot query — nssm.exe not on PATH (https://nssm.cc)\n`);
|
|
516
|
+
else if (status.code !== 0) ctx.out(`${paths.name}: not installed\n`);
|
|
517
|
+
else ctx.out(`${paths.name}: ${status.output.trim()}\n`);
|
|
518
|
+
return 0;
|
|
519
|
+
}
|
|
520
|
+
if (ctx.platform === "darwin") {
|
|
521
|
+
if (ctx.fs.existsSync(paths.installPath)) {
|
|
522
|
+
const print = await runCapture(ctx, "launchctl", ["print", `gui/${ctx.euid}/${paths.name}`]);
|
|
523
|
+
ctx.out(`user scope: ${paths.installPath} — ${print.code === 0 ? "loaded" : "installed but NOT loaded (launchctl bootstrap it, or `pi-dispatch service start`)"}\n`);
|
|
524
|
+
} else {
|
|
525
|
+
ctx.out(`user scope: not installed (${paths.installPath})\n`);
|
|
526
|
+
}
|
|
527
|
+
ctx.out(ctx.fs.existsSync(paths.systemPath) ? `system scope: ${paths.systemPath} EXISTS — not managed by this tool\n` : "system scope: none\n");
|
|
528
|
+
return 0;
|
|
529
|
+
}
|
|
530
|
+
if (ctx.fs.existsSync(paths.userPath)) {
|
|
531
|
+
const active = await runCapture(ctx, "systemctl", ["--user", "is-active", paths.name]);
|
|
532
|
+
ctx.out(`user scope: ${paths.userPath} — ${active.code === null ? "systemctl not found" : active.output.trim() || "unknown"}\n`);
|
|
533
|
+
} else {
|
|
534
|
+
ctx.out(`user scope: not installed (${paths.userPath})\n`);
|
|
535
|
+
}
|
|
536
|
+
const systemHits = [paths.systemPath, ...(ctx.which === "worker" ? ["/etc/systemd/system/worker.service"] : [])].filter((p) => ctx.fs.existsSync(p));
|
|
537
|
+
ctx.out(systemHits.length ? `system scope: ${systemHits.join(", ")} EXISTS — not managed by this tool\n` : "system scope: none\n");
|
|
538
|
+
return 0;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function doStart(ctx) {
|
|
542
|
+
return startStop(ctx, "start");
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function doStop(ctx) {
|
|
546
|
+
return startStop(ctx, "stop");
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Thin per-OS start/stop. macOS stop is `launchctl kill SIGTERM`, NOT bootout: bootout unloads the
|
|
551
|
+
* unit entirely, while kill delivers the same graceful SIGTERM systemd's stop does — the worker
|
|
552
|
+
* drains, exits 0, and KeepAlive's SuccessfulExit=false leaves a clean exit stopped.
|
|
553
|
+
*/
|
|
554
|
+
async function startStop(ctx, verb) {
|
|
555
|
+
const paths = unitPaths(ctx);
|
|
556
|
+
if (ctx.platform === "linux" && ctx.scope === "system") {
|
|
557
|
+
return fail(ctx.err, `--system is print-only (this tool never runs root commands). Run:\n sudo systemctl ${verb} ${paths.name}`);
|
|
558
|
+
}
|
|
559
|
+
let code;
|
|
560
|
+
if (ctx.platform === "darwin") {
|
|
561
|
+
code =
|
|
562
|
+
verb === "start"
|
|
563
|
+
? await run(ctx, "launchctl", ["kickstart", `gui/${ctx.euid}/${paths.name}`])
|
|
564
|
+
: await run(ctx, "launchctl", ["kill", "SIGTERM", `gui/${ctx.euid}/${paths.name}`]);
|
|
565
|
+
} else if (ctx.platform === "linux") {
|
|
566
|
+
code = await run(ctx, "systemctl", ["--user", verb, paths.name]);
|
|
567
|
+
} else {
|
|
568
|
+
code = await run(ctx, "nssm", [verb, paths.name]);
|
|
569
|
+
}
|
|
570
|
+
if (code !== 0) return fail(ctx.err, `${verb} ${paths.name} failed (${code === null ? "service tool not found" : `exit ${code}`}) — is it installed? (pi-dispatch service status)`);
|
|
571
|
+
ctx.out(`${verb === "start" ? "started" : "stopped"} ${paths.name}\n`);
|
|
572
|
+
return 0;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function doRestart(ctx, values) {
|
|
576
|
+
if (!values.drain) {
|
|
577
|
+
const stopped = await doStop(ctx);
|
|
578
|
+
if (stopped !== 0) return stopped;
|
|
579
|
+
return doStart(ctx);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const timeoutS = Number(values["drain-timeout"] ?? "600");
|
|
583
|
+
if (!Number.isFinite(timeoutS) || timeoutS <= 0) {
|
|
584
|
+
return fail(ctx.err, `--drain-timeout must be a positive number of seconds, got: ${values["drain-timeout"]}`);
|
|
585
|
+
}
|
|
586
|
+
let queue = ctx.queue;
|
|
587
|
+
if (!queue) {
|
|
588
|
+
// VALKEY_URL only, exactly like cli.mjs's pause/resume: the drain must work even when the rest
|
|
589
|
+
// of the config (forge auth …) is broken, and failFast keeps a down Valkey an error in seconds
|
|
590
|
+
// instead of a hung restart. Lazy imports for the same reason cli.mjs uses them: `service`
|
|
591
|
+
// subcommands that never touch the queue must not load bullmq/ioredis.
|
|
592
|
+
const url = ctx.env.VALKEY_URL ?? "redis://127.0.0.1:6379";
|
|
593
|
+
const { parseConnection } = await import("./connection.mjs");
|
|
594
|
+
const { makeQueue } = await import("./queue.mjs");
|
|
595
|
+
queue = makeQueue(parseConnection(url, { failFast: true }));
|
|
596
|
+
}
|
|
597
|
+
try {
|
|
598
|
+
await queue.pause();
|
|
599
|
+
ctx.out("paused — no new jobs will start; waiting for active jobs to finish\n");
|
|
600
|
+
const deadline = ctx.now() + timeoutS * 1000;
|
|
601
|
+
let { active = 0 } = await queue.getJobCounts("active");
|
|
602
|
+
while (active > 0) {
|
|
603
|
+
if (ctx.now() >= deadline) {
|
|
604
|
+
// Deliberately NO resume and NO restart: a job is still running. Restarting would abort
|
|
605
|
+
// it; resuming would feed new jobs toward a restart that is still owed. Paused is the
|
|
606
|
+
// safe durable state (it survives the restart the operator will now do by hand).
|
|
607
|
+
ctx.out(
|
|
608
|
+
`drain timed out after ${timeoutS}s with ${active} job(s) still active — NOT restarting.\nThe queue STAYS PAUSED so the running job can finish undisturbed. Investigate (pi-dispatch status), then restart and \`pi-dispatch resume\` yourself.\n`,
|
|
609
|
+
);
|
|
610
|
+
return 1;
|
|
611
|
+
}
|
|
612
|
+
ctx.out(` ${active} active — waiting\n`);
|
|
613
|
+
await ctx.sleep(2000);
|
|
614
|
+
({ active = 0 } = await queue.getJobCounts("active"));
|
|
615
|
+
}
|
|
616
|
+
const stopped = await doStop(ctx);
|
|
617
|
+
if (stopped !== 0) {
|
|
618
|
+
ctx.out("restart did not happen — the queue STAYS PAUSED; fix the service, then `pi-dispatch resume`.\n");
|
|
619
|
+
return 1;
|
|
620
|
+
}
|
|
621
|
+
const started = await doStart(ctx);
|
|
622
|
+
if (started !== 0) {
|
|
623
|
+
ctx.out("the service did not come back — the queue STAYS PAUSED; fix the service, then `pi-dispatch resume`.\n");
|
|
624
|
+
return 1;
|
|
625
|
+
}
|
|
626
|
+
await queue.resume();
|
|
627
|
+
ctx.out("resumed — drained restart complete\n");
|
|
628
|
+
return 0;
|
|
629
|
+
} catch (error) {
|
|
630
|
+
return fail(ctx.err, `could not reach Valkey — is it running? (docker compose up)\n ${error.message}`);
|
|
631
|
+
} finally {
|
|
632
|
+
await queue.close().catch(() => {});
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function fail(err, message) {
|
|
637
|
+
err(`error: ${message}\n`);
|
|
638
|
+
return 1;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** Re-join an argv for display; quote what cmd.exe would split (spaces) or what is a path (backslashes). */
|
|
642
|
+
function quoteArgs(args) {
|
|
643
|
+
return args.map((a) => (a.includes(" ") || a.includes("\\") ? `"${a}"` : a)).join(" ");
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** Exit code of a spawned command; null when it could not launch (not on PATH) — the up.mjs pattern. */
|
|
647
|
+
function run(ctx, cmd, args) {
|
|
648
|
+
return new Promise((resolvePromise) => {
|
|
649
|
+
let child;
|
|
650
|
+
try {
|
|
651
|
+
child = ctx.spawn(cmd, args, { stdio: "ignore" });
|
|
652
|
+
} catch {
|
|
653
|
+
resolvePromise(null);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
child.on("error", () => resolvePromise(null));
|
|
657
|
+
child.on("close", (code) => resolvePromise(code));
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Like run() but with stdout+stderr captured, for read-only lookups (nssm status, is-active …). */
|
|
662
|
+
function runCapture(ctx, cmd, args) {
|
|
663
|
+
return new Promise((resolvePromise) => {
|
|
664
|
+
let child;
|
|
665
|
+
try {
|
|
666
|
+
child = ctx.spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
667
|
+
} catch {
|
|
668
|
+
resolvePromise({ code: null, output: "" });
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
let output = "";
|
|
672
|
+
child.stdout?.on("data", (d) => (output += d));
|
|
673
|
+
child.stderr?.on("data", (d) => (output += d));
|
|
674
|
+
child.on("error", () => resolvePromise({ code: null, output }));
|
|
675
|
+
child.on("close", (code) => resolvePromise({ code, output }));
|
|
676
|
+
});
|
|
677
|
+
}
|