@edgehero/pi-dispatch 4.3.1 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/.env.example +84 -3
  2. package/README.md +21 -7
  3. package/deploy/console.service +52 -0
  4. package/deploy/docker-compose.yml +3 -1
  5. package/deploy/pi-dispatch-valkey.container +4 -2
  6. package/deploy/sandbox-gateway.service +23 -0
  7. package/deploy/worker-env-wrapper.cmd +6 -0
  8. package/deploy/worker-env-wrapper.sh +10 -1
  9. package/package.json +118 -19
  10. package/src/agent-snapshot.mjs +49 -0
  11. package/src/answerer-runtime.mjs +194 -0
  12. package/src/answerer.mjs +178 -0
  13. package/src/approval-policy.mjs +76 -0
  14. package/src/approval-runtime.mjs +188 -0
  15. package/src/approval-session.mjs +137 -0
  16. package/src/approval-state.mjs +170 -0
  17. package/src/auth-cli.mjs +86 -0
  18. package/src/auth-connections.mjs +145 -0
  19. package/src/auth-policy.mjs +32 -0
  20. package/src/auth-store.mjs +97 -0
  21. package/src/azure-host.mjs +66 -4
  22. package/src/azure-prompt.mjs +5 -4
  23. package/src/backend-conformance.mjs +34 -2
  24. package/src/backend-local.mjs +103 -34
  25. package/src/backend-openshell.mjs +1540 -0
  26. package/src/backend-podman.mjs +63 -2
  27. package/src/backend-registry.mjs +61 -4
  28. package/src/backends.mjs +395 -6
  29. package/src/bullmq-internals.mjs +208 -0
  30. package/src/canary-cli.mjs +28 -0
  31. package/src/canary-lock.mjs +41 -0
  32. package/src/canary-runtime.mjs +237 -0
  33. package/src/canary.mjs +53 -0
  34. package/src/cancel-cli.mjs +47 -34
  35. package/src/cancel-state.mjs +213 -15
  36. package/src/capabilities.mjs +36 -1
  37. package/src/capacity-cli.mjs +28 -8
  38. package/src/capacity-records.mjs +75 -16
  39. package/src/capacity.mjs +65 -0
  40. package/src/chat-cli.mjs +288 -0
  41. package/src/chat-fork.mjs +547 -0
  42. package/src/chat-mode.mjs +156 -0
  43. package/src/chat-view.mjs +720 -0
  44. package/src/checkpoint-paths.mjs +113 -0
  45. package/src/cli.mjs +136 -41
  46. package/src/comment-tables.mjs +322 -0
  47. package/src/community-auth-adapters.mjs +87 -0
  48. package/src/community-auth-helper.mjs +76 -0
  49. package/src/community-auth-metadata.mjs +6 -0
  50. package/src/community-auth-output-pins.mjs +38 -0
  51. package/src/community-auth-patches.mjs +168 -0
  52. package/src/community-auth-pins.mjs +38 -0
  53. package/src/config.mjs +190 -29
  54. package/src/connection.mjs +83 -7
  55. package/src/console-answer.mjs +113 -0
  56. package/src/console-audit.mjs +604 -0
  57. package/src/console-bootstrap.mjs +325 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-devices.mjs +372 -0
  60. package/src/console-install.mjs +239 -0
  61. package/src/console-open.mjs +99 -0
  62. package/src/console-probe.mjs +106 -0
  63. package/src/console-tokens.mjs +459 -0
  64. package/src/container-names.mjs +51 -0
  65. package/src/container-spec.mjs +20 -1
  66. package/src/cpu-reserve.mjs +4 -1
  67. package/src/defer-reasons.mjs +200 -0
  68. package/src/defer-write.mjs +208 -0
  69. package/src/deployment-venue.mjs +23 -6
  70. package/src/docker-run.mjs +11 -0
  71. package/src/doctor-bundles.mjs +130 -0
  72. package/src/doctor-model-proposals.mjs +109 -0
  73. package/src/doctor-openshell.mjs +238 -0
  74. package/src/doctor-results.mjs +602 -0
  75. package/src/doctor-spawner.mjs +215 -0
  76. package/src/doctor.mjs +1657 -196
  77. package/src/dollar-budget.mjs +212 -3
  78. package/src/endpoint-status.mjs +100 -0
  79. package/src/env-allowlist.mjs +96 -6
  80. package/src/env-file.mjs +6 -4
  81. package/src/envelope.mjs +5 -3
  82. package/src/file-expect.mjs +47 -0
  83. package/src/forgejo-host.mjs +42 -5
  84. package/src/forgejo-prompt.mjs +5 -4
  85. package/src/forges.mjs +33 -3
  86. package/src/fork-job.mjs +58 -0
  87. package/src/fork-policy.mjs +23 -0
  88. package/src/fork-store.mjs +338 -0
  89. package/src/get-token.mjs +1 -1
  90. package/src/git-dirty.mjs +119 -14
  91. package/src/git-hardening.mjs +85 -0
  92. package/src/github-host.mjs +31 -6
  93. package/src/github-prompt.mjs +7 -16
  94. package/src/gitlab-host.mjs +42 -3
  95. package/src/gitlab-prompt.mjs +5 -4
  96. package/src/held-document.mjs +23 -0
  97. package/src/held-replies.mjs +325 -0
  98. package/src/held-settle.mjs +266 -0
  99. package/src/hook-runs.mjs +188 -0
  100. package/src/host-budget.mjs +8 -5
  101. package/src/host-clone.mjs +72 -0
  102. package/src/host-loads.mjs +557 -0
  103. package/src/host-registry.mjs +66 -6
  104. package/src/host-telemetry.mjs +92 -0
  105. package/src/http-body.mjs +78 -0
  106. package/src/image-preflight.mjs +156 -13
  107. package/src/import-pi.mjs +60 -172
  108. package/src/inbox-relay.mjs +482 -0
  109. package/src/inbox-settle.mjs +196 -0
  110. package/src/index.mjs +734 -129
  111. package/src/init.mjs +35 -5
  112. package/src/job-data-version.mjs +75 -0
  113. package/src/job-id.mjs +30 -15
  114. package/src/job-layout.mjs +22 -0
  115. package/src/job-size.mjs +16 -0
  116. package/src/live-jobs.mjs +14 -6
  117. package/src/live-probes.mjs +6 -2
  118. package/src/live-transcript.mjs +872 -0
  119. package/src/loaded-record.mjs +609 -0
  120. package/src/mcp-config.mjs +4 -2
  121. package/src/mcp-preflight.mjs +36 -5
  122. package/src/model-attempts.mjs +84 -0
  123. package/src/model-catalog.mjs +83 -0
  124. package/src/model-cooldowns.mjs +22 -0
  125. package/src/model-failover-protocol.mjs +11 -0
  126. package/src/model-failover.mjs +134 -0
  127. package/src/model-observation.mjs +50 -0
  128. package/src/model-proposal-protocol.mjs +21 -0
  129. package/src/model-proposals.mjs +75 -0
  130. package/src/model-ref.mjs +67 -10
  131. package/src/model-roles-file.mjs +24 -0
  132. package/src/model-roles.mjs +73 -0
  133. package/src/model-status.mjs +34 -0
  134. package/src/models-json.mjs +12 -2
  135. package/src/money.mjs +1 -1
  136. package/src/oauth-openai.mjs +121 -0
  137. package/src/older-state.mjs +249 -0
  138. package/src/on-failure.mjs +17 -13
  139. package/src/open-browser.mjs +31 -2
  140. package/src/opener-cli.mjs +135 -0
  141. package/src/opener-protocol.mjs +27 -0
  142. package/src/opener-terminal.mjs +128 -0
  143. package/src/openshell-cli.mjs +155 -0
  144. package/src/openshell-policy.mjs +163 -0
  145. package/src/outbox.mjs +14 -0
  146. package/src/output-cap.mjs +1 -0
  147. package/src/package-canary-record.mjs +26 -0
  148. package/src/package-canary.mjs +135 -0
  149. package/src/package-names.mjs +9 -0
  150. package/src/package-stage.mjs +418 -0
  151. package/src/packages.mjs +24 -16
  152. package/src/parked.mjs +2 -1
  153. package/src/pause-windows.mjs +136 -17
  154. package/src/podman-stack.mjs +40 -14
  155. package/src/portfolio-snapshot.mjs +26 -7
  156. package/src/prepare-github.mjs +41 -77
  157. package/src/prepare-local.mjs +32 -2
  158. package/src/prepare.mjs +45 -11
  159. package/src/private-store-boundary.mjs +70 -0
  160. package/src/processor.mjs +1550 -173
  161. package/src/projects.mjs +23 -0
  162. package/src/prompt-fence.mjs +18 -0
  163. package/src/provider-key.mjs +7 -0
  164. package/src/provider-steering.mjs +1 -0
  165. package/src/question-context.mjs +35 -0
  166. package/src/question-group-runtime.mjs +91 -0
  167. package/src/question-groups.mjs +171 -0
  168. package/src/queue.mjs +264 -55
  169. package/src/reason-catalog.mjs +448 -0
  170. package/src/receiver-status.mjs +230 -0
  171. package/src/record-reason.mjs +37 -0
  172. package/src/recovery-queue.mjs +566 -0
  173. package/src/redact-secrets.mjs +219 -0
  174. package/src/reserve-estimate.mjs +101 -0
  175. package/src/reserved-env.mjs +36 -1
  176. package/src/retention.mjs +67 -0
  177. package/src/run-budget-ledger.mjs +283 -0
  178. package/src/run-checkpoint.mjs +627 -0
  179. package/src/run-container.mjs +749 -33
  180. package/src/run-history.mjs +791 -45
  181. package/src/run-mirror.mjs +75 -7
  182. package/src/run-progress.mjs +751 -0
  183. package/src/runtime-observations.mjs +3 -1
  184. package/src/runtime-settings.mjs +272 -37
  185. package/src/sandbox-cli.mjs +78 -2
  186. package/src/sandbox-deployment.mjs +51 -0
  187. package/src/sandbox-gateway-protocol.mjs +378 -0
  188. package/src/sandbox-hosts.mjs +100 -0
  189. package/src/sandbox-inventory.mjs +83 -0
  190. package/src/sandbox-policy.mjs +225 -0
  191. package/src/sandbox-prepare-requests.mjs +145 -0
  192. package/src/sandbox-prepare.mjs +285 -0
  193. package/src/sandbox-refs.mjs +70 -0
  194. package/src/sandbox-session-monitor.mjs +4 -0
  195. package/src/sandbox-session-watch.mjs +124 -0
  196. package/src/sandbox-sessions.mjs +161 -0
  197. package/src/sandbox-store.mjs +63 -6
  198. package/src/sandbox-workspace-guard.mjs +39 -0
  199. package/src/sandbox.mjs +27 -200
  200. package/src/scheduler-stall-guard.mjs +14 -8
  201. package/src/schedules.mjs +19 -6
  202. package/src/secret-bundles.mjs +245 -0
  203. package/src/secret-profiles.mjs +103 -3
  204. package/src/secrets.mjs +19 -9
  205. package/src/service-env.mjs +38 -5
  206. package/src/service.mjs +638 -106
  207. package/src/session-key.mjs +60 -0
  208. package/src/session-store.mjs +168 -10
  209. package/src/setup-contract.mjs +40 -0
  210. package/src/setup-egress.mjs +113 -0
  211. package/src/setup-host-entry.mjs +36 -0
  212. package/src/setup-host.mjs +242 -0
  213. package/src/setup-lock.mjs +41 -0
  214. package/src/size-records.mjs +15 -3
  215. package/src/start.mjs +1353 -135
  216. package/src/status-comment.mjs +415 -0
  217. package/src/stderr-transcript.mjs +318 -0
  218. package/src/steer-ack.mjs +122 -0
  219. package/src/steer-cli.mjs +213 -0
  220. package/src/steer-framing.mjs +185 -0
  221. package/src/steer-inbox.mjs +693 -0
  222. package/src/store-version.mjs +139 -0
  223. package/src/subscription-config.mjs +56 -0
  224. package/src/subscription-limits.mjs +3 -0
  225. package/src/subscription-quota.mjs +259 -0
  226. package/src/subscription-runtime.mjs +123 -0
  227. package/src/subscription-status.mjs +42 -0
  228. package/src/subscription-wait.mjs +11 -0
  229. package/src/subscriptions.mjs +7 -1
  230. package/src/thinking-level.mjs +19 -0
  231. package/src/touch-outbox.mjs +61 -0
  232. package/src/touch-state.mjs +133 -0
  233. package/src/triggers-file.mjs +299 -39
  234. package/src/triggers.mjs +780 -38
  235. package/src/trust-paths.mjs +171 -0
  236. package/src/up.mjs +402 -53
  237. package/src/updates-cli.mjs +61 -0
  238. package/src/updates-look.mjs +127 -0
  239. package/src/updates-reconcile.mjs +182 -0
  240. package/src/updates-subscription-adapters.mjs +8 -0
  241. package/src/updates.mjs +252 -0
  242. package/src/valkey-auth.mjs +32 -9
  243. package/src/valkey-endpoint.mjs +14 -4
  244. package/src/wait-for.mjs +1 -1
  245. package/src/wait-state.mjs +62 -3
  246. package/src/watch-board.mjs +38 -0
  247. package/src/watch-closer.mjs +1 -1
  248. package/src/watch-contract.mjs +102 -0
  249. package/src/watch-decision.mjs +78 -0
  250. package/src/watch-drainer.mjs +14 -0
  251. package/src/watch-issue-adapter.mjs +44 -0
  252. package/src/watch-issue.mjs +111 -0
  253. package/src/watch-record.mjs +10 -0
  254. package/src/watch-runtime.mjs +167 -0
  255. package/src/watch-store.mjs +183 -0
  256. package/src/watch-trace.mjs +34 -0
  257. package/src/workspace-checkpoint.mjs +1750 -0
  258. package/src/zero-touch-fixes.mjs +99 -0
  259. package/src/zero-touch.mjs +225 -0
  260. package/src/repeat-slot.mjs +0 -13
