letify 2.8.1__tar.gz → 2.8.2__tar.gz

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 (214) hide show
  1. {letify-2.8.1 → letify-2.8.2}/PKG-INFO +1 -1
  2. {letify-2.8.1 → letify-2.8.2}/docs/SPEC.md +18 -1
  3. {letify-2.8.1 → letify-2.8.2}/letify/__init__.py +1 -1
  4. {letify-2.8.1 → letify-2.8.2}/letify/protocol/worker.py +102 -1
  5. {letify-2.8.1 → letify-2.8.2}/letify/runtime/session.py +183 -52
  6. {letify-2.8.1 → letify-2.8.2}/letify/store/volume.py +16 -9
  7. {letify-2.8.1 → letify-2.8.2}/letify/transport/nat.py +15 -1
  8. {letify-2.8.1 → letify-2.8.2}/letify/transport/strategies.py +101 -15
  9. {letify-2.8.1 → letify-2.8.2}/letify-core/Cargo.lock +3 -3
  10. {letify-2.8.1 → letify-2.8.2}/letify-core/Cargo.toml +1 -1
  11. {letify-2.8.1 → letify-2.8.2}/letify-ext/package-lock.json +2 -2
  12. {letify-2.8.1 → letify-2.8.2}/letify-ext/package.json +1 -1
  13. {letify-2.8.1 → letify-2.8.2}/pyproject.toml +1 -1
  14. {letify-2.8.1 → letify-2.8.2}/tests/conftest.py +4 -0
  15. {letify-2.8.1 → letify-2.8.2}/tests/test_drive_store.py +108 -1
  16. {letify-2.8.1 → letify-2.8.2}/tests/test_strategies.py +125 -0
  17. {letify-2.8.1 → letify-2.8.2}/.github/workflows/ci.yml +0 -0
  18. {letify-2.8.1 → letify-2.8.2}/.github/workflows/docs.yml +0 -0
  19. {letify-2.8.1 → letify-2.8.2}/.github/workflows/publish.yml +0 -0
  20. {letify-2.8.1 → letify-2.8.2}/.gitignore +0 -0
  21. {letify-2.8.1 → letify-2.8.2}/.researchtree +0 -0
  22. {letify-2.8.1 → letify-2.8.2}/CLAUDE.md +0 -0
  23. {letify-2.8.1 → letify-2.8.2}/LICENSE +0 -0
  24. {letify-2.8.1 → letify-2.8.2}/PROJECT.md +0 -0
  25. {letify-2.8.1 → letify-2.8.2}/README.md +0 -0
  26. {letify-2.8.1 → letify-2.8.2}/docs/COMPONENT.md +0 -0
  27. {letify-2.8.1 → letify-2.8.2}/docs/INTENT.md +0 -0
  28. {letify-2.8.1 → letify-2.8.2}/docs/NETWORK.md +0 -0
  29. {letify-2.8.1 → letify-2.8.2}/docs/guide/01-getting-started.md +0 -0
  30. {letify-2.8.1 → letify-2.8.2}/docs/guide/02-providers.md +0 -0
  31. {letify-2.8.1 → letify-2.8.2}/docs/guide/03-execution-modes.md +0 -0
  32. {letify-2.8.1 → letify-2.8.2}/docs/guide/04-environments-and-data.md +0 -0
  33. {letify-2.8.1 → letify-2.8.2}/docs/guide/05-concurrency.md +0 -0
  34. {letify-2.8.1 → letify-2.8.2}/docs/guide/06-cost.md +0 -0
  35. {letify-2.8.1 → letify-2.8.2}/docs/guide/07-troubleshooting.md +0 -0
  36. {letify-2.8.1 → letify-2.8.2}/docs/guide/08-releasing.md +0 -0
  37. {letify-2.8.1 → letify-2.8.2}/docs/guide/README.md +0 -0
  38. {letify-2.8.1 → letify-2.8.2}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  39. {letify-2.8.1 → letify-2.8.2}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  40. {letify-2.8.1 → letify-2.8.2}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  41. {letify-2.8.1 → letify-2.8.2}/docs/guide/ko/04-/355/231/230/352/262/275/352/263/274-/353/215/260/354/235/264/355/204/260.md" +0 -0
  42. {letify-2.8.1 → letify-2.8.2}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  43. {letify-2.8.1 → letify-2.8.2}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  44. {letify-2.8.1 → letify-2.8.2}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  45. {letify-2.8.1 → letify-2.8.2}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  46. {letify-2.8.1 → letify-2.8.2}/docs/guide/ko/README.md +0 -0
  47. {letify-2.8.1 → letify-2.8.2}/docs/locales/README_ko.md +0 -0
  48. {letify-2.8.1 → letify-2.8.2}/docs/mkdocs.yml +0 -0
  49. {letify-2.8.1 → letify-2.8.2}/examples/README.md +0 -0
  50. {letify-2.8.1 → letify-2.8.2}/examples/nvfp4-lora/.letify.example +0 -0
  51. {letify-2.8.1 → letify-2.8.2}/examples/nvfp4-lora/00_smoke.py +0 -0
  52. {letify-2.8.1 → letify-2.8.2}/examples/nvfp4-lora/01_sweep.py +0 -0
  53. {letify-2.8.1 → letify-2.8.2}/examples/nvfp4-lora/02_resume.py +0 -0
  54. {letify-2.8.1 → letify-2.8.2}/examples/nvfp4-lora/03_watch.py +0 -0
  55. {letify-2.8.1 → letify-2.8.2}/examples/nvfp4-lora/README.md +0 -0
  56. {letify-2.8.1 → letify-2.8.2}/examples/nvfp4-lora/README_ko.md +0 -0
  57. {letify-2.8.1 → letify-2.8.2}/examples/nvfp4-lora/common.py +0 -0
  58. {letify-2.8.1 → letify-2.8.2}/examples/nvfp4-lora/recipe.py +0 -0
  59. {letify-2.8.1 → letify-2.8.2}/letify/cli.py +0 -0
  60. {letify-2.8.1 → letify-2.8.2}/letify/config/__init__.py +0 -0
  61. {letify-2.8.1 → letify-2.8.2}/letify/config/inventory.py +0 -0
  62. {letify-2.8.1 → letify-2.8.2}/letify/config/login.py +0 -0
  63. {letify-2.8.1 → letify-2.8.2}/letify/config/schema.py +0 -0
  64. {letify-2.8.1 → letify-2.8.2}/letify/config/secrets.py +0 -0
  65. {letify-2.8.1 → letify-2.8.2}/letify/config/writer.py +0 -0
  66. {letify-2.8.1 → letify-2.8.2}/letify/declare/__init__.py +0 -0
  67. {letify-2.8.1 → letify-2.8.2}/letify/declare/cache.py +0 -0
  68. {letify-2.8.1 → letify-2.8.2}/letify/declare/env.py +0 -0
  69. {letify-2.8.1 → letify-2.8.2}/letify/declare/function.py +0 -0
  70. {letify-2.8.1 → letify-2.8.2}/letify/declare/instance.py +0 -0
  71. {letify-2.8.1 → letify-2.8.2}/letify/declare/reduce.py +0 -0
  72. {letify-2.8.1 → letify-2.8.2}/letify/declare/replicas.py +0 -0
  73. {letify-2.8.1 → letify-2.8.2}/letify/errors.py +0 -0
  74. {letify-2.8.1 → letify-2.8.2}/letify/install.py +0 -0
  75. {letify-2.8.1 → letify-2.8.2}/letify/launcher.py +0 -0
  76. {letify-2.8.1 → letify-2.8.2}/letify/paths.py +0 -0
  77. {letify-2.8.1 → letify-2.8.2}/letify/protocol/__init__.py +0 -0
  78. {letify-2.8.1 → letify-2.8.2}/letify/protocol/codec.py +0 -0
  79. {letify-2.8.1 → letify-2.8.2}/letify/protocol/driver.py +0 -0
  80. {letify-2.8.1 → letify-2.8.2}/letify/protocol/handle.py +0 -0
  81. {letify-2.8.1 → letify-2.8.2}/letify/protocol/wire.py +0 -0
  82. {letify-2.8.1 → letify-2.8.2}/letify/providers/__init__.py +0 -0
  83. {letify-2.8.1 → letify-2.8.2}/letify/providers/base.py +0 -0
  84. {letify-2.8.1 → letify-2.8.2}/letify/providers/colab.py +0 -0
  85. {letify-2.8.1 → letify-2.8.2}/letify/providers/colab_files.py +0 -0
  86. {letify-2.8.1 → letify-2.8.2}/letify/providers/colab_keepalive.py +0 -0
  87. {letify-2.8.1 → letify-2.8.2}/letify/providers/elice.py +0 -0
  88. {letify-2.8.1 → letify-2.8.2}/letify/providers/kaggle.py +0 -0
  89. {letify-2.8.1 → letify-2.8.2}/letify/providers/kaggle_adapter.py +0 -0
  90. {letify-2.8.1 → letify-2.8.2}/letify/providers/local.py +0 -0
  91. {letify-2.8.1 → letify-2.8.2}/letify/providers/modal.py +0 -0
  92. {letify-2.8.1 → letify-2.8.2}/letify/providers/modal_adapter.py +0 -0
  93. {letify-2.8.1 → letify-2.8.2}/letify/providers/naming.py +0 -0
  94. {letify-2.8.1 → letify-2.8.2}/letify/providers/shell.py +0 -0
  95. {letify-2.8.1 → letify-2.8.2}/letify/providers/tunnel.py +0 -0
  96. {letify-2.8.1 → letify-2.8.2}/letify/providers/usage.py +0 -0
  97. {letify-2.8.1 → letify-2.8.2}/letify/remoting/__init__.py +0 -0
  98. {letify-2.8.1 → letify-2.8.2}/letify/remoting/capability.py +0 -0
  99. {letify-2.8.1 → letify-2.8.2}/letify/remoting/device/__init__.py +0 -0
  100. {letify-2.8.1 → letify-2.8.2}/letify/remoting/device/client.py +0 -0
  101. {letify-2.8.1 → letify-2.8.2}/letify/remoting/device/cuda.py +0 -0
  102. {letify-2.8.1 → letify-2.8.2}/letify/remoting/device/executor.py +0 -0
  103. {letify-2.8.1 → letify-2.8.2}/letify/remoting/device/frames.py +0 -0
  104. {letify-2.8.1 → letify-2.8.2}/letify/remoting/device/guard.py +0 -0
  105. {letify-2.8.1 → letify-2.8.2}/letify/remoting/device/tensor.py +0 -0
  106. {letify-2.8.1 → letify-2.8.2}/letify/remoting/device/trace.py +0 -0
  107. {letify-2.8.1 → letify-2.8.2}/letify/remoting/device/value.py +0 -0
  108. {letify-2.8.1 → letify-2.8.2}/letify/remoting/loader.py +0 -0
  109. {letify-2.8.1 → letify-2.8.2}/letify/remoting/probe.py +0 -0
  110. {letify-2.8.1 → letify-2.8.2}/letify/render.py +0 -0
  111. {letify-2.8.1 → letify-2.8.2}/letify/runtime/__init__.py +0 -0
  112. {letify-2.8.1 → letify-2.8.2}/letify/runtime/bootstrap.py +0 -0
  113. {letify-2.8.1 → letify-2.8.2}/letify/runtime/broker.py +0 -0
  114. {letify-2.8.1 → letify-2.8.2}/letify/runtime/channel.py +0 -0
  115. {letify-2.8.1 → letify-2.8.2}/letify/runtime/lease.py +0 -0
  116. {letify-2.8.1 → letify-2.8.2}/letify/runtime/pool.py +0 -0
  117. {letify-2.8.1 → letify-2.8.2}/letify/runtime/telemetry.py +0 -0
  118. {letify-2.8.1 → letify-2.8.2}/letify/store/__init__.py +0 -0
  119. {letify-2.8.1 → letify-2.8.2}/letify/store/backends/__init__.py +0 -0
  120. {letify-2.8.1 → letify-2.8.2}/letify/store/backends/drive.py +0 -0
  121. {letify-2.8.1 → letify-2.8.2}/letify/store/backends/filesystem.py +0 -0
  122. {letify-2.8.1 → letify-2.8.2}/letify/store/backends/google_auth.py +0 -0
  123. {letify-2.8.1 → letify-2.8.2}/letify/store/backends/layout.py +0 -0
  124. {letify-2.8.1 → letify-2.8.2}/letify/store/backends/objects.py +0 -0
  125. {letify-2.8.1 → letify-2.8.2}/letify/store/cas.py +0 -0
  126. {letify-2.8.1 → letify-2.8.2}/letify/store/handoff.py +0 -0
  127. {letify-2.8.1 → letify-2.8.2}/letify/store/pathdata.py +0 -0
  128. {letify-2.8.1 → letify-2.8.2}/letify/store/sendorder.py +0 -0
  129. {letify-2.8.1 → letify-2.8.2}/letify/stubs.py +0 -0
  130. {letify-2.8.1 → letify-2.8.2}/letify/tools.py +0 -0
  131. {letify-2.8.1 → letify-2.8.2}/letify/transport/__init__.py +0 -0
  132. {letify-2.8.1 → letify-2.8.2}/letify/transport/agent.py +0 -0
  133. {letify-2.8.1 → letify-2.8.2}/letify/transport/announce.py +0 -0
  134. {letify-2.8.1 → letify-2.8.2}/letify/transport/link.py +0 -0
  135. {letify-2.8.1 → letify-2.8.2}/letify/transport/pipeline.py +0 -0
  136. {letify-2.8.1 → letify-2.8.2}/letify/transport/portmap.py +0 -0
  137. {letify-2.8.1 → letify-2.8.2}/letify/transport/probe.py +0 -0
  138. {letify-2.8.1 → letify-2.8.2}/letify/transport/quic.py +0 -0
  139. {letify-2.8.1 → letify-2.8.2}/letify/transport/rendezvous.py +0 -0
  140. {letify-2.8.1 → letify-2.8.2}/letify/transport/setup.py +0 -0
  141. {letify-2.8.1 → letify-2.8.2}/letify/transport/sshopts.py +0 -0
  142. {letify-2.8.1 → letify-2.8.2}/letify-core/.gitignore +0 -0
  143. {letify-2.8.1 → letify-2.8.2}/letify-core/build.py +0 -0
  144. {letify-2.8.1 → letify-2.8.2}/letify-core/letify-agent/Cargo.toml +0 -0
  145. {letify-2.8.1 → letify-2.8.2}/letify-core/letify-agent/src/driver.rs +0 -0
  146. {letify-2.8.1 → letify-2.8.2}/letify-core/letify-agent/src/main.rs +0 -0
  147. {letify-2.8.1 → letify-2.8.2}/letify-core/letify-driver/Cargo.toml +0 -0
  148. {letify-2.8.1 → letify-2.8.2}/letify-core/letify-driver/src/client.rs +0 -0
  149. {letify-2.8.1 → letify-2.8.2}/letify-core/letify-driver/src/exports.rs +0 -0
  150. {letify-2.8.1 → letify-2.8.2}/letify-core/letify-driver/src/image.rs +0 -0
  151. {letify-2.8.1 → letify-2.8.2}/letify-core/letify-driver/src/lib.rs +0 -0
  152. {letify-2.8.1 → letify-2.8.2}/letify-core/letify-driver/src/status.rs +0 -0
  153. {letify-2.8.1 → letify-2.8.2}/letify-core/letify-driver/src/table.rs +0 -0
  154. {letify-2.8.1 → letify-2.8.2}/letify-core/letify-quic/Cargo.toml +0 -0
  155. {letify-2.8.1 → letify-2.8.2}/letify-core/letify-quic/src/main.rs +0 -0
  156. {letify-2.8.1 → letify-2.8.2}/letify-core/letify-wire/Cargo.toml +0 -0
  157. {letify-2.8.1 → letify-2.8.2}/letify-core/letify-wire/src/lib.rs +0 -0
  158. {letify-2.8.1 → letify-2.8.2}/letify-core/letify-wire/tests/throughput.rs +0 -0
  159. {letify-2.8.1 → letify-2.8.2}/letify-core/rust-toolchain.toml +0 -0
  160. {letify-2.8.1 → letify-2.8.2}/letify-ext/.vscodeignore +0 -0
  161. {letify-2.8.1 → letify-2.8.2}/letify-ext/LICENSE +0 -0
  162. {letify-2.8.1 → letify-2.8.2}/letify-ext/README.md +0 -0
  163. {letify-2.8.1 → letify-2.8.2}/letify-ext/media/icon.svg +0 -0
  164. {letify-2.8.1 → letify-2.8.2}/letify-ext/src/cli.ts +0 -0
  165. {letify-2.8.1 → letify-2.8.2}/letify-ext/src/extension.ts +0 -0
  166. {letify-2.8.1 → letify-2.8.2}/letify-ext/src/history.ts +0 -0
  167. {letify-2.8.1 → letify-2.8.2}/letify-ext/src/model.ts +0 -0
  168. {letify-2.8.1 → letify-2.8.2}/letify-ext/src/poll.ts +0 -0
  169. {letify-2.8.1 → letify-2.8.2}/letify-ext/src/render.ts +0 -0
  170. {letify-2.8.1 → letify-2.8.2}/letify-ext/test/cli.test.ts +0 -0
  171. {letify-2.8.1 → letify-2.8.2}/letify-ext/test/manifest.test.ts +0 -0
  172. {letify-2.8.1 → letify-2.8.2}/letify-ext/test/model.test.ts +0 -0
  173. {letify-2.8.1 → letify-2.8.2}/letify-ext/test/regressions.test.ts +0 -0
  174. {letify-2.8.1 → letify-2.8.2}/letify-ext/tsconfig.json +0 -0
  175. {letify-2.8.1 → letify-2.8.2}/letify_providers.py +0 -0
  176. {letify-2.8.1 → letify-2.8.2}/scripts/sync-main.sh +0 -0
  177. {letify-2.8.1 → letify-2.8.2}/scripts/version.py +0 -0
  178. {letify-2.8.1 → letify-2.8.2}/tests/fake_kaggle_adapter.py +0 -0
  179. {letify-2.8.1 → letify-2.8.2}/tests/fake_modal_adapter.py +0 -0
  180. {letify-2.8.1 → letify-2.8.2}/tests/test_cli.py +0 -0
  181. {letify-2.8.1 → letify-2.8.2}/tests/test_cli_output.py +0 -0
  182. {letify-2.8.1 → letify-2.8.2}/tests/test_colab_keepalive.py +0 -0
  183. {letify-2.8.1 → letify-2.8.2}/tests/test_config.py +0 -0
  184. {letify-2.8.1 → letify-2.8.2}/tests/test_core.py +0 -0
  185. {letify-2.8.1 → letify-2.8.2}/tests/test_data.py +0 -0
  186. {letify-2.8.1 → letify-2.8.2}/tests/test_data_streaming.py +0 -0
  187. {letify-2.8.1 → letify-2.8.2}/tests/test_declare.py +0 -0
  188. {letify-2.8.1 → letify-2.8.2}/tests/test_device.py +0 -0
  189. {letify-2.8.1 → letify-2.8.2}/tests/test_distributed.py +0 -0
  190. {letify-2.8.1 → letify-2.8.2}/tests/test_drive_handoff.py +0 -0
  191. {letify-2.8.1 → letify-2.8.2}/tests/test_editor_sessions.py +0 -0
  192. {letify-2.8.1 → letify-2.8.2}/tests/test_elice.py +0 -0
  193. {letify-2.8.1 → letify-2.8.2}/tests/test_frames.py +0 -0
  194. {letify-2.8.1 → letify-2.8.2}/tests/test_install.py +0 -0
  195. {letify-2.8.1 → letify-2.8.2}/tests/test_kaggle.py +0 -0
  196. {letify-2.8.1 → letify-2.8.2}/tests/test_kaggle_cookie.py +0 -0
  197. {letify-2.8.1 → letify-2.8.2}/tests/test_kaggle_link.py +0 -0
  198. {letify-2.8.1 → letify-2.8.2}/tests/test_link_log.py +0 -0
  199. {letify-2.8.1 → letify-2.8.2}/tests/test_notebook.py +0 -0
  200. {letify-2.8.1 → letify-2.8.2}/tests/test_parallel_placement.py +0 -0
  201. {letify-2.8.1 → letify-2.8.2}/tests/test_paths.py +0 -0
  202. {letify-2.8.1 → letify-2.8.2}/tests/test_portmap.py +0 -0
  203. {letify-2.8.1 → letify-2.8.2}/tests/test_protocol.py +0 -0
  204. {letify-2.8.1 → letify-2.8.2}/tests/test_providers.py +0 -0
  205. {letify-2.8.1 → letify-2.8.2}/tests/test_quic.py +0 -0
  206. {letify-2.8.1 → letify-2.8.2}/tests/test_remoting.py +0 -0
  207. {letify-2.8.1 → letify-2.8.2}/tests/test_render.py +0 -0
  208. {letify-2.8.1 → letify-2.8.2}/tests/test_runtime.py +0 -0
  209. {letify-2.8.1 → letify-2.8.2}/tests/test_ship.py +0 -0
  210. {letify-2.8.1 → letify-2.8.2}/tests/test_store.py +0 -0
  211. {letify-2.8.1 → letify-2.8.2}/tests/test_stubs.py +0 -0
  212. {letify-2.8.1 → letify-2.8.2}/tests/test_transport.py +0 -0
  213. {letify-2.8.1 → letify-2.8.2}/tests/test_tunnel_setup.py +0 -0
  214. {letify-2.8.1 → letify-2.8.2}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 2.8.1
