@edgehero/pi-dispatch 2.0.0 → 3.0.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 (80) hide show
  1. package/.env.example +44 -7
  2. package/README.md +14 -6
  3. package/deploy/com.pi-dispatch.worker.plist +1 -1
  4. package/deploy/docker-compose.yml +12 -0
  5. package/deploy/egress-proxy.conf +28 -3
  6. package/deploy/pi-dispatch-egress-proxy.container +8 -2
  7. package/deploy/worker-env-wrapper.cmd +1 -1
  8. package/deploy/worker-env-wrapper.sh +3 -3
  9. package/package.json +9 -2
  10. package/src/allocation.mjs +731 -0
  11. package/src/backends.mjs +243 -0
  12. package/src/budget.mjs +40 -4
  13. package/src/cli.mjs +222 -11
  14. package/src/config.mjs +126 -5
  15. package/src/daemon-facts.mjs +3 -0
  16. package/src/deployment-venue.mjs +1 -0
  17. package/src/doctor.mjs +2316 -183
  18. package/src/dollar-budget.mjs +373 -0
  19. package/src/dollar-fingerprint.mjs +83 -0
  20. package/src/egress-cli.mjs +316 -0
  21. package/src/egress-proxy-state.mjs +35 -5
  22. package/src/egress.mjs +16 -3
  23. package/src/env-allowlist.mjs +142 -18
  24. package/src/env-file.mjs +194 -25
  25. package/src/envelope.mjs +413 -0
  26. package/src/exit-code.mjs +22 -0
  27. package/src/fleet-lease.mjs +85 -25
  28. package/src/get-token.mjs +16 -5
  29. package/src/git-dirty.mjs +67 -0
  30. package/src/github-app-setup.mjs +6 -3
  31. package/src/github-host.mjs +5 -3
  32. package/src/host-pi.mjs +19 -3
  33. package/src/identity.mjs +2 -1
  34. package/src/image-preflight.mjs +98 -24
  35. package/src/image-ref.mjs +37 -0
  36. package/src/import-pi.mjs +4 -2
  37. package/src/index.mjs +407 -62
  38. package/src/init.mjs +18 -0
  39. package/src/job-id.mjs +26 -3
  40. package/src/live-probes.mjs +24 -9
  41. package/src/model-catalog.mjs +297 -0
  42. package/src/model-endpoints.mjs +649 -0
  43. package/src/model-ref.mjs +151 -0
  44. package/src/models-json.mjs +262 -0
  45. package/src/money.mjs +144 -0
  46. package/src/octokit-log.mjs +65 -0
  47. package/src/outbox-plan.mjs +218 -0
  48. package/src/outbox.mjs +29 -9
  49. package/src/output-cap.mjs +157 -0
  50. package/src/packages.mjs +2 -2
  51. package/src/pause-windows.mjs +81 -2
  52. package/src/pi-model-loader.mjs +77 -0
  53. package/src/podman-stack.mjs +16 -3
  54. package/src/portfolio-snapshot.mjs +304 -0
  55. package/src/prepare-local.mjs +247 -12
  56. package/src/prepare.mjs +35 -3
  57. package/src/pricing.mjs +9 -5
  58. package/src/priorities.mjs +569 -0
  59. package/src/processor.mjs +603 -173
  60. package/src/project-id.mjs +17 -0
  61. package/src/projects.mjs +238 -0
  62. package/src/provider-key.mjs +32 -7
  63. package/src/provider-steering.mjs +214 -59
  64. package/src/queue.mjs +111 -6
  65. package/src/reserved-env.mjs +30 -0
  66. package/src/run-container.mjs +59 -5
  67. package/src/run-history.mjs +379 -24
  68. package/src/run-mirror.mjs +30 -0
  69. package/src/runtime-settings.mjs +104 -9
  70. package/src/schedules.mjs +33 -1
  71. package/src/scoped-limits.mjs +447 -27
  72. package/src/secrets.mjs +2 -1
  73. package/src/service.mjs +15 -4
  74. package/src/session-store.mjs +131 -6
  75. package/src/start.mjs +528 -40
  76. package/src/subscriptions.mjs +7 -3
  77. package/src/triggers-file.mjs +65 -4
  78. package/src/triggers.mjs +140 -9
  79. package/src/up.mjs +308 -34
  80. package/src/valkey-endpoint.mjs +3 -2
@@ -23,21 +23,102 @@
23
23
  * worker drops would be a silently WIDENED spend limit. Pause-windows shipping without a version is a
24
24
  * sunk decision, not a precedent to extend to enforcement config.
25
25
  *
26
+ * Version 2 (issues #501 part 5, #502 part 6) is exactly that case made real: dollar windows (`dayUsd`,
27
+ * `weekUsd`, `monthUsd`) on a scope row, and `model:<provider>/<model>` rows carrying those alone. They are
28
+ * DOLLAR ledgers (dollar-budget.mjs, built here by `dollarCapsFor` and `modelDollarRows`), never job-count ones,
29
+ * so `budgetCapsFor`, `concurrencyFor` and the mutex never see them.
30
+ *
31
+ * Issue #498 adds the forge-qualified scope (`github:acme/web`), which names one forge's repo where a bare
32
+ * `acme/web` names that repo on every forge. A job matches its qualified row first, then its bare row, and a file
33
+ * holding both for one repo is refused. EVERY key (the job-count windows, the dollar windows, the in-process slot and
34
+ * the fleet lease) is built from the MATCHED ROW's scope, never from the job's, so a bare row keeps exactly the key
35
+ * it always had and a qualified row counts on its own.
36
+ *
37
+ * Issue #499 part B adds the project row (`project:<id>`, the id from projects.json; version 2). It carries the job-count windows,
38
+ * `concurrent` and the dollar windows, and it caps every member of the project as one: a job reserves in
39
+ * its repo or folder row, then its project's row, then the global windows (`scopedLedgers`), and its keys are built from
40
+ * the project ROW's scope like every other row's, so a project needs no keyspace of its own.
41
+ *
26
42
  * Custom: scoped limits validated inline per triggers.mjs/pause-windows.mjs precedent; zod not in deps
27
43
  */
28
44
 
29
- import { createHash } from "node:crypto";
30
45
  import { existsSync as fsExistsSync, readFileSync as fsReadFileSync } from "node:fs";
31
46
  import { isAbsolute, resolve } from "node:path";
32
47
  import { configError } from "./config.mjs";
