@edgehero/pi-dispatch 4.3.0 → 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 (257) hide show
  1. package/.env.example +94 -4
  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 +199 -30
  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 +1663 -198
  74. package/src/dollar-budget.mjs +212 -3
  75. package/src/endpoint-status.mjs +100 -0
  76. package/src/env-allowlist.mjs +103 -9
  77. package/src/env-file.mjs +6 -4
  78. package/src/file-expect.mjs +47 -0
  79. package/src/forgejo-auth.mjs +4 -0
  80. package/src/forgejo-host.mjs +127 -31
  81. package/src/forgejo-prompt.mjs +5 -4
  82. package/src/forges.mjs +73 -15
  83. package/src/fork-job.mjs +58 -0
  84. package/src/fork-policy.mjs +23 -0
  85. package/src/fork-store.mjs +338 -0
  86. package/src/get-token.mjs +1 -1
  87. package/src/git-dirty.mjs +119 -14
  88. package/src/git-hardening.mjs +85 -0
  89. package/src/github-host.mjs +31 -6
  90. package/src/github-prompt.mjs +7 -16
  91. package/src/gitlab-host.mjs +42 -3
  92. package/src/gitlab-prompt.mjs +5 -4
  93. package/src/held-document.mjs +23 -0
  94. package/src/held-replies.mjs +325 -0
  95. package/src/held-settle.mjs +266 -0
  96. package/src/hook-runs.mjs +188 -0
  97. package/src/host-budget.mjs +8 -5
  98. package/src/host-clone.mjs +72 -0
  99. package/src/host-loads.mjs +557 -0
  100. package/src/host-registry.mjs +66 -6
  101. package/src/host-telemetry.mjs +92 -0
  102. package/src/http-body.mjs +78 -0
  103. package/src/image-preflight.mjs +156 -13
  104. package/src/import-pi.mjs +60 -172
  105. package/src/inbox-relay.mjs +482 -0
  106. package/src/inbox-settle.mjs +196 -0
  107. package/src/index.mjs +740 -129
  108. package/src/init.mjs +35 -5
  109. package/src/job-data-version.mjs +75 -0
  110. package/src/job-id.mjs +30 -15
  111. package/src/job-layout.mjs +22 -0
  112. package/src/job-size.mjs +16 -0
  113. package/src/live-jobs.mjs +14 -6
  114. package/src/live-probes.mjs +6 -2
  115. package/src/live-transcript.mjs +872 -0
  116. package/src/loaded-record.mjs +609 -0
  117. package/src/mcp-config.mjs +1 -1
  118. package/src/mcp-preflight.mjs +36 -5
  119. package/src/model-attempts.mjs +84 -0
  120. package/src/model-catalog.mjs +83 -0
  121. package/src/model-cooldowns.mjs +22 -0
  122. package/src/model-failover-protocol.mjs +11 -0
  123. package/src/model-failover.mjs +134 -0
  124. package/src/model-observation.mjs +50 -0
  125. package/src/model-proposal-protocol.mjs +21 -0
  126. package/src/model-proposals.mjs +75 -0
  127. package/src/model-ref.mjs +67 -10
  128. package/src/model-roles-file.mjs +24 -0
  129. package/src/model-roles.mjs +73 -0
  130. package/src/model-status.mjs +34 -0
  131. package/src/models-json.mjs +4 -1
  132. package/src/money.mjs +1 -1
  133. package/src/oauth-openai.mjs +121 -0
  134. package/src/older-state.mjs +249 -0
  135. package/src/on-failure.mjs +17 -13
  136. package/src/open-browser.mjs +31 -2
  137. package/src/opener-cli.mjs +135 -0
  138. package/src/opener-protocol.mjs +27 -0
  139. package/src/opener-terminal.mjs +128 -0
  140. package/src/openshell-cli.mjs +155 -0
  141. package/src/openshell-policy.mjs +163 -0
  142. package/src/outbox.mjs +14 -0
  143. package/src/output-cap.mjs +1 -0
  144. package/src/package-canary-record.mjs +26 -0
  145. package/src/package-canary.mjs +135 -0
  146. package/src/package-names.mjs +9 -0
  147. package/src/package-stage.mjs +418 -0
  148. package/src/packages.mjs +24 -16
  149. package/src/parked.mjs +2 -1
  150. package/src/pause-windows.mjs +136 -17
  151. package/src/podman-stack.mjs +40 -14
  152. package/src/portfolio-snapshot.mjs +26 -7
  153. package/src/prepare-github.mjs +41 -77
  154. package/src/prepare-local.mjs +32 -2
  155. package/src/prepare.mjs +45 -11
  156. package/src/private-store-boundary.mjs +70 -0
  157. package/src/processor.mjs +1554 -167
  158. package/src/projects.mjs +23 -0
  159. package/src/prompt-fence.mjs +18 -0
  160. package/src/provider-key.mjs +7 -0
  161. package/src/provider-steering.mjs +1 -0
  162. package/src/question-context.mjs +35 -0
  163. package/src/question-group-runtime.mjs +91 -0
  164. package/src/question-groups.mjs +171 -0
  165. package/src/queue.mjs +296 -55
  166. package/src/reason-catalog.mjs +448 -0
  167. package/src/receiver-status.mjs +230 -0
  168. package/src/record-reason.mjs +37 -0
  169. package/src/recovery-queue.mjs +566 -0
  170. package/src/redact-secrets.mjs +219 -0
  171. package/src/reserve-estimate.mjs +101 -0
  172. package/src/reserved-env.mjs +36 -1
  173. package/src/retention.mjs +67 -0
  174. package/src/run-budget-ledger.mjs +283 -0
  175. package/src/run-checkpoint.mjs +627 -0
  176. package/src/run-container.mjs +749 -33
  177. package/src/run-history.mjs +791 -45
  178. package/src/run-mirror.mjs +75 -7
  179. package/src/run-progress.mjs +751 -0
  180. package/src/runtime-observations.mjs +3 -1
  181. package/src/runtime-settings.mjs +272 -37
  182. package/src/sandbox-cli.mjs +78 -2
  183. package/src/sandbox-deployment.mjs +51 -0
  184. package/src/sandbox-gateway-protocol.mjs +378 -0
  185. package/src/sandbox-hosts.mjs +100 -0
  186. package/src/sandbox-inventory.mjs +83 -0
  187. package/src/sandbox-policy.mjs +225 -0
  188. package/src/sandbox-prepare-requests.mjs +145 -0
  189. package/src/sandbox-prepare.mjs +285 -0
  190. package/src/sandbox-refs.mjs +70 -0
  191. package/src/sandbox-session-monitor.mjs +4 -0
  192. package/src/sandbox-session-watch.mjs +124 -0
  193. package/src/sandbox-sessions.mjs +161 -0
  194. package/src/sandbox-store.mjs +63 -6
  195. package/src/sandbox-workspace-guard.mjs +39 -0
  196. package/src/sandbox.mjs +27 -200
  197. package/src/scheduler-stall-guard.mjs +14 -8
  198. package/src/schedules.mjs +19 -6
  199. package/src/secret-bundles.mjs +245 -0
  200. package/src/secret-profiles.mjs +103 -3
  201. package/src/secrets.mjs +19 -9
  202. package/src/service-env.mjs +38 -5
  203. package/src/service.mjs +638 -106
  204. package/src/session-key.mjs +60 -0
  205. package/src/session-store.mjs +168 -10
  206. package/src/setup-contract.mjs +40 -0
  207. package/src/setup-egress.mjs +113 -0
  208. package/src/setup-host-entry.mjs +33 -0
  209. package/src/setup-host.mjs +242 -0
  210. package/src/setup-lock.mjs +41 -0
  211. package/src/size-records.mjs +15 -3
  212. package/src/start.mjs +1353 -135
  213. package/src/status-comment.mjs +415 -0
  214. package/src/stderr-transcript.mjs +318 -0
  215. package/src/steer-ack.mjs +122 -0
  216. package/src/steer-cli.mjs +213 -0
  217. package/src/steer-framing.mjs +185 -0
  218. package/src/steer-inbox.mjs +693 -0
  219. package/src/store-version.mjs +139 -0
  220. package/src/subscription-config.mjs +56 -0
  221. package/src/subscription-limits.mjs +3 -0
  222. package/src/subscription-quota.mjs +259 -0
  223. package/src/subscription-runtime.mjs +123 -0
  224. package/src/subscription-status.mjs +42 -0
  225. package/src/subscription-wait.mjs +11 -0
  226. package/src/subscriptions.mjs +7 -1
  227. package/src/thinking-level.mjs +19 -0
  228. package/src/touch-outbox.mjs +61 -0
  229. package/src/touch-state.mjs +133 -0
  230. package/src/triggers-file.mjs +299 -39
  231. package/src/triggers.mjs +782 -40
  232. package/src/trust-paths.mjs +171 -0
  233. package/src/up.mjs +398 -51
  234. package/src/updates-cli.mjs +61 -0
  235. package/src/updates-look.mjs +127 -0
  236. package/src/updates-reconcile.mjs +182 -0
  237. package/src/updates-subscription-adapters.mjs +8 -0
  238. package/src/updates.mjs +252 -0
  239. package/src/valkey-auth.mjs +32 -9
  240. package/src/valkey-endpoint.mjs +14 -4
  241. package/src/wait-for.mjs +1 -1
  242. package/src/wait-state.mjs +62 -3
  243. package/src/watch-board.mjs +38 -0
  244. package/src/watch-closer.mjs +1 -1
  245. package/src/watch-contract.mjs +102 -0
  246. package/src/watch-decision.mjs +78 -0
  247. package/src/watch-drainer.mjs +14 -0
  248. package/src/watch-issue-adapter.mjs +44 -0
  249. package/src/watch-issue.mjs +111 -0
  250. package/src/watch-record.mjs +10 -0
  251. package/src/watch-runtime.mjs +167 -0
  252. package/src/watch-store.mjs +183 -0
  253. package/src/watch-trace.mjs +34 -0
  254. package/src/workspace-checkpoint.mjs +1750 -0
  255. package/src/zero-touch-fixes.mjs +99 -0
  256. package/src/zero-touch.mjs +225 -0
  257. package/src/repeat-slot.mjs +0 -13
