@edgehero/pi-dispatch 3.1.0 → 4.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.
package/src/sandbox.mjs CHANGED
@@ -9,6 +9,8 @@ import { DEFAULT_BACKEND, PODMAN_BACKEND, UNATTRIBUTED_BACKEND, parseBackendFloo
9
9
  import { configError } from "./config.mjs";
10
10
  import { assertJobUser, CONTAINER_HOME, SHIPPED_IMAGE_UID } from "./container-spec.mjs";
11
11
  import { buildDockerRunArgs, buildPodmanRunArgs, insideDir } from "./docker-run.mjs";
12
+ import { CGROUP_PARENT, cgroupParentFor } from "./cpu-reserve.mjs";
13
+ import { DEFAULT_JOB_SIZE, recordedJobSize } from "./job-size.mjs";
12
14
  import { makeImagePreflight } from "./image-preflight.mjs";
13
15
  import { decideJobUser, JOB_USER_FIX, makeDaemonFactsReader, relabelsPrivateMounts, resolveImageUser, socketFacts } from "./job-user.mjs";
14
16
  import { DEFAULT_EGRESS_PROXY, NETWORK_SUFFIX, createJobNetwork, egressArmed, egressEnv, egressProxyName, networkEndpoints, networkExists, networkNameFor, removeJobNetwork, removeNetworkOrSay } from "./egress.mjs";
@@ -22,7 +24,8 @@ import { isSandboxTombstone, readManifest, readRetained, sandboxDeadline, sandbo
22
24
  *
23
25
  * A SECOND container shape, deliberately not a second copy of the first. The argv comes from the run's VENUE's own
24
26
  * job builder (`buildDockerRunArgs` on `local`, `buildPodmanRunArgs` on `podman`, `SANDBOX_LAUNCHERS` below) through
25
- * its `extraFlags` seam, so `ISOLATION_FLAGS`, `--memory` and `--cpus` (and on podman keep-id and
27
+ * its `extraFlags` seam, so `ISOLATION_FLAGS` and the size flags (`--memory`, `--memory-swap`, `--cpu-shares`, `--shm-size`
28
+ * and the `--cpus` ceiling, at the RUN's recorded size, issue #596) (and on podman keep-id and
26
29
  * `PODMAN_PINNED_FLAGS`) reach this container BY CONSTRUCTION: a future change to the boundary cannot land on job
27
30
  * containers and miss this one, which is the whole reason for reusing the builder rather than writing a leaner argv
28
31
  * here.
@@ -154,7 +157,7 @@ function inPortRange(n) {
154
157
  * @param relabel true where the job's own mounts carried `:Z` (issue #355), so the retained ones do again
155
158
  * @param workspaceOwned true when `workspace` is the retained clone (the worker's own), false for an operator's folder
156
159
  */
157
- export function buildSandboxRunArgs({ venue = DEFAULT_BACKEND, image, name, workspace, jobDir, publish = [], term, idleSeconds = 0, network = null, egressEnv: proxyEnv = {}, user = null, home = null, relabel = false, workspaceOwned = false }) {
160
+ export function buildSandboxRunArgs({ venue = DEFAULT_BACKEND, image, name, workspace, jobDir, publish = [], term, idleSeconds = 0, network = null, egressEnv: proxyEnv = {}, user = null, home = null, relabel = false, workspaceOwned = false, size = DEFAULT_JOB_SIZE, hostCpus = null, cgroupParent = CGROUP_PARENT }) {
158
161
  // Thrown, not defaulted to docker: a caller naming a venue this file has no launcher for is assembling a session
159
162
  // in a runtime nobody chose, which is the one mistake the table exists to make impossible.
160
163
  const launcher = sandboxLauncher(venue);
@@ -192,6 +195,13 @@ export function buildSandboxRunArgs({ venue = DEFAULT_BACKEND, image, name, work
192
195
  // operator's folder, which a private label would take away from every other container.
193
196
  relabel,
194
197
  workspaceOwned,
198
+ // Issue #596: the RUN's size (its manifest's, `sandboxSizeOf`), so the shell has the memory, swap bound and CPU
199
+ // weight the job had, and the same `--cpus` ceiling from this CLI's own runtime read.
200
+ size,
201
+ hostCpus,
202
+ // Issue #596, phase 2: the jobs' parent cgroup, as a job on this venue gets it (none where Podman's cgroup manager
203
+ // is not systemd, `cgroupParentFor`), so a session shares the host's CPU reserve with the jobs.
204
+ cgroupParent,
195
205
  // The terminal's two variables, and neither is a credential. TERM so the shell renders; TMOUT so a
196
206
  // forgotten session closes itself. HOME beside `--user` and the proxy variables below are the rest.
197
207
  // `buildDockerRunArgs` skips undefined, so an unset TERM or a disabled idle timeout emits nothing rather than
@@ -1029,6 +1039,9 @@ export async function openSandbox({
1029
1039
  if (resolved.refused) return resolved;
1030
1040
  // Admitted by `resolveSandbox`, so never null here.
1031
1041
  const { bin } = sandboxLauncher(resolved.venue);
1042
+ // Issue #596: the run's size, decided before anything is asked or created. A recorded size that is malformed refuses.
1043
+ const size = sandboxSizeOf(resolved.manifest);
1044
+ if (size === null) return { refused: "size-invalid", message: `the run's recorded size is malformed, so the size ${jobId} ran at is unknown; re-run the job instead` };
1032
1045
 
1033
1046
  // `--publish` AND AN ARMED POLICY ARE OPPOSITE DIRECTIONS, and docker resolves the contradiction SILENTLY
1034
1047
  // (issue #362). An armed policy puts this shell on its own `--internal` network, and a container attached
@@ -1134,6 +1147,9 @@ export async function openSandbox({
1134
1147
  user: jobUser?.user ?? null,
1135
1148
  home: jobUser?.home ?? null,
1136
1149
  relabel: jobUser?.relabel === true,
1150
+ size,
1151
+ hostCpus: jobUser?.hostCpus ?? null,
1152
+ cgroupParent: jobUser?.cgroupParent === null ? null : CGROUP_PARENT,
1137
1153
  // By containment, the rule `rebaseWorkspace` already moves the retained clone by: a workspace inside the retained
1138
1154
  // job dir is the worker's own clone, one outside it is the operator's folder. Not by the manifest's `kind`, so a run
1139
1155
  // retained before a preparer moved its clone is still judged by where the files actually are.
@@ -1421,15 +1437,21 @@ async function decideLocalSandboxJobUser({
1421
1437
  // Issue #452, gate round 5: the facts read ONCE here even where the uid needs none of them (a VM-backed platform, an
1422
1438
  // endpoint on another machine), and carried beside the answer for the teardown's detach gate, which otherwise reads
1423
1439
  // `docker info` again at teardown and, if that read fails, keeps the network (the Docker Desktop case).
1440
+ // Issue #596: the CPU count rides beside it, for the session's `--cpus` ceiling.
1441
+ let admittedCpus = null;
1424
1442
  const admittedOn = async () => {
1425
1443
  try {
1426
1444
  const read = await readFacts();
1445
+ admittedCpus = read?.answered ? (read.facts?.hostCpus ?? null) : null;
1427
1446
  return read?.answered ? runtimeFromFacts(read) : undefined;
1428
1447
  } catch {
1429
1448
  return undefined;
1430
1449
  }
1431
1450
  };
1432
- if (platform === "darwin" || platform === "win32") return withRuntime({ user: null, home: null }, await admittedOn());
1451
+ if (platform === "darwin" || platform === "win32") {
1452
+ const runtime = await admittedOn();
1453
+ return withRuntime({ user: null, home: null }, runtime, admittedCpus);
1454
+ }
1433
1455
  const stamp = manifest?.jobUser;
1434
1456
  const read = readJobUserStamp(stamp);
1435
1457
  if (read.malformed) return { ...MALFORMED_STAMP };
@@ -1467,7 +1489,8 @@ async function decideLocalSandboxJobUser({
1467
1489
  if (rootful?.refusal) return { refused: PODMAN_CONF_WIDENS_JOB, message: rootfulConfRefusal(rootful.refusal) };
1468
1490
  // `runtime` (issue #452, gate round 4): the facts this session was admitted on, for its teardown's detach gate.
1469
1491
  const runtime = daemon?.answered ? runtimeFromFacts(daemon) : remoteRuntime;
1470
- if (decision.mode === "image") return withRuntime({ user: null, home: null, ...relabel }, runtime);
1492
+ const hostCpus = daemon?.answered ? (daemon.facts?.hostCpus ?? null) : admittedCpus;
1493
+ if (decision.mode === "image") return withRuntime({ user: null, home: null, ...relabel }, runtime, hostCpus);
1471
1494
  const needsImage = identity.euid !== SHIPPED_IMAGE_UID;
1472
1495
  const caps = needsImage ? await imageCapabilities(manifest?.image) : { ok: true, capabilities: [] };
1473
1496
  if (needsImage && !caps?.ok) {
@@ -1478,7 +1501,7 @@ async function decideLocalSandboxJobUser({
1478
1501
  return { refused: chosen.refused, message: `the retained image ${manifest?.image} does not declare anyUid, so it cannot run as the uid that owns this run's files (issue #341)` };
1479
1502
  }
1480
1503
  if (chosen.refused) return { refused: chosen.refused, message: `${JOB_USER_FIX[chosen.cause] ?? "the job user could not be decided"} (issue #341)` };
1481
- return withRuntime({ user: chosen.user, home: chosen.home, ...relabel }, runtime);
1504
+ return withRuntime({ user: chosen.user, home: chosen.home, ...relabel }, runtime, hostCpus);
1482
1505
  }
1483
1506
 
1484
1507
  /**
@@ -1486,8 +1509,14 @@ async function decideLocalSandboxJobUser({
1486
1509
  * non-enumerable, so the answer's shape, which callers compare and print, is what it always was, while `openSandbox`'s
1487
1510
  * teardown hands it to the detach gate and reads the daemon nothing more.
1488
1511
  */
1489
- function withRuntime(answer, runtime) {
1512
+ function withRuntime(answer, runtime, hostCpus = null, cgroupParent = CGROUP_PARENT) {
1490
1513
  if (runtime !== undefined) Object.defineProperty(answer, "runtime", { value: runtime, enumerable: false });
1514
+ // Issue #596, phase 2: whether the session runs under the jobs' parent cgroup (`cgroupParentFor`), non-enumerable for
1515
+ // `runtime`'s reason. Set only when it is none, so every other answer is what it always was.
1516
+ if (cgroupParent === null) Object.defineProperty(answer, "cgroupParent", { value: null, enumerable: false });
1517
+ // Issue #596: the runtime's CPU count from the same read, for the session's `--cpus` ceiling. Non-enumerable for
1518
+ // `runtime`'s reason: the answer's shape stays what it always was.
1519
+ if (Number.isSafeInteger(hostCpus)) Object.defineProperty(answer, "hostCpus", { value: hostCpus, enumerable: false });
1491
1520
  return answer;
1492
1521
  }
1493
1522
 
@@ -1582,7 +1611,19 @@ async function decidePodmanSandboxJobUser({
1582
1611
  if (chosen.unavailable) return { refused: "job-user-unknown", message: `which uid the sandbox may run as could not be decided (${chosen.reason}); is podman answering \`podman info\` as this account?` };
1583
1612
  // `relabel` on podman is `podman info`'s SELinux fact, the rule a podman job's own mounts follow (issue #355).
1584
1613
  // `runtime` (issue #452, gate round 4): the same read, for the session teardown's detach gate, so it reads nothing again.
1585
- return withRuntime({ user: chosen.user, home: chosen.home, ...(chosen.relabel === true ? { relabel: true } : {}) }, { podman: true, rootless: info.info?.rootless ?? null, version: info.info?.version ?? null });
1614
+ return withRuntime({ user: chosen.user, home: chosen.home, ...(chosen.relabel === true ? { relabel: true } : {}) }, { podman: true, rootless: info.info?.rootless ?? null, version: info.info?.version ?? null }, info.info?.hostCpus ?? null, cgroupParentFor({ podman: true, cgroupManager: info.info?.cgroupManager ?? null }));
1615
+ }
1616
+
1617
+ /**
1618
+ * The size a sandbox reopens a run at (issue #596): the size its manifest recorded, or the built-in 4g and 2 for a run
1619
+ * retained before sizes existed (no `size` key), which is the size every such run had. A `size` key that is PRESENT
1620
+ * and does not rebuild (`recordedJobSize`) is null, and the open refuses (`size-invalid`): the retention writes the key
1621
+ * only with a size, so a malformed one is damage, and repairing it to 4g would open a shell at a size the run never
1622
+ * had, larger than a small project's, without a word.
1623
+ */
1624
+ export function sandboxSizeOf(manifest) {
1625
+ if (!manifest || typeof manifest !== "object" || !Object.hasOwn(manifest, "size")) return DEFAULT_JOB_SIZE;
1626
+ return recordedJobSize(manifest.size);
1586
1627
  }
1587
1628
 
1588
1629
  /**
@@ -39,6 +39,10 @@
39
39
  * its repo or folder row, then its project's row, then the global windows (`scopedLedgers`), and its keys are built from
40
40
  * the project ROW's scope like every other row's, so a project needs no keyspace of its own.
41
41
  *
42
+ * Issue #596 (phase 1) adds version 3: a project row may carry its jobs' size (`memory`, `cpus`) and the host budget's
43
+ * two knobs (`hostShare`, `minJobs`), see `SIZE_LIMIT_FIELDS`. This module only parses them; `job-size.mjs` resolves the
44
+ * size at pickup, and the host budget (`host-budget.mjs`, phase 2) enforces `hostShare` and `minJobs`.
45
+ *
42
46
  * Custom: scoped limits validated inline per triggers.mjs/pause-windows.mjs precedent; zod not in deps
43
47
  */
44
48
 
@@ -51,6 +55,7 @@ import { splitModelEntry } from "./model-ref.mjs";
51
55
  import { formatMicros, parseUsdMicros } from "./money.mjs";
52
56
  import { parseScopeString, qualifiedScopeOf, scopeOf } from "./pause-windows.mjs";
53
57
  import { PROJECT_ID_RE, isProjectId } from "./project-id.mjs";
58
+ import { formatCpus, formatMemory, parseCpus, parseMemory } from "./job-size.mjs";
54
59
 
55
60
  /**
56
61
  * The highest schema version this build reads. A file declaring a higher one is refused loudly. The admin writes the
@@ -66,7 +71,26 @@ import { PROJECT_ID_RE, isProjectId } from "./project-id.mjs";
66
71
  * cap, a concurrency limit and a lease that one build enforces and another silently ignores. Version 2 makes every
67
72
  * older build refuse the file loudly instead.
68
73
  */
69
- export const SCOPED_LIMITS_VERSION = 2;
74
+ export const SCOPED_LIMITS_VERSION = 3;
75
+
76
+ /**
77
+ * The four size fields of version 3 (issue #596), on a PROJECT row only, in display order:
78
+ * - `memory`: the project's jobs' memory (`"512m"`, `"4g"`; `job-size.mjs` `parseMemory`), stored in its one spelling;
79
+ * - `cpus`: their CPU weight (`0.5`, `2`; `parseCpus`), stored as a number;
80
+ * - `hostShare`: the most of a host's job budget the project's running jobs may hold together, as a whole PERCENTAGE
81
+ * from 1 to 100 (`50` is half). ENFORCED by each host's budget (`host-budget.mjs`): a job that would take the
82
+ * project past it waits, and a size above it is refused there for a job on that host's own queue
83
+ * (`job-size-exceeds-share`; on the shared queue it waits for a host whose share fits it);
84
+ * - `minJobs`: how many of the project's jobs a host should make room for before it admits other projects' jobs, a
85
+ * soft minimum. ENFORCED by the host budget's tier 1 holds (`host-budget.mjs` `rankHolds`).
86
+ * A version 1 or 2 file that carries one is refused naming version 3, for the version 2 reason: a 3.1.0 worker drops
87
+ * unknown fields, so the file would size its jobs on one build and not on another. Every released build refuses a
88
+ * version 3 file as newer, but only when it LOADS the file, at boot: a worker already running when the file becomes
89
+ * version 3 keeps its last good file on reload (`scoped_limits_reload_invalid`) and runs the project's jobs at the
90
+ * default size until it is upgraded and restarted. So every worker is upgraded and restarted before a size is written.
91
+ * A version 3 row also refuses a key it does not know, so a misspelled size field cannot drop out silently.
92
+ */
93
+ export const SIZE_LIMIT_FIELDS = Object.freeze(["memory", "cpus", "hostShare", "minJobs"]);
70
94
 
71
95
  /** The four job-count limit fields a row may carry, in display order. */
72
96
  const LIMIT_FIELDS = ["day", "week", "month", "concurrent"];
@@ -74,6 +98,9 @@ const LIMIT_FIELDS = ["day", "week", "month", "concurrent"];
74
98
  /** The three dollar window fields (version 2), in display order. Each maps to a dollar window: day, week, month. */
75
99
  export const USD_LIMIT_FIELDS = Object.freeze(["dayUsd", "weekUsd", "monthUsd"]);
76
100
 
101
+ /** Every key a version 3 row may carry (issue #596). Any other key refuses a version 3 file (`normalizeLimit`). */
102
+ const V3_ROW_KEYS = new Set(["scope", ...LIMIT_FIELDS, ...USD_LIMIT_FIELDS, ...SIZE_LIMIT_FIELDS]);
103
+
77
104
  /**
78
105
  * The scope prefix of a per-model row (issue #502 part 6): `model:<provider>/<model>`. Reserved in BOTH versions. A
79
106
  * version 1 file naming it is refused (naming version 2) rather than read as a folder called `model:...`.
@@ -156,9 +183,10 @@ export function canonicalScope(job) {
156
183
 
157
184
  /**
158
185
  * Parse, validate, and normalize the scoped-limits file TEXT. Returns the normalized `limits` array
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
161
- * fields, unknown fields dropped -- the operator-file policy). Throws `configError` (fail-loud) on any
186
+ * (every row rebuilt as an explicit `{ scope, day, week, month, concurrent }` literal in a version 1 file,
187
+ * `{ scope, day, week, month, concurrent, dayUsd, weekUsd, monthUsd }` in a version 2 one, and that plus `memory,
188
+ * cpus, hostShare, minJobs` in a version 3 one, `null` for absent fields, unknown fields dropped in a version 1 or 2
189
+ * file -- the operator-file policy -- and refused in a version 3 one). Throws `configError` (fail-loud) on any
162
190
  * malformed entry. `path` is for error messages only -- this function touches no filesystem.
163
191
  */
164
192
  export function parseScopedLimits(text, path) {
@@ -173,7 +201,7 @@ export function parseScopedLimits(text, path) {
173
201
  }
174
202
  const version = parsed.version;
175
203
  if (!Number.isInteger(version) || version < 1) {
176
- throw configError(`scoped-limits file must have "version": 1 or ${SCOPED_LIMITS_VERSION} (an integer >= 1): ${path}`);
204
+ throw configError(`scoped-limits file must have "version": 1 to ${SCOPED_LIMITS_VERSION} (an integer >= 1): ${path}`);
177
205
  }
178
206
  if (version > SCOPED_LIMITS_VERSION) {
179
207
  throw configError(`scoped-limits file written by a newer pi-dispatch (version ${version}; this build understands ${SCOPED_LIMITS_VERSION}): ${path}`);
@@ -240,10 +268,10 @@ function normalizeModelLimit(row, trimmed, at, path, version) {
240
268
  // The runner folds model-less and overflow calls into an `other/other` usage row, so a row naming that pair
241
269
  // could never be told apart from the fold.
242
270
  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) {
271
+ for (const field of [...LIMIT_FIELDS, ...SIZE_LIMIT_FIELDS]) {
244
272
  if (row[field] !== undefined && row[field] !== null) throw configError(`${at}: a model row carries dayUsd, weekUsd and monthUsd only (${field} is refused): ${path}`);
245
273
  }
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 };
274
+ const norm = { scope: `${MODEL_SCOPE_PREFIX}${ref.provider}/${ref.model}`, day: null, week: null, month: null, concurrent: null, dayUsd: null, weekUsd: null, monthUsd: null, ...(version >= 3 ? sizeNulls() : {}) };
247
275
  let any = false;
248
276
  for (const field of USD_LIMIT_FIELDS) {
249
277
  if (row[field] === undefined || row[field] === null) continue;
@@ -260,6 +288,13 @@ function normalizeLimit(row, index, path, version = SCOPED_LIMITS_VERSION) {
260
288
  throw configError(`${at}: must be an object: ${path}`);
261
289
  }
262
290
  if (!isNonEmptyString(row.scope)) throw configError(`${at}: scope must be a non-empty string: ${path}`);
291
+ // Version 3 refuses a key it does not know (issue #596): a misspelled `Memory`, `cpu` or `mem` beside a field that
292
+ // keeps the row valid was dropped, and the project's jobs ran at the default size while the file read as sized.
293
+ // Versions 1 and 2 keep dropping unknown keys, so a file an older build wrote and reads still loads unchanged.
294
+ if (version >= 3) {
295
+ const unknown = Object.keys(row).filter((key) => !V3_ROW_KEYS.has(key));
296
+ if (unknown.length > 0) throw configError(`${at}: unknown key${unknown.length === 1 ? "" : "s"} ${unknown.map((k) => JSON.stringify(k)).join(", ")} (a version 3 row carries only scope, ${[...LIMIT_FIELDS, ...USD_LIMIT_FIELDS, ...SIZE_LIMIT_FIELDS].join(", ")}; keys are case-sensitive): ${path}`);
297
+ }
263
298
  const trimmed = row.scope.trim().normalize("NFC"); // the same NFC canonicalScope applies job-side
264
299
  if (isModelScope(trimmed)) return normalizeModelLimit(row, trimmed, at, path, version);
265
300
  // A NEAR MISS of a reserved prefix is refused, never read as a repo or folder row (PR #549's review): a
@@ -312,6 +347,9 @@ function normalizeLimit(row, index, path, version = SCOPED_LIMITS_VERSION) {
312
347
  // read-modify-write writes back exactly what the parser accepts. `dollarCapsFor` turns them into integers.
313
348
  // Only in a version 2 file's rows: a version 1 file reads into exactly the five-key literal it always did.
314
349
  ...(version >= 2 ? { dayUsd: null, weekUsd: null, monthUsd: null } : {}),
350
+ // Version 3's size fields (issue #596), the same way: only in a version 3 file's rows, null unless a project row
351
+ // sets them, so a version 1 or 2 file reads into exactly the literal it always did.
352
+ ...(version >= 3 ? sizeNulls() : {}),
315
353
  };
316
354
  let any = false;
317
355
  for (const field of USD_LIMIT_FIELDS) {
@@ -337,12 +375,55 @@ function normalizeLimit(row, index, path, version = SCOPED_LIMITS_VERSION) {
337
375
  norm[field] = value;
338
376
  any = true;
339
377
  }
378
+ // Issue #596: a project row may carry a size and nothing else, so the size fields count as limiting something.
379
+ if (sizeFields(row, norm, { at, path, version, project: isProjectScope(trimmed) })) any = true;
340
380
  if (!any) {
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}`);
381
+ throw configError(`${at}: at least one of day, week, month, concurrent${version >= 2 ? ", dayUsd, weekUsd, monthUsd" : ""}${version >= 3 && isProjectScope(trimmed) ? ", memory, cpus, hostShare, minJobs" : ""} is required (a row that limits nothing is a row an operator sets and then trusts): ${path}`);
342
382
  }
343
383
  return norm;
344
384
  }
345
385
 
386
+ /** The four version 3 size fields, all null: what a version 3 row carries when it sets none. */
387
+ function sizeNulls() {
388
+ return { memory: null, cpus: null, hostShare: null, minJobs: null };
389
+ }
390
+
391
+ /**
392
+ * Read a row's size fields into `norm` (issue #596), refusing loudly, and say whether it set any. Only a project row
393
+ * may carry them: a size is what one project's jobs need, and a repo or folder row is not where a job's project is
394
+ * decided. `minJobs` needs a size on the same row (a minimum of jobs of an unstated size reserves nothing anyone can
395
+ * judge) and may not exceed the row's own `concurrent` (a minimum the project can never reach). `hostShare` against
396
+ * `minJobs` times the size is judged where a host's budget is known (doctor warns, `hostBudgetChecks`), because a
397
+ * percentage of a host is an amount only on a host.
398
+ */
399
+ function sizeFields(row, norm, { at, path, version, project }) {
400
+ const present = SIZE_LIMIT_FIELDS.filter((f) => row[f] !== undefined && row[f] !== null);
401
+ if (present.length === 0) return false;
402
+ if (!project) throw configError(`${at}: ${present.join(", ")} belong on a project row (project:<id>), because a job's size is its project's: ${path}`);
403
+ if (version < 3) throw configError(`${at}: ${present.join(", ")} needs "version": 3 (this file says ${version}), so a build that predates job sizes refuses the file rather than running the project's jobs at the default size: ${path}`);
404
+ const sized = (field, parse, store) => {
405
+ if (row[field] === undefined || row[field] === null) return;
406
+ try {
407
+ norm[field] = store(parse(row[field]));
408
+ } catch (error) {
409
+ throw configError(`${at}: ${field}: ${error.message}: ${path}`);
410
+ }
411
+ };
412
+ sized("memory", parseMemory, formatMemory);
413
+ sized("cpus", parseCpus, (centi) => Number(formatCpus(centi)));
414
+ if (row.hostShare !== undefined && row.hostShare !== null) {
415
+ if (!Number.isSafeInteger(row.hostShare) || row.hostShare < 1 || row.hostShare > 100) throw configError(`${at}: hostShare is a whole percentage of the host's job budget, from 1 to 100 (got ${JSON.stringify(row.hostShare)}): ${path}`);
416
+ norm.hostShare = row.hostShare;
417
+ }
418
+ if (row.minJobs !== undefined && row.minJobs !== null) {
419
+ if (!Number.isSafeInteger(row.minJobs) || row.minJobs < 1) throw configError(`${at}: minJobs must be an integer >= 1: ${path}`);
420
+ if (norm.memory === null && norm.cpus === null) throw configError(`${at}: minJobs needs memory or cpus on the same row, so the room it asks a host to keep has a size: ${path}`);
421
+ if (norm.concurrent !== null && row.minJobs > norm.concurrent) throw configError(`${at}: minJobs ${row.minJobs} is above this row's concurrent ${norm.concurrent}, a minimum the project can never reach: ${path}`);
422
+ norm.minJobs = row.minJobs;
423
+ }
424
+ return true;
425
+ }
426
+
346
427
  /**
347
428
  * Load and validate the scoped-limits file named by `config.scopedLimitsFile`. Returns `[]` when the file
348
429
  * is unset (no scoped caps or concurrency -- a valid deployment; the folder mutex holds regardless, it is
@@ -567,12 +648,15 @@ export function dollarRowsBelowJobCap(limits, deploymentMaxCostUsd) {
567
648
  }
568
649
 
569
650
  /**
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.
651
+ * The lowest file version that expresses `rows` (normalized or as the admin builds them): 3 when any row carries a size
652
+ * field (issue #596), else 2 when any row carries a dollar field, is a model row, is a project row (issue #499 part B)
653
+ * or has a forge-qualified scope (issue #498), else 1.
572
654
  * The admin writes this, so a file
573
655
  * with bare and folder job-count rows only stays a version 1 file that an older worker still reads.
574
656
  */
575
657
  export function scopedLimitsVersionFor(rows) {
658
+ // Issue #596: a size field on any row needs version 3 (the parser refuses it off a project row, and says so).
659
+ if ((rows ?? []).some((l) => SIZE_LIMIT_FIELDS.some((f) => l?.[f] !== null && l?.[f] !== undefined))) return 3;
576
660
  const v2 = (rows ?? []).some((l) => {
577
661
  const scope = typeof l?.scope === "string" ? l.scope.trim() : l?.scope;
578
662
  return isModelScope(scope) || isProjectScope(scope) || isQualifiedScope(scope) || USD_LIMIT_FIELDS.some((f) => l?.[f] !== null && l?.[f] !== undefined);
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The run records a size suggestion reads (issue #596, phase 3, DES-SIZE-SUGGESTIONS), read ONCE per doctor run, panel
3
+ * view or insights render and handed to `suggestSize` for every project. Doctor and the admin share this reader, so the
4
+ * bounds below hold on every surface:
5
+ *
6
+ * - an MTIME PREFILTER: a file the worker last wrote before the window (plus a day for skew) is not opened. A
7
+ * keep-forever logs directory (`PI_LOG_RETENTION_DAYS=0`) is otherwise read whole on every render;
8
+ * - a SIZE CAP per file (`SIZING_RECORD_MAX_BYTES`, 256 KiB): a larger file is skipped and COUNTED, never parsed. A
9
+ * run record is a few KiB; the cap keeps a hand-placed or corrupted giant from costing a render its memory, and
10
+ * the count says it happened rather than leaving a silent hole;
11
+ * - only a record that carries measurements (an object `resources` and a `size`) is kept, and of those the NEWEST
12
+ * `SUGGEST_WINDOW_RUNS` per project (by `endedAt`), since no suggestion reads more. The filter runs BEFORE the
13
+ * cut: a run refused before its container started (a budget or policy refusal) writes a record with neither, and
14
+ * cut first, fifty such refusals would crowd every measured run out of the window.
15
+ *
16
+ * Never throws: an absent directory holds no records; another unreadable one is `unreachable` (doctor reads that as
17
+ * none, the panel says it).
18
+ */
19
+
20
+ import { readdirSync, readFileSync, statSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { SUGGEST_WINDOW_DAYS, SUGGEST_WINDOW_RUNS } from "./size-suggest.mjs";
23
+
24
+ /** The largest run record a size suggestion parses: 256 KiB. A larger one is skipped and counted. */
25
+ export const SIZING_RECORD_MAX_BYTES = 256 * 1024;
26
+ /** How far back a file's mtime may lie and still be opened: the window plus a day of skew. */
27
+ export const SIZING_MTIME_DAYS = SUGGEST_WINDOW_DAYS + 1;
28
+
29
+ const DAY_MS = 24 * 60 * 60 * 1000;
30
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
31
+
32
+ /**
33
+ * `{ records, skipped, unreachable }`: the parsed records (at most SUGGEST_WINDOW_RUNS per project, newest first by
34
+ * `endedAt`; a record with no string `project`, no readable `endedAt`, no object `resources` or no `size` is dropped,
35
+ * since no suggestion could read it), how many files were skipped for their size, and null or a reason when the
36
+ * directory itself could not be listed.
37
+ * `fs` is `{ readdirSync, statSync, readFileSync }`.
38
+ */
39
+ export function readSizingRecords(logsDir, { nowMs, fs = { readdirSync, readFileSync, statSync } } = {}) {
40
+ const since = nowMs - SIZING_MTIME_DAYS * DAY_MS;
41
+ let names;
42
+ try {
43
+ names = fs.readdirSync(logsDir);
44
+ } catch (err) {
45
+ return { records: [], skipped: 0, unreachable: err?.code === "ENOENT" ? null : `logs dir unreadable (${err?.code ?? "read-error"})` };
46
+ }
47
+ const byProject = new Map();
48
+ let skipped = 0;
49
+ for (const name of names) {
50
+ if (typeof name !== "string" || !name.endsWith(".json")) continue;
51
+ try {
52
+ const path = join(logsDir, name);
53
+ const st = fs.statSync(path);
54
+ if (!st.isFile() || st.mtimeMs < since) continue;
55
+ if (st.size > SIZING_RECORD_MAX_BYTES) {
56
+ skipped++;
57
+ continue;
58
+ }
59
+ const buf = fs.readFileSync(path);
60
+ if (buf.length > SIZING_RECORD_MAX_BYTES) {
61
+ skipped++; // it grew between the stat and the read
62
+ continue;
63
+ }
64
+ const record = JSON.parse(buf.toString("utf8"));
65
+ const at = typeof record?.endedAt === "string" ? Date.parse(record.endedAt) : NaN;
66
+ if (typeof record?.project !== "string" || !Number.isFinite(at)) continue;
67
+ if (!isObject(record.resources) || !isObject(record.size)) continue; // no measurements: never counts toward the 50
68
+ if (!byProject.has(record.project)) byProject.set(record.project, []);
69
+ byProject.get(record.project).push({ at, record });
70
+ } catch {
71
+ // unparseable, or reaped between the listing and the read
72
+ }
73
+ }
74
+ const records = [];
75
+ for (const list of byProject.values()) {
76
+ list.sort((a, b) => b.at - a.at);
77
+ for (const { record } of list.slice(0, SUGGEST_WINDOW_RUNS)) records.push(record);
78
+ }
79
+ return { records, skipped, unreachable: null };
80
+ }