@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
@@ -1,14 +1,19 @@
1
1
  import * as nodeFs from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { ensureUnderAccountRoot } from "./config.mjs";
4
+ import { findDuplicateKey } from "./json-duplicates.mjs";
5
+ import { checkDollarInvariant, parseUsdMicros } from "./money.mjs";
4
6
 
5
7
  /**
6
8
  * Runtime-settings overlay: the shared, durable truth between the admin extension and the worker
7
- * (INT-CONFIG-OVERLAY-CONTRACT, DES-RUNTIME-SETTINGS-FILE-OVERLAY). A flat `settings.json` with ten
9
+ * (INT-CONFIG-OVERLAY-CONTRACT, DES-RUNTIME-SETTINGS-FILE-OVERLAY). A flat `settings.json` with fourteen
8
10
  * optional keys -- `model`, `provider` (non-empty strings), `maxTurns`, `dailyCap`, `weeklyCap`,
9
11
  * `monthlyCap`, `maxTokens`, `dailyTokenCap` (int >= 1), `concurrency` (int 1-10), `softHoldPct`
10
- * (int 1-99) -- read by the worker at each job start and written atomically by the admin extension
11
- * (tmp + rename). `maxTokens`/`dailyTokenCap` are the optional token controls (issue #25).
12
+ * (int 1-99), and the dollar keys `maxCostUsd`, `dailyCostUsd`, `weeklyCostUsd`, `monthlyCostUsd`
13
+ * (a decimal string or number, `parseUsdMicros`) -- read by the worker at each job start and written
14
+ * atomically by the admin extension (tmp + rename). `maxTokens`/`dailyTokenCap` are the optional token
15
+ * controls (issue #25); the dollar keys are issue #501's (a window needs `maxCostUsd`, checked on the merged
16
+ * values by `resolveSettings`).
12
17
  *
13
18
  * `readOverlay` NEVER throws: a bad settings file returns a discriminated `{ invalid }` rather than an
14
19
  * exception, so the processor RETURNS a policy refusal (`settings-overlay-invalid`) instead of letting
@@ -25,7 +30,7 @@ import { ensureUnderAccountRoot } from "./config.mjs";
25
30
  * Custom: overlay validated inline per config.mjs precedent; zod not in deps
26
31
  */
27
32
 
28
- export const KNOWN_KEYS = ["model", "provider", "maxTurns", "dailyCap", "weeklyCap", "monthlyCap", "maxTokens", "dailyTokenCap", "concurrency", "softHoldPct"];
33
+ export const KNOWN_KEYS = ["model", "provider", "maxTurns", "dailyCap", "weeklyCap", "monthlyCap", "maxTokens", "dailyTokenCap", "concurrency", "softHoldPct", "maxCostUsd", "dailyCostUsd", "weeklyCostUsd", "monthlyCostUsd"];
29
34
 
30
35
  /**
31
36
  * Overlay keys the OVERLAY accepts but no model-callable tool may set. `secretProfiles` maps a profile name
@@ -43,6 +48,39 @@ export const KNOWN_KEYS = ["model", "provider", "maxTurns", "dailyCap", "weeklyC
43
48
  */
44
49
  export const OPERATOR_ONLY_KEYS = ["secretProfiles"];
45
50
 
