@edgehero/pi-dispatch 2.1.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.env.example +41 -5
  2. package/README.md +11 -5
  3. package/deploy/docker-compose.yml +12 -0
  4. package/deploy/egress-proxy.conf +28 -3
  5. package/deploy/pi-dispatch-egress-proxy.container +8 -2
  6. package/package.json +9 -2
  7. package/src/allocation.mjs +731 -0
  8. package/src/backends.mjs +243 -0
  9. package/src/budget.mjs +40 -4
  10. package/src/cli.mjs +222 -11
  11. package/src/config.mjs +126 -5
  12. package/src/daemon-facts.mjs +3 -0
  13. package/src/deployment-venue.mjs +1 -0
  14. package/src/doctor.mjs +2280 -195
  15. package/src/dollar-budget.mjs +373 -0
  16. package/src/dollar-fingerprint.mjs +83 -0
  17. package/src/egress-cli.mjs +316 -0
  18. package/src/egress-proxy-state.mjs +35 -5
  19. package/src/egress.mjs +12 -0
  20. package/src/env-allowlist.mjs +125 -6
  21. package/src/env-file.mjs +194 -25
  22. package/src/envelope.mjs +413 -0
  23. package/src/exit-code.mjs +22 -0
  24. package/src/fleet-lease.mjs +85 -25
  25. package/src/get-token.mjs +16 -5
  26. package/src/git-dirty.mjs +67 -0
  27. package/src/github-app-setup.mjs +6 -3
  28. package/src/github-host.mjs +5 -3
  29. package/src/host-pi.mjs +1 -1
  30. package/src/identity.mjs +2 -1
  31. package/src/image-preflight.mjs +98 -24
  32. package/src/image-ref.mjs +37 -0
  33. package/src/import-pi.mjs +4 -2
  34. package/src/index.mjs +407 -62
  35. package/src/init.mjs +18 -0
  36. package/src/job-id.mjs +26 -3
  37. package/src/live-probes.mjs +24 -9
  38. package/src/model-catalog.mjs +297 -0
  39. package/src/model-endpoints.mjs +671 -0
  40. package/src/model-ref.mjs +151 -0
  41. package/src/models-json.mjs +268 -0
  42. package/src/money.mjs +144 -0
  43. package/src/octokit-log.mjs +65 -0
  44. package/src/outbox-plan.mjs +218 -0
  45. package/src/outbox.mjs +29 -9
  46. package/src/output-cap.mjs +157 -0
  47. package/src/pause-windows.mjs +81 -2
  48. package/src/pi-model-loader.mjs +77 -0
  49. package/src/podman-stack.mjs +16 -3
  50. package/src/portfolio-snapshot.mjs +304 -0
  51. package/src/prepare-local.mjs +247 -12
  52. package/src/prepare.mjs +35 -3
  53. package/src/priorities.mjs +569 -0
  54. package/src/processor.mjs +599 -170
  55. package/src/project-id.mjs +17 -0
  56. package/src/projects.mjs +238 -0
  57. package/src/provider-steering.mjs +179 -65
  58. package/src/queue.mjs +111 -6
  59. package/src/reserved-env.mjs +31 -0
  60. package/src/run-container.mjs +59 -5
  61. package/src/run-history.mjs +379 -24
  62. package/src/run-mirror.mjs +30 -0
  63. package/src/runtime-settings.mjs +104 -9
  64. package/src/schedules.mjs +33 -1
  65. package/src/scoped-limits.mjs +447 -27
  66. package/src/service.mjs +15 -4
  67. package/src/session-store.mjs +131 -6
  68. package/src/start.mjs +528 -40
  69. package/src/triggers-file.mjs +65 -4
  70. package/src/triggers.mjs +141 -11
  71. package/src/up.mjs +308 -34
  72. package/src/valkey-endpoint.mjs +3 -2