3
+ Version: 2.8.2
4
4
  Summary: Declarations that become infrastructure.
5
5
  Project-URL: Homepage, https://github.com/DarkPyonix/letify
6
6
  Project-URL: Documentation, https://github.com/DarkPyonix/letify/tree/main/docs
@@ -1275,7 +1275,9 @@ Letify keeps its files in a folder named `letify` at the root of that Drive, one
1275
1275
 
1276
1276
  An ephemeral provider uses the Drive store for its environment archive when the machine declares one and the account sets no `bucket`, which keeps precedence. A provider whose environment survives the session on its own disk or volume does not use it. An archive on Drive is an uncompressed tar, because compression was most of the cost: on a Kaggle runtime, packing a 5.5 GB environment as tar.gz and uploading it took about 12 minutes, against an upload of roughly 77 s for the uncompressed tree at the measured rate.
1277
1277
 
1278
- Whether an archive is worth it is measured, not assumed, because a provider with a fast package index can build faster than it restores. On a Kaggle runtime the same 5.5 GB environment built in 35.4 s and restored from Drive in about 90 s. So after a sync letify packs only when the build took longer than the archive would take to restore, estimated as the project directory's size over 176 MiB/s, the slower Drive download measured, plus the same size over 170 MB/s, the extraction rate measured on a sandbox disk. The record beside the archive keeps the build seconds and, once a session has restored from it, the restore seconds that session measured; a later session restores only while the measured restore is shorter than the measured build, and syncs otherwise, saying `letify: <runtime>: syncing rather than restoring from Drive, because a restore took <s> s against a <s> s build`.
1278
+ Whether an archive is worth it is measured, not assumed, because a provider with a fast package index can build faster than it restores. On a Kaggle runtime the same 5.5 GB environment built in 35.4 s and restored from Drive in about 90 s. So after a sync letify packs only when the build took at least twice as long as the archive would take to restore, estimated as the project directory's size over 176 MiB/s, the slower Drive download measured, plus the same size over 170 MB/s, the extraction rate measured on a sandbox disk. The record beside the archive keeps the build seconds and, once a session has restored from it, the restore seconds that session measured; a later session restores only while the measured restore is shorter than the measured build, and syncs otherwise, saying `letify: <runtime>: syncing rather than restoring from Drive, because a restore took <s> s against a <s> s build`. The factor of two is there because packing has a cost of its own that the comparison leaves out: on a Kaggle runtime a 7.2 GB environment that built in 87.6 s against an estimated 81 s restore was packed, which would save about 6 s a session at the price of the pack.
1279
+
1280
+ The pack to Drive runs on the worker beside the session's calls, not before them. After the interpreter check the session starts the pack as a background job and returns, so the first call starts at once; the worker answers calls on its main thread while a separate thread writes the tar and uploads it, and the archive's ref and build record are written only when the upload completes. The upload session needs the archive's digest and size, so the worker reports them once the tar is written, and a client thread that checks every 2 s creates the resumable session and hands its URL to the worker, which then uploads; the client records and reports the result at the start and end of a call and at session close. A session that ends first leaves no ref, and the next session syncs and may pack again. While it runs the pack says nothing more after `caching the environment, <size> GB`; when it ends it says `letify: <runtime>: cached the environment, <size> GB in <s> s (<rate> MiB/s)`, or the Drive failure line above, on standard error of whichever call or session close observes it first. Closing the session waits up to 60 s for a pack still running and then abandons it, saying `letify: <runtime>: not caching the environment, because the session closed <s> s into the pack`.
1279
1281
 