51
+ // The dollar keys' near-miss spellings (issue #501), already normalised (`overlayKeyShape`), each mapped to the key it
52
+ // meant: the four keys, their env names (`PI_MAX_COST_USD` pasted into settings.json is the likeliest slip of all),
53
+ // the micro-dollar spelling, `costUsd`, and the bare `maxCost` forms.
54
+ const DOLLAR_NEAR_MISSES = Object.freeze({
55
+ maxcostusd: "maxCostUsd", pimaxcostusd: "maxCostUsd", maxcostmicros: "maxCostUsd", costusd: "maxCostUsd", maxcost: "maxCostUsd",
56
+ dailycostusd: "dailyCostUsd", pidailycostusd: "dailyCostUsd", dailycostmicros: "dailyCostUsd", dailycost: "dailyCostUsd",
57
+ weeklycostusd: "weeklyCostUsd", piweeklycostusd: "weeklyCostUsd", weeklycostmicros: "weeklyCostUsd", weeklycost: "weeklyCostUsd",
58
+ monthlycostusd: "monthlyCostUsd", pimonthlycostusd: "monthlyCostUsd", monthlycostmicros: "monthlyCostUsd", monthlycost: "monthlyCostUsd",
59
+ });
60
+
61
+ // A small FIXED look-alike table, to ASCII: Cyrillic а е о р с у х к м т н в і and Greek ο α ε ρ ν τ κ ι (lowercase
62
+ // forms; the key is lowercased first, which maps their capitals onto these). Fullwidth forms are mapped by code point.
63
+ const LOOK_ALIKES = new Map(Object.entries({
64
+ "\u0430": "a", "\u0435": "e", "\u043E": "o", "\u0440": "p", "\u0441": "c", "\u0443": "y", "\u0445": "x", "\u043A": "k", "\u043C": "m", "\u0442": "t", "\u043D": "h", "\u0432": "b", "\u0456": "i",
65
+ "\u03BF": "o", "\u03B1": "a", "\u03B5": "e", "\u03C1": "p", "\u03BD": "v", "\u03C4": "t", "\u03BA": "k", "\u03B9": "i",
66
+ }));
67
+
68
+ /**
69
+ * An overlay key's shape for the dollar near-miss check (issue #501): fullwidth forms to ASCII, lowercased, the fixed
70
+ * look-alike table applied, and the separators `_`, `-`, `.` and space dropped. Nothing else is removed, so a key with
71
+ * any other character (`host_ü`, `notés`) keeps it and can never EQUAL a target. That is the whole rule: the overlay
72
+ * refuses only an exact match after this, never a subsequence or a distance, because a refusal here stops every job
73
+ * and a false one is worse than a drop. The trigger loader keeps its own, wider sweep (`validateRunModel`): a
74
+ * triggers file is reviewed content refused at load, where a false hit costs an edit, not a day of jobs.
75
+ */
76
+ function overlayKeyShape(key) {
77
+ let out = "";
78
+ for (const ch of key.replace(/[\uFF01-\uFF5E]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xfee0)).toLowerCase()) {
79
+ out += LOOK_ALIKES.get(ch) ?? ch;
80
+ }
81
+ return out.replace(/[_\-. ]/g, "");
82
+ }
83
+
46
84
  function isNonEmptyString(value) {
47
85
  return typeof value === "string" && value.trim() !== "";
48
86
  }
@@ -127,14 +165,38 @@ function validateOverlay(candidate, log) {
127
165
  overlay[key] = { ...value };
128
166
  break;
129
167
  }
168
+ case "maxCostUsd":
169
+ case "dailyCostUsd":
170
+ case "weeklyCostUsd":
171
+ case "monthlyCostUsd":
172
+ // Issue #501: the per-job dollar cap and the three dollar windows. Kept AS WRITTEN (a string or a number)
173
+ // once it parses, so the file the admin writes back holds what the operator typed, never a converted
174
+ // number; the worker converts it to micro-dollars per job, exactly as it converts env's. A window needs
175
+ // `maxCostUsd`, a cross-key rule this per-key check cannot see: `resolveSettings` applies it to the
176
+ // merged values.
177
+ try {
178
+ parseUsdMicros(value, key);
179
+ } catch (error) {
180
+ return { invalid: error.message };
181
+ }
182
+ overlay[key] = value;
183
+ break;
130
184
  case "softHoldPct":
131
185
  // A percentage of each active cap; 100 would equal the hard wall (no band) and 0 has no meaning,
132
186
  // so the enforced band is 1-99. Absence disables the soft-hold entirely.
133
187
  if (!isIntInRange(value, 1, 99)) return { invalid: "softHoldPct must be an integer 1-99" };
