agent-hitch 0.2.16 → 0.2.17

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 (361) hide show
  1. package/README.md +7 -28
  2. package/README.zh-CN.md +6 -26
  3. package/dist/scripts/benchmark-dataset-verification.js +118 -0
  4. package/dist/scripts/benchmark-dataset-verification.js.map +1 -0
  5. package/dist/scripts/canary-external-endpoint.js +56 -0
  6. package/dist/scripts/canary-external-endpoint.js.map +1 -0
  7. package/dist/scripts/canary-native-training.js +8 -6
  8. package/dist/scripts/canary-native-training.js.map +1 -1
  9. package/dist/scripts/canary-remote-training.js +8 -6
  10. package/dist/scripts/canary-remote-training.js.map +1 -1
  11. package/dist/scripts/check-architecture.js +1 -1
  12. package/dist/scripts/check-architecture.js.map +1 -1
  13. package/dist/src/adapters/providers/codex.js +5 -2
  14. package/dist/src/adapters/providers/codex.js.map +1 -1
  15. package/dist/src/adapters/providers/model-call.js +1 -2
  16. package/dist/src/adapters/providers/model-call.js.map +1 -1
  17. package/dist/src/adapters/providers/training-tool.js +2 -0
  18. package/dist/src/adapters/providers/training-tool.js.map +1 -1
  19. package/dist/src/backends/harbor/artifact-pins.js +42 -0
  20. package/dist/src/backends/harbor/artifact-pins.js.map +1 -0
  21. package/dist/src/backends/harbor/backend.js +16 -8
  22. package/dist/src/backends/harbor/backend.js.map +1 -1
  23. package/dist/src/backends/harbor/bound-credential-helper.js +24 -0
  24. package/dist/src/backends/harbor/bound-credential-helper.js.map +1 -0
  25. package/dist/src/backends/harbor/credential-snapshot.js +71 -0
  26. package/dist/src/backends/harbor/credential-snapshot.js.map +1 -0
  27. package/dist/src/backends/harbor/environment-config.js +25 -2
  28. package/dist/src/backends/harbor/environment-config.js.map +1 -1
  29. package/dist/src/backends/harbor/environment-files.js +143 -0
  30. package/dist/src/backends/harbor/environment-files.js.map +1 -0
  31. package/dist/src/backends/harbor/evaluation-binding.js +160 -0
  32. package/dist/src/backends/harbor/evaluation-binding.js.map +1 -0
  33. package/dist/src/backends/harbor/host-credential-helper.js +5 -0
  34. package/dist/src/backends/harbor/host-credential-helper.js.map +1 -1
  35. package/dist/src/backends/harbor/index.js +2 -0
  36. package/dist/src/backends/harbor/index.js.map +1 -1
  37. package/dist/src/backends/harbor/model-proxy-config.js +3 -18
  38. package/dist/src/backends/harbor/model-proxy-config.js.map +1 -1
  39. package/dist/src/backends/harbor/portable-regrade.js +15 -3
  40. package/dist/src/backends/harbor/portable-regrade.js.map +1 -1
  41. package/dist/src/backends/harbor/python-runtime.js +71 -0
  42. package/dist/src/backends/harbor/python-runtime.js.map +1 -0
  43. package/dist/src/backends/harbor/tools.js +6 -4
  44. package/dist/src/backends/harbor/tools.js.map +1 -1
  45. package/dist/src/backends/index.js +3 -0
  46. package/dist/src/backends/index.js.map +1 -1
  47. package/dist/src/cli/arguments.js +30 -30
  48. package/dist/src/cli/arguments.js.map +1 -1
  49. package/dist/src/cli/commands/capabilities.js +7 -3
  50. package/dist/src/cli/commands/capabilities.js.map +1 -1
  51. package/dist/src/cli/commands/daemon.js +0 -17
  52. package/dist/src/cli/commands/daemon.js.map +1 -1
  53. package/dist/src/cli/commands/eval-binding.js +32 -0
  54. package/dist/src/cli/commands/eval-binding.js.map +1 -0
  55. package/dist/src/cli/commands/eval.js +21 -15
  56. package/dist/src/cli/commands/eval.js.map +1 -1
  57. package/dist/src/cli/commands/models.js +32 -45
  58. package/dist/src/cli/commands/models.js.map +1 -1
  59. package/dist/src/cli/commands/run.js +0 -35
  60. package/dist/src/cli/commands/run.js.map +1 -1
  61. package/dist/src/cli/main.js +0 -4
  62. package/dist/src/cli/main.js.map +1 -1
  63. package/dist/src/cli/output.js +5 -16
  64. package/dist/src/cli/output.js.map +1 -1
  65. package/dist/src/control-plane/eval-records.js +3 -1
  66. package/dist/src/control-plane/eval-records.js.map +1 -1
  67. package/dist/src/control-plane/eval-recovery.js +4 -1
  68. package/dist/src/control-plane/eval-recovery.js.map +1 -1
  69. package/dist/src/control-plane/eval-scheduler.js +16 -8
  70. package/dist/src/control-plane/eval-scheduler.js.map +1 -1
  71. package/dist/src/control-plane/index.js +0 -2
  72. package/dist/src/control-plane/index.js.map +1 -1
  73. package/dist/src/control-plane/model-capture-planning.js +3 -4
  74. package/dist/src/control-plane/model-capture-planning.js.map +1 -1
  75. package/dist/src/control-plane/remote-model-routes.js +4 -24
  76. package/dist/src/control-plane/remote-model-routes.js.map +1 -1
  77. package/dist/src/control-plane/remote-offer-contract.js +2 -2
  78. package/dist/src/control-plane/remote-offer-contract.js.map +1 -1
  79. package/dist/src/control-plane/remote-result-transport.js +7 -8
  80. package/dist/src/control-plane/remote-result-transport.js.map +1 -1
  81. package/dist/src/control-plane/remote-work-coordinator.js +2 -2
  82. package/dist/src/control-plane/remote-work-coordinator.js.map +1 -1
  83. package/dist/src/control-plane/remote-work-recovery.js +2 -2
  84. package/dist/src/control-plane/remote-work-recovery.js.map +1 -1
  85. package/dist/src/control-plane/remote-worker-protocol.js +1 -1
  86. package/dist/src/control-plane/remote-worker-protocol.js.map +1 -1
  87. package/dist/src/control-plane/remote-worker-runner.js +21 -6
  88. package/dist/src/control-plane/remote-worker-runner.js.map +1 -1
  89. package/dist/src/control-plane/remote-workers.js +2 -2
  90. package/dist/src/control-plane/remote-workers.js.map +1 -1
  91. package/dist/src/control-plane/rerun-recovery.js +1 -1
  92. package/dist/src/control-plane/rerun-recovery.js.map +1 -1
  93. package/dist/src/control-plane/rerun-scheduler.js +21 -14
  94. package/dist/src/control-plane/rerun-scheduler.js.map +1 -1
  95. package/dist/src/control-plane/rerun-source.js +5 -2
  96. package/dist/src/control-plane/rerun-source.js.map +1 -1
  97. package/dist/src/control-plane/rerun-submission.js +7 -5
  98. package/dist/src/control-plane/rerun-submission.js.map +1 -1
  99. package/dist/src/control-plane/synthetic-result.js +3 -2
  100. package/dist/src/control-plane/synthetic-result.js.map +1 -1
  101. package/dist/src/control-plane/worker-model-relay.js +1 -1
  102. package/dist/src/control-plane/worker-model-relay.js.map +1 -1
  103. package/dist/src/controller-runtime/hash.js +6 -0
  104. package/dist/src/controller-runtime/hash.js.map +1 -1
  105. package/dist/src/daemon/client.js +12 -35
  106. package/dist/src/daemon/client.js.map +1 -1
  107. package/dist/src/daemon/evaluation-binding.js +33 -0
  108. package/dist/src/daemon/evaluation-binding.js.map +1 -0
  109. package/dist/src/daemon/evaluation-submission.js +27 -0
  110. package/dist/src/daemon/evaluation-submission.js.map +1 -0
  111. package/dist/src/daemon/index.js +1 -0
  112. package/dist/src/daemon/index.js.map +1 -1
  113. package/dist/src/daemon/json-stream.js +98 -0
  114. package/dist/src/daemon/json-stream.js.map +1 -0
  115. package/dist/src/daemon/scheduler.js +1 -4
  116. package/dist/src/daemon/scheduler.js.map +1 -1
  117. package/dist/src/daemon/server.js +14 -54
  118. package/dist/src/daemon/server.js.map +1 -1
  119. package/dist/src/domain/endpoints.js +2 -0
  120. package/dist/src/domain/endpoints.js.map +1 -0
  121. package/dist/src/domain/evaluation-binding.js +5 -0
  122. package/dist/src/domain/evaluation-binding.js.map +1 -0
  123. package/dist/src/domain/index.js +2 -0
  124. package/dist/src/domain/index.js.map +1 -1
  125. package/dist/src/domain/runs.js.map +1 -1
  126. package/dist/src/evals/benchmark-adapter-manifest.js +95 -32
  127. package/dist/src/evals/benchmark-adapter-manifest.js.map +1 -1
  128. package/dist/src/evals/benchmark-verification.js +106 -0
  129. package/dist/src/evals/benchmark-verification.js.map +1 -0
  130. package/dist/src/evals/collect-only-rerun.js +3 -2
  131. package/dist/src/evals/collect-only-rerun.js.map +1 -1
  132. package/dist/src/evals/directory.js +9 -1
  133. package/dist/src/evals/directory.js.map +1 -1
  134. package/dist/src/evals/endpoint-attempts.js +112 -0
  135. package/dist/src/evals/endpoint-attempts.js.map +1 -0
  136. package/dist/src/evals/endpoint-usage.js +8 -0
  137. package/dist/src/evals/endpoint-usage.js.map +1 -0
  138. package/dist/src/evals/environment-binding.js +9 -0
  139. package/dist/src/evals/environment-binding.js.map +1 -0
  140. package/dist/src/evals/eval-artifact-planning.js +10 -1
  141. package/dist/src/evals/eval-artifact-planning.js.map +1 -1
  142. package/dist/src/evals/eval-finalization.js +2 -3
  143. package/dist/src/evals/eval-finalization.js.map +1 -1
  144. package/dist/src/evals/eval-logical-plan.js +2 -0
  145. package/dist/src/evals/eval-logical-plan.js.map +1 -1
  146. package/dist/src/evals/eval-result-builder.js +4 -0
  147. package/dist/src/evals/eval-result-builder.js.map +1 -1
  148. package/dist/src/evals/eval-state.js +22 -0
  149. package/dist/src/evals/eval-state.js.map +1 -0
  150. package/dist/src/evals/execution-plan.js +6 -2
  151. package/dist/src/evals/execution-plan.js.map +1 -1
  152. package/dist/src/evals/index.js +5 -1
  153. package/dist/src/evals/index.js.map +1 -1
  154. package/dist/src/evals/infrastructure-retry.js +2 -0
  155. package/dist/src/evals/infrastructure-retry.js.map +1 -1
  156. package/dist/src/evals/model-capture-runtime.js +9 -13
  157. package/dist/src/evals/model-capture-runtime.js.map +1 -1
  158. package/dist/src/evals/native-phase-evidence.js +8 -2
  159. package/dist/src/evals/native-phase-evidence.js.map +1 -1
  160. package/dist/src/evals/planned-execution.js +7 -2
  161. package/dist/src/evals/planned-execution.js.map +1 -1
  162. package/dist/src/evals/planned-progress-publisher.js +18 -5
  163. package/dist/src/evals/planned-progress-publisher.js.map +1 -1
  164. package/dist/src/evals/planned-retry-execution.js +1 -0
  165. package/dist/src/evals/planned-retry-execution.js.map +1 -1
  166. package/dist/src/evals/preparation-rerun.js +0 -1
  167. package/dist/src/evals/preparation-rerun.js.map +1 -1
  168. package/dist/src/evals/progress.js +92 -7
  169. package/dist/src/evals/progress.js.map +1 -1
  170. package/dist/src/evals/records.js +12 -2
  171. package/dist/src/evals/records.js.map +1 -1
  172. package/dist/src/evals/recovery.js +2 -0
  173. package/dist/src/evals/recovery.js.map +1 -1
  174. package/dist/src/evals/remote-rerun-execution.js +1 -3
  175. package/dist/src/evals/remote-rerun-execution.js.map +1 -1
  176. package/dist/src/evals/remote-verifier-rerun.js +1 -1
  177. package/dist/src/evals/remote-verifier-rerun.js.map +1 -1
  178. package/dist/src/evals/request.js +14 -45
  179. package/dist/src/evals/request.js.map +1 -1
  180. package/dist/src/evals/rerun-observation.js +31 -0
  181. package/dist/src/evals/rerun-observation.js.map +1 -0
  182. package/dist/src/evals/rerun.js +23 -28
  183. package/dist/src/evals/rerun.js.map +1 -1
  184. package/dist/src/evals/service.js +45 -42
  185. package/dist/src/evals/service.js.map +1 -1
  186. package/dist/src/evals/shared-runtime.js +125 -0
  187. package/dist/src/evals/shared-runtime.js.map +1 -0
  188. package/dist/src/evals/trial-import.js +10 -2
  189. package/dist/src/evals/trial-import.js.map +1 -1
  190. package/dist/src/evals/trial-publication-recovery.js +1 -1
  191. package/dist/src/evals/trial-publication-recovery.js.map +1 -1
  192. package/dist/src/evals/verifier-artifacts.js +1 -0
  193. package/dist/src/evals/verifier-artifacts.js.map +1 -1
  194. package/dist/src/evals/verifier-only-rerun.js +1 -1
  195. package/dist/src/evals/verifier-only-rerun.js.map +1 -1
  196. package/dist/src/evals/verifier-rerun-result.js +3 -2
  197. package/dist/src/evals/verifier-rerun-result.js.map +1 -1
  198. package/dist/src/evals/verifier-score-artifacts.js +9 -1
  199. package/dist/src/evals/verifier-score-artifacts.js.map +1 -1
  200. package/dist/src/foundation/config.js +0 -5
  201. package/dist/src/foundation/config.js.map +1 -1
  202. package/dist/src/inference/index.js +3 -22
  203. package/dist/src/inference/index.js.map +1 -1
  204. package/dist/src/inference/lock.js +44 -130
  205. package/dist/src/inference/lock.js.map +1 -1
  206. package/dist/src/model-access/endpoint-contract.js +127 -0
  207. package/dist/src/model-access/endpoint-contract.js.map +1 -0
  208. package/dist/src/model-access/endpoint-credentials.js +54 -0
  209. package/dist/src/model-access/endpoint-credentials.js.map +1 -0
  210. package/dist/src/model-access/endpoint-probe-stream.js +66 -0
  211. package/dist/src/model-access/endpoint-probe-stream.js.map +1 -0
  212. package/dist/src/model-access/endpoint-verification.js +76 -0
  213. package/dist/src/model-access/endpoint-verification.js.map +1 -0
  214. package/dist/src/model-access/endpoints.js +105 -0
  215. package/dist/src/model-access/endpoints.js.map +1 -0
  216. package/dist/src/model-access/index.js +5 -1
  217. package/dist/src/model-access/index.js.map +1 -1
  218. package/dist/src/model-access/proxy.js +57 -14
  219. package/dist/src/model-access/proxy.js.map +1 -1
  220. package/dist/src/model-access/remote-model.js +21 -29
  221. package/dist/src/model-access/remote-model.js.map +1 -1
  222. package/dist/src/model-access/usage.js +30 -0
  223. package/dist/src/model-access/usage.js.map +1 -0
  224. package/dist/src/runs/endpoint-runtime.js +40 -0
  225. package/dist/src/runs/endpoint-runtime.js.map +1 -0
  226. package/dist/src/runs/executor.js +28 -42
  227. package/dist/src/runs/executor.js.map +1 -1
  228. package/dist/src/runs/local-inference-environment.js +5 -53
  229. package/dist/src/runs/local-inference-environment.js.map +1 -1
  230. package/dist/src/runs/manifest.js +1 -0
  231. package/dist/src/runs/manifest.js.map +1 -1
  232. package/dist/src/runs/outcome.js +3 -10
  233. package/dist/src/runs/outcome.js.map +1 -1
  234. package/dist/src/runs/queued.js +3 -2
  235. package/dist/src/runs/queued.js.map +1 -1
  236. package/dist/src/runs/records.js +3 -0
  237. package/dist/src/runs/records.js.map +1 -1
  238. package/dist/src/runs/request.js +10 -38
  239. package/dist/src/runs/request.js.map +1 -1
  240. package/dist/src/workers/remote-harbor-work-spec.js +8 -10
  241. package/dist/src/workers/remote-harbor-work-spec.js.map +1 -1
  242. package/dist/src/workers/remote-harbor-worker.js +3 -0
  243. package/dist/src/workers/remote-harbor-worker.js.map +1 -1
  244. package/docs/evidence/eval-preparation-2026-10-09.json +393 -0
  245. package/docs/evidence/eval-preparation-2026-10-09.md +178 -0
  246. package/docs/local-model-inference-spec.zh-CN.md +4 -663
  247. package/docs/resource-storage.zh-CN.md +116 -0
  248. package/docs/schemas/endpoint-evaluation-attempt.schema.json +29 -0
  249. package/docs/schemas/eval-artifact-pin.schema.json +14 -0
  250. package/docs/schemas/eval-execution-plan.schema.json +486 -105
  251. package/docs/schemas/eval-progress.schema.json +1 -1
  252. package/docs/schemas/eval-request.schema.json +149 -18
  253. package/docs/schemas/eval-rerun-submission.schema.json +8 -1
  254. package/docs/schemas/eval-result.schema.json +310 -56
  255. package/docs/schemas/evaluation-binding.schema.json +15 -0
  256. package/docs/schemas/external-endpoint-binding.schema.json +153 -0
  257. package/docs/schemas/external-endpoint-config.schema.json +93 -0
  258. package/docs/schemas/inference-execution-evidence.schema.json +253 -3
  259. package/docs/schemas/inference-lock-v3.schema.json +6 -0
  260. package/docs/schemas/inference-lock.schema.json +842 -341
  261. package/docs/schemas/ordered-eval-control.schema.json +7 -1
  262. package/docs/schemas/ready-endpoint-binding.schema.json +201 -0
  263. package/docs/schemas/run-manifest.schema.json +6 -0
  264. package/docs/schemas/run-request.schema.json +122 -20
  265. package/examples/external-inference/.env.example +16 -0
  266. package/examples/external-inference/README.md +203 -0
  267. package/examples/external-inference/accept.mjs +297 -0
  268. package/examples/external-inference/compose.gateway.yaml +119 -0
  269. package/examples/external-inference/compose.single.yaml +64 -0
  270. package/examples/external-inference/compose.tp2.yaml +15 -0
  271. package/examples/external-inference/deployment.example.json +58 -0
  272. package/examples/external-inference/endpoint.chat-completions.example.json +13 -0
  273. package/examples/external-inference/endpoint.responses.example.json +13 -0
  274. package/integrations/harbor/hitch_build_inputs.py +213 -0
  275. package/integrations/harbor/hitch_build_process.py +126 -0
  276. package/integrations/harbor/hitch_compose_preparation.py +274 -0
  277. package/integrations/harbor/hitch_harbor_agent.py +25 -39
  278. package/integrations/harbor/hitch_harbor_environment.py +96 -3
  279. package/integrations/harbor/hitch_image_cache.py +357 -0
  280. package/integrations/harbor/hitch_python_runtime.py +303 -0
  281. package/integrations/harbor/hitch_shared_runtime.py +123 -0
  282. package/integrations/model-call/cli.js +11 -10
  283. package/integrations/training-tool/cli.js +5 -3
  284. package/node_modules/smol-toml/LICENSE +24 -0
  285. package/node_modules/smol-toml/README.md +418 -0
  286. package/node_modules/smol-toml/dist/date.d.ts +41 -0
  287. package/node_modules/smol-toml/dist/date.js +127 -0
  288. package/node_modules/smol-toml/dist/error.d.ts +38 -0
  289. package/node_modules/smol-toml/dist/error.js +63 -0
  290. package/node_modules/smol-toml/dist/extract.js +69 -0
  291. package/node_modules/smol-toml/dist/index.cjs +734 -0
  292. package/node_modules/smol-toml/dist/index.d.ts +43 -0
  293. package/node_modules/smol-toml/dist/index.js +33 -0
  294. package/node_modules/smol-toml/dist/parse.d.ts +36 -0
  295. package/node_modules/smol-toml/dist/parse.js +149 -0
  296. package/node_modules/smol-toml/dist/primitive.js +238 -0
  297. package/node_modules/smol-toml/dist/stringify.d.ts +31 -0
  298. package/node_modules/smol-toml/dist/stringify.js +181 -0
  299. package/node_modules/smol-toml/dist/struct.js +179 -0
  300. package/node_modules/smol-toml/dist/util.d.ts +38 -0
  301. package/node_modules/smol-toml/dist/util.js +89 -0
  302. package/node_modules/smol-toml/package.json +68 -0
  303. package/package.json +15 -4
  304. package/dist/src/cli/commands/local.js +0 -130
  305. package/dist/src/cli/commands/local.js.map +0 -1
  306. package/dist/src/cli/commands/model-node.js +0 -26
  307. package/dist/src/cli/commands/model-node.js.map +0 -1
  308. package/dist/src/control-plane/inference-manager.js +0 -357
  309. package/dist/src/control-plane/inference-manager.js.map +0 -1
  310. package/dist/src/control-plane/managed-gateway-receipt.js +0 -26
  311. package/dist/src/control-plane/managed-gateway-receipt.js.map +0 -1
  312. package/dist/src/control-plane/managed-service-recovery.js +0 -158
  313. package/dist/src/control-plane/managed-service-recovery.js.map +0 -1
  314. package/dist/src/daemon/inference-prepare.js +0 -47
  315. package/dist/src/daemon/inference-prepare.js.map +0 -1
  316. package/dist/src/inference/daemon-capacity.js +0 -41
  317. package/dist/src/inference/daemon-capacity.js.map +0 -1
  318. package/dist/src/inference/device-reservation.js +0 -58
  319. package/dist/src/inference/device-reservation.js.map +0 -1
  320. package/dist/src/inference/doctor.js +0 -95
  321. package/dist/src/inference/doctor.js.map +0 -1
  322. package/dist/src/inference/generation-recovery.js +0 -83
  323. package/dist/src/inference/generation-recovery.js.map +0 -1
  324. package/dist/src/inference/managed-launcher.js +0 -35
  325. package/dist/src/inference/managed-launcher.js.map +0 -1
  326. package/dist/src/inference/materialize.js +0 -66
  327. package/dist/src/inference/materialize.js.map +0 -1
  328. package/dist/src/inference/model-location.js +0 -34
  329. package/dist/src/inference/model-location.js.map +0 -1
  330. package/dist/src/inference/node-client.js +0 -167
  331. package/dist/src/inference/node-client.js.map +0 -1
  332. package/dist/src/inference/node-registry.js +0 -79
  333. package/dist/src/inference/node-registry.js.map +0 -1
  334. package/dist/src/inference/observation.js +0 -79
  335. package/dist/src/inference/observation.js.map +0 -1
  336. package/dist/src/inference/preflight.js +0 -132
  337. package/dist/src/inference/preflight.js.map +0 -1
  338. package/dist/src/inference/process-sglang.js +0 -169
  339. package/dist/src/inference/process-sglang.js.map +0 -1
  340. package/dist/src/inference/protocol-probe.js +0 -29
  341. package/dist/src/inference/protocol-probe.js.map +0 -1
  342. package/dist/src/inference/remote-model-store.js +0 -24
  343. package/dist/src/inference/remote-model-store.js.map +0 -1
  344. package/dist/src/inference/runtime-catalog.js +0 -41
  345. package/dist/src/inference/runtime-catalog.js.map +0 -1
  346. package/dist/src/inference/runtime-store.js +0 -93
  347. package/dist/src/inference/runtime-store.js.map +0 -1
  348. package/dist/src/inference/service-attachment.js +0 -46
  349. package/dist/src/inference/service-attachment.js.map +0 -1
  350. package/dist/src/inference/service-inspection.js +0 -38
  351. package/dist/src/inference/service-inspection.js.map +0 -1
  352. package/dist/src/inference/sglang.js +0 -279
  353. package/dist/src/inference/sglang.js.map +0 -1
  354. package/dist/src/inference/supervisor.js +0 -460
  355. package/dist/src/inference/supervisor.js.map +0 -1
  356. package/dist/src/model-access/local-gateway-request.js +0 -139
  357. package/dist/src/model-access/local-gateway-request.js.map +0 -1
  358. package/dist/src/model-access/local-gateway.js +0 -323
  359. package/dist/src/model-access/local-gateway.js.map +0 -1
  360. package/dist/src/runs/local-inference-run.js +0 -53
  361. package/dist/src/runs/local-inference-run.js.map +0 -1
