agent-hitch 0.2.15 → 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 +56 -15
  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,357 @@
1
+ """Host-local, cross-process preparation cache for resolved Compose builds."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from contextlib import asynccontextmanager
6
+ from contextvars import ContextVar
7
+ import fcntl
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import re
12
+ import stat
13
+ import sys
14
+ import tempfile
15
+ import time
16
+
17
+ from hitch_build_inputs import UnsupportedBuild, build_spec, digest, pinned_dockerfile, remove_snapshot, snapshot_context
18
+
19
+ _IMAGE_ID = re.compile(r"sha256:[a-f0-9]{64}")
20
+ _PROXY_NAMES = ("HTTP_PROXY", "HTTPS_PROXY", "FTP_PROXY", "ALL_PROXY", "NO_PROXY")
21
+ _LOCK_FDS = ContextVar("hitch_build_lock_fds", default=())
22
+
23
+
24
+ async def docker(args, *, env, timeout=600, check=True):
25
+ """Keep command lifetime and cache locks tied to the worker's liveness."""
26
+ spawning = asyncio.create_task(asyncio.create_subprocess_exec(
27
+ sys.executable, "-I", "-B", str(Path(__file__).with_name("hitch_build_process.py")), "docker", *args,
28
+ env=env, start_new_session=True, pass_fds=_LOCK_FDS.get(), stdin=asyncio.subprocess.PIPE,
29
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
30
+ ))
31
+ process = communication = None
32
+ try:
33
+ # Do not lose ownership if cancellation arrives during process creation.
34
+ process = await asyncio.shield(spawning)
35
+ communication = asyncio.create_task(communicate(process))
36
+ stdout, _ = await asyncio.wait_for(asyncio.shield(communication), timeout)
37
+ except BaseException:
38
+ async def stop():
39
+ owned = process if process is not None else await spawning
40
+ owned.stdin.close()
41
+ await (communication if communication is not None else communicate(owned))
42
+
43
+ cleanup = asyncio.create_task(stop())
44
+ while not cleanup.done():
45
+ try:
46
+ await asyncio.shield(cleanup)
47
+ except asyncio.CancelledError:
48
+ # Repeated cancellation cannot release image/slot locks early.
49
+ pass
50
+ cleanup.result()
51
+ raise
52
+ finally:
53
+ if process is not None:
54
+ process.stdin.close()
55
+ if check and process.returncode:
56
+ # Do not surface build args, proxy credentials or registry stderr.
57
+ raise RuntimeError(f"Hitch image preparation Docker {args[0]} failed ({process.returncode})")
58
+ return stdout.decode("utf-8", errors="strict") if process.returncode == 0 else None
59
+
60
+
61
+ async def communicate(process):
62
+ stdout, stderr = await asyncio.gather(process.stdout.read(), process.stderr.read())
63
+ try:
64
+ # Unlike Process.communicate(), keep stdin open while Docker runs. The
65
+ # supervisor also waits for descendants holding either output pipe.
66
+ process.stdin.write(b"1")
67
+ await process.stdin.drain()
68
+ except (BrokenPipeError, ConnectionResetError):
69
+ pass
70
+ await process.wait()
71
+ return stdout, stderr
72
+
73
+
74
+ @asynccontextmanager
75
+ async def lock(paths):
76
+ handles = []
77
+ acquired = None
78
+ token = None
79
+ try:
80
+ for path in paths:
81
+ fd = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
82
+ handles.append(os.fdopen(fd, "a"))
83
+ while acquired is None:
84
+ for handle in handles:
85
+ try:
86
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
87
+ acquired = handle
88
+ break
89
+ except BlockingIOError:
90
+ pass
91
+ if acquired is None:
92
+ await asyncio.sleep(0.05)
93
+ token = _LOCK_FDS.set((*_LOCK_FDS.get(), acquired.fileno()))
94
+ yield
95
+ finally:
96
+ if token is not None:
97
+ _LOCK_FDS.reset(token)
98
+ for handle in handles:
99
+ handle.close()
100
+
101
+
102
+ def atomic_json(path, value):
103
+ fd, name = tempfile.mkstemp(dir=path.parent, prefix=".record-")
104
+ try:
105
+ with os.fdopen(fd, "w") as stream:
106
+ json.dump(value, stream, sort_keys=True, separators=(",", ":"))
107
+ os.replace(name, path)
108
+ finally:
109
+ Path(name).unlink(missing_ok=True)
110
+
111
+
112
+ def read_json(path):
113
+ try:
114
+ if path.is_symlink() or path.stat().st_size > 65536:
115
+ return None
116
+ return json.loads(path.read_text())
117
+ except (OSError, ValueError):
118
+ return None
119
+
120
+
121
+ def image_matches_platform(info, platform):
122
+ variants = (None, "", "v8") if platform == "linux/arm64" else (None, "")
123
+ return (isinstance(info, dict)
124
+ and f"{info.get('Os', info.get('os'))}/{info.get('Architecture', info.get('architecture'))}" == platform
125
+ and info.get("Variant", info.get("variant")) in variants)
126
+
127
+
128
+ def recover_contexts(directory):
129
+ # Called under the registry lock. Legacy/unrecognized directories have no
130
+ # ownership proof and are retained. Never use PID age or a time threshold.
131
+ for candidate in directory.glob(".context-*"):
132
+ if candidate.is_symlink() or not candidate.is_dir():
133
+ continue
134
+ try:
135
+ fd = os.open(candidate / ".owner.lock", os.O_RDWR | os.O_NOFOLLOW)
136
+ except OSError:
137
+ continue
138
+ with os.fdopen(fd, "a") as owner:
139
+ info = os.fstat(owner.fileno())
140
+ if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid():
141
+ continue
142
+ try:
143
+ fcntl.flock(owner, fcntl.LOCK_EX | fcntl.LOCK_NB)
144
+ except BlockingIOError:
145
+ continue
146
+ if candidate.exists():
147
+ remove_snapshot(candidate)
148
+
149
+
150
+ @asynccontextmanager
151
+ async def context_snapshot(directory):
152
+ temporary = owner = token = None
153
+ try:
154
+ # Serialize publication of the directory and its already-held lock.
155
+ # No copier/builder starts before ownership has been established.
156
+ async with lock([directory / "context-registry.lock"]):
157
+ recover_contexts(directory)
158
+ temporary = Path(tempfile.mkdtemp(dir=directory, prefix=".context-"))
159
+ fd = os.open(temporary / ".owner.lock", os.O_CREAT | os.O_EXCL | os.O_RDWR | os.O_NOFOLLOW, 0o600)
160
+ owner = os.fdopen(fd, "a")
161
+ fcntl.flock(owner, fcntl.LOCK_EX | fcntl.LOCK_NB)
162
+ # Supervisors inherit this descriptor along with key/slot locks. A
163
+ # dead parent cannot make a live build's context eligible for recovery.
164
+ token = _LOCK_FDS.set((*_LOCK_FDS.get(), owner.fileno()))
165
+ yield temporary
166
+ finally:
167
+ try:
168
+ if temporary is not None:
169
+ remove_snapshot(temporary)
170
+ finally:
171
+ if token is not None:
172
+ _LOCK_FDS.reset(token)
173
+ if owner is not None:
174
+ owner.close()
175
+
176
+
177
+ class ImagePreparationCache:
178
+ def __init__(self, directory, env, slots=2):
179
+ self.directory = Path(directory)
180
+ if not self.directory.is_absolute() or self.directory.resolve() != self.directory:
181
+ raise ValueError("Hitch image cache requires an absolute non-symlink directory")
182
+ if isinstance(slots, bool) or not isinstance(slots, int) or not 1 <= slots <= 64:
183
+ raise ValueError("Hitch image build slots must be between 1 and 64")
184
+ self.directory.mkdir(mode=0o700, parents=True, exist_ok=True)
185
+ if self.directory.stat().st_uid != os.getuid() or self.directory.stat().st_mode & 0o022:
186
+ raise ValueError("Hitch image cache must be owned by this user and not writable by others")
187
+ self.env, self.slots, self.daemon = env, slots, None
188
+
189
+ async def identity(self):
190
+ if self.env.get("DOCKER_BUILDKIT", "").lower() in ("0", "f", "false"):
191
+ raise UnsupportedBuild("legacy-builder")
192
+ try:
193
+ return await self._identity()
194
+ except UnsupportedBuild:
195
+ raise
196
+ except (RuntimeError, TimeoutError, OSError, ValueError, TypeError, AttributeError) as error:
197
+ # Optional capability probes must not prevent native Compose
198
+ # builds. Actual image build failures remain errors in prepare().
199
+ raise UnsupportedBuild("builder-capabilities") from error
200
+
201
+ async def _identity(self):
202
+ # Source policies can deny or remap even digest-pinned base inputs.
203
+ # Their files and external policy dependencies are outside this key.
204
+ if self.env.get("EXPERIMENTAL_BUILDKIT_SOURCE_POLICY") or self.env.get("BUILDX_DEFAULT_POLICY"):
205
+ raise UnsupportedBuild("source-policy")
206
+ # Buildx discovers enclosing Git state from the context's location,
207
+ # which an independent snapshot deliberately does not retain.
208
+ for name in ("BUILDX_GIT_LABELS", "BUILDX_GIT_CHECK_DIRTY"):
209
+ if self.env.get(name, "").lower() not in ("", "0", "f", "false"):
210
+ raise UnsupportedBuild("git-build-metadata")
211
+ if self.daemon is None:
212
+ raw = await docker(["info", "--format", "{{json .}}"], env=self.env, timeout=30)
213
+ data = json.loads(raw)
214
+ architecture = {"x86_64": "amd64", "aarch64": "arm64"}.get(data.get("Architecture"), data.get("Architecture"))
215
+ if not data.get("ID") or data.get("OSType") != "linux":
216
+ raise UnsupportedBuild("daemon-platform")
217
+ name = self.env.get("BUILDX_BUILDER")
218
+ if not name:
219
+ inspected = await docker(["buildx", "inspect"], env=self.env, timeout=30)
220
+ match = re.search(r"(?m)^Name:\s+(\S+)", inspected)
221
+ name = match[1] if match else None
222
+ # Human inspect output iterates driver-option maps in random order.
223
+ # Use structured data and discard activity, status and GC counters.
224
+ listing = await docker(["buildx", "ls", "--format", "{{json .}}"], env=self.env, timeout=30)
225
+ builders = [json.loads(line) for line in listing.splitlines() if line.strip()]
226
+ selected = next((entry for entry in builders if entry.get("Name") == name), None)
227
+ if not selected or not selected.get("Driver") or not selected.get("Nodes"):
228
+ raise UnsupportedBuild("builder-identity")
229
+ # A custom builder can run on a different architecture and have
230
+ # different defaults from the daemon Compose will use. Optimize
231
+ # only the current Docker context's built-in builder; let native
232
+ # Compose select and configure all other builders.
233
+ if selected["Driver"] != "docker" or (await docker(["context", "show"], env=self.env, timeout=30)).strip() != name:
234
+ raise UnsupportedBuild("nondefault-builder")
235
+ nodes = []
236
+ for node in selected["Nodes"]:
237
+ normalized = {key: node.get(key) for key in ("Name", "Endpoint", "DriverOpts", "Flags", "Version", "ProxyConfig", "Labels")}
238
+ normalized.update({key: sorted(node.get(key) or []) for key in ("Platforms", "IDs")})
239
+ nodes.append(normalized)
240
+ builder = {"name": name, "driver": selected["Driver"], "nodes": sorted(nodes, key=lambda n: str(n["Name"]))}
241
+ config = Path(self.env.get("DOCKER_CONFIG") or str(Path(self.env.get("HOME") or Path.home()) / ".docker")) / "config.json"
242
+ try:
243
+ proxies = json.loads(config.read_text()).get("proxies", {})
244
+ except FileNotFoundError:
245
+ proxies = {}
246
+ except (OSError, ValueError, AttributeError) as error:
247
+ raise UnsupportedBuild("docker-client-config") from error
248
+ self.daemon = {"id": data["ID"], "platform": f"linux/{architecture}", "builder": digest(builder), "builder_name": name, "proxy_config": digest(proxies)}
249
+ return self.daemon
250
+
251
+ async def inspect(self, image, *, platform=None):
252
+ selection = ["--platform", platform] if platform else []
253
+ raw = await docker(["image", "inspect", *selection, "--format", "{{json .}}", image], env=self.env, timeout=30, check=False)
254
+ return json.loads(raw) if raw else None
255
+
256
+ async def _inspect_for_reuse(self, image, *, platform=None):
257
+ try:
258
+ return await self.inspect(image, platform=platform)
259
+ except (TimeoutError, OSError, ValueError) as error:
260
+ # A failed optional probe cannot certify reuse, but must not block
261
+ # native Compose. Post-build verification remains strict below.
262
+ raise UnsupportedBuild("image-inspection") from error
263
+
264
+ async def check_bases(self, bases, platform):
265
+ # Digest-pinned base definitions can contain inherited ONBUILD triggers.
266
+ # Inspect the immutable config once; a registry error falls back to Harbor.
267
+ for base in bases:
268
+ # Older receipts did not validate variant selection.
269
+ key = digest(["base-v2", base, platform])
270
+ path = self.directory / f"base-{key}.json"
271
+ async with lock([self.directory / f"base-{key}.lock"]):
272
+ cached = read_json(path)
273
+ if cached == {"base": base, "platform": platform, "no_onbuild": True}:
274
+ continue
275
+ # Ask Docker to select the requested index member. A default
276
+ # inspection can refer to another pre-pulled variant, even
277
+ # when its config omits the optional Variant field. Older
278
+ # daemons lacking this option use the registry/fallback path.
279
+ info = await self._inspect_for_reuse(base, platform=platform)
280
+ if not image_matches_platform(info, platform):
281
+ try:
282
+ raw = await docker(["buildx", "imagetools", "inspect", base, "--format", "{{json .Image}}"], env=self.env, timeout=30)
283
+ info = json.loads(raw)
284
+ info = info.get(platform, info)
285
+ config = info.get("config")
286
+ if not isinstance(config, dict) or not image_matches_platform(info, platform):
287
+ raise UnsupportedBuild("base-platform")
288
+ except (RuntimeError, asyncio.TimeoutError, OSError, ValueError, AttributeError) as error:
289
+ raise UnsupportedBuild("base-config-unavailable") from error
290
+ else:
291
+ config = info.get("Config", {})
292
+ if config.get("OnBuild") or config.get("onbuild"):
293
+ raise UnsupportedBuild("inherited-onbuild")
294
+ atomic_json(path, {"base": base, "platform": platform, "no_onbuild": True})
295
+
296
+ async def prepare(self, service):
297
+ started = time.monotonic()
298
+ phases = {}
299
+ daemon = await self.identity()
300
+ source, spec = build_spec(service, self.env.get("DOCKER_DEFAULT_PLATFORM") or daemon["platform"])
301
+ if self.directory.is_relative_to(source):
302
+ raise UnsupportedBuild("cache-inside-context")
303
+ async with context_snapshot(self.directory) as temporary:
304
+ context = temporary / "context"
305
+ phase = time.monotonic()
306
+ context_digest = await snapshot_context(source, context)
307
+ phases["snapshot_seconds"] = time.monotonic() - phase
308
+ bases = pinned_dockerfile(context / spec["dockerfile"])
309
+ phase = time.monotonic()
310
+ await self.check_bases(bases, spec["platform"])
311
+ phases["base_check_seconds"] = time.monotonic() - phase
312
+ implicit = {name: self.env.get(name) for name in (
313
+ "BUILDX_BUILDER", "BUILDKIT_HOST", "SOURCE_DATE_EPOCH",
314
+ *_PROXY_NAMES, *(name.lower() for name in _PROXY_NAMES),
315
+ )}
316
+ implicit.update({name: value for name, value in self.env.items() if name.startswith(("BUILDX_", "BUILDKIT_"))})
317
+ key = digest({"schema": 1, "context": context_digest, "build": spec, "daemon": daemon,
318
+ "implicit": implicit})
319
+ record = self.directory / f"image-{key}.json"
320
+ tag = f"hitch-prepared:{digest(str(self.directory))[:16]}-{key}"
321
+ phase = time.monotonic()
322
+ async with lock([self.directory / f"image-{key}.lock"]):
323
+ phases["key_lock_wait_seconds"] = time.monotonic() - phase
324
+ cached = read_json(record)
325
+ if isinstance(cached, dict) and cached.get("key") == key and _IMAGE_ID.fullmatch(str(cached.get("image"))):
326
+ phase = time.monotonic()
327
+ info = await self._inspect_for_reuse(cached["image"])
328
+ phases["probe_seconds"] = time.monotonic() - phase
329
+ if self.valid_image(info, key, spec["platform"]):
330
+ return {"image": info["Id"], "cache_key": key, "cache_hit": True, "seconds": time.monotonic() - started, "phases": phases}
331
+ slots = [self.directory / f"slot-{digest(daemon['id'])}-{index}.lock" for index in range(self.slots)]
332
+ phase = time.monotonic()
333
+ async with lock(slots):
334
+ phases["build_slot_wait_seconds"] = time.monotonic() - phase
335
+ args = ["buildx", "build", "--builder", daemon["builder_name"], "--load", "--progress", "plain", "--platform", spec["platform"],
336
+ "--file", str(context / spec["dockerfile"]), "--tag", tag]
337
+ if spec["target"]:
338
+ args.extend(["--target", spec["target"]])
339
+ for key_name, value in sorted(spec["args"].items()):
340
+ args.extend(["--build-arg", f"{key_name}={value}"])
341
+ for key_name, value in sorted(spec["labels"].items()):
342
+ args.extend(["--label", f"{key_name}={value}"])
343
+ args.extend(["--label", f"io.hitch.prepared-key={key}", str(context)])
344
+ phase = time.monotonic()
345
+ await docker(args, env=self.env, timeout=None)
346
+ phases["build_seconds"] = time.monotonic() - phase
347
+ info = await self.inspect(tag)
348
+ if not self.valid_image(info, key, spec["platform"]):
349
+ raise RuntimeError("Hitch prepared image identity/platform mismatch")
350
+ atomic_json(record, {"key": key, "image": info["Id"], "platform": spec["platform"], "tag": tag})
351
+ return {"image": info["Id"], "cache_key": key, "cache_hit": False, "seconds": time.monotonic() - started, "phases": phases}
352
+
353
+ @staticmethod
354
+ def valid_image(info, key, platform):
355
+ return (isinstance(info, dict) and _IMAGE_ID.fullmatch(str(info.get("Id")))
356
+ and image_matches_platform(info, platform)
357
+ and (info.get("Config", {}).get("Labels") or {}).get("io.hitch.prepared-key") == key)
@@ -0,0 +1,303 @@
1
+ """Read-only content inventory for the interpreter which launches Harbor.
2
+
3
+ Invoked with the actual launcher interpreter, before starting a task. Generated
4
+ bytecode caches do not change identity; a cache that can disagree with its source
5
+ is rejected instead of being silently omitted. No imported package is trusted to
6
+ report its own version or integrity.
7
+ """
8
+
9
+ import hashlib
10
+ from collections import Counter
11
+ import importlib.util
12
+ import json
13
+ import marshal
14
+ import os
15
+ from pathlib import Path
16
+ import re
17
+ import stat
18
+ import struct
19
+ import sys
20
+ import sysconfig
21
+ import types
22
+ import zipimport
23
+
24
+
25
+ MAX_FILES = 250_000
26
+ MAX_BYTES = 2 * 1024**3
27
+ LIMIT_ENV_PREFIX = "HITCH_HARBOR_PYTHON_RUNTIME_"
28
+
29
+
30
+ def inventory_limit(name, default):
31
+ """Bound host work independently of the candidate's task and model deadlines."""
32
+ value = os.environ.get(LIMIT_ENV_PREFIX + name)
33
+ if value is None:
34
+ return default
35
+ if not re.fullmatch(r"[1-9][0-9]{0,18}", value) or int(value) > sys.maxsize:
36
+ raise ValueError("invalid Python runtime inventory limit")
37
+ return int(value)
38
+
39
+
40
+ def constant_key(value):
41
+ """Compare compiled constants without NaN equality or signed-zero ambiguity."""
42
+ if isinstance(value, types.CodeType):
43
+ # Preserve the interpreter's code comparison for every non-constant field.
44
+ return (types.CodeType, value.replace(co_consts=()),
45
+ tuple(constant_key(item) for item in value.co_consts))
46
+ if isinstance(value, float):
47
+ return (float, struct.pack("!d", value))
48
+ if isinstance(value, complex):
49
+ return (complex, struct.pack("!dd", value.real, value.imag))
50
+ if isinstance(value, tuple):
51
+ return (tuple, tuple(constant_key(item) for item in value))
52
+ if isinstance(value, frozenset):
53
+ # Distinct NaNs with identical bits can coexist in a frozen set.
54
+ return (frozenset, frozenset(Counter(constant_key(item) for item in value).items()))
55
+ return (type(value), value)
56
+
57
+
58
+ def nan_graph(value, references):
59
+ """Project the only constants whose equality depends on shared identity."""
60
+ if isinstance(value, (float, complex)) and value != value:
61
+ references[id(value)] += 1
62
+ return ("nan", id(value))
63
+ if isinstance(value, types.CodeType):
64
+ value = value.co_consts
65
+ if isinstance(value, (tuple, frozenset)):
66
+ children = []
67
+ for index, child in enumerate(value):
68
+ graph = nan_graph(child, references)
69
+ if graph is not None:
70
+ label = constant_key(child) if isinstance(value, frozenset) else index
71
+ children.append((label, graph))
72
+ if children:
73
+ return ("set" if isinstance(value, frozenset) else "sequence", tuple(children))
74
+ return None
75
+
76
+
77
+ def mapped_nan_graph(graph, mapping=None):
78
+ """An order-independent key once every left-hand NaN has a known partner."""
79
+ kind, items = graph
80
+ if kind == "nan":
81
+ identity = items if mapping is None else mapping.get(items)
82
+ return None if identity is None else (kind, identity)
83
+ children = []
84
+ for label, child in items:
85
+ key = mapped_nan_graph(child, mapping)
86
+ if key is None:
87
+ return None
88
+ children.append((label, key))
89
+ return (kind, frozenset(Counter(children).items()) if kind == "set" else tuple(children))
90
+
91
+
92
+ def compiled_constants_equal(left, right):
93
+ if constant_key(left) != constant_key(right):
94
+ return False
95
+ left_refs, right_refs = Counter(), Counter()
96
+ left_graph, right_graph = nan_graph(left, left_refs), nan_graph(right, right_refs)
97
+ if sorted(left_refs.values()) != sorted(right_refs.values()):
98
+ return False
99
+ if all(count == 1 for count in left_refs.values()):
100
+ return True
101
+
102
+ def set_choices(first, second, pending, forward, backward):
103
+ label, child = first[0]
104
+ for index, (other_label, other_child) in enumerate(second):
105
+ if label == other_label:
106
+ remaining = second[:index] + second[index + 1:]
107
+ yield (pending + [(("set", first[1:]), ("set", remaining)), (child, other_child)],
108
+ forward.copy(), backward.copy())
109
+
110
+ # Set iteration order changes across compilation/marshal. Match a bijection
111
+ # lazily, retaining alternatives when later ordered constants constrain it.
112
+ # An explicit stack avoids recursion proportional to the number of constants.
113
+ choices = [iter([([(left_graph, right_graph)], {}, {})])]
114
+ while choices:
115
+ state = next(choices[-1], None)
116
+ if state is None:
117
+ choices.pop()
118
+ continue
119
+ pending, forward, backward = state
120
+ while pending:
121
+ first, second = pending.pop()
122
+ kind, items = first
123
+ other_kind, other_items = second
124
+ if kind != other_kind or (kind != "nan" and len(items) != len(other_items)):
125
+ break
126
+ if kind == "nan":
127
+ if forward.get(items, other_items) != other_items or backward.get(other_items, items) != items:
128
+ break
129
+ forward[items], backward[other_items] = other_items, items
130
+ elif kind == "sequence":
131
+ if any(a[0] != b[0] for a, b in zip(items, other_items)):
132
+ break
133
+ for a, b in reversed(tuple(zip(items, other_items))):
134
+ pair = (a[1], b[1])
135
+ # Ordered positions constrain identities without guessing.
136
+ # Apply all such anchors before considering unordered sets.
137
+ if a[1][0] == "set":
138
+ pending.insert(0, pair)
139
+ else:
140
+ pending.append(pair)
141
+ elif items:
142
+ mapped = mapped_nan_graph(first, forward)
143
+ if mapped is not None:
144
+ if mapped != mapped_nan_graph(second):
145
+ break
146
+ continue
147
+ choices.append(set_choices(items, other_items, pending, forward, backward))
148
+ break
149
+ else:
150
+ return True
151
+ return False
152
+
153
+
154
+ def inventory(launcher):
155
+ max_files = inventory_limit("MAX_FILES", MAX_FILES)
156
+ max_bytes = inventory_limit("MAX_BYTES", MAX_BYTES)
157
+ launcher = Path(launcher).absolute()
158
+ if not launcher.is_file():
159
+ raise ValueError("missing launcher")
160
+ if sys.pycache_prefix is not None:
161
+ raise ValueError("external Python cache directories are unsupported")
162
+ # A -c probe starts with cwd; a script starts with its containing directory.
163
+ # Never accept relative PYTHONPATH entries: execution uses a different cwd.
164
+ search_path = list(sys.path) if getattr(sys.flags, "safe_path", False) else [str(launcher.resolve(strict=True).parent), *sys.path[1:]]
165
+ if any(not os.path.isabs(value) for value in search_path):
166
+ raise ValueError("relative Python module path")
167
+ records = {}
168
+ snapshots = {}
169
+ directories = {}
170
+ active = set()
171
+ visited = set()
172
+ byte_count = 0
173
+
174
+ def read(file):
175
+ nonlocal byte_count
176
+ before = file.stat()
177
+ if not stat.S_ISREG(before.st_mode):
178
+ raise ValueError("non-regular Python runtime file")
179
+ if byte_count + before.st_size > max_bytes:
180
+ raise ValueError("Python runtime inventory exceeds byte limit")
181
+ data = file.read_bytes()
182
+ byte_count += len(data)
183
+ after = file.stat()
184
+ signature = lambda s: (s.st_dev, s.st_ino, s.st_size, s.st_mtime_ns, s.st_ctime_ns, s.st_mode)
185
+ if signature(before) != signature(after):
186
+ raise ValueError("Python runtime changed while reading")
187
+ snapshots[str(file)] = signature(after)
188
+ return data
189
+
190
+ def cache_is_derived(file):
191
+ if file.suffix != ".pyc" or file.parent.name != "__pycache__":
192
+ return False
193
+ tag = re.escape(sys.implementation.cache_tag)
194
+ match = re.fullmatch(r"(.+)\." + tag + r"(?:\.opt-([012]))?\.pyc", file.name)
195
+ if not match:
196
+ # Another interpreter's tagged cache cannot be imported here.
197
+ return True
198
+ source = file.parent.parent / (match[1] + ".py")
199
+ if not source.is_file():
200
+ # CPython does not import orphan files inside __pycache__.
201
+ return True
202
+ data = read(file)
203
+ if len(data) < 16 or data[:4] != importlib.util.MAGIC_NUMBER:
204
+ raise ValueError("unverifiable Python bytecode cache")
205
+ code = marshal.loads(data[16:])
206
+ if not isinstance(code, types.CodeType):
207
+ raise ValueError("invalid Python bytecode cache")
208
+ expected = compile(read(source), code.co_filename, "exec", dont_inherit=True, optimize=int(match[2] or 0))
209
+ if not compiled_constants_equal(code, expected):
210
+ raise ValueError("Python bytecode cache disagrees with source")
211
+ return True
212
+
213
+ def visit(file):
214
+ name = str(file)
215
+ if name in visited:
216
+ return
217
+ # Derived caches still consume traversal work even though they do not
218
+ # contribute records to the runtime's content identity.
219
+ if len(visited) >= max_files:
220
+ raise ValueError("Python runtime inventory exceeds file limit")
221
+ visited.add(name)
222
+ if file.is_symlink():
223
+ target = file.resolve(strict=True)
224
+ records[name] = ["link", os.readlink(file), str(target)]
225
+ visit(target)
226
+ elif file.is_dir():
227
+ if name in active:
228
+ raise ValueError("cyclic Python runtime directory")
229
+ active.add(name)
230
+ names = sorted(os.listdir(file))
231
+ directories[name] = names
232
+ # Cache directories and verified caches are derived, not inputs.
233
+ if file.name != "__pycache__":
234
+ records[name] = ["directory"]
235
+ for child in names:
236
+ visit(file / child)
237
+ active.remove(name)
238
+ elif file.is_file():
239
+ if cache_is_derived(file):
240
+ return
241
+ data = read(file)
242
+ records[name] = ["file", stat.S_IMODE(file.stat().st_mode), hashlib.sha256(data).hexdigest()]
243
+ elif file.exists():
244
+ raise ValueError("unsupported Python runtime file")
245
+ else:
246
+ # sys.path and module origins can refer inside an archive. Those
247
+ # paths are importable even though filesystem stat reports missing.
248
+ try:
249
+ importer = zipimport.zipimporter(name)
250
+ except zipimport.ZipImportError:
251
+ records[name] = ["missing"]
252
+ else:
253
+ archive = Path(importer.archive)
254
+ records[name] = ["zip-path", str(archive), importer.prefix]
255
+ visit(archive)
256
+
257
+ for file in [launcher, Path(sys.executable), Path(sys.prefix) / "pyvenv.cfg"]:
258
+ visit(file)
259
+ for value in search_path:
260
+ visit(Path(value))
261
+ # Startup hooks (for example Homebrew sitecustomize) may have been imported
262
+ # from a directory later removed from sys.path. Include their actual files.
263
+ for module in list(sys.modules.values()):
264
+ origin = getattr(module, "__file__", None)
265
+ if isinstance(origin, str):
266
+ file = Path(origin)
267
+ if not file.is_absolute():
268
+ file = file.absolute()
269
+ visit(file)
270
+ library = Path(sysconfig.get_config_var("LIBDIR") or sys.base_prefix) / (sysconfig.get_config_var("LDLIBRARY") or "libpython")
271
+ visit(library)
272
+ for name, expected in snapshots.items():
273
+ s = Path(name).stat()
274
+ if (s.st_dev, s.st_ino, s.st_size, s.st_mtime_ns, s.st_ctime_ns, s.st_mode) != expected:
275
+ raise ValueError("Python runtime changed during inventory")
276
+ for name, expected in directories.items():
277
+ if sorted(os.listdir(name)) != expected:
278
+ raise ValueError("Python runtime directory changed during inventory")
279
+ identity = {
280
+ "schema_version": "1",
281
+ "implementation": sys.implementation.name,
282
+ "version": sys.version,
283
+ "prefix": sys.prefix,
284
+ "base_prefix": sys.base_prefix,
285
+ "search_path": search_path,
286
+ "files": sorted(records.items()),
287
+ }
288
+ encoded = json.dumps(identity, sort_keys=True, ensure_ascii=True, separators=(",", ":")).encode()
289
+ return {
290
+ "schema_version": "1",
291
+ "integrity": "sha256:" + hashlib.sha256(encoded).hexdigest(),
292
+ "python_version": ".".join(map(str, sys.version_info[:3])),
293
+ "file_count": sum(value[0] == "file" for value in records.values()),
294
+ }
295
+
296
+
297
+ if __name__ == "__main__":
298
+ try:
299
+ print(json.dumps(inventory(sys.argv[1]), separators=(",", ":")))
300
+ except Exception:
301
+ # Paths or package import errors can contain credentials or private data.
302
+ print("Harbor Python runtime cannot be verified", file=sys.stderr)
303
+ sys.exit(1)