@edgehero/pi-dispatch 2.1.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 (71) 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 +8 -1
  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 +2261 -203
  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 +107 -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/identity.mjs +2 -1
  30. package/src/image-preflight.mjs +98 -24
  31. package/src/image-ref.mjs +37 -0
  32. package/src/import-pi.mjs +4 -2
  33. package/src/index.mjs +407 -62
  34. package/src/init.mjs +18 -0
  35. package/src/job-id.mjs +26 -3
  36. package/src/live-probes.mjs +24 -9
  37. package/src/model-catalog.mjs +297 -0
  38. package/src/model-endpoints.mjs +649 -0
  39. package/src/model-ref.mjs +151 -0
  40. package/src/models-json.mjs +262 -0
  41. package/src/money.mjs +144 -0
  42. package/src/octokit-log.mjs +65 -0
  43. package/src/outbox-plan.mjs +218 -0
  44. package/src/outbox.mjs +29 -9
  45. package/src/output-cap.mjs +157 -0
  46. package/src/pause-windows.mjs +81 -2
  47. package/src/pi-model-loader.mjs +77 -0
  48. package/src/podman-stack.mjs +16 -3
  49. package/src/portfolio-snapshot.mjs +304 -0
  50. package/src/prepare-local.mjs +247 -12
  51. package/src/prepare.mjs +35 -3
  52. package/src/priorities.mjs +569 -0
  53. package/src/processor.mjs +599 -170
  54. package/src/project-id.mjs +17 -0
  55. package/src/projects.mjs +238 -0
  56. package/src/provider-steering.mjs +179 -65
  57. package/src/queue.mjs +111 -6
  58. package/src/reserved-env.mjs +30 -0
  59. package/src/run-container.mjs +59 -5
  60. package/src/run-history.mjs +379 -24
  61. package/src/run-mirror.mjs +30 -0
  62. package/src/runtime-settings.mjs +104 -9
  63. package/src/schedules.mjs +33 -1
  64. package/src/scoped-limits.mjs +447 -27
  65. package/src/service.mjs +15 -4
  66. package/src/session-store.mjs +131 -6
  67. package/src/start.mjs +528 -40
  68. package/src/triggers-file.mjs +65 -4
  69. package/src/triggers.mjs +135 -7
  70. package/src/up.mjs +308 -34
  71. package/src/valkey-endpoint.mjs +3 -2
