@vantran-se/openchamber-web 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (442) hide show
  1. package/README.md +32 -24
  2. package/bin/cli.js +1 -3
  3. package/bin/cli.test.js +40 -16
  4. package/bin/lib/DOCUMENTATION.md +6 -5
  5. package/bin/lib/cli-args.js +6 -1
  6. package/bin/lib/cli-args.test.js +34 -0
  7. package/bin/lib/cli-lifecycle.js +0 -1
  8. package/bin/lib/cli-network.js +8 -2
  9. package/bin/lib/cli-process.js +1 -1
  10. package/bin/lib/cli-settings-accessors.test.js +21 -13
  11. package/bin/lib/cli-startup.js +17 -11
  12. package/bin/lib/cli-startup.test.js +92 -2
  13. package/bin/lib/commands-connect-url.js +8 -1
  14. package/bin/lib/commands-schedule.js +8 -2
  15. package/bin/lib/commands-serve.js +15 -21
  16. package/bin/lib/commands-update.js +12 -24
  17. package/bin/lib/commands-update.test.js +14 -46
  18. package/dist/assets/DiffView-Cv9MSXl1.js +4 -0
  19. package/dist/assets/FilePreviewCommentMenu-D4kLseHj.js +4 -0
  20. package/dist/assets/FilesView-BVoD8I2x.js +14 -0
  21. package/dist/assets/{GitView-DzuzhIPx.js → GitView-Bzb8p-ew.js} +4 -4
  22. package/dist/assets/GuestSurfacePane-Crdbvsxo.js +1 -0
  23. package/dist/assets/InProgressOperationBanner-CwMjNQgk.js +2 -0
  24. package/dist/assets/{JsonTreeView-B_EPRjzy.js → JsonTreeView-DRKHZfsn.js} +1 -1
  25. package/dist/assets/MarkdownImageGallery-DZtqT1Zn.js +1 -0
  26. package/dist/assets/MarkdownRendererImpl-CqG7HCNd.js +10 -0
  27. package/dist/assets/OnboardingScreen-DMIxwYK5.js +1 -0
  28. package/dist/assets/{PatchDiff-6yh1csza.js → PatchDiff-CUcCg1jk.js} +1 -1
  29. package/dist/assets/PierreDiffViewer-CnnR-dnD.js +228 -0
  30. package/dist/assets/PlanView-RCBadcY7.js +5 -0
  31. package/dist/assets/SettingsView-CjBjZFRO.js +30 -0
  32. package/dist/assets/SettingsWindow-DyM7QuhI.js +1 -0
  33. package/dist/assets/SubmoduleDiffSummary-BWIfgMED.js +1 -0
  34. package/dist/assets/ToolOutputDialog-Q0P3FuAi.js +15 -0
  35. package/dist/assets/ToolPartDiffPreview-p7qR9FpU.js +8 -0
  36. package/dist/assets/{VirtualizedFileDiff-BTJVRkyo.js → VirtualizedFileDiff-CFpjL8Ns.js} +3 -3
  37. package/dist/assets/{Virtualizer-xCEYz34p.js → Virtualizer-C0IsclZn.js} +1 -1
  38. package/dist/assets/WalkthroughView-DI31zD02.js +4 -0
  39. package/dist/assets/appThemeRegistry-B9gkFq9i.js +1 -0
  40. package/dist/assets/{computeVirtualFileMetrics-CmPe29DM.js → computeVirtualFileMetrics-C2WM6JC2.js} +2 -2
  41. package/dist/assets/de-YKfk5MmC.js +14 -0
  42. package/dist/assets/es-_gwT0kr-.js +20 -0
  43. package/dist/assets/fileTreeStatus-D438yi8M.js +1 -0
  44. package/dist/assets/fr-DqzvXmIm.js +18 -0
  45. package/dist/assets/index-BLaHKdtx.js +1 -0
  46. package/dist/assets/index-BkMXRrVQ.css +1 -0
  47. package/dist/assets/index-DxNa7xYB.js +65 -0
  48. package/dist/assets/initializeSharedPreferences-Bck5EAWP.js +1 -0
  49. package/dist/assets/{isDiffPlainText-CQmDCnEv.js → isDiffPlainText-Dgbw8VKk.js} +1 -1
  50. package/dist/assets/{isFilePlainText-CYBUzW4I.js → isFilePlainText-_ZS5UYRj.js} +1 -1
  51. package/dist/assets/ja-B-GFYhQE.js +20 -0
  52. package/dist/assets/ko-mzksfqDC.js +20 -0
  53. package/dist/assets/main-C5Xx8dn_.js +2 -0
  54. package/dist/assets/main-CfBSQM1H.js +990 -0
  55. package/dist/assets/markdownCore-BmWwW1ll.js +7 -0
  56. package/dist/assets/miniChat-mnKwy4vQ.js +2 -0
  57. package/dist/assets/mobile-DeEQd0wZ.js +2 -0
  58. package/dist/assets/nl-CWQr6NSq.js +20 -0
  59. package/dist/assets/{parseDiffFromFile-Cy0PNxun.js → parseDiffFromFile-BUgn-T9p.js} +2 -2
  60. package/dist/assets/patchFileDiff-Dt8LhgBh.js +13 -0
  61. package/dist/assets/pl-UmRcDUX2.js +20 -0
  62. package/dist/assets/pt-BR-BSLybvVF.js +20 -0
  63. package/dist/assets/relocateRestoredSession-B5E9xb3T.js +1 -0
  64. package/dist/assets/renderElectronMiniChatApp-C0a9A5_J.js +2 -0
  65. package/dist/assets/renderMobileApp-BNncg3Pk.js +25 -0
  66. package/dist/assets/runtimeEndpointReset-CZ5aafpl.js +62 -0
  67. package/dist/assets/{selectionMarkdown-TkYk5h9l.js → selectionMarkdown-CUN3wUld.js} +9 -9
  68. package/dist/assets/{sessionOwnership-Bs1xbRfB.js → sessionOwnership-BSuxCEhb.js} +1 -1
  69. package/dist/assets/shikiHighlight-DHDx-6eM.js +1 -0
  70. package/dist/assets/space-apply-DM_2pRW7.js +1 -0
  71. package/dist/assets/space-close-Bpomt8z0.js +1 -0
  72. package/dist/assets/{surface-B6n7wJtt.js → surface-C-jQaGnL.js} +1 -1
  73. package/dist/assets/{textMateThemeFromAppTheme-CDlKffWz.js → textMateThemeFromAppTheme-DlsTphbY.js} +1 -1
  74. package/dist/assets/tr-gaTrX_HK.js +11 -0
  75. package/dist/assets/uk-CnJF6ykl.js +20 -0
  76. package/dist/assets/{useAppFontEffects-DCqCVGlT.js → useAppFontEffects-DGlvOqP_.js} +194 -233
  77. package/dist/assets/useInlineCommentController-kMN4Wg_D.js +2 -0
  78. package/dist/assets/{useRootScrollLock-BowwS8kq.js → useRootScrollLock-DMPpvpNP.js} +2 -2
  79. package/dist/assets/{vendor-base-ui-DX96QhTP.js → vendor-base-ui-Det7U81h.js} +1 -1
  80. package/dist/assets/{vendor-codemirror-autocomplete-DW1FQz9_.js → vendor-codemirror-autocomplete-BSHQHbKu.js} +1 -1
  81. package/dist/assets/{vendor-codemirror-commands-DpmhalwN.js → vendor-codemirror-commands-C4G_dPWe.js} +1 -1
  82. package/dist/assets/{vendor-codemirror-lang-angular-BqExbN1M.js → vendor-codemirror-lang-angular-B6LbBGHB.js} +1 -1
  83. package/dist/assets/{vendor-codemirror-lang-cpp-DZA36HfS.js → vendor-codemirror-lang-cpp-BWWXBx7W.js} +1 -1
  84. package/dist/assets/{vendor-codemirror-lang-css-B07blnnZ.js → vendor-codemirror-lang-css-Cayfb29q.js} +1 -1
  85. package/dist/assets/{vendor-codemirror-lang-go-BM8K9D1-.js → vendor-codemirror-lang-go-BoKlCNKw.js} +1 -1
  86. package/dist/assets/{vendor-codemirror-lang-html-DaH9adfE.js → vendor-codemirror-lang-html-BQWerHsJ.js} +1 -1
  87. package/dist/assets/{vendor-codemirror-lang-java-CsdC0viz.js → vendor-codemirror-lang-java-DWX7Fq_K.js} +1 -1
  88. package/dist/assets/{vendor-codemirror-lang-javascript-CpGYXGuo.js → vendor-codemirror-lang-javascript-DbpEcZM0.js} +2 -2
  89. package/dist/assets/{vendor-codemirror-lang-jinja-BBeGzXw5.js → vendor-codemirror-lang-jinja-D1JIJRA7.js} +1 -1
  90. package/dist/assets/{vendor-codemirror-lang-json-58T70Hqs.js → vendor-codemirror-lang-json-BzgckzmB.js} +1 -1
  91. package/dist/assets/{vendor-codemirror-lang-less-BI-xCA59.js → vendor-codemirror-lang-less-B9EpDXFi.js} +1 -1
  92. package/dist/assets/{vendor-codemirror-lang-liquid-zEcMa-zy.js → vendor-codemirror-lang-liquid-9U7kRbSf.js} +1 -1
  93. package/dist/assets/{vendor-codemirror-lang-markdown-BLKnlLP4.js → vendor-codemirror-lang-markdown-D2TEBIvZ.js} +1 -1
  94. package/dist/assets/{vendor-codemirror-lang-php-9f35M83-.js → vendor-codemirror-lang-php-BT6sDVzE.js} +1 -1
  95. package/dist/assets/vendor-codemirror-lang-python-jAMtYiA9.js +3 -0
  96. package/dist/assets/{vendor-codemirror-lang-rust-MNDL_jZQ.js → vendor-codemirror-lang-rust-CVO56EiL.js} +1 -1
  97. package/dist/assets/{vendor-codemirror-lang-sass-p9epsSNm.js → vendor-codemirror-lang-sass-DZlOoUWu.js} +1 -1
  98. package/dist/assets/{vendor-codemirror-lang-sql-Bqzh0EXX.js → vendor-codemirror-lang-sql-fZ6t1DPw.js} +1 -1
  99. package/dist/assets/{vendor-codemirror-lang-vue-BnNUWV-p.js → vendor-codemirror-lang-vue-DxbqrLiE.js} +1 -1
  100. package/dist/assets/{vendor-codemirror-lang-wast-C2a-oLmk.js → vendor-codemirror-lang-wast-DyU1Gt-I.js} +1 -1
  101. package/dist/assets/vendor-codemirror-lang-xml-C9a_K5_p.js +1 -0
  102. package/dist/assets/{vendor-codemirror-lang-yaml-C0sjswB0.js → vendor-codemirror-lang-yaml-Cptq8l0e.js} +1 -1
  103. package/dist/assets/{vendor-codemirror-language-ByBn5zIW.js → vendor-codemirror-language-DufXoQvE.js} +2 -2
  104. package/dist/assets/vendor-codemirror-language-data-CtwSWKtX.js +2 -0
  105. package/dist/assets/{vendor-codemirror-search-ib-sHD5W.js → vendor-codemirror-search-CvXM_pZg.js} +1 -1
  106. package/dist/assets/{vendor-codemirror-state-G3UuR-be.js → vendor-codemirror-state-Dek9sMtw.js} +1 -1
  107. package/dist/assets/{vendor-codemirror-view-Bniqp7p5.js → vendor-codemirror-view-1e0FQ6Wn.js} +1 -1
  108. package/dist/assets/vendor-framer-motion-DxDVhOLc.js +9 -0
  109. package/dist/assets/vendor-katex-CuQvj9-L.js +257 -0
  110. package/dist/assets/vendor-katex-DdZNUVNG.css +1 -0
  111. package/dist/assets/{vendor-motion-dom-DcCgeG69.js → vendor-motion-dom-HXPOMxb7.js} +1 -1
  112. package/dist/assets/{vendor-opencode-client-DHzhmLwV.js → vendor-opencode-client-BmFcUkbQ.js} +1 -1
  113. package/dist/assets/{vendor-replit-codemirror-vim-DkzqxN2H.js → vendor-replit-codemirror-vim-BlJRn-cW.js} +2 -2
  114. package/dist/assets/{vendor-tanstack-react-virtual-H_JmXOFV.js → vendor-tanstack-react-virtual-CvuLsk7D.js} +1 -1
  115. package/dist/assets/{vendor-tanstack-virtual-core-Dxc8hSTB.js → vendor-tanstack-virtual-core-DPaNcJcT.js} +1 -1
  116. package/dist/assets/zh-CN-t2vigSB1.js +20 -0
  117. package/dist/assets/zh-TW-jAgqBokB.js +20 -0
  118. package/dist/index.html +6 -6
  119. package/dist/mini-chat.html +6 -6
  120. package/dist/mobile.html +6 -6
  121. package/package.json +8 -5
  122. package/server/index.js +80 -36
  123. package/server/lib/agent-memory/actions.js +1 -6
  124. package/server/lib/agent-memory/routes.js +1 -2
  125. package/server/lib/agent-memory/runtime.js +1 -8
  126. package/server/lib/agent-tool/DOCUMENTATION.md +7 -3
  127. package/server/lib/agent-tool/runtime.js +3 -6
  128. package/server/lib/agent-tool/runtime.test.js +0 -2
  129. package/server/lib/browser-control/DOCUMENTATION.md +6 -0
  130. package/server/lib/changelog/update-notes.js +1 -1
  131. package/server/lib/context-obligatory/runtime.js +1 -2
  132. package/server/lib/dev-tunnel/DOCUMENTATION.md +8 -0
  133. package/server/lib/dev-tunnel/client.js +21 -10
  134. package/server/lib/dev-tunnel/tunnel.test.js +34 -0
  135. package/server/lib/dispatch-results/runtime.js +1 -1
  136. package/server/lib/enterprise-mode.d.ts +1 -0
  137. package/server/lib/enterprise-mode.js +13 -2
  138. package/server/lib/enterprise-mode.test.js +21 -0
  139. package/server/lib/event-stream/DOCUMENTATION.md +2 -2
  140. package/server/lib/event-stream/index.js +1 -3
  141. package/server/lib/event-stream/translate-v2.js +1 -9
  142. package/server/lib/event-stream/translate-v2.test.js +0 -11
  143. package/server/lib/fs/DOCUMENTATION.md +5 -2
  144. package/server/lib/fs/routes.js +62 -15
  145. package/server/lib/fs/routes.test.js +278 -48
  146. package/server/lib/fs/workspace-file-names.js +104 -0
  147. package/server/lib/fs/workspace-file-names.test.js +62 -0
  148. package/server/lib/git/DOCUMENTATION.md +17 -5
  149. package/server/lib/git/contributor-provenance-storage.js +9 -6
  150. package/server/lib/git/contributor-provenance-storage.test.js +29 -0
  151. package/server/lib/git/credential-broker.js +1 -1
  152. package/server/lib/git/credential-resolver.js +1 -4
  153. package/server/lib/git/credentials.js +1 -0
  154. package/server/lib/git/discovery-endpoint.js +1 -1
  155. package/server/lib/git/identity-provisioning.js +2 -1
  156. package/server/lib/git/identity-storage.js +3 -4
  157. package/server/lib/git/lfs-discovery.js +14 -14
  158. package/server/lib/git/lfs-discovery.test.js +16 -6
  159. package/server/lib/git/network-operation-plan.js +50 -62
  160. package/server/lib/git/network-operation-plan.test.js +103 -1
  161. package/server/lib/git/network-operation-registry.js +4 -6
  162. package/server/lib/git/network-operation-storage.js +4 -6
  163. package/server/lib/git/network-operations.js +12 -4
  164. package/server/lib/git/network-operations.test.js +19 -6
  165. package/server/lib/git/redaction.js +2 -1
  166. package/server/lib/git/repository-credential-runtime.js +5 -3
  167. package/server/lib/git/repository-credential-runtime.test.js +22 -0
  168. package/server/lib/git/routes.js +31 -137
  169. package/server/lib/git/routes.test.js +2 -2
  170. package/server/lib/git/service.js +143 -27
  171. package/server/lib/git/service.test.js +215 -1
  172. package/server/lib/git/ssh-credential-storage.js +1 -4
  173. package/server/lib/git/submodule-discovery.js +1 -1
  174. package/server/lib/git/worktree-bootstrap-storage.js +3 -4
  175. package/server/lib/git/worktree-bootstrap-storage.test.js +15 -0
  176. package/server/lib/git/worktree-creation.js +194 -0
  177. package/server/lib/git/worktree-creation.test.js +155 -0
  178. package/server/lib/github/DOCUMENTATION.md +11 -6
  179. package/server/lib/github/auth.js +2 -4
  180. package/server/lib/github/device-flow.js +2 -2
  181. package/server/lib/github/gh-cli-credential.js +1 -0
  182. package/server/lib/github/octokit.js +31 -0
  183. package/server/lib/github/pr-status.js +35 -18
  184. package/server/lib/github/pr-status.test.js +271 -3
  185. package/server/lib/github/reference-search.js +102 -13
  186. package/server/lib/github/reference-search.test.js +48 -7
  187. package/server/lib/github/routes.auth.test.js +18 -0
  188. package/server/lib/github/routes.js +360 -6
  189. package/server/lib/github/routes.mutations.test.js +239 -0
  190. package/server/lib/github/routes.test.js +134 -2
  191. package/server/lib/gitlab/DOCUMENTATION.md +5 -1
  192. package/server/lib/gitlab/auth-storage.test.js +1 -0
  193. package/server/lib/gitlab/mappers.js +30 -1
  194. package/server/lib/gitlab/resources.js +230 -6
  195. package/server/lib/gitlab/resources.test.js +179 -0
  196. package/server/lib/gitlab/routes.js +200 -6
  197. package/server/lib/gitlab/routes.test.js +186 -2
  198. package/server/lib/gitlab/validation.js +1 -5
  199. package/server/lib/guests/DOCUMENTATION.md +1 -1
  200. package/server/lib/guests/oauth.js +2 -2
  201. package/server/lib/guests/service.js +1 -1
  202. package/server/lib/guests/sockets.js +1 -1
  203. package/server/lib/injected-env.d.ts +8 -0
  204. package/server/lib/injected-env.js +50 -0
  205. package/server/lib/injected-env.test.js +56 -0
  206. package/server/lib/linear/DOCUMENTATION.md +5 -6
  207. package/server/lib/linear/issues.js +62 -4
  208. package/server/lib/linear/issues.test.js +68 -0
  209. package/server/lib/linear/oauth.js +5 -5
  210. package/server/lib/linear/parse.js +2 -9
  211. package/server/lib/message-queue/DOCUMENTATION.md +23 -1
  212. package/server/lib/message-queue/runtime.js +85 -12
  213. package/server/lib/message-queue/runtime.test.js +172 -2
  214. package/server/lib/notifications/apns-runtime.js +5 -6
  215. package/server/lib/notifications/apns-runtime.test.js +20 -4
  216. package/server/lib/openchamber-control/DOCUMENTATION.md +19 -1
  217. package/server/lib/openchamber-control/actions.js +2 -1
  218. package/server/lib/openchamber-control/service.js +119 -20
  219. package/server/lib/openchamber-control/service.test.js +166 -0
  220. package/server/lib/openchamber-sessions/archive-store.js +1 -6
  221. package/server/lib/openchamber-sessions/routes.js +47 -12
  222. package/server/lib/openchamber-sessions/routes.test.js +65 -2
  223. package/server/lib/openchamber-sessions/session-metadata-store.js +1 -6
  224. package/server/lib/opencode/DOCUMENTATION.md +22 -8
  225. package/server/lib/opencode/agents.js +62 -0
  226. package/server/lib/opencode/auth-state-runtime.js +3 -1
  227. package/server/lib/opencode/auth.d.ts +1 -1
  228. package/server/lib/opencode/auth.js +8 -0
  229. package/server/lib/opencode/auth.test.js +31 -0
  230. package/server/lib/opencode/config-entity-routes.js +16 -0
  231. package/server/lib/opencode/config-entity-v2.test.js +16 -1
  232. package/server/lib/opencode/config-paths.test.js +2 -2
  233. package/server/lib/opencode/config-v2.js +2 -4
  234. package/server/lib/opencode/feature-routes-runtime.js +89 -49
  235. package/server/lib/opencode/lifecycle.js +41 -21
  236. package/server/lib/opencode/lifecycle.test.js +50 -2
  237. package/server/lib/opencode/model-discovery.js +1 -1
  238. package/server/lib/opencode/model-discovery.test.js +17 -0
  239. package/server/lib/opencode/models-metadata.js +0 -2
  240. package/server/lib/opencode/openchamber-routes.test.js +3 -3
  241. package/server/lib/opencode/plugins.js +6 -9
  242. package/server/lib/opencode/provider-source-route.test.js +101 -3
  243. package/server/lib/opencode/providers-disabled.test.js +59 -0
  244. package/server/lib/opencode/providers.js +34 -0
  245. package/server/lib/opencode/proxy.js +11 -3
  246. package/server/lib/opencode/routes.js +40 -2
  247. package/server/lib/opencode/settings-helpers.js +27 -1
  248. package/server/lib/opencode/settings-helpers.test.js +19 -7
  249. package/server/lib/opencode/settings-registry.json +27 -16
  250. package/server/lib/opencode/settings-runtime.test.js +18 -0
  251. package/server/lib/opencode/shared-service-runtime.js +23 -104
  252. package/server/lib/opencode/shared.js +77 -46
  253. package/server/lib/opencode/shared.test.js +12 -12
  254. package/server/lib/opencode/shutdown-runtime.test.js +2 -3
  255. package/server/lib/opencode/skill-routes.js +123 -38
  256. package/server/lib/opencode/skill-routes.test.js +201 -0
  257. package/server/lib/opencode/skills.js +81 -34
  258. package/server/lib/opencode/skills.test.js +176 -21
  259. package/server/lib/opencode/startup-pipeline-runtime.js +6 -3
  260. package/server/lib/opencode/startup-pipeline-runtime.test.js +2 -1
  261. package/server/lib/opencode/watcher.test.js +0 -54
  262. package/server/lib/outbound-proxy.js +52 -0
  263. package/server/lib/outbound-proxy.test.js +55 -0
  264. package/server/lib/package-manager.js +23 -17
  265. package/server/lib/package-manager.test.js +5 -18
  266. package/server/lib/permission-auto-accept/DOCUMENTATION.md +1 -1
  267. package/server/lib/permission-auto-accept/runtime.js +19 -47
  268. package/server/lib/permission-auto-accept/runtime.test.js +2 -2
  269. package/server/lib/project-context/DOCUMENTATION.md +7 -1
  270. package/server/lib/project-context/routes.js +1 -2
  271. package/server/lib/project-context/runtime.js +12 -14
  272. package/server/lib/project-context/runtime.test.js +78 -2
  273. package/server/lib/projects/project-config.js +30 -15
  274. package/server/lib/projects/project-config.test.js +30 -0
  275. package/server/lib/projects/project-setup.js +1 -2
  276. package/server/lib/projects/routes.js +1 -2
  277. package/server/lib/quota/DOCUMENTATION.md +35 -7
  278. package/server/lib/quota/index.js +1 -0
  279. package/server/lib/quota/providers/claude/index.js +1 -1
  280. package/server/lib/quota/providers/cline-pass.js +1 -1
  281. package/server/lib/quota/providers/exe-dev.js +1 -1
  282. package/server/lib/quota/providers/google/index.js +1 -1
  283. package/server/lib/quota/providers/hyper.js +1 -1
  284. package/server/lib/quota/providers/kimi.js +116 -36
  285. package/server/lib/quota/providers/kimi.test.js +128 -3
  286. package/server/lib/quota/providers/opencode-go.js +144 -21
  287. package/server/lib/quota/providers/opencode-go.test.js +229 -6
  288. package/server/lib/quota/providers/openrouter.js +1 -1
  289. package/server/lib/quota/providers/zenmux.js +1 -1
  290. package/server/lib/quota/utils/auth.js +1 -0
  291. package/server/lib/quota/utils/transformers.js +2 -6
  292. package/server/lib/relay/DOCUMENTATION.md +18 -3
  293. package/server/lib/relay/e2ee.js +7 -7
  294. package/server/lib/relay/host-client.js +84 -13
  295. package/server/lib/relay/host-client.test.js +201 -1
  296. package/server/lib/relay/identity.js +6 -29
  297. package/server/lib/relay/identity.test.js +18 -28
  298. package/server/lib/relay/key-store.js +141 -0
  299. package/server/lib/relay/key-store.test.js +138 -0
  300. package/server/lib/relay/service.js +8 -8
  301. package/server/lib/relay/service.test.js +10 -5
  302. package/server/lib/relay/signing-key.js +13 -36
  303. package/server/lib/relay/tunnel-codec.js +10 -10
  304. package/server/lib/route-module-exports.test.js +22 -0
  305. package/server/lib/routing/DOCUMENTATION.md +23 -3
  306. package/server/lib/routing/cache.js +87 -0
  307. package/server/lib/routing/defaults.js +11 -0
  308. package/server/lib/routing/history.js +12 -4
  309. package/server/lib/routing/runtime.js +39 -3
  310. package/server/lib/routing/runtime.test.js +81 -2
  311. package/server/lib/routing/store.js +12 -1
  312. package/server/lib/routing/store.test.js +13 -0
  313. package/server/lib/scheduled-tasks/DOCUMENTATION.md +59 -1
  314. package/server/lib/scheduled-tasks/existing-session.js +119 -0
  315. package/server/lib/scheduled-tasks/existing-session.test.js +350 -0
  316. package/server/lib/scheduled-tasks/loops.js +1 -8
  317. package/server/lib/scheduled-tasks/routes.js +8 -8
  318. package/server/lib/scheduled-tasks/runtime.js +103 -13
  319. package/server/lib/scheduled-tasks/runtime.test.js +48 -0
  320. package/server/lib/scheduled-tasks/service.js +33 -11
  321. package/server/lib/scheduled-tasks/service.test.js +128 -2
  322. package/server/lib/scheduled-tasks/session-defaults.js +27 -0
  323. package/server/lib/scheduled-tasks/session-defaults.test.js +19 -0
  324. package/server/lib/session-assist/context.js +1 -0
  325. package/server/lib/session-folders/routes.js +2 -2
  326. package/server/lib/session-goal/objectives.js +1 -1
  327. package/server/lib/session-knowledge/routes.js +2 -2
  328. package/server/lib/session-knowledge/runtime.js +1 -2
  329. package/server/lib/session-work/DOCUMENTATION.md +39 -14
  330. package/server/lib/session-work/questions.js +31 -2
  331. package/server/lib/session-work/runtime.js +22 -13
  332. package/server/lib/session-work/runtime.test.js +43 -26
  333. package/server/lib/session-work/state.js +10 -4
  334. package/server/lib/session-work/state.test.js +8 -1
  335. package/server/lib/shared/DOCUMENTATION.md +89 -0
  336. package/server/lib/shared/guards.js +53 -0
  337. package/server/lib/shared/guards.test.js +112 -0
  338. package/server/lib/skills-catalog/DOCUMENTATION.md +10 -8
  339. package/server/lib/skills-catalog/cache.js +3 -2
  340. package/server/lib/skills-catalog/curated-sources.js +5 -1
  341. package/server/lib/skills-catalog/git.js +107 -20
  342. package/server/lib/skills-catalog/git.test.js +78 -0
  343. package/server/lib/skills-catalog/install.js +21 -12
  344. package/server/lib/skills-catalog/scan.js +25 -15
  345. package/server/lib/skills-catalog/source.js +26 -1
  346. package/server/lib/skills-catalog/source.test.js +27 -0
  347. package/server/lib/source-control/DOCUMENTATION.md +2 -1
  348. package/server/lib/source-control/audit-storage.js +7 -6
  349. package/server/lib/source-control/audit-storage.test.js +11 -0
  350. package/server/lib/source-control/binding-service.js +2 -5
  351. package/server/lib/source-control/change-request-worktree.js +90 -0
  352. package/server/lib/source-control/change-request-worktree.test.js +121 -0
  353. package/server/lib/source-control/mutation-executor.js +1 -1
  354. package/server/lib/source-control/mutation-storage.js +11 -5
  355. package/server/lib/source-control/provider-instance.js +1 -2
  356. package/server/lib/source-control/repository-identity.js +1 -1
  357. package/server/lib/source-control/routes.js +14 -2
  358. package/server/lib/spaces/DOCUMENTATION.md +42 -17
  359. package/server/lib/spaces/command-stream.test.js +15 -0
  360. package/server/lib/spaces/dispatcher.js +7 -0
  361. package/server/lib/spaces/dispatcher.test.js +17 -0
  362. package/server/lib/spaces/hardening.js +2 -2
  363. package/server/lib/spaces/hardening.test.js +1 -1
  364. package/server/lib/spaces/host.js +2 -0
  365. package/server/lib/spaces/journey.js +66 -5
  366. package/server/lib/spaces/journey.test.js +95 -4
  367. package/server/lib/spaces/layout.js +1 -1
  368. package/server/lib/spaces/places/docker-disk.js +38 -14
  369. package/server/lib/spaces/places/docker-disk.test.js +80 -1
  370. package/server/lib/spaces/places/docker.js +35 -11
  371. package/server/lib/spaces/places/fake-docker.js +22 -3
  372. package/server/lib/spaces/places/memory-place.js +3 -0
  373. package/server/lib/spaces/places/packed.docker.live.test.js +1 -1
  374. package/server/lib/spaces/routes.js +5 -0
  375. package/server/lib/spaces/routes.test.js +4 -2
  376. package/server/lib/spaces/run-command.js +9 -2
  377. package/server/lib/spaces/tools.js +4 -5
  378. package/server/lib/spaces/tools.test.js +2 -2
  379. package/server/lib/terminal/runtime.js +18 -6
  380. package/server/lib/terminal/runtime.test.js +66 -0
  381. package/server/lib/tts/index.js +3 -0
  382. package/server/lib/tts/service.js +4 -2
  383. package/server/lib/walkthrough/DOCUMENTATION.md +3 -1
  384. package/server/lib/walkthrough/index.js +2 -3
  385. package/server/lib/walkthrough/pr-source.test.js +22 -0
  386. package/server/lib/walkthrough/sources.js +6 -1
  387. package/server/opencode-proxy.test.js +127 -0
  388. package/bin/lib/commands-startup.test.js +0 -15
  389. package/dist/assets/CodeMirrorCommentWidgets-CVDSjcrP.js +0 -1
  390. package/dist/assets/DiffView-Ct7vL3fD.js +0 -4
  391. package/dist/assets/FilesView-DT0uEYgT.js +0 -17
  392. package/dist/assets/GuestSurfacePane-DVvk1VPu.js +0 -1
  393. package/dist/assets/LinearIssuesView-C10MwA9h.js +0 -1
  394. package/dist/assets/MarkdownImageGallery-BbN_K_3d.js +0 -1
  395. package/dist/assets/MarkdownRendererImpl-BLt2Tkk9.js +0 -10
  396. package/dist/assets/OnboardingScreen-Bgp5A3WB.js +0 -1
  397. package/dist/assets/PierreDiffViewer-CWJLj-S-.js +0 -210
  398. package/dist/assets/PlanView-DsRWnsOI.js +0 -5
  399. package/dist/assets/PublishDialog-CCeKloRu.js +0 -2
  400. package/dist/assets/SettingsView-BuPPsNPp.js +0 -30
  401. package/dist/assets/SettingsWindow-CM5fL8Ot.js +0 -1
  402. package/dist/assets/SubmoduleDiffSummary-CfsMcfiQ.js +0 -1
  403. package/dist/assets/ToolOutputDialog-mZrfSofz.js +0 -15
  404. package/dist/assets/ToolPartDiffPreview-BUGCRYEL.js +0 -8
  405. package/dist/assets/WalkthroughView-ClBo7PPd.js +0 -4
  406. package/dist/assets/appThemeRegistry-CxzMxR-H.js +0 -1
  407. package/dist/assets/de-CBla4hjd.js +0 -14
  408. package/dist/assets/es-BmnZtxTD.js +0 -20
  409. package/dist/assets/fileTreeStatus-BSHFIGEg.js +0 -1
  410. package/dist/assets/fr-OVKIBUZO.js +0 -18
  411. package/dist/assets/index-CKCFKmjF.js +0 -1
  412. package/dist/assets/index-Dbx4V4GN.css +0 -1
  413. package/dist/assets/index-vQ1Pg1cr.js +0 -61
  414. package/dist/assets/ja-6v-yr-gE.js +0 -20
  415. package/dist/assets/ko-I_ZLvgQ9.js +0 -20
  416. package/dist/assets/main-CmO4dRtR.js +0 -2
  417. package/dist/assets/main-b9vqhlYM.js +0 -999
  418. package/dist/assets/markdownCore-BPEZRQfP.js +0 -7
  419. package/dist/assets/miniChat-B5Oi7B-6.js +0 -2
  420. package/dist/assets/mobile-CVtShif9.js +0 -2
  421. package/dist/assets/nl-DcvIJf46.js +0 -20
  422. package/dist/assets/patchFileDiff-CW3wCH1O.js +0 -13
  423. package/dist/assets/pl-JxCUl0sd.js +0 -20
  424. package/dist/assets/pt-BR-L89mjja2.js +0 -20
  425. package/dist/assets/relocateRestoredSession-DYh_9oBa.js +0 -1
  426. package/dist/assets/renderElectronMiniChatApp-BD-1JHSg.js +0 -2
  427. package/dist/assets/renderMobileApp-7s5InXt1.js +0 -25
  428. package/dist/assets/runtimeEndpointReset-DqLJC6L1.js +0 -53
  429. package/dist/assets/shikiHighlight-CXgZnvHh.js +0 -1
  430. package/dist/assets/space-apply-1trGVIl8.js +0 -1
  431. package/dist/assets/space-close-y7j-NuBv.js +0 -1
  432. package/dist/assets/tr-ka5J1flt.js +0 -11
  433. package/dist/assets/uk-PyGe6Q_u.js +0 -20
  434. package/dist/assets/useInlineCommentController-CrjSGCpi.js +0 -2
  435. package/dist/assets/vendor-codemirror-lang-python-B7ClrMIT.js +0 -3
  436. package/dist/assets/vendor-codemirror-lang-xml-C8tGnS64.js +0 -1
  437. package/dist/assets/vendor-codemirror-language-data-q2bKO1EM.js +0 -2
  438. package/dist/assets/vendor-framer-motion-Bq3iTZ6r.js +0 -1
  439. package/dist/assets/vendor-katex-CHD9BdDO.js +0 -257
  440. package/dist/assets/vendor-katex-Cp579UoJ.css +0 -1
  441. package/dist/assets/zh-CN-B0P2tybJ.js +0 -20
  442. package/dist/assets/zh-TW-Bs9C5cN8.js +0 -20