1280
1282
  Drive's own limits apply. The archive counts against the Google account's storage, which is the person's own plan. Google documents a daily upload limit of 750 GB per user for Google Workspace accounts, independent of the storage plan; an environment archive is a few gigabytes, so letify does not approach it. Standard use of the Drive API costs nothing, and Google documents that exceeding its request quota is planned to be charged to the project's billing account later in 2026; letify's requests are a handful per session against a quota of 325,000 units per user per minute.
1281
1283
 
@@ -1910,6 +1912,21 @@ It is opt-in because it needs the user's machine to accept inbound SSH and needs
1910
1912
 
1911
1913
  The key is per session. letify generates a new ed25519 key pair for each session and appends the public key to the user's `~/.ssh/authorized_keys` with `restrict,port-forwarding,command="/bin/false"` and the comment `letify-session <alias>`. The private key reaches the remote side through the rendezvous. Closing the link removes the line. Before a new session's key is added, every line carrying the `letify-session` marker for that alias is removed, so a crashed session never leaves a key behind.
1912
1914
 
1915
+ #### Reverse SSH through a jump host <!-- id: reverse-ssh-jump -->
1916
+
1917
+ > `reverse_ssh` takes an optional `jump`, for a user's machine whose SSH server the internet reaches only through another server.
1918
+
1919
+ ```toml
1920
+ [colab_pro_plus]
1921
+ reverse_ssh = { address = "168.188.127.175", port = 30008, user = "work", jump = { address = "jump.example.org", port = 20070, user = "root", key = "~/.ssh/id_jump" } }
1922
+ ```
1923
+
1924
+ With `jump`, `address` and `port` are the user's SSH server as the jump host reaches it, and the remote side connects to `<user>@<address>:<port>` through `-o ProxyCommand="ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i <session key> -W %h:%p -p <jump port> <jump user>@<jump address>"` and forwards its port back with `-R` as before. It is a ProxyCommand rather than `-J`, because the options given for the main hop on the command line do not reach a `-J` hop.
1925
+
1926
+ The per-session key authorizes both hops. Besides the user's own `~/.ssh/authorized_keys`, letify appends the session's public key to the jump host's `~/.ssh/authorized_keys` with `restrict,port-forwarding,permitopen="<address>:<port>",command="/bin/false"` and the comment `letify-session <alias>`, so the key opens a connection to the user's SSH server and nothing else. letify reaches the jump host from the user's machine with `ssh -i <jump key> -p <jump port> <jump user>@<jump address>`, `BatchMode=yes`, and edits the file there with a shell command; `key` is optional and defaults to the SSH client's own keys. Closing the link removes that line, and before a new session's key is added every line carrying the `letify-session` marker for that alias is removed from the jump host too, as on the user's machine. The jump key never leaves the user's machine.
1927
+
1928
+ A jump host that letify cannot log in to, or whose `authorized_keys` it cannot edit, fails the strategy with `reverse_ssh: the jump host <user>@<address>:<port> refused letify (<reason>)`, before anything is sent to the remote side.
1929
+
1913
1930
  ### Colab <!-- id: colab-transport -->
