@edgehero/pi-dispatch 0.1.1 → 0.2.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 +2 -1
- package/deploy/com.pi-dispatch.worker.plist +16 -6
- package/deploy/docker-compose.yml +66 -0
- package/deploy/worker-env-wrapper.cmd +23 -14
- package/deploy/worker-env-wrapper.sh +22 -15
- package/package.json +1 -1
- package/src/azure-prompt.mjs +14 -8
- package/src/cli.mjs +2 -2
- package/src/copy-tree.mjs +215 -0
- package/src/doctor.mjs +283 -8
- package/src/forgejo-prompt.mjs +14 -8
- package/src/github-app-setup.mjs +7 -2
- package/src/github-prompt.mjs +97 -17
- package/src/gitlab-prompt.mjs +14 -8
- package/src/host-pi.mjs +478 -0
- package/src/import-pi.mjs +299 -139
- package/src/materialize.mjs +262 -40
- package/src/outbox.mjs +5 -0
- package/src/packages.mjs +95 -2
- package/src/prepare-github.mjs +23 -4
- package/src/prepare-local.mjs +6 -1
- package/src/prepare.mjs +73 -14
- package/src/processor.mjs +89 -10
- package/src/queue.mjs +16 -2
- package/src/run-container.mjs +7 -2
- package/src/schedules.mjs +16 -1
- package/src/service.mjs +135 -40
- package/src/session-store.mjs +19 -4
- package/src/start.mjs +54 -9
- package/src/triggers.mjs +211 -12
package/src/service.mjs
CHANGED
|
@@ -3,12 +3,27 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Durable running used to mean hand-editing the per-OS examples in deploy/. This module reads those
|
|
5
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
|
|
6
|
+
* `/usr/bin/node` → `process.execPath`, `/opt/pi-dispatch` → the deployment folder — rather than
|
|
7
7
|
* introducing a `{{placeholder}}` dialect. That keeps the deploy/ files byte-usable examples (and
|
|
8
8
|
* deploy-lint keeps parsing exactly what ships); TEMPLATE_PINS below is the table's enforcement — the
|
|
9
9
|
* test suite asserts every literal is still present in every template, so template drift breaks the
|
|
10
10
|
* build loudly instead of breaking the render silently.
|
|
11
11
|
*
|
|
12
|
+
* Path doctrine (issue #96): this module used to derive a REPO_ROOT from its own location ("../..").
|
|
13
|
+
* Right in a checkout; WRONG under `npm install`, where src/ lives at
|
|
14
|
+
* node_modules/@edgehero/pi-dispatch/src and "../.." is the @edgehero SCOPE directory — every rendered
|
|
15
|
+
* unit pointed at files that do not exist. Three anchors replace it, each correct in BOTH layouts:
|
|
16
|
+
* - deployDir the deployment folder = the cwd `service` is invoked from. Owns everything
|
|
17
|
+
* host-side: WorkingDirectory, EnvironmentFile (<deployDir>/.env) and the daemon
|
|
18
|
+
* logs (<deployDir>/logs/, created at install time) — never the package dir, which
|
|
19
|
+
* npm may replace wholesale on update.
|
|
20
|
+
* - cliPath join(moduleDir, "cli.mjs"): cli.mjs sits beside this module in src/ in both
|
|
21
|
+
* layouts, so the worker ExecStart needs no repo root at all.
|
|
22
|
+
* - receiverStart import.meta.resolve("@edgehero/pi-dispatch-receiver/start"): the receiver
|
|
23
|
+
* package's own exported entry, wherever npm (or the workspace symlink) put it.
|
|
24
|
+
* null when the package is not installed — receiver renders refuse loudly instead
|
|
25
|
+
* of writing a unit that would crash-loop at boot.
|
|
26
|
+
*
|
|
12
27
|
* Scope doctrine:
|
|
13
28
|
* - User-level by default, everywhere. macOS REFUSES root outright (a LaunchAgent is per-user, and a
|
|
14
29
|
* root agent could not see the login session's Docker Desktop anyway — the svc.sh precedent).
|
|
@@ -32,12 +47,26 @@ import { dirname, join, resolve } from "node:path";
|
|
|
32
47
|
import { fileURLToPath } from "node:url";
|
|
33
48
|
import { parseArgs } from "node:util";
|
|
34
49
|
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
const
|
|
50
|
+
// src/ is where this module lives in BOTH layouts (worker/src in a checkout,
|
|
51
|
+
// node_modules/@edgehero/pi-dispatch/src under npm). Deploy templates resolve one level up from it
|
|
52
|
+
// (the init.mjs pattern): worker/deploy is SHIPPED in the npm tarball and kept byte-identical to the
|
|
53
|
+
// repo-root deploy/ (the documented source) by worker/test/publish.test.mjs — so `service` renders
|
|
54
|
+
// the same templates from a checkout and from an npm install, no matter where the CLI is invoked from.
|
|
55
|
+
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The receiver's entry point comes from the receiver PACKAGE (its ./start export), wherever module
|
|
59
|
+
* resolution finds it from here — the workspace symlink in a checkout, the sibling install under the
|
|
60
|
+
* deployment folder's node_modules in production. Never a path guessed off this module: that guess is
|
|
61
|
+
* exactly what issue #96 is about. null = not installed, and the receiver renders refuse on it.
|
|
62
|
+
*/
|
|
63
|
+
function resolveReceiverStart() {
|
|
64
|
+
try {
|
|
65
|
+
return fileURLToPath(import.meta.resolve("@edgehero/pi-dispatch-receiver/start"));
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
41
70
|
|
|
42
71
|
/**
|
|
43
72
|
* The whole substitution surface, template by template. The render replaces ONLY these literals (plus
|
|
@@ -47,9 +76,9 @@ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
|
47
76
|
*/
|
|
48
77
|
export const TEMPLATE_PINS = {
|
|
49
78
|
"worker.service": [
|
|
50
|
-
"ExecStart=/usr/bin/node worker/src/cli.mjs worker", //
|
|
51
|
-
"WorkingDirectory=/opt/pi-dispatch", // /opt/pi-dispatch → the
|
|
52
|
-
"EnvironmentFile=/opt/pi-dispatch/.env",
|
|
79
|
+
"ExecStart=/usr/bin/node worker/src/cli.mjs worker", // the WHOLE line → `<execPath> <cliPath> worker` (cli.mjs sits beside this module in src/ in both layouts)
|
|
80
|
+
"WorkingDirectory=/opt/pi-dispatch", // /opt/pi-dispatch → the deployment folder (the cwd `service` runs from)
|
|
81
|
+
"EnvironmentFile=/opt/pi-dispatch/.env", // → <deployDir>/.env — the operator's .env lives beside the units, never inside the package
|
|
53
82
|
"\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
83
|
"WantedBy=multi-user.target", // → default.target in user scope (multi-user.target never runs there)
|
|
55
84
|
// Byte-for-byte survivors — semantics the render must not lose:
|
|
@@ -60,7 +89,7 @@ export const TEMPLATE_PINS = {
|
|
|
60
89
|
"TimeoutStopSec=30",
|
|
61
90
|
],
|
|
62
91
|
"receiver.service": [
|
|
63
|
-
"ExecStart=/usr/bin/node receiver/src/start.mjs",
|
|
92
|
+
"ExecStart=/usr/bin/node receiver/src/start.mjs", // the WHOLE line → `<execPath> <receiverStart>` (the receiver package's resolved ./start export)
|
|
64
93
|
"WorkingDirectory=/opt/pi-dispatch",
|
|
65
94
|
"EnvironmentFile=/opt/pi-dispatch/.env",
|
|
66
95
|
"\nUser=pi\n",
|
|
@@ -70,9 +99,9 @@ export const TEMPLATE_PINS = {
|
|
|
70
99
|
],
|
|
71
100
|
"com.pi-dispatch.worker.plist": [
|
|
72
101
|
"<string>com.pi-dispatch.worker</string>", // → com.pi-dispatch.receiver for --receiver
|
|
73
|
-
"<string>/opt/pi-dispatch/deploy/worker-env-wrapper.sh</string>", //
|
|
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>",
|
|
102
|
+
"<string>/opt/pi-dispatch/deploy/worker-env-wrapper.sh</string>", // → the PACKAGE's wrapper copy, followed by the exec argv (<node> <script> …) the wrapper now runs verbatim
|
|
103
|
+
"<key>WorkingDirectory</key>\n\t<string>/opt/pi-dispatch</string>", // → the deployment folder (load-bearing: the wrapper sources ./.env there); also the anchor for the PATH injection below
|
|
104
|
+
"<string>/opt/pi-dispatch/logs/worker.out.log</string>", // → <deployDir>/logs/… (install creates the dir; launchd will not)
|
|
76
105
|
"<string>/opt/pi-dispatch/logs/worker.err.log</string>",
|
|
77
106
|
"<key>SuccessfulExit</key>", // the KeepAlive shape the wrapper's exit-2 conversion pairs with
|
|
78
107
|
"<integer>30</integer>", // ExitTimeOut — room for the SIGTERM drain
|
|
@@ -82,8 +111,8 @@ export const TEMPLATE_PINS = {
|
|
|
82
111
|
// the two cannot drift apart — especially the AppExit pair, which is the EXIT_POLICY never-retry.
|
|
83
112
|
"nssm-install.cmd": [
|
|
84
113
|
"pi-dispatch-worker",
|
|
85
|
-
"C:\\pi-dispatch", // the REPO placeholder → the
|
|
86
|
-
"deploy\\worker-env-wrapper.cmd",
|
|
114
|
+
"C:\\pi-dispatch", // the REPO placeholder → the deployment folder (cwd)
|
|
115
|
+
"deploy\\worker-env-wrapper.cmd", // the sequence points at the PACKAGE's wrapper copy and passes the exec argv behind it
|
|
87
116
|
"AppStopMethodConsole 15000",
|
|
88
117
|
"AppThrottle 5000",
|
|
89
118
|
"AppExit Default Restart",
|
|
@@ -116,7 +145,11 @@ export async function runService(argv = [], deps = {}) {
|
|
|
116
145
|
platform = process.platform,
|
|
117
146
|
euid = typeof process.geteuid === "function" ? process.geteuid() : null,
|
|
118
147
|
execPath = process.execPath,
|
|
119
|
-
|
|
148
|
+
// The deployment folder: where the operator ran `service`, where .env lives, where logs/ goes.
|
|
149
|
+
cwd = process.cwd(),
|
|
150
|
+
// Injectable so tests can render as if from an npm install without installing anything.
|
|
151
|
+
moduleDir = MODULE_DIR,
|
|
152
|
+
resolveReceiver = resolveReceiverStart,
|
|
120
153
|
home = homedir(),
|
|
121
154
|
user = env.USER || userInfo().username,
|
|
122
155
|
tmp = tmpdir(),
|
|
@@ -162,12 +195,22 @@ export async function runService(argv = [], deps = {}) {
|
|
|
162
195
|
}
|
|
163
196
|
if (!["darwin", "linux", "win32"].includes(platform)) return fail(err, `unsupported platform: ${platform}`);
|
|
164
197
|
|
|
198
|
+
// The templates dir is module-relative like moduleDir itself: worker/deploy in a checkout,
|
|
199
|
+
// <pkg>/deploy under npm — the SHIPPED copies, correct in both layouts (unlike the old repo root).
|
|
200
|
+
const templatesDir = resolve(moduleDir, "..", "deploy");
|
|
165
201
|
const ctx = {
|
|
166
202
|
env,
|
|
167
203
|
platform,
|
|
168
204
|
euid,
|
|
169
205
|
execPath,
|
|
170
|
-
|
|
206
|
+
deployDir: cwd,
|
|
207
|
+
cliPath: join(moduleDir, "cli.mjs"),
|
|
208
|
+
templatesDir,
|
|
209
|
+
wrapperSh: join(templatesDir, "worker-env-wrapper.sh"),
|
|
210
|
+
wrapperCmd: join(templatesDir, "worker-env-wrapper.cmd"),
|
|
211
|
+
// Resolved only when asked for: worker-only invocations must not care whether the receiver
|
|
212
|
+
// package exists here at all.
|
|
213
|
+
receiverStart: values.receiver ? resolveReceiver() : null,
|
|
171
214
|
home,
|
|
172
215
|
user,
|
|
173
216
|
tmp,
|
|
@@ -240,7 +283,20 @@ function unitPaths(ctx) {
|
|
|
240
283
|
}
|
|
241
284
|
|
|
242
285
|
function readTemplate(ctx, name) {
|
|
243
|
-
return ctx.fs.readFileSync(join(
|
|
286
|
+
return ctx.fs.readFileSync(join(ctx.templatesDir, name), "utf8");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The receiver refusal, shared by render and install: a null receiverStart means the receiver package
|
|
291
|
+
* is not resolvable from this worker install. Refusing beats rendering — a unit pointing at a
|
|
292
|
+
* nonexistent start.mjs would install cleanly and then crash-loop at boot, which is exactly the failure
|
|
293
|
+
* mode issue #96 shipped for every path.
|
|
294
|
+
*/
|
|
295
|
+
function refuseMissingReceiver(ctx) {
|
|
296
|
+
return fail(
|
|
297
|
+
ctx.err,
|
|
298
|
+
"the receiver package is not installed here — run: npm install @edgehero/pi-dispatch-receiver (from the deployment folder)",
|
|
299
|
+
);
|
|
244
300
|
}
|
|
245
301
|
|
|
246
302
|
/**
|
|
@@ -250,12 +306,23 @@ function readTemplate(ctx, name) {
|
|
|
250
306
|
*/
|
|
251
307
|
function renderLinuxUnit(ctx) {
|
|
252
308
|
const template = ctx.which === "receiver" ? "receiver.service" : "worker.service";
|
|
309
|
+
// The ExecStart line is replaced WHOLE, not path-by-path: the template's script path is relative
|
|
310
|
+
// to a repo-root WorkingDirectory that only a checkout has. The rendered unit points at absolute
|
|
311
|
+
// entries that exist in both layouts — cliPath beside this module; the receiver package's ./start
|
|
312
|
+
// export — so ExecStart works no matter what WorkingDirectory is.
|
|
313
|
+
const execStart =
|
|
314
|
+
ctx.which === "receiver"
|
|
315
|
+
? ["ExecStart=/usr/bin/node receiver/src/start.mjs", `ExecStart=${ctx.execPath} ${ctx.receiverStart}`]
|
|
316
|
+
: ["ExecStart=/usr/bin/node worker/src/cli.mjs worker", `ExecStart=${ctx.execPath} ${ctx.cliPath} worker`];
|
|
253
317
|
// The banner outranks the template's own "TEMPLATE/UNTESTED EXAMPLE — set the PLACEHOLDERs" header,
|
|
254
318
|
// which renders through below (the no-markers design keeps templates byte-usable, so their prose
|
|
255
319
|
// survives): a reader of the rendered unit should know the placeholders are already substituted.
|
|
256
320
|
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
321
|
readTemplate(ctx, template)
|
|
258
|
-
.
|
|
322
|
+
.replace(execStart[0], execStart[1])
|
|
323
|
+
// /opt/pi-dispatch → the deployment folder (the cwd this render ran from): WorkingDirectory and
|
|
324
|
+
// EnvironmentFile stay operator territory, never the package dir npm may wipe on update.
|
|
325
|
+
.replaceAll("/opt/pi-dispatch", ctx.deployDir)
|
|
259
326
|
.replaceAll("/usr/bin/node", ctx.execPath);
|
|
260
327
|
if (ctx.scope === "user") {
|
|
261
328
|
// A systemd --user unit always runs as the invoking user, and systemd REJECTS a User= line in
|
|
@@ -279,48 +346,67 @@ function renderLinuxUnit(ctx) {
|
|
|
279
346
|
|
|
280
347
|
/**
|
|
281
348
|
* 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
|
|
283
|
-
*
|
|
349
|
+
* template: same KeepAlive/ExitTimeOut shape, label and log names swapped, and the shared wrapper given
|
|
350
|
+
* the receiver's exec argv instead of the worker's. The wrapper's exit-2 conversion is a no-op for the
|
|
284
351
|
* receiver — it has no EXIT_POLICY — and harmless.
|
|
285
352
|
*/
|
|
286
353
|
function renderPlist(ctx) {
|
|
287
354
|
let plist = readTemplate(ctx, "com.pi-dispatch.worker.plist");
|
|
355
|
+
// One wrapper, two daemons: the exec argv IS the difference now. The wrapper sources ./.env in the
|
|
356
|
+
// unit's WorkingDirectory and runs exactly these arguments — no `receiver` selector flag, no paths
|
|
357
|
+
// guessed inside the wrapper (issue #96: the wrapper's self-relative guess broke under npm install).
|
|
358
|
+
const execArgv = ctx.which === "receiver" ? [ctx.execPath, ctx.receiverStart] : [ctx.execPath, ctx.cliPath, "worker"];
|
|
288
359
|
if (ctx.which === "receiver") {
|
|
289
360
|
plist = plist
|
|
290
361
|
.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
362
|
.replaceAll("worker.out.log", "receiver.out.log")
|
|
296
363
|
.replaceAll("worker.err.log", "receiver.err.log");
|
|
297
364
|
}
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
|
|
365
|
+
// The template's two-element ProgramArguments (sh + wrapper) becomes sh + the PACKAGE's wrapper +
|
|
366
|
+
// the command: absolute node, absolute script. The wrapper path is module-relative (templatesDir),
|
|
367
|
+
// so it exists in a checkout AND under node_modules — unlike the old repo-root guess.
|
|
368
|
+
plist = plist.replace(
|
|
369
|
+
"<string>/opt/pi-dispatch/deploy/worker-env-wrapper.sh</string>",
|
|
370
|
+
[
|
|
371
|
+
`<string>${ctx.wrapperSh}</string>`,
|
|
372
|
+
"<!-- the command the wrapper execs after sourcing ./.env in WorkingDirectory - absolute paths, nothing guessed -->",
|
|
373
|
+
...execArgv.map((a) => `<string>${a}</string>`),
|
|
374
|
+
].join("\n\t\t"),
|
|
375
|
+
);
|
|
376
|
+
// launchd's default PATH is /usr/bin:/bin — an nvm or Homebrew node is invisible to it. The exec
|
|
377
|
+
// argv above pins THIS node absolutely, but the worker's own children (npx-style hooks, tooling
|
|
378
|
+
// that spawns bare `node`) still resolve via PATH; prepending the render node's directory keeps
|
|
379
|
+
// them on the SAME binary. PATH is configuration, not a secret: the template's deliberate
|
|
380
|
+
// no-EnvironmentVariables stance is about credentials, which still live only in .env.
|
|
302
381
|
plist = plist.replace(
|
|
303
382
|
"<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
|
|
383
|
+
"<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 child processes may call bare `node`. Not a secrets dict - credentials still live only\n\t in .env (see the header comment). -->\n\t<key>EnvironmentVariables</key>\n\t<dict>\n\t\t<key>PATH</key>\n\t\t<string>" +
|
|
305
384
|
`${dirname(ctx.execPath)}:/usr/bin:/bin:/usr/sbin:/sbin</string>\n\t</dict>`,
|
|
306
385
|
);
|
|
307
|
-
|
|
386
|
+
// Everything left standing on /opt/pi-dispatch — WorkingDirectory, the log paths, comment prose —
|
|
387
|
+
// belongs to the deployment folder.
|
|
388
|
+
return plist.replaceAll("/opt/pi-dispatch", ctx.deployDir);
|
|
308
389
|
}
|
|
309
390
|
|
|
310
391
|
/**
|
|
311
|
-
* The nssm command sequence — deploy/nssm-install.cmd's exact steps with computed paths
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
392
|
+
* The nssm command sequence — deploy/nssm-install.cmd's exact steps with computed paths: the service
|
|
393
|
+
* Application is the PACKAGE's .cmd wrapper, its AppParameters are the exec argv (<node> <script> …)
|
|
394
|
+
* the wrapper now runs verbatim, and AppDirectory is the deployment folder so the wrapper finds ./.env
|
|
395
|
+
* there (the same WorkingDirectory contract as launchd). Values that carry semantics
|
|
396
|
+
* (AppStopMethodConsole 15000, AppThrottle 5000, AppExit Default Restart, AppExit 2 Exit) mirror the
|
|
397
|
+
* template byte-for-byte and are pinned. Backslashes on purpose where paths are BUILT here: this argv
|
|
398
|
+
* reaches nssm on a real Windows host, where deployDir is already a Windows path (wrapperCmd and
|
|
399
|
+
* cliPath come from win32 path.join and need no help).
|
|
315
400
|
*/
|
|
316
401
|
function nssmSequence(ctx) {
|
|
317
402
|
const service = `pi-dispatch-${ctx.which}`;
|
|
318
|
-
const logDir = `${ctx.
|
|
403
|
+
const logDir = `${ctx.deployDir}\\logs`;
|
|
404
|
+
const execArgv = ctx.which === "receiver" ? [ctx.execPath, ctx.receiverStart] : [ctx.execPath, ctx.cliPath, "worker"];
|
|
319
405
|
return {
|
|
320
406
|
service,
|
|
321
407
|
commands: [
|
|
322
|
-
["install", service,
|
|
323
|
-
["set", service, "AppDirectory", ctx.
|
|
408
|
+
["install", service, ctx.wrapperCmd, ...execArgv],
|
|
409
|
+
["set", service, "AppDirectory", ctx.deployDir],
|
|
324
410
|
["set", service, "AppStdout", `${logDir}\\${ctx.which}.out.log`],
|
|
325
411
|
["set", service, "AppStderr", `${logDir}\\${ctx.which}.err.log`],
|
|
326
412
|
["set", service, "AppStopMethodConsole", "15000"],
|
|
@@ -332,12 +418,13 @@ function nssmSequence(ctx) {
|
|
|
332
418
|
}
|
|
333
419
|
|
|
334
420
|
function doRender(ctx) {
|
|
421
|
+
if (ctx.which === "receiver" && !ctx.receiverStart) return refuseMissingReceiver(ctx);
|
|
335
422
|
const paths = unitPaths(ctx);
|
|
336
423
|
if (ctx.platform === "darwin") {
|
|
337
424
|
ctx.out(`# → ${paths.installPath}\n`);
|
|
338
425
|
ctx.out(renderPlist(ctx));
|
|
339
426
|
ctx.out(
|
|
340
|
-
"\n# note: ProgramArguments runs
|
|
427
|
+
"\n# note: ProgramArguments runs the package's worker-env-wrapper.sh, which sources ./.env in the\n# WorkingDirectory above and then runs the argv that follows it — launchd has no EnvironmentFile.\n# The wrapper also converts a policy refusal (exit 2, EXIT_POLICY) into a clean exit, so KeepAlive\n# never relaunch-loops a refusal into a provider bill.\n",
|
|
341
428
|
);
|
|
342
429
|
return 0;
|
|
343
430
|
}
|
|
@@ -354,6 +441,7 @@ function doRender(ctx) {
|
|
|
354
441
|
}
|
|
355
442
|
|
|
356
443
|
async function doInstall(ctx) {
|
|
444
|
+
if (ctx.which === "receiver" && !ctx.receiverStart) return refuseMissingReceiver(ctx);
|
|
357
445
|
const paths = unitPaths(ctx);
|
|
358
446
|
|
|
359
447
|
// THE refusal, worker only: one worker per docker daemon (DES-CONCURRENCY-3). The worker's boot
|
|
@@ -396,6 +484,9 @@ async function installDarwin(ctx, paths) {
|
|
|
396
484
|
await run(ctx, "launchctl", ["bootout", `gui/${ctx.euid}/${paths.name}`]);
|
|
397
485
|
}
|
|
398
486
|
ctx.fs.mkdirSync(dirname(paths.installPath), { recursive: true });
|
|
487
|
+
// launchd creates the StandardOutPath FILES but not their parent directory: without this the job
|
|
488
|
+
// spawns and dies with its error unwritable. Done at install, not render — render stays read-only.
|
|
489
|
+
ctx.fs.mkdirSync(join(ctx.deployDir, "logs"), { recursive: true });
|
|
399
490
|
ctx.fs.writeFileSync(paths.installPath, renderPlist(ctx));
|
|
400
491
|
const bootstrap = await run(ctx, "launchctl", ["bootstrap", `gui/${ctx.euid}`, paths.installPath]);
|
|
401
492
|
if (bootstrap !== 0) {
|
|
@@ -459,6 +550,10 @@ async function installWindows(ctx) {
|
|
|
459
550
|
const removed = await run(ctx, "nssm", ["remove", service, "confirm"]);
|
|
460
551
|
if (removed !== 0) return fail(ctx.err, `nssm remove ${service} confirm failed (exit ${removed})`);
|
|
461
552
|
}
|
|
553
|
+
// nssm, like launchd, does not create the AppStdout/AppStderr directory. join(), not the
|
|
554
|
+
// sequence's literal backslashes: this branch runs on the actual Windows host, where join is
|
|
555
|
+
// win32-flavoured anyway.
|
|
556
|
+
ctx.fs.mkdirSync(join(ctx.deployDir, "logs"), { recursive: true });
|
|
462
557
|
for (const args of commands) {
|
|
463
558
|
const code = await run(ctx, "nssm", args);
|
|
464
559
|
if (code !== 0) return fail(ctx.err, `nssm ${args.join(" ")} failed (exit ${code})`);
|
package/src/session-store.mjs
CHANGED
|
@@ -32,9 +32,12 @@ import { sessionKeyFor } from "./session-key.mjs";
|
|
|
32
32
|
*
|
|
33
33
|
* NEVER THROWS. Every path returns `{ resume, reason, ... }` or null-ish, because a disk fault must not
|
|
34
34
|
* fail a prepare that only asked whether there was a transcript -- the posture makeFindPreviousRun
|
|
35
|
-
* already sets. The one fail-CLOSED case lives in the processor, not here
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* already sets. The one fail-CLOSED case lives in the processor, not here, and it is a gate this module is
|
|
36
|
+
* never asked: runJob returns a `sessions-dir-unset` policy refusal for a job whose trigger armed
|
|
37
|
+
* `run.resume` while `sessionsDir` is null (processor.mjs, before the mint and before reserveBudget), so a
|
|
38
|
+
* job that reaches `resolveSession` at all has already been proven to have somewhere to persist to.
|
|
39
|
+
* Refused rather than run, because running it silently without persistence is the failure
|
|
40
|
+
* validatePackagesFlag's own comment describes.
|
|
38
41
|
*/
|
|
39
42
|
|
|
40
43
|
/** Container-side name, fixed. Nothing key-derived crosses the boundary -- see makeSessionStore. */
|
|
@@ -68,7 +71,13 @@ export function makeSessionStore({
|
|
|
68
71
|
*/
|
|
69
72
|
function resolveSession(job, { jobDir, resolved = {}, piVersion = null } = {}) {
|
|
70
73
|
try {
|
|
71
|
-
|
|
74
|
+
// Unreachable in a wired worker, and deliberately kept: resolveSession is only ever called for a
|
|
75
|
+
// job that armed run.resume (prepare-github.mjs), and processor.mjs refuses exactly that job
|
|
76
|
+
// pre-spend when this is null -- the `sessions-dir-unset` policy return. This stays as the
|
|
77
|
+
// DI-seam backstop, because both the store and the preparer are injected and neither can assume
|
|
78
|
+
// the caller came through that gate; a null here is the same no-mount, nothing-written shape a
|
|
79
|
+
// pre-feature job had.
|
|
80
|
+
if (!sessionsDir) return null;
|
|
72
81
|
const key = sessionKeyFor(job, resolved);
|
|
73
82
|
// No key is not a failure and not a degradation: this job has no durable identity (a fork PR, a
|
|
74
83
|
// CLI run, an unresolvable head ref), so it gets no mount and no transcript on disk.
|
|
@@ -106,6 +115,12 @@ export function makeSessionStore({
|
|
|
106
115
|
* agents' turns into one transcript.
|
|
107
116
|
*/
|
|
108
117
|
function promoteSession(session, { piVersion = null } = {}) {
|
|
118
|
+
// The second DI-seam backstop, and unreachable for the same reason as the `!sessionsDir` return
|
|
119
|
+
// above: sessionKeyFor is total and binary (null, or 32 hex chars), so resolveSession returns null
|
|
120
|
+
// rather than a keyless session, and processor.mjs only calls this when prepare handed it one. Kept
|
|
121
|
+
// because the store and the preparer are separately injected and neither can assume the other. It is
|
|
122
|
+
// NOT in INT-RUN-HISTORY-FILE-CONTRACT's session.reason enum, deliberately: a token no wired worker
|
|
123
|
+
// can emit does not belong in the record's vocabulary, and `promote-failed` below does.
|
|
109
124
|
if (!session?.key) return { promoted: false, reason: "no-key" };
|
|
110
125
|
try {
|
|
111
126
|
const staged = join(session.hostDir, SESSION_FILE_NAME);
|
package/src/start.mjs
CHANGED
|
@@ -320,14 +320,51 @@ export async function startWorker(
|
|
|
320
320
|
// (CONST-RETRY-INFRA-ONLY). The processor calls it as the sole COMPLETED-path chain step.
|
|
321
321
|
const collectChain = makeCollectChain({ queue: runtimeQueue, config, log });
|
|
322
322
|
|
|
323
|
-
// REQ-GLOBAL-PI-OVERLAY staged packages: read the operator's stage manifest
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
323
|
+
// REQ-GLOBAL-PI-OVERLAY staged packages: read the operator's stage manifest at EACH job start, like
|
|
324
|
+
// getSettings above and the pause-window ref below.
|
|
325
|
+
//
|
|
326
|
+
// This was a boot-time read until issue #102, and the argument for that was sound while it held: the
|
|
327
|
+
// staged set was deploy-time state under a :ro mount, identical for every job, so a per-job read bought
|
|
328
|
+
// nothing. What changed is that `import-pi --with-packages` now discovers what the operator installed in
|
|
329
|
+
// pi, which makes `pi install X` then re-stage a ROUTINE act rather than a rare one. Under the boot read
|
|
330
|
+
// the jobs after such a re-stage keep the old set until someone restarts the worker, and when the re-stage
|
|
331
|
+
// DROPS a package the symptom is worse than staleness: the runner refuses a missing staged dir at
|
|
332
|
+
// container start (exit 2), and budget is reserved before the container, so every job burns a daily-cap
|
|
333
|
+
// slot until the restart. A free filesystem read that prevents a reserved-and-wasted slot is exactly what
|
|
334
|
+
// CONST-BUDGET-BEFORE-TOKENS asks for.
|
|
335
|
+
//
|
|
336
|
+
// Last-known-good on a failed read, never []: an empty set emits no PI_PACKAGES at all, so the runner's
|
|
337
|
+
// assertPackagePathsExist has nothing to refuse and the job would run WITHOUT its tools and still exit 0.
|
|
338
|
+
// That is the silent no-op this project refuses. And never a throw: a transient overlay fault must not
|
|
339
|
+
// become a queue retry (CONST-RETRY-INFRA-ONLY).
|
|
340
|
+
let lastGoodPackagePaths = [];
|
|
341
|
+
let lastPackageKey = null;
|
|
342
|
+
// Logged once per CHANGE, not once per job: a line every job would drown the log it is meant to serve.
|
|
343
|
+
// EVERY resolved read records its key, including the empty one, so "nothing staged" becoming "one package
|
|
344
|
+
// staged" is the change it obviously is rather than a first read that logs nothing.
|
|
345
|
+
const notePackageKey = (key) => {
|
|
346
|
+
if (lastPackageKey !== null && key !== lastPackageKey) log("packages_stage_changed", { count: key === "" ? 0 : key.split(":").length });
|
|
347
|
+
lastPackageKey = key;
|
|
348
|
+
};
|
|
349
|
+
const getPackagePaths = () => {
|
|
350
|
+
if (!config.globalPiDir) return [];
|
|
351
|
+
const staged = readStageManifest({ globalPiDir: config.globalPiDir });
|
|
352
|
+
if (!staged) {
|
|
353
|
+
if (lastGoodPackagePaths.length > 0) {
|
|
354
|
+
log("packages_manifest_unreadable", { overlay: config.globalPiDir, keeping: lastGoodPackagePaths.length });
|
|
355
|
+
return lastGoodPackagePaths;
|
|
356
|
+
}
|
|
357
|
+
notePackageKey("");
|
|
358
|
+
return [];
|
|
359
|
+
}
|
|
360
|
+
const paths = containerPackagePaths(staged);
|
|
361
|
+
notePackageKey(paths.join(":"));
|
|
362
|
+
lastGoodPackagePaths = paths;
|
|
363
|
+
return paths;
|
|
364
|
+
};
|
|
365
|
+
// One read at boot, for the same log line the boot read always emitted, and to seed last-known-good.
|
|
366
|
+
if (config.globalPiDir && !readStageManifest({ globalPiDir: config.globalPiDir })) log("packages_manifest_absent", { overlay: config.globalPiDir });
|
|
367
|
+
getPackagePaths();
|
|
331
368
|
|
|
332
369
|
const worker = createWorkerFn({
|
|
333
370
|
connection: parseConnection(config.valkeyUrl),
|
|
@@ -352,13 +389,21 @@ export async function startWorker(
|
|
|
352
389
|
// Completed-only, so a policy or infra exit leaves the canonical transcript byte-identical and a
|
|
353
390
|
// retry starts from what the first attempt did (CONST-RETRY-INFRA-ONLY).
|
|
354
391
|
promoteSession: sessionStore.promoteSession,
|
|
392
|
+
// The same value the store above was built from, passed explicitly so the processor's fail-closed
|
|
393
|
+
// `run.resume` gate answers from THIS config rather than from its own env default. Identical on the
|
|
394
|
+
// real path; the difference shows under an injected env, where the store would be built from the
|
|
395
|
+
// synthetic value while the gate read the process one.
|
|
396
|
+
sessionsDir: config.sessionsDir,
|
|
355
397
|
runContainer: makeRunContainerFn({
|
|
356
398
|
image: config.jobImage,
|
|
357
399
|
hostEnv: env,
|
|
358
400
|
openJobLog,
|
|
359
401
|
globalPiDir: config.globalPiDir, // REQ-GLOBAL-PI-OVERLAY: :ro overlay mount when configured
|
|
360
402
|
allowGlobalExtensions: config.allowGlobalExtensions,
|
|
361
|
-
|
|
403
|
+
// REQ-GLOBAL-PI-OVERLAY: staged package paths; every job receives them unless its trigger set
|
|
404
|
+
// packages:false. A RESOLVER, not the array: the factory is still constructed exactly once, only
|
|
405
|
+
// the value it reads became a call, so a re-stage takes effect on the next job without a restart.
|
|
406
|
+
packagePaths: getPackagePaths,
|
|
362
407
|
forwardEnv: config.forwardEnv,
|
|
363
408
|
authFromPi: config.authFromPi, // source the provider key from ~/.pi/agent/auth.json when env has none
|
|
364
409
|
// Self-hosted instance URLs, keyed by forge. A MAP rather than one scalar per forge: the table says
|