134
188
  overlay[key] = value;
135
189
  break;
136
- default:
190
+ default: {
191
+ // Issue #501: a near miss of a DOLLAR key is refused when its shape (`overlayKeyShape`: case, the
192
+ // separators, a fixed look-alike table) EXACTLY equals a known misspelling: `maxcostusd`, `MaxCostUsd`,
193
+ // `max_cost_usd`, `max<U+0421>ostUsd`, `PI_MAX_COST_USD`, `maxCostMicros`, `costUsd`. Dropped and logged,
194
+ // the operator's cap would not apply while the file reads as setting one. Every other unknown key keeps
195
+ // the forward-compatible drop-and-log, `host_<U+00FC>` and `maxCostCents` included.
196
+ const meant = Object.hasOwn(DOLLAR_NEAR_MISSES, overlayKeyShape(key)) ? DOLLAR_NEAR_MISSES[overlayKeyShape(key)] : undefined;
197
+ if (meant !== undefined) return { invalid: `${key} is not a settings key -- did you mean ${meant}? A dollar cap the worker drops would not apply while the file reads as set` };
137
198
  log("settings_overlay_unknown_key", { key });
199
+ }
138
200
  }
139
201
  }
140
202
  return { overlay };
@@ -163,15 +225,20 @@ export function readOverlay(path, { fs = nodeFs, log = () => {} } = {}) {
163
225
  } catch {
164
226
  return { invalid: "settings file is not valid JSON" };
165
227
  }
228
+ // Issue #501: a duplicate key is refused, the triggers file's rule. JSON.parse keeps the LAST value, so
229
+ // `{"maxCostUsd":"1","maxCostUsd":"999"}` would run under $999 while the first line reads as $1. Only the KEY
230
+ // is named, never a value. `writeOverlay` serialises an object, so a file it wrote never has one.
231
+ const duplicate = findDuplicateKey(text);
232
+ if (duplicate) return { invalid: `settings file has a duplicate key ${JSON.stringify(duplicate.key)}: JSON keeps the last value, so the file that reads and the file that runs differ` };
166
233
  return validateOverlay(parsed, log);
167
234
  }
168
235
 
169
236
  /**
170
- * Resolve the ten effective settings from `config` and a validated `overlay`: overlay value where the
237
+ * Resolve the fourteen effective settings from `config` and a validated `overlay`: overlay value where the
171
238
  * overlay sets it, else the config value. Precedence is overlay > env > default only -- `config`
172
- * already carries env > default. `weeklyCap`/`monthlyCap`/`maxTokens`/`dailyTokenCap`/`softHoldPct` may
173
- * resolve to `null` (their config value when unset), meaning that window / cap / band is disabled.
174
- * `job.data` is NOT merged here; that layer is the processor's.
239
+ * already carries env > default. `weeklyCap`/`monthlyCap`/`maxTokens`/`dailyTokenCap`/`softHoldPct` and the
240
+ * four dollar keys may resolve to `null` (their config value when unset), meaning that window / cap / band is
241
+ * disabled. `job.data` is NOT merged here; that layer is the processor's.
175
242
  */
176
243
  export function effectiveSettings(config, overlay) {
177
244
  const o = overlay ?? {};
@@ -186,9 +253,37 @@ export function effectiveSettings(config, overlay) {
186
253
  dailyTokenCap: o.dailyTokenCap ?? config.dailyTokenCap,
187
254
  concurrency: o.concurrency ?? config.concurrency,
188
255
  softHoldPct: o.softHoldPct ?? config.softHoldPct,
256
+ maxCostUsd: o.maxCostUsd ?? config.maxCostUsd,
257
+ dailyCostUsd: o.dailyCostUsd ?? config.dailyCostUsd,
258
+ weeklyCostUsd: o.weeklyCostUsd ?? config.weeklyCostUsd,
259
+ monthlyCostUsd: o.monthlyCostUsd ?? config.monthlyCostUsd,
189
260
  };
190
261
  }
