@edgehero/pi-dispatch 4.3.1 → 5.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 (260) hide show
  1. package/.env.example +84 -3
  2. package/README.md +21 -7
  3. package/deploy/console.service +52 -0
  4. package/deploy/docker-compose.yml +3 -1
  5. package/deploy/pi-dispatch-valkey.container +4 -2
  6. package/deploy/sandbox-gateway.service +23 -0
  7. package/deploy/worker-env-wrapper.cmd +6 -0
  8. package/deploy/worker-env-wrapper.sh +10 -1
  9. package/package.json +118 -19
  10. package/src/agent-snapshot.mjs +49 -0
  11. package/src/answerer-runtime.mjs +194 -0
  12. package/src/answerer.mjs +178 -0
  13. package/src/approval-policy.mjs +76 -0
  14. package/src/approval-runtime.mjs +188 -0
  15. package/src/approval-session.mjs +137 -0
  16. package/src/approval-state.mjs +170 -0
  17. package/src/auth-cli.mjs +86 -0
  18. package/src/auth-connections.mjs +145 -0
  19. package/src/auth-policy.mjs +32 -0
  20. package/src/auth-store.mjs +97 -0
  21. package/src/azure-host.mjs +66 -4
  22. package/src/azure-prompt.mjs +5 -4
  23. package/src/backend-conformance.mjs +34 -2
  24. package/src/backend-local.mjs +103 -34
  25. package/src/backend-openshell.mjs +1540 -0
  26. package/src/backend-podman.mjs +63 -2
  27. package/src/backend-registry.mjs +61 -4
  28. package/src/backends.mjs +395 -6
  29. package/src/bullmq-internals.mjs +208 -0
  30. package/src/canary-cli.mjs +28 -0
  31. package/src/canary-lock.mjs +41 -0
  32. package/src/canary-runtime.mjs +237 -0
  33. package/src/canary.mjs +53 -0
  34. package/src/cancel-cli.mjs +47 -34
  35. package/src/cancel-state.mjs +213 -15
  36. package/src/capabilities.mjs +36 -1
  37. package/src/capacity-cli.mjs +28 -8
  38. package/src/capacity-records.mjs +75 -16
  39. package/src/capacity.mjs +65 -0
  40. package/src/chat-cli.mjs +288 -0
  41. package/src/chat-fork.mjs +547 -0
  42. package/src/chat-mode.mjs +156 -0
  43. package/src/chat-view.mjs +720 -0
  44. package/src/checkpoint-paths.mjs +113 -0
  45. package/src/cli.mjs +136 -41
  46. package/src/comment-tables.mjs +322 -0
  47. package/src/community-auth-adapters.mjs +87 -0
  48. package/src/community-auth-helper.mjs +76 -0
  49. package/src/community-auth-metadata.mjs +6 -0
  50. package/src/community-auth-output-pins.mjs +38 -0
  51. package/src/community-auth-patches.mjs +168 -0
  52. package/src/community-auth-pins.mjs +38 -0
  53. package/src/config.mjs +190 -29
  54. package/src/connection.mjs +83 -7
  55. package/src/console-answer.mjs +113 -0
  56. package/src/console-audit.mjs +604 -0
  57. package/src/console-bootstrap.mjs +325 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-devices.mjs +372 -0
  60. package/src/console-install.mjs +239 -0
  61. package/src/console-open.mjs +99 -0
  62. package/src/console-probe.mjs +106 -0
  63. package/src/console-tokens.mjs +459 -0
  64. package/src/container-names.mjs +51 -0
  65. package/src/container-spec.mjs +20 -1
  66. package/src/cpu-reserve.mjs +4 -1
  67. package/src/defer-reasons.mjs +200 -0
  68. package/src/defer-write.mjs +208 -0
  69. package/src/deployment-venue.mjs +23 -6
  70. package/src/docker-run.mjs +11 -0
  71. package/src/doctor-bundles.mjs +130 -0
  72. package/src/doctor-model-proposals.mjs +109 -0
  73. package/src/doctor-openshell.mjs +238 -0
  74. package/src/doctor-results.mjs +602 -0
  75. package/src/doctor-spawner.mjs +215 -0
  76. package/src/doctor.mjs +1657 -196
  77. package/src/dollar-budget.mjs +212 -3
  78. package/src/endpoint-status.mjs +100 -0
  79. package/src/env-allowlist.mjs +96 -6
  80. package/src/env-file.mjs +6 -4
  81. package/src/envelope.mjs +5 -3
  82. package/src/file-expect.mjs +47 -0
  83. package/src/forgejo-host.mjs +42 -5
  84. package/src/forgejo-prompt.mjs +5 -4
  85. package/src/forges.mjs +33 -3
  86. package/src/fork-job.mjs +58 -0
  87. package/src/fork-policy.mjs +23 -0
  88. package/src/fork-store.mjs +338 -0
  89. package/src/get-token.mjs +1 -1
  90. package/src/git-dirty.mjs +119 -14
  91. package/src/git-hardening.mjs +85 -0
  92. package/src/github-host.mjs +31 -6
  93. package/src/github-prompt.mjs +7 -16
  94. package/src/gitlab-host.mjs +42 -3
  95. package/src/gitlab-prompt.mjs +5 -4
  96. package/src/held-document.mjs +23 -0
  97. package/src/held-replies.mjs +325 -0
  98. package/src/held-settle.mjs +266 -0
  99. package/src/hook-runs.mjs +188 -0
  100. package/src/host-budget.mjs +8 -5
  101. package/src/host-clone.mjs +72 -0
  102. package/src/host-loads.mjs +557 -0
  103. package/src/host-registry.mjs +66 -6
  104. package/src/host-telemetry.mjs +92 -0
  105. package/src/http-body.mjs +78 -0
  106. package/src/image-preflight.mjs +156 -13
  107. package/src/import-pi.mjs +60 -172
  108. package/src/inbox-relay.mjs +482 -0
  109. package/src/inbox-settle.mjs +196 -0
  110. package/src/index.mjs +734 -129
  111. package/src/init.mjs +35 -5
  112. package/src/job-data-version.mjs +75 -0
  113. package/src/job-id.mjs +30 -15
  114. package/src/job-layout.mjs +22 -0
  115. package/src/job-size.mjs +16 -0
  116. package/src/live-jobs.mjs +14 -6
  117. package/src/live-probes.mjs +6 -2
  118. package/src/live-transcript.mjs +872 -0
  119. package/src/loaded-record.mjs +609 -0
  120. package/src/mcp-config.mjs +4 -2
  121. package/src/mcp-preflight.mjs +36 -5
  122. package/src/model-attempts.mjs +84 -0
  123. package/src/model-catalog.mjs +83 -0
  124. package/src/model-cooldowns.mjs +22 -0
  125. package/src/model-failover-protocol.mjs +11 -0
  126. package/src/model-failover.mjs +134 -0
  127. package/src/model-observation.mjs +50 -0
  128. package/src/model-proposal-protocol.mjs +21 -0
  129. package/src/model-proposals.mjs +75 -0
  130. package/src/model-ref.mjs +67 -10
  131. package/src/model-roles-file.mjs +24 -0
  132. package/src/model-roles.mjs +73 -0
  133. package/src/model-status.mjs +34 -0
  134. package/src/models-json.mjs +12 -2
  135. package/src/money.mjs +1 -1
  136. package/src/oauth-openai.mjs +121 -0
  137. package/src/older-state.mjs +249 -0
  138. package/src/on-failure.mjs +17 -13
  139. package/src/open-browser.mjs +31 -2
  140. package/src/opener-cli.mjs +135 -0
  141. package/src/opener-protocol.mjs +27 -0
  142. package/src/opener-terminal.mjs +128 -0
  143. package/src/openshell-cli.mjs +155 -0
  144. package/src/openshell-policy.mjs +163 -0
  145. package/src/outbox.mjs +14 -0
  146. package/src/output-cap.mjs +1 -0
  147. package/src/package-canary-record.mjs +26 -0
  148. package/src/package-canary.mjs +135 -0
  149. package/src/package-names.mjs +9 -0
  150. package/src/package-stage.mjs +418 -0
  151. package/src/packages.mjs +24 -16
  152. package/src/parked.mjs +2 -1
  153. package/src/pause-windows.mjs +136 -17
  154. package/src/podman-stack.mjs +40 -14
  155. package/src/portfolio-snapshot.mjs +26 -7
  156. package/src/prepare-github.mjs +41 -77
  157. package/src/prepare-local.mjs +32 -2
  158. package/src/prepare.mjs +45 -11
  159. package/src/private-store-boundary.mjs +70 -0
  160. package/src/processor.mjs +1550 -173
  161. package/src/projects.mjs +23 -0
  162. package/src/prompt-fence.mjs +18 -0
  163. package/src/provider-key.mjs +7 -0
  164. package/src/provider-steering.mjs +1 -0
  165. package/src/question-context.mjs +35 -0
  166. package/src/question-group-runtime.mjs +91 -0
  167. package/src/question-groups.mjs +171 -0
  168. package/src/queue.mjs +264 -55
  169. package/src/reason-catalog.mjs +448 -0
  170. package/src/receiver-status.mjs +230 -0
  171. package/src/record-reason.mjs +37 -0
  172. package/src/recovery-queue.mjs +566 -0
  173. package/src/redact-secrets.mjs +219 -0
  174. package/src/reserve-estimate.mjs +101 -0
  175. package/src/reserved-env.mjs +36 -1
  176. package/src/retention.mjs +67 -0
  177. package/src/run-budget-ledger.mjs +283 -0
  178. package/src/run-checkpoint.mjs +627 -0
  179. package/src/run-container.mjs +749 -33
  180. package/src/run-history.mjs +791 -45
  181. package/src/run-mirror.mjs +75 -7
  182. package/src/run-progress.mjs +751 -0
  183. package/src/runtime-observations.mjs +3 -1
  184. package/src/runtime-settings.mjs +272 -37
  185. package/src/sandbox-cli.mjs +78 -2
  186. package/src/sandbox-deployment.mjs +51 -0
  187. package/src/sandbox-gateway-protocol.mjs +378 -0
  188. package/src/sandbox-hosts.mjs +100 -0
  189. package/src/sandbox-inventory.mjs +83 -0
  190. package/src/sandbox-policy.mjs +225 -0
  191. package/src/sandbox-prepare-requests.mjs +145 -0
  192. package/src/sandbox-prepare.mjs +285 -0
  193. package/src/sandbox-refs.mjs +70 -0
  194. package/src/sandbox-session-monitor.mjs +4 -0
  195. package/src/sandbox-session-watch.mjs +124 -0
  196. package/src/sandbox-sessions.mjs +161 -0
  197. package/src/sandbox-store.mjs +63 -6
  198. package/src/sandbox-workspace-guard.mjs +39 -0
  199. package/src/sandbox.mjs +27 -200
  200. package/src/scheduler-stall-guard.mjs +14 -8
  201. package/src/schedules.mjs +19 -6
  202. package/src/secret-bundles.mjs +245 -0
  203. package/src/secret-profiles.mjs +103 -3
  204. package/src/secrets.mjs +19 -9
  205. package/src/service-env.mjs +38 -5
  206. package/src/service.mjs +638 -106
  207. package/src/session-key.mjs +60 -0
  208. package/src/session-store.mjs +168 -10
  209. package/src/setup-contract.mjs +40 -0
  210. package/src/setup-egress.mjs +113 -0
  211. package/src/setup-host-entry.mjs +36 -0
  212. package/src/setup-host.mjs +242 -0
  213. package/src/setup-lock.mjs +41 -0
  214. package/src/size-records.mjs +15 -3
  215. package/src/start.mjs +1353 -135
  216. package/src/status-comment.mjs +415 -0
  217. package/src/stderr-transcript.mjs +318 -0
  218. package/src/steer-ack.mjs +122 -0
  219. package/src/steer-cli.mjs +213 -0
  220. package/src/steer-framing.mjs +185 -0
  221. package/src/steer-inbox.mjs +693 -0
  222. package/src/store-version.mjs +139 -0
  223. package/src/subscription-config.mjs +56 -0
  224. package/src/subscription-limits.mjs +3 -0
  225. package/src/subscription-quota.mjs +259 -0
  226. package/src/subscription-runtime.mjs +123 -0
  227. package/src/subscription-status.mjs +42 -0
  228. package/src/subscription-wait.mjs +11 -0
  229. package/src/subscriptions.mjs +7 -1
  230. package/src/thinking-level.mjs +19 -0
  231. package/src/touch-outbox.mjs +61 -0
  232. package/src/touch-state.mjs +133 -0
  233. package/src/triggers-file.mjs +299 -39
  234. package/src/triggers.mjs +780 -38
  235. package/src/trust-paths.mjs +171 -0
  236. package/src/up.mjs +402 -53
  237. package/src/updates-cli.mjs +61 -0
  238. package/src/updates-look.mjs +127 -0
  239. package/src/updates-reconcile.mjs +182 -0
  240. package/src/updates-subscription-adapters.mjs +8 -0
  241. package/src/updates.mjs +252 -0
  242. package/src/valkey-auth.mjs +32 -9
  243. package/src/valkey-endpoint.mjs +14 -4
  244. package/src/wait-for.mjs +1 -1
  245. package/src/wait-state.mjs +62 -3
  246. package/src/watch-board.mjs +38 -0
  247. package/src/watch-closer.mjs +1 -1
  248. package/src/watch-contract.mjs +102 -0
  249. package/src/watch-decision.mjs +78 -0
  250. package/src/watch-drainer.mjs +14 -0
  251. package/src/watch-issue-adapter.mjs +44 -0
  252. package/src/watch-issue.mjs +111 -0
  253. package/src/watch-record.mjs +10 -0
  254. package/src/watch-runtime.mjs +167 -0
  255. package/src/watch-store.mjs +183 -0
  256. package/src/watch-trace.mjs +34 -0
  257. package/src/workspace-checkpoint.mjs +1750 -0
  258. package/src/zero-touch-fixes.mjs +99 -0
  259. package/src/zero-touch.mjs +225 -0
  260. package/src/repeat-slot.mjs +0 -13
