@edgehero/pi-dispatch 4.3.1 → 5.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 (256) hide show
  1. package/.env.example +84 -3
  2. package/README.md +20 -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 +117 -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 +128 -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 +159 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-probe.mjs +106 -0
  60. package/src/console-tokens.mjs +459 -0
  61. package/src/container-names.mjs +51 -0
  62. package/src/container-spec.mjs +20 -1
  63. package/src/cpu-reserve.mjs +4 -1
  64. package/src/defer-reasons.mjs +200 -0
  65. package/src/defer-write.mjs +208 -0
  66. package/src/deployment-venue.mjs +23 -6
  67. package/src/docker-run.mjs +11 -0
  68. package/src/doctor-bundles.mjs +130 -0
  69. package/src/doctor-model-proposals.mjs +109 -0
  70. package/src/doctor-openshell.mjs +238 -0
  71. package/src/doctor-results.mjs +602 -0
  72. package/src/doctor-spawner.mjs +215 -0
  73. package/src/doctor.mjs +1639 -196
  74. package/src/dollar-budget.mjs +212 -3
  75. package/src/endpoint-status.mjs +100 -0
  76. package/src/env-allowlist.mjs +96 -6
  77. package/src/env-file.mjs +6 -4
  78. package/src/file-expect.mjs +47 -0
  79. package/src/forgejo-host.mjs +42 -5
  80. package/src/forgejo-prompt.mjs +5 -4
  81. package/src/forges.mjs +33 -3
  82. package/src/fork-job.mjs +58 -0
  83. package/src/fork-policy.mjs +23 -0
  84. package/src/fork-store.mjs +338 -0
  85. package/src/get-token.mjs +1 -1
  86. package/src/git-dirty.mjs +119 -14
  87. package/src/git-hardening.mjs +85 -0
  88. package/src/github-host.mjs +31 -6
  89. package/src/github-prompt.mjs +7 -16
  90. package/src/gitlab-host.mjs +42 -3
  91. package/src/gitlab-prompt.mjs +5 -4
  92. package/src/held-document.mjs +23 -0
  93. package/src/held-replies.mjs +325 -0
  94. package/src/held-settle.mjs +266 -0
  95. package/src/hook-runs.mjs +188 -0
  96. package/src/host-budget.mjs +8 -5
  97. package/src/host-clone.mjs +72 -0
  98. package/src/host-loads.mjs +557 -0
  99. package/src/host-registry.mjs +66 -6
  100. package/src/host-telemetry.mjs +92 -0
  101. package/src/http-body.mjs +78 -0
  102. package/src/image-preflight.mjs +156 -13
  103. package/src/import-pi.mjs +60 -172
  104. package/src/inbox-relay.mjs +482 -0
  105. package/src/inbox-settle.mjs +196 -0
  106. package/src/index.mjs +734 -129
  107. package/src/init.mjs +35 -5
  108. package/src/job-data-version.mjs +75 -0
  109. package/src/job-id.mjs +30 -15
  110. package/src/job-layout.mjs +22 -0
  111. package/src/job-size.mjs +16 -0
  112. package/src/live-jobs.mjs +14 -6
  113. package/src/live-probes.mjs +6 -2
  114. package/src/live-transcript.mjs +872 -0
  115. package/src/loaded-record.mjs +609 -0
  116. package/src/mcp-config.mjs +1 -1
  117. package/src/mcp-preflight.mjs +36 -5
  118. package/src/model-attempts.mjs +84 -0
  119. package/src/model-catalog.mjs +83 -0
  120. package/src/model-cooldowns.mjs +22 -0
  121. package/src/model-failover-protocol.mjs +11 -0
  122. package/src/model-failover.mjs +134 -0
  123. package/src/model-observation.mjs +50 -0
  124. package/src/model-proposal-protocol.mjs +21 -0
  125. package/src/model-proposals.mjs +75 -0
  126. package/src/model-ref.mjs +67 -10
  127. package/src/model-roles-file.mjs +24 -0
  128. package/src/model-roles.mjs +73 -0
  129. package/src/model-status.mjs +34 -0
  130. package/src/models-json.mjs +4 -1
  131. package/src/money.mjs +1 -1
  132. package/src/oauth-openai.mjs +121 -0
  133. package/src/older-state.mjs +249 -0
  134. package/src/on-failure.mjs +17 -13
  135. package/src/open-browser.mjs +31 -2
  136. package/src/opener-cli.mjs +135 -0
  137. package/src/opener-protocol.mjs +27 -0
  138. package/src/opener-terminal.mjs +128 -0
  139. package/src/openshell-cli.mjs +155 -0
  140. package/src/openshell-policy.mjs +163 -0
  141. package/src/outbox.mjs +14 -0
  142. package/src/output-cap.mjs +1 -0
  143. package/src/package-canary-record.mjs +26 -0
  144. package/src/package-canary.mjs +135 -0
  145. package/src/package-names.mjs +9 -0
  146. package/src/package-stage.mjs +418 -0
  147. package/src/packages.mjs +24 -16
  148. package/src/parked.mjs +2 -1
  149. package/src/pause-windows.mjs +136 -17
  150. package/src/podman-stack.mjs +40 -14
  151. package/src/portfolio-snapshot.mjs +26 -7
  152. package/src/prepare-github.mjs +41 -77
  153. package/src/prepare-local.mjs +32 -2
  154. package/src/prepare.mjs +45 -11
  155. package/src/private-store-boundary.mjs +70 -0
  156. package/src/processor.mjs +1550 -173
  157. package/src/projects.mjs +23 -0
  158. package/src/prompt-fence.mjs +18 -0
  159. package/src/provider-key.mjs +7 -0
  160. package/src/provider-steering.mjs +1 -0
  161. package/src/question-context.mjs +35 -0
  162. package/src/question-group-runtime.mjs +91 -0
  163. package/src/question-groups.mjs +171 -0
  164. package/src/queue.mjs +264 -55
  165. package/src/reason-catalog.mjs +448 -0
  166. package/src/receiver-status.mjs +230 -0
  167. package/src/record-reason.mjs +37 -0
  168. package/src/recovery-queue.mjs +566 -0
  169. package/src/redact-secrets.mjs +219 -0
  170. package/src/reserve-estimate.mjs +101 -0
  171. package/src/reserved-env.mjs +36 -1
  172. package/src/retention.mjs +67 -0
  173. package/src/run-budget-ledger.mjs +283 -0
  174. package/src/run-checkpoint.mjs +627 -0
  175. package/src/run-container.mjs +749 -33
  176. package/src/run-history.mjs +791 -45
  177. package/src/run-mirror.mjs +75 -7
  178. package/src/run-progress.mjs +751 -0
  179. package/src/runtime-observations.mjs +3 -1
  180. package/src/runtime-settings.mjs +272 -37
  181. package/src/sandbox-cli.mjs +78 -2
  182. package/src/sandbox-deployment.mjs +51 -0
  183. package/src/sandbox-gateway-protocol.mjs +378 -0
  184. package/src/sandbox-hosts.mjs +100 -0
  185. package/src/sandbox-inventory.mjs +83 -0
  186. package/src/sandbox-policy.mjs +225 -0
  187. package/src/sandbox-prepare-requests.mjs +145 -0
  188. package/src/sandbox-prepare.mjs +285 -0
  189. package/src/sandbox-refs.mjs +70 -0
  190. package/src/sandbox-session-monitor.mjs +4 -0
  191. package/src/sandbox-session-watch.mjs +124 -0
  192. package/src/sandbox-sessions.mjs +161 -0
  193. package/src/sandbox-store.mjs +63 -6
  194. package/src/sandbox-workspace-guard.mjs +39 -0
  195. package/src/sandbox.mjs +27 -200
  196. package/src/scheduler-stall-guard.mjs +14 -8
  197. package/src/schedules.mjs +19 -6
  198. package/src/secret-bundles.mjs +245 -0
  199. package/src/secret-profiles.mjs +103 -3
  200. package/src/secrets.mjs +19 -9
  201. package/src/service-env.mjs +38 -5
  202. package/src/service.mjs +638 -106
  203. package/src/session-key.mjs +60 -0
  204. package/src/session-store.mjs +168 -10
  205. package/src/setup-contract.mjs +40 -0
  206. package/src/setup-egress.mjs +113 -0
  207. package/src/setup-host-entry.mjs +33 -0
  208. package/src/setup-host.mjs +242 -0
  209. package/src/setup-lock.mjs +41 -0
  210. package/src/size-records.mjs +15 -3
  211. package/src/start.mjs +1353 -135
  212. package/src/status-comment.mjs +415 -0
  213. package/src/stderr-transcript.mjs +318 -0
  214. package/src/steer-ack.mjs +122 -0
  215. package/src/steer-cli.mjs +213 -0
  216. package/src/steer-framing.mjs +185 -0
  217. package/src/steer-inbox.mjs +693 -0
  218. package/src/store-version.mjs +139 -0
  219. package/src/subscription-config.mjs +56 -0
  220. package/src/subscription-limits.mjs +3 -0
  221. package/src/subscription-quota.mjs +259 -0
  222. package/src/subscription-runtime.mjs +123 -0
  223. package/src/subscription-status.mjs +42 -0
  224. package/src/subscription-wait.mjs +11 -0
  225. package/src/subscriptions.mjs +7 -1
  226. package/src/thinking-level.mjs +19 -0
  227. package/src/touch-outbox.mjs +61 -0
  228. package/src/touch-state.mjs +133 -0
  229. package/src/triggers-file.mjs +299 -39
  230. package/src/triggers.mjs +780 -38
  231. package/src/trust-paths.mjs +171 -0
  232. package/src/up.mjs +398 -51
  233. package/src/updates-cli.mjs +61 -0
  234. package/src/updates-look.mjs +127 -0
  235. package/src/updates-reconcile.mjs +182 -0
  236. package/src/updates-subscription-adapters.mjs +8 -0
  237. package/src/updates.mjs +252 -0
  238. package/src/valkey-auth.mjs +32 -9
  239. package/src/valkey-endpoint.mjs +14 -4
  240. package/src/wait-for.mjs +1 -1
  241. package/src/wait-state.mjs +62 -3
  242. package/src/watch-board.mjs +38 -0
  243. package/src/watch-closer.mjs +1 -1
  244. package/src/watch-contract.mjs +102 -0
  245. package/src/watch-decision.mjs +78 -0
  246. package/src/watch-drainer.mjs +14 -0
  247. package/src/watch-issue-adapter.mjs +44 -0
  248. package/src/watch-issue.mjs +111 -0
  249. package/src/watch-record.mjs +10 -0
  250. package/src/watch-runtime.mjs +167 -0
  251. package/src/watch-store.mjs +183 -0
  252. package/src/watch-trace.mjs +34 -0
  253. package/src/workspace-checkpoint.mjs +1750 -0
  254. package/src/zero-touch-fixes.mjs +99 -0
  255. package/src/zero-touch.mjs +225 -0
  256. package/src/repeat-slot.mjs +0 -13