1914
1931
 
1915
1932
  > Colab is a `Shell` whose rendezvous is `colab exec`. It has no forward SSH, and its fallback is `colab exec` with the Colab file API.
@@ -68,7 +68,7 @@ distributed: Final = Host.distributed
68
68
  # The enum class stays importable from letify.declare.instance for letify's own use.
69
69
  del Host
70
70
 
71
- __version__ = "2.8.1"
71
+ __version__ = "2.8.2"
72
72
 
73
73
 
74
74
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -2367,6 +2367,7 @@ def _op_push_blob(request):
2367
2367
 
2368
2368
  url = request.pop("url")
2369
2369
  headers = request.pop("headers", None) or {}
2370
+ cancel = request.pop("cancel", None)
2370
2371
  path = _data_file(os.path.expanduser(request["dir"]), request["digest"])
2371
2372
  size = os.path.getsize(path)
2372
2373
  piece = int(request.get("piece") or (64 << 20))
@@ -2374,6 +2375,8 @@ def _op_push_blob(request):
2374
2375
  with open(path, "rb") as handle:
2375
2376
  offset = 0
2376
2377
  while offset < size:
2378
+ if cancel is not None and cancel.is_set():
2379
+ raise RuntimeError("the upload was abandoned")
2377
2380
  block = handle.read(piece)