package/src/packages.mjs CHANGED
@@ -27,6 +27,7 @@
27
27
  * manifest must degrade to "no staged packages", not crash the worker mid-queue.
28
28
  */
29
29
 
30
+ import { EXACT_VERSION_RE, NPM_NAME_RE } from "./package-names.mjs";
30
31
  import { existsSync, readdirSync, readFileSync } from "node:fs";
31
32
  import { basename, dirname, join, resolve } from "node:path";
32
33
  import { configError } from "./config.mjs";
@@ -50,9 +51,7 @@ export const STAGE_MANIFEST = "packages.json";
50
51
  export const CONTAINER_PACKAGES_ROOT = `${CONTAINER_GLOBAL_PI_DIR}/${PACKAGES_SUBDIR}`;
51
52
 
52
53
  /** An exact semver -- prerelease and build metadata allowed, ranges/tags/wildcards are not. */
53
- export const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
54
- /** An npm package name: lowercase, optionally `@scope/`-prefixed. */
55
- export const NPM_NAME_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
54
+ export { EXACT_VERSION_RE, NPM_NAME_RE };
56
55
 
57
56
  /**
58
57
  * The pi resource kinds a package may contribute by convention dir, when it carries no `pi` manifest.
@@ -61,8 +60,8 @@ export const NPM_NAME_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
61
60
  * would add an edge to the import-pi <-> packages cycle that only survives because both sides use their
62
61
  * bindings at call time.
63
62
  *
64
- * This list is pi's, not ours: at the 0.99.1 pin (as at 0.80.7) `collectPackageResources` falls through to exactly these
65
- * four directory names when `readPiManifest` returns null, so a package with no `pi` key and a `skills/`
63
+ * This list is pi's, not ours: at the pin `collectPackageResources` falls through to exactly these four directory
64
+ * names when `readPiManifest` returns null or lists no resource (1.5.0), so a package with no `pi` key and a `skills/`
66
65
  * dir IS a pi package. See host-pi.mjs's PINNED_PI_NEEDLES for the assertion that keeps that true.
67
66
  */
