@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
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pi-dispatch import-pi` — stage a CREDENTIAL-FREE copy of the host's `pi` setup into a global overlay
|
|
3
|
+
* dir, so every job can reuse it (REQ-GLOBAL-PI-OVERLAY). Point `PI_GLOBAL_PI_DIR` at the result and the
|
|
4
|
+
* worker mounts it `:ro` into each container, layered UNDER each repo's own `.pi/`.
|
|
5
|
+
*
|
|
6
|
+
* The overlay must never carry a secret (CONST-TOKEN-SCOPED-PER-JOB): the provider key stays in the host's
|
|
7
|
+
* `auth.json` / env and reaches the container through the env allowlist, never a mounted file. So this
|
|
8
|
+
* command copies only the safe subset and REFUSES a `models.json` that embeds a literal key.
|
|
9
|
+
*
|
|
10
|
+
* Copied: models.json (definitions only, sanitized), skills/, APPEND_SYSTEM.md, and extensions/ (verbatim;
|
|
11
|
+
* the admin extension is hard-blocked). Extensions come along BY DEFAULT -- staging is the vetting
|
|
12
|
+
* step, and an overlay missing the operator's own extensions is not the setup they asked for --
|
|
13
|
+
* with `--no-extensions` as the escape hatch. Every extension staged is PRINTED by name, because
|
|
14
|
+
* this is the moment the operator can still see what is about to run inside every job container.
|
|
15
|
+
* Staged: packages/ — only under --with-packages — pinned third-party pi packages, installed here on the
|
|
16
|
+
* host so a job container can load them from the overlay with NO network access (issue #58).
|
|
17
|
+
* Never: auth.json, settings.json, sessions/, themes/, prompts/, tools/.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, copyFileSync, renameSync, rmSync } from "node:fs";
|
|
20
|
+
import { execFile } from "node:child_process";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { promisify } from "node:util";
|
|
24
|
+
import { PACKAGES_SUBDIR, STAGE_MANIFEST, parsePackagesFile } from "./packages.mjs";
|
|
25
|
+
|
|
26
|
+
/** A valid skill/extension entry name: lowercase kebab/underscore, no dots (no "..") and no slashes. */
|
|
27
|
+
export const ENTRY_NAME_RE = /^[a-z0-9](?:[a-z0-9_.-]{0,62}[a-z0-9])?$/i;
|
|
28
|
+
/** The admin extension — never duplicated into a job overlay (it can enqueue paid jobs: a recursion vector). */
|
|
29
|
+
export const ADMIN_RE = /pi-dispatch|dispatch-admin/i;
|
|
30
|
+
|
|
31
|
+
/** The pi resource kinds a package may contribute by convention dir, when it carries no `pi` manifest. */
|
|
32
|
+
const RESOURCE_DIRS = ["extensions", "skills", "prompts", "themes"];
|
|
33
|
+
|
|
34
|
+
const execFileAsync = promisify(execFile);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The default package-stager runner. ARRAY argv, never a shell string -- a package name from a config file
|
|
38
|
+
* must never be able to become shell syntax on the operator's host. See `npmExecOptions` for the one
|
|
39
|
+
* platform on which `shell: true` is nonetheless unavoidable, and why it is safe there.
|
|
40
|
+
*/
|
|
41
|
+
function defaultExec(file, args, options) {
|
|
42
|
+
return execFileAsync(file, args, options);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Resolve the host's pi agent dir the way pi's getAgentDir() does (env override, else ~/.pi/agent).
|
|
46
|
+
// Custom: the worker CLI depends on @earendil-works/pi-ai/compat, not the whole pi-coding-agent SDK;
|
|
47
|
+
// importing the SDK just to read one well-known path is not worth the weight.
|
|
48
|
+
function defaultFrom(env) {
|
|
49
|
+
return env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A config value that defers to the environment/a command rather than embedding a literal secret. */
|
|
53
|
+
function isIndirection(v) {
|
|
54
|
+
return typeof v === "string" && (v.startsWith("$") || v.startsWith("!"));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Find a literal secret in a parsed models.json. Returns a human-readable location, or null if clean.
|
|
59
|
+
* A provider `apiKey`, or an auth-ish header, that is a plain string (not `$ENV` / `!cmd`) is a literal.
|
|
60
|
+
*/
|
|
61
|
+
export function findLiteralSecret(models) {
|
|
62
|
+
const providers = models?.providers;
|
|
63
|
+
if (!providers || typeof providers !== "object") return null;
|
|
64
|
+
for (const [name, cfg] of Object.entries(providers)) {
|
|
65
|
+
if (typeof cfg?.apiKey === "string" && !isIndirection(cfg.apiKey)) return `providers.${name}.apiKey`;
|
|
66
|
+
const headers = cfg?.headers;
|
|
67
|
+
if (headers && typeof headers === "object") {
|
|
68
|
+
for (const [h, val] of Object.entries(headers)) {
|
|
69
|
+
if (/auth|api[-_]?key|token|secret|bearer/i.test(h) && typeof val === "string" && !isIndirection(val)) {
|
|
70
|
+
return `providers.${name}.headers.${h}`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function runImportPi(argv = [], deps = {}) {
|
|
79
|
+
const {
|
|
80
|
+
env = process.env,
|
|
81
|
+
cwd = process.cwd(),
|
|
82
|
+
out = (s) => process.stdout.write(s),
|
|
83
|
+
fs = { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, copyFileSync, renameSync, rmSync },
|
|
84
|
+
exec = defaultExec,
|
|
85
|
+
// Injected like `fs`/`exec`/`out` so the win32 npm branch below is reachable from a test on any host;
|
|
86
|
+
// it is the branch that was dead on arrival precisely because nothing could exercise it here.
|
|
87
|
+
platform = process.platform,
|
|
88
|
+
} = deps;
|
|
89
|
+
|
|
90
|
+
// Extensions are copied unless the operator says otherwise. `--with-extensions` is still accepted and is
|
|
91
|
+
// now a no-op, so an existing setup script keeps working and keeps meaning what it always meant.
|
|
92
|
+
const withExtensions = !argv.includes("--no-extensions");
|
|
93
|
+
const withPackages = argv.includes("--with-packages");
|
|
94
|
+
const from = flagValue(argv, "--from") ?? defaultFrom(env);
|
|
95
|
+
const to = flagValue(argv, "--to") ?? join(cwd, "pi-global");
|
|
96
|
+
const packagesFile = flagValue(argv, "--packages-file") ?? env.PI_PACKAGES_FILE ?? join(cwd, "pi-packages.json");
|
|
97
|
+
|
|
98
|
+
if (!fs.existsSync(from)) {
|
|
99
|
+
out(`error: no pi setup found at ${from}\n Is pi installed and configured? Set PI_CODING_AGENT_DIR or pass --from <dir>.\n`);
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Pre-flight the one hard security gate BEFORE writing anything: a literal key in models.json aborts the
|
|
104
|
+
// whole import so no half-overlay is produced and no secret is ever written to the overlay.
|
|
105
|
+
const modelsSrc = join(from, "models.json");
|
|
106
|
+
let modelsText;
|
|
107
|
+
if (fs.existsSync(modelsSrc)) {
|
|
108
|
+
modelsText = fs.readFileSync(modelsSrc, "utf8");
|
|
109
|
+
let parsed;
|
|
110
|
+
try {
|
|
111
|
+
parsed = JSON.parse(modelsText);
|
|
112
|
+
} catch {
|
|
113
|
+
modelsText = null; // malformed: skip it with a warning rather than abort the whole import
|
|
114
|
+
}
|
|
115
|
+
if (parsed) {
|
|
116
|
+
const leak = findLiteralSecret(parsed);
|
|
117
|
+
if (leak) {
|
|
118
|
+
out(
|
|
119
|
+
`error: ${modelsSrc} embeds a literal secret at ${leak}.\n` +
|
|
120
|
+
` The overlay is mounted into an adversarial-input container, so it must be credential-free.\n` +
|
|
121
|
+
` Move the key to auth.json (\`pi\` login) or reference the environment (e.g. "$MY_KEY"), then re-run.\n`,
|
|
122
|
+
);
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const results = [];
|
|
129
|
+
fs.mkdirSync(to, { recursive: true });
|
|
130
|
+
|
|
131
|
+
// models.json — definitions only, already proven literal-secret-free above.
|
|
132
|
+
if (modelsText) {
|
|
133
|
+
fs.writeFileSync(join(to, "models.json"), modelsText);
|
|
134
|
+
results.push(["models.json", "custom model/provider definitions (credential-free)"]);
|
|
135
|
+
} else if (fs.existsSync(modelsSrc)) {
|
|
136
|
+
results.push(["models.json", "SKIPPED — not valid JSON"]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// skills/ — copy each named skill dir (SKILL.md + its files), skipping symlinks and odd names.
|
|
140
|
+
const skillsCount = copyNamedDirs(fs, join(from, "skills"), join(to, "skills"), out);
|
|
141
|
+
if (skillsCount > 0) results.push(["skills/", `${skillsCount} skill${skillsCount === 1 ? "" : "s"}`]);
|
|
142
|
+
|
|
143
|
+
// APPEND_SYSTEM.md — the operator's global persona.
|
|
144
|
+
if (fs.existsSync(join(from, "APPEND_SYSTEM.md"))) {
|
|
145
|
+
fs.copyFileSync(join(from, "APPEND_SYSTEM.md"), join(to, "APPEND_SYSTEM.md"));
|
|
146
|
+
results.push(["APPEND_SYSTEM.md", "global persona (layers under each repo's persona)"]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// extensions/ -- the sharp edge, and copied BY DEFAULT: the operator staged this overlay to be their own
|
|
150
|
+
// setup, and one they have to arm twice more is not that. The admin extension stays hard-blocked (it can
|
|
151
|
+
// enqueue paid jobs from inside a container: a recursion vector, not part of this relaxation).
|
|
152
|
+
//
|
|
153
|
+
// Every copied name is listed, not just counted. What lands here runs in every job container from the
|
|
154
|
+
// next job onward, so the operator gets ONE moment to read the actual names -- a bare "3 extensions" row
|
|
155
|
+
// would leave them re-deriving the list from a directory they cannot see from the worker host.
|
|
156
|
+
const extSrc = join(from, "extensions");
|
|
157
|
+
if (withExtensions && fs.existsSync(extSrc)) {
|
|
158
|
+
const { copied, blocked } = copyExtensions(fs, extSrc, join(to, "extensions"), out);
|
|
159
|
+
if (copied.length > 0) {
|
|
160
|
+
results.push(["extensions/", `${copied.length} extension${copied.length === 1 ? "" : "s"} -- these LOAD in every job; VET THESE`]);
|
|
161
|
+
// Note-less rows: the printer emits them bare, so the names read as a list under the count above.
|
|
162
|
+
for (const name of copied) results.push([` - ${name}`, ""]);
|
|
163
|
+
}
|
|
164
|
+
for (const name of blocked) out(` blocked extension "${name}" — the admin extension must never run inside a job.\n`);
|
|
165
|
+
out(
|
|
166
|
+
"\n⚠ Extensions run code against adversarial input with open network egress and are NOT scanned for\n" +
|
|
167
|
+
" secrets. They load in every job as staged -- review every one listed below, and set\n" +
|
|
168
|
+
" PI_GLOBAL_ALLOW_EXTENSIONS=0 in .env if you need them off.\n",
|
|
169
|
+
);
|
|
170
|
+
} else if (fs.existsSync(extSrc)) {
|
|
171
|
+
results.push(["extensions/", "skipped -- --no-extensions was passed (nothing from extensions/ reaches a job)"]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// packages/ — pinned third-party pi packages, staged from npm on THIS host so the job container never
|
|
175
|
+
// needs the network (issue #58). All-or-nothing: a failure leaves no half-staged set to load.
|
|
176
|
+
if (withPackages) {
|
|
177
|
+
const staged = await stagePackages({ fs, exec, out, packagesFile, to, platform });
|
|
178
|
+
if (staged.error) {
|
|
179
|
+
out(`error: ${staged.error}\n`);
|
|
180
|
+
return 1;
|
|
181
|
+
}
|
|
182
|
+
const n = staged.packages.length;
|
|
183
|
+
results.push(["packages/", `${n} package${n === 1 ? "" : "s"} -- third-party code, VET THESE`]);
|
|
184
|
+
for (const warn of staged.warnings) results.push([`packages/${warn.dir}`, `WARN: ${warn.reason}`]);
|
|
185
|
+
} else if (fs.existsSync(join(to, PACKAGES_SUBDIR))) {
|
|
186
|
+
results.push(["packages/", "kept -- re-run with --with-packages to refresh"]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
out(`Imported the credential-free subset of ${from} → ${to}\n\n`);
|
|
190
|
+
// A note-less row is a list item under the row above it (the extension names), so it prints bare rather
|
|
191
|
+
// than padded out to a column that has nothing to hold.
|
|
192
|
+
for (const [name, note] of results) out(note ? ` ${name.padEnd(18)} ${note}\n` : ` ${name}\n`);
|
|
193
|
+
out(`\n (auth.json, settings.json, sessions/ are never copied — your credential stays in env/auth.json.)\n`);
|
|
194
|
+
out(nextSteps(to, withExtensions, withPackages));
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Copy `<src>/<name>/**` for each valid, non-symlink child dir. Returns the count copied. */
|
|
199
|
+
function copyNamedDirs(fs, src, dst, out) {
|
|
200
|
+
if (!fs.existsSync(src)) return 0;
|
|
201
|
+
let n = 0;
|
|
202
|
+
for (const name of fs.readdirSync(src)) {
|
|
203
|
+
if (!ENTRY_NAME_RE.test(name)) {
|
|
204
|
+
out(` skipped "${name}" — unexpected name\n`);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const childSrc = join(src, name);
|
|
208
|
+
if (fs.statSync(childSrc).isSymbolicLink?.() || !fs.statSync(childSrc).isDirectory()) continue;
|
|
209
|
+
copyTree(fs, childSrc, join(dst, name));
|
|
210
|
+
n++;
|
|
211
|
+
}
|
|
212
|
+
return n;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Like copyNamedDirs but reports the admin extension it refuses to copy. Returns the NAMES copied, not a
|
|
217
|
+
* count: the caller prints them, so the operator sees exactly what is now going into job containers.
|
|
218
|
+
*/
|
|
219
|
+
function copyExtensions(fs, src, dst, out) {
|
|
220
|
+
const copied = [];
|
|
221
|
+
const blocked = [];
|
|
222
|
+
for (const name of fs.readdirSync(src)) {
|
|
223
|
+
if (ADMIN_RE.test(name)) {
|
|
224
|
+
blocked.push(name);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (!ENTRY_NAME_RE.test(name)) {
|
|
228
|
+
out(` skipped "${name}" — unexpected name\n`);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const childSrc = join(src, name);
|
|
232
|
+
const st = fs.statSync(childSrc);
|
|
233
|
+
if (st.isSymbolicLink?.()) continue;
|
|
234
|
+
if (st.isDirectory()) copyTree(fs, childSrc, join(dst, name));
|
|
235
|
+
else fs.copyFileSync(childSrc, join(dst, name));
|
|
236
|
+
copied.push(name);
|
|
237
|
+
}
|
|
238
|
+
return { copied, blocked };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Recursively copy a directory tree, skipping symlinks (a symlink could point outside the source). */
|
|
242
|
+
function copyTree(fs, src, dst) {
|
|
243
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
244
|
+
for (const entry of fs.readdirSync(src)) {
|
|
245
|
+
const s = join(src, entry);
|
|
246
|
+
const st = fs.statSync(s);
|
|
247
|
+
if (st.isSymbolicLink?.()) continue;
|
|
248
|
+
if (st.isDirectory()) copyTree(fs, s, join(dst, entry));
|
|
249
|
+
else fs.copyFileSync(s, join(dst, entry));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Stage every package pinned in `packagesFile` into `<to>/packages/<dir>` and write the stage manifest.
|
|
255
|
+
* Returns `{ packages, warnings }`, or `{ error }` -- ALL-OR-NOTHING, because a half-staged set is worse
|
|
256
|
+
* than none: pi would load the packages that made it and silently skip the rest (issue #58).
|
|
257
|
+
*
|
|
258
|
+
* Each package is installed into a private `.staging-<i>` dir, asserted there, and only renamed into place
|
|
259
|
+
* once EVERY package has passed. A staged dir must be SELF-CONTAINED (`package.json` + its own
|
|
260
|
+
* `node_modules/`) because at job time it is resolved from a read-only mount with no network and no
|
|
261
|
+
* install step -- so every assertion below is about that property.
|
|
262
|
+
*/
|
|
263
|
+
async function stagePackages({ fs, exec, out, packagesFile, to, platform = process.platform }) {
|
|
264
|
+
if (!fs.existsSync(packagesFile)) {
|
|
265
|
+
return { error: `--with-packages needs a packages file, none at ${packagesFile}\n Run \`pi-dispatch init\` to scaffold one, or pass --packages-file <path>.` };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let entries;
|
|
269
|
+
try {
|
|
270
|
+
entries = parsePackagesFile(fs.readFileSync(packagesFile, "utf8"), packagesFile);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
// Refused before a single directory is created, so a bad file stages nothing at all.
|
|
273
|
+
return { error: error.message };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const packagesRoot = join(to, PACKAGES_SUBDIR);
|
|
277
|
+
const rootExisted = fs.existsSync(packagesRoot);
|
|
278
|
+
fs.mkdirSync(packagesRoot, { recursive: true });
|
|
279
|
+
|
|
280
|
+
const npmBin = platform === "win32" ? "npm.cmd" : "npm";
|
|
281
|
+
const stagingDirs = [];
|
|
282
|
+
const prepared = [];
|
|
283
|
+
const renamed = [];
|
|
284
|
+
const warnings = [];
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
for (const [index, entry] of entries.entries()) {
|
|
288
|
+
const staging = join(packagesRoot, `.staging-${index}`);
|
|
289
|
+
fs.rmSync(staging, { recursive: true, force: true }); // a crashed earlier run may have left one
|
|
290
|
+
stagingDirs.push(staging);
|
|
291
|
+
fs.mkdirSync(staging, { recursive: true });
|
|
292
|
+
// A private root package.json pins npm's idea of "the project" to the staging dir, so it cannot
|
|
293
|
+
// walk up and install into (or read config from) the operator's own checkout.
|
|
294
|
+
fs.writeFileSync(join(staging, "package.json"), `${JSON.stringify({ name: "pi-dispatch-staging", private: true }, null, 2)}\n`);
|
|
295
|
+
|
|
296
|
+
// ARRAY argv, never a shell string: the name and version come from a config file and must never be
|
|
297
|
+
// able to become shell syntax on the operator's host. The install target is the exec's `cwd`, NOT a
|
|
298
|
+
// `--prefix <staging>` pair -- npm installs into the cwd's node_modules by default, and dropping the
|
|
299
|
+
// flag removes the only filesystem PATH from argv. What is left is nothing but literal flags and one
|
|
300
|
+
// `name@version` token already validated against NPM_NAME_RE + EXACT_VERSION_RE; that is the property
|
|
301
|
+
// npmExecOptions relies on below.
|
|
302
|
+
//
|
|
303
|
+
// --ignore-scripts is load-bearing: without it the lifecycle scripts of this package AND of every
|
|
304
|
+
// transitive dependency would run as the operator, on the operator's host, at stage time.
|
|
305
|
+
// --omit=peer because pi aliases its own packages for extensions at load time, so a staged peer
|
|
306
|
+
// copy is ignored dead weight -- and a floating pi version at that (CONST-PI-VERSION-PINNED).
|
|
307
|
+
// --install-strategy=nested asks npm to keep every dependency inside the package dir; step 4 below
|
|
308
|
+
// ASSERTS the result rather than trusting the flag, whose name and default have moved across npm
|
|
309
|
+
// versions.
|
|
310
|
+
const args = [
|
|
311
|
+
"install",
|
|
312
|
+
`${entry.name}@${entry.version}`,
|
|
313
|
+
"--omit=dev",
|
|
314
|
+
"--omit=peer",
|
|
315
|
+
"--omit=optional",
|
|
316
|
+
"--ignore-scripts",
|
|
317
|
+
"--install-strategy=nested",
|
|
318
|
+
"--no-audit",
|
|
319
|
+
"--no-fund",
|
|
320
|
+
"--loglevel=error",
|
|
321
|
+
];
|
|
322
|
+
out(` staging ${entry.name}@${entry.version} -> packages/${entry.dir}\n`);
|
|
323
|
+
try {
|
|
324
|
+
await exec(npmBin, args, npmExecOptions(platform, staging));
|
|
325
|
+
} catch (error) {
|
|
326
|
+
const detail = String(error?.stderr ?? error?.message ?? "").trim();
|
|
327
|
+
throw new Error(`npm install failed for ${entry.name}@${entry.version}: ${detail}`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const source = join(staging, "node_modules", entry.name);
|
|
331
|
+
let pkg;
|
|
332
|
+
try {
|
|
333
|
+
pkg = JSON.parse(fs.readFileSync(join(source, "package.json"), "utf8"));
|
|
334
|
+
} catch {
|
|
335
|
+
throw new Error(`${entry.name}@${entry.version}: npm reported success but there is no readable package.json at ${join(source, "package.json")}`);
|
|
336
|
+
}
|
|
337
|
+
if (pkg.version !== entry.version) {
|
|
338
|
+
throw new Error(`${entry.name}: npm staged version ${JSON.stringify(pkg.version)}, not the pinned ${JSON.stringify(entry.version)} (CONST-PI-VERSION-PINNED)`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Dependency completeness -- catches hoisting whatever npm's flag defaults do this month. A
|
|
342
|
+
// hoisted dependency would only surface as an import failure inside a job, hours later.
|
|
343
|
+
for (const dep of Object.keys(pkg.dependencies ?? {})) {
|
|
344
|
+
if (!fs.existsSync(join(source, "node_modules", dep))) {
|
|
345
|
+
throw new Error(`${entry.name}: dependency "${dep}" is not inside the package dir -- npm hoisted it out, so the staged copy could not import it at run time (no network, no install)`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// A package that contributes no pi resources loads as a silent no-op; staging exists to turn that
|
|
350
|
+
// run-time nothing into a stage-time error the operator can act on.
|
|
351
|
+
const manifest = pkg.pi !== null && typeof pkg.pi === "object" ? pkg.pi : null;
|
|
352
|
+
const hasResourceDir = RESOURCE_DIRS.some((name) => fs.existsSync(join(source, name)));
|
|
353
|
+
if (!manifest && !hasResourceDir) {
|
|
354
|
+
throw new Error(`${entry.name} is not a pi package -- no "pi" manifest in package.json and none of ${RESOURCE_DIRS.join("/")}; it would load as a silent no-op`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Containment: manifest entries are resolved relative to the package dir at job time, so one that
|
|
358
|
+
// climbs out of it would reach the rest of the read-only overlay.
|
|
359
|
+
const escaping = manifest && findEscapingEntry(manifest);
|
|
360
|
+
if (escaping) {
|
|
361
|
+
throw new Error(`${entry.name}: pi manifest entry ${JSON.stringify(escaping)} leaves the package dir (no ".." segment, no leading "/")`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Warn, do not refuse: --ignore-scripts means a build/postinstall step did NOT run and an optional
|
|
365
|
+
// dependency was NOT fetched, so such a package is staged INCOMPLETE and may fail at run time.
|
|
366
|
+
const scriptKeys = ["install", "preinstall", "postinstall"].filter((key) => typeof pkg.scripts?.[key] === "string");
|
|
367
|
+
const hasOptional = Object.keys(pkg.optionalDependencies ?? {}).length > 0;
|
|
368
|
+
if (scriptKeys.length > 0 || hasOptional) {
|
|
369
|
+
const declares = [...scriptKeys.map((key) => `scripts.${key}`), ...(hasOptional ? ["optionalDependencies"] : [])].join(", ");
|
|
370
|
+
warnings.push({ dir: entry.dir, reason: `${entry.name} declares ${declares} -- staged with --ignore-scripts, so it is INCOMPLETE and may fail at run time` });
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
prepared.push({ entry, source });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Renames happen only after EVERY package has passed, so a failure on the last one cannot leave the
|
|
377
|
+
// earlier ones swapped in beside a stale manifest.
|
|
378
|
+
for (const { entry, source } of prepared) {
|
|
379
|
+
const dest = join(packagesRoot, entry.dir);
|
|
380
|
+
fs.rmSync(dest, { recursive: true, force: true }); // replace a previous stage of the same package
|
|
381
|
+
// renameSync, never copyTree: copyTree's symlink guard uses statSync, which FOLLOWS links, so it
|
|
382
|
+
// would copy the target of every node_modules/.bin symlink instead of skipping it.
|
|
383
|
+
fs.renameSync(source, dest);
|
|
384
|
+
renamed.push(dest);
|
|
385
|
+
}
|
|
386
|
+
} catch (error) {
|
|
387
|
+
for (const dest of renamed) fs.rmSync(dest, { recursive: true, force: true });
|
|
388
|
+
for (const staging of stagingDirs) fs.rmSync(staging, { recursive: true, force: true });
|
|
389
|
+
if (!rootExisted) fs.rmSync(packagesRoot, { recursive: true, force: true });
|
|
390
|
+
return { error: `${error.message}\n Nothing was staged -- fix ${packagesFile} (or the package) and re-run with --with-packages.` };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
for (const staging of stagingDirs) fs.rmSync(staging, { recursive: true, force: true });
|
|
394
|
+
|
|
395
|
+
const stageManifest = { stagedAt: new Date().toISOString(), packages: entries.map(({ name, version, dir }) => ({ name, version, dir })) };
|
|
396
|
+
fs.writeFileSync(join(packagesRoot, STAGE_MANIFEST), `${JSON.stringify(stageManifest, null, 2)}\n`);
|
|
397
|
+
return { packages: entries, warnings };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The exec options for one `npm install`: always `cwd: <staging>` (that IS the install target now that
|
|
402
|
+
* `--prefix` is gone), plus `shell: true` on win32 and nowhere else.
|
|
403
|
+
*
|
|
404
|
+
* WHY shell:true is REQUIRED on win32: npm ships there as `npm.cmd`, and since Node 18.20.2 / 20.12.2
|
|
405
|
+
* (CVE-2024-27980) spawning a `.cmd`/`.bat` WITHOUT a shell throws EINVAL outright. This package floors at
|
|
406
|
+
* Node >=22.19, so every Node it can run on has that behaviour -- without this, `--with-packages` fails on
|
|
407
|
+
* every Windows host with a misleading "spawn npm.cmd EINVAL" and the branch above is dead on arrival.
|
|
408
|
+
*
|
|
409
|
+
* WHY shell:true is SAFE HERE SPECIFICALLY, which it would NOT be in general: with `--prefix` replaced by
|
|
410
|
+
* `cwd`, argv holds no filesystem path at all -- only literal flags this file spells out, plus the single
|
|
411
|
+
* `name@version` token, and BOTH halves of that token were validated before anything was created (packages.mjs
|
|
412
|
+
* rejects any name failing NPM_NAME_RE and any version failing EXACT_VERSION_RE, neither of which admits a
|
|
413
|
+
* space, quote, or cmd metacharacter). So no operator-supplied string that could survive as shell syntax ever
|
|
414
|
+
* reaches the command line. Re-introducing a path -- or loosening either regex -- breaks that argument, so
|
|
415
|
+
* this option must be revisited together with them.
|
|
416
|
+
*/
|
|
417
|
+
function npmExecOptions(platform, staging) {
|
|
418
|
+
return platform === "win32" ? { cwd: staging, shell: true } : { cwd: staging };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** The first string anywhere in a `pi` manifest that leaves the package dir, or null when all are contained. */
|
|
422
|
+
function findEscapingEntry(value) {
|
|
423
|
+
if (typeof value === "string") {
|
|
424
|
+
return /^[\\/]/.test(value) || value.split(/[\\/]/).includes("..") ? value : null;
|
|
425
|
+
}
|
|
426
|
+
if (Array.isArray(value) || (value !== null && typeof value === "object")) {
|
|
427
|
+
for (const child of Object.values(value)) {
|
|
428
|
+
const hit = findEscapingEntry(child);
|
|
429
|
+
if (hit) return hit;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function flagValue(argv, flag) {
|
|
436
|
+
const i = argv.indexOf(flag);
|
|
437
|
+
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function nextSteps(to, withExtensions, withPackages) {
|
|
441
|
+
const steps = [`Set PI_GLOBAL_PI_DIR=${to} in .env`, "pi-dispatch doctor # verifies the overlay is credential-free"];
|
|
442
|
+
// The vetting step is no longer a switch to flip -- it already happened by staging. What is left is the
|
|
443
|
+
// off switch, named here so an operator who does not want the extensions is not left hunting for it.
|
|
444
|
+
if (withExtensions) steps.push("Vet the extensions listed above -- they load in every job; set PI_GLOBAL_ALLOW_EXTENSIONS=0 in .env to disable them");
|
|
445
|
+
// Same inversion for packages: staging is what loads them, so the step worth naming is how to withhold
|
|
446
|
+
// them from a trigger that should not run third-party code.
|
|
447
|
+
if (withPackages) steps.push('Staged packages load in every job -- set `run.packages: false` on any trigger in triggers.json that must not load them');
|
|
448
|
+
return `
|
|
449
|
+
Next:
|
|
450
|
+
${steps.map((step, i) => ` ${i + 1}. ${step}\n`).join("")}`;
|
|
451
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { DelayedError, UnrecoverableError, Worker } from "bullmq";
|
|
4
|
+
import { InfraRetry, runJob } from "./processor.mjs";
|
|
5
|
+
|
|
6
|
+
const exec = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
export const QUEUE = "pi-jobs";
|
|
9
|
+
export const JOB_TIMEOUT_MS = 30 * 60 * 1000; // REQ-JOB-TIMEOUT-30M
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build the BullMQ processor.
|
|
13
|
+
*
|
|
14
|
+
* It MUST declare exactly three parameters (job, token, signal). BullMQ only allocates an
|
|
15
|
+
* AbortController when `processor.length >= 3` (it inspects the function's arity at construction),
|
|
16
|
+
* so dropping the unused `token` would silently disable BOTH the 30-minute timeout and the shutdown
|
|
17
|
+
* abort -- with no error. A test asserts the arity precisely because the failure is silent.
|
|
18
|
+
*
|
|
19
|
+
* Dependencies are injected so this is testable without a live queue: `cancelJob` (fired by the
|
|
20
|
+
* timeout), `stopContainer` (fired by the abort), and the orchestration deps.
|
|
21
|
+
*
|
|
22
|
+
* Once per job, before runJob, it resolves the runtime-settings overlay via `getSettings`
|
|
23
|
+
* (INT-CONFIG-OVERLAY-CONTRACT). A present-but-invalid overlay resolves to a POLICY refusal RETURNED
|
|
24
|
+
* (never thrown), so BullMQ marks the job completed without a retry (CONST-RETRY-INFRA-ONLY). A valid
|
|
25
|
+
* overlay fills the effective `provider`/`model`/`maxTurns`/`dailyCap`/`weeklyCap`/`monthlyCap`/`softHoldPct`
|
|
26
|
+
* under `job.data > overlay > env` precedence and re-binds the worker slot count via `applyConcurrency`.
|
|
27
|
+
* The overlay changes which values the spend caps take, never when they are checked -- reserveBudget still
|
|
28
|
+
* runs inside runJob against the freshly passed caps (CONST-BUDGET-BEFORE-TOKENS).
|
|
29
|
+
*/
|
|
30
|
+
export function makeProcessor({ cancelJob, stopContainer, redis, getSettings, applyConcurrency = () => {}, pauseUntil = () => null, deps, recordRun = () => {}, timeoutMs = JOB_TIMEOUT_MS, now = () => Date.now() }) {
|
|
31
|
+
return async function processor(job, token, signal) {
|
|
32
|
+
// Scoped pause windows (REQ-SCOPED-PAUSE-WINDOWS): if this job's folder/repo is inside an active pause
|
|
33
|
+
// window, DEFER it to the window end via BullMQ's delayed set -- the job keeps its identity/dedup and
|
|
34
|
+
// auto-resumes when re-picked. This is FIRST, before the kill timer, the settings read, and the budget
|
|
35
|
+
// reservation, so a deferred job arms no timer, reserves no slot, and spends nothing
|
|
36
|
+
// (CONST-BUDGET-BEFORE-TOKENS). `moveToDelayed` needs the worker's `token`; the `> now + 1s` guard keeps
|
|
37
|
+
// a boundary tick from busy-deferring. A thrown `DelayedError` is how BullMQ learns the job was deferred
|
|
38
|
+
// (worker.js recognises it) rather than completed or failed.
|
|
39
|
+
// One clock snapshot for both the window lookup and the guard, so they cannot disagree across the
|
|
40
|
+
// call (and a test can inject a fixed clock). `now` defaults to the real wall clock in production.
|
|
41
|
+
const nowMs = now();
|
|
42
|
+
const until = pauseUntil(job.data, nowMs);
|
|
43
|
+
if (until && until > nowMs + 1000) {
|
|
44
|
+
await job.moveToDelayed(until, token);
|
|
45
|
+
throw new DelayedError();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const startedAt = new Date().toISOString();
|
|
49
|
+
const name = `pi-job-${job.id}`;
|
|
50
|
+
const timer = setTimeout(() => {
|
|
51
|
+
// BullMQ has no per-job kill timer; this is ours. cancelJob raises the AbortSignal.
|
|
52
|
+
Promise.resolve(cancelJob(job.id, "job-timeout-30m")).catch(() => {});
|
|
53
|
+
}, timeoutMs);
|
|
54
|
+
|
|
55
|
+
// Abort (timeout OR shutdown) => stop the container. docker stop sends SIGTERM then SIGKILL
|
|
56
|
+
// after the grace period; the runner exits and runContainer returns/throws.
|
|
57
|
+
const onAbort = () => {
|
|
58
|
+
Promise.resolve(stopContainer(name)).catch(() => {});
|
|
59
|
+
};
|
|
60
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const settings = await getSettings();
|
|
64
|
+
if (settings.invalid) {
|
|
65
|
+
// A present-but-invalid overlay is a POLICY refusal, RETURNED (never thrown) so BullMQ marks the
|
|
66
|
+
// job completed and does not retry a file that can never parse (CONST-RETRY-INFRA-ONLY). Resolved
|
|
67
|
+
// before runJob, so no budget slot is reserved and no container starts (CONST-BUDGET-BEFORE-TOKENS).
|
|
68
|
+
// recordRun leaves the durable settings-overlay-invalid trace for the admin extension.
|
|
69
|
+
// No provider/model here, alone among the terminal results: the overlay is the thing that failed to parse, so no honest effective value exists -- buildRecord defaults both null.
|
|
70
|
+
const result = { outcome: "policy", reason: "settings-overlay-invalid", exitCode: null, turns: null, tokens: null, budgetReserved: false };
|
|
71
|
+
recordRun({ job, result, startedAt, endedAt: new Date().toISOString() });
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Re-bind the worker slot count to the effective concurrency before the run (no-op default when
|
|
76
|
+
// unwired, e.g. a bare makeProcessor under test).
|
|
77
|
+
applyConcurrency(settings.concurrency);
|
|
78
|
+
|
|
79
|
+
// Fill the effective job settings under `job.data > overlay > env` precedence: an explicit per-job
|
|
80
|
+
// field wins; an omitted one takes the overlay value, else env, resolved at this job's start
|
|
81
|
+
// (INT-CONFIG-OVERLAY-CONTRACT). Receiver GitHub jobs carry no provider/model/maxTurns, so this fill
|
|
82
|
+
// supplies the provider the container env allowlist requires -- absent it, the allowlist refuses a job
|
|
83
|
+
// only after its budget slot is reserved. The `caps`/`softHoldPct` passed to runJob change which
|
|
84
|
+
// values reserveBudget checks, never when it runs.
|
|
85
|
+
const effectiveJob = {
|
|
86
|
+
...job.data,
|
|
87
|
+
provider: job.data.provider ?? settings.provider,
|
|
88
|
+
model: job.data.model ?? settings.model,
|
|
89
|
+
maxTurns: job.data.maxTurns ?? settings.maxTurns,
|
|
90
|
+
maxTokens: job.data.maxTokens ?? settings.maxTokens, // optional per-job token budget (issue #25); null => runner meter only
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const result = await runJob(effectiveJob, {
|
|
94
|
+
redis,
|
|
95
|
+
// The three spend windows (week/month null when disabled) and the soft-hold band, resolved this
|
|
96
|
+
// job-start under overlay > env. reserveBudget checks them before the container.
|
|
97
|
+
caps: { day: settings.dailyCap, week: settings.weeklyCap, month: settings.monthlyCap },
|
|
98
|
+
softHoldPct: settings.softHoldPct,
|
|
99
|
+
// The daily TOKEN cap (issue #25), same overlay > env resolution. Check-AFTER, so it gates the
|
|
100
|
+
// NEXT job on prior recorded spend; null => the daily token counter is disabled.
|
|
101
|
+
tokenCap: settings.dailyTokenCap,
|
|
102
|
+
...deps,
|
|
103
|
+
runContainer: (ctx) => deps.runContainer({ ...ctx, name, signal }),
|
|
104
|
+
// collectChain (INT-OUTBOX-CONTRACT) reads the completed parent's REAL BullMQ job: its `.id`
|
|
105
|
+
// (the parent id children carry) and `.data` (kind/chainDepth). runJob's own `job` is the
|
|
106
|
+
// effectiveJob -- a spread of job.data with no `.id`/`.data` -- so inject the real wrapper here,
|
|
107
|
+
// mirroring the name/signal injection above. Omitted when unwired so a bare processor falls back
|
|
108
|
+
// to runJob's no-op default (a chain fault can never flip a completed outcome either way).
|
|
109
|
+
...(deps.collectChain ? { collectChain: (ctx) => deps.collectChain({ ...ctx, job }) } : {}),
|
|
110
|
+
// prepareWorkspace needs the REAL BullMQ job's `.id` to derive a cron job's scheduled-for
|
|
111
|
+
// instant from the deterministic repeat:<id>:<millis> jobId (DES-CRON-VIA-BULLMQ-SCHEDULER)
|
|
112
|
+
// for the local /job/event.json. runJob's own `job` is the effectiveJob -- a spread of
|
|
113
|
+
// job.data with no `.id` -- and the real wrapper is only in scope here, so inject it as
|
|
114
|
+
// `queueJobId`, mirroring the collectChain injection above. Omitted when unwired so a bare
|
|
115
|
+
// processor keeps runJob's plain (job, token) call.
|
|
116
|
+
...(deps.prepareWorkspace ? { prepareWorkspace: (j, t) => deps.prepareWorkspace(j, t, { queueJobId: job.id }) } : {}),
|
|
117
|
+
});
|
|
118
|
+
recordRun({ job, result, startedAt, endedAt: new Date().toISOString() });
|
|
119
|
+
return result;
|
|
120
|
+
} catch (error) {
|
|
121
|
+
recordRun({ job, error, startedAt, endedAt: new Date().toISOString() });
|
|
122
|
+
if (error instanceof InfraRetry) throw error; // retryable: BullMQ retries per attempts
|
|
123
|
+
// A non-retryable, non-infra error (our bug) must not retry forever. UnrecoverableError
|
|
124
|
+
// records it as failed-and-distinct in the queue's failed set without a retry.
|
|
125
|
+
throw new UnrecoverableError(error.message);
|
|
126
|
+
} finally {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
signal.removeEventListener("abort", onAbort);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function createWorker({ connection, concurrency, getSettings, redis, deps, recordRun, limiter, pauseUntil, extraClosers = [] }) {
|
|
134
|
+
let worker; // referenced by cancelJob/applyConcurrency before assignment; only called later, so the TDZ is fine
|
|
135
|
+
const processor = makeProcessor({
|
|
136
|
+
cancelJob: (id, reason) => worker.cancelJob(id, reason),
|
|
137
|
+
stopContainer: (name) => exec("docker", ["stop", "-t", "5", name]),
|
|
138
|
+
redis,
|
|
139
|
+
getSettings,
|
|
140
|
+
// Late-bound over `worker`: an overlay concurrency change re-binds the live slot count at the next
|
|
141
|
+
// job start. Guarded so only an integer that actually differs touches the property.
|
|
142
|
+
applyConcurrency: (n) => {
|
|
143
|
+
if (Number.isInteger(n) && worker.concurrency !== n) worker.concurrency = n;
|
|
144
|
+
},
|
|
145
|
+
pauseUntil,
|
|
146
|
+
deps,
|
|
147
|
+
recordRun,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
worker = new Worker(QUEUE, processor, {
|
|
151
|
+
// maxRetriesPerRequest: null is REQUIRED for BullMQ's blocking connections, or it throws.
|
|
152
|
+
connection: { ...connection, maxRetriesPerRequest: null },
|
|
153
|
+
concurrency,
|
|
154
|
+
maxStalledCount: 0, // a stalled paid job FAILS, never silently re-runs (verified live)
|
|
155
|
+
...(limiter ? { limiter } : {}),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const shutdown = async () => {
|
|
159
|
+
// Abort active jobs (=> docker stop via onAbort), then close. Without the cancel,
|
|
160
|
+
// worker.close() would wait up to 30 minutes for the container.
|
|
161
|
+
await Promise.resolve(worker.cancelAllJobs?.("shutdown")).catch(() => {});
|
|
162
|
+
await worker.close();
|
|
163
|
+
// Close auxiliary resources (e.g. a cron scheduler) after the worker drains. Per-item catch
|
|
164
|
+
// so one failing or absent closer never strands the others or blocks exit -- matches the
|
|
165
|
+
// swallow posture on cancelAllJobs above.
|
|
166
|
+
await Promise.all(extraClosers.map((c) => Promise.resolve(c.close?.()).catch(() => {})));
|
|
167
|
+
process.exit(0);
|
|
168
|
+
};
|
|
169
|
+
process.once("SIGTERM", shutdown);
|
|
170
|
+
process.once("SIGINT", shutdown);
|
|
171
|
+
// Windows never delivers an external SIGTERM. nssm's console-stop delivers Ctrl-C => SIGINT
|
|
172
|
+
// (handled above); SIGBREAK covers console-close. Route it to the same shutdown so a stopped
|
|
173
|
+
// worker still aborts in-flight jobs and docker-stops their containers rather than orphaning them.
|
|
174
|
+
if (process.platform === "win32") process.once("SIGBREAK", shutdown);
|
|
175
|
+
|
|
176
|
+
return worker;
|
|
177
|
+
}
|