@@ -0,0 +1,609 @@
1
+ import { imageRefProblem } from "./image-ref.mjs";
2
+ import { MCP_SERVER_NAME_RE } from "./mcp-config.mjs";
3
+ import { parsePackageCanary } from "./package-canary-record.mjs";
4
+ import { isPackageName } from "./package-names.mjs";
5
+
6
+ /**
7
+ * What a run loaded, on its record (issue #800, INT-RUN-HISTORY-FILE-CONTRACT's `loaded`). The one place in the worker that
8
+ * reads the runner's `loaded` field (INT-RUNNER-EXIT-CODE-PROTOCOL), so a change to that field changes this file alone.
9
+ *
10
+ * THE MAC DOES NOT VOUCH FOR THE CONTENT, and that is the whole reason this file exists rather than a copy of the field.
11
+ * A repository's extension runs in the runner's process and can patch `JSON.stringify`, `RegExp.prototype.test` or
12
+ * `Array.prototype.map` before the line is signed, so a line that verifies can hold ANY string, and the runner's own shape
13
+ * checks cover ordinary API use and no more (the OQ-003 residual). THIS FILE IS THE BOUNDARY: every string kept is checked
14
+ * here, in the worker's process, against a closed list, a closed pattern or what the worker itself knows (the packages it
15
+ * handed over, the overlay's folder, the trigger's servers, pi's own tool names), and any one violation makes the whole
16
+ * field `runner: "invalid"`: half of a line that lied about something is not a fact. What the worker knows by itself stays
17
+ * either way. REJECTED: dropping only the bad part, which would let a forged line choose what the record is silent about. The one
18
+ * exception is a tool's name the job's own redactor would change, withheld alone and counted (`readExtTools` says why).
19
+ *
20
+ * WHAT SURVIVES IT, said plainly. Three things the line alone says, which the worker holds to a shape because it has nothing
21
+ * to compare them with, so a repository extension in the same process can forge each:
22
+ * - an extension tool's name, `LOADED_TOOL_NAME_RE`: no space, no markup, no path, though `ignore-all-previous` passes. Nothing
23
+ * acts on one here. A trigger's page offers one to switch off, and a person who does writes it to `run.excludeExtensionTools`, which can only narrow what a
24
+ * job has; at worst a forged name, which no package or overlay extension brought, stops that trigger's strict jobs.
25
+ * - `pi`, `PI_VERSION_RE`: three dotted numbers, at most 32 characters. Digits, then, and no word.
26
+ * - every count, an integer up to `LOADED_COUNT_MAX`. It can be wrong, and a forger can spell digits in a run of them.
27
+ * A `pi` holding one of the job's secrets or a known token's shape is refused anyway, and such a tool name is withheld (`known.redact`); a secret
28
+ * transformed, or cut across two values, or spelled in counts, is not caught, as everywhere the redactor stands (redact-secrets.mjs,
29
+ * NOT DEFENDED). Every other string kept is a closed word, a folder listing's name, or the worker's own: a package's name and
30
+ * version off its stage manifest, a server's name off the trigger, the image, `leftOut` and the base commit. A package's version
31
+ * keeps its pre-release tail because the line cannot choose it; it picks a row by position and the worker writes what is in
32
+ * that row.
33
+ *
34
+ * Never read to decide anything: a display, as `resources` is.
35
+ *
36
+ * Imports no spawn, no Valkey and no triggers file parser, because `run-history.mjs`, which the admin loads inside pi,
37
+ * imports this. `known.ownTools` is therefore handed in by the one caller that has `EXCLUDABLE_TOOL_NAMES`.
38
+ */
39
+
40
+ /** This field's own shape version on the record. A new key leaves it alone, as the record's own `v` is left alone. */
41
+ export const LOADED_RECORD_VERSION = 1;
42
+ /** The version of the runner's field this file can read. */
43
+ export const LOADED_LINE_VERSION = 1;
44
+ /**
45
+ * Whether the runner said: it said and it held up (`reported`), it never said (`absent`), it said and it did not hold up
46
+ * (`invalid`), or it had something to say and there was no room for it (`cut`): on its line, or on the record past
47
+ * `LOADED_RECORD_MAX_BYTES`. `cut` is not `absent`: the job got as far as noting what it loaded, and a reader saying "an older
48
+ * release wrote this" would be wrong about the run.
49
+ */
50
+ export const LOADED_RUNNER_WORDS = Object.freeze(["reported", "absent", "invalid", "cut"]);
51
+ /** The most the field may take on a record, serialized. Measured against every cap in the tests. */
52
+ export const LOADED_RECORD_MAX_BYTES = 24 * 1024;
53
+ /**
54
+ * The largest count the field may hold, anywhere: past it the whole field is a violation. A package row, a server and `active`
55
+ * have tighter clamps below; this bounds what has none (`tiers`, which keep true totals, `cut`'s counts and the `problems`
56
+ * totals). A million of anything loaded is no job's, and it is what makes the worst case above a number rather than sixteen
57
+ * digits a slot. The runner clamps none of these: a job past it reads as `invalid`, the word for a line that cannot be believed.
58
+ */
59
+ export const LOADED_COUNT_MAX = 999_999;
60
+ /**
61
+ * `pi` on the line and `imagePi` from the image: `usage.piAi`'s rule (run-history.mjs). REJECTED: `EXACT_VERSION_RE`, whose
62
+ * `-...` and `+...` tails are free text, so a forged line read as `reported` with `pi: "1.5.0-ghs0123456789abcdef"`, a job's
63
+ * own token, and `1.5.0-ignore.all.previous-instr` beside it. A pi pin is an exact release (`CONST-PI-VERSION-PINNED`), and the
64
+ * runner holds to this too, so a genuine line never trips it.
65
+ */
66
+ export const PI_VERSION_RE = /^\d+\.\d+\.\d+$/;
67
+
68
+ // The runner's vocabulary and caps (image/runner/src/loaded.mjs, which the worker cannot import because it ships in the
69
+ // job image), restated here and held to the source by worker/test/loaded-record.test.mjs.
70
+ export const LOADED_STAGES = Object.freeze(["start", "none", "loader", "session", "ready"]);
71
+ export const LOADED_TIERS = Object.freeze(["package", "repository", "trigger", "overlay", "other"]);
72
+ export const LOADED_FLOW_WORDS = Object.freeze([...LOADED_TIERS, "none"]);
73
+ export const LOADED_MCP_STATES = Object.freeze(["connected", "failed", "timeout", "pending", "disabled"]);
74
+ export const LOADED_ROW = Object.freeze(["extensions", "skills", "prompts", "themes", "tools", "failed", "dropped", "shadowed"]);
75
+ export const LOADED_FORMS = Object.freeze(["full", "compact", "minimal"]);
76
+ export const LOADED_MAX_PACKAGES = 16;
77
+ export const LOADED_MAX_SERVERS = 8;
78
+ export const LOADED_MAX_OVERLAY_NAMES = 16;
79
+ export const LOADED_MAX_EXT_TOOLS = 24;
80
+ export const LOADED_MAX_COUNT = 999;
81
+ export const LOADED_MAX_PROBLEM_COUNT = 99;
82
+ export const LOADED_MAX_TOOL_COUNT = 9999;
83
+ export const LOADED_OVERLAY_NAME_RE = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,48}$/;
84
+ export const LOADED_TOOL_NAME_RE = /^[A-Za-z][A-Za-z0-9_-]{0,47}$/;
85
+ export const LOADED_JOB_TOOLS = Object.freeze(["report_progress", "await_human_input", "request_approval", "subagent", "codemode", "tool_search", "list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource", "query_logs", "decide", "board_write", "read_question", "list_repository", "read_repository", "read_standing_answer", "read_reply", "list_replies", "submit_answer_draft"]);
86
+
87
+ /** Where a row's counts stop being a resource count, whose cap is LOADED_MAX_COUNT, and become a problem count. */
88
+ const FIRST_PROBLEM_SLOT = LOADED_ROW.indexOf("failed");
89
+ const FAILED_SLOT = FIRST_PROBLEM_SLOT;
90
+ const DROPPED_SLOT = LOADED_ROW.indexOf("dropped");
91
+ /** The longest reference a runtime takes, near enough: a name, a tag and a digest. A bound for the record, not a rule. */
92
+ const IMAGE_MAX = 512;
93
+ /** `sha256:` and 64 hex: `normalizeImageId` gives every runtime's id this one spelling (image-preflight.mjs). */
94
+ const IMAGE_DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
95
+ /**
96
+ * The commit a run's workspace was prepared at, in full, sha1 or sha256: what `validPin` (prepare-github.mjs) holds a pin to
97
+ * and what `git rev-parse` prints. Exported because a surface that builds a link from it holds it to this same rule, so a
98
+ * link's words and its address cannot disagree about which records name a commit. REJECTED: a short hash, which a forge may
99
+ * not resolve or may resolve to another commit as the repository grows.
100
+ */
101
+ export const LOADED_BASE_SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
102
+ const PI_VERSION_MAX = 32;
103
+ /** An `excludeTools` entry: a tool's name, or a server's pattern (`mcp__<server>__*`, triggers.mjs `mcpServerToolPattern`). */
104
+ const LEFT_OUT_TOOL_RE = /^[A-Za-z][A-Za-z0-9_-]{0,47}\*?$/;
105
+ /** Pi's own, the MCP tools and a pattern per server of a trigger's at most eight (triggers.mjs): far under this. */
106
+ const LEFT_OUT_TOOLS_MAX = 32;
107
+ /** The most names `run.excludeExtensions` or `run.excludeExtensionTools` takes: the loader refuses more, by this number, so a record holds all of them. */
108
+ export const LEFT_OUT_NAMES_MAX = 16;
109
+ /** How many own tools a line may name, a bound on a list `known.ownTools` then judges by name. */
110
+ const OWN_TOOLS_MAX = 16;
111
+
112
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
113
+ const isCount = (value, max = LOADED_COUNT_MAX) => Number.isSafeInteger(value) && value >= 0 && value <= max;
114
+ const isPiVersion = (value) => typeof value === "string" && value.length <= PI_VERSION_MAX && PI_VERSION_RE.test(value);
115
+ /** Whether the job's own redactor would change this: it holds a value of the job's, or a known token's shape. */
116
+ const holdsSecret = (text, redact) => typeof redact === "function" && redact(text) !== text;
117
+ const isToolName = (value) => typeof value === "string" && LOADED_TOOL_NAME_RE.test(value);
118
+ const isOverlayName = (value) => typeof value === "string" && LOADED_OVERLAY_NAME_RE.test(value);
119
+ /** A package row's slot held to its clamp: a resource count, then a problem count. */
120
+ const slotMax = (slot) => (slot < FIRST_PROBLEM_SLOT ? LOADED_MAX_COUNT : LOADED_MAX_PROBLEM_COUNT);
121
+ const rowFields = (row) => Object.fromEntries(LOADED_ROW.map((name, slot) => [name, row[slot]]));
122
+
123
+ /** A name in `run.excludeExtensions`: a package's manifest name, or an overlay entry's. */
124
+ const isExcludedName = (value) => isPackageName(value) || isOverlayName(value);
125
+
126
+ /**
127
+ * Whether `name` in `run.excludeExtensions` means this first path segment of the overlay's `extensions/`: the runner's rule, restated
128
+ * because the worker cannot import what ships in the job image, and held to it by a test.
129
+ */
130
+ export function overlayEntryNamed(name, segment) {
131
+ return name === segment || name === segment.replace(/\.(?:ts|js)$/, "");
132
+ }
133
+
134
+ /** Which names neither a staged package nor an overlay entry on this host holds, from what the worker read: `{ name }` rows and a folder listing. */
135
+ export function extensionsNotHere(names, { packages = [], overlay = [] } = {}) {
136
+ return names.filter((name) => !packages.some((row) => row?.name === name) && !overlay.some((segment) => overlayEntryNamed(name, segment)));
137
+ }
138
+
139
+ /**
140
+ * What the worker itself left out of this job, in its own words, because the runner cannot tell: it reads no `run.packages`
141
+ * and sees only what reached it, so `"packages": false` and nothing staged read the same from inside (env-allowlist.mjs).
142
+ * `overlayExtensions` is this job's overlay switch as the worker was built with it: the host's `PI_GLOBAL_ALLOW_EXTENSIONS=0`,
143
+ * or a canary replay, which runs every job with the overlay shut (canary-runtime.mjs). Not the value handed to a restricted
144
+ * job, which is forced off for it alone: that is `restricted`, a different statement with a different fix.
145
+ * `tools` keeps what passes the shape. The loader validated the field, so a name failing here is a hand-built job's.
146
+ * `run.excludeExtensions` splits in two: `extensionsNotHere` the names this host holds neither way, so this run left out nothing for
147
+ * them, and `extensions` the rest, what it left out or could not say of. The caller works out the first, and names none of them where
148
+ * it read no stage or no folder. `extensionTools` is `run.excludeExtensionTools`. Each only when it names something, so every record
149
+ * from before them, and every run with none, keeps its bytes.
150
+ */
151
+ export function leftOutOf(job, { allowGlobalExtensions = true, excludedNotHere = [] } = {}) {
152
+ const tools = Array.isArray(job?.excludeTools) ? [...new Set(job.excludeTools.filter((name) => typeof name === "string" && LEFT_OUT_TOOL_RE.test(name)))].slice(0, LEFT_OUT_TOOLS_MAX) : [];
153
+ const named = (list, ok) => (Array.isArray(list) ? [...new Set(list.filter(ok))].slice(0, LEFT_OUT_NAMES_MAX) : []);
154
+ const asked = named(job?.excludeExtensions, isExcludedName);
155
+ const notHere = asked.filter((name) => excludedNotHere.includes(name));
156
+ const extensions = asked.filter((name) => !notHere.includes(name));
157
+ const extensionTools = named(job?.excludeExtensionTools, isToolName);
158
+ return {
159
+ tools,
160
+ packages: job?.packages === false,
161
+ overlayExtensions: allowGlobalExtensions === false,
162
+ restricted: Boolean(job?.watch || job?.answerDraft || job?.questionGrouping),
163
+ ...(extensions.length > 0 ? { extensions } : {}),
164
+ ...(notHere.length > 0 ? { extensionsNotHere: notHere } : {}),
165
+ ...(extensionTools.length > 0 ? { extensionTools } : {}),
166
+ };
167
+ }
168
+
169
+ /** Whether `leftOut` says anything: a run with no line and nothing left out has no worker fact worth a key of its own. */
170
+ export function leavesOut(leftOut) {
171
+ return isObject(leftOut) && (leftOut.packages === true || leftOut.overlayExtensions === true || leftOut.restricted === true || (Array.isArray(leftOut.tools) && leftOut.tools.length > 0) || Array.isArray(leftOut.extensions) || Array.isArray(leftOut.extensionsNotHere) || Array.isArray(leftOut.extensionTools));
172
+ }
173
+
174
+ /** The field with no runner part: what the worker knows when the runner said nothing, or nothing that held up. */
175
+ function workerOnly(runner, { image = null, imageDigest = null, imagePi = null, leftOut = null } = {}) {
176
+ return { v: LOADED_RECORD_VERSION, runner, at: null, cut: null, image, imageDigest, imagePi, pi: null, flow: null, sources: [], mcp: null, tools: null, problems: null, leftOut };
177
+ }
178
+
179
+ /**
180
+ * What the sink hands on for a line that says `loadedCut: true` and holds no `loaded`: the runner noted one and the line had
181
+ * no room for even its smallest form. One object, compared by identity, so nothing a line writes can be taken for it.
182
+ */
183
+ export const LOADED_LINE_CUT = Object.freeze({ cut: true });
184
+
185
+ /** Raised inside `readLine` with a fixed word, never line text, and caught by `rebuildLoaded`. */
186
+ class LineViolation extends Error {
187
+ constructor(word) {
188
+ super(word);
189
+ this.word = word;
190
+ }
191
+ }
192
+ const refuse = (word) => {
193
+ throw new LineViolation(word);
194
+ };
195
+
196
+ function readCut(raw) {
197
+ if (!isObject(raw) || !LOADED_FORMS.includes(raw.form)) refuse("cut");
198
+ for (const key of ["packages", "servers", "names", "counts"]) if (!isCount(raw[key])) refuse("cut");
199
+ return { form: raw.form, packages: raw.packages, servers: raw.servers, names: raw.names, counts: raw.counts };
200
+ }
201
+
202
+ function readFlow(raw, form, packages) {
203
+ if (raw.flow === undefined) {
204
+ if (raw.flowPackage !== undefined) refuse("flow");
205
+ return null;
206
+ }
207
+ if (!LOADED_FLOW_WORDS.includes(raw.flow)) refuse("flow");
208
+ if (raw.flow !== "package") {
209
+ if (raw.flowPackage !== undefined) refuse("flow");
210
+ return { tier: raw.flow, package: null };
211
+ }
212
+ // The minimal form drops which package and keeps that it was one. The worker holds the whole list past the row cap.
213
+ if (raw.flowPackage === undefined && form === "minimal") return { tier: "package", package: null };
214
+ if (!isCount(raw.flowPackage) || raw.flowPackage >= packages.length) refuse("flow-package");
215
+ return { tier: "package", package: packages[raw.flowPackage]?.name ?? null };
216
+ }
217
+
218
+ function readTiers(raw) {
219
+ if (!isObject(raw)) refuse("tiers");
220
+ const out = {};
221
+ for (const tier of LOADED_TIERS) {
222
+ const row = raw[tier];
223
+ if (!Array.isArray(row) || row.length !== LOADED_ROW.length || !row.every((value) => isCount(value))) refuse("tiers");
224
+ out[tier] = row;
225
+ }
226
+ return out;
227
+ }
228
+
229
+ /** The rows, as many as the worker handed over up to the cap, and the cap's count agreeing with the list. */
230
+ function readPackageRows(raw, packages) {
231
+ if (!Array.isArray(raw) || raw.length !== Math.min(packages.length, LOADED_MAX_PACKAGES)) refuse("packages-length");
232
+ for (const row of raw) {
233
+ if (!Array.isArray(row) || row.length !== LOADED_ROW.length || !row.every((value, slot) => isCount(value, slotMax(slot)))) refuse("packages-row");
234
+ }
235
+ return raw;
236
+ }
237
+
238
+ /** Each name a first path segment the overlay's folder really holds: a name not on this disk came from nowhere the worker staged. */
239
+ function readOverlay(raw, onDisk) {
240
+ if (!isObject(raw) || !Array.isArray(raw.loaded) || !Array.isArray(raw.failed)) refuse("overlay");
241
+ if (raw.loaded.length + raw.failed.length > LOADED_MAX_OVERLAY_NAMES) refuse("overlay");
242
+ for (const name of [...raw.loaded, ...raw.failed]) {
243
+ if (!isOverlayName(name)) refuse("overlay-name");
244
+ if (!onDisk.has(name)) refuse("overlay-not-on-disk");
245
+ }
246
+ return { loaded: raw.loaded, failed: raw.failed };
247
+ }
248
+
249
+ /**
250
+ * `extTools` by source: `[[index, ...names]]`, each index in range once and every name in the shape, within the overall
251
+ * cap. The shape admits a bare token (`ghp_...` is one word of letters and digits), and an extension in the signer's process
252
+ * can read the job's environment, so a name holding one of this job's secrets or a known token's shape never reaches the
253
+ * record: `redact` is the job's own redactor, the worker's only way to know a value when it sees one.
254
+ *
255
+ * Such a name is WITHHELD alone, and counted, where every other check here voids the field, because it is no sign of a lie. The
256
+ * redactor takes every env value outside the structural allowlist for a secret (`NON_SECRET_ENV_KEYS`, inverted on purpose), so
257
+ * `SEARCH_INDEX=docs_search` forwarded through `PI_FORWARD_ENV`, beside a staged package that registers `docs_search`, read every
258
+ * run of that trigger as `invalid`, all its counts and problems lost, with nothing forged. A forged line gains nothing from this:
259
+ * it could leave any name off anyway, and the row's count still says how many there were.
260
+ */
261
+ function readExtTools(raw, packageCount, overlayCount, redact) {
262
+ if (!isObject(raw) || !Array.isArray(raw.p) || !Array.isArray(raw.o)) refuse("ext-tools");
263
+ let total = 0;
264
+ let withheld = 0;
265
+ const side = (rows, limit) => {
266
+ const out = new Map();
267
+ for (const row of rows) {
268
+ if (!Array.isArray(row) || row.length < 2) refuse("ext-tools");
269
+ const [index, ...names] = row;
270
+ if (!isCount(index) || index >= limit || out.has(index)) refuse("ext-tools-index");
271
+ if (!names.every(isToolName)) refuse("ext-tools-name");
272
+ total += names.length;
273
+ const kept = names.filter((name) => !holdsSecret(name, redact));
274
+ withheld += names.length - kept.length;
275
+ out.set(index, kept);
276
+ }
277
+ return out;
278
+ };
279
+ const p = side(raw.p, packageCount);
280
+ const o = side(raw.o, overlayCount);
281
+ if (total > LOADED_MAX_EXT_TOOLS) refuse("ext-tools-cap");
282
+ return { p, o, withheld };
283
+ }
284
+
285
+ function readMcp(raw, servers) {
286
+ if (!Array.isArray(raw) || raw.length !== Math.min(servers.length, LOADED_MAX_SERVERS)) refuse("mcp-length");
287
+ return raw.map((row, index) => {
288
+ if (!Array.isArray(row) || row.length !== 2 || !LOADED_MCP_STATES.includes(row[0]) || !isCount(row[1], LOADED_MAX_TOOL_COUNT)) refuse("mcp-row");
289
+ const name = servers[index];
290
+ return { name: typeof name === "string" && MCP_SERVER_NAME_RE.test(name) ? name : null, state: row[0], tools: row[1] };
291
+ });
292
+ }
293
+
294
+ /**
295
+ * Which of the job's `run.excludeExtensionTools` no staged package or overlay extension registered: positions on the line, as a package's row is, and the worker's own list
296
+ * names them, so a forged line can only pick from what the operator wrote. Conditional, so no other job's record changes by a byte.
297
+ */
298
+ function readUnmatched(raw, excludeExtensionTools) {
299
+ if (raw === undefined) return {};
300
+ const list = Array.isArray(excludeExtensionTools) ? excludeExtensionTools : [];
301
+ if (!Array.isArray(raw) || raw.length === 0 || new Set(raw).size !== raw.length || !raw.every((position) => isCount(position) && position < list.length && isToolName(list[position]))) refuse("tools-unmatched");
302
+ return { unmatched: raw.map((position) => list[position]) };
303
+ }
304
+
305
+ function readTools(raw, form, ownTools, excludeExtensionTools) {
306
+ if (!isObject(raw) || !isCount(raw.active, LOADED_MAX_TOOL_COUNT)) refuse("tools");
307
+ const unmatched = readUnmatched(raw.unmatched, excludeExtensionTools);
308
+ if (form === "compact") return { active: raw.active, own: null, job: null, ...unmatched };
309
+ if (!Array.isArray(raw.own) || raw.own.length > OWN_TOOLS_MAX || new Set(raw.own).size !== raw.own.length || !raw.own.every((name) => ownTools.has(name))) refuse("tools-own");
310
+ if (!Array.isArray(raw.job) || new Set(raw.job).size !== raw.job.length || !raw.job.every((name) => LOADED_JOB_TOOLS.includes(name))) refuse("tools-job");
311
+ return { active: raw.active, own: raw.own, job: raw.job, ...unmatched };
312
+ }
313
+
314
+ const totalOf = (tiers, slot) => Object.values(tiers).reduce((sum, row) => sum + row[slot], 0);
315
+ const badServers = (mcp) => (mcp === null ? null : mcp.filter((row) => row.state === "failed" || row.state === "timeout").length);
316
+
317
+ /** The sources a tiers-only line can say: one unnamed package row of true totals, and the rest of the tiers. */
318
+ function tierSources(tiers, { packageRows = null, overlay = null } = {}) {
319
+ const out = packageRows ?? [{ tier: "package", name: null, version: null, ...rowFields(tiers.package), toolNames: null }];
320
+ out.push({ tier: "overlay", names: overlay?.names ?? null, failedNames: overlay?.failedNames ?? null, ...rowFields(tiers.overlay), namedTools: overlay?.namedTools ?? null });
321
+ for (const tier of ["repository", "trigger", "other"]) out.push({ tier, ...rowFields(tiers[tier]) });
322
+ return out;
323
+ }
324
+
325
+ /** The line as the record's runner part, or a thrown LineViolation with a fixed word. */
326
+ function readLine(raw, known) {
327
+ if (!isObject(raw)) refuse("not-an-object");
328
+ if (raw.v !== LOADED_LINE_VERSION) refuse("version");
329
+ if (!LOADED_STAGES.includes(raw.at)) refuse("stage");
330
+ if (raw.pi !== null && !isPiVersion(raw.pi)) refuse("pi");
331
+ // A numeric token passes the shape: digits are all it has, and the job's own value is still the one thing it may not be.
332
+ if (raw.pi !== null && holdsSecret(raw.pi, known?.redact)) refuse("pi-secret");
333
+ const cut = readCut(raw.cut);
334
+ // Each a package as the worker knows it, or null: the two facts it joins onto a positional row.
335
+ const packages = (Array.isArray(known?.packages) ? known.packages : []).map(parsePackageCanary);
336
+ const servers = Array.isArray(known?.servers) ? known.servers : [];
337
+ const early = raw.at === "start" || raw.at === "none";
338
+ const live = raw.at === "session" || raw.at === "ready";
339
+ const shownCut = cut.form === "full" && cut.packages === 0 && cut.servers === 0 && cut.names === 0 && cut.counts === 0 ? null : cut;
340
+ const runner = { at: raw.at, cut: shownCut, pi: raw.pi, flow: null, sources: [], mcp: null, tools: null, problems: null };
341
+ if (early) {
342
+ // Those stages say the runner's version and no more: a count before anything loaded would be a claim.
343
+ if (raw.tiers !== undefined || raw.flow !== undefined) refuse("early-facts");
344
+ return runner;
345
+ }
346
+ // Every form keeps `cut`, and each of its two list counts agrees with the list the worker handed over.
347
+ if (cut.packages !== Math.max(0, packages.length - LOADED_MAX_PACKAGES)) refuse("packages-cut");
348
+ if (live && cut.servers !== Math.max(0, servers.length - LOADED_MAX_SERVERS)) refuse("servers-cut");
349
+ runner.flow = readFlow(raw, cut.form, packages);
350
+
351
+ if (cut.form === "minimal") {
352
+ if (!isCount(raw.failed) || !isCount(raw.dropped)) refuse("minimal");
353
+ if (live ? !isCount(raw.mcpBad, LOADED_MAX_SERVERS) : raw.mcpBad !== undefined) refuse("minimal");
354
+ runner.problems = { failed: raw.failed, dropped: raw.dropped, mcp: live ? raw.mcpBad : null };
355
+ return runner;
356
+ }
357
+
358
+ const tiers = readTiers(raw.tiers);
359
+ if (live) {
360
+ runner.mcp = readMcp(raw.mcp, servers);
361
+ runner.tools = readTools(raw.tools, cut.form, known?.ownTools instanceof Set ? known.ownTools : new Set(known?.ownTools ?? []), known?.excludeExtensionTools);
362
+ } else if (raw.mcp !== undefined || raw.tools !== undefined) {
363
+ refuse("stage-facts");
364
+ }
365
+ const failed = totalOf(tiers, FAILED_SLOT);
366
+ const dropped = totalOf(tiers, DROPPED_SLOT);
367
+ // Each tier held and their sum need not: a total past the bound is the same violation as a count past it.
368
+ if (!isCount(failed) || !isCount(dropped)) refuse("tiers-total");
369
+ runner.problems = { failed, dropped, mcp: badServers(runner.mcp) };
370
+ if (cut.form === "compact") {
371
+ runner.sources = tierSources(tiers);
372
+ return runner;
373
+ }
374
+
375
+ const rows = readPackageRows(raw.packages, packages);
376
+ const overlay = readOverlay(raw.overlay, new Set(Array.isArray(known?.overlayExtensions) ? known.overlayExtensions : []));
377
+ const extTools = live ? readExtTools(raw.extTools, packages.length, overlay.loaded.length, known?.redact) : { p: new Map(), o: new Map(), withheld: 0 };
378
+ // A package past the row cap can still name tools: the line indexes the worker's whole list. It has no row to hold them
379
+ // here, so they are counted as cut, the runner's own word for a name the record does not keep, as a withheld name is.
380
+ const rowless = [...extTools.p].reduce((sum, [index, names]) => sum + (index >= rows.length ? names.length : 0), 0);
381
+ if (rowless + extTools.withheld > 0) runner.cut = { ...cut, names: cut.names + rowless + extTools.withheld };
382
+ if (extTools.withheld > 0) {
383
+ try {
384
+ known?.onWithheld?.(extTools.withheld);
385
+ } catch {
386
+ // a log that throws costs the line, never the field
387
+ }
388
+ }
389
+ const packageRows = rows.map((row, index) => ({ tier: "package", name: packages[index]?.name ?? null, version: packages[index]?.version ?? null, ...rowFields(row), toolNames: extTools.p.get(index) ?? [] }));
390
+ const namedTools = [...extTools.o].filter(([, names]) => names.length > 0).sort(([a], [b]) => a - b).map(([index, names]) => ({ name: overlay.loaded[index], tools: names }));
391
+ runner.sources = tierSources(tiers, { packageRows, overlay: { names: overlay.loaded, failedNames: overlay.failed, namedTools } });
392
+ return runner;
393
+ }
394
+
395
+ /**
396
+ * The record's `loaded` from the line's field and what the worker knows. `raw` undefined or null (an older image, a line
397
+ * the supervisor wrote, no verified line at all) is `absent`, `LOADED_LINE_CUT` is `cut`, and anything that fails a check
398
+ * is `invalid`, each with the worker's own facts kept. Never throws.
399
+ *
400
+ * known.packages one `{ name, version }` or null per path in this job's PI_PACKAGES, in that order, the
401
+ * candidate of a canary at its place. Rows are positional, so this is what names them
402
+ * known.overlayExtensions the names in `<overlay>/extensions` on this host
403
+ * known.servers the trigger's server names, in PI_MCP_SERVERS order
404
+ * known.ownTools pi's own tool names (`EXCLUDABLE_TOOL_NAMES`)
405
+ * known.leftOut `leftOutOf`, which the worker alone can say
406
+ * known.excludeExtensionTools the list in PI_EXCLUDE_EXTENSION_TOOLS, in that order, which names what the line says no package or overlay extension registered
407
+ * known.redact this job's own redactor (`makeRedactor`): a tool's name kept comes through it unchanged
408
+ * known.onViolation hears the fixed word of a check that failed, for the worker's log. Never line text
409
+ * known.onWithheld hears how many tool names `redact` kept off the record, for the worker's log. Never a name
410
+ */
411
+ export function rebuildLoaded(raw, known = {}) {
412
+ const facts = { leftOut: isObject(known?.leftOut) ? known.leftOut : null };
413
+ if (raw === undefined || raw === null) return workerOnly("absent", facts);
414
+ if (raw === LOADED_LINE_CUT) return workerOnly("cut", facts);
415
+ let runner;
416
+ try {
417
+ runner = readLine(raw, known);
418
+ } catch (error) {
419
+ try {
420
+ known?.onViolation?.(error instanceof LineViolation ? error.word : "unreadable");
421
+ } catch {
422
+ // a log that throws costs the line, never the field
423
+ }
424
+ return workerOnly("invalid", facts);
425
+ }
426
+ const built = recordedLoaded({ ...workerOnly("reported", facts), ...runner });
427
+ if (built === null) {
428
+ try {
429
+ known?.onViolation?.("record");
430
+ } catch {
431
+ // as above
432
+ }
433
+ return workerOnly("invalid", facts);
434
+ }
435
+ return built;
436
+ }
437
+
438
+ /**
439
+ * The field with the image's facts added: what the preflight read before the container started (`imagePreflight`), or the
440
+ * image alone when the container's own field is null (it was refused, or never reported anything). `imageDigest` is the
441
+ * runtime's local image id (`{{.Id}}`): two local builds of one tag differ, so it is THIS HOST'S id for what ran and never
442
+ * a thing to compare across hosts. `null` when the preflight named no image, which keeps a record with no image at all
443
+ * byte-identical to what it was. `imageFromJob` is present only on a run on an image its job named rather than this host's
444
+ * default, so its pi and image say nothing of what the host runs everything else on. REJECTED: reading that from the trigger
445
+ * as it stands now, which may have changed since the run, and a CLI job's `--image` has no trigger.
446
+ */
447
+ export function loadedWithImage(loaded, img) {
448
+ if (typeof img?.image !== "string") return loaded ?? null;
449
+ const facts = { image: img.image, imageDigest: img.imageDigest ?? null, imagePi: img.piVersion ?? null, ...(img.fromJob === true ? { imageFromJob: true } : {}) };
450
+ return { ...(loaded ?? workerOnly("absent")), ...facts };
451
+ }
452
+
453
+ /** The worker's own image facts on a field, for a field the container's line made that has yet to carry them. */
454
+ export function imageFactsOf(loaded) {
455
+ return { image: loaded.image, imageDigest: loaded.imageDigest, imagePi: loaded.imagePi, ...(loaded.imageFromJob === true ? { imageFromJob: true } : {}) };
456
+ }
457
+
458
+ /**
459
+ * The field with the commit this run's workspace was prepared at, which only the worker can say: a repository extension in the
460
+ * runner's process can write anything on its line, so a line's `baseSha` is never read (`readLine` names what it keeps) and this
461
+ * value replaces whatever the object held. Null stays null, undefined or a non-string adds nothing: a record that has no base
462
+ * to name has no key, and a preflight's image-only field before the workspace was prepared is byte-identical to what it was.
463
+ * The shape is held where every other fact's is, at `recordedLoaded`.
464
+ */
465
+ export function loadedWithBase(loaded, baseSha) {
466
+ if (loaded === null || loaded === undefined) return null;
467
+ return typeof baseSha === "string" ? { ...loaded, baseSha } : loaded;
468
+ }
469
+
470
+ const namesWithin = (value, max, ok) => Array.isArray(value) && value.length <= max && value.every(ok);
471
+
472
+ /** A count field of a source, or undefined when any is not a count: its tier's row, by name. */
473
+ function sourceCounts(source) {
474
+ const out = {};
475
+ for (const name of LOADED_ROW) {
476
+ if (!isCount(source[name])) return undefined;
477
+ out[name] = source[name];
478
+ }
479
+ return out;
480
+ }
481
+
482
+ /** One `sources` row, REBUILT, by its tier's own shape, or null. */
483
+ /** A list held to its shape, as a list of the record's own: the source keeps its array, and whatever a reader does to this one. */
484
+ const copied = (list) => (list === null ? null : [...list]);
485
+
486
+ function recordedSource(source) {
487
+ if (!isObject(source) || !LOADED_TIERS.includes(source.tier)) return null;
488
+ const counts = sourceCounts(source);
489
+ if (counts === undefined) return null;
490
+ if (source.tier === "package") {
491
+ // Null is a package the worker could not name, and the unnamed row of a form that keeps only the totals.
492
+ const named = source.name === null && source.version === null ? { name: null, version: null } : parsePackageCanary(source);
493
+ if (named === null) return null;
494
+ if (source.toolNames !== null && !namesWithin(source.toolNames, LOADED_MAX_EXT_TOOLS, isToolName)) return null;
495
+ return { tier: "package", ...named, ...counts, toolNames: copied(source.toolNames) };
496
+ }
497
+ if (source.tier === "overlay") {
498
+ const names = (value) => value === null || namesWithin(value, LOADED_MAX_OVERLAY_NAMES, isOverlayName);
499
+ if (!names(source.names) || !names(source.failedNames)) return null;
500
+ let namedTools = null;
501
+ if (source.namedTools !== null) {
502
+ if (!Array.isArray(source.namedTools) || source.namedTools.length > LOADED_MAX_OVERLAY_NAMES) return null;
503
+ namedTools = [];
504
+ for (const row of source.namedTools) {
505
+ if (!isObject(row) || !isOverlayName(row.name) || !namesWithin(row.tools, LOADED_MAX_EXT_TOOLS, isToolName)) return null;
506
+ namedTools.push({ name: row.name, tools: [...row.tools] });
507
+ }
508
+ }
509
+ return { tier: "overlay", names: copied(source.names), failedNames: copied(source.failedNames), ...counts, namedTools };
510
+ }
511
+ return { tier: source.tier, ...counts };
512
+ }
513
+
514
+ function recordedCut(cut) {
515
+ if (cut === null) return null;
516
+ try {
517
+ return readCut(cut);
518
+ } catch {
519
+ return undefined;
520
+ }
521
+ }
522
+
523
+ function recordedLeftOut(leftOut) {
524
+ if (leftOut === null) return null;
525
+ if (!isObject(leftOut) || !namesWithin(leftOut.tools, LEFT_OUT_TOOLS_MAX, (name) => typeof name === "string" && LEFT_OUT_TOOL_RE.test(name))) return undefined;
526
+ for (const key of ["packages", "overlayExtensions", "restricted"]) if (typeof leftOut[key] !== "boolean") return undefined;
527
+ const lists = {};
528
+ for (const [key, ok] of [["extensions", isExcludedName], ["extensionsNotHere", isExcludedName], ["extensionTools", isToolName]]) {
529
+ if (leftOut[key] === undefined) continue;
530
+ if (!namesWithin(leftOut[key], LEFT_OUT_NAMES_MAX, ok) || leftOut[key].length === 0) return undefined;
531
+ lists[key] = [...leftOut[key]];
532
+ }
533
+ return { tools: copied(leftOut.tools), packages: leftOut.packages, overlayExtensions: leftOut.overlayExtensions, restricted: leftOut.restricted, ...lists };
534
+ }
535
+
536
+ /** The runner's half of the record, REBUILT, or undefined on any violation. */
537
+ function recordedRunnerPart(value) {
538
+ if (!LOADED_STAGES.includes(value.at)) return undefined;
539
+ if (value.pi !== null && !isPiVersion(value.pi)) return undefined;
540
+ const cut = recordedCut(value.cut);
541
+ if (cut === undefined) return undefined;
542
+ let flow = null;
543
+ if (value.flow !== null) {
544
+ if (!isObject(value.flow) || !LOADED_FLOW_WORDS.includes(value.flow.tier)) return undefined;
545
+ const named = value.flow.package;
546
+ if (named !== null && (value.flow.tier !== "package" || !isPackageName(named))) return undefined;
547
+ flow = { tier: value.flow.tier, package: named };
548
+ }
549
+ if (!Array.isArray(value.sources) || value.sources.length > LOADED_MAX_PACKAGES + LOADED_TIERS.length - 1) return undefined;
550
+ const sources = value.sources.map(recordedSource);
551
+ if (sources.includes(null)) return undefined;
552
+ let mcp = null;
553
+ if (value.mcp !== null) {
554
+ if (!Array.isArray(value.mcp) || value.mcp.length > LOADED_MAX_SERVERS) return undefined;
555
+ mcp = [];
556
+ for (const row of value.mcp) {
557
+ if (!isObject(row) || !(row.name === null || (typeof row.name === "string" && MCP_SERVER_NAME_RE.test(row.name))) || !LOADED_MCP_STATES.includes(row.state) || !isCount(row.tools, LOADED_MAX_TOOL_COUNT)) return undefined;
558
+ mcp.push({ name: row.name, state: row.state, tools: row.tools });
559
+ }
560
+ }
561
+ let tools = null;
562
+ if (value.tools !== null) {
563
+ if (!isObject(value.tools) || !isCount(value.tools.active, LOADED_MAX_TOOL_COUNT)) return undefined;
564
+ if (value.tools.own !== null && !namesWithin(value.tools.own, OWN_TOOLS_MAX, isToolName)) return undefined;
565
+ if (value.tools.job !== null && !namesWithin(value.tools.job, LOADED_JOB_TOOLS.length, (name) => LOADED_JOB_TOOLS.includes(name))) return undefined;
566
+ if (value.tools.unmatched !== undefined && (!namesWithin(value.tools.unmatched, LEFT_OUT_NAMES_MAX, isToolName) || value.tools.unmatched.length === 0)) return undefined;
567
+ tools = { active: value.tools.active, own: copied(value.tools.own), job: copied(value.tools.job), ...(value.tools.unmatched !== undefined ? { unmatched: [...value.tools.unmatched] } : {}) };
568
+ }
569
+ let problems = null;
570
+ if (value.problems !== null) {
571
+ if (!isObject(value.problems) || !isCount(value.problems.failed) || !isCount(value.problems.dropped) || !(value.problems.mcp === null || isCount(value.problems.mcp, LOADED_MAX_SERVERS))) return undefined;
572
+ problems = { failed: value.problems.failed, dropped: value.problems.dropped, mcp: value.problems.mcp };
573
+ }
574
+ return { at: value.at, cut, pi: value.pi, flow, sources, mcp, tools, problems };
575
+ }
576
+
577
+ /**
578
+ * The record's `loaded` (issue #800), REBUILT, never the source's object, the posture of every other field of the record:
579
+ * or null on any violation, and then the record has no such key. What reaches this was made by `rebuildLoaded` and
580
+ * `loadedWithImage`, so null here is a hand-built source and no ordinary run. Only `reported` carries a runner part, whatever
581
+ * the source said. Past `LOADED_RECORD_MAX_BYTES` it reads as `cut`, the worker's facts kept. Never throws.
582
+ */
583
+ export function recordedLoaded(value) {
584
+ if (!isObject(value) || value.v !== LOADED_RECORD_VERSION || !LOADED_RUNNER_WORDS.includes(value.runner)) return null;
585
+ const image = typeof value.image === "string" && value.image.length <= IMAGE_MAX && imageRefProblem(value.image) === null ? value.image : null;
586
+ const imageDigest = typeof value.imageDigest === "string" && IMAGE_DIGEST_RE.test(value.imageDigest) ? value.imageDigest : null;
587
+ const imagePi = isPiVersion(value.imagePi) ? value.imagePi : null;
588
+ // Conditional for the reason `baseSha` is, just below: an image no job named has nothing to say here, and a key on every
589
+ // record would change the bytes of all of them. Beside the facts it qualifies, and meaningless with no image to qualify.
590
+ const fromJob = value.imageFromJob === true && image !== null ? { imageFromJob: true } : {};
591
+ // A CONDITIONAL key, the last of the field. Null, as `image` is, would change the bytes of every record written before the
592
+ // workspace was prepared, and those have nothing to say about a commit.
593
+ const base = typeof value.baseSha === "string" && LOADED_BASE_SHA_RE.test(value.baseSha) ? { baseSha: value.baseSha } : {};
594
+ const leftOut = recordedLeftOut(value.leftOut ?? null);
595
+ if (leftOut === undefined) return null;
596
+ let runnerPart = { at: null, cut: null, pi: null, flow: null, sources: [], mcp: null, tools: null, problems: null };
597
+ if (value.runner === "reported") {
598
+ runnerPart = recordedRunnerPart(value);
599
+ if (runnerPart === undefined) return null;
600
+ }
601
+ const { at, cut, pi, flow, sources, mcp, tools, problems } = runnerPart;
602
+ const out = { v: LOADED_RECORD_VERSION, runner: value.runner, at, cut, image, imageDigest, imagePi, ...fromJob, pi, flow, sources, mcp, tools, problems, leftOut, ...base };
603
+ if (Buffer.byteLength(JSON.stringify(out), "utf8") <= LOADED_RECORD_MAX_BYTES) return out;
604
+ // No job's line gets here today (the tests measure the worst one); a cap that grows would. REJECTED: null, as this read before.
605
+ // The bound falls after the image's facts are added, so a line that had passed every check then
606
+ // left no key at all, and the run read like an older worker's. What is left always fits: the image's three facts, at
607
+ // most 32 left-out tools, at most 16 names of each exclusion and the base commit, under 8 KiB between them.
608
+ return { ...out, runner: "cut", at: null, cut: null, pi: null, flow: null, sources: [], mcp: null, tools: null, problems: null };
609
+ }
@@ -435,7 +435,7 @@ export function mcpServerWhere(server) {
435
435
  return basename(server.command);
436
436
  }
437
437
 
438
- const ABSENT_CODES = new Set(["ENOENT", "ELOOP", "ENOTDIR", "ENAMETOOLONG"]);
438
+ export const ABSENT_CODES = new Set(["ENOENT", "ELOOP", "ENOTDIR", "ENAMETOOLONG"]);
439
439
 
440
440
  /**
441
441
  * The overlay's `mcp.json` (`<globalPiDir>/mcp.json`), parsed: `null` when there is none, the `parseOverlayMcp` result