@@ -30,7 +30,7 @@
30
30
 
31
31
  import { readdirSync, readFileSync, statSync } from "node:fs";
32
32
  import { homedir } from "node:os";
33
- import { JOB_NAME_PREFIX, execDockerBounded, jobContainerName, makeReaper, makeStopContainer } from "./backend-local.mjs";
33
+ import { JOB_NAME_PREFIX, execDockerBounded, isRuntimeName, jobContainerName, makeContainerGone, makeJobContainerLister, makePauseVerbs, makeReaper, makeStopContainer } from "./backend-local.mjs";
34
34
  import { BACKENDS, PODMAN_ADDS_NO_MOUNTS, PODMAN_BACKEND, PODMAN_CONF_WIDENS_JOB, PODMAN_NETWORK_HELPER_KEYS, PODMAN_WIDENING_KEYS, PODMAN_BOUNDS_DELEGATED, PODMAN_SERVICE_LOCAL, observationRefusalIsTransient, observationRefusals, unobservedFloor } from "./backends.mjs";
35
35
  import { CONTAINER_HOME, SHIPPED_IMAGE_UID } from "./container-spec.mjs";
36
36
  import { buildPodmanRunArgs } from "./docker-run.mjs";
@@ -981,9 +981,15 @@ export const PODMAN_BACKEND_OPTIONS = Object.freeze([
981
981
  "authFromPi",
982
982
  "forgeHosts",
983
983
  "onOutput",
984
+ "openLiveTranscript",
985
+ // issue #646: the inbox relay's opener, as start.mjs hands local's
986
+ "openInboxRelay",
984
987
  // the floor the per-job observation preflight judges, and the boot-built reaper
985
988
  "backendFloor",
986
989
  "reap",
990
+ // issue #681: the host budget's leftover listing and gone-check, as start.mjs hands them (its listing seam)
991
+ "listJobContainers",
992
+ "containerGone",
987
993
  // the facts, the identity and the files the observations and the job user come from
988
994
  "readInfo",
989
995
  "platform",
@@ -1002,6 +1008,33 @@ export const PODMAN_BACKEND_OPTIONS = Object.freeze([
1002
1008
  "makeStopContainer",
1003
1009
  ]);
1004
1010
 
1011
+ /**
1012
+ * Whether this venue can FREEZE a job (issue #645, the `pausable` row), from a `podman info` read: `{ pausable, reason }`,
1013
+ * with `pausable` `true`, `false`, or `null` when the read did not answer. Rootless Podman cannot pause on cgroup v1
1014
+ * (`podman pause` refuses it there: the v1 freezer is a hierarchy the user is never delegated), and every podman venue
1015
+ * is rootless because a rootful one is refused, so the cgroup version decides it. The version comes from the SAME read
1016
+ * the venue admits jobs on and `podmanBoundsDelegated` judges, so the freeze and the bounds cannot disagree about which
1017
+ * version this host runs. A version `podman info` did not give is not v1: the CLI is then left to answer for itself,
1018
+ * which it does with a non-zero exit the verb returns as `{ ok: false }`. The bundle's verbs judge the KEPT read only, so
1019
+ * an unanswered or not-yet-taken read (`pausable: null`) also leaves the CLI to answer.
1020
+ */
1021
+ export function podmanPausable(read) {
1022
+ if (read?.answered !== true || !read.info) return { pausable: null, reason: read?.reason ?? "unanswered" };
1023
+ if (read.info.cgroupVersion === "v1") return { pausable: false, reason: "cgroup-v1-rootless" };
1024
+ return { pausable: true, reason: null };
1025
+ }
1026
+
1027
+ /** An execFile-shaped runner (resolves on exit 0, rejects with `.code` otherwise) as `execDockerBounded`'s `{ code, error }`. */
1028
+ function settledRun(execFn) {
1029
+ return (cmd, args) =>
1030
+ Promise.resolve()
1031
+ .then(() => execFn(cmd, args))
1032
+ .then(
1033
+ () => ({ code: 0, error: null }),
1034
+ (err) => ({ code: typeof err?.code === "number" ? err.code : null, error: err ?? { code: null } }),
1035
+ );
1036
+ }
1037
+
1005
1038
  /** The keeper read's bound: one `podman inspect`, on a pre-spend gate, like the proxy's own read beside it. */
1006
1039
  export const NETNS_KEEPER_READ_TIMEOUT_MS = 10_000;
1007
1040
 
@@ -1061,7 +1094,7 @@ export function keeperPreflight(proxyPreflight, { armed, proxy, info, spawnFn =
1061
1094
 
1062
1095
  /**
1063
1096
  * The podman bundle: `{ name, declares, neverStartedExits, containerName, namePrefix, binds, runContainer,
1064
- * imagePreflight, egressPreflight, stopContainer, reap, jobUserPreflight, observationPreflight }`.
1097
+ * imagePreflight, egressPreflight, stopContainer, pause, unpause, reap, jobUserPreflight, observationPreflight }`.
1065
1098
  *
1066
1099
  * BUILT here from PR #425's factories with `bin: "podman"` (and `buildPodmanRunArgs` for the run), rather than taken
1067
1100
  * built as `makeLocalBackend` does, because the pairing IS the adapter: a run under one binary and a stop, a preflight or
@@ -1087,8 +1120,12 @@ export function makePodmanBackend(opts = {}) {
1087
1120
  authFromPi = false,
1088
1121
  forgeHosts = {},
1089
1122
  onOutput,
1123
+ openLiveTranscript = null,
1124
+ openInboxRelay = null,
1090
1125
  backendFloor = {},
1091
1126
  reap,
1127
+ listJobContainers,
1128
+ containerGone,
1092
1129
  readInfo = makePodmanInfoReader(),
1093
1130
  platform = process.platform,
1094
1131
  euid = process.geteuid?.(),
@@ -1105,9 +1142,25 @@ export function makePodmanBackend(opts = {}) {
1105
1142
  makeStopContainer: makeStopContainerFn = makeStopContainer,
1106
1143
  } = opts ?? {};
1107
1144
  if (reap !== undefined && typeof reap !== "function") throw new Error(`backend "${PODMAN_BACKEND}": reap must be a function (makePodmanReaper)`);
1145
+ for (const [key, fn] of Object.entries({ listJobContainers, containerGone })) {
1146
+ if (fn !== undefined && typeof fn !== "function") throw new Error(`backend "${PODMAN_BACKEND}": ${key} must be a function when it is passed`);
1147
+ }
1108
1148
  const spawnSeam = spawnFn ? { spawnFn } : {};
1109
1149
  const execSeam = exec ? { exec } : spawnFn ? { exec: execViaSpawn(spawnFn) } : {};
1110
1150
  const info = cachedPodmanInfo(readInfo, { log });
1151
+ // The freezer verbs (issue #645) under the venue's own binary, through the same exec seam as the stop, so a test's fake
1152
+ // child sees every podman spawn. In production the bounded runner; `makePauseVerbs` bounds an injected one too.
1153
+ const verbs = makePauseVerbs({ bin: "podman", ...(execSeam.exec ? { run: settledRun(execSeam.exec) } : {}) });
1154
+ // The name first, then cgroup v1, both BEFORE any spawn. The version comes from the KEPT answered `podman info`
1155
+ // (`peek`), never a fresh read: a read here would be a second spawn with a bound of its own beside the verb's 15 s,
1156
+ // and every job is admitted on an answered read (`observationPreflight`), so a running job always has one kept.
1157
+ // With none kept (no job admitted yet) the verb runs and `podman pause`'s own refusal comes back as `{ ok: false }`.
1158
+ const gated = (fn) => async (name) => {
1159
+ if (!isRuntimeName(name)) return { ok: false, reason: "invalid-name" };
1160
+ const can = podmanPausable(info.peek?.() ?? null);
1161
+ if (can.pausable === false) return { ok: false, reason: can.reason };
1162
+ return fn(name);
1163
+ };
1111
1164
 
1112
1165
  const runContainer = makeRunContainerFn({
1113
1166
  image,
@@ -1116,6 +1169,8 @@ export function makePodmanBackend(opts = {}) {
1116
1169
  egressProxy,
1117
1170
  ...(openJobLog ? { openJobLog } : {}),
1118
1171
  ...(onOutput ? { onOutput } : {}),
1172
+ ...(openLiveTranscript ? { openLiveTranscript } : {}),
1173
+ ...(openInboxRelay ? { openInboxRelay } : {}),
1119
1174
  globalPiDir,
1120
1175
  allowGlobalExtensions,
1121
1176
  packagePaths,
@@ -1192,7 +1247,13 @@ export function makePodmanBackend(opts = {}) {
1192
1247
  imagePreflight: makeImagePreflightFn({ image, bin: "podman", ...spawnSeam }),
1193
1248
  egressPreflight: keeperPreflight(makeEgressPreflightFn({ proxy: egressProxy, armed: egress, bin: "podman", ...spawnSeam }), { armed: egress, proxy: egressProxy, info, spawnFn }),
1194
1249
  stopContainer: makeStopContainerFn({ bin: "podman", ...execSeam }),
1250
+ pause: gated(verbs.pause),
1251
+ unpause: gated(verbs.unpause),
1195
1252
  reap: reap ?? makePodmanReaper({ log, ...execSeam }),
1253
+ // Issue #681: this store's leftover job containers and whether one is gone, asked of podman through the same exec
1254
+ // seam as the stop and the reaper. The wiring used to pick the CLI from the venue's name instead.
1255
+ listJobContainers: listJobContainers ?? makeJobContainerLister({ bin: "podman", ...execSeam }),
1256
+ containerGone: containerGone ?? makeContainerGone({ binOf: () => "podman", ...execSeam }),
1196
1257
  jobUserPreflight,
1197
1258
  observationPreflight,
1198
1259
  };
@@ -66,8 +66,19 @@ export const BACKEND_NOT_REGISTERED = "BACKEND_NOT_REGISTERED";
66
66
  export const OPTIONAL_PREFLIGHT_DEFAULTS = Object.freeze({
67
67
  observationPreflight: () => ({ ok: true }),
68
68
  jobUserPreflight: () => ({ user: null, home: null }),
69
+ // Issue #704 part 3: a project secret bundle judged against the venue (`(job, { endpoints, vars, shadowed })`). A
70
+ // venue that carries none admits every bundle, which is what a venue whose container holds its env as values does:
71
+ // the variables join the env, and `endpoints` means nothing there. Only a venue whose own layer holds credentials
72
+ // (both OpenShell venues, which carry one) has a narrower answer.
73
+ bundleGate: () => ({ ok: true }),
69
74
  });
70
75
 
76
+ /** The freezer verbs a bundle MAY carry (issue #645, the `pausable` row), always as a pair. */
77
+ export const PAUSE_VERBS = Object.freeze(["pause", "unpause"]);
78
+
79
+ /** Whether a (validated) bundle carries both freezer verbs. */
80
+ const isPausable = (bundle) => typeof bundle?.pause === "function" && typeof bundle?.unpause === "function";
81
+
71
82
  /** The venue's own member when it carries one, else the absent answer, as a promise either way. */
72
83
  async function optionalPreflight(bundle, fn, ...args) {
73
84
  return typeof bundle[fn] === "function" ? bundle[fn](...args) : OPTIONAL_PREFLIGHT_DEFAULTS[fn]();
@@ -131,6 +142,9 @@ export function resolveBackendName(data, defaultName) {
131
142
  * @param bundles the blessed backend bundles (each from a `make<Name>Backend`), keyed by their own `name`
132
143
  * @param defaultName the venue a job that names none runs in -- `PI_BACKENDS[0]`
133
144
  */
145
+ /** The functions every registered bundle must carry, checked at boot. */
146
+ export const REQUIRED_FUNCTIONS = Object.freeze(["runContainer", "imagePreflight", "egressPreflight", "stopContainer", "reap", "containerName", "listJobContainers", "containerGone"]);
147
+
134
148
  export function makeBackendRegistry({ bundles = [], defaultName, blessed = null, reaps = null } = {}) {
135
149
  const byName = new Map();
136
150
  for (const bundle of bundles) {
@@ -144,7 +158,10 @@ export function makeBackendRegistry({ bundles = [], defaultName, blessed = null,
144
158
  // `containerName` is in this list because THIS MODULE calls it: `registry.containerName(job)` builds
145
159
  // the name the abort then stops. Omitting it built fine and threw at the first pickup, which is the
146
160
  // property this constructor claims to have and did not.
147
- for (const fn of ["runContainer", "imagePreflight", "egressPreflight", "stopContainer", "reap", "containerName"]) {
161
+ // `listJobContainers` and `containerGone` (issue #681) because the host budget asks every venue both: its leftover
162
+ // containers at boot, and whether an orphan's container is gone before its hold is freed. Without them the wiring
163
+ // guessed the runtime from the venue's name, and every venue but podman was asked through docker.
164
+ for (const fn of REQUIRED_FUNCTIONS) {
148
165
  if (typeof bundle[fn] !== "function") throw new Error(`backend registry: ${JSON.stringify(bundle.name)} has no ${fn}()`);
149
166
  }
150
167
  if (!Array.isArray(bundle.neverStartedExits) || !bundle.neverStartedExits.every(Number.isInteger)) {
@@ -159,6 +176,13 @@ export function makeBackendRegistry({ bundles = [], defaultName, blessed = null,
159
176
  for (const fn of Object.keys(OPTIONAL_PREFLIGHT_DEFAULTS)) {
160
177
  if (bundle[fn] !== undefined && typeof bundle[fn] !== "function") throw new Error(`backend registry: ${JSON.stringify(bundle.name)} has a ${fn} that is not a function`);
161
178
  }
179
+ // The freezer verbs (issue #645) are optional too, but only as a PAIR: a venue that could pause and not unpause
180
+ // would freeze a paid job for good at its first live checkpoint. Refused at boot, like a typo'd preflight.
181
+ const verbs = PAUSE_VERBS.filter((fn) => bundle[fn] !== undefined);
182
+ for (const fn of verbs) {
183
+ if (typeof bundle[fn] !== "function") throw new Error(`backend registry: ${JSON.stringify(bundle.name)} has a ${fn} that is not a function`);
184
+ }
185
+ if (verbs.length === 1) throw new Error(`backend registry: ${JSON.stringify(bundle.name)} carries ${verbs[0]} without its pair (pause and unpause come together, or neither)`);
162
186
  byName.set(bundle.name, bundle);
163
187
  }
164
188
  if (byName.size === 0) throw new Error("backend registry: at least one backend must be registered");
@@ -215,15 +239,38 @@ export function makeBackendRegistry({ bundles = [], defaultName, blessed = null,
215
239
  * the abort path had to change: the container's NAME is not enough to find the runtime that holds
216
240
  * it once there is more than one, and `index.mjs` had only the name.
217
241
  */
218
- runContainer: (args) => backendFor(args?.job).runContainer(args),
242
+ // The venue's freezer verbs ride on the call bag (issue #645), from the SAME bundle that runs the container, so a
243
+ // live checkpoint can never freeze a job through another venue's CLI. A venue without them gets none, and its
244
+ // container gets no live checkpoints; the call bag is then exactly the caller's.
245
+ // Issue #646: a steer job is also told whether its venue BINDS mounts (the bundle's own `binds`), which runContainer
246
+ // arms steering on: a copying venue would upload the inbox once and never see a later message. Only for a steer job,
247
+ // so every other call bag stays exactly the caller's (plus the verbs above).
248
+ runContainer: (args) => {
249
+ const bundle = backendFor(args?.job);
250
+ const withVerbs = isPausable(bundle) ? { ...args, pause: bundle.pause, unpause: bundle.unpause } : args;
251
+ return bundle.runContainer(args?.job?.steer === true ? { ...withVerbs, binds: bundle.binds === true } : withVerbs);
252
+ },
219
253
  imagePreflight: (job) => backendFor(job).imagePreflight(job),
220
254
  egressPreflight: (job) => backendFor(job).egressPreflight(job),
221
- // The two OPTIONAL per-venue preflights (issue #354), dispatched through the same resolution as every
222
- // required one, so an unregistered name still throws BACKEND_NOT_REGISTERED rather than being waved
255
+ // The OPTIONAL per-venue preflights (issue #354; `bundleGate`, #704 part 3), dispatched through the same
256
+ // resolution as every required one, so an unregistered name still throws BACKEND_NOT_REGISTERED rather than being waved
223
257
  // through by a default. A venue that carries none gets the answer the processor's own defaults give.
224
258
  observationPreflight: (job) => optionalPreflight(backendFor(job), "observationPreflight", job),
225
259
  jobUserPreflight: (job, opts) => optionalPreflight(backendFor(job), "jobUserPreflight", job, opts),
260
+ bundleGate: (job, opts) => optionalPreflight(backendFor(job), "bundleGate", job, opts),
226
261
  stopContainer: (name, job) => backendFor(job).stopContainer(name, job),
262
+ // Issue #645: whether this job's venue can freeze it, and the verbs themselves, dispatched like every per-job
263
+ // function. A venue without verbs answers `{ ok: false, reason: "not-pausable" }`, never a throw: the caller is a
264
+ // best-effort checkpoint beside a running job.
265
+ pausable: (job) => isPausable(backendFor(job)),
266
+ pause: async (name, job) => {
267
+ const bundle = backendFor(job);
268
+ return isPausable(bundle) ? bundle.pause(name) : { ok: false, reason: "not-pausable" };
269
+ },
270
+ unpause: async (name, job) => {
271
+ const bundle = backendFor(job);
272
+ return isPausable(bundle) ? bundle.unpause(name) : { ok: false, reason: "not-pausable" };
273
+ },
227
274
  // ON THE SURFACE, not reached for through `backendFor` by a call site. Both are per-job backend
228
275
  // FACTS rather than functions, and an earlier draft left them off: the wiring rebuilt
229
276
  // `neverStartedExits` at the call site and `index.mjs` imported `jobContainerName` from the local
@@ -231,6 +278,16 @@ export function makeBackendRegistry({ bundles = [], defaultName, blessed = null,
231
278
  // module's header says is structurally impossible -- and the container NAME is the argument the
232
279
  // abort's `stopContainer` receives, so building it locally while resolving the venue per job was the
233
280
  // same defect in the one call the slice exists to make dispatchable.
281
+ // Issue #681: the host budget's gone-check, asked of the venue that holds the container. `venue` is the job's data or
282
+ // `{ backend }`, as the budget keeps it. A venue no bundle answers for is `null` (unknown), never a throw: the
283
+ // caller is the budget's sweep, and `null` keeps the hold, which is the safe answer for room nobody can account for.
284
+ containerGone: async (name, venue) => {
285
+ const bundle = byName.get(resolveBackendName(venue ?? {}, defaultName));
286
+ return bundle ? bundle.containerGone(name) : null;
287
+ },
288
+ // Each registered venue's leftover-container lister, keyed by name, for the budget's boot seed.
289
+ // Called ON the bundle, as `containerGone` is, so an adapter whose lister is a method reading `this` still works.
290
+ listers: () => Object.fromEntries([...byName].map(([name, bundle]) => [name, () => bundle.listJobContainers()])),
234
291
  neverStartedExits: (job) => backendFor(job).neverStartedExits,
235
292
  containerName: (job) => backendFor(job).containerName(job?.id),
236
293
  // The reaper map, validated above against the registered set, so the boot sweep can be handed