2378
2381
  end = offset + len(block)
2379
2382
  span = "bytes %d-%d/%d" % (offset, end - 1, size)
@@ -2395,6 +2398,98 @@ def _op_push_blob(request):
2395
2398
  return {"ok": True, "value": {"id": remote_id, "size": size}}
2396
2399
 
2397
2400
 
2401
+ #: The one environment pack running beside the session's calls, or None. Spec "Project
2402
+ #: data / environment archive": the pack is a background job on the worker.
2403
+ _PACK_JOB = {}
2404
+
2405
+
2406
+ def _pack_push_run(job, request):
2407
+ """Pack a directory, wait for the upload target, upload it, and remove the staging dir.
2408
+
2409
+ Runs on its own thread, so the worker's main thread keeps answering calls. The state
2410
+ goes packing, packed, uploading, then done or failed. The staging directory is gone
2411
+ before done or failed is reported.
2412
+ """
2413
+ import shutil
2414
+
2415
+ began = time.monotonic()
2416
+ directory = os.path.expanduser(request["dir"])
2417
+ result = None
2418
+ try:
2419
+ packed = _op_pack_blob(
2420
+ {"path": request["path"], "dir": request["dir"], "gzip": False}
2421
+ )["value"]
2422
+ job["digest"], job["size"] = packed["digest"], packed["size"]
2423
+ job["state"] = "packed"
2424
+ job["ready"].wait()
2425
+ target = job["target"]
2426
+ if target is None:
2427
+ raise RuntimeError("the pack was abandoned")
2428
+ job["state"] = "uploading"
2429
+ sent = _op_push_blob(
2430
+ {
2431
+ "dir": packed["dir"],
2432
+ "digest": packed["digest"],
2433
+ "url": target["url"],
2434
+ "headers": target.get("headers") or {},
2435
+ "piece": target.get("piece"),
2436
+ "cancel": job["cancel"],
2437
+ }
2438
+ )["value"]
2439
+ result = {"state": "done", "id": sent["id"], "size": sent["size"]}
2440
+ except BaseException as exc:
2441
+ result = {"state": "failed", "reason": str(exc) or type(exc).__name__}
2442
+ finally:
2443
+ shutil.rmtree(directory, ignore_errors=True)
2444
+ result["seconds"] = time.monotonic() - began
2445
+ job.update(result)
2446
+
2447
+
2448
+ def _op_pack_push_start(request):
2449
+ """Start packing a directory on a thread of its own and return at once."""
2450
+ import threading
2451
+
2452
+ current = _PACK_JOB.get("job")
2453
+ if current is not None and current["state"] in ("packing", "packed", "uploading"):
2454
+ return {"ok": False, "error": "a pack is already running", "traceback": ""}
2455
+ job = {
2456
+ "state": "packing",
2457
+ "target": None,
2458
+ "ready": threading.Event(),
2459
+ "cancel": threading.Event(),
2460
+ }
2461
+ _PACK_JOB["job"] = job
2462
+ threading.Thread(target=_pack_push_run, args=(job, request), daemon=True).start()
2463
+ return {"ok": True, "value": None}
2464
+
2465
+
2466
+ def _op_pack_push_target(request):
2467
+ """Hand the running pack its upload session, or abandon it when ``url`` is None."""
2468
+ job = _PACK_JOB.get("job")
2469
+ if job is None:
2470
+ return {"ok": False, "error": "no pack is running", "traceback": ""}
2471
+ if request.get("url") is None:
2472
+ job["cancel"].set()
2473
+ job["target"] = None
2474
+ else:
2475
+ job["target"] = {
2476
+ "url": request["url"],
2477
+ "headers": request.get("headers") or {},
2478
+ "piece": request.get("piece"),
2479
+ }
2480
+ job["ready"].set()
2481
+ return {"ok": True, "value": None}
2482
+
2483
+
2484
+ def _op_pack_push_state(request):
2485
+ """Report the pack: its state, and the digest, size, file id, seconds or reason it has."""
2486
+ job = _PACK_JOB.get("job")
2487
+ if job is None:
2488
+ return {"ok": True, "value": {"state": "none"}}
2489
+ keys = ("state", "digest", "size", "id", "seconds", "reason")
2490
+ return {"ok": True, "value": {key: job[key] for key in keys if key in job}}
2491
+
2492
+
2398
2493
  def _op_place_blob(request):