@@ -0,0 +1,413 @@
1
+ /**
2
+ * The allocation envelope (issue #504, INT-ENVELOPE-FILE-CONTRACT): the operator's outer limits for delegated
3
+ * allocation. One `envelope.json` of a total per window, a floor per project, default weights and the delegation
4
+ * rules. An agent's plan moves headroom between projects INSIDE it (priorities.mjs); nothing an agent writes reaches
5
+ * this file.
6
+ *
7
+ * Pure and fs-injectable, on the scoped-limits.mjs and projects.mjs pattern: `parseEnvelope` judges the file TEXT
8
+ * fail-loud, `loadEnvelope` layers the one fs read on top. The worker will hold the parsed envelope in a watched ref
9
+ * with a last-good copy, as it does the projects file.
10
+ *
11
+ * `version` is REQUIRED and a newer one is refused: this is a money file, and a field an old build silently drops
12
+ * could widen what a plan may move. Unknown keys are REFUSED here, stricter than the operator-file policy that drops
13
+ * them in scoped-limits.json and projects.json: a mistyped `maxStepPct` dropped in silence would read as a bound the
14
+ * file does not hold, and every key in this file is a bound.
15
+ *
16
+ * Every refusal names the field, never a value: a value typed into the wrong field could be anything.
17
+ *
18
+ * Custom: envelope validated inline per scoped-limits.mjs precedent; zod not in deps
19
+ */
20
+
21
+ import { existsSync as fsExistsSync, readFileSync as fsReadFileSync, realpathSync as fsRealpathSync, statSync as fsStatSync } from "node:fs";
22
+ import { dirname, isAbsolute, resolve, sep } from "node:path";
23
+ import { configError } from "./config.mjs";
24
+ import { fingerprint } from "./fingerprint.mjs";
25
+ import { formatMicros, parseUsdMicros } from "./money.mjs";
26
+ import { OTHER, PLAN_WRITERS, WEIGHT_MAX } from "./priorities.mjs";
27
+ import { isProjectId } from "./project-id.mjs";
28
+
29
+ /** The highest schema version this build reads. A file declaring a higher one is refused loudly. */
30
+ export const ENVELOPE_VERSION = 1;
31
+ /** The windows an envelope may govern: the UTC day, the Monday week and the month of budget.mjs's keys. */
32
+ export const ENVELOPE_WINDOWS = Object.freeze(["day", "week", "month"]);
33
+ /** Each window's operator dollar field on a scoped-limits row (`project:<id>`). */
34
+ const WINDOW_USD_FIELD = Object.freeze({ day: "dayUsd", week: "weekUsd", month: "monthUsd" });
35
+ /** Each window's row fields that bound it: its own and every LONGER window's, since a week cap also caps each day in it. */
36
+ const BOUNDING_FIELDS = Object.freeze({ day: ["dayUsd", "weekUsd", "monthUsd"], week: ["weekUsd", "monthUsd"], month: ["monthUsd"] });
37
+
38
+ /** The longest interval and plan lifetime a file may set: a year. Beyond it a value is a typo, not a policy. */
39
+ const MAX_INTERVAL_HOURS = 24 * 366;
40
+ const MAX_PLAN_DAYS = 366;
41
+
42
+ const TOP_KEYS = new Set(["version", "window", "totalUsd", "floorsUsd", "defaultWeights", "delegation"]);
43
+ const DELEGATION_KEYS = new Set(["enabled", "writers", "maxStepPct", "minIntervalHours", "maxPlanDays"]);
44
+
45
+ function isPlainObject(value) {
46
+ return value !== null && typeof value === "object" && !Array.isArray(value);
47
+ }
48
+
49
+ function refuseUnknown(object, allowed, at, path) {
50
+ const extra = Object.keys(object).filter((k) => !allowed.has(k)).length;
51
+ // Counted, never quoted: the key itself may be anything an editor produced.
52
+ if (extra > 0) throw configError(`envelope file: ${at} has ${extra} unknown key(s); the fields are ${[...allowed].join(", ")}: ${path}`);
53
+ }
54
+
55
+ /**
56
+ * A floor: `parseUsdMicros`'s rules (a plain decimal, at most 6 decimals, a string recommended), except that 0 is a
57
+ * floor and not a refusal. A floor of 0 is the honest "no guaranteed share", and `_other`'s default.
58
+ */
59
+ function floorMicros(value, field, path) {
60
+ if (value === 0 || (typeof value === "string" && /^0(\.0{1,6})?$/.test(value))) return 0;
61
+ try {
62
+ return parseUsdMicros(value, field);
63
+ } catch (error) {
64
+ throw configError(`envelope file: ${error.message.replace(/above 0/, "of 0 or more")}: ${path}`);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Parse, validate and normalize the envelope file TEXT. Returns
70
+ * `{ version, window, totalMicros, floors, defaultWeights, delegation }`:
71
+ * - `floors`: `{ id: micros }` for every project the file names and `_other` (floor 0 when absent);
72
+ * - `defaultWeights`: `{ id: weight }` for the same entries, each 1 when absent;
73
+ * - `delegation`: `{ enabled, writers, maxStepPct, minIntervalHours, maxPlanDays }`, `enabled` false and the rest
74
+ * null when the file has no `delegation`.
75
+ * Throws `configError` on anything malformed. `path` is for messages only.
76
+ *
77
+ * `projects` (the parsed projects.json) and `limits` (the parsed scoped-limits.json) are what the floors are judged
78
+ * against: a floor names a project or `_other`, and a project's operator dollar row for the envelope's window may not
79
+ * sit below its floor. `maxCostMicros` is the deployment's per-job cost cap, or null: an envelope needs one, because
80
+ * every governed job reserves its per-job cap against its project's allocation.
81
+ */
82
+ export function parseEnvelope(text, path, { projects = [], limits = [], maxCostMicros = null } = {}) {
83
+ let raw;
84
+ try {
85
+ raw = JSON.parse(text);
86
+ } catch (error) {
87
+ // Not the parser's own message: it quotes file text around the fault.
88
+ const at = /position (\d+)/.exec(String(error?.message))?.[1];
89
+ throw configError(`envelope file is not valid JSON${at === undefined ? "" : ` (at character ${at})`}: ${path}`);
90
+ }
91
+ if (!isPlainObject(raw)) throw configError(`envelope file must be an object with "version", "window", "totalUsd" and "floorsUsd": ${path}`);
92
+ const version = raw.version;
93
+ if (!Number.isInteger(version) || version < 1) throw configError(`envelope file must have "version": ${ENVELOPE_VERSION} (an integer >= 1): ${path}`);
94
+ if (version > ENVELOPE_VERSION) throw configError(`envelope file written by a newer pi-dispatch (version ${version}; this build understands ${ENVELOPE_VERSION}): ${path}`);
95
+ refuseUnknown(raw, TOP_KEYS, "the file", path);
96
+
97
+ // A per-job cap first: without one no governed job can reserve, so every such job would refuse as a configuration
98
+ // error, and an envelope that admits nothing is a mistake the boot should name.
99
+ // A positive safe integer of micro-dollars, nothing else: 0 would be a cap that admits no priced call, and a string
100
+ // or a float here is a caller's defect that must not read as "a cap is set".
101
+ if (!Number.isSafeInteger(maxCostMicros) || maxCostMicros <= 0) {
102
+ throw configError(`envelope file needs a per-job cost cap: set PI_MAX_COST_USD (each job governed by the envelope reserves its per-job cap against its project's allocation): ${path}`);
103
+ }
104
+
105
+ if (!ENVELOPE_WINDOWS.includes(raw.window)) throw configError(`envelope file: window must be one of ${ENVELOPE_WINDOWS.join(", ")}: ${path}`);
106
+ const window = raw.window;
107
+
108
+ let totalMicros;
109
+ try {
110
+ totalMicros = parseUsdMicros(raw.totalUsd, "totalUsd");
111
+ } catch (error) {
112
+ throw configError(`envelope file: ${error.message}: ${path}`);
113
+ }
114
+
115
+ if (!isPlainObject(raw.floorsUsd)) throw configError(`envelope file: floorsUsd must be an object of project id to dollars: ${path}`);
116
+ const known = new Set((Array.isArray(projects) ? projects : []).map((p) => p?.id));
117
+ const floors = {};
118
+ for (const id of Object.keys(raw.floorsUsd).sort()) {
119
+ checkEntryId(id, known, "floorsUsd", path);
120
+ floors[id] = floorMicros(raw.floorsUsd[id], `floorsUsd.${id}`, path);
121
+ }
122
+ if (floors[OTHER] === undefined) floors[OTHER] = 0;
123
+ const sum = Object.values(floors).reduce((s, v) => s + v, 0);
124
+ if (sum > totalMicros) throw configError(`envelope file: the floors add up to ${formatMicros(sum)}, above totalUsd ${formatMicros(totalMicros)}: ${path}`);
125
+
126
+ const defaultWeights = {};
127
+ if (raw.defaultWeights !== undefined) {
128
+ if (!isPlainObject(raw.defaultWeights)) throw configError(`envelope file: defaultWeights must be an object of project id to weight: ${path}`);
129
+ for (const id of Object.keys(raw.defaultWeights)) {
130
+ checkEntryId(id, known, "defaultWeights", path);
131
+ if (floors[id] === undefined) throw configError(`envelope file: defaultWeights.${id} names a project with no floor; add it to floorsUsd (0 is a floor): ${path}`);
132
+ const w = raw.defaultWeights[id];
133
+ if (!Number.isInteger(w) || w < 0 || w > WEIGHT_MAX) throw configError(`envelope file: defaultWeights.${id} must be an integer from 0 to ${WEIGHT_MAX}: ${path}`);
134
+ }
135
+ }
136
+ for (const id of Object.keys(floors).sort()) defaultWeights[id] = raw.defaultWeights?.[id] ?? 1;
137
+
138
+ checkRowsAgainstFloors(floors, window, limits, path);
139
+ const delegation = parseDelegation(raw.delegation, path);
140
+ return { version, window, totalMicros, floors, defaultWeights, delegation };
141
+ }
142
+
143
+ /** A key of `floorsUsd` or `defaultWeights`: `_other`, or a project id that projects.json has. */
144
+ function checkEntryId(id, known, field, path) {
145
+ if (id === OTHER) return;
146
+ // The id is quoted only once it is known to match the id charset: it then cannot carry a control byte or a path.
147
+ if (!isProjectId(id)) throw configError(`envelope file: a key of ${field} is neither ${OTHER} nor a project id: ${path}`);
148
+ if (!known.has(id)) throw configError(`envelope file: ${field}.${id} names a project that is not in the projects file; add the project there first, or remove the key: ${path}`);
149
+ }
150
+
151
+ /**
152
+ * A project's operator dollar row (`project:<id>` in scoped-limits.json) for the envelope's window OR A LONGER ONE, below
153
+ * the project's floor, refuses the file and names both. The allocation can only lower a project's cap
154
+ * (`min(row, allocation)`), so such a floor promises money the operator's own row never lets the project spend: a
155
+ * `weekUsd` of $1 bounds every day of that week, so a day floor of $50 under it is as empty as a day row of $1. A
156
+ * shorter row (a `dayUsd` under a week envelope) is not compared: seven days of it may still reach the floor.
157
+ */
158
+ function checkRowsAgainstFloors(floors, window, limits, path) {
159
+ for (const [id, floor] of Object.entries(floors)) {
160
+ if (id === OTHER || floor === 0) continue;
161
+ const row = (Array.isArray(limits) ? limits : []).find((l) => l?.scope === `project:${id}`);
162
+ for (const field of BOUNDING_FIELDS[window]) {
163
+ const value = row?.[field];
164
+ if (value === null || value === undefined) continue;
165
+ const cap = parseUsdMicros(value, field);
166
+ if (cap < floor) {
167
+ throw configError(`envelope file: floorsUsd.${id} (${formatMicros(floor)}) is above the scoped-limits row project:${id} ${field} (${formatMicros(cap)}); lower the floor or raise the row: ${path}`);
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ /** The `delegation` block, normalized. Absent means off. When on, every bound is required: none has a silent default. */
174
+ function parseDelegation(raw, path) {
175
+ if (raw === undefined) return { enabled: false, writers: [], maxStepPct: null, minIntervalHours: null, maxPlanDays: null };
176
+ if (!isPlainObject(raw)) throw configError(`envelope file: delegation must be an object: ${path}`);
177
+ refuseUnknown(raw, DELEGATION_KEYS, "delegation", path);
178
+ if (typeof raw.enabled !== "boolean") throw configError(`envelope file: delegation.enabled must be true or false: ${path}`);
179
+ const out = { enabled: raw.enabled, writers: [], maxStepPct: null, minIntervalHours: null, maxPlanDays: null };
180
+ const need = raw.enabled;
181
+ if (raw.writers !== undefined || need) {
182
+ if (!Array.isArray(raw.writers) || raw.writers.length === 0 || raw.writers.some((w) => !PLAN_WRITERS.includes(w)) || new Set(raw.writers).size !== raw.writers.length) {
183
+ throw configError(`envelope file: delegation.writers must be a non-empty list of distinct writers from ${PLAN_WRITERS.join(", ")}: ${path}`);
184
+ }
185
+ out.writers = [...raw.writers].sort();
186
+ }
187
+ if (raw.maxStepPct !== undefined || need) {
188
+ // 0 is refused: delegation that may move nothing is delegation off, and `enabled: false` says that plainly.
189
+ if (!Number.isInteger(raw.maxStepPct) || raw.maxStepPct < 1 || raw.maxStepPct > 100) {
190
+ throw configError(`envelope file: delegation.maxStepPct must be an integer from 1 to 100 (to stop plans, set delegation.enabled to false): ${path}`);
191
+ }
192
+ out.maxStepPct = raw.maxStepPct;
193
+ }
194
+ if (raw.minIntervalHours !== undefined || need) {
195
+ if (!Number.isInteger(raw.minIntervalHours) || raw.minIntervalHours < 0 || raw.minIntervalHours > MAX_INTERVAL_HOURS) {
196
+ throw configError(`envelope file: delegation.minIntervalHours must be an integer from 0 to ${MAX_INTERVAL_HOURS}: ${path}`);
197
+ }
198
+ out.minIntervalHours = raw.minIntervalHours;
199
+ }
200
+ if (raw.maxPlanDays !== undefined || need) {
201
+ if (!Number.isInteger(raw.maxPlanDays) || raw.maxPlanDays < 1 || raw.maxPlanDays > MAX_PLAN_DAYS) {
202
+ throw configError(`envelope file: delegation.maxPlanDays must be an integer from 1 to ${MAX_PLAN_DAYS}: ${path}`);
203
+ }
204
+ out.maxPlanDays = raw.maxPlanDays;
205
+ }
206
+ return out;
207
+ }
208
+
209
+ /**
210
+ * Load and validate the envelope file named by `config.envelopeFile`. Returns null when it is unset: no envelope, no
211
+ * delegation anywhere, and every cap exactly as the operator's rows and windows set it. An empty string is a value, so
212
+ * it reaches `existsSync` and is refused, the rule the projects and scoped-limits keys follow. `context` is
213
+ * `parseEnvelope`'s. `readFileSync`/`existsSync` are injectable for tests.
214
+ */
215
+ export function loadEnvelope(config, context = {}, { readFileSync = fsReadFileSync, existsSync = fsExistsSync } = {}) {
216
+ const path = config.envelopeFile;
217
+ if (path === null || path === undefined) return null;
218
+ if (!existsSync(path)) throw configError(`envelope file does not exist: ${path}`);
219
+ return parseEnvelope(readFileSync(path, "utf8"), path, context);
220
+ }
221
+
222
+ /**
223
+ * The 16-hex digest of a normalized envelope (`fingerprint.mjs`: sorted keys, sha256). Two hosts with one envelope
224
+ * have one digest, whatever the file's whitespace or key order; a host whose digest differs from the applied plan's
225
+ * refuses governed jobs (issue #504 part 7). A digest, never the values, so it is admissible in a host row.
226
+ */
227
+ export function envelopeDigest(envelope) {
228
+ return fingerprint(envelope);
229
+ }
230
+
231
+ /** A file's identity: its device and inode, as `stat` with `bigint` reports them. Two names of one file share it. */
232
+ function identityOf(stat) {
233
+ return `${stat.dev}:${stat.ino}`;
234
+ }
235
+
236
+ /**
237
+ * `stat(path)` with `bigint`, following links as the kernel does, or null when nothing is there (ENOENT, ENOTDIR). Any
238
+ * other failure is a configuration error naming the path: a path the worker cannot stat is one it cannot vouch for.
239
+ */
240
+ function statOrNull(path, statSync, what) {
241
+ try {
242
+ return statSync(path, { bigint: true });
243
+ } catch (error) {
244
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return null;
245
+ throw configError(`${what} ${JSON.stringify(String(path))} cannot be read (${error?.code ?? "error"}), so the envelope's containment check cannot judge it`);
246
+ }
247
+ }
248
+
249
+ /**
250
+ * The job path's candidate locations, as path strings to `stat`. A job path's spelling is never a reason to refuse;
251
+ * instead it is judged at every location it can name:
252
+ * - the KERNEL's: absolutized WITHOUT normalizing (a relative path is appended to the working directory) and handed
253
+ * raw to `stat`, so `/srv/a/link/../b` follows the link first, then `..`;
254
+ * - the TEXTUAL one, `resolve(raw)`: what a container runtime mounts. Docker cleans a bind source as text before
255
+ * mounting it, so `T/link/../b` mounts `T/b` whatever `T/link` points at, and a container can write there;
256
+ * - for a RELATIVE path, both again against the shell's working directory: the Docker and Podman CLIs absolutize a
257
+ * relative bind source with Go's `filepath.Abs`, whose `os.Getwd` returns `$PWD` when `$PWD` names the working
258
+ * directory, possibly through a symlink or a firmlink, so `./../y` from such a cwd reaches the LOGICAL parent,
259
+ * not the physical one `process.cwd()` gives. Added when `$PWD` is absolute and has the cwd's identity (device and
260
+ * inode): Go's own `SameFile` test, so this is exactly when Go uses it.
261
+ */
262
+ function jobPathCandidates(path, cwd, env, statSync) {
263
+ const bases = [cwd];
264
+ if (!isAbsolute(path)) {
265
+ // env-internal PWD: the shell's logical working directory, read only to see the cwd the way Go's os.Getwd does.
266
+ const pwd = env?.PWD;
267
+ if (typeof pwd === "string" && isAbsolute(pwd) && pwd !== cwd) {
268
+ let same = false;
269
+ try {
270
+ same = identityOf(statSync(pwd, { bigint: true })) === identityOf(statSync(cwd, { bigint: true }));
271
+ } catch {
272
+ same = false;
273
+ }
274
+ if (same) bases.push(pwd);
275
+ }
276
+ }
277
+ const candidates = [];
278
+ for (const base of isAbsolute(path) ? [null] : bases) {
279
+ const raw = base === null ? path : `${base}${sep}${path}`;
280
+ candidates.push(raw, resolve(raw));
281
+ }
282
+ return candidates;
283
+ }
284
+
285
+ /**
286
+ * The envelope path, checked to be its own CANONICAL path, and its real path back. Absolute, and
287
+ * `realpathSync.native(path) === path`: no symlink anywhere on the way, no `.` or `..`, no case variant, no firmlink
288
+ * spelling. A symlink on the way could sit inside a job path, however far up the chain, and the job could repoint it;
289
+ * requiring the canonical path removes every such link instead of chasing them. The refusal names the canonical path
290
+ * to write instead, when there is one. The FILE must exist (the check runs with a successful load) and must have one
291
+ * link: a hard link elsewhere is the same file under a name this check cannot see, and it could sit in a job path.
292
+ */
293
+ function canonicalEnvelopePath(path, realpath, statSync) {
294
+ const text = String(path);
295
+ if (!isAbsolute(text)) throw configError(`the envelope path ${JSON.stringify(text)} must be absolute: write its canonical path, with no symlink, "." or ".." in it`);
296
+ let real;
297
+ try {
298
+ real = realpath(text);
299
+ } catch (error) {
300
+ throw configError(`the envelope path ${JSON.stringify(text)} cannot be resolved (${error?.code ?? "error"}); the containment check needs the file to exist at its canonical path`);
301
+ }
302
+ if (real !== text) {
303
+ throw configError(`the envelope path ${JSON.stringify(text)} is not its own canonical path; write ${JSON.stringify(real)} instead (no symlink, ".", "..", case variant or alias anywhere on the way), so no link a job can repoint leads to it`);
304
+ }
305
+ const stat = statSync(text, { bigint: true });
306
+ // Before the link count: a directory's nlink counts its subdirectories, so "has 3 hard links" would misname it.
307
+ if (!stat.isFile()) {
308
+ throw configError(`the envelope path ${JSON.stringify(text)} is not a regular file; point it at the envelope file itself`);
309
+ }
310
+ if (stat.nlink > 1n) {
311
+ throw configError(`the envelope file ${JSON.stringify(text)} has ${stat.nlink} hard links; a second name of the file could sit inside a job path, so keep exactly one`);
312
+ }
313
+ return real;
314
+ }
315
+
316
+ /**
317
+ * The identities (device and inode) of the envelope file and every directory above it. A job path "contains" the
318
+ * envelope exactly when one of its locations has one of these identities: identity, not spelling, so a firmlink
319
+ * (`/System/Volumes/Data/Users` and `/Users` on macOS), a bind-mount alias, a case variant or any other second name of
320
+ * a job path is judged as the directory it is. Equality counts: a job path that IS the envelope's folder exposes the
321
+ * file as surely as its parent does.
322
+ */
323
+ function envelopeIdentities(real, statSync) {
324
+ const out = new Set();
325
+ let p = real;
326
+ for (;;) {
327
+ const st = statOrNull(p, statSync, "the envelope path's ancestor");
328
+ if (st !== null) out.add(identityOf(st));
329
+ const parent = dirname(p);
330
+ if (parent === p) break;
331
+ p = parent;
332
+ }
333
+ return out;
334
+ }
335
+
336
+ /**
337
+ * The identities a job folder may never have (issue #504 part B, the prepare-time re-check): the envelope file's
338
+ * folder and every directory above it, as `envelopeIdentities` gives them, keyed `dev:ino`. A local job's folder is
339
+ * resolved at prepare and refused when its identity is one of these, so a folder whose spelling was checked earlier
340
+ * cannot have become the envelope's folder through a swapped link since. Throws as `envelopeInsideJobPaths` does on
341
+ * an envelope path that is not canonical, so a caller that cannot vouch for the envelope refuses rather than guesses.
342
+ */
343
+ export function envelopeProtectedIdentities(envelopePath, { realpathSync = fsRealpathSync.native, statSync = fsStatSync } = {}) {
344
+ return envelopeIdentities(canonicalEnvelopePath(envelopePath, realpathSync, statSync), statSync);
345
+ }
346
+
347
+ /** A `stat` result's identity (`dev:ino`), the key `envelopeProtectedIdentities` holds. Pass a `bigint` stat. */
348
+ export function fileIdentity(stat) {
349
+ return identityOf(stat);
350
+ }
351
+
352
+ /**
353
+ * The first host path a job container can see that holds the envelope file, as `{ kind, path }`, or null. The boot
354
+ * refuses an envelope inside one (issue #504 part 3): a job that can write the envelope can write its own bounds.
355
+ * `kind` is `cron-folder` (a cron trigger's `run.folder`, bind-mounted read-write), `run-root` (a
356
+ * `PI_DISPATCH_RUN_ROOTS` root, where `dispatch_run` and chained jobs run), `skills-dir` (a trigger's `run.skillsDir`,
357
+ * copied in) or `global-pi-dir` (`PI_GLOBAL_PI_DIR`). `path` is the configured job path, as the operator wrote it.
358
+ *
359
+ * The envelope path must be its own canonical path, and the file must have one link (`canonicalEnvelopePath`).
360
+ * Containment is then decided by FILE IDENTITY (device and inode), never by comparing path strings: a job path contains
361
+ * the envelope when any of its locations (`jobPathCandidates`) is the envelope's folder or a directory above it
362
+ * (`envelopeIdentities`). A job path is skipped only when none of its locations exists. Residual, named: a host bind
363
+ * mount of the envelope's folder placed under a job path is a second view this check cannot see; do not make one.
364
+ *
365
+ * Throws `configError` when the envelope path is not canonical or the file has more than one link, when a run root is
366
+ * relative, and when a path cannot be read for a reason other than its absence. `realpathSync`, `statSync`, `cwd` and
367
+ * `env` are injectable for tests.
368
+ */
369
+ export function envelopeInsideJobPaths(envelopePath, { cronFolders = [], runRoots = [], skillsDirs = [], globalPiDir = null } = {}, { realpathSync = fsRealpathSync.native, statSync = fsStatSync, cwd = process.cwd(), env = process.env } = {}) {
370
+ const real = canonicalEnvelopePath(envelopePath, realpathSync, statSync);
371
+ // A relative run root is refused under an envelope, as `PI_GLOBAL_PI_DIR` and `run.skillsDir` already must be
372
+ // absolute: the worker would judge it against its own working directory, while the admin's `dispatch_run` resolves
373
+ // it in the operator's pi process, so the two could mean different folders.
374
+ for (const root of runRoots ?? []) {
375
+ if (typeof root === "string" && root.trim() !== "" && !isAbsolute(root)) {
376
+ throw configError(`the run root ${JSON.stringify(root)} in PI_DISPATCH_RUN_ROOTS is relative; with an envelope set, every run root must be an absolute path, so the worker and the admin judge one folder`);
377
+ }
378
+ }
379
+ const identities = envelopeIdentities(real, statSync);
380
+ const groups = [
381
+ ["cron-folder", cronFolders],
382
+ ["run-root", runRoots],
383
+ ["skills-dir", skillsDirs],
384
+ ["global-pi-dir", globalPiDir === null || globalPiDir === undefined ? [] : [globalPiDir]],
385
+ ];
386
+ for (const [kind, paths] of groups) {
387
+ for (const p of paths ?? []) {
388
+ if (typeof p !== "string" || p.trim() === "") continue;
389
+ for (const candidate of jobPathCandidates(p, cwd, env, statSync)) {
390
+ const st = statOrNull(candidate, statSync, "a job path");
391
+ if (st !== null && identities.has(identityOf(st))) return { kind, path: p };
392
+ }
393
+ }
394
+ }
395
+ return null;
396
+ }
397
+
398
+ /**
399
+ * Load the envelope AND judge where it lies, in one step (issue #504 part B, INT-ENVELOPE-FILE-CONTRACT): every load
400
+ * that succeeds is followed by `envelopeInsideJobPaths` against the job paths of the moment, at boot and on every
401
+ * reload, because a later edit can move the file, or a job path, so that one holds the other. Returns the parsed
402
+ * envelope, null when `PI_ENVELOPE_FILE` is unset, or throws a `configError` naming the job path that holds it.
403
+ * `maxCostMicros` is the merged per-job cap in micro-dollars, or null.
404
+ */
405
+ export function loadEnvelopeChecked(config, { projects = [], limits = [], maxCostMicros = null, jobPaths }, { io = {}, containment = {} } = {}) {
406
+ const envelope = loadEnvelope(config, { projects, limits, maxCostMicros }, io);
407
+ if (envelope === null) return null;
408
+ const inside = envelopeInsideJobPaths(config.envelopeFile, jobPaths, containment);
409
+ if (inside) {
410
+ throw configError(`the envelope file ${JSON.stringify(String(config.envelopeFile))} lies inside a job path (${inside.kind} ${JSON.stringify(inside.path)}) that a job container can see, so a job could write its own bounds; move the envelope outside every cron run.folder, PI_DISPATCH_RUN_ROOTS root, run.skillsDir and PI_GLOBAL_PI_DIR`);
411
+ }
412
+ return envelope;
413
+ }
package/src/exit-code.mjs CHANGED
@@ -87,6 +87,28 @@ export function decideWait(exitCode) {
87
87
  }
88
88
  }
89
89
 
90
+ /**
91
+ * A listener for an error on the CLI's stdout (issue #503, gate round 1): a write to a pipe whose reader has exited
92
+ * fails with EPIPE, which Node emits as an `error` event, and with no listener that is an uncaught exception printed
93
+ * with its whole stack after the verb had already done its work. Installed by the worker's CLI for every verb. It only
94
+ * quiets the trace: the exit code stays what that uncaught exception gave (1), or the verb's own when it set one.
95
+ */
96
+ export function installStdoutPipeGuard({ stream = process.stdout, proc = process, write = (line) => process.stderr.write(line) } = {}) {
97
+ stream.on("error", (error) => {
98
+ // The reader went away (`pi-dispatch egress render | head -1`). The guard removes the stack trace and NOTHING
99
+ // else (gate round 2): the exit code is the one an unhandled EPIPE gave, 1, unless the verb had already set its
100
+ // own. Exiting 0 here told systemd's Restart=on-failure and launchd's SuccessfulExit=false that a long-running
101
+ // worker whose log reader died had finished cleanly, so neither restarted it (measured).
102
+ if (error?.code === "EPIPE") {
103
+ proc.exit(typeof proc.exitCode === "number" ? proc.exitCode : EXIT_INFRA);
104
+ return;
105
+ }
106
+ // Anything else on stdout is a fault worth one line on stderr, and infra's exit code, as Node's own default would be.
107
+ write(`error: writing to stdout failed: ${error?.code ?? error?.message ?? "unknown"}\n`);
108
+ proc.exit(EXIT_INFRA);
109
+ });
110
+ }
111
+
90
112
  /**
91
113
  * A process-level printer for a promise nobody handled (PR #475's review, round 2): Node prints such a rejection's
92
114
  * reason WHOLE, and a Valkey client's error may carry what it sent (connection.mjs scrubs that at the source; this is
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Fleet-wide leases for the two bounds that stopped meaning what they say when a second host appeared
2
+ * Fleet-wide leases for the bounds that stopped meaning what they say when a second host appeared
3
3
  * (issue #57).
4
4
  *
5
5
  * `PI_WAIT_CHECK_SLOTS` and a `scoped-limits.json` row's `concurrent` are both enforced by
@@ -27,6 +27,12 @@
27
27
  * a second source of truth: it is the SAME source writing down what it just established. Making the
28
28
  * reaper the claim's owner removes the contradiction rather than arguing around it.
29
29
  *
30
+ * The ENDPOINT claim (issue #503) is the third, and it is a scope claim in every property that matters
31
+ * here: a job takes one slot of a declared model server at pickup and holds it until its container is
32
+ * gone, so the claim is for a container, its TTL is the scope claim's, and the boot reaper owns it the
33
+ * same way. Local jobs take it too, where they skip the scope claim: a folder path names nothing across
34
+ * hosts, but an endpoint is one physical server.
35
+ *
30
36
  * N INDEPENDENT KEYS, NEVER ONE COUNTER. A counter with one TTL loses every claim when it expires and
31
37
  * leaks a permanent `+1` on a crash; N `SET NX PX` keys mean a lost release costs exactly one slot for
32
38
  * exactly the TTL and never the whole semaphore. `wait:key:<dedupId>` is the in-repo precedent.
@@ -36,9 +42,23 @@
36
42
  * `concurrent: 2` scope frees the other holder's slot.
37
43
  */
38
44
 
39
- /** Slot keys. Both live under a prefix an operator can see whole with one `KEYS`. */
45
+ import { createHash } from "node:crypto";
46
+
47
+ /**
48
+ * Sixteen hex of a sha256: the `localJobId` idiom, shared by the scope budget keys (`scopeKeyPrefix`) and the
49
+ * two hashed slot keys below, so one spelling of "the hash of this name" exists. A hash rather than the name
50
+ * itself because a scope legally contains `:` and `/`, which would collide with the key grammar.
51
+ */
52
+ export const hash16 = (value) => createHash("sha256").update(String(value)).digest("hex").slice(0, 16);
53
+
54
+ /** Slot keys. All live under a prefix an operator can see whole with one `KEYS`. */
40
55
  export const checkSlotKey = (i) => `wait:check:${i}`;
41
56
  export const scopeSlotKey = (hash, i) => `slot:s:${hash}:${i}`;
57
+ /**
58
+ * A model endpoint's slots (issue #503): `slot:m:<hash16(id)>:<i>`. Hashed although an endpoint id is already
59
+ * `[a-z0-9-]{1,32}`, so the three slot keys share one shape and the sweeper one argument list.
60
+ */
61
+ export const endpointSlotKey = (hash, i) => `slot:m:${hash}:${i}`;
42
62
 
43
63
  /**
44
64
  * Build a lease over `slots` numbered keys.
@@ -50,6 +70,16 @@ export const scopeSlotKey = (hash, i) => `slot:s:${hash}:${i}`;
50
70
  * deployment or stop every scoped job; the in-process bound is still there underneath, so failing open
51
71
  * degrades the fleet bound to the per-host one, which is exactly the behaviour before this existed.
52
72
  */
73
+ /**
74
+ * Compare-and-delete, in ONE server-side step (PR #518's gate). A GET followed by a DEL is not release-if-mine: between
75
+ * the two, the claim can expire and another host take it, and the DEL then frees THAT host's slot (measured). The
76
+ * script deletes only while the value is still ours.
77
+ */
78
+ export const RELEASE_IF_MINE = 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end';
79
+
80
+ /** The sweep's twin: delete only while the value starts with this host's `<workerName>#` prefix. */
81
+ export const DELETE_IF_PREFIX = 'local v = redis.call("get", KEYS[1]) if v and string.sub(v, 1, #ARGV[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end';
82
+
53
83
  export function makeFleetLease({ redis, holderPrefix, keyFor, ttlMs, now = () => Date.now(), log = () => {}, timeoutMs = 2_000 }) {
54
84
  // Bounded for `host-registry.mjs`'s reason, which applies to every module sharing this client:
55
85
  // `maxRetriesPerRequest: null` makes a command against an unreachable server QUEUE rather than reject,
@@ -76,24 +106,27 @@ export function makeFleetLease({ redis, holderPrefix, keyFor, ttlMs, now = () =>
76
106
  if (!Number.isFinite(slots) || slots < 1) return { ok: true, release: async () => {}, refresh: async () => true };
77
107
  const holder = `${holderPrefix}#${id}`;
78
108
  const start = Math.abs(hashCode(String(id))) % slots;
109
+ const releaseIfMine = async (key) => {
110
+ try {
111
+ await bounded(redis.eval(RELEASE_IF_MINE, 1, key, holder));
112
+ } catch {
113
+ // The TTL is the backstop. A lost release costs one slot for one TTL.
114
+ }
115
+ };
116
+ const attempted = [];
79
117
  for (let n = 0; n < slots; n++) {
80
118
  const key = keyFor(...keyArgs, (start + n) % slots);
119
+ attempted.push(key);
81
120
  try {
82
121
  const won = await bounded(redis.set(key, holder, "PX", perCall ?? ttlMs, "NX"));
83
122
  if (!won) continue;
84
123
  return {
85
124
  ok: true,
86
125
  key,
87
- async release() {
88
- try {
89
- // Release-if-MINE, which is what makes this idempotent where the in-process map is
90
- // not: a double release cannot free another holder's slot, because the second call
91
- // finds a value that is no longer ours.
92
- if ((await bounded(redis.get(key))) === holder) await bounded(redis.del(key));
93
- } catch {
94
- // The TTL is the backstop. A lost release costs one slot for one TTL.
95
- }
96
- },
126
+ // Release-if-MINE, which is what makes this idempotent where the in-process map is not: a double
127
+ // release cannot free another holder's slot, because the second call finds a value that is no
128
+ // longer ours. Atomic, so a release after the TTL cannot free a claim another host took since.
129
+ release: () => releaseIfMine(key),
97
130
  async refresh(nextMs = ttlMs) {
98
131
  try {
99
132
  if ((await bounded(redis.get(key))) !== holder) return false;
@@ -108,7 +141,13 @@ export function makeFleetLease({ redis, holderPrefix, keyFor, ttlMs, now = () =>
108
141
  // Granting is the safe direction: the in-process bound is still underneath, so this
109
142
  // degrades the fleet-wide ceiling to the per-host one rather than to nothing.
110
143
  log("fleet_lease_unavailable", { reason: err?.message });
111
- return { ok: true, degraded: true, release: async () => {}, refresh: async () => true };
144
+ // A TIMED-OUT SET IS NOT A FAILED ONE (PR #518's gate). The shared client queues commands rather
145
+ // than rejecting them, so the SET this call gave up on can still land later and hold the slot for
146
+ // a whole TTL in another host's way, with nobody left to release it (measured with a slow Valkey).
147
+ // So the degraded handle still releases, best-effort and compare-and-delete, every key it tried:
148
+ // only a key holding our own value is deleted, so a key that was never ours is left alone. A
149
+ // release that runs before the late SET lands misses it, and the TTL is the backstop then.
150
+ return { ok: true, degraded: true, release: async () => void (await Promise.all(attempted.map(releaseIfMine))), refresh: async () => true };
112
151
  }
113
152
  }
114
153
  return null;
@@ -139,31 +178,52 @@ function hashCode(s) {
139
178
  * No `KEYS`, no `SCAN`, and no index set to leak.
140
179
  */
141
180
  export function makeScopeClaimSweeper({ redis, workerName, limits, log = () => {}, timeoutMs = 2_000 }) {
181
+ return makeClaimSweeper({ redis, workerName, keyFor: scopeSlotKey, rows: (limits ?? []).map((row) => ({ hash: row?.hash, count: Number(row?.concurrent) })), event: "scope_claims", log, timeoutMs });
182
+ }
183
+
184
+ /**
185
+ * The sweep itself, over any hashed slot key (issue #503 added the endpoint slots beside the scope slots). Each row is
186
+ * `{ hash, count }`: the hash the key is built from and how many indexes can carry a claim. `event` names the two log
187
+ * lines, `<event>_sweep_skipped` and `<event>_swept`, so each lease's sweep says which it is.
188
+ *
189
+ * An endpoint's claim is for a container exactly as a scope's is (a job holds its slot for its whole run), so the
190
+ * same precondition decides it: only after a reaper that enumerated.
191
+ */
192
+ export function makeClaimSweeper({ redis, workerName, keyFor, rows, event, log = () => {}, timeoutMs = 2_000 }) {
142
193
  return async function sweep({ reaped }) {
143
194
  if (!reaped) {
144
- log("scope_claims_sweep_skipped", { reason: "reaper-skipped" });
195
+ log(`${event}_sweep_skipped`, { reason: "reaper-skipped" });
145
196
  return { swept: 0, skipped: true };
146
197
  }
147
198
  const prefix = `${workerName}#`;
148
199
  let swept = 0;
149
- for (const row of limits ?? []) {
150
- const n = Number(row?.concurrent);
200
+ for (const row of rows ?? []) {
201
+ const n = Number(row?.count);
151
202
  if (!Number.isFinite(n) || n < 1 || !row?.hash) continue;
152
203
  for (let i = 0; i < n; i++) {
153
- const key = scopeSlotKey(row.hash, i);
204
+ const key = keyFor(row.hash, i);
154
205
  try {
155
- const held = await withTimeout(redis.get(key), timeoutMs);
156
- if (typeof held === "string" && held.startsWith(prefix)) {
157
- await withTimeout(redis.del(key), timeoutMs);
158
- swept++;
206
+ // One atomic compare-and-delete per key, the release's own rule: a GET then DEL could delete a claim
207
+ // another host took in between.
208
+ if (Number(await withTimeout(redis.eval(DELETE_IF_PREFIX, 1, key, prefix), timeoutMs)) > 0) swept++;
209
+ } catch (err) {
210
+ // A REPLY error is about THIS key (a WRONGTYPE on a key someone else wrote under the prefix): the server
211
+ // answered, so the next key is worth asking. Logged by key, never by value, and the sweep goes on
212
+ // (PR #518's second gate).
213
+ if (err?.name === "ReplyError") {
214
+ log(`${event}_sweep_key_error`, { key, reason: String(err.message).slice(0, 200) });
215
+ continue;
159
216
  }
160
- } catch {
161
- // Best-effort by contract: this is an OPTIMISATION over the TTL, never the mechanism, so a
162
- // fault here costs at most one TTL of a stale claim and must never block boot.
217
+ // Best-effort by contract: this is an OPTIMISATION over the TTL, never the mechanism, so it must never
218
+ // block boot. It STOPS at the first fault (PR #518's gate): a Valkey that does not answer would
219
+ // otherwise cost one timeout per key, 64 per endpoint, before the worker starts. A timeout or a connection
220
+ // fault is about the server, so no other key would fare better.
221
+ log(`${event}_sweep_skipped`, { reason: "valkey-fault", detail: err?.message, swept });
222
+ return { swept, skipped: true };
163
223
  }
164
224
  }
165
225
  }
166
- if (swept > 0) log("scope_claims_swept", { count: swept });
226
+ if (swept > 0) log(`${event}_swept`, { count: swept });
167
227
  return { swept, skipped: false };
168
228
  };
169
229
  }