33
- import { scopeOf } from "./pause-windows.mjs";
48
+ import { DOLLAR_KEY_PREFIX } from "./dollar-budget.mjs";
49
+ import { hash16 } from "./fleet-lease.mjs";
50
+ import { splitModelEntry } from "./model-ref.mjs";
51
+ import { formatMicros, parseUsdMicros } from "./money.mjs";
52
+ import { parseScopeString, qualifiedScopeOf, scopeOf } from "./pause-windows.mjs";
53
+ import { PROJECT_ID_RE, isProjectId } from "./project-id.mjs";
34
54
 
35
- /** The schema version this build reads and writes. A file declaring a higher one is refused loudly. */
36
- export const SCOPED_LIMITS_VERSION = 1;
55
+ /**
56
+ * The highest schema version this build reads. A file declaring a higher one is refused loudly. The admin writes the
57
+ * LOWEST version that expresses a file (`scopedLimitsVersionFor`), so a file with no dollar or model row stays 1.
58
+ *
59
+ * Version 2 (issues #501 part 5 and #502 part 6) adds dollar windows: `dayUsd`, `weekUsd` and `monthUsd` on a repo or
60
+ * folder row, and `model:<provider>/<model>` rows that carry those three fields only. A version 1 file that uses
61
+ * either is refused with an error naming version 2: a version 1 worker drops unknown fields, so a file that says 1
62
+ * while it means 2 would read as a narrower cap on one build and no cap on another.
63
+ *
64
+ * A forge-qualified row (`github:acme/web`, issue #498) needs version 2 as well, for the same reason: every released
65
+ * build reads `github:acme/web` as a plain repo string no job ever has, so a version 1 file holding one would be a
66
+ * cap, a concurrency limit and a lease that one build enforces and another silently ignores. Version 2 makes every
67
+ * older build refuse the file loudly instead.
68
+ */
69
+ export const SCOPED_LIMITS_VERSION = 2;
37
70
 
38
- /** The four limit fields a row may carry, in display order. */
71
+ /** The four job-count limit fields a row may carry, in display order. */
39
72
  const LIMIT_FIELDS = ["day", "week", "month", "concurrent"];
40
73
 
74
+ /** The three dollar window fields (version 2), in display order. Each maps to a dollar window: day, week, month. */
75
+ export const USD_LIMIT_FIELDS = Object.freeze(["dayUsd", "weekUsd", "monthUsd"]);
76
+
77
+ /**
78
+ * The scope prefix of a per-model row (issue #502 part 6): `model:<provider>/<model>`. Reserved in BOTH versions. A
79
+ * version 1 file naming it is refused (naming version 2) rather than read as a folder called `model:...`.
80
+ */
81
+ export const MODEL_SCOPE_PREFIX = "model:";
82
+
83
+ /** The dollar key prefix of a repo or folder row: `budget:usd:s:<hash16>` (the deployment's is `budget:usd`). */
84
+ export const SCOPE_DOLLAR_KEY_PREFIX = `${DOLLAR_KEY_PREFIX}:s`;
85
+ /** The dollar key prefix of a model row: `budget:usd:mdl:<hash16>`. */
86
+ export const MODEL_DOLLAR_KEY_PREFIX = `${DOLLAR_KEY_PREFIX}:mdl`;
87
+
88
+ /** A scope that LOOKS like a model row (any case, `models`, spaces before the colon) but is not the exact prefix form. */
89
+ const MODEL_NEAR_MISS = /^models?\s*:/i;
90
+
91
+ /**
92
+ * The scope prefix of a project row (issue #499 part B): `project:<id>`, the id from projects.json. A project row needs
93
+ * version 2 in EVERY field, its job counts and `concurrent` included, for the forge-qualified row's reason: the last
94
+ * released build (2.1.0) reads `project:shop` as a plain repo string no job has, so a version 1 file holding one would
95
+ * be a cap and a concurrency limit that one build enforces and another silently drops. Version 2 makes that build
96
+ * refuse the file as newer instead.
97
+ */
98
+ export const PROJECT_SCOPE_PREFIX = "project:";
99
+ /** A scope that LOOKS like a project row (any case, `projects`, spaces before the colon). Only the exact form parses. */
100
+ const PROJECT_NEAR_MISS = /^projects?\s*:/i;
101
+
102
+ /** Is this row scope a per-model row? */
103
+ export function isModelScope(scope) {
104
+ return typeof scope === "string" && scope.startsWith(MODEL_SCOPE_PREFIX);
105
+ }
106
+
107
+ /** Is this row scope a project row? */
108
+ export function isProjectScope(scope) {
109
+ return typeof scope === "string" && scope.startsWith(PROJECT_SCOPE_PREFIX);
110
+ }
111
+
112
+ /** The row scope of a project: `project:<id>`. Every key of a project row hashes this string. */
113
+ export function projectScope(id) {
114
+ return `${PROJECT_SCOPE_PREFIX}${id}`;
115
+ }
116
+
117
+ /** A row that is never a job's own scope: a model row or a project row. `limitFor` and the scope advisories skip them. */
118
+ function isGroupScope(scope) {
119
+ return isModelScope(scope) || isProjectScope(scope);
120
+ }
121
+
41
122
  function isNonEmptyString(value) {
42
123
  return typeof value === "string" && value.trim() !== "";
43
124
  }