@@ -21,6 +21,7 @@
21
21
  * calls docker directly. That refusal is now gone because the seam is real.
22
22
  */
23
23
 
24
+ import { JOB_NAME_PREFIX, jobContainerName } from "./container-names.mjs";
24
25
  import { execFile } from "node:child_process";
25
26
  import { promisify } from "node:util";
26
27
  import { scrubCredentials } from "./redact.mjs";
@@ -51,7 +52,9 @@ export function reaperExec({ execFileFn = execFile, timeoutMs = REAPER_STEP_TIME
51
52
  // 1 MiB, the facts readers' own); every other step keeps the reaper's 30 s.
52
53
  return (bin, args, opts = {}) =>
53
54
  new Promise((resolve, reject) => {
54
- execFileFn(bin, [...args], { timeout: opts.timeoutMs ?? timeoutMs, killSignal: "SIGKILL", maxBuffer: opts.maxBuffer ?? 1024 * 1024 }, (err, stdout, stderr) => {
55
+ // `env` (issue #684): a step that hands the CLI a credential through its own environment, never its argv
56
+ // (the openshell venue's `provider create --credential NAME`); every other step inherits the worker's.
57
+ execFileFn(bin, [...args], { timeout: opts.timeoutMs ?? timeoutMs, killSignal: "SIGKILL", maxBuffer: opts.maxBuffer ?? 1024 * 1024, ...(opts.env ? { env: opts.env } : {}) }, (err, stdout, stderr) => {
55
58
  if (err) reject(Object.assign(err, { stdout: String(stdout ?? ""), stderr: String(stderr ?? "") }));
56
59
  else resolve({ stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
57
60
  });
@@ -59,22 +62,8 @@ export function reaperExec({ execFileFn = execFile, timeoutMs = REAPER_STEP_TIME
59
62
  }
60
63
  const execReaperBounded = reaperExec();
61
64
 
62
- /**
63
- * `pi-job-` -- the container-name namespace, and a LOAD-BEARING string rather than a prefix chosen for
64
- * readability. TWO sweeps match it as a SUBSTRING, both at boot in `start.mjs`: the container reaper's
65
- * `docker ps` filter and the network reaper's `docker network ls` filter. They share the NAME rule
66
- * (`isJobNamespace`) and deliberately not the STATE question -- see `reapNetwork` for why one lists running
67
- * containers and the other asks about members in any state. The sandbox tooling is the
68
- * counterpart rather than a third sweep -- it names itself `pi-sandbox-` precisely to stay OUTSIDE this
69
- * namespace, so a worker restart cannot tear down the shell an operator is sitting in, and it reaps its own
70
- * by job id rather than by name.
71
- *
72
- * Exported and imported rather than re-typed at each site for the reason `CONTAINER_GLOBAL_PI_DIR` is: the
73
- * namespace and the filters that sweep it are ONE fact, and two literals in two modules is how a rename
74
- * lands in the producer and not in the reaper, leaving every crashed worker's containers behind forever with
75
- * both test suites green.
76
- */
77
- export const JOB_NAME_PREFIX = "pi-job-";
65
+ // Naming is a leaf so operator guidance uses the runtime's exact rule without loading its implementation.
66
+ export { JOB_NAME_PREFIX, jobContainerName } from "./container-names.mjs";
78
67
 
79
68
  /**
80
69
  * The container states `docker network inspect` lists in `.Containers`, and therefore the ones the daemon
@@ -87,23 +76,7 @@ export const JOB_NAME_PREFIX = "pi-job-";
87
76
  */
88
77
  export { ENDPOINT_LISTED_STATES };
89
78
 
90
- /**
91
- * `pi-job-<jobId>`. The name a running job answers to, for `docker stop` on the 30-minute timeout, for the
92
- * per-job egress network derived from it, and for the reaper's filter.
93
- *
94
- * Sanitised to the runtimes' own name rule, `[a-zA-Z0-9][a-zA-Z0-9_.-]*` (docker's `RestrictedNameChars`, and
95
- * Podman's, measured), every other character becoming `_`, as `sanitizeJobId` does for file names. It said here that
96
- * BullMQ ids are already that shape, and a cron job's is not: the job scheduler mints `repeat:<schedulerId>:<millis>`,
97
- * so `pi-job-repeat:...` was refused at create (exit 125, measured on Podman 5.8.1) and so was its `-net` network, and
98
- * every cron job ended `container-never-started` before it ever ran (issue #435). The prefix supplies the first
99
- * character. Deterministic, because every path that must find the container again (the timeout's stop, the cancel,
100
- * the network, the log sink) asks this function for the name rather than deriving it. Not injective: `repeat:a:1`
101
- * and a literal `repeat_a_1` share a name. BullMQ ids are unique per queue and no producer here mints an id with `_`
102
- * where a scheduler id has `:`, so it is a residual rather than a collision anything reaches.
103
- */
104
- export function jobContainerName(jobId) {
105
- return `${JOB_NAME_PREFIX}${String(jobId).replace(/[^A-Za-z0-9._-]/g, "_")}`;
106
- }
79
+
107
80
 
108
81
  /**
109
82
  * Bundle the local backend's already-built functions into one checked value.
@@ -139,6 +112,19 @@ export const BACKEND_PROVIDED = ["name", "declares", "namePrefix", "containerNam
139
112
  */
140
113
  export const BACKEND_VALUES = ["neverStartedExits"];
141
114
 
115
+ /**
116
+ * Functions a bundle carries that `makeLocalBackend` builds ITSELF unless the caller passes its own (issue #645): the
117
+ * freezer verbs. Optional to pass rather than required like `BACKEND_FUNCTIONS`, because every caller of this factory
118
+ * (start.mjs and the tests) predates them and the local adapter knows how to spell them for docker; a test seam is
119
+ * the only reason to pass one. Still a NAMED list, so a misspelt `pasue` is refused as an unknown member like any other
120
+ * key rather than silently replaced by the default.
121
+ *
122
+ * Issue #681: the host budget's two venue questions joined them, for the same reason. `listJobContainers` lists this
123
+ * venue's leftover job containers for the boot seed and `containerGone` settles an orphan's hold; both are docker's
124
+ * here, and a bundle carries its own so the budget never asks one runtime about another's container.
125
+ */
126
+ export const BACKEND_OPTIONAL_FUNCTIONS = ["pause", "unpause", "listJobContainers", "containerGone"];
127
+
142
128
  export function makeLocalBackend(parts = {}) {
143
129
  const { runContainer, imagePreflight, egressPreflight, stopContainer, reap } = parts ?? {};
144
130
  const missing = Object.entries({ runContainer, imagePreflight, egressPreflight, stopContainer, reap })
@@ -155,9 +141,14 @@ export function makeLocalBackend(parts = {}) {
155
141
  // control again, arriving through a dropped argument.
156
142
  for (const key of Object.keys(parts ?? {})) {
157
143
  if (BACKEND_FUNCTIONS.includes(key) || BACKEND_VALUES.includes(key)) continue;
144
+ if (BACKEND_OPTIONAL_FUNCTIONS.includes(key)) {
145
+ if (typeof parts[key] !== "function") throw new Error(`backend "${DEFAULT_BACKEND}": ${key} must be a function when it is passed`);
146
+ continue;
147
+ }
158
148
  throw new Error(`backend "${DEFAULT_BACKEND}": unknown bundle member ${JSON.stringify(key)} (this factory takes ${BACKEND_FUNCTIONS.join(", ")} and sets ${BACKEND_PROVIDED.join(", ")} itself)`);
159
149
  }
160
150
 
151
+ const verbs = makePauseVerbs({ bin: "docker" });
161
152
  return {
162
153
  name: DEFAULT_BACKEND,
163
154
  // The declaration is READ from the table, never re-typed here. An adapter that stated its own
@@ -175,6 +166,14 @@ export function makeLocalBackend(parts = {}) {
175
166
  egressPreflight,
176
167
  stopContainer,
177
168
  reap,
169
+ // The freezer verbs (issue #645), `pausable` in the table. Never throw; `{ ok: false, reason }` is the caller's cue to
170
+ // carry on without a frozen read.
171
+ pause: parts.pause ?? verbs.pause,
172
+ unpause: parts.unpause ?? verbs.unpause,
173
+ // The host budget's two questions about THIS venue's containers (issue #681), asked of docker because docker holds
174
+ // them. Before, the wiring derived the CLI from the job's venue name and sent every venue but podman to docker.
175
+ listJobContainers: parts.listJobContainers ?? makeJobContainerLister({ bin: "docker" }),
176
+ containerGone: parts.containerGone ?? makeContainerGone({ binOf: () => "docker" }),
178
177
  // The integers this runtime uses for "the runner never ran". The processor asks the BACKEND rather
179
178
  // than assuming docker's triple, because those numbers collide with the runner's own exit channel.
180
179
  neverStartedExits: LOCAL_NEVER_STARTED_EXITS,
@@ -218,6 +217,76 @@ export function makeStopContainer({ exec = execDocker, bin = "docker" } = {}) {
218
217
  };
219
218
  }
220
219
 
220
+ /**
221
+ * The bound on one `pause` or `unpause` (issue #645). 15 s, not `execDockerBounded`'s 5 s default: a freeze waits for
222
+ * every task in the container's cgroup to reach the frozen state, and a container mid-`fork` storm or in
223
+ * uninterruptible I/O takes visibly longer than a CLI round trip, while a caller holding a job's checkpoint cannot wait
224
+ * on a wedged daemon forever. Past it the verb answers `{ ok: false, reason: "timeout" }` and the child is SIGKILLed.
225
+ */
226
+ export const PAUSE_TIMEOUT_MS = 15_000;
227
+
228
+ /**
229
+ * A runtime's container name, which is also what keeps the argument from reading as a FLAG: `docker pause --help`
230
+ * exits 0 and freezes nothing, so a name beginning with `-` would come back as a successful pause of no container.
231
+ * `jobContainerName` only mints names of this shape (the runtimes' own rule), so refusing anything else costs no job.
232
+ */
233
+ const RUNTIME_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
234
+
235
+ /** Whether `name` is a runtime container name the freezer verbs accept. Exported so a venue's own gate checks it FIRST. */
236
+ export function isRuntimeName(name) {
237
+ return typeof name === "string" && RUNTIME_NAME.test(name);
238
+ }
239
+
240
+ /**
241
+ * `{ pause(name), unpause(name) }` for the venue's CLI (`docker pause` / `podman pause` and their `unpause`), each
242
+ * resolving `{ ok: true }` or `{ ok: false, reason }` and NEVER rejecting (issue #645, the `pausable` row).
243
+ *
244
+ * WHAT IT IS FOR. Pausing uses the cgroup freezer: every process in the container stops where it stands, nothing is
245
+ * killed, and the bind-mounted workspace holds still, so the host can read it as one consistent state while nothing in
246
+ * the container runs. That is the whole of it; a frozen container is granted nothing and its mounts do not change.
247
+ *
248
+ * NEVER THROWS, because its caller is a best-effort read beside a running job: a freeze that did not take means the
249
+ * read is taken live or skipped, never that the job fails. A failure is a fixed token (`timeout`, `exit-<n>`,
250
+ * `<bin>-not-found`, `spawn-failed`, `invalid-name`) and never the CLI's stderr, which can repeat a `DOCKER_HOST` with
251
+ * credentials in it (issue #339's reason for `execDockerBounded` not reading it by default).
252
+ *
253
+ * BOUNDED TWICE ON PURPOSE. `execDockerBounded` (the default `run`) settles on its own timer and kills the child, which
254
+ * is the bound in production; the race below is the same bound for an injected `run`, so "never hangs" is a property of
255
+ * the verb rather than of whichever seam a caller passed. `docker stop` is not bounded here, which is why this uses
256
+ * `execDockerBounded` and not `makeStopContainer`'s `exec`: the abort path does not await its stop and this caller does.
257
+ */
258
+ export function makePauseVerbs({ bin = "docker", timeoutMs = PAUSE_TIMEOUT_MS, run = (cmd, args, opts) => execDockerBounded(args, { bin: cmd, timeoutMs: opts.timeoutMs }) } = {}) {
259
+ const verb = (word) =>
260
+ async function pauseVerb(name) {
261
+ if (!isRuntimeName(name)) return { ok: false, reason: "invalid-name" };
262
+ let timer = null;
263
+ const expired = new Promise((resolve) => {
264
+ timer = setTimeout(() => resolve({ code: null, error: { timedOut: true } }), timeoutMs);
265
+ });
266
+ let result;
267
+ try {
268
+ result = await Promise.race([Promise.resolve().then(() => run(bin, [word, name], { timeoutMs })), expired]);
269
+ } catch (err) {
270
+ result = { code: null, error: err };
271
+ } finally {
272
+ clearTimeout(timer);
273
+ }
274
+ if (!result?.error && result?.code === 0) return { ok: true };
275
+ return { ok: false, reason: pauseFailure(bin, result) };
276
+ };
277
+ return { pause: verb("pause"), unpause: verb("unpause") };
278
+ }
279
+
280
+ /** The fixed token for a verb that did not take; values Node supplies only, never CLI text. */
281
+ function pauseFailure(bin, result) {
282
+ const error = result?.error ?? null;
283
+ if (error?.timedOut || error?.killed) return "timeout";
284
+ if (error?.code === "ENOENT") return `${bin}-not-found`;
285
+ if (typeof result?.code === "number") return `exit-${result.code}`;
286
+ if (typeof error?.code === "number") return `exit-${error.code}`;
287
+ return "spawn-failed";
288
+ }
289
+
221
290
  /**
222
291
  * What the CLI resolved, as a phrase that is never EMPTY. Four call sites interpolate an endpoint into a
223
292
  * sentence of the form "resolves <this>, which is not shown to be on this host", and a context can carry no