@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,225 @@
1
+ import { existsSync } from "node:fs";
2
+ import { DEFAULT_BACKEND, PODMAN_BACKEND, UNATTRIBUTED_BACKEND, parseBackendFloor, parseBackendList } from "./backends.mjs";
3
+ import { buildDockerRunArgs, buildPodmanRunArgs } from "./docker-run.mjs";
4
+ // The manifest's version rule (issue #761) lives beside its writers in sandbox-store.mjs, which the console already
5
+ // imports; only the refusal below is decided here.
6
+ import { SANDBOX_MANIFEST_VERSION, sandboxDeadline, sandboxManifestNewer } from "./sandbox-store.mjs";
7
+
8
+ /**
9
+ * sandbox-policy.mjs -- which retained runs a sandbox may reopen, decided from the manifest and an environment alone
10
+ * (issue #710, INT-SANDBOX-CONTRACT): the launcher table, the venue policy, and the refusals `resolveSandbox` makes
11
+ * before anything is spawned.
12
+ *
13
+ * A LEAF on purpose. These are pure functions over a manifest, and sandbox.mjs, which opens the shell, imports the
14
+ * container env builder, whose graph carries the model provider SDKs; the operator console asks the same questions on
15
+ * every run page and must not bundle those to do it (3.1 MB against 6.9 MB). sandbox.mjs re-exports every name here
16
+ * unchanged, so the CLI, the panel and the worker read one definition, and admin/test/console-bundle.test.mjs holds this file's
17
+ * import graph free of the provider SDKs.
18
+ */
19
+
20
+ /**
21
+ * The venues a sandbox opens on, and how each one spells its container (issue #429): the CLI every spawn for that
22
+ * venue goes through, and the job argv builder the session's argv is built by. Keyed by the venue name the manifest
23
+ * records, so a run is reopened ONLY in the runtime that ran it: a podman run under docker would reproduce none of it
24
+ * (another store, no keep-id, another uid map), and the reverse is as wrong.
25
+ *
26
+ * A TABLE rather than a `bin` read off the backend bundles, and that alternative was the obvious one: the bundles are
27
+ * built by the worker at boot, and the CLI and the admin panel never build them (they would construct a
28
+ * `runContainer`, a reaper and a `podman info` reader to open a shell). What the sandbox needs from a venue is two
29
+ * facts, and both are the same values the bundles are built with (`bin: "podman"` and `buildPodmanRunArgs` in
30
+ * `makePodmanBackend`); `sandbox.test.mjs` pins the pairing. A venue not in this table has no sandbox, by
31
+ * construction: nothing here can reopen a run under a runtime it did not run in.
32
+ *
33
+ * Not "any venue declaring `remote: false`": a future non-remote venue on a third runtime would pass that and be
34
+ * reopened under one of these two. Such a venue must add its own row, deliberately.
35
+ */
36
+ export const SANDBOX_LAUNCHERS = Object.freeze({
37
+ [DEFAULT_BACKEND]: Object.freeze({ bin: "docker", build: buildDockerRunArgs }),
38
+ [PODMAN_BACKEND]: Object.freeze({ bin: "podman", build: buildPodmanRunArgs }),
39
+ });
40
+
41
+ /** The launcher for `venue`, or null. `Object.hasOwn`, so `"toString"` is not a venue. */
42
+ export function sandboxLauncher(venue) {
43
+ return typeof venue === "string" && Object.hasOwn(SANDBOX_LAUNCHERS, venue) ? SANDBOX_LAUNCHERS[venue] : null;
44
+ }
45
+
46
+ /**
47
+ * The blessed venues a sandbox can open on, in `PI_BACKENDS` order: the venues this host both runs and has a launcher
48
+ * for. The retention reaper asks each one which sandboxes are open, and `--list` draws its RUNNING column from them.
49
+ */
50
+ export function sandboxVenues(blessed) {
51
+ return (Array.isArray(blessed) ? blessed : []).filter((venue) => sandboxLauncher(venue) !== null);
52
+ }
53
+
54
+ /**
55
+ * `{ blessed, backendFloor }` as a sandbox opened from `env` sees them, through the worker's own two parsers. For the
56
+ * admin panel, which deliberately never calls `loadConfig`; the CLI has the parsed config already. THROWS on a
57
+ * malformed `PI_BACKENDS` or `PI_BACKEND_FLOOR`, exactly as the worker refuses to boot on one: a typo must never read
58
+ * as "podman is not blessed" (a refusal an operator then chases in the wrong place) or as "no floor" (a podman sandbox
59
+ * opened without the observations the deployment asks for). Read from THIS process's environment and never a
60
+ * deployment's `.env`, which `OQ-038` records for the panel.
61
+ */
62
+ export function sandboxVenuePolicy(env) {
63
+ return { blessed: parseBackendList(env?.PI_BACKENDS), backendFloor: parseBackendFloor(env?.PI_BACKEND_FLOOR) };
64
+ }
65
+
66
+ /**
67
+ * Whether a retained run can be re-opened HERE, judged by the venue it ran in (issue #277). `null` when it
68
+ * can, else `{ refused, message }`. Exported for the admin panel, which asks before advertising the key.
69
+ *
70
+ * WHY THIS IS PER JOB. A sandbox opens a shell on THIS host, in the runtime the job ran in, against the job's retained
71
+ * directory, so it can reproduce only a run whose container was built here. The check used to be deployment-wide
72
+ * (refuse every sandbox when any blessed venue was remote) because the command takes a job id and could not learn its
73
+ * venue; the manifest now records it, and the answer belongs to the job.
74
+ *
75
+ * HELD MEANS A VENUE THIS FILE HAS A LAUNCHER FOR (`SANDBOX_LAUNCHERS`: `local` through the docker CLI, `podman`
76
+ * through this account's rootless podman), AND ONE `blessed` NAMES (issue #429). `blessed` is `PI_BACKENDS` as the
77
+ * CALLER's process reads it: the CLI's `loadConfig`, the panel's own environment (`sandboxVenuePolicy`). A shell whose
78
+ * `PI_BACKENDS` does not bless a venue has said it does not run that runtime, so reopening a run there would spawn a CLI
79
+ * that environment took out of service. It is NOT what keeps an open shell's directory from the retention reaper, and
80
+ * an earlier version of this comment said it was: the opener's `PI_BACKENDS` and the worker's routinely differ
81
+ * (`OQ-038`), so the reaper asks the runtime each retained run records instead, whatever either blesses
82
+ * (`makeSandboxRuntimeWatch`). It defaults to `local` alone, which is what `PI_BACKENDS` unset means, so a caller from
83
+ * before issue #429 refuses and admits exactly as it did.
84
+ *
85
+ * Not "any venue declaring `remote: false`": a future non-remote venue on a third runtime would pass that and be
86
+ * reopened under one of these two, reproducing a run from a runtime it never ran in. Such a venue must add its own
87
+ * launcher row, deliberately.
88
+ *
89
+ * A MANIFEST WITH NO `backend` KEY predates venue attribution and ran on `local` (`UNATTRIBUTED_BACKEND`).
90
+ * A key that is PRESENT but not a name is refused: `typeof` first, because the table's `backendFor(null)`
91
+ * returns `local`, and a null stamp means the venue was never known, not that it was local.
92
+ */
93
+ export function sandboxVenueRefusal({ jobId, manifest, blessed = [DEFAULT_BACKEND] }) {
94
+ const venue = sandboxVenueOf(manifest);
95
+ if (typeof venue !== "string" || venue === "") {
96
+ return { refused: "venue-unreachable", message: `the manifest for ${jobId} names no backend, so this host cannot tell whether the run happened here` };
97
+ }
98
+ const launcher = sandboxLauncher(venue);
99
+ const held = Array.isArray(blessed) && blessed.includes(venue);
100
+ if (launcher && held) return null;
101
+ if (launcher) {
102
+ // A venue this file CAN open, on a host (as this process reads it) that does not bless it. The fix is an
103
+ // environment, and it is named with where it is read: the CLI and the panel read `PI_BACKENDS` from the process
104
+ // they run in, never from a deployment's `.env`, and "set it where you run this" is the whole remedy.
105
+ return {
106
+ refused: "venue-unreachable",
107
+ message: `${jobId} ran on the ${JSON.stringify(venue)} backend, which PI_BACKENDS in this process's environment does not bless (it reads ${JSON.stringify((Array.isArray(blessed) ? blessed : []).join(","))}), so no sandbox opens on it here: a sandbox opens only on a venue this host blesses, through that venue's own CLI (${launcher.bin}). Set PI_BACKENDS where you run this as the worker's is set; it is read from this environment, not from the deployment's .env.`,
108
+ };
109
+ }
110
+ return {
111
+ refused: "venue-unreachable",
112
+ // Names BOTH sides (issue #354): the venue that ran it, and the venues a sandbox opens through. "Not on this host's
113
+ // docker daemon" was true only while every venue but `local` was elsewhere.
114
+ message: `${jobId} ran on the ${JSON.stringify(venue)} backend, and a sandbox opens a shell only through a venue it has a launcher for on this host (${Object.entries(SANDBOX_LAUNCHERS).map(([name, l]) => `${JSON.stringify(name)} through the ${l.bin} CLI`).join(", ")}) against the retained directory, so it cannot reproduce that run. Open it on the venue that ran it.`,
115
+ };
116
+ }
117
+
118
+ /** The venue a retained manifest records: its `backend` key when present (whatever it holds), else `UNATTRIBUTED_BACKEND`. */
119
+ export function sandboxVenueOf(manifest) {
120
+ return Object.hasOwn(manifest ?? {}, "backend") ? manifest.backend : UNATTRIBUTED_BACKEND;
121
+ }
122
+
123
+ /**
124
+ * The refusals `resolveSandbox` decides from the MANIFEST ALONE, in the order an operator should read
125
+ * them. Not every manifest-only refusal on the `b` path: `decideSandboxJobUser` can refuse a run from
126
+ * `manifest.jobUser` too, and it stays where it is for two MEASURED reasons rather than the vaguer "it
127
+ * would change what the CLI refuses and when" this comment used to give (issue #367, item 5).
128
+ *
129
+ * IT IS NOT MANIFEST-ONLY. It returns `{ user: null, home: null }` on `darwin` and `win32` before it ever
130
+ * reads the stamp, so the SAME malformed `jobUser` refuses everywhere else and does not refuse on macOS or
131
+ * Windows. Measured across twelve platform strings: the split is darwin and win32 against every other
132
+ * value, linux, the BSDs, sunos, aix and the empty string alike, which is wider than "linux" and is why
133
+ * this says it that way. Folding it in would make this function's answer, and therefore whether the panel
134
+ * advertises `b` at all, depend on the operator's own OS for an identical run. Every other refusal here is
135
+ * a property of the run, and that is the whole of the argument.
136
+ *
137
+ * NOT because it is async, which is true of the function and is NOT a reason about this refusal: measured,
138
+ * `job-user-stamp-invalid` is reached with zero calls to the endpoint resolver, the daemon-facts reader
139
+ * and the image preflight, on every platform and every malformed shape. It is decided from the manifest
140
+ * before any await that does work. A well-formed stamp does reach the daemon, which is why this function
141
+ * stays async and out of a predicate called on every left and right between runs, but that is a cost of
142
+ * moving the WHOLE function, not of the refusal #367 item 5 is about.
143
+ *
144
+ * Extracted (issue #337) because the admin panel needs the same answer before it advertises `b`, and the
145
+ * alternative is the shape this file's own `openSandbox` docblock warns about: "Two callers assembling
146
+ * the same session from parts is how one of them drops a part." The panel had exactly that, checking the
147
+ * venue and not the other two, so a run whose manifest names no image was offered the key, given two
148
+ * lines of detail about the session it would get, and refused the moment the key was pressed.
149
+ *
150
+ * SYNCHRONOUS AND MANIFEST-ONLY is the boundary, not an accident of what fitted. The panel reads this
151
+ * inside a key handler, once per record; anything needing docker (a proxy that is not running, a sandbox
152
+ * already up) stays in `openSandbox` where it belongs and is `OQ-038`'s residual for the panel.
153
+ *
154
+ * The VENUE comes first, and that ordering is #277's: for a run from another venue the image and the
155
+ * workspace are symptoms, and the first refusal an operator reads should be the cause. A workspace that
156
+ * happens to exist at the same path on this host would otherwise pass and silently reproduce the wrong
157
+ * run.
158
+ *
159
+ * Before even the venue, a manifest this build cannot read (issue #761): `manifest-newer`. A newer release may have
160
+ * redefined any field below, the venue's included, so judging one would answer a question this build cannot ask. A
161
+ * `version` that is not an integer of 1 or more is refused the same way, since nothing here wrote it. `supported` is
162
+ * injectable for tests.
163
+ */
164
+ export function sandboxSyncRefusal({ jobId, manifest, fileExists = existsSync, blessed, supported = SANDBOX_MANIFEST_VERSION }) {
165
+ const newer = sandboxManifestNewer(manifest, { supported });
166
+ if (newer) {
167
+ return {
168
+ refused: "manifest-newer",
169
+ message: newer.version === null
170
+ ? `the manifest for ${jobId} carries a "version" that is not an integer of 1 or more, which no pi-dispatch writes, so this host cannot tell how to read it`
171
+ : `the retained workspace for ${jobId} was kept by a newer pi-dispatch (manifest version ${newer.version}; this build understands ${supported}); open it with that release`,
172
+ };
173
+ }
174
+ const venue = sandboxVenueRefusal({ jobId, manifest, blessed });
175
+ if (venue) return venue;
176
+ if (!manifest?.image) {
177
+ return { refused: "no-image", message: `the manifest for ${jobId} names no image, so the sandbox cannot reproduce the run` };
178
+ }
179
+ if (!manifest?.workspace || !fileExists(manifest.workspace)) {
180
+ // The common cause for a local run: the operator's folder moved or was deleted. Naming the path is
181
+ // the whole diagnosis, so name it.
182
+ return { refused: "workspace-gone", message: `the workspace for ${jobId} is no longer at ${manifest.workspace} — a local folder that moved cannot be re-opened` };
183
+ }
184
+ return null;
185
+ }
186
+
187
+ /**
188
+ * How close to its deadline a run may be and still open without `--pin` (issue #446): five minutes.
189
+ *
190
+ * WHY A MARGIN AND NOT THE DEADLINE ITSELF. The sweep holds a run whose sandbox a runtime reports open, but an open is
191
+ * not in any `ps` until its container starts, and the opener spends that stretch on its own runtime asks (the running
192
+ * check, the job user, the image, the network), each bounded in seconds. A sweep whose `ps` came before the container
193
+ * and whose clock (`at`, read after its asks) is past the deadline would still delete the directory under the new
194
+ * shell: window 1 of #446, reproduced. A run whose deadline is more than this far away cannot be past it by the time a
195
+ * pass that missed the container decides, so the refusal closes that window with room to spare; inside it, `--pin`
196
+ * writes a new deadline FIRST, which the sweep's own re-reads honour.
197
+ */
198
+ export const SANDBOX_OPEN_GRACE_MS = 5 * 60 * 1000;
199
+
200
+ /**
201
+ * The refusal of a run past its deadline, or within `SANDBOX_OPEN_GRACE_MS` of it, unless the open pins it first
202
+ * (issue #446). `null` when the run may open.
203
+ *
204
+ * The deadline is `sandboxDeadline`'s, the sweep's own rule (the pin; else the earlier of the `retainUntil` the worker
205
+ * wrote and `createdAt` plus THIS opener's window), so an opener whose own PI_SANDBOX_RETENTION_HOURS is larger than
206
+ * the worker's is refused by the worker's deadline, not admitted by its own. The one case it cannot see, a worker
207
+ * whose window was lowered after the run was retained, is the post-launch look's (`openSandbox`).
208
+ * Shared by `resolveSandbox` and the admin panel's `readSandboxInfo`, which must not advertise `b` for a run the key
209
+ * press would refuse; the panel has no pin, so its refusal is this one and it names the CLI command.
210
+ */
211
+ export function sandboxWindowRefusal({ jobId, manifest, retentionHours, at, pin = false }) {
212
+ if (pin) return null;
213
+ const { until } = sandboxDeadline(manifest, retentionHours);
214
+ const fix = `open it with \`pi-dispatch sandbox ${jobId} --pin\`, which extends its retention before anything starts`;
215
+ if (until === null) {
216
+ return { refused: "past-window", message: `the retained workspace for ${jobId} records no creation time, so the retention sweep deletes it on its next pass; ${fix}` };
217
+ }
218
+ if (until - at > SANDBOX_OPEN_GRACE_MS) return null;
219
+ const when = new Date(until).toISOString();
220
+ const where = until <= at ? `is past its retention window (it closed at ${when})` : `is within ${Math.round(SANDBOX_OPEN_GRACE_MS / 60000)} minutes of the end of its retention window (${when})`;
221
+ return {
222
+ refused: "past-window",
223
+ message: `the retained workspace for ${jobId} ${where}, so the retention sweep may delete it under the shell; ${fix}`,
224
+ };
225
+ }
@@ -0,0 +1,145 @@
1
+ /** Host-bound control requests. This queue never enters the paid job processor or pi-jobs discovery (#771). */
2
+ import { Worker } from "bullmq";
3
+ import { createHash } from "node:crypto";
4
+ import { makeQueue } from "./queue.mjs";
5
+ import { assertJudgedConnection, onValkeyError } from "./connection.mjs";
6
+ import { TOKEN_NAME_RE } from "./console-tokens.mjs";
7
+ import { WORKER_NAME_RE } from "./worker-name.mjs";
8
+ import { createSandboxPinHandler, newSandboxId, PREPARED_SANDBOX_ID_RE } from "./sandbox-prepare.mjs";
9
+ import { RELEASE_IF_MINE } from "./fleet-lease.mjs";
10
+
11
+ export const SANDBOX_PREPARE_REQUEST_TTL_MS = 30 * 60_000;
12
+ export const SANDBOX_PREPARE_PENDING_MAX = 32;
13
+ const RESULT_RETENTION_SECONDS = 7 * 86_400;
14
+ const REQUEST_OP_TIMEOUT_MS = 3_000;
15
+ const digest = (v) => createHash("sha256").update(JSON.stringify(v)).digest("hex");
16
+ const refused = (reason, message) => ({ refused: reason, message });
17
+ const idOk = (id) => typeof id === "string" && /^[A-Za-z0-9._:-]{1,200}$/.test(id);
18
+
19
+ export function sandboxPrepareQueueName(host) {
20
+ if (!WORKER_NAME_RE.test(host ?? "")) throw new Error("sandbox control queue requires a checked host");
21
+ return `pi-sandbox-prepare@${host}`;
22
+ }
23
+
24
+ export const sandboxPrepareSlotKey = (host, slot) => `sandbox:prepare:pending:${host}:${slot}`;
25
+ export function makeSandboxPrepareQueue(connection, { host } = {}) {
26
+ return makeQueue(connection, { name: sandboxPrepareQueueName(host) });
27
+ }
28
+
29
+ /** Rebuild exactly the command's fields; no payload can add a token, image, executable or provider setting. */
30
+ export function sandboxPreparationRequest(raw) {
31
+ if (!raw || !PREPARED_SANDBOX_ID_RE.test(raw.id ?? "") || !WORKER_NAME_RE.test(raw.host ?? "") || !TOKEN_NAME_RE.test(raw.by ?? "")) return refused("sandbox-request-invalid", "the sandbox request needs a checked id, host and operator name");
32
+ const common = { op: raw.op ?? "prepare", id: raw.id, host: raw.host, by: raw.by };
33
+ if (common.op === "prepare") {
34
+ if (typeof raw.repo !== "string" || raw.repo.length > 300 || typeof raw.ref !== "string" || raw.ref.length > 200 || !Number.isInteger(raw.triggerIndex) || raw.triggerIndex < 0 || !/^[a-f0-9]{64}$/.test(raw.triggerDigest ?? "") || !/^[a-f0-9]{64}$/.test(raw.planDigest ?? "") || typeof raw.pin !== "boolean") return refused("sandbox-request-invalid", "the prepare request is missing its reviewed trigger and host plan");
35
+ return { request: { ...common, repo: raw.repo, ref: raw.ref, triggerIndex: raw.triggerIndex, triggerDigest: raw.triggerDigest, planDigest: raw.planDigest, pin: raw.pin } };
36
+ }
37
+ if (common.op === "pin") {
38
+ const expect = raw.expect;
39
+ if (!idOk(raw.runId) || !expect || !/^[a-f0-9]{64}$/.test(expect.generation ?? "") || !(expect.until === null || (Number.isSafeInteger(expect.until) && expect.until >= 0)) || typeof expect.pin !== "boolean") return refused("sandbox-request-invalid", "the pin request needs the kept workspace's reviewed incarnation and deadline");
40
+ return { request: { ...common, runId: raw.runId, expect: { generation: expect.generation, until: expect.until, pin: expect.pin } } };
41
+ }
42
+ return refused("sandbox-request-invalid", "the sandbox control request names an unknown operation");
43
+ }
44
+
45
+ function bounded(promise, timeoutMs = REQUEST_OP_TIMEOUT_MS) {
46
+ return new Promise((resolve, reject) => {
47
+ const timer = setTimeout(() => reject(new Error("sandbox request did not answer")), timeoutMs);
48
+ Promise.resolve(promise).then((r) => { clearTimeout(timer); resolve(r); }, (e) => { clearTimeout(timer); reject(e); });
49
+ });
50
+ }
51
+
52
+ async function releaseSlot(redis, request) {
53
+ if (!Number.isInteger(request?.slot) || request.slot < 0 || request.slot >= SANDBOX_PREPARE_PENDING_MAX) return;
54
+ await bounded(redis.eval(RELEASE_IF_MINE, 1, sandboxPrepareSlotKey(request.host, request.slot), request.id)).catch(() => {});
55
+ }
56
+
57
+ /** Thirty-two expiring individual claims bound pending work atomically, including concurrent console sessions. */
58
+ export async function enqueueSandboxPreparation(queue, raw, { now = Date.now } = {}) {
59
+ const parsed = sandboxPreparationRequest({ ...raw, id: raw?.id ?? newSandboxId() });
60
+ if (parsed.refused) return parsed;
61
+ const request = parsed.request;
62
+ if (queue.name !== sandboxPrepareQueueName(request.host)) return refused("sandbox-host-mismatch", "the request queue does not belong to the selected host");
63
+ let redis, slot = null;
64
+ try {
65
+ const existing = await bounded(queue.getJob(request.id));
66
+ if (existing) return digest(sandboxPreparationRequest(existing.data).request) === digest(request) ? { id: request.id, state: (await readSandboxPreparation(queue, request.id)).state, existing: true } : refused("sandbox-request-collision", "this request id already names another sandbox request");
67
+ redis = await bounded(queue.client);
68
+ for (let i = 0; i < SANDBOX_PREPARE_PENDING_MAX; i++) {
69
+ const taken = await bounded(redis.set(sandboxPrepareSlotKey(request.host, i), request.id, "PX", SANDBOX_PREPARE_REQUEST_TTL_MS, "NX"));
70
+ if (taken === "OK") { slot = i; break; }
71
+ }
72
+ if (slot === null) return refused("sandbox-preparation-full", "this host already has 32 pending sandbox requests; wait for one to finish");
73
+ const stored = { ...request, requestedAt: now(), slot };
74
+ await bounded(queue.add("sandbox-control", stored, { jobId: request.id, attempts: 2, backoff: { type: "exponential", delay: 1_000 }, removeOnComplete: { age: RESULT_RETENTION_SECONDS, count: 1_000 }, removeOnFail: { age: RESULT_RETENTION_SECONDS, count: 1_000 } }));
75
+ const saved = await bounded(queue.getJob(request.id));
76
+ const same = saved && digest(sandboxPreparationRequest(saved.data).request) === digest(request);
77
+ if (!same) {
78
+ await releaseSlot(redis, stored);
79
+ return refused("sandbox-request-collision", "this request id already names another sandbox request");
80
+ }
81
+ // Two simultaneous identical adds can acquire different claims; the stored request owns only its own one.
82
+ if (saved.data.slot !== slot) await releaseSlot(redis, stored);
83
+ return { id: request.id, state: "pending" };
84
+ } catch {
85
+ // A timed-out add can still land on reconnect. Keep its claim until its expiry; report ambiguity with the id.
86
+ return { id: request.id, state: "unknown", message: "the host request could not be confirmed; refresh its status before preparing again" };
87
+ }
88
+ }
89
+
90
+ export async function readSandboxPreparation(queue, id) {
91
+ if (!PREPARED_SANDBOX_ID_RE.test(id ?? "")) return { id, state: "unknown" };
92
+ try {
93
+ const job = await bounded(queue.getJob(id));
94
+ if (!job) return { id, state: "unknown" };
95
+ const parsed = sandboxPreparationRequest(job.data);
96
+ if (parsed.refused || job.id !== id) return { id, state: "unknown" };
97
+ const rawState = await bounded(job.getState());
98
+ const base = { id, host: parsed.request.host, op: parsed.request.op };
99
+ if (rawState === "completed") {
100
+ const result = job.returnvalue;
101
+ if (result?.refused) return { ...base, state: "refused", reason: result.refused, message: result.message };
102
+ if (idOk(result?.id) && result?.host === base.host && Number.isSafeInteger(result.until) && typeof result.pinned === "boolean") return { ...base, state: "ready", result: { id: result.id, host: result.host, until: result.until, pinned: result.pinned, ...(typeof result.backend === "string" ? { backend: result.backend } : {}), ...(result.prepared === true ? { prepared: true } : {}), ...(/^[a-f0-9]{64}$/.test(result.generation ?? "") ? { generation: result.generation } : {}) } };
103
+ return { ...base, state: "unknown" };
104
+ }
105
+ if (rawState === "failed") return { ...base, state: "failed", reason: "sandbox-infrastructure-failed", message: "sandbox preparation failed on its host; check the host's forge connection and disk, then prepare again" };
106
+ return { ...base, state: rawState === "active" ? "preparing" : ["waiting", "prioritized", "delayed", "paused", "waiting-children"].includes(rawState) ? "pending" : "unknown" };
107
+ } catch { return { id, state: "unknown", unreachable: true }; }
108
+ }
109
+
110
+ export async function listSandboxPreparations(queue, { limit = SANDBOX_PREPARE_PENDING_MAX } = {}) {
111
+ try {
112
+ const cap = Math.max(1, Math.min(SANDBOX_PREPARE_PENDING_MAX, limit));
113
+ const jobs = await bounded(queue.getJobs(["active", "waiting", "delayed", "completed", "failed"], 0, cap));
114
+ const rows = await Promise.all(jobs.slice(0, cap).map((j) => readSandboxPreparation(queue, j.id)));
115
+ return { rows, more: jobs.length > cap };
116
+ } catch { return { unreachable: true, rows: [] }; }
117
+ }
118
+
119
+ export function startSandboxPrepareWorker({ connection, host, prepare, pin = null, config = null, now = Date.now, Worker: WorkerClass = Worker, workerOptions = {}, log = () => {} } = {}) {
120
+ assertJudgedConnection(connection);
121
+ if (typeof prepare !== "function") throw new Error("sandbox control worker needs a preparer");
122
+ const pinWorkspace = pin ?? (config ? createSandboxPinHandler({ config, now }) : null);
123
+ const name = sandboxPrepareQueueName(host);
124
+ const worker = new WorkerClass(name, async (job) => {
125
+ const parsed = sandboxPreparationRequest(job.data);
126
+ if (parsed.refused) return parsed;
127
+ const request = parsed.request;
128
+ if (request.host !== host) return refused("sandbox-host-mismatch", "the request reached another host; prepare it again on the selected host");
129
+ const requestedAt = job.data.requestedAt;
130
+ const redis = await worker.client;
131
+ let release = true;
132
+ try {
133
+ if (!Number.isSafeInteger(requestedAt) || now() < requestedAt - 30_000 || now() - requestedAt > SANDBOX_PREPARE_REQUEST_TTL_MS) return refused("sandbox-request-expired", "the sandbox request waited too long; preview and prepare it again");
134
+ if (request.op === "pin") return typeof pinWorkspace === "function" ? await pinWorkspace(request) : refused("sandbox-pin-unavailable", "this host cannot pin a workspace");
135
+ return await prepare(request);
136
+ } catch (error) {
137
+ if (error?.piDispatchConfig) return refused("sandbox-config-invalid", "the host's sandbox configuration is invalid; run pi-dispatch doctor on that host");
138
+ release = (job.attemptsMade ?? 0) + 1 >= (job.opts?.attempts ?? 2);
139
+ log("sandbox_preparation_failed", { id: request.id, reason: "infrastructure" });
140
+ throw new Error("sandbox preparation infrastructure failed");
141
+ } finally { if (release) await releaseSlot(redis, job.data); }
142
+ }, { lockDuration: 30_000, ...workerOptions, connection, concurrency: 1, maxStalledCount: 1 });
143
+ onValkeyError(worker, "sandbox control worker");
144
+ return worker;
145
+ }