2399
2494
  """Move a received file blob to its destination path, and unpack it when asked.
2400
2495
 
@@ -2539,6 +2634,9 @@ _OPS = {
2539
2634
  "pack_dir": _op_pack_dir,
2540
2635
  "pack_blob": _op_pack_blob,
2541
2636
  "push_blob": _op_push_blob,
2637
+ "pack_push_start": _op_pack_push_start,
2638
+ "pack_push_target": _op_pack_push_target,
2639
+ "pack_push_state": _op_pack_push_state,
2542
2640
  "place_blob": _op_place_blob,
2543
2641
  "exec": _op_exec,
2544
2642
  "eval": _op_eval,
@@ -2553,7 +2651,10 @@ _OPS = {
2553
2651
  #: Answered by the frame reader at once, so they work while a call runs.
2554
2652
  # ``data_failed`` is light because it must not queue behind the call it is about: the call
2555
2653
  # is blocked on a blob the sender will never send, so a queued answer would deadlock.
2556
- _LIGHT = ("stat", "lease", "reduce_peers", "drive_grant", "data_failed")
2654
+ _LIGHT = (
2655
+ "stat", "lease", "reduce_peers", "drive_grant", "data_failed",
2656
+ "pack_push_state", "pack_push_target",
2657
+ )
2557
2658
 
2558
2659
  #: Answered by several replies, the last one marked. These send their own replies.
2559
2660
  _STREAMING = ("data_stream",)
@@ -22,6 +22,7 @@ import datetime
22
22
  import json
23
23
  import posixpath
24
24
  import sys
25
+ import threading
25
26
  import time
26
27
  from collections.abc import Callable, Iterator
27
28
  from dataclasses import dataclass, field
@@ -62,6 +63,16 @@ DRIVE_DOWNLOAD_BPS = 176 * 1024**2
62
63
  #: 170 MB/s on a sandbox disk. Spec "Materializing into a runtime".
63
64
  EXTRACT_BPS = 170_000_000
64
65
 
66
+ #: How many times the estimated restore a build must have taken before the environment is
67
+ #: packed to Drive. Spec "Materializing into a runtime": packing has a cost of its own.
68
+ DRIVE_PACK_FACTOR = 2
69
+
70
+ #: Seconds a closing session waits for a pack still running before it abandons it.
71
+ PACK_CLOSE_WAIT_S = 60.0
72
+
73
+ #: Seconds between the checks that hand a finished pack its upload session.
74
+ PACK_POLL_S = 2.0
75
+
65
76
 
66
77
  def _against_the_build(size: int, rate: float, build_s: float) -> str:
67
78
  """The three numbers both cache decisions weigh, as they are reported.
@@ -87,6 +98,15 @@ def _sync_rather_than_restore(archive_bytes: int, rate: float, build_s: float) -
87
98
  )
88
99
 
89
100
 
101
+ @dataclass
102
+ class _DrivePack:
103
+ """A pack the worker is running for a Drive store, as this process tracks it."""
104
+
105
+ volume: Any
106
+ began: float = field(default_factory=time.monotonic)
107
+ target_sent: bool = False
108
+
109
+
90
110
  @dataclass
91
111
  class Runtime:
92
112
  """One live session on one provider."""
@@ -155,6 +175,11 @@ class Runtime:
155
175
  #: Set once ``shutdown`` has run, so a second discard does nothing.
156
176
  _shut_down: bool = field(default=False, repr=False)
157
177
 
178
+ #: The environment pack running on the worker, or None. Spec "Materializing into a
179
+ #: runtime": the pack to Drive runs beside the session's calls.
180
+ _pack: _DrivePack | None = field(default=None, repr=False)
181
+ _pack_lock: Any = field(default_factory=threading.RLock, repr=False)
182
+
158
183
  @property
159
184
  def key(self) -> str:
160
185
  """Pool key. Runtimes with equal keys are interchangeable."""
@@ -215,14 +240,12 @@ class Runtime:
215
240
  + (f", {size / 1_000_000_000:.1f} GB" if size else "")
216
241
  )
217
242
  volume = self._environment_volumes()[0]
218
- try:
243
+ if volume.store.backend.moves_on_runtime:
244
+ self._start_drive_pack(volume, root)
245
+ else:
219
246
  volume.cache_env_from(
220
247
  self, self.env, root, platform=self.platform, build_s=self.env_build_s
221
248
  )
222
- except Exception as exc:
223
- if not volume.store.backend.moves_on_runtime:
224
- raise
225
- self._not_using_drive(exc, after="not caching the environment")
226
249
  self.keep_blobs_on_disk()
227
250
  for volume in self.volumes:
228
251
  self.attach(volume)
@@ -263,6 +286,7 @@ class Runtime:
263
286
  if self._shut_down:
264
287
  return
265
288
  self._shut_down = True
289
+ self._close_pack()
266
290
  timestamp = datetime.datetime.now(datetime.UTC).isoformat()
267
291
  print(
268
292
  f"{timestamp} letify: discarding {self.name} provider={self.provider.alias} "
@@ -371,9 +395,13 @@ class Runtime:
371
395
  fn, args, kwargs, timeout=timeout, modules=self.env.ship_modules
372
396
  )
373
397
  args, kwargs = self._externalize(args, kwargs)
374
- return self._call_with_data(
375
- fn, args, kwargs, timeout, data_order, data_first_wave, replica
376
- )
398
+ self._poll_pack()
399
+ try:
400
+ return self._call_with_data(
401
+ fn, args, kwargs, timeout, data_order, data_first_wave, replica
402
+ )
403
+ finally:
404
+ self._poll_pack()
377
405
 
378
406
  def _call_with_data(
379
407
  self,
@@ -695,46 +723,6 @@ class Runtime:
695
723
  )
696
724
  return digest, size
697
725
 