191
262
 
263
+ /**
264
+ * The per-job settings the worker runs with, from `config` and a `readOverlay` result: `{ invalid }` when the
265
+ * overlay is invalid or the MERGED values break a cross-key rule, else the effective settings plus
266
+ * `secretProfiles`. Pure, so the processor's one settings read (start.mjs `getSettings`) is testable without
267
+ * booting a worker.
268
+ *
269
+ * The dollar invariant (issue #501) runs HERE, on merged values, because `validateOverlay` sees one key at a
270
+ * time and the overlay alone: a window in the overlay with the per-job cap in env is valid, and a check of
271
+ * the overlay alone would refuse it. An invalid result becomes the `settings-overlay-invalid` refusal, before
272
+ * any spend.
273
+ *
274
+ * `secretProfiles` (REQ-TRIGGER-SECRETS) rides ALONGSIDE the effective keys rather than inside them:
275
+ * `effectiveSettings` resolves `overlay > env` over a fixed literal whose tests pin its key set, and this key
276
+ * deliberately has no such precedence (a name declared in both sources is refused per delivery, not silently
277
+ * won by either).
278
+ */
279
+ export function resolveSettings(config, read) {
280
+ if (read.invalid) return { invalid: read.invalid };
281
+ const settings = effectiveSettings(config, read.overlay);
282
+ const broken = checkDollarInvariant(settings);
283
+ if (broken) return broken;
284
+ return { ...settings, secretProfiles: read.overlay?.secretProfiles ?? {} };
285
+ }
286
+
192
287
  /**
193
288
  * Write `candidate` to `path` atomically. Validates with the same rules as `readOverlay` (an invalid
194
289
  * candidate returns `{ invalid }` and touches no file; an empty `{}` candidate is valid), serialises
package/src/schedules.mjs CHANGED
@@ -15,6 +15,7 @@
15
15
  import { existsSync as fsExistsSync, readFileSync as fsReadFileSync } from "node:fs";
16
16
  import { isAbsolute } from "node:path";
17
17
  import { configError } from "./config.mjs";
18
+ import { triggerCapAboveDeployment } from "./money.mjs";
18
19
  import { parseTriggers } from "./triggers.mjs";
19
20
 
20
21
  /**
@@ -32,6 +33,16 @@ export function loadSchedules(config, { readFileSync = fsReadFileSync, existsSyn
32
33
  }
33
34
 
34
35
  const triggers = parseTriggers(readFileSync(path, "utf8"), path);
36
+ // Issue #501: a trigger's run.maxCostUsd above this deployment's PI_MAX_COST_USD refuses the file, at boot
37
+ // (an operator is present) and on a live reload (the running schedules are kept). Every kind is checked,
38
+ // not only cron: the worker runs the forge jobs too, and this is the one load it does of the file. Here and
39
+ // not in parseTriggers, which is pure and shared with the receiver, which never sees the worker's env. The
40
+ // job itself runs under the smaller of the two whatever this finds (effectiveJobOf), so this is the loud
41
+ // half, never the only one. Env only: an overlay cap can change between jobs and is applied per job.
42
+ const above = triggerCapAboveDeployment(triggers, config.maxCostUsd);
43
+ if (above !== -1) {
44
+ throw configError(`trigger at index ${above}: run.maxCostUsd is above this deployment's PI_MAX_COST_USD. A trigger can only narrow the per-job cost cap, so lower it or remove it (${path})`);
45
+ }
35
46
 
36
47
  return triggers.filter((t) => t.on.type === "cron").map((t) => normalizeCronSchedule(t, path, existsSync, fleet));
37
48
  }
@@ -64,6 +75,23 @@ export function authoredCron(config, { readFileSync = fsReadFileSync, existsSync
64
75
  }
65
76
  }
66
77
 
78
+ /**
79
+ * The host paths a job container can see (issue #504 part B), for `envelopeInsideJobPaths`: every cron trigger's
80
+ * `run.folder` and every trigger's `run.skillsDir` in the triggers file, the `PI_DISPATCH_RUN_ROOTS` roots and
81
+ * `PI_GLOBAL_PI_DIR`. Read from the FILE, not from this host's served schedules: a folder another host serves today may
82
+ * be this host's after an edit, and the check costs nothing to be wide. Throws when the triggers file does not parse.
83
+ */
84
+ export function envelopeJobPaths(config, { readFileSync = fsReadFileSync, existsSync = fsExistsSync } = {}) {
85
+ const out = { cronFolders: [], runRoots: [...(config.dispatchRunRoots ?? [])], skillsDirs: [], globalPiDir: config.globalPiDir ?? null };
86
+ const path = config.triggersFile;
87
+ if (path === null || path === undefined || !existsSync(path)) return out;
88
+ for (const t of parseTriggers(readFileSync(path, "utf8"), path)) {
89
+ if (t?.on?.type === "cron" && typeof t.run?.folder === "string") out.cronFolders.push(t.run.folder);
90
+ if (typeof t?.run?.skillsDir === "string") out.skillsDirs.push(t.run.skillsDir);
91
+ }
92
+ return out;
93
+ }
94
+
67
95
  /**
68
96
  * Split a schedule set into the triggers THIS host serves and the ones it does not (issue #57).
69
97
  *
@@ -150,7 +178,11 @@ function normalizeCronSchedule({ on, run }, path, existsSync, fleet) {
150
178
  // key. A command trigger carries no flow/task at all (the validator enforces the XOR), so those two
151
179
  // keys hold undefined here and drop at JSON serialization -- the command schedule's data is exactly
152
180
  // kind/folder/command plus the shared fields.
153
- const data = { kind: "local", folder: run.folder, flow: run.flow, task: run.task, ...(run.command !== undefined && { command: run.command }), provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages: run.packages, image: run.image, ...(run.backend !== undefined && { backend: run.backend }), ...(run.excludeTools !== undefined && { excludeTools: run.excludeTools }), ...(run.skillsDir !== undefined && { skillsDir: run.skillsDir }), ...(run.secrets !== undefined && { secrets: run.secrets }), ...(run.secretsProfile !== undefined && { secretsProfile: run.secretsProfile }), resume: run.resume, trigger: { id: on.id, pattern: on.pattern } };
181
+ //
182
+ // `portfolio` (issue #505) is conditional like `command`, so an unflagged schedule grows no key, and it rides at JOB
183
+ // level, never inside `trigger`: `trigger` is copied verbatim into `/job/event.json`, and whether a job may send a
184
+ // budget plan is the worker's decision to re-check against the live file, not a fact for the agent to read.
185
+ const data = { kind: "local", folder: run.folder, flow: run.flow, task: run.task, ...(run.command !== undefined && { command: run.command }), provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages: run.packages, image: run.image, ...(run.backend !== undefined && { backend: run.backend }), ...(run.excludeTools !== undefined && { excludeTools: run.excludeTools }), ...(run.models !== undefined && { models: run.models }), ...(run.maxCostUsd !== undefined && { maxCostUsd: run.maxCostUsd }), ...(run.skillsDir !== undefined && { skillsDir: run.skillsDir }), ...(run.secrets !== undefined && { secrets: run.secrets }), ...(run.secretsProfile !== undefined && { secretsProfile: run.secretsProfile }), resume: run.resume, ...(run.portfolio !== undefined && { portfolio: run.portfolio }), trigger: { id: on.id, pattern: on.pattern } };
154
186
  // Retention only; the deterministic repeat:<id>:<millis> jobId supplies dedup, so no jobId here, and
155
187
  // scheduler jobs are not retried (DES-CRON-VIA-BULLMQ-SCHEDULER) so no attempts/backoff.
156
188
  const opts = { removeOnComplete: { age: 24 * 3600 }, removeOnFail: { age: 7 * 24 * 3600 } };