@@ -0,0 +1,213 @@
1
+ """Conservative, content-addressed local Docker build inputs.
2
+
3
+ Unsupported inputs keep Harbor's original build path. Snapshot before hashing:
4
+ the builder consumes exactly the bytes named by the key, never a mutable source
5
+ directory. Task names, runtime mounts and ownership labels are not build inputs.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from contextlib import contextmanager
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import re
15
+ import shutil
16
+ import stat
17
+ from pathlib import Path
18
+
19
+
20
+ class UnsupportedBuild(ValueError):
21
+ pass
22
+
23
+
24
+ @contextmanager
25
+ def read_context():
26
+ # Docker can ignore unreadable entries using .dockerignore; our complete
27
+ # snapshot cannot. Only source I/O becomes fallback, not destination errors.
28
+ try:
29
+ yield
30
+ except OSError as error:
31
+ raise UnsupportedBuild("context-read") from error
32
+
33
+
34
+ def digest(value):
35
+ return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
36
+
37
+
38
+ def build_spec(service, default_platform):
39
+ build = service.get("build")
40
+ if not isinstance(build, dict) or set(build) - {"context", "dockerfile", "args", "target", "labels"}:
41
+ raise UnsupportedBuild("build-options")
42
+ if service.get("pull_policy") not in (None, "build", "missing", "if_not_present", "never"):
43
+ raise UnsupportedBuild("pull-policy")
44
+ context = build.get("context")
45
+ dockerfile = build.get("dockerfile", "Dockerfile")
46
+ # Resolved config re-escapes paths too, so an existing '$$' sibling could
47
+ # otherwise be mistaken for Compose's actual '$' input tree or Dockerfile.
48
+ if any(isinstance(value, str) and "$" in value for value in (context, dockerfile)):
49
+ raise UnsupportedBuild("escaped-build-paths")
50
+ if not isinstance(context, str) or not Path(context).is_absolute():
51
+ raise UnsupportedBuild("nonlocal-context")
52
+ source = Path(context)
53
+ if source.resolve() != source or not source.is_dir():
54
+ raise UnsupportedBuild("context-path")
55
+ if (not isinstance(dockerfile, str) or not dockerfile or "\\" in dockerfile
56
+ or Path(dockerfile).is_absolute() or any(p in ("", ".", "..") for p in dockerfile.split("/"))):
57
+ raise UnsupportedBuild("dockerfile-path")
58
+ platform = service.get("platform") or default_platform
59
+ if platform not in ("linux/amd64", "linux/arm64"):
60
+ raise UnsupportedBuild("platform")
61
+ args, labels = build.get("args", {}), build.get("labels", {})
62
+ for values in (args, labels):
63
+ if not isinstance(values, dict) or any(not isinstance(k, str) or not k or not isinstance(v, str) for k, v in values.items()):
64
+ raise UnsupportedBuild("unresolved-build-values")
65
+ # Compose config serializes literal dollars differently from its
66
+ # execution plan. Keep native interpolation/escaping for such inputs.
67
+ if any("$" in key or "$" in value for key, value in values.items()):
68
+ raise UnsupportedBuild("escaped-build-values")
69
+ if "BUILDKIT_SYNTAX" in args:
70
+ # This reserved argument selects a frontend just like a # syntax
71
+ # directive; even pinned frontends can introduce untracked inputs.
72
+ raise UnsupportedBuild("custom-frontend")
73
+ if "io.hitch.prepared-key" in labels:
74
+ raise UnsupportedBuild("reserved-cache-label")
75
+ target = build.get("target")
76
+ if target is not None and (not isinstance(target, str) or not re.fullmatch(r"[a-zA-Z0-9_.-]+", target)):
77
+ raise UnsupportedBuild("build-target")
78
+ return source, {"dockerfile": dockerfile, "platform": platform, "args": args, "labels": labels, "target": target}
79
+
80
+
81
+ def pinned_dockerfile(source):
82
+ with read_context():
83
+ if source.stat().st_size > 1024 * 1024:
84
+ raise UnsupportedBuild("dockerfile-size")
85
+ try:
86
+ content = source.read_text(encoding="utf-8")
87
+ except UnicodeDecodeError as error:
88
+ # Docker can accept non-UTF-8 comment bytes. Preserve its parser
89
+ # and the original input instead of decoding lossily for caching.
90
+ raise UnsupportedBuild("dockerfile-encoding") from error
91
+ # Restrict parser directives and syntax we cannot unambiguously inspect.
92
+ if "\0" in content or "<<" in content or re.search(r"(?im)^\s*#\s*escape\s*=", content):
93
+ raise UnsupportedBuild("dockerfile-syntax")
94
+ if re.search(r"(?im)^\s*#\s*syntax\s*=", content):
95
+ # Even a pinned custom frontend can introduce untracked input sources.
96
+ raise UnsupportedBuild("custom-frontend")
97
+ stages, bases = set(), []
98
+ logical = re.sub(r"\\\r?\n", " ", "\n".join(line for line in content.splitlines() if not line.lstrip().startswith("#")))
99
+ for line in logical.splitlines():
100
+ line = line.strip()
101
+ if not line or line.startswith("#"):
102
+ continue
103
+ tokens = re.split(r"\s+", line, maxsplit=1)
104
+ instruction, body = tokens[0], tokens[1] if len(tokens) == 2 else ""
105
+ instruction = instruction.upper()
106
+ if instruction not in {"FROM", "RUN", "CMD", "LABEL", "MAINTAINER", "EXPOSE", "ENV", "ENTRYPOINT", "VOLUME", "USER", "WORKDIR", "ARG", "STOPSIGNAL", "HEALTHCHECK", "SHELL", "COPY", "ADD", "ONBUILD"}:
107
+ raise UnsupportedBuild("dockerfile-instruction")
108
+ if instruction == "FROM":
109
+ match = re.fullmatch(r"(?:--platform=(linux/(?:amd64|arm64))\s+)?(\S+)(?:\s+[Aa][Ss]\s+([\w.-]+))?", body.strip())
110
+ if not match:
111
+ raise UnsupportedBuild("dynamic-base")
112
+ if match[1]:
113
+ raise UnsupportedBuild("per-stage-platform")
114
+ base, alias = match[2], match[3]
115
+ if base.lower() not in stages and base != "scratch":
116
+ if not re.fullmatch(r"[^\s$]+@sha256:[a-f0-9]{64}", base):
117
+ raise UnsupportedBuild("mutable-base")
118
+ bases.append(base)
119
+ if alias:
120
+ stages.add(alias.lower())
121
+ elif instruction == "ADD":
122
+ # ADD can read remote URLs or Git trees, independent of the context.
123
+ raise UnsupportedBuild("add-input")
124
+ elif instruction == "COPY":
125
+ if "--from" in body and not re.search(r"--from=\S+", body):
126
+ raise UnsupportedBuild("copy-source-syntax")
127
+ for external in re.findall(r"--from=(\S+)", body):
128
+ if external.lower() not in stages and not external.isdigit() and not re.fullmatch(r"[^\s$]+@sha256:[a-f0-9]{64}", external):
129
+ raise UnsupportedBuild("mutable-copy-source")
130
+ elif instruction == "RUN" and re.search(r"--(?:mount|device|security)=", body):
131
+ raise UnsupportedBuild("external-run-input")
132
+ elif instruction == "ONBUILD":
133
+ raise UnsupportedBuild("onbuild-input")
134
+ if not re.search(r"(?im)^\s*FROM\s", logical):
135
+ raise UnsupportedBuild("missing-base")
136
+ return sorted(set(bases))
137
+
138
+
139
+ async def snapshot_context(source, target):
140
+ listxattr = getattr(os, "listxattr", None)
141
+ if not callable(listxattr):
142
+ raise UnsupportedBuild("context-xattr-inspection")
143
+ entries = []
144
+
145
+ async def copy(current, destination, relative):
146
+ with read_context():
147
+ info = current.lstat()
148
+ mode = stat.S_IMODE(info.st_mode)
149
+ try:
150
+ attributes = listxattr(current, follow_symlinks=False)
151
+ except (OSError, NotImplementedError) as error:
152
+ # An uninspectable context must use the original build path; never
153
+ # assume unsupported metadata is absent from the cache identity.
154
+ raise UnsupportedBuild("context-xattr-inspection") from error
155
+ if attributes:
156
+ raise UnsupportedBuild("context-xattrs")
157
+ if stat.S_ISDIR(info.st_mode):
158
+ destination.mkdir(mode=0o700)
159
+ with read_context():
160
+ children = sorted(current.iterdir(), key=lambda p: os.fsencode(p.name))
161
+ entries.append([relative, "directory", mode])
162
+ for child in children:
163
+ await copy(child, destination / child.name, f"{relative}/{child.name}" if relative else child.name)
164
+ with read_context():
165
+ if [p.name for p in children] != sorted(os.listdir(current), key=os.fsencode):
166
+ raise UnsupportedBuild("context-changed")
167
+ os.chmod(destination, mode)
168
+ os.utime(destination, ns=(info.st_atime_ns, info.st_mtime_ns))
169
+ elif stat.S_ISREG(info.st_mode):
170
+ if info.st_nlink != 1:
171
+ # COPY can preserve link relationships. Separate copies and a
172
+ # byte-only identity cannot represent them, even on a hit.
173
+ raise UnsupportedBuild("context-hardlinks")
174
+ checksum = hashlib.sha256()
175
+ with read_context():
176
+ fd = os.open(current, os.O_RDONLY | os.O_NOFOLLOW)
177
+ with os.fdopen(fd, "rb") as reader, destination.open("xb") as writer:
178
+ with read_context():
179
+ before = os.fstat(reader.fileno())
180
+ if (before.st_ino, before.st_dev) != (info.st_ino, info.st_dev):
181
+ raise UnsupportedBuild("context-changed")
182
+ while True:
183
+ with read_context():
184
+ chunk = reader.read(1024 * 1024)
185
+ if not chunk:
186
+ break
187
+ checksum.update(chunk)
188
+ writer.write(chunk)
189
+ await asyncio.sleep(0)
190
+ with read_context():
191
+ after = os.fstat(reader.fileno())
192
+ if (before.st_size, before.st_mtime_ns, before.st_ctime_ns) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns):
193
+ raise UnsupportedBuild("context-changed")
194
+ os.chmod(destination, mode)
195
+ os.utime(destination, ns=(info.st_atime_ns, info.st_mtime_ns))
196
+ entries.append([relative, "file", mode, checksum.hexdigest()])
197
+ else:
198
+ raise UnsupportedBuild("special-context-entry")
199
+ await asyncio.sleep(0)
200
+
201
+ await copy(source, target, "")
202
+ return digest(entries)
203
+
204
+
205
+ def remove_snapshot(directory):
206
+ # Source modes may be read-only. Never follow a symlink while cleaning up.
207
+ for base, directories, _ in os.walk(directory, followlinks=False):
208
+ os.chmod(base, 0o700)
209
+ for child in directories:
210
+ candidate = Path(base) / child
211
+ if not candidate.is_symlink():
212
+ os.chmod(candidate, 0o700)
213
+ shutil.rmtree(directory)
@@ -0,0 +1,126 @@
1
+ """Supervise one Docker command across loss of its Harbor parent.
2
+
3
+ The parent owns stdin's write end and sends one byte only after both output
4
+ pipes close. EOF without that handshake means cancellation or parent death.
5
+ Inherited cache locks remain held here until the command group is terminated.
6
+ This file deliberately depends only on the Python standard library.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import os
12
+ import signal
13
+ import sys
14
+ import time
15
+
16
+
17
+ def group_has_live_members(pgid):
18
+ try:
19
+ os.killpg(pgid, 0)
20
+ except ProcessLookupError:
21
+ return False
22
+ except PermissionError:
23
+ # macOS can return EPERM for a group awaiting zombie reaping. A real
24
+ # permission failure also cannot establish that its members exited.
25
+ return True
26
+ if sys.platform != "linux":
27
+ return True
28
+ # An orphan zombie can remain until init reaps it, but cannot hold files
29
+ # or execute. A live D-state helper, however, can outlast SIGKILL.
30
+ try:
31
+ with os.scandir("/proc") as entries:
32
+ for entry in entries:
33
+ if not entry.name.isdecimal():
34
+ continue
35
+ try:
36
+ with open(f"{entry.path}/stat") as stream:
37
+ fields = stream.read().rpartition(")")[2].split()
38
+ except (FileNotFoundError, ProcessLookupError):
39
+ continue
40
+ if int(fields[2]) == pgid and fields[0] not in ("Z", "X"):
41
+ return True
42
+ except (OSError, ValueError, IndexError):
43
+ # Failed observation cannot authorize release of build ownership.
44
+ return True
45
+ return False
46
+
47
+
48
+ async def stop_group(process):
49
+ try:
50
+ os.killpg(process.pid, signal.SIGTERM)
51
+ except ProcessLookupError:
52
+ await process.wait()
53
+ return
54
+ except PermissionError:
55
+ pass
56
+ deadline = time.monotonic() + 5
57
+ while time.monotonic() < deadline:
58
+ try:
59
+ os.killpg(process.pid, 0)
60
+ except ProcessLookupError:
61
+ break
62
+ except PermissionError:
63
+ pass
64
+ await asyncio.sleep(0.02)
65
+ else:
66
+ try:
67
+ os.killpg(process.pid, signal.SIGKILL)
68
+ except (ProcessLookupError, PermissionError):
69
+ pass
70
+ await process.wait()
71
+ while group_has_live_members(process.pid):
72
+ await asyncio.sleep(0.05)
73
+
74
+
75
+ async def supervise(command):
76
+ loop = asyncio.get_running_loop()
77
+ interrupted = asyncio.Event()
78
+ for signum in (signal.SIGTERM, signal.SIGINT):
79
+ loop.add_signal_handler(signum, interrupted.set)
80
+ reader = asyncio.StreamReader()
81
+ transport, _ = await loop.connect_read_pipe(lambda: asyncio.StreamReaderProtocol(reader), sys.stdin.buffer)
82
+ process = None
83
+ pending = []
84
+ try:
85
+ # The liveness pipe and inherited locks belong to this supervisor only;
86
+ # Docker and its plugins receive neither, including through stdin.
87
+ process = await asyncio.create_subprocess_exec(*command, stdin=asyncio.subprocess.DEVNULL, start_new_session=True)
88
+ # Release our copies of the output pipes while keeping Python's
89
+ # streams valid. Buffered output or shutdown diagnostics must not
90
+ # replace the command's exit status with a stream-flush failure.
91
+ with open(os.devnull, "wb") as sink:
92
+ os.dup2(sink.fileno(), sys.stdout.fileno())
93
+ os.dup2(sink.fileno(), sys.stderr.fileno())
94
+ exited = asyncio.create_task(process.wait())
95
+ control = asyncio.create_task(reader.read(1))
96
+ stopped = asyncio.create_task(interrupted.wait())
97
+ pending = [exited, control, stopped]
98
+ acknowledged = False
99
+ while True:
100
+ watched = [control, stopped] if exited.done() and not acknowledged else [exited, control, stopped]
101
+ done, _ = await asyncio.wait(watched, return_when=asyncio.FIRST_COMPLETED)
102
+ if stopped in done:
103
+ return 125
104
+ if control in done:
105
+ if control.result() != b"1" or acknowledged:
106
+ return 125
107
+ acknowledged = True
108
+ control = asyncio.create_task(reader.read(1))
109
+ pending.append(control)
110
+ if acknowledged and exited.done():
111
+ code = exited.result()
112
+ return code if code >= 0 else 128 - code
113
+ finally:
114
+ # A successful leader and closed output pipes do not prove that a
115
+ # plugin's silent helper stopped. Reclaim the group on every exit.
116
+ if process is not None:
117
+ await stop_group(process)
118
+ for task in pending:
119
+ if not task.done():
120
+ task.cancel()
121
+ await asyncio.gather(*pending, return_exceptions=True)
122
+ transport.close()
123
+
124
+
125
+ if __name__ == "__main__":
126
+ raise SystemExit(asyncio.run(supervise(sys.argv[1:])))
@@ -0,0 +1,274 @@
1
+ """Prepare immutable images and Harbor's own keepalive without pooling tasks."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ import tempfile
7
+
8
+ import yaml
9
+
10
+ from hitch_build_inputs import UnsupportedBuild, build_spec, digest, pinned_dockerfile
11
+ from hitch_image_cache import ImagePreparationCache, atomic_json, docker, image_matches_platform
12
+
13
+ _DEFAULT_COMMAND = ["sh", "-c", "sleep infinity"]
14
+ _MANAGED_COMMAND = ["sh", "-c", 'trap \'kill "$child"; wait "$child"; exit 0\' TERM INT; sleep infinity & child=$!; wait "$child"']
15
+ _LIFECYCLE_FIELDS = ("init", "stop_signal", "stop_grace_period", "pre_stop")
16
+ _RUNTIME_PLATFORM_LABEL = "io.hitch.preparation.default-platform"
17
+
18
+
19
+ def compose_document(output):
20
+ # Harbor merges stderr into stdout. Benign Compose interpolation warnings
21
+ # may precede the document; only accept a complete trailing JSON object.
22
+ lines = output.splitlines(keepends=True)
23
+ for index, line in enumerate(lines):
24
+ if line.lstrip().startswith("{"):
25
+ try:
26
+ document = json.loads("".join(lines[index:]))
27
+ if isinstance(document, dict):
28
+ return document
29
+ except ValueError:
30
+ pass
31
+ raise UnsupportedBuild("compose-config-output")
32
+
33
+
34
+ def compose_services(output):
35
+ services = compose_document(output).get("services")
36
+ if not isinstance(services, dict):
37
+ raise UnsupportedBuild("compose-config-output")
38
+ return services
39
+
40
+
41
+ def resolved_build_platforms(builds, output):
42
+ # The project .env participates in build defaults without appearing in
43
+ # service config or the parent's process environment. Ask Compose itself.
44
+ targets = compose_document(output).get("target")
45
+ if not isinstance(targets, dict) or set(targets) != set(builds):
46
+ raise UnsupportedBuild("compose-build-plan")
47
+ resolved = {}
48
+ for name, service in builds.items():
49
+ target = targets[name]
50
+ if not isinstance(target, dict):
51
+ raise UnsupportedBuild("compose-build-plan")
52
+ platforms = target.get("platforms", [])
53
+ if not isinstance(platforms, list) or len(platforms) > 1 or any(p not in ("linux/amd64", "linux/arm64") for p in platforms):
54
+ raise UnsupportedBuild("compose-build-platform")
55
+ resolved[name] = {**service, "platform": platforms[0]} if platforms else service
56
+ return resolved
57
+
58
+
59
+ def image_tag_key(reference):
60
+ # Compare Compose-resolved names using Docker's familiar-name defaults.
61
+ # Digest references are not mutable build tags. Keep their identity intact.
62
+ if "@" in reference:
63
+ return reference
64
+ registry, separator, repository = reference.partition("/")
65
+ if not separator or (registry != "localhost" and "." not in registry
66
+ and ":" not in registry and registry == registry.lower()):
67
+ registry, repository = "docker.io", reference
68
+ if registry == "index.docker.io":
69
+ registry = "docker.io"
70
+ if registry == "docker.io" and "/" not in repository:
71
+ repository = "library/" + repository
72
+ if ":" not in repository.rsplit("/", 1)[-1]:
73
+ repository += ":latest"
74
+ return f"{registry}/{repository}"
75
+
76
+
77
+ def refreshes_latest(reference, policy):
78
+ return policy not in ("never", "build") and image_tag_key(reference).endswith(":latest")
79
+
80
+
81
+ def require_independent_build_images(services):
82
+ references = {}
83
+ unnamed_build = False
84
+ image_only = False
85
+ for service in services.values():
86
+ builds = bool(service.get("build"))
87
+ reference = service.get("image")
88
+ if reference:
89
+ references.setdefault(image_tag_key(reference), []).append(builds)
90
+ elif builds:
91
+ unnamed_build = True
92
+ for mount in service.get("volumes") or []:
93
+ if isinstance(mount, dict) and mount.get("type") == "image":
94
+ source = mount.get("source")
95
+ if not isinstance(source, str) or not source:
96
+ raise UnsupportedBuild("image-mount-source")
97
+ references.setdefault(image_tag_key(source), []).append(False)
98
+ if not builds:
99
+ image_only = True
100
+ # Cached builds publish private tags, not Compose's shared mutable names.
101
+ # Leave tag publication, pull policy and competing producers to Compose.
102
+ if any(len(uses) > 1 and any(uses) for uses in references.values()):
103
+ raise UnsupportedBuild("shared-build-image")
104
+ # Any explicitly named service can consume or overwrite a generated tag,
105
+ # including another builder. Do not guess Compose's project-name rules.
106
+ # All-unnamed builders remain independent: service names are unique.
107
+ if unnamed_build and (references or image_only):
108
+ raise UnsupportedBuild("implicit-build-image")
109
+
110
+
111
+ class ComposePreparation:
112
+ def __init__(self, environment, cache_directory=None, build_slots=2, managed_keepalive=False):
113
+ self.environment = environment
114
+ self.cache_directory, self.build_slots = cache_directory, build_slots
115
+ self.managed_keepalive = managed_keepalive
116
+ self.images = {}
117
+ self.overlay = {}
118
+ self.path = None
119
+ self.temporary = None
120
+ self.receipts = []
121
+ self.force_build = False
122
+ self.runtime_prepared = False
123
+
124
+ def reset(self, force_build):
125
+ if self.temporary is not None:
126
+ self.temporary.cleanup()
127
+ self.images, self.overlay, self.receipts = {}, {}, []
128
+ self.path, self.temporary = None, None
129
+ self.runtime_prepared, self.force_build = False, force_build
130
+ if force_build and self.cache_directory:
131
+ self.record({"event": "bypass", "reason": "force-build"})
132
+
133
+ async def build(self, run):
134
+ if not self.cache_directory or self.force_build:
135
+ return None
136
+ env = self.environment
137
+ cache = ImagePreparationCache(self.cache_directory, env._compose_env_vars(), self.build_slots)
138
+ result = await run(["config", "--format", "json"])
139
+ try:
140
+ services = compose_services(result.stdout)
141
+ builds = {name: value for name, value in services.items() if value.get("build")}
142
+ if not builds:
143
+ return result.model_copy(update={"stdout": "", "stderr": ""})
144
+ # A composition with unsupported inputs uses its original build path
145
+ # as a whole, including inter-service dependencies and extra contexts.
146
+ require_independent_build_images(services)
147
+ # Preserve documented latest refresh semantics across Compose
148
+ # versions. Leave the native choice of local or pulled image to up.
149
+ if any(service.get("image") and refreshes_latest(service["image"], service.get("pull_policy"))
150
+ for service in builds.values()):
151
+ raise UnsupportedBuild("refreshable-build-image")
152
+ plan = await run(["build", "--print"], check=False)
153
+ if plan.return_code != 0:
154
+ # Older Compose versions and conflicting defaults retain their
155
+ # native build behavior, including the original error handling.
156
+ raise UnsupportedBuild("compose-build-plan")
157
+ builds = resolved_build_platforms(builds, plan.stdout)
158
+ daemon = await cache.identity()
159
+ for service in builds.values():
160
+ context, spec = build_spec(service, cache.env.get("DOCKER_DEFAULT_PLATFORM") or daemon["platform"])
161
+ pinned_dockerfile(context / spec["dockerfile"])
162
+ prepared = {name: await cache.prepare(value) for name, value in builds.items()}
163
+ except UnsupportedBuild as error:
164
+ self.record({"event": "fallback", "reason": str(error)})
165
+ return None
166
+ for name, receipt in prepared.items():
167
+ self.images[name] = receipt["image"]
168
+ self.overlay[name] = {"image": receipt["image"], "build": "__RESET__", "pull_policy": "never"}
169
+ self.record({"event": "image", "service": name, **receipt})
170
+ if prepared:
171
+ self.write_overlay()
172
+ return result.model_copy(update={"stdout": "", "stderr": ""})
173
+
174
+ async def runtime(self, run):
175
+ if self.runtime_prepared or not self.managed_keepalive:
176
+ return
177
+ self.runtime_prepared = True
178
+ env = self.environment
179
+ if getattr(env, "_is_windows_container", False) or self.custom_keepalive():
180
+ return
181
+ # Resolve dotenv defaults through Compose itself. A config-only label
182
+ # yields an unambiguous JSON value even when other environment values
183
+ # contain newlines. This temporary probe never reaches container up.
184
+ with tempfile.TemporaryDirectory(prefix="hitch-compose-platform-") as directory:
185
+ probe = Path(directory) / "compose.json"
186
+ probe.write_text(json.dumps({"services": {"main": {"labels": {
187
+ _RUNTIME_PLATFORM_LABEL: "${DOCKER_DEFAULT_PLATFORM:-}",
188
+ }}}}))
189
+ result = await run(["-f", str(probe), "config", "--format", "json"])
190
+ try:
191
+ service = compose_services(result.stdout)["main"]
192
+ except UnsupportedBuild:
193
+ return
194
+ platform = service.get("platform") or (service.get("labels") or {}).get(_RUNTIME_PLATFORM_LABEL)
195
+ if platform not in (None, "", "linux/amd64", "linux/arm64"):
196
+ return
197
+ if (service.get("command") != _DEFAULT_COMMAND or service.get("entrypoint")
198
+ or any(key in service for key in _LIFECYCLE_FIELDS)):
199
+ return
200
+ reference = service.get("image") or env._main_image_name
201
+ policy = service.get("pull_policy")
202
+ # A successful local inspection says nothing about a replacement image
203
+ # pulled or rebuilt by up. Compose also refreshes latest under missing.
204
+ if (policy not in (None, "never", "missing", "if_not_present")
205
+ or refreshes_latest(reference, policy)):
206
+ return
207
+ try:
208
+ raw = await docker(["image", "inspect", "--format", "{{json .}}", reference],
209
+ env=env._compose_env_vars(), timeout=30, check=False)
210
+ if not raw:
211
+ return
212
+ image = json.loads(raw)
213
+ except (TimeoutError, OSError, ValueError):
214
+ # Metadata is optional for native startup. The Docker supervisor
215
+ # has already drained command cleanup before a timeout reaches us.
216
+ self.record({"event": "fallback", "reason": "keepalive-image-inspection"})
217
+ return
218
+ if not isinstance(image, dict) or not isinstance(image.get("Config", {}), dict):
219
+ return
220
+ if platform and not image_matches_platform(image, platform):
221
+ # Compose may pull/select a different variant if its default
222
+ # local inspection does not match the requested runtime platform.
223
+ return
224
+ config = image.get("Config", {})
225
+ stop_signal = service.get("stop_signal", config.get("StopSignal") or "SIGTERM")
226
+ if image.get("Os") != "linux" or config.get("Entrypoint") or stop_signal not in ("SIGTERM", "SIGINT", "15", "2"):
227
+ return
228
+ self.overlay.setdefault("main", {})["command"] = _MANAGED_COMMAND
229
+ self.write_overlay()
230
+ self.record({"event": "managed-keepalive", "service": "main"})
231
+
232
+ def custom_keepalive(self):
233
+ env = self.environment
234
+ for source in [env._environment_docker_compose_path, *env.extra_docker_compose_paths]:
235
+ if source.exists():
236
+ try:
237
+ document = yaml.safe_load(source.read_text(encoding="utf-8"))
238
+ except yaml.YAMLError:
239
+ # Compose supports tags such as !reset and !override that
240
+ # PyYAML does not. Leave their interpretation to Compose.
241
+ return True
242
+ if document is None:
243
+ continue
244
+ if not isinstance(document, dict) or "include" in document:
245
+ return True
246
+ services = document.get("services") or {}
247
+ if not isinstance(services, dict):
248
+ return True
249
+ main = services.get("main") or {}
250
+ # An extended/included definition may explicitly choose even
251
+ # the default command; we cannot attribute it to Harbor.
252
+ if not isinstance(main, dict) or any(key in main for key in ("extends", "command", "entrypoint", *_LIFECYCLE_FIELDS)):
253
+ return True
254
+ return False
255
+
256
+ def write_overlay(self):
257
+ if self.temporary is None:
258
+ self.temporary = tempfile.TemporaryDirectory(prefix="hitch-compose-prepared-")
259
+ self.path = Path(self.temporary.name) / "compose.yaml"
260
+ # Compose interpolates '$' even in JSON/YAML list-form commands.
261
+ content = json.dumps({"services": self.overlay}, sort_keys=True).replace("$", "$$")
262
+ self.path.write_text(content.replace('"build": "__RESET__"', '"build": !reset null'))
263
+
264
+ def record(self, value):
265
+ self.receipts.append(value)
266
+ env = self.environment
267
+ env.logger.info("Hitch image preparation: %s", json.dumps(value, sort_keys=True))
268
+ # Host-only evidence; do not expose context paths/args/credentials or
269
+ # mount this cache into candidates, sidecars, or verifier containers.
270
+ directory = env.trial_paths.trial_dir
271
+ directory.mkdir(parents=True, exist_ok=True)
272
+ atomic_json(directory / f"hitch-preparation-{digest(env.session_id)[:16]}.json", {
273
+ "schema_version": "1", "session_id": env.session_id, "events": self.receipts,
274
+ })