@@ -75,7 +156,8 @@ export function canonicalScope(job) {
75
156
 
76
157
  /**
77
158
  * Parse, validate, and normalize the scoped-limits file TEXT. Returns the normalized `limits` array
78
- * (every row rebuilt as an explicit `{ scope, day, week, month, concurrent }` literal, `null` for absent
159
+ * (every row rebuilt as an explicit `{ scope, day, week, month, concurrent }` literal in a version 1 file, and
160
+ * `{ scope, day, week, month, concurrent, dayUsd, weekUsd, monthUsd }` in a version 2 one, `null` for absent
79
161
  * fields, unknown fields dropped -- the operator-file policy). Throws `configError` (fail-loud) on any
80
162
  * malformed entry. `path` is for error messages only -- this function touches no filesystem.
81
163
  */
@@ -91,7 +173,7 @@ export function parseScopedLimits(text, path) {
91
173
  }
92
174
  const version = parsed.version;
93
175
  if (!Number.isInteger(version) || version < 1) {
94
- throw configError(`scoped-limits file must have "version": 1 (an integer >= 1): ${path}`);
176
+ throw configError(`scoped-limits file must have "version": 1 or ${SCOPED_LIMITS_VERSION} (an integer >= 1): ${path}`);
95
177
  }
96
178
  if (version > SCOPED_LIMITS_VERSION) {
97
179
  throw configError(`scoped-limits file written by a newer pi-dispatch (version ${version}; this build understands ${SCOPED_LIMITS_VERSION}): ${path}`);
@@ -99,26 +181,98 @@ export function parseScopedLimits(text, path) {
99
181
  if (!Array.isArray(parsed.limits)) {
100
182
  throw configError(`scoped-limits file must have a "limits" array: ${path}`);
101
183
  }
102
- const rows = parsed.limits.map((row, index) => normalizeLimit(row, index, path));
184
+ const rows = parsed.limits.map((row, index) => normalizeLimit(row, index, path, version));
185
+ refuseMixedForms(rows, path);
103
186
  const seen = new Map();
104
187
  rows.forEach((row, index) => {
105
- if (seen.has(row.scope)) {
188
+ // A model row's identity is its LOWERCASED ref, the one its dollar key hashes (`modelDollarKeyPrefix`): two rows
189
+ // differing only in case would be two caps on one counter.
190
+ const id = isModelScope(row.scope) ? row.scope.toLowerCase() : row.scope;
191
+ if (seen.has(id)) {
106
192
  // Two rows for one scope is a precedence question with no right answer; the admin's
107
193
  // edit-in-place never produces one, so a duplicate is always a hand-edit mistake.
108
- throw configError(`scoped limit at index ${index}: duplicate scope ${JSON.stringify(row.scope)} (first at index ${seen.get(row.scope)}): ${path}`);
194
+ throw configError(`scoped limit at index ${index}: duplicate scope ${JSON.stringify(row.scope)} (first at index ${seen.get(id)}): ${path}`);
109
195
  }
110
- seen.set(row.scope, index);
196
+ seen.set(id, index);
111
197
  });
112
198
  return rows;
113
199
  }
114
200
 
115
- function normalizeLimit(row, index, path) {
201
+ /**
202
+ * A bare row and a qualified row for one repo (`acme/web` beside `github:acme/web`) refuse the file, naming both
203
+ * indexes (issue #498). Whatever fields either carries, count or dollar: one row applies to a job, and with both forms
204
+ * present that rule would need a precedence ladder, which reads as one cap while the other silently stops counting.
205
+ * Refusing is the simpler rule, and it is loud. Two qualified rows for one repo on two forges are fine.
206
+ */
207
+ function refuseMixedForms(rows, path) {
208
+ const bare = new Map();
209
+ rows.forEach((row, index) => {
210
+ if (!isGroupScope(row.scope) && parseScopeString(row.scope).type === "bare") bare.set(row.scope, index);
211
+ });
212
+ rows.forEach((row, index) => {
213
+ if (isGroupScope(row.scope)) return;
214
+ const parsed = parseScopeString(row.scope);
215
+ if (parsed.type !== "qualified" || !bare.has(parsed.repo)) return;
216
+ const other = bare.get(parsed.repo);
217
+ throw configError(`scoped limit at index ${index}: ${JSON.stringify(row.scope)} and the bare ${JSON.stringify(parsed.repo)} at index ${other} name the same repo; keep one form (a bare row covers every forge, a qualified row one forge): ${path}`);
218
+ });
219
+ }
220
+
221
+ /** A dollar field's value, or the refusal: `parseUsdMicros`'s rules (strings recommended), named by row and field. */
222
+ function usdField(value, field, at, path) {
223
+ try {
224
+ return parseUsdMicros(value, field);
225
+ } catch (error) {
226
+ throw configError(`${at}: ${error.message}: ${path}`);
227
+ }
228
+ }
229
+
230
+ /**
231
+ * A per-model row (version 2): `{ scope: "model:<provider>/<model>", dayUsd?, weekUsd?, monthUsd? }`. The ref follows
232
+ * the allowed-model list's rule (`splitModelEntry`: split at the first `/`, each half a valid id). `day`, `week`,
233
+ * `month` and `concurrent` are refused here: a model is not a scope a job runs IN, so a job-count cap or a
234
+ * concurrency limit on it would read as enforced while nothing counts it.
235
+ */
236
+ function normalizeModelLimit(row, trimmed, at, path, version) {
237
+ if (version < 2) throw configError(`${at}: a "model:" row needs "version": 2 (this file says ${version}): ${path}`);
238
+ const ref = splitModelEntry(trimmed.slice(MODEL_SCOPE_PREFIX.length));
239
+ if (ref === null) throw configError(`${at}: a model row's scope must be model:<provider>/<model> (each 1 to 64 characters, no spaces): ${path}`);
240
+ // The runner folds model-less and overflow calls into an `other/other` usage row, so a row naming that pair
241
+ // could never be told apart from the fold.
242
+ if (`${ref.provider}/${ref.model}`.toLowerCase() === "other/other") throw configError(`${at}: model:other/other names the usage ledger's fold row, not a model: ${path}`);
243
+ for (const field of LIMIT_FIELDS) {
244
+ if (row[field] !== undefined && row[field] !== null) throw configError(`${at}: a model row carries dayUsd, weekUsd and monthUsd only (${field} is refused): ${path}`);
245
+ }
246
+ const norm = { scope: `${MODEL_SCOPE_PREFIX}${ref.provider}/${ref.model}`, day: null, week: null, month: null, concurrent: null, dayUsd: null, weekUsd: null, monthUsd: null };
247
+ let any = false;
248
+ for (const field of USD_LIMIT_FIELDS) {
249
+ if (row[field] === undefined || row[field] === null) continue;
250
+ norm[field] = formatMicros(usdField(row[field], field, at, path));
251
+ any = true;
252
+ }
253
+ if (!any) throw configError(`${at}: a model row needs at least one of dayUsd, weekUsd, monthUsd: ${path}`);
254
+ return norm;
255
+ }
256
+
257
+ function normalizeLimit(row, index, path, version = SCOPED_LIMITS_VERSION) {
116
258
  const at = `scoped limit at index ${index}`;
117
259
  if (row === null || typeof row !== "object" || Array.isArray(row)) {
118
260
  throw configError(`${at}: must be an object: ${path}`);
119
261
  }
120
262
  if (!isNonEmptyString(row.scope)) throw configError(`${at}: scope must be a non-empty string: ${path}`);
121
263
  const trimmed = row.scope.trim().normalize("NFC"); // the same NFC canonicalScope applies job-side
264
+ if (isModelScope(trimmed)) return normalizeModelLimit(row, trimmed, at, path, version);
265
+ // A NEAR MISS of a reserved prefix is refused, never read as a repo or folder row (PR #549's review): a
266
+ // mistyped `Model:openai/x`, `models:...` or `model :...` would otherwise parse as a repo scope no job ever has,
267
+ // a dollar cap that governs nothing while the file reads as capping a model.
268
+ if (MODEL_NEAR_MISS.test(trimmed)) throw configError(`${at}: a model row's scope is written exactly model:<provider>/<model> (lowercase "model", no space before the colon): ${path}`);
269
+ // Issue #499 part B: `project:<id>` is a project row. Exactly that form: a near miss (`Project:shop`, `projects:shop`,
270
+ // `project :shop`) or a malformed id is refused, never read as a repo row no job has, for the model row's reason.
271
+ if (PROJECT_NEAR_MISS.test(trimmed)) {
272
+ const id = trimmed.startsWith(PROJECT_SCOPE_PREFIX) ? trimmed.slice(PROJECT_SCOPE_PREFIX.length) : null;
273
+ if (id === null) throw configError(`${at}: a project row's scope is written exactly project:<id> (lowercase "project", no space before the colon): ${path}`);
274
+ if (!isProjectId(id)) throw configError(`${at}: a project row's id must match ${PROJECT_ID_RE.source} (the id in projects.json): ${path}`);
275
+ }
122
276
  if (trimmed === "*") {
123
277
  // "*" as ONE shared counter is redundant with the global caps, so the only useful reading is a
124
278
  // per-scope default -- the OPPOSITE of what "*" means one file over (pause-windows: one rule
@@ -131,19 +285,42 @@ function normalizeLimit(row, index, path) {
131
285
  // nothing, and a silently inert money limit is the failure class this repo refuses outright.
132
286
  throw configError(`${at}: scopes match exactly; a scope containing "*" is refused (no globs): ${path}`);
133
287
  }
288
+ // Issue #498: `<forge kind>:<repo>` names one forge's repo; an unknown `<word>:` prefix is refused naming the kinds.
289
+ // A project row is not a scope string a job carries, so the scope grammar does not apply to it.
290
+ let form = { type: "project" };
291
+ if (!isProjectScope(trimmed)) {
292
+ try {
293
+ form = parseScopeString(trimmed);
294
+ } catch (error) {
295
+ throw configError(`${at}: ${error.message}: ${path}`);
296
+ }
297
+ }
298
+ if (form.type === "project" && version < 2) throw configError(`${at}: a project row needs "version": 2 (this file says ${version}), so a build that predates project rows refuses the file rather than reading an inert repo string: ${path}`);
299
+ if (form.type === "qualified" && version < 2) throw configError(`${at}: a forge-qualified scope needs "version": 2 (this file says ${version}), so a build that predates it refuses the file rather than reading an inert repo string: ${path}`);
134
300
  const norm = {
135
301
  // An absolute path is stored resolved so a `/srv/site/` row governs `/srv/site` jobs -- the same
136
302
  // collapse canonicalScope applies on the job side. isAbsolute is PLATFORM-NATIVE on purpose, so a
137
303
  // foreign-platform row (a windows drive path on a POSIX worker) stays verbatim and is inert here;
138
304
  // the doctor's unreferenced-scope advisory names it. Resolving it instead would "work" only by
139
305
  // both sides mangling into the same cwd-prefixed string -- a match by accident, not by contract.
140
- scope: isAbsolute(trimmed) ? resolve(trimmed) : trimmed,
306
+ scope: isProjectScope(trimmed) ? trimmed : form.type === "qualified" ? `${form.kind}:${form.repo}` : isAbsolute(trimmed) ? resolve(trimmed) : trimmed,
141
307
  day: null,
142
308
  week: null,
143
309
  month: null,
144
310
  concurrent: null,
311
+ // Version 2's dollar windows, each the canonical decimal string (`formatMicros`), so the admin's
312
+ // read-modify-write writes back exactly what the parser accepts. `dollarCapsFor` turns them into integers.
313
+ // Only in a version 2 file's rows: a version 1 file reads into exactly the five-key literal it always did.
314
+ ...(version >= 2 ? { dayUsd: null, weekUsd: null, monthUsd: null } : {}),
145
315
  };
146
316
  let any = false;
317
+ for (const field of USD_LIMIT_FIELDS) {
318
+ const value = row[field];
319
+ if (value === undefined || value === null) continue;
320
+ if (version < 2) throw configError(`${at}: ${field} needs "version": 2 (this file says ${version}): ${path}`);
321
+ norm[field] = formatMicros(usdField(value, field, at, path));
322
+ any = true;
323
+ }
147
324
  for (const field of LIMIT_FIELDS) {
148
325
  const value = row[field];
149
326
  // Absent-or-null (subscriptions.mjs's rule): null is the normalizer's OWN output for an unset
@@ -161,7 +338,7 @@ function normalizeLimit(row, index, path) {
161
338
  any = true;
162
339
  }
163
340
  if (!any) {
164
- throw configError(`${at}: at least one of day, week, month, concurrent is required (a row that limits nothing is a row an operator sets and then trusts): ${path}`);
341
+ throw configError(`${at}: at least one of day, week, month, concurrent${version >= 2 ? ", dayUsd, weekUsd, monthUsd" : ""} is required (a row that limits nothing is a row an operator sets and then trusts): ${path}`);
165
342
  }
166
343
  return norm;
167
344
  }
@@ -179,24 +356,238 @@ export function loadScopedLimits(config, { readFileSync = fsReadFileSync, exists
179
356
  }
180
357
 
181
358
  /**
182
- * The exact-match row for a canonical scope, or null. Exact string equality only -- the pause matcher's
183
- * semantics minus its "*" (refused above). With duplicates refused there is no precedence ladder.
359
+ * The row that applies to this job, or null. Exact string equality only -- the pause matcher's semantics minus
360
+ * its "*" (refused above). A forge job matches the row equal to its `qualifiedScopeOf` first (`github:acme/web`),
361
+ * then the row equal to its canonical bare repo (`acme/web`); a local job matches its resolved folder. With
362
+ * duplicates refused and a bare row beside a qualified row for one repo refused too, at most one of the two can
363
+ * exist, so the order is not a precedence ladder: it only says where to look.
364
+ */
365
+ export function limitFor(limits, job) {
366
+ if (!Array.isArray(limits)) return null;
367
+ // A model row is never a job's scope: it caps a model wherever it runs (`modelDollarRows`). Nor is a project row: it
368
+ // applies through the job's project (`projectRowFor`), never because a scope string happens to equal it.
369
+ const find = (scope) => (isNonEmptyString(scope) ? limits.find((l) => l.scope === scope && !isGroupScope(l.scope)) ?? null : null);
370
+ if (job?.kind !== "local") {
371
+ const qualified = find(qualifiedScopeOf(job));
372
+ if (qualified !== null) return qualified;
373
+ }
374
+ return find(canonicalScope(job));
375
+ }
376
+
377
+ /**
378
+ * The scope every per-scope KEY of this job is built from (issue #498): the matched row's scope, or the job's
379
+ * canonical scope when no row matches. The in-process slot, the fleet lease (`slot:s:<hash16>`), and through
380
+ * `budgetCapsFor`/`dollarCapsFor` the job-count and dollar windows all hash this one string. Keyed by the ROW, never
381
+ * the job: a bare `acme/web` row then keeps the exact key it had before qualified scopes existed (its counts carry
382
+ * over, and the admin, which recomputes keys from rows, keeps reading them), and it stays ONE shared cap across
383
+ * forges as written. The boot sweeper already hashes row scopes, so the gate and the sweeper are one rule.
184
384
  */
185
- export function limitFor(limits, scope) {
186
- if (!Array.isArray(limits) || !isNonEmptyString(scope)) return null;
187
- return limits.find((l) => l.scope === scope) ?? null;
385
+ export function rowScopeFor(job, limits) {
386
+ return limitFor(limits, job)?.scope ?? canonicalScope(job);
188
387
  }
189
388
 
190
389
  /**
191
390
  * The scoped budget windows this job reserves against, or null when nothing applies (no row for the
192
- * scope, or the row is concurrency-only). The returned `scope` is CANONICAL so the redis counters are
193
- * spelling-stable. Shaped like the global `caps` object so `reserveBudget` consumes it unchanged.
391
+ * scope, or the row is concurrency-only). The returned `scope` is the MATCHED ROW's (issue #498), so the redis
392
+ * counters are spelling-stable and a bare row keeps its key. Shaped like the global `caps` object so
393
+ * `reserveBudget` consumes it unchanged.
194
394
  */
195
395
  export function budgetCapsFor(job, limits) {
196
- const scope = canonicalScope(job);
197
- const row = limitFor(limits, scope);
396
+ const row = limitFor(limits, job);
198
397
  if (!row || (row.day === null && row.week === null && row.month === null)) return null;
199
- return { scope, caps: { day: row.day, week: row.week, month: row.month } };
398
+ return { scope: row.scope, caps: { day: row.day, week: row.week, month: row.month } };
399
+ }
400
+
401
+ /**
402
+ * The project row of this project id, or null (no id, or no row for it). A job's project is resolved ONCE at pickup
403
+ * (`projectOf`, projects.mjs), and every project-row consumer below takes that id, never the job: the gate, the ledgers
404
+ * and the record then agree for the attempt whatever an operator does to projects.json mid-run.
405
+ */
406
+ export function projectRowFor(limits, projectId) {
407
+ if (!Array.isArray(limits) || !isProjectId(projectId)) return null;
408
+ const scope = projectScope(projectId);
409
+ return limits.find((l) => l?.scope === scope) ?? null;
410
+ }
411
+
412
+ /**
413
+ * The project rows whose id is not a project in `projects` (the parsed projects.json), as `[{ index, id }]` in file
414
+ * order. Such a row caps nothing (no job can belong to a project that does not exist), so it is a refusal wherever the
415
+ * two files meet: at boot, on a live reload of either file, and in the admin's write (issue #499 part B).
416
+ */
417
+ export function danglingProjectRows(limits, projects) {
418
+ const ids = new Set((Array.isArray(projects) ? projects : []).map((p) => p?.id));
419
+ const out = [];
420
+ (Array.isArray(limits) ? limits : []).forEach((row, index) => {
421
+ if (!isProjectScope(row?.scope)) return;
422
+ const id = row.scope.slice(PROJECT_SCOPE_PREFIX.length);
423
+ if (!ids.has(id)) out.push({ index, id });
424
+ });
425
+ return out;
426
+ }
427
+
428
+ /**
429
+ * Refuse a limits list that names a project `projects` does not have, naming each such row's index and id (ids are
430
+ * charset-checked, never a path or a name) and BOTH files: the row lives in the scoped-limits file (`limitsPath`), the
431
+ * missing id in the projects file (`projectsPath`, null when PI_PROJECTS_FILE is unset), so an operator reading a
432
+ * refused projects.json edit looks for the index in the right file. Pure: the caller decides whether that is a boot
433
+ * refusal or a kept last-good copy.
434
+ */
435
+ export function checkProjectRows(limits, projects, limitsPath, projectsPath = null) {
436
+ const dangling = danglingProjectRows(limits, projects);
437
+ if (dangling.length === 0) return;
438
+ const rows = dangling.map((d) => `index ${d.index} ("${PROJECT_SCOPE_PREFIX}${d.id}")`).join(", ");
439
+ const where = projectsPath ? `the projects file ${projectsPath}` : "the projects file (PI_PROJECTS_FILE is unset, so there are no projects)";
440
+ throw configError(`scoped-limits row(s) at ${rows} in ${limitsPath} name a project that is not in ${where}; add the project there first, or remove the row`);
441
+ }
442
+
443
+ /** The refusal reason of a full repo or folder job-count window. */
444
+ export const SCOPE_CAP_REASON = "scope-cap";
445
+ /** The refusal reason of a full project job-count window (issue #499 part B). */
446
+ export const PROJECT_CAP_REASON = "project-cap";
447
+
448
+ /**
449
+ * THE ordered job-count ledgers of one job's scoped rows (issue #499 part B), as `[{ scope, keyPrefix, caps, reason }]`:
450
+ * its repo or folder row, then its project's row. The processor appends the global ledger last and reserves them in
451
+ * this order through ONE helper (budget.mjs `reserveLedgers`), and gives them back in reverse (`releaseLedgers`).
452
+ *
453
+ * Narrowest first, for the reason the scoped ledger always reserved before the global one: a refusal by a narrow
454
+ * ledger never consumes a slot in a wider one, so a noisy repo cannot drain its project's day, and a full project
455
+ * cannot drain the deployment's. A row with no day, week or month (concurrency or dollars only) is no ledger here.
456
+ *
457
+ * `projectId` is the id resolved at pickup (`projectOf`), or null. One list instead of one hand-built pair per
458
+ * call site: every release site in the processor walks this list, so a ledger added here cannot be forgotten there.
459
+ */
460
+ export function scopedLedgers(job, limits, projectId) {
461
+ const out = [];
462
+ const scoped = budgetCapsFor(job, limits);
463
+ if (scoped) out.push({ scope: scoped.scope, keyPrefix: scopeKeyPrefix(scoped.scope), caps: scoped.caps, reason: SCOPE_CAP_REASON });
464
+ const row = projectRowFor(limits, projectId);
465
+ if (row && (row.day !== null || row.week !== null || row.month !== null)) {
466
+ out.push({ scope: row.scope, keyPrefix: scopeKeyPrefix(row.scope), caps: { day: row.day, week: row.week, month: row.month }, reason: PROJECT_CAP_REASON });
467
+ }
468
+ return out;
469
+ }
470
+
471
+ /** A row's dollar windows as integer micro-dollars `{ day, week, month }`, each null when unset, or null when none is. */
472
+ function usdCaps(row) {
473
+ const caps = {};
474
+ for (const [field, name] of [["dayUsd", "day"], ["weekUsd", "week"], ["monthUsd", "month"]]) {
475
+ caps[name] = row?.[field] === null || row?.[field] === undefined ? null : parseUsdMicros(row[field], field);
476
+ }
477
+ return caps.day === null && caps.week === null && caps.month === null ? null : caps;
478
+ }
479
+
480
+ /**
481
+ * The repo or folder dollar windows this job reserves in (issue #501 part 5), or null when its scope's row has none
482
+ * (or there is no row). Ledger-shaped for `reserveDollars`: `{ scope, keyPrefix, caps }`, the caps in integer
483
+ * micro-dollars and `keyPrefix` `budget:usd:s:<hash16>` of the same MATCHED ROW scope the job-count windows hash
484
+ * (issue #498). A sibling of `budgetCapsFor`: the job-count and dollar windows of one row are separate ledgers.
485
+ */
486
+ export function dollarCapsFor(job, limits) {
487
+ const row = limitFor(limits, job);
488
+ const caps = row ? usdCaps(row) : null;
489
+ return caps === null ? null : { scope: row.scope, keyPrefix: scopeDollarKeyPrefix(row.scope), caps };
490
+ }
491
+
492
+ /**
493
+ * The project dollar windows this job reserves in (issue #499 part B), or null when its project (resolved at pickup) has
494
+ * no row or the row has no dollar window. Ledger-shaped like `dollarCapsFor`, with `keyPrefix`
495
+ * `scopeDollarKeyPrefix("project:<id>")`: the project row's own scope, the rule every row's key follows, so no second
496
+ * dollar keyspace exists for projects.
497
+ */
498
+ export function projectDollarCapsFor(limits, projectId) {
499
+ const row = projectRowFor(limits, projectId);
500
+ const caps = row ? usdCaps(row) : null;
501
+ return caps === null ? null : { scope: row.scope, keyPrefix: scopeDollarKeyPrefix(row.scope), caps };
502
+ }
503
+
504
+ /**
505
+ * The model dollar windows this job reserves in (issue #502 part 6), as ledgers `{ ref, keyPrefix, caps }` in file
506
+ * order, where `ref` is the row's lowercased `provider/model`. `models` is the job's EFFECTIVE allowed-model list:
507
+ * - a list reserves in the rows of its listed models, compared ignoring case (the ledger lowercases ids, so the
508
+ * row and the usage row it settles from meet in lowercase);
509
+ * - no list (`null` or `undefined`) reserves in EVERY model row. An unrestricted job may switch to any model
510
+ * mid-run, so it could spend in any of them; reserving in none would let it run up a model's window unseen.
511
+ * This fails closed: an unrestricted job is refused when any model window is full, and the remedy is a list.
512
+ * A job that reserves nothing at all (the zero-reservation rule, the processor's) reserves in none of these either.
513
+ */
514
+ export function modelDollarRows(limits, models) {
515
+ if (!Array.isArray(limits)) return [];
516
+ const rows = limits.filter((l) => isModelScope(l?.scope));
517
+ let wanted = null;
518
+ if (Array.isArray(models)) wanted = new Set(models.filter((m) => typeof m === "string").map((m) => m.toLowerCase()));
519
+ const out = [];
520
+ for (const row of rows) {
521
+ const ref = row.scope.slice(MODEL_SCOPE_PREFIX.length).toLowerCase();
522
+ if (wanted !== null && !wanted.has(ref)) continue;
523
+ const caps = usdCaps(row);
524
+ if (caps !== null) out.push({ ref, keyPrefix: modelDollarKeyPrefix(ref), caps });
525
+ }
526
+ return out;
527
+ }
528
+
529
+ /** A row's kind for the dollar advisories: `model`, `project` (issue #499 part B) or `scope` (a repo or folder). */
530
+ function rowKind(scope) {
531
+ return isModelScope(scope) ? "model" : isProjectScope(scope) ? "project" : "scope";
532
+ }
533
+
534
+ /**
535
+ * The rows that carry a dollar window, as `[{ index, kind }]` (`kind` is `scope`, `project` or `model`), when the deployment has
536
+ * NO per-job cap (`deploymentMaxCostUsd` null or undefined: env and overlay merged by the caller), else `[]` (PR #549's
537
+ * review). Such a row refuses every job it applies to as `config-refused` unless the job's trigger sets its own
538
+ * `run.maxCostUsd`, so the worker and doctor WARN, never refuse: a trigger may legitimately supply the cap. Index and
539
+ * kind only, never the scope string (a folder scope is a host path).
540
+ */
541
+ export function dollarRowsWithoutCap(limits, deploymentMaxCostUsd) {
542
+ if (deploymentMaxCostUsd !== null && deploymentMaxCostUsd !== undefined) return [];
543
+ const out = [];
544
+ (limits ?? []).forEach((row, index) => {
545
+ if (USD_LIMIT_FIELDS.some((f) => row?.[f] !== null && row?.[f] !== undefined)) out.push({ index, kind: rowKind(row.scope) });
546
+ });
547
+ return out;
548
+ }
549
+
550
+ /**
551
+ * The windows of dollar rows whose cap is BELOW the deployment's per-job cap, as `[{ index, kind, window }]` (PR
552
+ * #549's review). A job reserves its whole per-job cap, so such a window refuses every job it applies to, every time,
553
+ * until the cap is raised or the per-job cap lowered (a trigger's smaller `run.maxCostUsd` still fits). Doctor names
554
+ * them; `[]` when no deployment cap is set or none is below it.
555
+ */
556
+ export function dollarRowsBelowJobCap(limits, deploymentMaxCostUsd) {
557
+ if (deploymentMaxCostUsd === null || deploymentMaxCostUsd === undefined) return [];
558
+ const jobCap = parseUsdMicros(deploymentMaxCostUsd, "maxCostUsd");
559
+ const out = [];
560
+ (limits ?? []).forEach((row, index) => {
561
+ for (const [field, window] of [["dayUsd", "day"], ["weekUsd", "week"], ["monthUsd", "month"]]) {
562
+ if (row?.[field] === null || row?.[field] === undefined) continue;
563
+ if (parseUsdMicros(row[field], field) < jobCap) out.push({ index, kind: rowKind(row.scope), window });
564
+ }
565
+ });
566
+ return out;
567
+ }
568
+
569
+ /**
570
+ * The lowest file version that expresses `rows` (normalized or as the admin builds them): 2 when any row carries a
571
+ * dollar field, is a model row, is a project row (issue #499 part B) or has a forge-qualified scope (issue #498), else 1.
572
+ * The admin writes this, so a file
573
+ * with bare and folder job-count rows only stays a version 1 file that an older worker still reads.
574
+ */
575
+ export function scopedLimitsVersionFor(rows) {
576
+ const v2 = (rows ?? []).some((l) => {
577
+ const scope = typeof l?.scope === "string" ? l.scope.trim() : l?.scope;
578
+ return isModelScope(scope) || isProjectScope(scope) || isQualifiedScope(scope) || USD_LIMIT_FIELDS.some((f) => l?.[f] !== null && l?.[f] !== undefined);
579
+ });
580
+ return v2 ? 2 : 1;
581
+ }
582
+
583
+ /** Is this written scope forge-qualified? False for anything `parseScopeString` refuses: the parser names that. */
584
+ function isQualifiedScope(scope) {
585
+ if (typeof scope !== "string" || scope === "" || isModelScope(scope)) return false;
586
+ try {
587
+ return parseScopeString(scope).type === "qualified";
588
+ } catch {
589
+ return false;
590
+ }
200
591
  }
201
592
 
202
593
  /**
@@ -217,7 +608,7 @@ export function concurrencyFor(job, limits) {
217
608
  const scope = canonicalScope(job);
218
609
  if (scope === null) return Infinity;
219
610
  const structural = job?.kind === "local" ? 1 : Infinity;
220
- const configured = limitFor(limits, scope)?.concurrent ?? Infinity;
611
+ const configured = limitFor(limits, job)?.concurrent ?? Infinity;
221
612
  return Math.min(structural, configured);
222
613
  }
223
614
 
@@ -233,8 +624,37 @@ export function concurrencyFor(job, limits) {
233
624
  * accepted cost.
234
625
  */
235
626
  export function scopeKeyPrefix(scope) {
236
- const h = createHash("sha256").update(String(scope)).digest("hex").slice(0, 16);
237
- return `budget:s:${h}`;
627
+ return `budget:s:${hash16(scope)}`;
628
+ }
629
+
630
+ /** A repo or folder's DOLLAR key prefix: `budget:usd:s:<hash16(scope)>`, the same hash as `scopeKeyPrefix`. */
631
+ export function scopeDollarKeyPrefix(scope) {
632
+ return `${SCOPE_DOLLAR_KEY_PREFIX}:${hash16(scope)}`;
633
+ }
634
+
635
+ /**
636
+ * A model's DOLLAR key prefix: `budget:usd:mdl:<hash16(lowercase provider/model)>`. LOWERCASED before hashing,
637
+ * because the run record's ledger lowercases ids (`parseExitUsage`) and a model row settles from that ledger: one
638
+ * model must be one counter whatever case a row, a list or the runner spells it in. `ref` is `provider/model`,
639
+ * without the `model:` prefix.
640
+ */
641
+ export function modelDollarKeyPrefix(ref) {
642
+ return `${MODEL_DOLLAR_KEY_PREFIX}:${hash16(String(ref).toLowerCase())}`;
643
+ }
644
+
645
+ /** The dollar key prefix of one normalized row, scope or model: what the admin reads its counters under. */
646
+ export function dollarKeyPrefixFor(row) {
647
+ return isModelScope(row?.scope) ? modelDollarKeyPrefix(row.scope.slice(MODEL_SCOPE_PREFIX.length)) : scopeDollarKeyPrefix(row?.scope);
648
+ }
649
+
650
+ /**
651
+ * The rows the boot sweeper walks for this host's stale fleet claims (`makeScopeClaimSweeper`): every row as
652
+ * `{ concurrent, hash }`, the hash of its ROW scope, the string the pickup gate leases under. A project row's
653
+ * `concurrent` (issue #499 part B) is leased under `hash16("project:<id>")` and swept under the same hash; a row with no
654
+ * `concurrent` carries a null count, which the sweeper skips.
655
+ */
656
+ export function scopeClaimRows(limits) {
657
+ return (Array.isArray(limits) ? limits : []).map((row) => ({ concurrent: row.concurrent, hash: hash16(row.scope) }));
238
658
  }
239
659
 
240
660
  /**
package/src/secrets.mjs CHANGED
@@ -298,7 +298,8 @@ export function makeSecretsResolver({
298
298
  // prevent. Same conflated `undefined` as issue #286, one module over.
299
299
  //
300
300
  // It also closes a second hole that never needed auth.json. The worker never WRITES the OAuth variable
301
- // (`apiKeyVariable` skips it deliberately) and pi reads it BEFORE `ANTHROPIC_API_KEY`, so a presence
301
+ // or (from the 0.99.1 pin, issue #509) the bearer ANTHROPIC_AUTH_TOKEN (`apiKeyVariable` skips both
302
+ // deliberately) and pi reads both BEFORE `ANTHROPIC_API_KEY`, so a presence
302
303
  // filter held it only on hosts that happened to export it, and a trigger binding it outranked the
303
304
  // operator's own key on the pure-env path too.
304
305
  //
package/src/service.mjs CHANGED
@@ -41,7 +41,7 @@
41
41
  * the rest of the config is broken.
42
42
  */
43
43
  import { spawn as nodeSpawn } from "node:child_process";
44
- import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
44
+ import { existsSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
45
45
  import { lookup as dnsLookup } from "node:dns/promises";
46
46
  import { connect as netConnect } from "node:net";
47
47
  import { homedir, networkInterfaces, tmpdir, userInfo } from "node:os";
@@ -51,9 +51,13 @@ import { parseArgs } from "node:util";
51
51
  import { parseBackendList, venuesOf } from "./backends.mjs";
52
52
  import { sharedShellIgnored } from "./deployment-venue.mjs";
53
53
  import { egressArmed, egressProxyName } from "./egress.mjs";
54
- import { updateEnvFile } from "./env-file.mjs";
54
+ import { ENV_WRITER_FS, updateEnvFile } from "./env-file.mjs";
55
+ import { MODEL_ENDPOINTS_INCLUDE_NAME } from "./model-endpoints.mjs";
55
56
  import { VALKEY_HEALTH_SCRIPT, VALKEY_PASSWORD_KEY, VALKEY_START_SCRIPT, dollarsDoubled, newValkeyPassword, valkeyPasswordDecision } from "./valkey-auth.mjs";
56
- import { ALL_QUADLET_FILES, ALLOWLIST_PLACEHOLDER, NETNS_KEEPER, NETNS_KEEPER_FORMAT, judgeNetnsKeeper, keeperUnderRunningProxyHint, managerEnvRefusal, PROXY_CONF_PLACEHOLDER, QUADLET_FILES, applyStack, decideValkey, describeAction, passwdNameFrom, readSubuidRanges, readValkeyKeys, valkeySharedOn, VALKEY_SHARED_KEY, describeRollBack, journalWrite, rollBackWrites, foreignContainerRefusal, foreignContainers, lingerNote, planStack, proxyConfCopyPath, proxyRestartWarning, quadletDir, readLinger, readStackKeys, stackComponents, unknownContainerRefusal, userBusRefusal, valkeyEnvPath, valkeyPasswordRestartWarning, workerUnitDeps } from "./podman-stack.mjs";
57
+ import { ALL_QUADLET_FILES, ALLOWLIST_PLACEHOLDER, MODEL_ENDPOINTS_PLACEHOLDER, NETNS_KEEPER, NETNS_KEEPER_FORMAT, judgeNetnsKeeper, keeperUnderRunningProxyHint, managerEnvRefusal, PROXY_CONF_PLACEHOLDER, QUADLET_FILES, applyStack, decideValkey, describeAction, passwdNameFrom, readSubuidRanges, readValkeyKeys, valkeySharedOn, VALKEY_SHARED_KEY, describeRollBack, journalWrite, rollBackWrites, foreignContainerRefusal, foreignContainers, lingerNote, planStack, proxyConfCopyPath, proxyRestartWarning, quadletDir, readLinger, readStackKeys, stackComponents, unknownContainerRefusal, userBusRefusal, valkeyEnvPath, valkeyPasswordRestartWarning, workerUnitDeps } from "./podman-stack.mjs";
58
+
59
+ /** This command's default fs seam: its own calls, and every call the `.env` writer makes (`ENV_WRITER_FS`, issue #522). */
60
+ export const SERVICE_FS = { existsSync, mkdirSync, ...ENV_WRITER_FS };
57
61
 
58
62
  // src/ is where this module lives in BOTH layouts (worker/src in a checkout,
59
63
  // node_modules/@edgehero/pi-dispatch/src under npm). Deploy templates resolve one level up from it
@@ -169,6 +173,7 @@ export const TEMPLATE_PINS = {
169
173
  "pi-dispatch-egress-proxy.container": [
170
174
  `Volume=${PROXY_CONF_PLACEHOLDER}:/etc/squid/squid.conf:ro,z`, // → the account-owned COPY of the package's egress-proxy.conf (~/.config/pi-dispatch/egress-proxy.conf, podman-stack.mjs proxyConfCopyPath), because `z` cannot relabel a root-owned package file (measured)
171
175
  `Volume=${ALLOWLIST_PLACEHOLDER}:/etc/pi-dispatch/allowlist.conf:ro,z`, // → <deployDir>/egress-allowlist.conf, the list `init` scaffolds
176
+ `Volume=${MODEL_ENDPOINTS_PLACEHOLDER}:/etc/pi-dispatch/model-endpoints.conf:ro,z`, // → <deployDir>/model-endpoints.conf, the include `init` scaffolds and `egress render` rewrites in place (issue #503)
172
177
  "ContainerName=pi-dispatch-egress-proxy",
173
178
  "Network=pi-dispatch-egress-out.network",
174
179
  "WantedBy=default.target",
@@ -343,7 +348,7 @@ export async function runService(argv = [], deps = {}) {
343
348
  // this command. An operator changes it by running the command as someone else, not by declaring it.
344
349
  user = env.USER || userInfo().username,
345
350
  tmp = tmpdir(),
346
- fs = { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, chmodSync, statSync, renameSync, realpathSync },
351
+ fs = SERVICE_FS,
347
352
  spawn = nodeSpawn,
348
353
  out = (s) => process.stdout.write(s),
349
354
  err = (s) => process.stderr.write(s),
@@ -1056,6 +1061,12 @@ async function stackRefusal(ctx, paths, stack) {
1056
1061
  if (stack.components.proxy && !ctx.fs.existsSync(allowlist)) {
1057
1062
  blocking.push(`the egress policy is on, and ${allowlist} does not exist: a proxy unit mounting a missing file makes Podman create a DIRECTORY there and squid fail confusingly. Run \`pi-dispatch init\` in ${ctx.deployDir} first (it never overwrites), or set PI_EGRESS=0 in .env to opt out of the policy`);
1058
1063
  }
1064
+ // Issue #503: the rules include the declared model endpoints' file, and squid will not start when it is missing. Said
1065
+ // only beside an allowlist that exists: without one, the reason above already names `init`, which writes both.
1066
+ const endpointsInclude = join(ctx.deployDir, MODEL_ENDPOINTS_INCLUDE_NAME);
1067
+ if (stack.components.proxy && ctx.fs.existsSync(allowlist) && !ctx.fs.existsSync(endpointsInclude)) {
1068
+ blocking.push(`the egress policy is on, and ${endpointsInclude} does not exist: the proxy's rules include it, and squid will not start without it. Run \`pi-dispatch init\` in ${ctx.deployDir} first (it never overwrites), or set PI_EGRESS=0 in .env to opt out of the policy`);
1069
+ }
1059
1070
  const bus = stack.plan.actions.length > 0 ? userBusRefusal({ env: ctx.env, user: ctx.user, euid: ctx.euid }) : null;
1060
1071
  if (bus) blocking.push(bus);
1061
1072
  // The manager's own XDG_RUNTIME_DIR and XDG_CONFIG_HOME (PR #463 round 3, measured): another account's makes every