68
67
  export const RESOURCE_DIRS = ["extensions", "skills", "prompts", "themes"];
@@ -76,6 +75,12 @@ const MAX_NAME_LENGTH = 214;
76
75
  /** The staged dir name doubles as a path segment in the container, so it stays short and flat. */
77
76
  const MAX_DIR_LENGTH = 64;
78
77
 
78
+ /**
79
+ * A `dir` on the stage manifest: a plain path segment. Since issue #796 it names one stage (`package-stage.mjs`, name, version and a unique
80
+ * suffix) and may be longer than the package's own directory name, which `MAX_DIR_LENGTH` still bounds. A receipt from before is still valid.
81
+ */
82
+ const STAGED_TREE_RE = /^[a-z0-9](?:[a-z0-9_.-]{0,142}[a-z0-9])?$/i;
83
+
79
84
  function isNonEmptyString(value) {
80
85
  return typeof value === "string" && value.trim() !== "";
81
86
  }
@@ -266,7 +271,7 @@ export function readStageManifest({ globalPiDir, readFile = readFileSync, fileEx
266
271
  if (entry === null || typeof entry !== "object") return null;
267
272
  const { name, version, dir } = entry;
268
273
  if (!isNonEmptyString(name) || !isNonEmptyString(version) || !isNonEmptyString(dir)) return null;
269
- if (!ENTRY_NAME_RE.test(dir) || dir.length > MAX_DIR_LENGTH) return null;
274
+ if (!STAGED_TREE_RE.test(dir)) return null;
270
275
  packages.push({ name, version, dir, from: entry.from === FROM_HOST ? FROM_HOST : FROM_DECLARED });
271
276
  }
272
277
  return { stagedAt: typeof parsed.stagedAt === "string" ? parsed.stagedAt : null, packages };
@@ -281,12 +286,14 @@ export function readStageManifest({ globalPiDir, readFile = readFileSync, fileEx
281
286
  * readStageManifest's policy, because the consumers are advisory (doctor's per-trigger flow lines,
282
287
  * and issue #188's topology) and a half-staged tree must degrade to "nothing visible", not a crash.
283
288
  *
284
- * The semantics mirror pi's collectPackageResources at the 0.99.1 pin EXACTLY (unchanged since 0.80.7), because an enumerator
285
- * that agrees with pi by hand is how doctor comes to report a tier pi then ignores:
286
- * - a `pi` manifest object means its `skills` entries are the ONLY sources -- a manifest WITHOUT a
287
- * `skills` key contributes NO skills and gets NO convention fallback (readPiManifest short-circuits
288
- * before the dir walk);
289
- * - no `pi` key at all falls through to the convention `skills/` dir (RESOURCE_DIRS);
289
+ * The semantics mirror pi's collectPackageResources at the pin EXACTLY, and worker/test/packages.test.mjs holds them to
290
+ * pi's own package manager, because an enumerator that agrees with pi by hand is how doctor comes to report a tier pi then
291
+ * ignores:
292
+ * - a `pi` object that lists a resource (a field that is an array of strings, which is all readPiManifest keeps) means
293
+ * its `skills` entries are the ONLY sources -- one listing other resources but no `skills` contributes NO skills and
294
+ * gets NO convention fallback;
295
+ * - no `pi` key, or since 1.5.0 a `pi` object holding only metadata such as `changelogPath`, falls through to the
296
+ * convention `skills/` dir (RESOURCE_DIRS);
290
297
  * - a manifest entry may be a SKILL.md file, a skill dir, or a dir of skill dirs -- pi walks
291
298
  * recursively, so this walks the same shape (bounded, it is host-advisory);
292
299
  * - a glob (`*`/`?`) or override (`!`/`+`/`-` prefix) entry makes the whole package UNENUMERABLE
@@ -307,10 +314,11 @@ export function readStagedSkills({ globalPiDir, readFile = readFileSync, fileExi
307
314
  const root = join(globalPiDir, PACKAGES_SUBDIR, pkg.dir);
308
315
  let sources;
309
316
  try {
310
- const parsed = JSON.parse(readFile(join(root, "package.json"), "utf8"));
311
- const pi = parsed !== null && typeof parsed === "object" && parsed.pi !== null && typeof parsed.pi === "object" ? parsed.pi : null;
312
- if (pi) {
313
- const entries = Array.isArray(pi.skills) ? pi.skills.filter((e) => typeof e === "string") : [];
317
+ const parsed = JSON.parse(readFile(join(root, "package.json"), "utf8").replace(/^\uFEFF/, ""));
318
+ const pi = parsed !== null && typeof parsed === "object" && parsed.pi !== null && typeof parsed.pi === "object" && !Array.isArray(parsed.pi) ? parsed.pi : null;
319
+ const listed = (field) => Array.isArray(pi?.[field]) && pi[field].every((e) => typeof e === "string");
320
+ if (RESOURCE_DIRS.some(listed)) {
321
+ const entries = listed("skills") ? pi.skills : [];
314
322
  if (entries.some((e) => e.startsWith("!") || e.startsWith("+") || e.startsWith("-") || e.includes("*") || e.includes("?"))) {
315
323
  out.unenumerable.push(pkg.name);
316
324
  continue;
package/src/parked.mjs CHANGED
@@ -11,8 +11,9 @@
11
11
  * widens WHICH comments start a job, never WHO may: the comment trigger's author gate still runs first.
12
12
  */
13
13
  export const PARKED_LABEL = "pi-awaiting-input";
14
+ export const APPROVAL_LABEL = "pi-awaiting-approval";
14
15
 
15
16
  /** Whether a subset's `[{ name }]` label list (or Azure's tag list, the same shape) carries the marker. */
16
17
  export function hasParkedLabel(labels) {
17
- return Array.isArray(labels) && labels.some((label) => label?.name === PARKED_LABEL);
18
+ return Array.isArray(labels) && labels.some((label) => (label?.name === PARKED_LABEL || label?.name === APPROVAL_LABEL));
18
19
  }
@@ -17,9 +17,106 @@ import { existsSync as fsExistsSync, readFileSync as fsReadFileSync } from "node
17
17
  import { isAbsolute } from "node:path";
18
18
  import { configError } from "./config.mjs";
19
19
  import { FORGE_KINDS, isForgeKind } from "./forges.mjs";
20
+ import { heldDocument } from "./held-document.mjs";
21
+ import { findDuplicateKey } from "./json-duplicates.mjs";
22
+ import { neededVersion, underDeclared, valueNeededVersion, valueUnderDeclared, versionProblem } from "./store-version.mjs";
20
23
 
21
- // Sunday-first to match JS getUTCDay() and the Intl weekday index used in zonedParts().
22
- const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
24
+ /**
25
+ * The highest pause windows file version this build reads (issue #761, `DES-STORE-VERSION-WHEN-NEEDED`): a top-level
26
+ * `version`, absent meaning 1, written only when a window uses a field that needs more, so every file of today's
27
+ * fields stays byte-identical.
28
+ */
29
+ export const PAUSE_WINDOWS_VERSION = 1;
30
+
31
+ /**
32
+ * The window fields `normalizeWindow` reads, in its order. The console's whole-list action checks a submitted window
33
+ * against this list, and the version table's keys must come from it; a test holds it to the normalizer's own reads.
34
+ */
35
+ export const PAUSE_WINDOW_FIELDS = Object.freeze(["scope", "from", "to", "tz", "days", "dateFrom", "dateTo"]);
36
+
37
+ /**
38
+ * Each window field whose ABSENCE an older build would misread (a field that narrows when a window pauses, so a build
39
+ * that drops it pauses at the wrong time), with the version that introduced it. Empty until a field needs one.
40
+ */
41
+ export const PAUSE_WINDOW_FIELD_VERSIONS = Object.freeze({});
42
+
43
+ /**
44
+ * Each EXISTING window field whose range a later release widens, as `field -> (value) => version` (the shared rule,
45
+ * `valueNeededVersion`): a new `scope` form, say, or a new `days` form. An older build would refuse the whole file as
46
+ * broken, keep its last good windows and run the jobs the new file pauses; a stamp makes it defer them instead.
47
+ * Empty until a value needs one.
48
+ */
49
+ export const PAUSE_WINDOW_VALUE_VERSIONS = Object.freeze({});
50
+
51
+ /**
52
+ * The lowest version a list of RAW windows needs, from each window's own keys and their values: 1 while nothing needs
53
+ * more. The highest window wins, whatever its place in the list.
54
+ */
55
+ export function pauseWindowsVersionFor(windows, { table = PAUSE_WINDOW_FIELD_VERSIONS, valueTable = PAUSE_WINDOW_VALUE_VERSIONS } = {}) {
56
+ let needed = 1;
57
+ for (const w of Array.isArray(windows) ? windows : []) {
58
+ if (w === null || typeof w !== "object") continue;
59
+ const needs = Math.max(neededVersion(Object.keys(w), table), valueNeededVersion(w, valueTable));
60
+ if (needs > needed) needed = needs;
61
+ }
62
+ return needed;
63
+ }
64
+
65
+ /**
66
+ * Whether a writer must refuse the parsed document `doc`: `{ newer, message }` when its raw `version` is newer than
67
+ * this build reads (`newer: true`) or is not an integer of 1 or more (`newer: false`), else null. The RAW version
68
+ * only, never the full parser, so a list with a bad window in it can still be replaced whole; a writer refuses the newer case (it would lose what a newer release wrote) and replaces a broken `version`.
69
+ */
70
+ export function pauseWindowsVersionRefusal(doc, { supported = PAUSE_WINDOWS_VERSION } = {}) {
71
+ if (doc === null || typeof doc !== "object" || Array.isArray(doc) || !Object.hasOwn(doc, "version")) return null;
72
+ return versionProblem(doc.version, { supported, what: "pause-windows file" });
73
+ }
74
+
75
+ /**
76
+ * What a write of this file starts from, `{ doc, windows }`, or `{ problem }` with the sentence saying why it could not keep
77
+ * what the file holds, and `doc` when it parsed, for the caller's newer-release answer, which comes first. One function for
78
+ * `writePauseWindows` and the console's whole-list action, so a preview and its write cannot read a file two ways. Only a file
79
+ * that holds nothing starts from no windows: missing (`null`), whitespace, or a byte-order mark alone. A file with one bad
80
+ * window is not such a file, since its raw list is still there to replace. A key written twice is refused too: `JSON.parse`
81
+ * keeps the last, so a write would drop the windows a person reading the file can see.
82
+ */
83
+ export function heldPauseWindows(text, path) {
84
+ if ((text ?? "").trim() === "") return { doc: null, windows: [] };
85
+ const { value, problem } = heldDocument(text, "windows");
86
+ const refused = problem ?? (findDuplicateKey(text) ? "has a key written twice" : null);
87
+ if (refused) return { doc: value ?? null, problem: `the pause-windows file ${path} ${refused}, so its windows cannot be kept` };
88
+ return { doc: value, windows: Array.isArray(value.windows) ? value.windows : [] };
89
+ }
90
+
91
+ /**
92
+ * The pause windows file text for `windows`, written over the parsed document `doc` the write started from (issue
93
+ * #761): 2-space JSON and a trailing newline. Every top-level key of `doc` other than `windows` and `version` is
94
+ * carried in its original place (the file is shared, and a writer from an older release must not delete a block a
95
+ * newer one added), `windows` keeps its position (first when `doc` had none), and
96
+ * `version` leads only when the windows need more than 1. A `doc` whose only key is `windows`, or no `doc`, gives
97
+ * exactly the bytes this file was always written with, `JSON.stringify({ windows }, null, 2)` plus the newline.
98
+ * Built with `Object.defineProperty`, so a carried key named `__proto__` stays data.
99
+ */
100
+ export function pauseWindowsText(doc, windows, { table = PAUSE_WINDOW_FIELD_VERSIONS, valueTable = PAUSE_WINDOW_VALUE_VERSIONS } = {}) {
101
+ const rest = doc !== null && typeof doc === "object" && !Array.isArray(doc) ? doc : {};
102
+ const out = {};
103
+ const put = (key, value) => Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true });
104
+ const version = pauseWindowsVersionFor(windows, { table, valueTable });
105
+ if (version > 1) put("version", version);
106
+ if (!Object.hasOwn(rest, "windows")) put("windows", windows);
107
+ for (const key of Object.keys(rest)) {
108
+ if (key === "version") continue;
109
+ put(key, key === "windows" ? windows : rest[key]);
110
+ }
111
+ return `${JSON.stringify(out, null, 2)}\n`;
112
+ }
113
+
114
+ /**
115
+ * The weekdays a window's `days` may name, Sunday-first to match JS getUTCDay() and the Intl weekday index used in
116
+ * zonedParts(). A `days` entry is read by its first three letters, lowercased. EXPORTED for the version bolt (issue
117
+ * #761): a form added here is one an older build refuses, so it needs a `PAUSE_WINDOW_VALUE_VERSIONS.days` rule.
118
+ */
119
+ export const DAYS = Object.freeze(["sun", "mon", "tue", "wed", "thu", "fri", "sat"]);
23
120
  const DAY_SET = new Set(DAYS);
24
121
 
25
122
  function isNonEmptyString(value) {
@@ -60,19 +157,38 @@ function validateTz(tz, at, path) {
60
157
  * Parse, validate, and normalize the pause-windows file text. Returns an array of normalized windows
61
158
  * (unknown fields dropped; `fromMin`/`toMin` precomputed). Throws `configError` (fail-loud) on any malformed
62
159
  * entry. `path` is for messages only.
160
+ *
161
+ * The file's `version` is judged FIRST, before the windows (issue #761): a newer file's windows may use a shape this
162
+ * build would call malformed, and the true message is "upgrade". A newer file throws a configError with
163
+ * `.newer === true` (the worker defers jobs rather than misread a pause); a broken `version`, or a window using a field
164
+ * or a value that needs a higher version than the file declares, throws the ordinary configError. `supported`, `table`
165
+ * and `valueTable` are injectable for tests.
63
166
  */
64
- export function parsePauseWindows(text, path) {
167
+ export function parsePauseWindows(text, path, { supported = PAUSE_WINDOWS_VERSION, table = PAUSE_WINDOW_FIELD_VERSIONS, valueTable = PAUSE_WINDOW_VALUE_VERSIONS } = {}) {
65
168
  let parsed;
66
169
  try {
67
170
  parsed = JSON.parse(text);
68
171
  } catch (error) {
69
172
  throw configError(`pause-windows file is not valid JSON: ${path} (${error.message})`);
70
173
  }
174
+ const problem = pauseWindowsVersionRefusal(parsed, { supported });
175
+ if (problem) {
176
+ const error = configError(`${problem.message}: ${path}`);
177
+ if (problem.newer) error.newer = true;
178
+ throw error;
179
+ }
71
180
  const windows = parsed?.windows;
72
181
  if (!Array.isArray(windows)) {
73
182
  throw configError(`pause-windows file must have a "windows" array: ${path}`);
74
183
  }
75
- return windows.map((w, index) => normalizeWindow(w, index, path));
184
+ const normalized = windows.map((w, index) => normalizeWindow(w, index, path));
185
+ windows.forEach((w, index) => {
186
+ const short = underDeclared(Object.keys(w), table, parsed.version);
187
+ if (short) throw configError(`pause window at index ${index}: ${short.field} needs pause-windows file version ${short.needs}, and the file declares ${parsed.version ?? 1}: ${path}`);
188
+ const shortValue = valueUnderDeclared(w, valueTable, parsed.version);
189
+ if (shortValue) throw configError(`pause window at index ${index}: ${shortValue.field} holds a value that needs pause-windows file version ${shortValue.needs}, and the file declares ${parsed.version ?? 1}: ${path}`);
190
+ });
191
+ return normalized;
76
192
  }
77
193
 
78
194
  function normalizeWindow(w, index, path) {
@@ -142,11 +258,12 @@ export function loadPauseWindows(config, { readFileSync = fsReadFileSync, exists
142
258
  const WEEKDAY_INDEX = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
143
259
 
144
260
  /** The wall-clock parts of instant `ms` in `tz`: `{ y, mo, d, hh, mi, dow }` (dow 0=Sun..6=Sat). */
145
- function zonedParts(tz, ms) {
146
- const fmt = new Intl.DateTimeFormat("en-US", {
261
+ function zonedParts(tz, ms, formatters = null) {
262
+ const fmt = formatters?.get(tz) ?? new Intl.DateTimeFormat("en-US", {
147
263
  timeZone: tz, hour12: false,
148
264
  year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", weekday: "short",
149
265
  });
266
+ if (formatters && !formatters.has(tz)) formatters.set(tz, fmt);
150
267
  const p = {};
151
268
  for (const part of fmt.formatToParts(new Date(ms))) p[part.type] = part.value;
152
269
  let hh = Number(p.hour);
@@ -159,17 +276,17 @@ function zonedParts(tz, ms) {
159
276
  * guess back in `tz`, diff to recover the zone offset, subtract. Correct except the ~1h DST-transition seam,
160
277
  * which is immaterial for a pause boundary.
161
278
  */
162
- function wallToMs(tz, y, mo, d, hh, mi) {
279
+ function wallToMs(tz, y, mo, d, hh, mi, formatters = null) {
163
280
  const guess = Date.UTC(y, mo - 1, d, hh, mi);
164
- const shown = zonedParts(tz, guess);
281
+ const shown = zonedParts(tz, guess, formatters);
165
282
  const shownAsUtc = Date.UTC(shown.y, shown.mo - 1, shown.d, shown.hh, shown.mi);
166
283
  return guess - (shownAsUtc - guess);
167
284
  }
168
285
 
169
286
  /** The date `delta` days from `parts`' date, computed at local noon to dodge DST edges. Returns zoned parts. */
170
- function shiftDay(tz, parts, delta) {
171
- const noon = wallToMs(tz, parts.y, parts.mo, parts.d, 12, 0);
172
- return zonedParts(tz, noon + delta * 86400000);
287
+ function shiftDay(tz, parts, delta, formatters = null) {
288
+ const noon = wallToMs(tz, parts.y, parts.mo, parts.d, 12, 0, formatters);
289
+ return zonedParts(tz, noon + delta * 86400000, formatters);
173
290
  }
174
291
 
175
292
  function ymd(parts) {
@@ -186,11 +303,13 @@ function qualifies(w, parts) {
186
303
  }
187
304
 
188
305
  /** The end epoch ms of the window occurrence active at `nowMs`, or null if `now` is not inside `w`. Exported
189
- * so the admin panel can mark a single window "currently paused · resumes in …" without a synthetic job. */
190
- export function windowEndAt(w, nowMs) {
191
- const p = zonedParts(w.tz, nowMs);
306
+ * so the admin panel can mark a single window "currently paused · resumes in …" without a synthetic job.
307
+ * An optional caller-owned formatter cache lets a bounded calendar projection reuse clocks; the worker default
308
+ * still constructs its clock per call, and both paths evaluate exactly the same wall-time rules. */
309
+ export function windowEndAt(w, nowMs, formatters = null) {
310
+ const p = zonedParts(w.tz, nowMs, formatters);
192
311
  const nowMin = p.hh * 60 + p.mi;
193
- const endAt = (dateParts) => wallToMs(w.tz, dateParts.y, dateParts.mo, dateParts.d, Math.floor(w.toMin / 60), w.toMin % 60);
312
+ const endAt = (dateParts) => wallToMs(w.tz, dateParts.y, dateParts.mo, dateParts.d, Math.floor(w.toMin / 60), w.toMin % 60, formatters);
194
313
 
195
314
  if (w.fromMin < w.toMin) {
196
315
  // Same-day window: [from, to) today, starting today.
@@ -199,8 +318,8 @@ export function windowEndAt(w, nowMs) {
199
318
  }
200
319
  // Overnight window (from > to): started tonight (>= from, today qualifies) -> ends tomorrow at `to`,
201
320
  // or started last night (< to, yesterday qualified) -> ends today at `to`.
202
- if (nowMin >= w.fromMin && qualifies(w, p)) return endAt(shiftDay(w.tz, p, 1));
203
- if (nowMin < w.toMin && qualifies(w, shiftDay(w.tz, p, -1))) return endAt(p);
321
+ if (nowMin >= w.fromMin && qualifies(w, p)) return endAt(shiftDay(w.tz, p, 1, formatters));
322
+ if (nowMin < w.toMin && qualifies(w, shiftDay(w.tz, p, -1, formatters))) return endAt(p);
204
323
  return null;
205
324
  }
206
325
 
@@ -25,6 +25,7 @@
25
25
  import { execFileSync } from "node:child_process";
26
26
  import { connect as netConnect } from "node:net";
27
27
  import { dirname, join } from "node:path";
28
+ import { openshellSetupOf, venuesOf } from "./backends.mjs";
28
29
  import { DEFAULT_EGRESS_PROXY, egressProxyName } from "./egress.mjs";
29
30
  import { MODEL_ENDPOINTS_INCLUDE_NAME } from "./model-endpoints.mjs";
30
31
  import { VALKEY_PASSWORD_KEY, valkeyEnvFileText } from "./valkey-auth.mjs";
@@ -593,8 +594,9 @@ export async function decideValkey({ venues, url, installed, probeTcp, lookup, e
593
594
  export async function resolveWorkerValkey({ url, venues, platform, env, envText, envPath, probeTcp, lookup, fs, euid, user, ownerName, interfaces, subuids, waitMs = 20_000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = () => Date.now(), configError: refuse }) {
594
595
  // Gate round 3: judged on EVERY venue on Linux, not only podman's. Another account's listener is refused everywhere
595
596
  // (item 7: a local-venue worker silently adopted another account's rootless Valkey); root's (docker-proxy) is refused
596
- // only where the podman venue is this deployment's and `local` is not.
597
- const rootRefused = venues.podmanUsed && !venues.localUsed;
597
+ // only where the podman venue is this deployment's and `local` is not, or (issue #707, review round 1 of #715) where an
598
+ // openshell venue on Podman is the queue's engine (`openshellSetupOf`), whose Quadlet Valkey is this account's too.
599
+ const rootRefused = (venues.podmanUsed && !venues.localUsed) || openshellSetupOf(venues, env).valkeyEngine === "podman";
598
600
  const same = { url, servername: null, pinned: null, notes: [], rootRefused };
599
601
  if (platform !== "linux") return same;
600
602
  const notes = [];
@@ -956,7 +958,12 @@ export function foreignContainerRefusal(found, { forceHint }) {
956
958
  * docker-shaped worker for a podman deployment, and the opposite guess a stack for a docker one. So does a line systemd
957
959
  * splits differently from this reader (`envFileSystemdHazard`, issue #447), for the whole file.
958
960
  */
959
- export const STACK_KEYS = Object.freeze(["PI_BACKENDS", "PI_EGRESS", "PI_EGRESS_PROXY"]);
961
+ // Issue #707: and the openshell venues' two keys, read from the same place for the same reason. `up` and `service install`
962
+ // decide from PI_OPENSHELL_ENGINE which engine's store gets the job image and where Valkey runs (`openshellSetupOf`), so
963
+ // a shell value the service never sees would stand the stack up on the wrong engine.
964
+ const PODMAN_VENUE_KEYS = Object.freeze(["PI_BACKENDS", "PI_EGRESS", "PI_EGRESS_PROXY"]);
965
+ const OPENSHELL_VENUE_KEYS = Object.freeze(["PI_OPENSHELL_ENGINE", "PI_OPENSHELL_GATEWAY"]);
966
+ export const STACK_KEYS = Object.freeze([...PODMAN_VENUE_KEYS, ...OPENSHELL_VENUE_KEYS]);
960
967
 
961
968
  /** The longest venue-key value `readStackKeys` accepts, in bytes (issue #447 gate round 2; systemd's own limit is ~128 KiB). */
962
969
  export const STACK_VALUE_MAX = 4096;
@@ -967,7 +974,26 @@ function shapedRefusal(path, hazard, why) {
967
974
  return `${path} line ${hazard.line} has ${shape.what}, and ${why}, so what the service reads for it is unknown: ${shape.fix}`;
968
975
  }
969
976
 
970
- export function readStackKeys(content, { loader = "systemd", path = ".env", assumeSpelled = false } = {}) {
977
+ /**
978
+ * The venue keys of a deployment's `.env` (`STACK_KEYS`), read by `readVenueKeys` below in two passes since review round 2
979
+ * of #715. The three the podman venue decides from are read and refused as ever. The openshell venues' two are read the
980
+ * same way but REFUSED only where an openshell venue is blessed (the file's PI_BACKENDS, or `openshellBlessed` from the
981
+ * caller, which knows this shell's): a stray `export PI_OPENSHELL_ENGINE=...` or a continued line in a `local`-only
982
+ * deployment names a key nothing there reads, and stopping `up` or `service install` on it is a refusal with no reason.
983
+ */
984
+ export function readStackKeys(content, { loader = "systemd", path = ".env", assumeSpelled = false, openshellBlessed = false } = {}) {
985
+ const base = readVenueKeys(content, { loader, path, assumeSpelled, keys: PODMAN_VENUE_KEYS, about: "whether this deployment runs the podman venue" });
986
+ if (base.error) return base;
987
+ let blessed = openshellBlessed === true;
988
+ try {
989
+ blessed ||= venuesOf({ PI_BACKENDS: base.keys.PI_BACKENDS }).openshellUsed;
990
+ } catch {}
991
+ const openshell = readVenueKeys(content, { loader, path, assumeSpelled, keys: OPENSHELL_VENUE_KEYS, about: "which engine and gateway the openshell venue uses (PI_OPENSHELL_ENGINE, PI_OPENSHELL_GATEWAY)" });
992
+ if (openshell.error) return blessed ? openshell : base;
993
+ return { keys: { ...base.keys, ...openshell.keys } };
994
+ }
995
+
996
+ function readVenueKeys(content, { loader, path, assumeSpelled, keys: KEYS, about }) {
971
997
  // The file's BYTES where the caller has them (issue #447, gate round 1): systemd refuses to LOAD a file with a NUL
972
998
  // or with invalid UTF-8 in a key or value, and the unit then fails with every key unset, which no reading of the
973
999
  // decoded text can see. Refused whatever the file assigns, because the service does not start on it at all.
@@ -979,9 +1005,9 @@ export function readStackKeys(content, { loader = "systemd", path = ".env", assu
979
1005
  const lines = String(text ?? "").split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
980
1006
  // A line TOUCHES a key when the key is its leading word, `export ` allowed: that is where a loader could read an
981
1007
  // assignment of it. A key name inside another key's value (`PI_ENV_SETUP=/opt/PI_BACKENDS.sh`) touches nothing.
982
- const touches = new RegExp(`^[ \\t]*(?:export[ \\t]+)?(${STACK_KEYS.join("|")})(?![A-Za-z0-9_])`);
1008
+ const touches = new RegExp(`^[ \\t]*(?:export[ \\t]+)?(${KEYS.join("|")})(?![A-Za-z0-9_])`);
983
1009
  // `export K=v` is an assignment only to a loader that SOURCES the file (the macOS wrapper); systemd ignores it.
984
- const exact = new RegExp(`^[ \\t]*${loader === "shell" ? "(?:export[ \\t]+)?" : ""}(${STACK_KEYS.join("|")})=`);
1010
+ const exact = new RegExp(`^[ \\t]*${loader === "shell" ? "(?:export[ \\t]+)?" : ""}(${KEYS.join("|")})=`);
985
1011
  // A `#` line is a comment to every loader. A `;` line (a comment to systemd's EnvironmentFile=) needs no rule of its
986
1012
  // own: its leading word is `;`, never a key, so it touches nothing and is not refused (the round 2 nit).
987
1013
  const comment = /^[ \t]*#/;
@@ -1004,13 +1030,13 @@ export function readStackKeys(content, { loader = "systemd", path = ".env", assu
1004
1030
  // value, and to a shell part of the joined line (`X=a\` + `#;PI_EGRESS=0` sets PI_EGRESS, gate round 2).
1005
1031
  const afterLoneCr = (l) => (l.includes("\r") ? l.slice(l.indexOf("\r") + 1) : "");
1006
1032
  const inValue = loader === "cmd" ? [] : envFileValueLines(text, { loader });
1007
- const spelledOutside = (commentLine) => (assumeSpelled ? STACK_KEYS[0] : STACK_KEYS.find((k) => lines.some((l, i) => (commentLine.test(l) && !inValue[i] ? afterLoneCr(l) : l).includes(k))));
1033
+ const spelledOutside = (commentLine) => (assumeSpelled ? KEYS[0] : KEYS.find((k) => lines.some((l, i) => (commentLine.test(l) && !inValue[i] ? afterLoneCr(l) : l).includes(k))));
1008
1034
  if (loader === "systemd") {
1009
1035
  const h = envFileSystemdHazard(text);
1010
1036
  const spelled = h === null ? undefined : spelledOutside(/^[ \t]*[#;]/);
1011
1037
  if (h !== null && spelled !== undefined) {
1012
1038
  const shape = SYSTEMD_HAZARD_SHAPES[h.shape];
1013
- return { error: `${path} line ${h.line} has ${shape.what}. The service's systemd would read the lines of this file differently from this command, so which venue keys (${STACK_KEYS.join(", ")}) it sets is unknown: ${shape.fix}` };
1039
+ return { error: `${path} line ${h.line} has ${shape.what}. The service's systemd would read the lines of this file differently from this command, so which venue keys (${KEYS.join(", ")}) it sets is unknown: ${shape.fix}` };
1014
1040
  }
1015
1041
  }
1016
1042
  let touched = null;
@@ -1021,7 +1047,7 @@ export function readStackKeys(content, { loader = "systemd", path = ".env", assu
1021
1047
  if (!m) continue;
1022
1048
  touched ??= { key: m[1], line: i + 1 };
1023
1049
  if (!exact.test(line)) {
1024
- return { error: `${path} line ${i + 1} assigns ${m[1]} in a form other than a plain ${m[1]}=value line, which the loaders do not agree on (systemd sets \`${m[1]} =x\` and ignores \`export ${m[1]}=x\`; the shells do the opposite). Whether this deployment runs the podman venue is therefore unknown: write it as a plain ${m[1]}=value line` };
1050
+ return { error: `${path} line ${i + 1} assigns ${m[1]} in a form other than a plain ${m[1]}=value line, which the loaders do not agree on (systemd sets \`${m[1]} =x\` and ignores \`export ${m[1]}=x\`; the shells do the opposite). ${about[0].toUpperCase()}${about.slice(1)} is therefore unknown: write it as a plain ${m[1]}=value line` };
1025
1051
  }
1026
1052
  }
1027
1053
  if (touched) {
@@ -1060,12 +1086,12 @@ export function readStackKeys(content, { loader = "systemd", path = ".env", assu
1060
1086
  return { error: `${path} line ${hazard.line} is one this command cannot read (an open quote, a continuation, a line that runs, or a second assignment on one line), and ${why}, so what the service reads for it is unknown. Fix that line first` };
1061
1087
  }
1062
1088
  }
1063
- const found = readEnvAssignments(text, STACK_KEYS, { loader });
1089
+ const found = readEnvAssignments(text, KEYS, { loader });
1064
1090
  const keys = {};
1065
- for (const key of STACK_KEYS) {
1091
+ for (const key of KEYS) {
1066
1092
  const read = found[key];
1067
1093
  if (!read) continue;
1068
- if (!read.plain) return { error: `${path} line ${read.line} assigns ${key} in a form this command cannot read the way the service's loader will ($, quotes, spaces or a backslash in the value), so whether this deployment runs the podman venue is unknown. Write it as a plain ${key}=value line` };
1094
+ if (!read.plain) return { error: `${path} line ${read.line} assigns ${key} in a form this command cannot read the way the service's loader will ($, quotes, spaces or a backslash in the value), so ${about} is unknown. Write it as a plain ${key}=value line` };
1069
1095
  // THE PROJECT'S OWN CAP, not systemd's (gate round 3 corrected the wording): systemd 259 passes a value up to about
1070
1096
  // 128 KiB, and past that the whole environment is `envFileLoadHazard`'s `exec-too-large`. A venue key names a list,
1071
1097
  // a switch or a container, so 4096 bytes is far more than one needs, and a longer one is a mistake worth naming.
@@ -1291,9 +1317,9 @@ export function describeRollBack(rolled, { partial = false } = {}) {
1291
1317
  * also take the worker down with it, since the worker's own queue connection retries and says why, and a proxy that
1292
1318
  * is down refuses jobs pre-spend by itself. `After=` so a boot starts the queue before the worker reaches for it.
1293
1319
  */
1294
- export function workerUnitDeps(units) {
1320
+ export function workerUnitDeps(units, { forWhat = "the podman venue (issue #430)" } = {}) {
1295
1321
  if (units.length === 0) return "";
1296
- return `# Added by \`pi-dispatch service install\` for the podman venue (issue #430): the Quadlet units it installed.\nWants=${units.join(" ")}\nAfter=${units.join(" ")}\n`;
1322
+ return `# Added by \`pi-dispatch service install\` for ${forWhat}: the Quadlet units it installed.\nWants=${units.join(" ")}\nAfter=${units.join(" ")}\n`;
1297
1323
  }
1298
1324
 
1299
1325
  /**
@@ -16,7 +16,9 @@
16
16
  * settles in), so the numbers are complete on every host. A counter holds what was spent AND what running jobs still
17
17
  * hold: that is the number the next job is admitted against, so it is the one a manager should plan with. Run counts
18
18
  * come from the local run history merged with the Valkey run mirror; without a mirror (no `PI_WORKER_NAME`) they are
19
- * this host's alone, and `fleet.runsComplete` says so.
19
+ * this host's alone, and `fleet.runsComplete` says so. A run record from a NEWER pi-dispatch (issue #761, run-history.mjs
20
+ * `recordReadable`) is not counted, from either source, and makes `fleet.runsComplete` false the same way: a count that
21
+ * reads a record past its stable core could count a field that release gave another meaning.
20
22
  */
21
23
 
22
24
  import * as nodeFs from "node:fs";
@@ -26,6 +28,7 @@ import { dayKey, monthKey, weekKey } from "./budget.mjs";
26
28
  import { OTHER, PLAN_FIELDS, PLAN_RULES, envelopeEntries, scopeRef } from "./priorities.mjs";
27
29
  import { InfraRetry } from "./processor.mjs";
28
30
  import { isProjectId } from "./project-id.mjs";
31
+ import { recordReadable } from "./run-history.mjs";
29
32
  import { RUNS_INDEX_MAX, mergeRuns, readMirroredRuns } from "./run-mirror.mjs";
30
33
  import { scopeDollarKeyPrefix } from "./scoped-limits.mjs";
31
34
 
@@ -220,7 +223,9 @@ export async function buildPortfolioSnapshot({ envelope, digest, projects = [],
220
223
  /**
221
224
  * The run records of this host's history (`PI_LOGS_DIR/*.json`) that ended at or after `sinceMs`: `{ records, complete }`.
222
225
  * A file older than the window by its mtime is not opened (a record is written when its run ends). Never throws: a
223
- * missing directory is no runs, and any other fault reads as `complete: false`.
226
+ * missing directory is no runs, and any other fault reads as `complete: false`. A record from a newer pi-dispatch is
227
+ * left out and counted in `newerSkipped` (present only when there were any), judged against the window by its core
228
+ * times, the rule `countRuns` reads.
224
229
  */
225
230
  export function readLocalRuns({ logsDir, sinceMs, fs = nodeFs }) {
226
231
  let names;
@@ -231,19 +236,22 @@ export function readLocalRuns({ logsDir, sinceMs, fs = nodeFs }) {
231
236
  }
232
237
  const records = [];
233
238
  let complete = true;
239
+ let newerSkipped = 0;
234
240
  for (const name of names) {
235
241
  if (!name.endsWith(".json")) continue;
236
242
  try {
237
243
  const st = fs.statSync(join(logsDir, name));
238
244
  if (!st.isFile() || st.mtimeMs < sinceMs) continue;
239
245
  const rec = JSON.parse(fs.readFileSync(join(logsDir, name), "utf8"));
240
- if (rec && typeof rec === "object" && typeof rec.jobId === "string") records.push(rec);
246
+ if (!rec || typeof rec !== "object" || typeof rec.jobId !== "string") continue;
247
+ if (recordReadable(rec)) records.push(rec);
248
+ else if (Date.parse(rec.endedAt ?? rec.startedAt ?? "") >= sinceMs) newerSkipped++;
241
249
  } catch (error) {
242
250
  // A record reaped between the listing and the read is simply gone; anything else leaves the count short.
243
251
  if (error?.code !== "ENOENT") complete = false;
244
252
  }
245
253
  }
246
- return { records, complete };
254
+ return { records, complete, ...(newerSkipped > 0 ? { newerSkipped } : {}) };
247
255
  }
248
256
 
249
257
  /**
@@ -257,8 +265,13 @@ export function readLocalRuns({ logsDir, sinceMs, fs = nodeFs }) {
257
265
  * anything yet, so it is retried rather than run on a snapshot with holes. The run mirror is a view and never throws; a
258
266
  * mirror that could not be read makes `runsComplete` false. A snapshot over 64 KiB is refused as
259
267
  * `portfolio-snapshot-oversize`, before any spend, and the log line names the project count.
268
+ *
269
+ * `indexMax` is this host's run index cap (`PI_RUN_MIRROR_MAX_RUNS`, issue #761), the most the mirror read asks for.
270
+ * Every writer trims the shared index to its own cap, this host included, so a read of this many is the whole index
271
+ * unless a peer with a larger cap has written since this host last did; a read cut short says `truncated`, which makes
272
+ * `runsComplete` false rather than leaving a short count unflagged.
260
273
  */
261
- export function makePortfolioSnapshot({ checkPortfolioFlag, governing, projects = () => [], limits = () => null, allocation, redis, logsDir, mirror = false, readMirror = readMirroredRuns, fs = nodeFs, now = () => new Date(), log = () => {} }) {
274
+ export function makePortfolioSnapshot({ checkPortfolioFlag, governing, projects = () => [], limits = () => null, allocation, redis, logsDir, mirror = false, readMirror = readMirroredRuns, indexMax = RUNS_INDEX_MAX, fs = nodeFs, now = () => new Date(), log = () => {} }) {
262
275
  return async function portfolioSnapshot(job) {
263
276
  const flagged = await Promise.resolve()
264
277
  .then(() => checkPortfolioFlag(job))
@@ -282,11 +295,17 @@ export function makePortfolioSnapshot({ checkPortfolioFlag, governing, projects
282
295
  const local = readLocalRuns({ logsDir, sinceMs, fs });
283
296
  let mirrored = [];
284
297
  let complete = false;
298
+ let newerSkipped = local.newerSkipped ?? 0;
285
299
  if (mirror) {
286
- const read = await readMirror(redis, { limit: RUNS_INDEX_MAX, sinceMs, now: () => at.getTime() });
287
- mirrored = read.runs;
300
+ const read = await readMirror(redis, { limit: indexMax, sinceMs, now: () => at.getTime() });
301
+ for (const r of Array.isArray(read?.runs) ? read.runs : []) {
302
+ if (recordReadable(r)) mirrored.push(r);
303
+ else if (r !== null && typeof r === "object" && !Array.isArray(r)) newerSkipped++;
304
+ }
288
305
  complete = local.complete && (read.degraded === "ok" || read.degraded === "off");
289
306
  }
307
+ // A newer release's record left out of the counts leaves them short, which is what `runsComplete` says.
308
+ if (newerSkipped > 0) complete = false;
290
309
  const records = mergeRuns(local.records, mirrored, { limit: Number.POSITIVE_INFINITY });
291
310
  snapshot = await buildPortfolioSnapshot({ envelope: g.envelope, digest: g.digest, projects: projects(), limits: limits(), allocation: { state, lastAttempt: last }, redis, runs: { records, complete }, now: at });
292
311
  } catch (error) {