@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.
- package/README.md +32 -24
- package/bin/cli.js +1 -3
- package/bin/cli.test.js +40 -16
- package/bin/lib/DOCUMENTATION.md +6 -5
- package/bin/lib/cli-args.js +6 -1
- package/bin/lib/cli-args.test.js +34 -0
- package/bin/lib/cli-lifecycle.js +0 -1
- package/bin/lib/cli-network.js +8 -2
- package/bin/lib/cli-process.js +1 -1
- package/bin/lib/cli-settings-accessors.test.js +21 -13
- package/bin/lib/cli-startup.js +17 -11
- package/bin/lib/cli-startup.test.js +92 -2
- package/bin/lib/commands-connect-url.js +8 -1
- package/bin/lib/commands-schedule.js +8 -2
- package/bin/lib/commands-serve.js +15 -21
- package/bin/lib/commands-update.js +12 -24
- package/bin/lib/commands-update.test.js +14 -46
- package/dist/assets/DiffView-Cv9MSXl1.js +4 -0
- package/dist/assets/FilePreviewCommentMenu-D4kLseHj.js +4 -0
- package/dist/assets/FilesView-BVoD8I2x.js +14 -0
- package/dist/assets/{GitView-DzuzhIPx.js → GitView-Bzb8p-ew.js} +4 -4
- package/dist/assets/GuestSurfacePane-Crdbvsxo.js +1 -0
- package/dist/assets/InProgressOperationBanner-CwMjNQgk.js +2 -0
- package/dist/assets/{JsonTreeView-B_EPRjzy.js → JsonTreeView-DRKHZfsn.js} +1 -1
- package/dist/assets/MarkdownImageGallery-DZtqT1Zn.js +1 -0
- package/dist/assets/MarkdownRendererImpl-CqG7HCNd.js +10 -0
- package/dist/assets/OnboardingScreen-DMIxwYK5.js +1 -0
- package/dist/assets/{PatchDiff-6yh1csza.js → PatchDiff-CUcCg1jk.js} +1 -1
- package/dist/assets/PierreDiffViewer-CnnR-dnD.js +228 -0
- package/dist/assets/PlanView-RCBadcY7.js +5 -0
- package/dist/assets/SettingsView-CjBjZFRO.js +30 -0
- package/dist/assets/SettingsWindow-DyM7QuhI.js +1 -0
- package/dist/assets/SubmoduleDiffSummary-BWIfgMED.js +1 -0
- package/dist/assets/ToolOutputDialog-Q0P3FuAi.js +15 -0
- package/dist/assets/ToolPartDiffPreview-p7qR9FpU.js +8 -0
- package/dist/assets/{VirtualizedFileDiff-BTJVRkyo.js → VirtualizedFileDiff-CFpjL8Ns.js} +3 -3
- package/dist/assets/{Virtualizer-xCEYz34p.js → Virtualizer-C0IsclZn.js} +1 -1
- package/dist/assets/WalkthroughView-DI31zD02.js +4 -0
- package/dist/assets/appThemeRegistry-B9gkFq9i.js +1 -0
- package/dist/assets/{computeVirtualFileMetrics-CmPe29DM.js → computeVirtualFileMetrics-C2WM6JC2.js} +2 -2
- package/dist/assets/de-YKfk5MmC.js +14 -0
- package/dist/assets/es-_gwT0kr-.js +20 -0
- package/dist/assets/fileTreeStatus-D438yi8M.js +1 -0
- package/dist/assets/fr-DqzvXmIm.js +18 -0
- package/dist/assets/index-BLaHKdtx.js +1 -0
- package/dist/assets/index-BkMXRrVQ.css +1 -0
- package/dist/assets/index-DxNa7xYB.js +65 -0
- package/dist/assets/initializeSharedPreferences-Bck5EAWP.js +1 -0
- package/dist/assets/{isDiffPlainText-CQmDCnEv.js → isDiffPlainText-Dgbw8VKk.js} +1 -1
- package/dist/assets/{isFilePlainText-CYBUzW4I.js → isFilePlainText-_ZS5UYRj.js} +1 -1
- package/dist/assets/ja-B-GFYhQE.js +20 -0
- package/dist/assets/ko-mzksfqDC.js +20 -0
- package/dist/assets/main-C5Xx8dn_.js +2 -0
- package/dist/assets/main-CfBSQM1H.js +990 -0
- package/dist/assets/markdownCore-BmWwW1ll.js +7 -0
- package/dist/assets/miniChat-mnKwy4vQ.js +2 -0
- package/dist/assets/mobile-DeEQd0wZ.js +2 -0
- package/dist/assets/nl-CWQr6NSq.js +20 -0
- package/dist/assets/{parseDiffFromFile-Cy0PNxun.js → parseDiffFromFile-BUgn-T9p.js} +2 -2
- package/dist/assets/patchFileDiff-Dt8LhgBh.js +13 -0
- package/dist/assets/pl-UmRcDUX2.js +20 -0
- package/dist/assets/pt-BR-BSLybvVF.js +20 -0
- package/dist/assets/relocateRestoredSession-B5E9xb3T.js +1 -0
- package/dist/assets/renderElectronMiniChatApp-C0a9A5_J.js +2 -0
- package/dist/assets/renderMobileApp-BNncg3Pk.js +25 -0
- package/dist/assets/runtimeEndpointReset-CZ5aafpl.js +62 -0
- package/dist/assets/{selectionMarkdown-TkYk5h9l.js → selectionMarkdown-CUN3wUld.js} +9 -9
- package/dist/assets/{sessionOwnership-Bs1xbRfB.js → sessionOwnership-BSuxCEhb.js} +1 -1
- package/dist/assets/shikiHighlight-DHDx-6eM.js +1 -0
- package/dist/assets/space-apply-DM_2pRW7.js +1 -0
- package/dist/assets/space-close-Bpomt8z0.js +1 -0
- package/dist/assets/{surface-B6n7wJtt.js → surface-C-jQaGnL.js} +1 -1
- package/dist/assets/{textMateThemeFromAppTheme-CDlKffWz.js → textMateThemeFromAppTheme-DlsTphbY.js} +1 -1
- package/dist/assets/tr-gaTrX_HK.js +11 -0
- package/dist/assets/uk-CnJF6ykl.js +20 -0
- package/dist/assets/{useAppFontEffects-DCqCVGlT.js → useAppFontEffects-DGlvOqP_.js} +194 -233
- package/dist/assets/useInlineCommentController-kMN4Wg_D.js +2 -0
- package/dist/assets/{useRootScrollLock-BowwS8kq.js → useRootScrollLock-DMPpvpNP.js} +2 -2
- package/dist/assets/{vendor-base-ui-DX96QhTP.js → vendor-base-ui-Det7U81h.js} +1 -1
- package/dist/assets/{vendor-codemirror-autocomplete-DW1FQz9_.js → vendor-codemirror-autocomplete-BSHQHbKu.js} +1 -1
- package/dist/assets/{vendor-codemirror-commands-DpmhalwN.js → vendor-codemirror-commands-C4G_dPWe.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-angular-BqExbN1M.js → vendor-codemirror-lang-angular-B6LbBGHB.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-cpp-DZA36HfS.js → vendor-codemirror-lang-cpp-BWWXBx7W.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-css-B07blnnZ.js → vendor-codemirror-lang-css-Cayfb29q.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-go-BM8K9D1-.js → vendor-codemirror-lang-go-BoKlCNKw.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-html-DaH9adfE.js → vendor-codemirror-lang-html-BQWerHsJ.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-java-CsdC0viz.js → vendor-codemirror-lang-java-DWX7Fq_K.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-javascript-CpGYXGuo.js → vendor-codemirror-lang-javascript-DbpEcZM0.js} +2 -2
- package/dist/assets/{vendor-codemirror-lang-jinja-BBeGzXw5.js → vendor-codemirror-lang-jinja-D1JIJRA7.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-json-58T70Hqs.js → vendor-codemirror-lang-json-BzgckzmB.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-less-BI-xCA59.js → vendor-codemirror-lang-less-B9EpDXFi.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-liquid-zEcMa-zy.js → vendor-codemirror-lang-liquid-9U7kRbSf.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-markdown-BLKnlLP4.js → vendor-codemirror-lang-markdown-D2TEBIvZ.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-php-9f35M83-.js → vendor-codemirror-lang-php-BT6sDVzE.js} +1 -1
- package/dist/assets/vendor-codemirror-lang-python-jAMtYiA9.js +3 -0
- package/dist/assets/{vendor-codemirror-lang-rust-MNDL_jZQ.js → vendor-codemirror-lang-rust-CVO56EiL.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-sass-p9epsSNm.js → vendor-codemirror-lang-sass-DZlOoUWu.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-sql-Bqzh0EXX.js → vendor-codemirror-lang-sql-fZ6t1DPw.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-vue-BnNUWV-p.js → vendor-codemirror-lang-vue-DxbqrLiE.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-wast-C2a-oLmk.js → vendor-codemirror-lang-wast-DyU1Gt-I.js} +1 -1
- package/dist/assets/vendor-codemirror-lang-xml-C9a_K5_p.js +1 -0
- package/dist/assets/{vendor-codemirror-lang-yaml-C0sjswB0.js → vendor-codemirror-lang-yaml-Cptq8l0e.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-ByBn5zIW.js → vendor-codemirror-language-DufXoQvE.js} +2 -2
- package/dist/assets/vendor-codemirror-language-data-CtwSWKtX.js +2 -0
- package/dist/assets/{vendor-codemirror-search-ib-sHD5W.js → vendor-codemirror-search-CvXM_pZg.js} +1 -1
- package/dist/assets/{vendor-codemirror-state-G3UuR-be.js → vendor-codemirror-state-Dek9sMtw.js} +1 -1
- package/dist/assets/{vendor-codemirror-view-Bniqp7p5.js → vendor-codemirror-view-1e0FQ6Wn.js} +1 -1
- package/dist/assets/vendor-framer-motion-DxDVhOLc.js +9 -0
- package/dist/assets/vendor-katex-CuQvj9-L.js +257 -0
- package/dist/assets/vendor-katex-DdZNUVNG.css +1 -0
- package/dist/assets/{vendor-motion-dom-DcCgeG69.js → vendor-motion-dom-HXPOMxb7.js} +1 -1
- package/dist/assets/{vendor-opencode-client-DHzhmLwV.js → vendor-opencode-client-BmFcUkbQ.js} +1 -1
- package/dist/assets/{vendor-replit-codemirror-vim-DkzqxN2H.js → vendor-replit-codemirror-vim-BlJRn-cW.js} +2 -2
- package/dist/assets/{vendor-tanstack-react-virtual-H_JmXOFV.js → vendor-tanstack-react-virtual-CvuLsk7D.js} +1 -1
- package/dist/assets/{vendor-tanstack-virtual-core-Dxc8hSTB.js → vendor-tanstack-virtual-core-DPaNcJcT.js} +1 -1
- package/dist/assets/zh-CN-t2vigSB1.js +20 -0
- package/dist/assets/zh-TW-jAgqBokB.js +20 -0
- package/dist/index.html +6 -6
- package/dist/mini-chat.html +6 -6
- package/dist/mobile.html +6 -6
- package/package.json +8 -5
- package/server/index.js +80 -36
- package/server/lib/agent-memory/actions.js +1 -6
- package/server/lib/agent-memory/routes.js +1 -2
- package/server/lib/agent-memory/runtime.js +1 -8
- package/server/lib/agent-tool/DOCUMENTATION.md +7 -3
- package/server/lib/agent-tool/runtime.js +3 -6
- package/server/lib/agent-tool/runtime.test.js +0 -2
- package/server/lib/browser-control/DOCUMENTATION.md +6 -0
- package/server/lib/changelog/update-notes.js +1 -1
- package/server/lib/context-obligatory/runtime.js +1 -2
- package/server/lib/dev-tunnel/DOCUMENTATION.md +8 -0
- package/server/lib/dev-tunnel/client.js +21 -10
- package/server/lib/dev-tunnel/tunnel.test.js +34 -0
- package/server/lib/dispatch-results/runtime.js +1 -1
- package/server/lib/enterprise-mode.d.ts +1 -0
- package/server/lib/enterprise-mode.js +13 -2
- package/server/lib/enterprise-mode.test.js +21 -0
- package/server/lib/event-stream/DOCUMENTATION.md +2 -2
- package/server/lib/event-stream/index.js +1 -3
- package/server/lib/event-stream/translate-v2.js +1 -9
- package/server/lib/event-stream/translate-v2.test.js +0 -11
- package/server/lib/fs/DOCUMENTATION.md +5 -2
- package/server/lib/fs/routes.js +62 -15
- package/server/lib/fs/routes.test.js +278 -48
- package/server/lib/fs/workspace-file-names.js +104 -0
- package/server/lib/fs/workspace-file-names.test.js +62 -0
- package/server/lib/git/DOCUMENTATION.md +17 -5
- package/server/lib/git/contributor-provenance-storage.js +9 -6
- package/server/lib/git/contributor-provenance-storage.test.js +29 -0
- package/server/lib/git/credential-broker.js +1 -1
- package/server/lib/git/credential-resolver.js +1 -4
- package/server/lib/git/credentials.js +1 -0
- package/server/lib/git/discovery-endpoint.js +1 -1
- package/server/lib/git/identity-provisioning.js +2 -1
- package/server/lib/git/identity-storage.js +3 -4
- package/server/lib/git/lfs-discovery.js +14 -14
- package/server/lib/git/lfs-discovery.test.js +16 -6
- package/server/lib/git/network-operation-plan.js +50 -62
- package/server/lib/git/network-operation-plan.test.js +103 -1
- package/server/lib/git/network-operation-registry.js +4 -6
- package/server/lib/git/network-operation-storage.js +4 -6
- package/server/lib/git/network-operations.js +12 -4
- package/server/lib/git/network-operations.test.js +19 -6
- package/server/lib/git/redaction.js +2 -1
- package/server/lib/git/repository-credential-runtime.js +5 -3
- package/server/lib/git/repository-credential-runtime.test.js +22 -0
- package/server/lib/git/routes.js +31 -137
- package/server/lib/git/routes.test.js +2 -2
- package/server/lib/git/service.js +143 -27
- package/server/lib/git/service.test.js +215 -1
- package/server/lib/git/ssh-credential-storage.js +1 -4
- package/server/lib/git/submodule-discovery.js +1 -1
- package/server/lib/git/worktree-bootstrap-storage.js +3 -4
- package/server/lib/git/worktree-bootstrap-storage.test.js +15 -0
- package/server/lib/git/worktree-creation.js +194 -0
- package/server/lib/git/worktree-creation.test.js +155 -0
- package/server/lib/github/DOCUMENTATION.md +11 -6
- package/server/lib/github/auth.js +2 -4
- package/server/lib/github/device-flow.js +2 -2
- package/server/lib/github/gh-cli-credential.js +1 -0
- package/server/lib/github/octokit.js +31 -0
- package/server/lib/github/pr-status.js +35 -18
- package/server/lib/github/pr-status.test.js +271 -3
- package/server/lib/github/reference-search.js +102 -13
- package/server/lib/github/reference-search.test.js +48 -7
- package/server/lib/github/routes.auth.test.js +18 -0
- package/server/lib/github/routes.js +360 -6
- package/server/lib/github/routes.mutations.test.js +239 -0
- package/server/lib/github/routes.test.js +134 -2
- package/server/lib/gitlab/DOCUMENTATION.md +5 -1
- package/server/lib/gitlab/auth-storage.test.js +1 -0
- package/server/lib/gitlab/mappers.js +30 -1
- package/server/lib/gitlab/resources.js +230 -6
- package/server/lib/gitlab/resources.test.js +179 -0
- package/server/lib/gitlab/routes.js +200 -6
- package/server/lib/gitlab/routes.test.js +186 -2
- package/server/lib/gitlab/validation.js +1 -5
- package/server/lib/guests/DOCUMENTATION.md +1 -1
- package/server/lib/guests/oauth.js +2 -2
- package/server/lib/guests/service.js +1 -1
- package/server/lib/guests/sockets.js +1 -1
- package/server/lib/injected-env.d.ts +8 -0
- package/server/lib/injected-env.js +50 -0
- package/server/lib/injected-env.test.js +56 -0
- package/server/lib/linear/DOCUMENTATION.md +5 -6
- package/server/lib/linear/issues.js +62 -4
- package/server/lib/linear/issues.test.js +68 -0
- package/server/lib/linear/oauth.js +5 -5
- package/server/lib/linear/parse.js +2 -9
- package/server/lib/message-queue/DOCUMENTATION.md +23 -1
- package/server/lib/message-queue/runtime.js +85 -12
- package/server/lib/message-queue/runtime.test.js +172 -2
- package/server/lib/notifications/apns-runtime.js +5 -6
- package/server/lib/notifications/apns-runtime.test.js +20 -4
- package/server/lib/openchamber-control/DOCUMENTATION.md +19 -1
- package/server/lib/openchamber-control/actions.js +2 -1
- package/server/lib/openchamber-control/service.js +119 -20
- package/server/lib/openchamber-control/service.test.js +166 -0
- package/server/lib/openchamber-sessions/archive-store.js +1 -6
- package/server/lib/openchamber-sessions/routes.js +47 -12
- package/server/lib/openchamber-sessions/routes.test.js +65 -2
- package/server/lib/openchamber-sessions/session-metadata-store.js +1 -6
- package/server/lib/opencode/DOCUMENTATION.md +22 -8
- package/server/lib/opencode/agents.js +62 -0
- package/server/lib/opencode/auth-state-runtime.js +3 -1
- package/server/lib/opencode/auth.d.ts +1 -1
- package/server/lib/opencode/auth.js +8 -0
- package/server/lib/opencode/auth.test.js +31 -0
- package/server/lib/opencode/config-entity-routes.js +16 -0
- package/server/lib/opencode/config-entity-v2.test.js +16 -1
- package/server/lib/opencode/config-paths.test.js +2 -2
- package/server/lib/opencode/config-v2.js +2 -4
- package/server/lib/opencode/feature-routes-runtime.js +89 -49
- package/server/lib/opencode/lifecycle.js +41 -21
- package/server/lib/opencode/lifecycle.test.js +50 -2
- package/server/lib/opencode/model-discovery.js +1 -1
- package/server/lib/opencode/model-discovery.test.js +17 -0
- package/server/lib/opencode/models-metadata.js +0 -2
- package/server/lib/opencode/openchamber-routes.test.js +3 -3
- package/server/lib/opencode/plugins.js +6 -9
- package/server/lib/opencode/provider-source-route.test.js +101 -3
- package/server/lib/opencode/providers-disabled.test.js +59 -0
- package/server/lib/opencode/providers.js +34 -0
- package/server/lib/opencode/proxy.js +11 -3
- package/server/lib/opencode/routes.js +40 -2
- package/server/lib/opencode/settings-helpers.js +27 -1
- package/server/lib/opencode/settings-helpers.test.js +19 -7
- package/server/lib/opencode/settings-registry.json +27 -16
- package/server/lib/opencode/settings-runtime.test.js +18 -0
- package/server/lib/opencode/shared-service-runtime.js +23 -104
- package/server/lib/opencode/shared.js +77 -46
- package/server/lib/opencode/shared.test.js +12 -12
- package/server/lib/opencode/shutdown-runtime.test.js +2 -3
- package/server/lib/opencode/skill-routes.js +123 -38
- package/server/lib/opencode/skill-routes.test.js +201 -0
- package/server/lib/opencode/skills.js +81 -34
- package/server/lib/opencode/skills.test.js +176 -21
- package/server/lib/opencode/startup-pipeline-runtime.js +6 -3
- package/server/lib/opencode/startup-pipeline-runtime.test.js +2 -1
- package/server/lib/opencode/watcher.test.js +0 -54
- package/server/lib/outbound-proxy.js +52 -0
- package/server/lib/outbound-proxy.test.js +55 -0
- package/server/lib/package-manager.js +23 -17
- package/server/lib/package-manager.test.js +5 -18
- package/server/lib/permission-auto-accept/DOCUMENTATION.md +1 -1
- package/server/lib/permission-auto-accept/runtime.js +19 -47
- package/server/lib/permission-auto-accept/runtime.test.js +2 -2
- package/server/lib/project-context/DOCUMENTATION.md +7 -1
- package/server/lib/project-context/routes.js +1 -2
- package/server/lib/project-context/runtime.js +12 -14
- package/server/lib/project-context/runtime.test.js +78 -2
- package/server/lib/projects/project-config.js +30 -15
- package/server/lib/projects/project-config.test.js +30 -0
- package/server/lib/projects/project-setup.js +1 -2
- package/server/lib/projects/routes.js +1 -2
- package/server/lib/quota/DOCUMENTATION.md +35 -7
- package/server/lib/quota/index.js +1 -0
- package/server/lib/quota/providers/claude/index.js +1 -1
- package/server/lib/quota/providers/cline-pass.js +1 -1
- package/server/lib/quota/providers/exe-dev.js +1 -1
- package/server/lib/quota/providers/google/index.js +1 -1
- package/server/lib/quota/providers/hyper.js +1 -1
- package/server/lib/quota/providers/kimi.js +116 -36
- package/server/lib/quota/providers/kimi.test.js +128 -3
- package/server/lib/quota/providers/opencode-go.js +144 -21
- package/server/lib/quota/providers/opencode-go.test.js +229 -6
- package/server/lib/quota/providers/openrouter.js +1 -1
- package/server/lib/quota/providers/zenmux.js +1 -1
- package/server/lib/quota/utils/auth.js +1 -0
- package/server/lib/quota/utils/transformers.js +2 -6
- package/server/lib/relay/DOCUMENTATION.md +18 -3
- package/server/lib/relay/e2ee.js +7 -7
- package/server/lib/relay/host-client.js +84 -13
- package/server/lib/relay/host-client.test.js +201 -1
- package/server/lib/relay/identity.js +6 -29
- package/server/lib/relay/identity.test.js +18 -28
- package/server/lib/relay/key-store.js +141 -0
- package/server/lib/relay/key-store.test.js +138 -0
- package/server/lib/relay/service.js +8 -8
- package/server/lib/relay/service.test.js +10 -5
- package/server/lib/relay/signing-key.js +13 -36
- package/server/lib/relay/tunnel-codec.js +10 -10
- package/server/lib/route-module-exports.test.js +22 -0
- package/server/lib/routing/DOCUMENTATION.md +23 -3
- package/server/lib/routing/cache.js +87 -0
- package/server/lib/routing/defaults.js +11 -0
- package/server/lib/routing/history.js +12 -4
- package/server/lib/routing/runtime.js +39 -3
- package/server/lib/routing/runtime.test.js +81 -2
- package/server/lib/routing/store.js +12 -1
- package/server/lib/routing/store.test.js +13 -0
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +59 -1
- package/server/lib/scheduled-tasks/existing-session.js +119 -0
- package/server/lib/scheduled-tasks/existing-session.test.js +350 -0
- package/server/lib/scheduled-tasks/loops.js +1 -8
- package/server/lib/scheduled-tasks/routes.js +8 -8
- package/server/lib/scheduled-tasks/runtime.js +103 -13
- package/server/lib/scheduled-tasks/runtime.test.js +48 -0
- package/server/lib/scheduled-tasks/service.js +33 -11
- package/server/lib/scheduled-tasks/service.test.js +128 -2
- package/server/lib/scheduled-tasks/session-defaults.js +27 -0
- package/server/lib/scheduled-tasks/session-defaults.test.js +19 -0
- package/server/lib/session-assist/context.js +1 -0
- package/server/lib/session-folders/routes.js +2 -2
- package/server/lib/session-goal/objectives.js +1 -1
- package/server/lib/session-knowledge/routes.js +2 -2
- package/server/lib/session-knowledge/runtime.js +1 -2
- package/server/lib/session-work/DOCUMENTATION.md +39 -14
- package/server/lib/session-work/questions.js +31 -2
- package/server/lib/session-work/runtime.js +22 -13
- package/server/lib/session-work/runtime.test.js +43 -26
- package/server/lib/session-work/state.js +10 -4
- package/server/lib/session-work/state.test.js +8 -1
- package/server/lib/shared/DOCUMENTATION.md +89 -0
- package/server/lib/shared/guards.js +53 -0
- package/server/lib/shared/guards.test.js +112 -0
- package/server/lib/skills-catalog/DOCUMENTATION.md +10 -8
- package/server/lib/skills-catalog/cache.js +3 -2
- package/server/lib/skills-catalog/curated-sources.js +5 -1
- package/server/lib/skills-catalog/git.js +107 -20
- package/server/lib/skills-catalog/git.test.js +78 -0
- package/server/lib/skills-catalog/install.js +21 -12
- package/server/lib/skills-catalog/scan.js +25 -15
- package/server/lib/skills-catalog/source.js +26 -1
- package/server/lib/skills-catalog/source.test.js +27 -0
- package/server/lib/source-control/DOCUMENTATION.md +2 -1
- package/server/lib/source-control/audit-storage.js +7 -6
- package/server/lib/source-control/audit-storage.test.js +11 -0
- package/server/lib/source-control/binding-service.js +2 -5
- package/server/lib/source-control/change-request-worktree.js +90 -0
- package/server/lib/source-control/change-request-worktree.test.js +121 -0
- package/server/lib/source-control/mutation-executor.js +1 -1
- package/server/lib/source-control/mutation-storage.js +11 -5
- package/server/lib/source-control/provider-instance.js +1 -2
- package/server/lib/source-control/repository-identity.js +1 -1
- package/server/lib/source-control/routes.js +14 -2
- package/server/lib/spaces/DOCUMENTATION.md +42 -17
- package/server/lib/spaces/command-stream.test.js +15 -0
- package/server/lib/spaces/dispatcher.js +7 -0
- package/server/lib/spaces/dispatcher.test.js +17 -0
- package/server/lib/spaces/hardening.js +2 -2
- package/server/lib/spaces/hardening.test.js +1 -1
- package/server/lib/spaces/host.js +2 -0
- package/server/lib/spaces/journey.js +66 -5
- package/server/lib/spaces/journey.test.js +95 -4
- package/server/lib/spaces/layout.js +1 -1
- package/server/lib/spaces/places/docker-disk.js +38 -14
- package/server/lib/spaces/places/docker-disk.test.js +80 -1
- package/server/lib/spaces/places/docker.js +35 -11
- package/server/lib/spaces/places/fake-docker.js +22 -3
- package/server/lib/spaces/places/memory-place.js +3 -0
- package/server/lib/spaces/places/packed.docker.live.test.js +1 -1
- package/server/lib/spaces/routes.js +5 -0
- package/server/lib/spaces/routes.test.js +4 -2
- package/server/lib/spaces/run-command.js +9 -2
- package/server/lib/spaces/tools.js +4 -5
- package/server/lib/spaces/tools.test.js +2 -2
- package/server/lib/terminal/runtime.js +18 -6
- package/server/lib/terminal/runtime.test.js +66 -0
- package/server/lib/tts/index.js +3 -0
- package/server/lib/tts/service.js +4 -2
- package/server/lib/walkthrough/DOCUMENTATION.md +3 -1
- package/server/lib/walkthrough/index.js +2 -3
- package/server/lib/walkthrough/pr-source.test.js +22 -0
- package/server/lib/walkthrough/sources.js +6 -1
- package/server/opencode-proxy.test.js +127 -0
- package/bin/lib/commands-startup.test.js +0 -15
- package/dist/assets/CodeMirrorCommentWidgets-CVDSjcrP.js +0 -1
- package/dist/assets/DiffView-Ct7vL3fD.js +0 -4
- package/dist/assets/FilesView-DT0uEYgT.js +0 -17
- package/dist/assets/GuestSurfacePane-DVvk1VPu.js +0 -1
- package/dist/assets/LinearIssuesView-C10MwA9h.js +0 -1
- package/dist/assets/MarkdownImageGallery-BbN_K_3d.js +0 -1
- package/dist/assets/MarkdownRendererImpl-BLt2Tkk9.js +0 -10
- package/dist/assets/OnboardingScreen-Bgp5A3WB.js +0 -1
- package/dist/assets/PierreDiffViewer-CWJLj-S-.js +0 -210
- package/dist/assets/PlanView-DsRWnsOI.js +0 -5
- package/dist/assets/PublishDialog-CCeKloRu.js +0 -2
- package/dist/assets/SettingsView-BuPPsNPp.js +0 -30
- package/dist/assets/SettingsWindow-CM5fL8Ot.js +0 -1
- package/dist/assets/SubmoduleDiffSummary-CfsMcfiQ.js +0 -1
- package/dist/assets/ToolOutputDialog-mZrfSofz.js +0 -15
- package/dist/assets/ToolPartDiffPreview-BUGCRYEL.js +0 -8
- package/dist/assets/WalkthroughView-ClBo7PPd.js +0 -4
- package/dist/assets/appThemeRegistry-CxzMxR-H.js +0 -1
- package/dist/assets/de-CBla4hjd.js +0 -14
- package/dist/assets/es-BmnZtxTD.js +0 -20
- package/dist/assets/fileTreeStatus-BSHFIGEg.js +0 -1
- package/dist/assets/fr-OVKIBUZO.js +0 -18
- package/dist/assets/index-CKCFKmjF.js +0 -1
- package/dist/assets/index-Dbx4V4GN.css +0 -1
- package/dist/assets/index-vQ1Pg1cr.js +0 -61
- package/dist/assets/ja-6v-yr-gE.js +0 -20
- package/dist/assets/ko-I_ZLvgQ9.js +0 -20
- package/dist/assets/main-CmO4dRtR.js +0 -2
- package/dist/assets/main-b9vqhlYM.js +0 -999
- package/dist/assets/markdownCore-BPEZRQfP.js +0 -7
- package/dist/assets/miniChat-B5Oi7B-6.js +0 -2
- package/dist/assets/mobile-CVtShif9.js +0 -2
- package/dist/assets/nl-DcvIJf46.js +0 -20
- package/dist/assets/patchFileDiff-CW3wCH1O.js +0 -13
- package/dist/assets/pl-JxCUl0sd.js +0 -20
- package/dist/assets/pt-BR-L89mjja2.js +0 -20
- package/dist/assets/relocateRestoredSession-DYh_9oBa.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-BD-1JHSg.js +0 -2
- package/dist/assets/renderMobileApp-7s5InXt1.js +0 -25
- package/dist/assets/runtimeEndpointReset-DqLJC6L1.js +0 -53
- package/dist/assets/shikiHighlight-CXgZnvHh.js +0 -1
- package/dist/assets/space-apply-1trGVIl8.js +0 -1
- package/dist/assets/space-close-y7j-NuBv.js +0 -1
- package/dist/assets/tr-ka5J1flt.js +0 -11
- package/dist/assets/uk-PyGe6Q_u.js +0 -20
- package/dist/assets/useInlineCommentController-CrjSGCpi.js +0 -2
- package/dist/assets/vendor-codemirror-lang-python-B7ClrMIT.js +0 -3
- package/dist/assets/vendor-codemirror-lang-xml-C8tGnS64.js +0 -1
- package/dist/assets/vendor-codemirror-language-data-q2bKO1EM.js +0 -2
- package/dist/assets/vendor-framer-motion-Bq3iTZ6r.js +0 -1
- package/dist/assets/vendor-katex-CHD9BdDO.js +0 -257
- package/dist/assets/vendor-katex-Cp579UoJ.css +0 -1
- package/dist/assets/zh-CN-B0P2tybJ.js +0 -20
- 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
|
-
|
|
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
|
|
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
|
|
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:
|
|
39
|
-
* Docker machine that calls itself Colima is asked to
|
|
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
|
-
*
|
|
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',
|
|
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=${
|
|
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
|
-
|
|
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:
|
|
40
|
-
export const SPACE_BASE_IMAGE = 'node@sha256:
|
|
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
|
-
|
|
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
|
-
|
|
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, `
|
|
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')
|
|
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:
|
|
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`);
|