package/src/prepare.mjs CHANGED
@@ -59,6 +59,14 @@ export function makePrepareWorkspace({
59
59
  // construction -- prepares exactly what it always did: no /session mount, nothing on disk.
60
60
  resolveSession = () => null,
61
61
  prepareLocal = prepareLocalWorkspace,
62
+ // Issue #504 part B: where a local job's resolved folder may lie. `{ jobPaths, envelopeFile }`: `jobPaths()` gives the
63
+ // run roots and cron folders a folder named inside must stay inside, and `envelopeFile` the file whose folder no job
64
+ // may mount. Null (a bare dispatcher) passes neither, and prepareLocal then judges nothing but the folder itself.
65
+ localPlacement = null,
66
+ // Issue #505: `(job) => null | { body } | { outcome: "policy", reason }`, the portfolio snapshot builder
67
+ // (portfolio-snapshot.mjs `makePortfolioSnapshot`). Asked only for a local job the processor confirmed as a portfolio
68
+ // job (`portfolio: true` below); null (a bare dispatcher) writes no snapshot for any job.
69
+ portfolioSnapshot = null,
62
70
  // Keyed by `job.kind`, so a new forge is one entry rather than a new `if`. A kind with no entry falls
63
71
  // through to the throw below, which is what makes an unrouted job loud instead of a silent no-op.
64
72
  preparers = { github: prepareGithubWorkspace },
@@ -70,11 +78,32 @@ export function makePrepareWorkspace({
70
78
  // not only here: a temp cleaner (systemd-tmpfiles ages /tmp) can remove an idle jobs dir, and a name under the temp
71
79
  // dir that is gone is one any account can create next.
72
80
  ensureDir = (dir) => ensureJobsDir(dir),
81
+ // Issue #524: how a thrown preparer's job dir is removed. A seam so the guard around it is testable.
82
+ removeDir = (dir) => rmSync(dir, { recursive: true, force: true }),
73
83
  }) {
74
84
  ensureDir(jobsDir);
75
- return async function prepareWorkspace(job, token, { queueJobId, piVersion = null, jobUser = null, podmanStore = null } = {}) {
85
+ return async function prepareWorkspace(job, token, { queueJobId, piVersion = null, jobUser = null, podmanStore = null, portfolio = false } = {}) {
76
86
  ensureDir(jobsDir);
77
87
  const jobDir = mkdtempSync(join(jobsDir, "job-"));
88
+ // Issue #524: a THROW out of anything below leaves `jobDir` to nobody. The processor tears down only what
89
+ // it was handed, and a throw hands it nothing, so every config refusal raised in a preparer (a local folder
90
+ // that does not exist, a forge kind with no preparer) and every infrastructure throw (a clone that failed)
91
+ // left one empty `jobs/job-*` per attempt, forever. `discardOnPolicy` covers the RETURNED refusals; this
92
+ // covers the thrown ones, in one place for every kind, rather than in each preparer that can throw.
93
+ // A retry makes a fresh directory, so nothing is lost by removing this one.
94
+ try {
95
+ return await prepareInto(job, token, jobDir, { queueJobId, piVersion, jobUser, podmanStore, portfolio });
96
+ } catch (error) {
97
+ // GUARDED: a removal that fails (a busy mount, a permission flipped mid-job) must never replace the error
98
+ // that is the job's actual outcome. The directory is then left, which is what happened before this catch.
99
+ try {
100
+ removeDir(jobDir);
101
+ } catch {}
102
+ throw error;
103
+ }
104
+ };
105
+
106
+ async function prepareInto(job, token, jobDir, { queueJobId, piVersion, jobUser, podmanStore, portfolio }) {
78
107
  // The trigger's injected skills (REQ-PER-TRIGGER-SKILLS, issue #60), COPIED here rather than
79
108
  // mounted, and copied ONCE for every job kind because this is where local and forge converge.
80
109
  //
@@ -128,7 +157,10 @@ export function makePrepareWorkspace({
128
157
  ? `Use the "${job.flow}" skill for this task.\n\n${pointer}${job.task ?? ""}`
129
158
  : `${pointer}${job.task ?? ""}`;
130
159
  const event = localEventContext(job, queueJobId, findPreviousRun);
131
- return discardOnPolicy(stampSandbox(await prepareLocal({ folder: job.folder, task, jobDir, event }), sandbox), jobDir);
160
+ // The snapshot only for a confirmed portfolio job, and never for a chained child or a manual run, whatever its data
161
+ // says (the processor's gate already holds both; this keeps a direct caller to the same rule).
162
+ const snapshot = portfolio === true && typeof portfolioSnapshot === "function" && event.source === "cron" ? { portfolio: () => portfolioSnapshot(job) } : {};
163
+ return discardOnPolicy(stampSandbox(await prepareLocal({ folder: job.folder, task, jobDir, event, ...(localPlacement ? { jobPaths: localPlacement.jobPaths, envelopeFile: localPlacement.envelopeFile ?? null } : {}), ...snapshot }), sandbox), jobDir);
132
164
  }
133
165
  const prepare = preparers[job.kind];
134
166
  if (prepare) {
@@ -151,7 +183,7 @@ export function makePrepareWorkspace({
151
183
  );
152
184
  }
153
185
  throw new Error(`unknown job kind: ${job.kind}`);
154
- };
186
+ }
155
187
  }
156
188
 
157
189
  /**
@@ -0,0 +1,569 @@
1
+ /**
2
+ * Delegated allocation, the pure half (issue #504; INT-PRIORITIES-PLAN-CONTRACT,
3
+ * DES-DELEGATED-ALLOCATION-INSIDE-ENVELOPE). An agent writes PRIORITIES, never dollar numbers: a plan of integer
4
+ * weights per project (and per member repo), and this module does the arithmetic inside the operator's envelope
5
+ * (envelope.mjs). Models reason poorly about a shared budget, and weights make every invariant checkable.
6
+ *
7
+ * PURE and import-light (`node:crypto` and the import-free project id rule): the worker, the admin extension and a
8
+ * test all call it, and none of them may drag config.mjs's fs and os in with it. Nothing here reads a clock, a file or
9
+ * Valkey. The callers pass `now`, the envelope and the current effective vector.
10
+ *
11
+ * Money is integer micro-dollars as a Number at every edge (money.mjs). Inside `allocate` and `rebase` it is BigInt:
12
+ * a remainder times a weight can pass 2^53 (a $1,000,000 remainder is 1e12 micro-dollars, times 1000 is 1e15, and the
13
+ * step blend multiplies a difference by another amount), and a float there would drift by a micro-dollar, which a
14
+ * property test of "sums to the total" then catches only sometimes. Each Number that comes in is checked as a safe,
15
+ * non-negative integer, and each that goes out is below the total, which was such an integer.
16
+ *
17
+ * Agent-authored text never leaves this module in a refusal: every refusal is a fixed enum and a field name from a
18
+ * fixed list (`PLAN_FIELDS`), so a refusal can enter a log line, an audit row or a tool result as it stands.
19
+ */
20
+
21
+ import { createHash } from "node:crypto";
22
+ import { isProjectId } from "./project-id.mjs";
23
+
24
+ /** The highest plan version this build reads. A plan declaring a higher one is refused (`plan-invalid`, `version`). */
25
+ export const PLAN_VERSION = 1;
26
+ /** The highest weight a plan may give. Integers from 0, so every share is a ratio of small integers. */
27
+ export const WEIGHT_MAX = 1000;
28
+ /** The longest `reason`, in code points. A note for the panel, not a document. */
29
+ export const REASON_MAX = 200;
30
+ /** The largest plan text read, in bytes (UTF-8). The job-side collector refuses a larger file before reading it. */
31
+ export const PLAN_MAX_BYTES = 16 * 1024;
32
+ /** The plan lifetime used when neither the caller nor the envelope gives one. */
33
+ export const DEFAULT_MAX_PLAN_DAYS = 14;
34
+ /**
35
+ * The envelope entry for every scope in no envelope project. `PROJECT_ID_RE` forbids `_`, so no real project can
36
+ * collide with it, and its dollar keys (`scopeDollarKeyPrefix("project:_other")`) cannot either.
37
+ */
38
+ export const OTHER = "_other";
39
+ /** The two writers that may send a plan: the operator's session tool and a flagged portfolio job (issue #505). */
40
+ export const PLAN_WRITERS = Object.freeze(["operator-session", "portfolio-job"]);
41
+
42
+ /** The two refusals `parsePlan` returns. `plan-incomplete` is also a rung of the apply ladder (`planRefusal`). */
43
+ export const PLAN_INVALID = "plan-invalid";
44
+ export const PLAN_INCOMPLETE = "plan-incomplete";
45
+
46
+ /**
47
+ * The fields a refusal names: a fixed list, so a refusal never echoes a key or a value the agent wrote. An unknown
48
+ * key is named by WHERE it sits (`plan`, `projects`, `projects.repos`), never by its own spelling.
49
+ */
50
+ export const PLAN_FIELDS = Object.freeze([
51
+ "body",
52
+ "plan",
53
+ "version",
54
+ "basis",
55
+ "validUntil",
56
+ "projects",
57
+ "projects.id",
58
+ "projects.weight",
59
+ "projects.reason",
60
+ "projects.repos",
61
+ "projects.repos.ref",
62
+ "projects.repos.weight",
63
+ ]);
64
+
65
+ /** Why a field was refused: a fixed list too. */
66
+ export const PLAN_RULES = Object.freeze(["json", "too-large", "shape", "unknown-key", "missing", "type", "range", "newer", "format", "duplicate", "unknown-id", "control-char", "too-long", "past", "too-far", "missing-project", "missing-repo", "unknown-ref"]);
67
+
68
+ /** The apply ladder's refusals in their order (DES-DELEGATED-ALLOCATION-INSIDE-ENVELOPE). `plan-busy` is the lock's. */
69
+ export const PLAN_LADDER = Object.freeze(["delegation-off", "writer-not-allowed", "plan-duplicate", "plan-stale", "plan-too-soon", "plan-incomplete", "plan-busy"]);
70
+
71
+ const PLAN_KEYS = new Set(["version", "basis", "validUntil", "projects"]);
72
+ const PROJECT_KEYS = new Set(["id", "weight", "reason", "repos"]);
73
+ const REPO_KEYS = new Set(["ref", "weight"]);
74
+ const BASIS_RE = /^[0-9a-f]{16}$/;
75
+ const REF_RE = /^[0-9a-f]{8}$/;
76
+ // A UTC instant as `Date.prototype.toISOString` writes it, seconds required, milliseconds optional, `Z` only. An offset
77
+ // or a date alone is refused: a plan is compared against a clock on every host, and one spelling is one instant.
78
+ const INSTANT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
79
+ // Refused in a reason rather than stripped: every control (C0, DEL, C1), every format character (the bidi controls and
80
+ // isolates, zero-width characters, the byte order mark, the tag block, and the joiners too, so an emoji sequence is
81
+ // refused: a reason is a short note, not a place for pictures), a lone surrogate, a private-use or unassigned code
82
+ // point, and the line and paragraph separators. Each can change what a reader of the panel sees or draw as nothing, and
83
+ // stripping would store text the writer did not write.
84
+ const REASON_REFUSED = /[\p{Cc}\p{Cf}\p{Cs}\p{Co}\p{Cn}\p{Zl}\p{Zp}]/u;
85
+ /** At most this many projects, and repos per project, in a plan: a bound on the work a plan can ask for. */
86
+ const LIST_MAX = 256;
87
+ const DAY_MS = 24 * 60 * 60 * 1000;
88
+ /** The longest plan life any caller may ask for: a year, the envelope's own ceiling. */
89
+ const MAX_PLAN_DAYS = 366;
90
+ /** The largest magnitude a Date holds (ECMA-262: 8.64e15 ms either side of the epoch). */
91
+ const MAX_DATE_MS = 8.64e15;
92
+
93
+ /**
94
+ * The canonical JSON of a JSON-able value: object keys sorted at every depth, no whitespace. The input of `planId`,
95
+ * so two plans that differ only in key order are one plan.
96
+ */
97
+ export function canonicalJson(value) {
98
+ if (value === null || typeof value !== "object") return JSON.stringify(value ?? null);
99
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
100
+ const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
101
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
102
+ }
103
+
104
+ function sha256(text) {
105
+ return createHash("sha256").update(text).digest("hex");
106
+ }
107
+
108
+ /**
109
+ * A plan's id: the first 16 hex digits of sha256 over its canonical JSON. `plan` is the canonical plan `parsePlan`
110
+ * returns (projects sorted by id, repos by ref, an absent optional field absent), so a plan written twice with its
111
+ * projects in another order has one id, and a re-collected file is a `plan-duplicate`.
112
+ */
113
+ export function planId(plan) {
114
+ return sha256(canonicalJson(plan)).slice(0, 16);
115
+ }
116
+
117
+ /**
118
+ * A member's reference in a plan: the first 8 hex digits of sha256 over the member's canonical scope, the spelling
119
+ * projects.json stores and `projectOf` matches (`github:acme/web`, or a resolved folder). A plan names repos by ref so
120
+ * that no path and no repository name ever appears in it.
121
+ */
122
+ export function scopeRef(scope) {
123
+ return sha256(String(scope)).slice(0, 8);
124
+ }
125
+
126
+ /** The ids an envelope governs, sorted: every key of its floors, `_other` always among them. */
127
+ export function envelopeEntries(envelope) {
128
+ return Object.keys(envelope?.floors ?? {}).sort();
129
+ }
130
+
131
+ function refuse(reason, field, rule, extra = {}) {
132
+ return { ok: false, reason, field, rule, ...extra };
133
+ }
134
+
135
+ function isPlainObject(value) {
136
+ return value !== null && typeof value === "object" && !Array.isArray(value);
137
+ }
138
+
139
+ function unknownKey(object, allowed) {
140
+ return Object.keys(object).some((k) => !allowed.has(k));
141
+ }
142
+
143
+ function isWeight(value) {
144
+ return Number.isInteger(value) && value >= 0 && value <= WEIGHT_MAX;
145
+ }
146
+
147
+ /**
148
+ * Parse and judge a priorities plan's TEXT (INT-PRIORITIES-PLAN-CONTRACT). Returns `{ ok: true, plan, id, validUntil }`
149
+ * or a refusal `{ ok: false, reason, field, rule }`: `reason` is `plan-invalid` or `plan-incomplete`, `field` is one of
150
+ * `PLAN_FIELDS` and `rule` one of `PLAN_RULES`. One refusal, the first found, in the order the fields are listed.
151
+ *
152
+ * `plan` is the CANONICAL plan, the input of `planId`: `{ version, basis, validUntil?, projects }` with `projects`
153
+ * sorted by id as `{ id, weight, reason?, repos? }` and `repos` sorted by ref, and a written `validUntil` in the
154
+ * one spelling `toISOString` gives (milliseconds always shown). An optional field the writer left out stays out, so the
155
+ * id is a property of what was written: a plan with no `validUntil` re-collected tomorrow has the same id. `validUntil` beside it is the RESOLVED instant (ISO): the written one, or `now` plus `maxPlanDays`.
156
+ *
157
+ * Options:
158
+ * - `envelope` (the normalized envelope, envelope.mjs): every id must be one of its entries, and every entry must be
159
+ * named, else `plan-incomplete` on `projects`. Without it the check is structural only (the runner's pre-check).
160
+ * - `projects` (the parsed projects.json): a project that lists `repos` must name every member by `scopeRef`, else
161
+ * `plan-incomplete` on `projects.repos`; a ref that is no member is `plan-invalid`. Without it, refs are checked
162
+ * for form only.
163
+ * - `now` (ms or Date, required when `validUntil` matters, which is always): the clock the instant is judged by.
164
+ * - `maxPlanDays`: the longest a plan may live; else the envelope's, else `DEFAULT_MAX_PLAN_DAYS`.
165
+ *
166
+ * A `plan-incomplete` refusal also carries `plan`, `id` and `validUntil`: the apply ladder puts that rung after the
167
+ * duplicate, stale and too-soon rungs (`planRefusal`), and those need the id.
168
+ */
169
+ export function parsePlan(text, { envelope = null, projects = null, now, maxPlanDays } = {}) {
170
+ if (typeof text !== "string") return refuse(PLAN_INVALID, "body", "type");
171
+ if (Buffer.byteLength(text, "utf8") > PLAN_MAX_BYTES) return refuse(PLAN_INVALID, "body", "too-large");
172
+ let raw;
173
+ try {
174
+ raw = JSON.parse(text);
175
+ } catch {
176
+ // Never the parser's message: it quotes the text around the fault, and the text is agent-authored.
177
+ return refuse(PLAN_INVALID, "body", "json");
178
+ }
179
+ if (!isPlainObject(raw)) return refuse(PLAN_INVALID, "plan", "shape");
180
+ if (unknownKey(raw, PLAN_KEYS)) return refuse(PLAN_INVALID, "plan", "unknown-key");
181
+
182
+ if (raw.version === undefined) return refuse(PLAN_INVALID, "version", "missing");
183
+ if (!Number.isInteger(raw.version) || raw.version < 1) return refuse(PLAN_INVALID, "version", "type");
184
+ if (raw.version > PLAN_VERSION) return refuse(PLAN_INVALID, "version", "newer");
185
+
186
+ // Required, with null spelled out: "I saw no plan" must be written, so a writer that forgot the field is told so
187
+ // rather than read as a first plan.
188
+ if (!("basis" in raw)) return refuse(PLAN_INVALID, "basis", "missing");
189
+ if (raw.basis !== null && (typeof raw.basis !== "string" || !BASIS_RE.test(raw.basis))) return refuse(PLAN_INVALID, "basis", "format");
190
+
191
+ const nowMs = now instanceof Date ? now.getTime() : now;
192
+ if (!Number.isFinite(nowMs)) throw new TypeError("parsePlan: `now` (ms or a Date) is required");
193
+ const days = maxPlanDays ?? envelope?.delegation?.maxPlanDays ?? DEFAULT_MAX_PLAN_DAYS;
194
+ if (!Number.isInteger(days) || days < 1 || days > MAX_PLAN_DAYS) throw new TypeError(`parsePlan: maxPlanDays must be an integer from 1 to ${MAX_PLAN_DAYS}`);
195
+ const limitMs = nowMs + days * DAY_MS;
196
+ // Both instants must be ones a Date can hold, so `toISOString` below can never throw a RangeError: a bad clock is
197
+ // the caller's defect, and it is reported as one, never as a crash in the middle of a judgement.
198
+ if (Math.abs(nowMs) > MAX_DATE_MS || Math.abs(limitMs) > MAX_DATE_MS) throw new TypeError("parsePlan: `now` plus maxPlanDays is outside the range a Date can hold");
199
+ let validUntilMs = limitMs;
200
+ if (raw.validUntil !== undefined) {
201
+ if (typeof raw.validUntil !== "string" || !INSTANT_RE.test(raw.validUntil)) return refuse(PLAN_INVALID, "validUntil", "format");
202
+ const ms = Date.parse(raw.validUntil);
203
+ // `Date.parse` accepts 2026-02-30 and rolls it over; the round trip refuses what is not a real instant.
204
+ if (!Number.isFinite(ms) || new Date(ms).toISOString().slice(0, 19) !== raw.validUntil.slice(0, 19)) return refuse(PLAN_INVALID, "validUntil", "format");
205
+ if (ms <= nowMs) return refuse(PLAN_INVALID, "validUntil", "past");
206
+ if (ms > limitMs) return refuse(PLAN_INVALID, "validUntil", "too-far");
207
+ validUntilMs = ms;
208
+ }
209
+
210
+ if (!Array.isArray(raw.projects)) return refuse(PLAN_INVALID, "projects", raw.projects === undefined ? "missing" : "type");
211
+ const entries = envelope ? envelopeEntries(envelope) : null;
212
+ if (raw.projects.length > LIST_MAX) return refuse(PLAN_INVALID, "projects", "too-long");
213
+ const members = membersById(projects);
214
+ const out = [];
215
+ const seen = new Set();
216
+ for (const entry of raw.projects) {
217
+ if (!isPlainObject(entry)) return refuse(PLAN_INVALID, "projects", "shape");
218
+ if (unknownKey(entry, PROJECT_KEYS)) return refuse(PLAN_INVALID, "projects", "unknown-key");
219
+ if (entry.id === undefined) return refuse(PLAN_INVALID, "projects.id", "missing");
220
+ if (entry.id !== OTHER && !isProjectId(entry.id)) return refuse(PLAN_INVALID, "projects.id", "format");
221
+ if (seen.has(entry.id)) return refuse(PLAN_INVALID, "projects.id", "duplicate");
222
+ if (entries && !entries.includes(entry.id)) return refuse(PLAN_INVALID, "projects.id", "unknown-id");
223
+ seen.add(entry.id);
224
+ if (entry.weight === undefined) return refuse(PLAN_INVALID, "projects.weight", "missing");
225
+ if (!Number.isInteger(entry.weight)) return refuse(PLAN_INVALID, "projects.weight", "type");
226
+ if (!isWeight(entry.weight)) return refuse(PLAN_INVALID, "projects.weight", "range");
227
+ const project = { id: entry.id, weight: entry.weight };
228
+ if (entry.reason !== undefined) {
229
+ if (typeof entry.reason !== "string" || entry.reason.trim() === "") return refuse(PLAN_INVALID, "projects.reason", "type");
230
+ if (REASON_REFUSED.test(entry.reason)) return refuse(PLAN_INVALID, "projects.reason", "control-char");
231
+ if ([...entry.reason].length > REASON_MAX) return refuse(PLAN_INVALID, "projects.reason", "too-long");
232
+ project.reason = entry.reason;
233
+ }
234
+ if (entry.repos !== undefined) {
235
+ const repos = parseRepos(entry.repos, entry.id, members);
236
+ if (!repos.ok) return repos;
237
+ project.repos = repos.repos;
238
+ }
239
+ out.push(project);
240
+ }
241
+ out.sort((a, b) => compareIds(a.id, b.id));
242
+ // A written `validUntil` enters the canonical plan as `toISOString` writes it, so `...00Z` and `...00.000Z`, one
243
+ // instant, give one id.
244
+ const plan = { version: raw.version, basis: raw.basis, ...(raw.validUntil !== undefined ? { validUntil: new Date(validUntilMs).toISOString() } : {}), projects: out };
245
+ const id = planId(plan);
246
+ const validUntil = new Date(validUntilMs).toISOString();
247
+ if (entries && entries.some((e) => !seen.has(e))) return refuse(PLAN_INCOMPLETE, "projects", "missing-project", { plan, id, validUntil });
248
+ if (members) {
249
+ for (const project of out) {
250
+ if (!project.repos) continue;
251
+ const want = members.get(project.id) ?? [];
252
+ if (want.some((ref) => !project.repos.some((r) => r.ref === ref))) return refuse(PLAN_INCOMPLETE, "projects.repos", "missing-repo", { plan, id, validUntil });
253
+ }
254
+ }
255
+ return { ok: true, plan, id, validUntil };
256
+ }
257
+
258
+ /** Each project's member refs, from the parsed projects.json, or null when it was not given. */
259
+ function membersById(projects) {
260
+ if (!Array.isArray(projects)) return null;
261
+ const out = new Map();
262
+ for (const p of projects) out.set(p?.id, (Array.isArray(p?.members) ? p.members : []).map(scopeRef));
263
+ return out;
264
+ }
265
+
266
+ function parseRepos(repos, projectId, members) {
267
+ if (!Array.isArray(repos) || repos.length === 0) return refuse(PLAN_INVALID, "projects.repos", "type");
268
+ // `_other` has no members: a scope in no project has no ref a plan could name.
269
+ if (projectId === OTHER) return refuse(PLAN_INVALID, "projects.repos", "unknown-ref");
270
+ if (repos.length > LIST_MAX) return refuse(PLAN_INVALID, "projects.repos", "too-long");
271
+ const known = members ? members.get(projectId) ?? [] : null;
272
+ const out = [];
273
+ for (const repo of repos) {
274
+ if (!isPlainObject(repo)) return refuse(PLAN_INVALID, "projects.repos", "shape");
275
+ if (unknownKey(repo, REPO_KEYS)) return refuse(PLAN_INVALID, "projects.repos", "unknown-key");
276
+ if (repo.ref === undefined) return refuse(PLAN_INVALID, "projects.repos.ref", "missing");
277
+ if (typeof repo.ref !== "string" || !REF_RE.test(repo.ref)) return refuse(PLAN_INVALID, "projects.repos.ref", "format");
278
+ if (out.some((r) => r.ref === repo.ref)) return refuse(PLAN_INVALID, "projects.repos.ref", "duplicate");
279
+ if (known && !known.includes(repo.ref)) return refuse(PLAN_INVALID, "projects.repos.ref", "unknown-ref");
280
+ if (repo.weight === undefined) return refuse(PLAN_INVALID, "projects.repos.weight", "missing");
281
+ if (!Number.isInteger(repo.weight)) return refuse(PLAN_INVALID, "projects.repos.weight", "type");
282
+ if (!isWeight(repo.weight)) return refuse(PLAN_INVALID, "projects.repos.weight", "range");
283
+ out.push({ ref: repo.ref, weight: repo.weight });
284
+ }
285
+ out.sort((a, b) => compareIds(a.ref, b.ref));
286
+ return { ok: true, repos: out };
287
+ }
288
+
289
+ /** Code-unit order, the tie-break every rule here uses: ids and refs are ASCII, so this is plain ascending order. */
290
+ function compareIds(a, b) {
291
+ return a < b ? -1 : a > b ? 1 : 0;
292
+ }
293
+
294
+ /**
295
+ * The first rung of the apply ladder a parsed plan stops on, or null when it may apply (the lock, `plan-busy`, is the
296
+ * caller's). Pure: the order is DES-DELEGATED-ALLOCATION-INSIDE-ENVELOPE's, and it is here so the worker and the admin
297
+ * judge a plan by one function.
298
+ *
299
+ * - `envelope`: the normalized envelope, or null (no envelope: delegation is off);
300
+ * - `writer`: who sent the plan, one of `PLAN_WRITERS`;
301
+ * - `parsed`: what `parsePlan` returned, `ok` or a `plan-incomplete` refusal (a `plan-invalid` one never gets here);
302
+ * - `current`: the applied state, `{ planId, lastPlanAt }`, or null when none is stored. `planId` is the id the
303
+ * next plan's `basis` must name (null for the neutral state, so a first plan sends `basis: null`); `lastPlanAt`
304
+ * is when a writer's plan last applied (ISO), or null, so neutral, expiry and re-base never start an interval;
305
+ * - `now`: ms or a Date.
306
+ */
307
+ export function planRefusal({ envelope, writer, parsed, current, now }) {
308
+ const delegation = envelope?.delegation;
309
+ if (!delegation?.enabled) return "delegation-off";
310
+ if (!PLAN_WRITERS.includes(writer) || !delegation.writers.includes(writer)) return "writer-not-allowed";
311
+ const currentId = current?.planId ?? null;
312
+ if (parsed.id === currentId) return "plan-duplicate";
313
+ if (parsed.plan.basis !== currentId) return "plan-stale";
314
+ const nowMs = now instanceof Date ? now.getTime() : now;
315
+ const last = current?.lastPlanAt ? Date.parse(current.lastPlanAt) : NaN;
316
+ if (Number.isFinite(last) && nowMs - last < delegation.minIntervalHours * 60 * 60 * 1000) return "plan-too-soon";
317
+ if (parsed.ok === false && parsed.reason === PLAN_INCOMPLETE) return "plan-incomplete";
318
+ return null;
319
+ }
320
+
321
+ // ── the arithmetic ─────────────────────────────────────────────────────────────────────────────────────────
322
+
323
+ /** A Number micro-dollar amount as BigInt, or a TypeError: every amount that comes in is a safe, non-negative integer. */
324
+ function micros(value, what) {
325
+ if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${what} must be a non-negative safe integer of micro-dollars`);
326
+ return BigInt(value);
327
+ }
328
+
329
+ /** Floor division for BigInt (BigInt `/` truncates toward zero, which is wrong for a negative numerator). */
330
+ function floorDiv(a, b) {
331
+ const q = a / b;
332
+ return (a % b !== 0n && (a < 0n) !== (b < 0n)) ? q - 1n : q;
333
+ }
334
+
335
+ /**
336
+ * Split `amount` between `keys` in proportion to `weights` (BigInt, each >= 0), by largest remainder: each key gets
337
+ * floor(amount * w / W), and the micro-dollars left go one each to the largest fractional remainders, ties to the
338
+ * earlier key in `keys` order. Sums exactly to `amount` when any weight is above 0; all zero gives every key 0n.
339
+ */
340
+ function largestRemainder(amount, keys, weights) {
341
+ const total = keys.reduce((s, k) => s + weights[k], 0n);
342
+ const out = {};
343
+ if (total === 0n) {
344
+ for (const k of keys) out[k] = 0n;
345
+ return out;
346
+ }
347
+ const rems = [];
348
+ let given = 0n;
349
+ for (const k of keys) {
350
+ const product = amount * weights[k];
351
+ out[k] = product / total;
352
+ given += out[k];
353
+ rems.push({ k, rem: product % total });
354
+ }
355
+ let left = amount - given;
356
+ rems.sort((a, b) => (a.rem === b.rem ? 0 : a.rem > b.rem ? -1 : 1)); // stable, so ties keep `keys` order
357
+ for (let i = 0; left > 0n; i++, left--) out[rems[i].k] += 1n;
358
+ return out;
359
+ }
360
+
361
+ /** The normalized envelope's numbers as BigInt, checked: `{ total, floors: { id: bigint }, entries }`. */
362
+ function envelopeNumbers(envelope) {
363
+ const entries = envelopeEntries(envelope);
364
+ if (!entries.includes(OTHER)) throw new TypeError(`the envelope must carry ${OTHER} among its floors`);
365
+ const total = micros(envelope?.totalMicros, "envelope.totalMicros");
366
+ const floors = {};
367
+ let sum = 0n;
368
+ for (const id of entries) {
369
+ floors[id] = micros(envelope.floors[id], `envelope.floors.${id}`);
370
+ sum += floors[id];
371
+ }
372
+ if (sum > total) throw new TypeError("the envelope's floors exceed its total");
373
+ return { total, floors, entries };
374
+ }
375
+
376
+ /** A vector `{ allocations, unallocated }` as BigInt over exactly `entries`, checked to sum to `total`. */
377
+ function vectorNumbers(vector, entries, total, what) {
378
+ const keys = Object.keys(vector?.allocations ?? {}).sort();
379
+ if (keys.length !== entries.length || keys.some((k, i) => k !== entries[i])) throw new TypeError(`${what} must cover exactly the envelope's entries (rebase it first)`);
380
+ const out = {};
381
+ let sum = 0n;
382
+ for (const id of entries) {
383
+ out[id] = micros(vector.allocations[id], `${what}.allocations.${id}`);
384
+ sum += out[id];
385
+ }
386
+ const unallocated = micros(vector.unallocated ?? 0, `${what}.unallocated`);
387
+ if (sum + unallocated !== total) throw new TypeError(`${what} does not sum to the envelope's total`);
388
+ return { allocations: out, unallocated };
389
+ }
390
+
391
+ /** The unallocated money's key in a step vector. It sorts before every id, so a tie in the re-rounding favours it. */
392
+ const UNALLOCATED = "";
393
+
394
+ function toNumbers(allocations, unallocated) {
395
+ const out = {};
396
+ for (const [k, v] of Object.entries(allocations)) out[k] = Number(v);
397
+ return { allocations: out, unallocated: Number(unallocated) };
398
+ }
399
+
400
+ /**
401
+ * The deterministic allocation (DES-DELEGATED-ALLOCATION-INSIDE-ENVELOPE). Returns
402
+ * `{ allocations: { id: micros }, unallocated, clamped, target, repos }`, every amount a Number of micro-dollars:
403
+ *
404
+ * 1. FLOORS first: every entry gets its floor, and `R = total - sum(floors)` is what the weights split.
405
+ * 2. The TARGET: `floor(R * w / W)` each, then the micro-dollars left one each by largest fractional remainder, ties
406
+ * by id ascending, so the target sums to the total exactly. All weights 0: floors only, and R stays UNALLOCATED,
407
+ * the money-safe direction.
408
+ * 3. The STEP, only when `current` (the effective vector now applied) is given: `S = floor(total * maxStepPct / 100)`
409
+ * and `D` the largest move of any entry, the unallocated money counted as one more entry. `D <= S` applies the
410
+ * target. Otherwise every entry moves the same fraction S/D of its way, and the blend is re-rounded by largest
411
+ * remainder (ties: the unallocated money first, then id ascending). Both ends of the blend keep every floor and
412
+ * the total, so the blend does; and a value within S of the start rounds to an integer within S of it, so no
413
+ * entry ever moves by more than S. `clamped` says the step bound.
414
+ * 4. REPO shares, for each project whose weights list `repos` (`{ projectId: { ref: weight } }`): the project's
415
+ * allocation split by largest remainder, ties by ref ascending, with no floors and no step. All repo weights 0
416
+ * gives each repo 0.
417
+ *
418
+ * `weights` must name every envelope entry with an integer 0 to `WEIGHT_MAX`. `current` must cover exactly the
419
+ * entries and sum to the total (re-base it with `rebase` after an envelope change). Throws a TypeError on any input
420
+ * that breaks those rules: a caller with a bad vector has a defect, and a guessed answer here would be a money answer.
421
+ * Never returns more than the total, in any entry or in the sum.
422
+ */
423
+ export function allocate({ envelope, weights, current = null, repos = null }) {
424
+ const { total, floors, entries } = envelopeNumbers(envelope);
425
+ const w = {};
426
+ for (const id of entries) {
427
+ if (!isWeight(weights?.[id])) throw new TypeError(`weights.${id} must be an integer from 0 to ${WEIGHT_MAX}`);
428
+ w[id] = BigInt(weights[id]);
429
+ }
430
+ for (const id of Object.keys(weights ?? {})) if (!entries.includes(id)) throw new TypeError(`weights.${id} is not an envelope entry`);
431
+
432
+ // 1 and 2: floors, then the remainder by weight.
433
+ const remainder = total - entries.reduce((s, id) => s + floors[id], 0n);
434
+ const anyWeight = entries.some((id) => w[id] > 0n);
435
+ const shares = largestRemainder(anyWeight ? remainder : 0n, entries, w);
436
+ const targetAlloc = {};
437
+ for (const id of entries) targetAlloc[id] = floors[id] + shares[id];
438
+ const targetUnallocated = anyWeight ? 0n : remainder;
439
+
440
+ // 3: the step.
441
+ let alloc = targetAlloc;
442
+ let unallocated = targetUnallocated;
443
+ let clamped = false;
444
+ if (current !== null) {
445
+ const pct = envelope?.delegation?.maxStepPct;
446
+ if (!Number.isInteger(pct) || pct < 1 || pct > 100) throw new TypeError("envelope.delegation.maxStepPct must be an integer from 1 to 100 to apply a step");
447
+ const before = vectorNumbers(current, entries, total, "current");
448
+ const keys = [UNALLOCATED, ...entries];
449
+ const b = { [UNALLOCATED]: before.unallocated, ...before.allocations };
450
+ const t = { [UNALLOCATED]: targetUnallocated, ...targetAlloc };
451
+ const step = (total * BigInt(pct)) / 100n;
452
+ let largest = 0n;
453
+ for (const k of keys) {
454
+ const move = t[k] > b[k] ? t[k] - b[k] : b[k] - t[k];
455
+ if (move > largest) largest = move;
456
+ }
457
+ if (largest > step) {
458
+ clamped = true;
459
+ const blended = blend(keys, b, t, step, largest);
460
+ unallocated = blended[UNALLOCATED];
461
+ alloc = {};
462
+ for (const id of entries) alloc[id] = blended[id];
463
+ }
464
+ }
465
+
466
+ // 4: repo shares inside each project's (stepped) allocation.
467
+ const repoOut = {};
468
+ for (const [projectId, repoWeights] of Object.entries(repos ?? {})) {
469
+ if (!entries.includes(projectId) || projectId === OTHER) throw new TypeError(`repos.${projectId} is not an envelope project`);
470
+ const refs = Object.keys(repoWeights ?? {}).sort();
471
+ const rw = {};
472
+ for (const ref of refs) {
473
+ if (!isWeight(repoWeights[ref])) throw new TypeError(`repos.${projectId}.${ref} must be an integer from 0 to ${WEIGHT_MAX}`);
474
+ rw[ref] = BigInt(repoWeights[ref]);
475
+ }
476
+ const split = largestRemainder(alloc[projectId], refs, rw);
477
+ repoOut[projectId] = {};
478
+ for (const ref of refs) repoOut[projectId][ref] = Number(split[ref]);
479
+ }
480
+
481
+ return { ...toNumbers(alloc, unallocated), clamped, target: toNumbers(targetAlloc, targetUnallocated), repos: repoOut };
482
+ }
483
+
484
+ /**
485
+ * The step blend, re-rounded: each key moves `(t - b) * S / D` from `b`, floored, and the micro-dollars the floors
486
+ * left (the fractional parts sum to a whole number, because the moves sum to 0) go one each to the largest fractional
487
+ * parts, ties in `keys` order. Exact in BigInt.
488
+ */
489
+ function blend(keys, b, t, step, largest) {
490
+ const out = {};
491
+ const fracs = [];
492
+ let sum = 0n;
493
+ for (const k of keys) {
494
+ const scaled = (t[k] - b[k]) * step;
495
+ const whole = floorDiv(scaled, largest);
496
+ out[k] = b[k] + whole;
497
+ sum += out[k];
498
+ fracs.push({ k, frac: scaled - whole * largest });
499
+ }
500
+ const total = keys.reduce((s, k) => s + b[k], 0n);
501
+ let left = total - sum;
502
+ fracs.sort((a, c) => (a.frac === c.frac ? 0 : a.frac > c.frac ? -1 : 1));
503
+ for (let i = 0; left > 0n; i++, left--) out[fracs[i].k] += 1n;
504
+ return out;
505
+ }
506
+
507
+ /**
508
+ * The repo shares of an effective vector (issue #504 part B): each project's allocation split by its repo weights,
509
+ * the rule of `allocate`'s step 4 (largest remainder, ties by ref, no floors, no step). `repos` is
510
+ * `{ projectId: { ref: weight } }`; a project that is not in `allocations`, or `_other`, is skipped, because a re-base
511
+ * may have dropped it. Used where a vector changes without a new plan (a re-base keeps the plan's repo weights).
512
+ */
513
+ export function repoShares(allocations, repos) {
514
+ const out = {};
515
+ for (const [projectId, repoWeights] of Object.entries(repos ?? {})) {
516
+ if (projectId === OTHER || allocations?.[projectId] === undefined) continue;
517
+ const refs = Object.keys(repoWeights ?? {}).sort();
518
+ const rw = {};
519
+ for (const ref of refs) {
520
+ if (!isWeight(repoWeights[ref])) throw new TypeError(`repos.${projectId}.${ref} must be an integer from 0 to ${WEIGHT_MAX}`);
521
+ rw[ref] = BigInt(repoWeights[ref]);
522
+ }
523
+ const split = largestRemainder(micros(allocations[projectId], `allocations.${projectId}`), refs, rw);
524
+ out[projectId] = {};
525
+ for (const ref of refs) out[projectId][ref] = Number(split[ref]);
526
+ }
527
+ return out;
528
+ }
529
+
530
+ /**
531
+ * The neutral allocation: the envelope's `defaultWeights`, no step. What every host computes with no applied plan,
532
+ * after a flush, after expiry and when delegation is off.
533
+ */
534
+ export function neutralAllocation(envelope) {
535
+ return allocate({ envelope, weights: envelope?.defaultWeights, current: null });
536
+ }
537
+
538
+ /**
539
+ * Project an effective vector onto a changed envelope (a new total, new floors, an entry added or removed), with NO
540
+ * step: an envelope change is the operator's act. Returns `{ allocations, unallocated }` over the new envelope's
541
+ * entries, summing to its total.
542
+ *
543
+ * The weights are the vector's CURRENT above-floor shares, never a plan's weights: an entry weighs
544
+ * `max(0, allocation - its new floor)`, and the unallocated money weighs what it holds. So a plan that was clamped
545
+ * part of the way to its target stays part of the way: it cannot reach its target through an envelope edit. An entry
546
+ * the new envelope adds weighs 0 (it gets its floor). An entry the new envelope drops brings its whole allocation to
547
+ * `_other`'s weight, because its jobs join `_other`. All weights 0 leaves the headroom unallocated.
548
+ */
549
+ export function rebase(vector, envelope) {
550
+ const { total, floors, entries } = envelopeNumbers(envelope);
551
+ const old = vector?.allocations ?? {};
552
+ const weights = { [UNALLOCATED]: micros(vector?.unallocated ?? 0, "vector.unallocated") };
553
+ for (const id of entries) weights[id] = 0n;
554
+ for (const id of Object.keys(old).sort()) {
555
+ const amount = micros(old[id], `vector.allocations.${id}`);
556
+ if (entries.includes(id)) {
557
+ weights[id] += amount > floors[id] ? amount - floors[id] : 0n;
558
+ } else {
559
+ weights[OTHER] += amount;
560
+ }
561
+ }
562
+ const remainder = total - entries.reduce((s, id) => s + floors[id], 0n);
563
+ const keys = [UNALLOCATED, ...entries];
564
+ const anyWeight = keys.some((k) => weights[k] > 0n);
565
+ const shares = largestRemainder(remainder, keys, weights);
566
+ const alloc = {};
567
+ for (const id of entries) alloc[id] = floors[id] + shares[id];
568
+ return toNumbers(alloc, anyWeight ? shares[UNALLOCATED] : remainder);
569
+ }