698
- def pack_dir_to_store(self, path: str, backend: Any) -> tuple[str, int]:
699
- """Pack a directory inside the runtime and have the runtime upload the archive.
700
-
701
- Spec "Materializing into a runtime": the archive goes from the runtime's blob cache
702
- to the backend without crossing this process. This process creates the upload
703
- session, because it holds the credential, and the runtime is sent that session's
704
- URL alone. Returns ``(digest, size)``. Needs a persistent channel, and raises
705
- rather than routing the archive through this process.
706
- """
707
- if not self.persistent_channel:
708
- raise RuntimeFailure("this channel keeps no worker that could upload the archive")
709
- staging = f"{posixpath.dirname(path.rstrip('/'))}/.letify-pack"
710
- value = self.request(
711
- {"op": "pack_blob", "path": path, "dir": staging, "gzip": False}, timeout=3600
712
- )
713
- digest, size, directory = value["digest"], int(value["size"]), value["dir"]
714
- try:
715
- if not backend.has(digest):
716
- target = backend.begin_upload(digest, size)
717
- if target is None:
718
- raise RuntimeFailure(f"the {backend.name} backend offered no upload")
719
- sent = self.request(
720
- {
721
- "op": "push_blob",
722
- "dir": directory,
723
- "digest": digest,
724
- "url": target["url"],
725
- "headers": dict(target.get("headers") or {}), # type: ignore[call-overload]
726
- "piece": target.get("piece"),
727
- },
728
- timeout=3600,
729
- )
730
- backend.record_upload(digest, str(sent["id"]))
731
- finally:
732
- self.exec(
733
- f"import shutil\nshutil.rmtree({directory!r}, ignore_errors=True)\n",
734
- timeout=120,
735
- )
736
- return digest, size
737
-
738
726
  def put_path(
739
727
  self,
740
728
  local: Path,
@@ -937,24 +925,167 @@ class Runtime:
937
925
  return False
938
926
 
939
927
  def _worth_packing_to_drive(self, root: str) -> bool:
940
- """Whether the build took longer than restoring the archive from Drive would.
928
+ """Whether the build took at least twice as long as restoring from Drive would.
941
929
 
942
930
  Spec "Materializing into a runtime": the archive goes from the runtime to Drive and
943
931
  never over this link, so the gate is the estimated restore, the project directory's
944
- size over the Drive download rate plus the same size over the extraction rate.
932
+ size over the Drive download rate plus the same size over the extraction rate. The
933
+ build must take ``DRIVE_PACK_FACTOR`` times that, because the pack has a cost of its
934
+ own that the comparison leaves out.
945
935
  """
946
936
  size = self._tree_size(root)
947
937
  if size is None or self.env_build_s is None:
948
938
  return True
949
939
  restore_s = size / DRIVE_DOWNLOAD_BPS + size / EXTRACT_BPS
950
- if self.env_build_s > restore_s:
940
+ if self.env_build_s >= DRIVE_PACK_FACTOR * restore_s:
951
941
  return True
952
942
  self._say(
953
943
  f"not caching the environment, because it built in {self.env_build_s:.0f} s "
954
- f"and would take an estimated {restore_s:.0f} s to restore from Drive"
944
+ f"and would take an estimated {restore_s:.0f} s to restore from Drive, and a "
945
+ f"cache pays only when the build takes at least {DRIVE_PACK_FACTOR} times as long"
955
946
  )
956
947
  return False
957
948
 
949
+ # -- the pack that runs beside the calls ---------------------------------
950
+
951
+ def _start_drive_pack(self, volume: Volume, root: str) -> None:
952
+ """Have the worker pack the environment and upload it on a thread of its own.
953
+
954
+ Spec "Materializing into a runtime": this returns at once, so the first call does
955
+ not wait for the pack. The ref and the build record are written when a poll sees
956
+ the upload finish. A failure to start leaves nothing cached and says so.
957
+ """
958
+ staging = f"{posixpath.dirname(root.rstrip('/'))}/.letify-pack"
959
+ try:
960
+ if not self.persistent_channel:
961
+ raise RuntimeFailure("this channel keeps no worker that could upload the archive")
962
+ self.request(
963
+ {"op": "pack_push_start", "path": root, "dir": staging}, timeout=120
964
+ )
965
+ except Exception as exc:
966
+ self._not_using_drive(exc, after="not caching the environment")
967
+ return
968
+ self._pack = _DrivePack(volume)
969
+ threading.Thread(target=self._feed_pack, name="letify-pack", daemon=True).start()
970
+
971
+ def _feed_pack(self) -> None:
972
+ """Hand the pack its upload session soon after the archive is written.
973
+
974
+ A call can run for hours, and the upload cannot start until this process has made
975
+ the session, so this does only that. Reporting stays with the calls and the close.
976
+ """
977
+ while True:
978
+ time.sleep(PACK_POLL_S)
979
+ job = self._pack
980
+ if job is None or job.target_sent or self._shut_down:
981
+ return
982
+ self._poll_pack(report=False)
983
+
984
+ def _poll_pack(self, *, report: bool = True) -> bool:
985
+ """Ask the worker how the pack is doing and act on the answer.
986
+
987
+ A packed archive is given its upload session. A finished upload is recorded, and
988
+ reported when ``report`` is set. False when the worker could not be asked.
989
+ """
990
+ with self._pack_lock:
991
+ job = self._pack
992
+ if job is None or self.channel is None:
993
+ return True
994
+ try:
995
+ state = self.request({"op": "pack_push_state"}, timeout=30)
996
+ except Exception:
997
+ return False
998
+ kind = state.get("state")
999
+ backend = job.volume.store.backend
1000
+ if kind == "packed" and not job.target_sent:
1001
+ job.target_sent = True
1002
+ try:
1003
+ if backend.has(state["digest"]):
1004
+ # Another session stored this archive first, so there is nothing to send.
1005
+ self._send_target(None)
1006
+ self._pack = None
1007
+ self._record_pack(job, state["digest"], int(state["size"]))
1008
+ return True
1009
+ target = backend.begin_upload(state["digest"], int(state["size"]))
1010
+ if target is None:
1011
+ raise RuntimeFailure(f"the {backend.name} backend offered no upload")
1012
+ self._send_target(target)
1013
+ except Exception as exc:
1014
+ self._pack = None
1015
+ try:
1016
+ self._send_target(None)
1017
+ except Exception:
1018
+ pass
1019
+ self._not_using_drive(exc, after="not caching the environment")
1020
+ elif kind == "done" and report:
1021
+ self._pack = None
1022
+ backend.record_upload(state["digest"], str(state["id"]))
1023
+ self._record_pack(job, state["digest"], int(state["size"]))
1024
+ seconds = float(state["seconds"])
1025
+ size = int(state["size"])
1026
+ self._say(
1027
+ f"cached the environment, {size / 1_000_000_000:.1f} GB in {seconds:.0f} s "
1028
+ f"({size / 1024**2 / max(seconds, 0.001):.1f} MiB/s)"
1029
+ )
1030
+ elif kind == "failed" and report:
1031
+ self._pack = None
1032
+ self._not_using_drive(
1033
+ RuntimeFailure(str(state.get("reason"))), after="not caching the environment"
1034
+ )
1035
+ elif kind == "none":
1036
+ self._pack = None
1037
+ return True
1038
+
1039
+ def _send_target(self, target: dict[str, Any] | None) -> None:
1040
+ """Give the worker the upload session, or None to abandon the pack."""
1041
+ payload: dict[str, Any] = {"op": "pack_push_target", "url": None}
1042
+ if target is not None:
1043
+ payload.update(
1044
+ url=target["url"],
1045
+ headers=dict(target.get("headers") or {}), # type: ignore[call-overload]
1046
+ piece=target.get("piece"),
1047
+ )
1048
+ self.request(payload, timeout=60)
1049
+
1050
+ def _record_pack(self, job: _DrivePack, digest: str, size: int) -> None:
1051
+ """Write the ref and the build record, which only a finished upload earns."""
1052
+ job.volume.record_env(
1053
+ self.env, self.platform, digest, size, build_s=self.env_build_s
1054
+ )
1055
+
1056
+ def _close_pack(self) -> None:
1057
+ """Wait for a running pack, up to ``PACK_CLOSE_WAIT_S``, then abandon it.
1058
+
1059
+ Spec "Materializing into a runtime": an abandoned pack records nothing, so the
1060
+ next session syncs and may pack again.
1061
+ """
1062
+ job = self._pack
1063
+ if job is None:
1064
+ return
1065
+ deadline = time.monotonic() + PACK_CLOSE_WAIT_S
1066
+ try:
1067
+ while self._pack is not None:
1068
+ if not self._poll_pack():
1069
+ self._pack = None
1070
+ return
1071
+ if self._pack is None:
1072
+ return
1073
+ if time.monotonic() >= deadline:
1074
+ break
1075
+ time.sleep(0.2)
1076
+ with self._pack_lock:
1077
+ self._pack = None
1078
+ try:
1079
+ self._send_target(None)
1080
+ except Exception:
1081
+ pass
1082
+ self._say(
1083
+ "not caching the environment, because the session closed "
1084
+ f"{time.monotonic() - job.began:.0f} s into the pack"
1085
+ )
1086
+ except Exception:
1087
+ self._pack = None
1088
+
958
1089
  def _restore_from(self, volume: Volume, where: dict[str, Any]) -> bool:
959
1090
  """Restore the environment from one volume's archive. True when the check passed.
960
1091
 
@@ -24,7 +24,7 @@ from pathlib import Path
24
24
  from typing import TYPE_CHECKING, Any
25
25
 
26
26
  from .. import protocol
27
- from .cas import BlobInfo, Store
27
+ from .cas import Store
28
28
 
29
29
  if TYPE_CHECKING:
30
30
  from ..declare.env import Env
@@ -236,22 +236,29 @@ class Volume:
236
236
  """
237
237
  runtime = _session(target)
238
238
  scratch = self.store.scratch_path() if self.store.streams else None
239
- if self.store.backend.moves_on_runtime:
240
- # The runtime uploads the archive itself, so it never crosses this process.
241
- digest, size = runtime.pack_dir_to_store(path, self.store.backend)
242
- info = BlobInfo(digest, size)
243
- elif scratch is not None and runtime.persistent_channel:
239
+ if scratch is not None and runtime.persistent_channel:
244
240
  # Streamed: the archive lands in a file beside the store and is renamed in.
245
241
  digest, _size = runtime.pack_dir_to_file(path, scratch)
246
242
  info = self.store.adopt_file(scratch, digest)
247
243
  else:
248
244
  payload, _digest = runtime.pack_dir(path)
249
245
  info = self.store.put_bytes(payload)
250
- self.store.point(self.env_ref(env, platform), info.digest)
251
- if build_s is not None:
252
- self.store.point(self.build_ref(env, platform), f"{build_s:.1f} {info.size}")
246
+ self.record_env(env, platform, info.digest, info.size, build_s=build_s)
253
247
  return info.digest
254
248
 
249
+ def record_env(
250
+ self, env: Env, platform: str | None, digest: str, size: int, *,
251
+ build_s: float | None = None,
252
+ ) -> None:
253
+ """Point the env ref at a stored archive and write the build record beside it.
254
+
255
+ A Drive archive is uploaded by the runtime in the background, so its ref is written
256
+ by the session that saw the upload finish, not by the pack that started it.
257
+ """
258
+ self.store.point(self.env_ref(env, platform), digest)
259
+ if build_s is not None:
260
+ self.store.point(self.build_ref(env, platform), f"{build_s:.1f} {size}")
261
+
255
262
  def build_ref(self, env: Env, platform: str | None = None) -> str:
256
263
  """The ref recording the build seconds and archive size beside the env ref."""
257
264
  return f"{self.env_ref(env, platform)}.build"
@@ -15,6 +15,7 @@ import os
15
15
  import re
16
16
  import secrets
17
17
  import select
18
+ import shlex
18
19
  import socket
19
20
  import struct
20
21
  import subprocess
@@ -560,10 +561,23 @@ def begin(request: dict):
560
561
  descriptor = os.open(key, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
561
562
  with os.fdopen(descriptor, "w") as handle:
562
563
  handle.write(request["private_key"])
564
+ # With a jump host the same identity and host-key policy must reach both hops, and
565
+ # -J does not carry command-line options to the jump connection, so the hop is an
566
+ # explicit ProxyCommand that repeats them.
567
+ jump = request.get("jump")
568
+ hop: list[str] = []
569
+ if jump:
570
+ proxy = (
571
+ "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new"
572
+ f" -i {shlex.quote(str(key))}"
573
+ f" -W %h:%p -p {int(jump.get('port', 22))}"
574
+ f" {shlex.quote(str(jump['user']) + '@' + str(jump['address']))}"
575
+ )
576
+ hop = ["-o", f"ProxyCommand={proxy}"]
563
577
  process = subprocess.Popen(
564
578
  [
565
579
  "ssh", "-N", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes",
566
- "-o", "StrictHostKeyChecking=accept-new",
580
+ "-o", "StrictHostKeyChecking=accept-new", *hop,
567
581
  "-R", f"0:127.0.0.1:{int(request.get('ssh_port', 22))}",
568
582
  "-p", str(request["port"]), "-i", str(key),
569
583
  f"{request['user']}@{request['address']}",