@@ -347,6 +347,8 @@ export function createSpacesHost({
347
347
  unsubscribeHostEvents = globalEventHub.subscribeEvent((event) => { if (event.spaceId === null) index.observeHostEvent(event.payload); });
348
348
  followTimer = setTimer(() => { void follow(); }, FOLLOW_INTERVAL_MS);
349
349
  followTimer?.unref?.();
350
+ // A running space whose gatekeeper lost its grants, from a start cut short, gets them again.
351
+ void journey.restoreLostGrants();
350
352
  return follow();
351
353
  },
352
354
  /** For the proxy: the merged session list, or the host's own when no space exists. */
@@ -382,6 +382,45 @@ export function createSpaceJourney({
382
382
  return { restored, needsAccess };
383
383
  };
384
384
 
385
+ /**
386
+ * The grants of every running space that its gatekeeper does not hold, said again from the
387
+ * record, at the host's start: a start the previous host did not live to finish leaves them
388
+ * unsaid, and the user would otherwise be asked for a key the record already names. Only what
389
+ * the host can say on its own goes: an environment key read now, and an opened domain; a typed
390
+ * key stays with the user. A space with an action under way is left to that action, and a
391
+ * gatekeeper that cannot be asked is left alone and logged. Nothing here fails the start.
392
+ */
393
+ const restoreLostGrants = async () => {
394
+ let spaces;
395
+ try {
396
+ spaces = await manager.listSpaces({ placeId: place.id });
397
+ } catch (error) {
398
+ logger.warn?.(`[spaces] could not list the spaces to say their grants again: ${error?.code ?? error?.message ?? error}`);
399
+ return;
400
+ }
401
+ for (const space of spaces) {
402
+ if (space.state !== 'running' || busy.has(space.id)) continue;
403
+ const grants = (records.read(space.id).record?.grants ?? []).filter((grant) => grant.kind !== 'model' || grant.source.kind === 'env');
404
+ if (grants.length === 0) continue;
405
+ let held;
406
+ try {
407
+ held = new Set((await gatekeeper.readPolicy(space.id)).grants);
408
+ } catch (error) {
409
+ logger.warn?.(`[spaces] the gatekeeper of space ${space.id} did not say what it holds: ${error?.code ?? error?.message ?? error}`);
410
+ continue;
411
+ }
412
+ const lost = grants.filter((grant) => !held.has(grant.id));
413
+ if (lost.length === 0) continue;
414
+ try {
415
+ const { restored } = await exclusive(space.id, () => restoreGrants(space.id, lost));
416
+ if (restored.length > 0) logger.info?.(`[spaces] said the grants ${restored.join(', ')} of space ${space.id} again`);
417
+ } catch (error) {
418
+ logger.warn?.(`[spaces] the grants of space ${space.id} were not said again: ${error?.code ?? error?.message ?? error}`);
419
+ }
420
+ }
421
+ onSpacesChanged();
422
+ };
423
+
385
424
  /**
386
425
  * Which grants of the record a running gatekeeper holds now, read from the gatekeeper itself:
387
426
  * after a machine restart it holds none, and the space "needs access" (DESIGN.md, Gatekeeper).
@@ -466,6 +505,11 @@ export function createSpaceJourney({
466
505
  const { record } = records.read(space.id);
467
506
  const grants = record?.grants ?? [];
468
507
  const projectPath = record?.repository ?? projectDirectory;
508
+ // A record without a space path is a creation that a previous host did not live to finish:
509
+ // the containers run, the code never arrived, and the user would otherwise send the agent
510
+ // into an empty folder. It is listed as a failed creation, whose one way out is Remove; a
511
+ // creation under way in this process is replaced by its own entry below.
512
+ const codeNeverArrived = record !== null && record.spacePath === null;
469
513
  return {
470
514
  id: space.id,
471
515
  name: space.name,
@@ -474,10 +518,10 @@ export function createSpaceJourney({
474
518
  directory: projectDirectory === null ? null : spaceProjectPath(space.id, projectDirectory),
475
519
  projectFolder: { path: projectPath, found: projectPath === null ? null : await folderExists(projectPath) },
476
520
  created: space.created,
477
- state: space.state,
521
+ state: codeNeverArrived ? 'failed' : space.state,
478
522
  stoppedIdle: space.stoppedIdle === true,
479
- step: null,
480
- failure: null,
523
+ step: codeNeverArrived ? 'failed' : null,
524
+ failure: codeNeverArrived ? { code: 'space_code_never_arrived', message: 'OpenChamber closed before the code arrived. Delete the space and create it again.', details: null } : null,
481
525
  network: record?.network ?? null,
482
526
  history: record?.history ?? 'unknown',
483
527
  setup: setup.describe(space.id, record),
@@ -741,7 +785,9 @@ export function createSpaceJourney({
741
785
  return { id: spaceId, ...(await removeEverything(spaceId, waiting.projectDirectory)), chats: null };
742
786
  }
743
787
  const space = await requireListed(spaceId);
744
- const chats = await saveChatsOf(space, allowUnsaved);
788
+ // A creation that a previous host did not live to finish is a failed creation too: no chat
789
+ // could have run in it, so nothing is saved and the archive is not asked.
790
+ const chats = space.state === 'failed' ? null : await saveChatsOf(space, allowUnsaved);
745
791
  const { record } = records.read(spaceId);
746
792
  const outcome = await removeEverything(spaceId, record?.repository ?? space.projectDirectory);
747
793
  onSpacesChanged();
@@ -829,6 +875,9 @@ export function createSpaceJourney({
829
875
  if (Array.from(pending.values()).some((entry) => entry.state === 'preparing')) {
830
876
  throw new SpaceError('space_preparing', 'A space is being made. Clean up when it is ready.');
831
877
  }
878
+ if (place.imagePulling()) {
879
+ throw new SpaceError('image_pulling', 'The image is being downloaded. Clean up when it is done.');
880
+ }
832
881
  const { freedBytes, kept, machine } = await place.cleanUpDisk();
833
882
  for (const item of kept.filter((entry) => entry.reason === 'failed')) {
834
883
  logger.warn?.(`[spaces] clean-up could not remove ${item.kind} ${item.name}: ${item.message}`);
@@ -838,6 +887,18 @@ export function createSpaceJourney({
838
887
  return { freedBytes, kept: kept.map(({ kind, reason }) => ({ kind, reason })), disk: await place.readDisk() };
839
888
  };
840
889
 
890
+ /**
891
+ * Downloads the base image on a place ahead of the first space (journey step 0), so no create
892
+ * dialog waits for it. The place runs one download at a time and a creation under way shares it.
893
+ * Answers the disk at once, `imagePulling` set; the page reads the disk again until it is not,
894
+ * and `imageFailure` says why the image is still absent after a download that failed.
895
+ */
896
+ const pullImage = async (placeId) => {
897
+ requirePlace(placeId);
898
+ place.pullImage().catch((error) => { logger.warn?.(`[spaces] the image download failed: ${error.code ?? error.message}`); });
899
+ return place.readDisk();
900
+ };
901
+
841
902
  /** For a turn-off that did not go through after the spaces were stopped: creations are taken again. */
842
903
  const reopen = () => { closing = false; };
843
904
 
@@ -899,5 +960,5 @@ export function createSpaceJourney({
899
960
  return { brought, applied, removal, kept };
900
961
  });
901
962
 
902
- return { createSpace, listSpaces, startSpace, stopSpace, restartSpace, restartOpenCode, removeSpace, stopAllSpaces, reopen, grantAccess, openDomain, readJournal, previewApply, applySpace, readIdleStopSetting, changeIdleStop, runSetup, readSetup, readDisk, cleanUpDisk };
963
+ return { createSpace, listSpaces, startSpace, stopSpace, restartSpace, restartOpenCode, removeSpace, stopAllSpaces, reopen, grantAccess, openDomain, restoreLostGrants, readJournal, previewApply, applySpace, readIdleStopSetting, changeIdleStop, runSetup, readSetup, readDisk, cleanUpDisk, pullImage };
903
964
  }
@@ -198,10 +198,26 @@ describe('the journey: create', () => {
198
198
 
199
199
  describe('the journey: disk and clean-up', () => {
200
200
  const DISK = { imageBytes: 1_632_000_000, toolsBytes: 438_000_000, spacesBytes: 0, freeBytes: 1_632_000_000, freesImage: true };
201
- const placeWithDisk = (cleaned) => ({
201
+ const placeWithDisk = (cleaned, pull = { pulling: false, pulls: 0, fail: null }) => ({
202
202
  ...createMemoryPlace(),
203
- readDisk: async () => DISK,
203
+ readDisk: async () => ({ ...DISK, imagePulling: pull.pulling }),
204
204
  cleanUpDisk: async () => { cleaned.count += 1; return cleaned.outcome; },
205
+ pullImage: async () => { pull.pulls += 1; if (pull.fail) throw pull.fail; },
206
+ imagePulling: () => pull.pulling,
207
+ });
208
+
209
+ it('starts the image download and answers the disk at once; a clean-up waits for the download; a failed download is logged, not thrown', async () => {
210
+ const warnings = [];
211
+ const cleaned = { count: 0, outcome: { freedBytes: 0, kept: [], machine: { state: 'skipped' } } };
212
+ const pull = { pulling: true, pulls: 0, fail: new SpaceError('image_pull_failed', 'Could not download the base image: no route to host.') };
213
+ const { journey } = journeyWith({ place: placeWithDisk(cleaned, pull), logger: { warn: (line) => warnings.push(line) } });
214
+ expect(await journey.pullImage('memory')).toEqual({ ...DISK, imagePulling: true });
215
+ expect(pull.pulls).toBe(1);
216
+ await expect(journey.pullImage('kubernetes')).rejects.toMatchObject({ code: 'place_not_found' });
217
+ await expect(journey.cleanUpDisk('memory')).rejects.toMatchObject({ code: 'image_pulling' });
218
+ expect(cleaned.count).toBe(0);
219
+ await new Promise((resolve) => { setTimeout(resolve, 0); });
220
+ expect(warnings).toEqual(['[spaces] the image download failed: image_pull_failed']);
205
221
  });
206
222
 
207
223
  it('answers what was freed and the disk after, keeps Docker\'s words out of the answer and logs them', async () => {
@@ -215,8 +231,8 @@ describe('the journey: disk and clean-up', () => {
215
231
  },
216
232
  };
217
233
  const { journey } = journeyWith({ place: placeWithDisk(cleaned), logger: { warn: (line) => warnings.push(line) } });
218
- expect(await journey.readDisk('memory')).toEqual(DISK);
219
- expect(await journey.cleanUpDisk('memory')).toEqual({ freedBytes: 440_000_000, kept: [{ kind: 'image', reason: 'in_use' }, { kind: 'tools', reason: 'failed' }], disk: DISK });
234
+ expect(await journey.readDisk('memory')).toEqual({ ...DISK, imagePulling: false });
235
+ expect(await journey.cleanUpDisk('memory')).toEqual({ freedBytes: 440_000_000, kept: [{ kind: 'image', reason: 'in_use' }, { kind: 'tools', reason: 'failed' }], disk: { ...DISK, imagePulling: false } });
220
236
  expect(warnings).toEqual([
221
237
  '[spaces] clean-up could not remove tools openchamber-tools-x: disk on fire',
222
238
  '[spaces] the Colima machine did not trim its disk: sudo: a password is required',
@@ -283,6 +299,17 @@ describe('the journey: start, stop, remove', () => {
283
299
  expect(calls.map(([name]) => name)).toEqual(['setNetwork']);
284
300
  });
285
301
 
302
+ it('lists a space whose code never arrived as a failed creation, which Remove takes away', async () => {
303
+ // A host that quit in the middle of bringing the code in leaves the containers running and
304
+ // the record without a space path. The next host must not list that as a healthy space.
305
+ const { journey, records, place, id } = await ready();
306
+ records.update(id, { spacePath: null, base: null });
307
+ expect((await journey.listSpaces())[0]).toMatchObject({ id, state: 'failed', step: 'failed', failure: { code: 'space_code_never_arrived', message: expect.stringContaining('closed before the code arrived'), details: null } });
308
+ expect(await journey.removeSpace(id)).toMatchObject({ id, removed: true, refsRemoved: true, failures: [] });
309
+ expect(await place.list()).toEqual([]);
310
+ expect(records.read(id)).toEqual({ status: 'missing', record: null });
311
+ });
312
+
286
313
  it('removes the space, the refs in the user\'s repository and the record', async () => {
287
314
  const { journey, place, records, calls, id } = await ready();
288
315
  expect(await journey.removeSpace(id)).toEqual({ id, removed: true, refsRemoved: true, failures: [], chats: null });
@@ -688,6 +715,62 @@ describe('the journey: grants', () => {
688
715
  expect((await refusing.journey.listSpaces({ access: true }))[0]).toMatchObject({ access: 'needs_access', needsAccess: ['openai'] });
689
716
  });
690
717
 
718
+ it('says the grants a running gatekeeper lost again when the host starts: an environment key and a domain, never a typed key', async () => {
719
+ const { journey, calls, gatekeeper, id, changes } = await ready({ hostEnvironment: { OPENAI_API_KEY: ENV_KEY } });
720
+ await journey.grantAccess(id, anthropic);
721
+ await journey.grantAccess(id, openai);
722
+ const opened = await journey.grantAccess(id, registry);
723
+ // The gatekeeper lost everything, as after a start the previous host did not live to finish; the space runs on.
724
+ gatekeeper.forget(id);
725
+ expect((await journey.listSpaces({ access: true }))[0]).toMatchObject({ access: 'needs_access', needsAccess: ['anthropic', 'openai', opened.grant.id] });
726
+ calls.splice(0);
727
+ const before = changes.count;
728
+
729
+ await journey.restoreLostGrants();
730
+ expect(calls.filter(([name]) => name === 'addGrant')).toEqual([
731
+ ['addGrant', id, { id: 'openai', upstream: 'https://api.openai.com/v1', header: 'authorization', secret: ENV_KEY }],
732
+ ['addGrant', id, { id: opened.grant.id, upstream: 'https://registry.example.com/npm/', header: null, secret: null }],
733
+ ]);
734
+ expect(JSON.stringify(calls)).not.toContain(KEY);
735
+ expect(changes.count).toBe(before + 1);
736
+ // Only the typed key still needs the user.
737
+ expect((await journey.listSpaces({ access: true }))[0]).toMatchObject({ access: 'needs_access', needsAccess: ['anthropic'] });
738
+
739
+ // A gatekeeper that holds its grants is asked and told nothing.
740
+ calls.splice(0);
741
+ await journey.restoreLostGrants();
742
+ expect(calls.map(([name]) => name)).toEqual(['readPolicy']);
743
+ });
744
+
745
+ it('leaves a stopped space, a space without grants and a gatekeeper that does not answer alone when the host starts', async () => {
746
+ const { journey, calls, gatekeeper, id, manager, place } = await ready({ hostEnvironment: { OPENAI_API_KEY: ENV_KEY } });
747
+ await journey.grantAccess(id, openai);
748
+ const empty = await manager.createSpace({ placeId: 'memory', projectDirectory: PROJECT, name: 'No grants' });
749
+ gatekeeper.forget(id);
750
+ gatekeeper.readPolicy = async () => { throw new SpaceError('gatekeeper_unreachable', 'the gatekeeper did not answer'); };
751
+ calls.splice(0);
752
+ await journey.restoreLostGrants();
753
+ expect(calls.filter(([name]) => name === 'addGrant')).toEqual([]);
754
+
755
+ await place.stop(id);
756
+ await place.stop(empty.id);
757
+ calls.splice(0);
758
+ await journey.restoreLostGrants();
759
+ expect(calls).toEqual([]);
760
+ });
761
+
762
+ it('leaves a space with an action under way to that action when the host starts', async () => {
763
+ const { journey, calls, gatekeeper, id, releaseCodeOut } = await ready({ hostEnvironment: { OPENAI_API_KEY: ENV_KEY }, holdCodeOut: true });
764
+ await journey.grantAccess(id, openai);
765
+ gatekeeper.forget(id);
766
+ const preview = journey.previewApply(id);
767
+ calls.splice(0);
768
+ await journey.restoreLostGrants();
769
+ expect(calls.filter(([name]) => name === 'readPolicy' || name === 'addGrant')).toEqual([]);
770
+ releaseCodeOut();
771
+ await preview;
772
+ });
773
+
691
774
  it('asks the gatekeeper only where there is something to ask, and never calls a failed read "granted"', async () => {
692
775
  const { journey, calls, id, manager, place } = await ready();
693
776
  const empty = await manager.createSpace({ placeId: 'memory', projectDirectory: PROJECT, name: 'No grants' });
@@ -1108,6 +1191,14 @@ describe('the journey: the chat archive of a deleted space', () => {
1108
1191
  };
1109
1192
  const notSaved = () => new SpaceError('chats_not_saved', 'The chats of "Fix login" could not be saved.', { name: 'Fix login', tooLarge: ['Big one'], failed: 0, listed: true });
1110
1193
 
1194
+ it('does not ask the archive for a space whose code never arrived, and removes it', async () => {
1195
+ const { journey, place, records, asked, id } = await ready();
1196
+ records.update(id, { spacePath: null, base: null });
1197
+ expect(await journey.removeSpace(id)).toMatchObject({ id, removed: true, refsRemoved: true, failures: [], chats: null });
1198
+ expect(asked).toEqual([]);
1199
+ expect(await place.list()).toEqual([]);
1200
+ });
1201
+
1111
1202
  it('saves the chats of a running space before it goes, and says how many', async () => {
1112
1203
  const { journey, place, asked, calls, id } = await ready();
1113
1204
  const outcome = await journey.removeSpace(id);
@@ -142,7 +142,7 @@ export const SPACE_ENVIRONMENT = Object.freeze({
142
142
  // NODE_USE_ENV_PROXY=1 proxies loopback too. Both spellings, because curl reads both.
143
143
  NO_PROXY: `${GATEKEEPER_ALIAS},localhost,127.0.0.1`,
144
144
  no_proxy: `${GATEKEEPER_ALIAS},localhost,127.0.0.1`,
145
- // Node 22 ignores proxy variables without this. Measured: EAI_AGAIN for every fetch.
145
+ // Node ignores proxy variables without this. Measured on 22 and 24: EAI_AGAIN for every fetch.
146
146
  NODE_USE_ENV_PROXY: '1',
147
147
  // OpenCode's catalog and update downloads would only spend corridor attempts.
148
148
  OPENCODE_DISABLE_MODELS_FETCH: '1',
@@ -1,6 +1,7 @@
1
1
  // The disk that spaces take on the Docker place, and the clean-up of what OpenChamber can make
2
2
  // again by itself (DESIGN.md, journey step 9 and decision 12): this owner's tools volumes but the
3
- // current one, this owner's leftover one-shot containers, and the space image.
3
+ // current one, this owner's leftover one-shot containers, the space image, and the space images
4
+ // of before.
4
5
  //
5
6
  // Nothing here decides whether a resource is still needed. Every removal goes without force, and
6
7
  // Docker refuses a volume a container mounts, an image a container was made from, and a running
@@ -35,10 +36,11 @@ const parseJson = (text) => {
35
36
 
36
37
  /**
37
38
  * `engine` is the place's docker CLI, `tools` its tools volume with `listOurs`, `image` the pinned
38
- * space image. `colimaPath` is the colima CLI, or null: after a clean-up that removed anything, a
39
- * Docker machine that calls itself Colima is asked to hand the freed blocks back to the Mac.
39
+ * space image and `retiredImages` the digests it replaced. `colimaPath` is the colima CLI, or null:
40
+ * after a clean-up that removed anything, a Docker machine that calls itself Colima is asked to
41
+ * hand the freed blocks back to the Mac.
40
42
  */
41
- export function createDockerDisk({ engine, runCommand, colimaPath, owner, image, tools }) {
43
+ export function createDockerDisk({ engine, runCommand, colimaPath, owner, image, retiredImages = [], tools, imagePullState = () => ({ pulling: false, failure: null }) }) {
42
44
  /**
43
45
  * Docker's own account of the disk: every volume with its size and how many containers mount it,
44
46
  * by name, and what each image alone takes, by id. `docker image inspect` is no use for the
@@ -62,14 +64,24 @@ export function createDockerDisk({ engine, runCommand, colimaPath, owner, image,
62
64
  .map((entry) => String(entry.Name));
63
65
 
64
66
  /**
65
- * The image with what it alone takes, or null when it is not there. In use when any container
67
+ * An image with what it alone takes, or null when it is not there. In use when any container
66
68
  * was made from it.
67
69
  */
68
- const readImage = async (images) => {
69
- const entry = await engine.inspect('image', image);
70
+ const readImage = async (name, images) => {
71
+ const entry = await engine.inspect('image', name);
70
72
  if (!entry) return null;
71
- const users = await engine.docker(['ps', '--all', '--filter', `ancestor=${image}`, '--format', '{{.Names}}'], QUERY_TIMEOUT_MS);
72
- return { bytes: images.get(String(entry.Id)) ?? 0, inUse: users.trim() !== '' };
73
+ const users = await engine.docker(['ps', '--all', '--filter', `ancestor=${name}`, '--format', '{{.Names}}'], QUERY_TIMEOUT_MS);
74
+ return { name, bytes: images.get(String(entry.Id)) ?? 0, inUse: users.trim() !== '', tags: entry.RepoTags ?? [] };
75
+ };
76
+
77
+ /**
78
+ * The retired images that are there and carry no tag but their own digest reference. A space
79
+ * pulls by digest, so another tag means someone pulled or tagged the same image for their own
80
+ * use, and a removal by digest would take their tag with it. Those stay.
81
+ */
82
+ const readRetiredImages = async (images) => {
83
+ const found = await Promise.all(retiredImages.map((name) => readImage(name, images)));
84
+ return found.filter((state) => state !== null && state.tags.every((tag) => tag === state.name));
73
85
  };
74
86
 
75
87
  const measure = async () => {
@@ -77,23 +89,30 @@ export function createDockerDisk({ engine, runCommand, colimaPath, owner, image,
77
89
  const { volumes } = usage;
78
90
  const sizeOf = (name) => volumes.get(name)?.bytes ?? 0;
79
91
  const oldTools = ours.volumes.filter((name) => name !== ours.current);
80
- return { volumes, ours, oldTools, spaces, image: await readImage(usage.images), sizeOf };
92
+ const [imageState, retired] = await Promise.all([readImage(image, usage.images), readRetiredImages(usage.images)]);
93
+ return { volumes, ours, oldTools, spaces, image: imageState, retired, sizeOf };
81
94
  };
82
95
 
83
96
  /**
84
97
  * What the page shows: the image, the tools and the spaces' own volumes in bytes, and what a
85
- * clean-up would free now. `freesImage` says whether the image is part of that.
98
+ * clean-up would free now. `freesImage` says whether the current image is part of that. A
99
+ * retired image is counted only in what a clean-up frees.
86
100
  */
87
101
  const read = async () => {
88
- const { volumes, ours, oldTools, spaces, image: imageState, sizeOf } = await measure();
102
+ const { volumes, ours, oldTools, spaces, image: imageState, retired, sizeOf } = await measure();
89
103
  const freeTools = oldTools.filter((name) => (volumes.get(name)?.links ?? 0) === 0);
90
104
  const freesImage = imageState !== null && !imageState.inUse;
105
+ const freeRetired = retired.filter((state) => !state.inUse).reduce((total, state) => total + state.bytes, 0);
91
106
  const sum = (names) => names.reduce((total, name) => total + sizeOf(name), 0);
107
+ const pull = imagePullState();
92
108
  return {
93
109
  imageBytes: imageState?.bytes ?? null,
110
+ // A download under way on this host, and how the last one ended while the image is absent.
111
+ imagePulling: pull.pulling,
112
+ imageFailure: imageState === null && !pull.pulling ? pull.failure : null,
94
113
  toolsBytes: sum(ours.volumes),
95
114
  spacesBytes: sum(spaces),
96
- freeBytes: sum(freeTools) + (freesImage ? imageState.bytes : 0),
115
+ freeBytes: sum(freeTools) + (freesImage ? imageState.bytes : 0) + freeRetired,
97
116
  freesImage,
98
117
  };
99
118
  };
@@ -121,7 +140,7 @@ export function createDockerDisk({ engine, runCommand, colimaPath, owner, image,
121
140
  * `freedBytes` counts the sizes measured just before, of what is gone afterwards.
122
141
  */
123
142
  const cleanUp = async () => {
124
- const { oldTools, ours, image: imageState, sizeOf } = await measure();
143
+ const { oldTools, ours, image: imageState, retired, sizeOf } = await measure();
125
144
  let freedBytes = 0;
126
145
  const kept = [];
127
146
  const attempt = async (kind, name, bytes, removal) => {
@@ -144,6 +163,11 @@ export function createDockerDisk({ engine, runCommand, colimaPath, owner, image,
144
163
  if (imageState) {
145
164
  removedAny = (await attempt('image', image, imageState.bytes, engine.removeOne('image', image))) || removedAny;
146
165
  }
166
+ // A retired image stays while a container made from it exists: the gatekeeper of a space
167
+ // created before the bump keeps its image until the space is removed.
168
+ for (const state of retired) {
169
+ removedAny = (await attempt('image', state.name, state.bytes, engine.removeOne('image', state.name))) || removedAny;
170
+ }
147
171
  return { freedBytes, kept, machine: removedAny ? await trimMachine() : { state: 'skipped' } };
148
172
  };
149
173
 
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
3
3
  import { buildSpaceLabels, buildToolsLabels, hashProjectDirectory } from '../labels.js';
4
4
  import { createRegistryToolsSource, toolsContentKey } from '../tools.js';
5
5
  import { parseDockerSize } from './docker-disk.js';
6
- import { SPACE_BASE_IMAGE, createDockerPlace } from './docker.js';
6
+ import { RETIRED_SPACE_BASE_IMAGES, SPACE_BASE_IMAGE, createDockerPlace } from './docker.js';
7
7
  import { createFakeDocker, hardenedContainerEntry } from './fake-docker.js';
8
8
 
9
9
  const OWNER = 'install-a';
@@ -74,6 +74,8 @@ describe('the disk of the Docker place', () => {
74
74
  });
75
75
  expect(await makePlace(fake).readDisk()).toEqual({
76
76
  imageBytes: 1_632_000_000,
77
+ imagePulling: false,
78
+ imageFailure: null,
77
79
  toolsBytes: 438_000_000 + 440_000_000 + 441_000_000,
78
80
  spacesBytes: 1_500_000_000,
79
81
  // The old tools nothing mounts; the image and the tools the stopped space holds are in use.
@@ -89,6 +91,34 @@ describe('the disk of the Docker place', () => {
89
91
  expect(await makePlace(without).readDisk()).toMatchObject({ imageBytes: null, freeBytes: 0, freesImage: false });
90
92
  });
91
93
 
94
+ it('downloads the image once for everyone who asks, says so on the disk, and remembers a failed download while the image is absent', async () => {
95
+ const fake = createFakeDocker({ imagePresent: false, resources: [toolsVolume(OWNER, KEY)] });
96
+ let release = null;
97
+ let pulls = 0;
98
+ const runCommand = (file, args, options) => {
99
+ if (args[0] !== 'pull') return fake.runCommand(file, args, options);
100
+ pulls += 1;
101
+ return new Promise((resolve) => { release = resolve; });
102
+ };
103
+ const place = makePlace(fake, { runCommand });
104
+ const first = place.pullImage();
105
+ const second = place.pullImage();
106
+ expect(second).toBe(first);
107
+ expect(place.imagePulling()).toBe(true);
108
+ expect(await place.readDisk()).toMatchObject({ imageBytes: null, imagePulling: true, imageFailure: null });
109
+ release({ code: 1, stdout: '', stderr: 'no route to host\n' });
110
+ await expect(first).rejects.toMatchObject({ code: 'image_pull_failed' });
111
+ expect(pulls).toBe(1);
112
+ expect(place.imagePulling()).toBe(false);
113
+ expect(await place.readDisk()).toMatchObject({ imagePulling: false, imageFailure: { code: 'image_pull_failed', message: expect.stringContaining('no route to host') } });
114
+ // The next download is a fresh one, and its success clears the failure.
115
+ const third = place.pullImage();
116
+ release({ code: 0, stdout: '', stderr: '' });
117
+ await third;
118
+ expect(pulls).toBe(2);
119
+ expect(await place.readDisk()).toMatchObject({ imagePulling: false, imageFailure: null });
120
+ });
121
+
92
122
  it('a failed read rejects rather than answering an empty disk', async () => {
93
123
  const fake = createFakeDocker({ failAt: (args) => args[0] === 'system' });
94
124
  await expect(makePlace(fake).readDisk()).rejects.toMatchObject({ code: 'docker_command_failed' });
@@ -142,6 +172,55 @@ describe('clean-up of the Docker place', () => {
142
172
  expect(fake.imagePresent()).toBe(true);
143
173
  });
144
174
 
175
+ describe('a retired space image', () => {
176
+ const RETIRED = RETIRED_SPACE_BASE_IMAGES[0];
177
+ const RETIRED_BYTES = 1_600_000_000;
178
+ // The gatekeeper of a space created before the bump, still on the image it was made from.
179
+ const oldGatekeeper = { kind: 'container', name: `openchamber-space-${ID}-gatekeeper`, entry: hardenedContainerEntry({ name: `openchamber-space-${ID}-gatekeeper`, labels: spaceLabels('gatekeeper'), network: `openchamber-space-${ID}-network`, mounts: [], running: false, image: RETIRED }) };
180
+
181
+ it('is counted in what a clean-up frees and nowhere else, and removed without force', async () => {
182
+ const fake = createFakeDocker({ imagePresent: false, retiredImages: [{ name: RETIRED, bytes: RETIRED_BYTES }], resources: [toolsVolume(OWNER, KEY)] });
183
+ const place = makePlace(fake);
184
+ expect(await place.readDisk()).toEqual({ imageBytes: null, imagePulling: false, imageFailure: null, toolsBytes: 0, spacesBytes: 0, freeBytes: RETIRED_BYTES, freesImage: false });
185
+ expect(await place.cleanUpDisk()).toEqual({ freedBytes: RETIRED_BYTES, kept: [], machine: { state: 'skipped' } });
186
+ expect(fake.retiredImagePresent(RETIRED)).toBe(false);
187
+ expect(removals(fake).some((args) => args.includes('--force'))).toBe(false);
188
+ });
189
+
190
+ it('stays while a container made from it exists, and goes once that container is gone', async () => {
191
+ const fake = createFakeDocker({ imagePresent: false, retiredImages: [{ name: RETIRED, bytes: RETIRED_BYTES }], resources: [toolsVolume(OWNER, KEY), oldGatekeeper] });
192
+ const place = makePlace(fake);
193
+ expect((await place.readDisk()).freeBytes).toBe(0);
194
+ const outcome = await place.cleanUpDisk();
195
+ expect(outcome.freedBytes).toBe(0);
196
+ expect(outcome.kept).toEqual([expect.objectContaining({ kind: 'image', name: RETIRED, reason: 'in_use' })]);
197
+ expect(fake.retiredImagePresent(RETIRED)).toBe(true);
198
+
199
+ // The old space is deleted, which takes its gatekeeper with it.
200
+ expect((await fake.runCommand('/usr/bin/docker', ['rm', oldGatekeeper.name], {})).code).toBe(0);
201
+ expect((await place.readDisk()).freeBytes).toBe(RETIRED_BYTES);
202
+ expect((await place.cleanUpDisk()).freedBytes).toBe(RETIRED_BYTES);
203
+ expect(fake.retiredImagePresent(RETIRED)).toBe(false);
204
+ });
205
+
206
+ it('is left alone, uncounted and never asked to go, when it carries a tag of its own', async () => {
207
+ const fake = createFakeDocker({ imagePresent: false, retiredImages: [{ name: RETIRED, bytes: RETIRED_BYTES, tags: [RETIRED, 'node:22-bookworm'] }], resources: [toolsVolume(OWNER, KEY)] });
208
+ const place = makePlace(fake);
209
+ expect((await place.readDisk()).freeBytes).toBe(0);
210
+ expect(await place.cleanUpDisk()).toEqual({ freedBytes: 0, kept: [], machine: { state: 'skipped' } });
211
+ expect(fake.retiredImagePresent(RETIRED)).toBe(true);
212
+ expect(removals(fake)).toEqual([]);
213
+ });
214
+
215
+ it('is counted as gone when it is not there', async () => {
216
+ const fake = createFakeDocker({ imagePresent: false, resources: [toolsVolume(OWNER, KEY)] });
217
+ const place = makePlace(fake);
218
+ expect((await place.readDisk()).freeBytes).toBe(0);
219
+ expect(await place.cleanUpDisk()).toEqual({ freedBytes: 0, kept: [], machine: { state: 'skipped' } });
220
+ expect(removals(fake)).toEqual([]);
221
+ });
222
+ });
223
+
145
224
  it('on Colima asks the machine to trim its disks after something went, and on any other engine does not', async () => {
146
225
  const colimaCalls = [];
147
226
  const fake = createFakeDocker({
@@ -36,8 +36,11 @@ import { createDockerTools } from './docker-tools.js';
36
36
 
37
37
  const DOCKER_PLACE_ID = 'docker';
38
38
 
39
- // node:22-bookworm as a multi-arch index digest. DOCUMENTATION.md says how it was verified.
40
- export const SPACE_BASE_IMAGE = 'node@sha256:dd5847a04b0deee391fa145f1f4c6d214196668b6bcc7988ebed67249f226844';
39
+ // node:24-bookworm as a multi-arch index digest. DOCUMENTATION.md says how it was verified.
40
+ export const SPACE_BASE_IMAGE = 'node@sha256:3d27e5c11e5786e309ec3e03f93ae536eb36e6e5eb3714d5eb3300a36157add0';
41
+ // Base images spaces used before, which the clean-up removes like the current one. A bump adds
42
+ // the digest it replaces here. node:22-bookworm, replaced on 2026-10-08.
43
+ export const RETIRED_SPACE_BASE_IMAGES = ['node@sha256:dd5847a04b0deee391fa145f1f4c6d214196668b6bcc7988ebed67249f226844'];
41
44
 
42
45
  const CHECK_TIMEOUT_MS = 10_000;
43
46
  const PULL_TIMEOUT_MS = 20 * 60_000;
@@ -67,7 +70,12 @@ export function createDockerPlace({ runCommand, openCommandStream = openCommandS
67
70
  const engine = createDockerEngine({ runCommand, dockerPath });
68
71
  const { run, docker, inspect, removeOne, removeStoppedContainer } = engine;
69
72
  const tools = createDockerTools({ engine, owner, toolsSource, image: SPACE_BASE_IMAGE, now, wait });
70
- const disk = createDockerDisk({ engine, runCommand, colimaPath, owner, image: SPACE_BASE_IMAGE, tools });
73
+ // The one download of the image under way, shared by a creation that needs it and the places
74
+ // page's button, and how the last one ended, so the page can say why the image is still absent.
75
+ let imagePull = null;
76
+ let imagePullFailure = null;
77
+ const imagePullState = () => ({ pulling: imagePull !== null, failure: imagePullFailure });
78
+ const disk = createDockerDisk({ engine, runCommand, colimaPath, owner, image: SPACE_BASE_IMAGE, retiredImages: RETIRED_SPACE_BASE_IMAGES, tools, imagePullState });
71
79
 
72
80
  /** Every resource that carries our marker and this owner, optionally for one space. Found by label only. */
73
81
  const findResources = async (spaceId) => {
@@ -159,18 +167,34 @@ export function createDockerPlace({ runCommand, openCommandStream = openCommandS
159
167
  };
160
168
  };
161
169
 
170
+ /** Downloads the image once; a second call while one runs waits on the same pull. */
171
+ const pullImage = () => {
172
+ if (imagePull === null) {
173
+ imagePull = run(['pull', SPACE_BASE_IMAGE], PULL_TIMEOUT_MS)
174
+ .then((result) => {
175
+ if (result.code !== 0) {
176
+ throw new SpaceError(
177
+ 'image_pull_failed',
178
+ `Could not download the base image: ${result.stderr.trim() || `exit code ${result.code}`}. Common causes: no internet access on the Docker machine, or Docker's credential helper cannot run in this session.`,
179
+ );
180
+ }
181
+ imagePullFailure = null;
182
+ })
183
+ .catch((error) => {
184
+ imagePullFailure = { code: error.code ?? 'image_pull_failed', message: error.message };
185
+ throw error;
186
+ })
187
+ .finally(() => { imagePull = null; });
188
+ }
189
+ return imagePull;
190
+ };
191
+
162
192
  const ensureImage = async () => {
163
193
  const present = await inspect('image', SPACE_BASE_IMAGE);
164
194
  if (present) {
165
195
  return;
166
196
  }
167
- const result = await run(['pull', SPACE_BASE_IMAGE], PULL_TIMEOUT_MS);
168
- if (result.code !== 0) {
169
- throw new SpaceError(
170
- 'image_pull_failed',
171
- `Could not download the base image: ${result.stderr.trim() || `exit code ${result.code}`}. Common causes: no internet access on the Docker machine, or Docker's credential helper cannot run in this session.`,
172
- );
173
- }
197
+ await pullImage();
174
198
  };
175
199
 
176
200
  const verifyContainer = async (spaceId, container) => {
@@ -624,5 +648,5 @@ export function createDockerPlace({ runCommand, openCommandStream = openCommandS
624
648
  return starting.get(spaceId);
625
649
  };
626
650
 
627
- return { id: DOCKER_PLACE_ID, check, create, list, exec, execArgv, connect, stop, start, remove, verify, readDisk: disk.read, cleanUpDisk: disk.cleanUp };
651
+ return { id: DOCKER_PLACE_ID, check, create, list, exec, execArgv, connect, stop, start, remove, verify, readDisk: disk.read, cleanUpDisk: disk.cleanUp, pullImage, imagePulling: () => imagePull !== null };
628
652
  }
@@ -160,7 +160,9 @@ const readMounts = (args) => args
160
160
  * `start` is where the clock of `now()` begins. `wait(ms)` moves that clock, so nothing here sleeps.
161
161
  * `beforeFill()` runs before a tools fill ends, so a test can hold it open.
162
162
  * For the disk: `volumeSizes` maps a volume name to its size as `docker system df` prints it,
163
- * `imageBytes` is the size of the space image, `engineName` what `docker info` calls the machine,
163
+ * `imageBytes` is the size of the space image, `retiredImages` other images that are there as
164
+ * `{ name, bytes, tags }`, where `tags` defaults to the digest reference alone, as a pull by digest
165
+ * leaves it on the containerd image store, `engineName` what `docker info` calls the machine,
164
166
  * and `colima(args)` answers a call of the colima CLI, any file whose name ends in `colima`.
165
167
  */
166
168
  export function createFakeDocker({
@@ -170,6 +172,7 @@ export function createFakeDocker({
170
172
  resources = [],
171
173
  imagePresent = true,
172
174
  imageBytes = 1_632_000_000,
175
+ retiredImages = [],
173
176
  volumeSizes = {},
174
177
  engineName = 'docker-desktop',
175
178
  colima = () => ok(),
@@ -181,6 +184,7 @@ export function createFakeDocker({
181
184
  } = {}) {
182
185
  let clock = start.getTime();
183
186
  let image = imagePresent;
187
+ const retired = new Map(retiredImages.map((entry, index) => [entry.name, { id: `sha256:4567${index}`, bytes: entry.bytes, tags: entry.tags ?? [entry.name] }]));
184
188
  const calls = [];
185
189
  const late = [];
186
190
  const state = new Map(resources.map((resource) => [`${resource.kind}:${resource.name}`, resource]));
@@ -298,7 +302,18 @@ export function createFakeDocker({
298
302
  if (fails) return failed('Error response from daemon: simulated failure');
299
303
  if (first === 'inspect') return inspect('container', args.slice(3));
300
304
  // The size here is the compressed download, as the containerd image store reports it; the place must not use it.
301
- if (first === 'image' && second === 'inspect') return image ? ok(JSON.stringify([{ Id: 'sha256:0123', Size: Math.round(imageBytes / 4) }])) : failed(NOT_FOUND_TEXT.image(args[2]), '[]');
305
+ if (first === 'image' && second === 'inspect') {
306
+ const old = retired.get(args[2]);
307
+ if (old) return ok(JSON.stringify([{ Id: old.id, Size: Math.round(old.bytes / 4), RepoTags: old.tags }]));
308
+ return image && args[2] === FAKE_BASE_IMAGE ? ok(JSON.stringify([{ Id: 'sha256:0123', Size: Math.round(imageBytes / 4), RepoTags: [FAKE_BASE_IMAGE] }])) : failed(NOT_FOUND_TEXT.image(args[2]), '[]');
309
+ }
310
+ if (first === 'image' && second === 'rm' && retired.has(args[args.length - 1])) {
311
+ const name = args[args.length - 1];
312
+ const user = ofKind('container').find((resource) => resource.entry.Config?.Image === name);
313
+ if (user && !args.includes('--force')) return failed(`Error response from daemon: conflict: unable to delete ${retired.get(name).id} (must be forced) - image is being used by stopped container ${user.entry.Id}`);
314
+ retired.delete(name);
315
+ return ok(`Deleted: ${name}`);
316
+ }
302
317
  if (first === 'image' && second === 'rm') {
303
318
  if (!image || args[args.length - 1] !== FAKE_BASE_IMAGE) return failed(NOT_FOUND_TEXT.image(args[args.length - 1]));
304
319
  // Like the real engine: without --force an image that any container was made from stays.
@@ -311,7 +326,10 @@ export function createFakeDocker({
311
326
  const mountedBy = (name) => ofKind('container').filter((resource) => resource.entry.Mounts.some((mount) => mount.Name === name)).length;
312
327
  return ok(JSON.stringify({
313
328
  // Like the containerd image store: the id is the digest, and the size is what the image alone takes unpacked.
314
- Images: image ? [{ ID: 'sha256:0123', Size: `${imageBytes / 1e9}GB`, UniqueSize: `${imageBytes / 1e9}GB` }] : [],
329
+ Images: [
330
+ ...(image ? [{ ID: 'sha256:0123', Size: `${imageBytes / 1e9}GB`, UniqueSize: `${imageBytes / 1e9}GB` }] : []),
331
+ ...Array.from(retired.values(), (old) => ({ ID: old.id, Size: `${old.bytes / 1e9}GB`, UniqueSize: `${old.bytes / 1e9}GB` })),
332
+ ],
315
333
  Volumes: ofKind('volume').map((resource) => ({ Name: resource.name, Size: volumeSizes[resource.name] ?? '0B', Links: String(mountedBy(resource.name)) })),
316
334
  }));
317
335
  }
@@ -406,6 +424,7 @@ export function createFakeDocker({
406
424
  calls,
407
425
  names: () => Array.from(state.keys()),
408
426
  imagePresent: () => image,
427
+ retiredImagePresent: (name) => retired.has(name),
409
428
  token: (container) => tokens.get(homeOf(container)),
410
429
  };
411
430
  }
@@ -50,6 +50,9 @@ export function createMemoryPlace({ id = 'memory' } = {}) {
50
50
  return {
51
51
  id,
52
52
  check: async () => ({ available: true, version: 'memory', os: 'none', arch: 'none', hostIsolation: true }),
53
+ // Memory has no image to download: a pull ends at once and none is ever under way.
54
+ pullImage: async () => {},
55
+ imagePulling: () => false,
53
56
  create: async ({ id: spaceId, name, project, created }) => {
54
57
  if (spaces.has(requireSpaceId(spaceId))) {
55
58
  throw new SpaceError('space_name_taken', `Space ${spaceId} already exists`);