@vantran-se/openchamber-web 1.23.1 → 1.24.3
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/bin/cli.test.js +29 -6
- package/bin/lib/cli-http.js +5 -1
- package/bin/lib/commands-status.js +5 -0
- package/dist/assets/{CodeMirrorCommentWidgets-BOpqXpFU.js → CodeMirrorCommentWidgets-BjJHrzmU.js} +1 -1
- package/dist/assets/DiffView-COVQhWGd.js +4 -0
- package/dist/assets/FilesView-B1V5JxFX.js +13 -0
- package/dist/assets/GitView-CDOJy5Ay.js +6 -0
- package/dist/assets/GuestAttachDialog-D3S0Rjic.js +1 -0
- package/dist/assets/{JsonTreeView-nd_E8eVM.js → JsonTreeView-D0C9nU7K.js} +1 -1
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/LinearIssuesView-D7FKg1XJ.js +1 -0
- package/dist/assets/{MarkdownImageGallery-CVYds3sQ.js → MarkdownImageGallery-BrFjAVEf.js} +1 -1
- package/dist/assets/MarkdownRendererImpl-DWepTafh.js +12 -0
- package/dist/assets/{OnboardingScreen-Dd2IbPTn.js → OnboardingScreen-Dyxxiqdt.js} +1 -1
- package/dist/assets/{PatchDiff-nUu5i4bG.js → PatchDiff-DnEhjXcC.js} +1 -1
- package/dist/assets/PierreDiffViewer-DyxZVVVR.js +210 -0
- package/dist/assets/PlanView-LW2z_EAP.js +5 -0
- package/dist/assets/PluginPane-B4tKno2h.js +3 -0
- package/dist/assets/SettingsView-DEaMulN9.js +29 -0
- package/dist/assets/{SettingsWindow-BccxY81J.js → SettingsWindow-FLDJXOgQ.js} +1 -1
- package/dist/assets/SubmoduleDiffSummary-CvVwD-Vi.js +1 -0
- package/dist/assets/ToolOutputDialog-BiP0x2Rw.js +15 -0
- package/dist/assets/{ToolPartDiffPreview-BFVgk0UK.js → ToolPartDiffPreview-CkR4msKP.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-z3_H74UR.js → VirtualizedFileDiff-0pyr_mHY.js} +3 -3
- package/dist/assets/{WalkthroughView-BqM9GFbJ.js → WalkthroughView-yNj4nOP5.js} +2 -2
- package/dist/assets/appThemeRegistry-BuUKhbYQ.js +1 -0
- package/dist/assets/commitAndPush-DvJKa3Ks.js +1 -0
- package/dist/assets/{computeVirtualFileMetrics-Bdazp9Qo.js → computeVirtualFileMetrics-DxTBMuu-.js} +1 -1
- package/dist/assets/de-DgxgFyGG.js +10 -0
- package/dist/assets/es-BFsZj5F_.js +16 -0
- package/dist/assets/fr-jdZ5KKQi.js +14 -0
- package/dist/assets/{index-eo1-D_7z.js → index-CKHjPKbq.js} +1 -1
- package/dist/assets/index-Cbci4i1W.js +40 -0
- package/dist/assets/index-DqaMWNrC.css +1 -0
- package/dist/assets/{isDiffPlainText-BvKH712-.js → isDiffPlainText-D7FkBMP6.js} +1 -1
- package/dist/assets/{isFilePlainText-BdVVPukf.js → isFilePlainText-CL2vsrIZ.js} +1 -1
- package/dist/assets/ja-C5kKrhO4.js +16 -0
- package/dist/assets/ko-DXDTT_0O.js +16 -0
- package/dist/assets/main-CZALtVek.js +896 -0
- package/dist/assets/main-CgXMBNvG.js +2 -0
- package/dist/assets/{markdown-shiki.worker-D5fBU-lw.js → markdown-shiki.worker-CahLvHIq.js} +5 -5
- package/dist/assets/markdownCore-Dh7WEsdM.js +5 -0
- package/dist/assets/miniChat-CwTmDixi.js +2 -0
- package/dist/assets/mobile-CLf2vCtO.js +2 -0
- package/dist/assets/{patchFileDiff-B7LjppSS.js → patchFileDiff-Bsba8KFp.js} +3 -3
- package/dist/assets/pl-D-7x4Y8Q.js +16 -0
- package/dist/assets/pt-BR-CSBZrHa8.js +16 -0
- package/dist/assets/renderElectronMiniChatApp-C5gAFBk0.js +2 -0
- package/dist/assets/renderMobileApp-V4hYd32H.js +25 -0
- package/dist/assets/runtimeEndpointReset-DhlHCn-2.js +25 -0
- package/dist/assets/{scheduledTasksApi-hceovDei.js → scheduledTasksApi-DzjbLd8P.js} +1 -1
- package/dist/assets/shikiHighlight-8_3csmr6.js +1 -0
- package/dist/assets/textMateThemeFromAppTheme-CHo2fCgW.js +1 -0
- package/dist/assets/tr-Dr-oJ22L.js +7 -0
- package/dist/assets/uk-9Nx83UcI.js +16 -0
- package/dist/assets/useAppFontEffects-khZXpFL_.js +7333 -0
- package/dist/assets/useInlineCommentController-DkQ_gqpn.js +2 -0
- package/dist/assets/useRootScrollLock-DdvHf9Pt.js +2 -0
- package/dist/assets/{vendor-base-ui-BY_5qla_.js → vendor-base-ui-CsXYnR5c.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-DWA1bm1F.js → vendor-codemirror-language-data-CdzUmUyZ.js} +2 -2
- package/dist/assets/vendor-framer-motion-Bq3iTZ6r.js +1 -0
- package/dist/assets/vendor-katex-Cp579UoJ.css +1 -0
- package/dist/assets/{vendor-motion-dom-D_o3BTI3.js → vendor-motion-dom-DcCgeG69.js} +1 -1
- package/dist/assets/{vendor-remixicon-react-Dil0zKKT.js → vendor-remixicon-react-BDmGmFCA.js} +1 -1
- package/dist/assets/vendor-strip-json-comments-5dJlOkP1.js +4 -0
- package/dist/assets/{vendor-tanstack-react-virtual-g05Cx3z9.js → vendor-tanstack-react-virtual-ClYN8CzB.js} +1 -1
- package/dist/assets/{vendor-tanstack-virtual-core-Dubg10g5.js → vendor-tanstack-virtual-core-BewVQryB.js} +1 -1
- package/dist/assets/{vendor-zod-B6551dwG.js → vendor-zod-CgAdXWZO.js} +4 -4
- package/dist/assets/{web-components-B_Z64So5.js → web-components-B8IcVQAA.js} +2 -2
- package/dist/assets/zh-CN-Bxy_Hj3r.js +16 -0
- package/dist/assets/zh-TW-0BB4gpHM.js +16 -0
- package/dist/index.html +6 -4
- package/dist/mini-chat.html +6 -4
- package/dist/mobile.html +6 -4
- package/package.json +12 -9
- package/server/built-in-extensions/registry.json +4 -0
- package/server/index.d.ts +2 -0
- package/server/index.js +44 -10
- package/server/lib/agent-tool/DOCUMENTATION.md +17 -4
- package/server/lib/agent-tool/runtime.js +62 -15
- package/server/lib/agent-tool/runtime.test.js +88 -2
- package/server/lib/changelog/update-notes.js +2 -1
- package/server/lib/changelog/update-notes.test.js +19 -0
- package/server/lib/event-stream/DOCUMENTATION.md +23 -0
- package/server/lib/event-stream/delta-coalescer.js +0 -0
- package/server/lib/event-stream/delta-coalescer.test.js +292 -0
- package/server/lib/event-stream/global-hub.js +53 -23
- package/server/lib/event-stream/global-hub.test.js +160 -0
- package/server/lib/event-stream/global-ws-bridge.js +1 -0
- package/server/lib/event-stream/index.js +4 -0
- package/server/lib/fs/DOCUMENTATION.md +10 -1
- package/server/lib/fs/git-process.test.js +77 -0
- package/server/lib/fs/routes.js +58 -5
- package/server/lib/fs/routes.test.js +116 -0
- package/server/lib/fs/search.js +2 -1
- package/server/lib/git/DOCUMENTATION.md +20 -5
- package/server/lib/git/routes.js +16 -3
- package/server/lib/git/serial-refresh.js +93 -0
- package/server/lib/git/serial-refresh.test.js +112 -0
- package/server/lib/git/service.js +429 -70
- package/server/lib/git/service.test.js +418 -0
- package/server/lib/github/pr-status.js +5 -5
- package/server/lib/github/pr-status.test.js +1 -1
- package/server/lib/github/routes.js +4 -4
- package/server/lib/guests/DOCUMENTATION.md +68 -0
- package/server/lib/guests/auth-store.js +184 -0
- package/server/lib/guests/auth-store.test.js +112 -0
- package/server/lib/guests/background.test.js +83 -0
- package/server/lib/guests/builtins.js +34 -0
- package/server/lib/guests/builtins.test.js +174 -0
- package/server/lib/guests/catalog.js +477 -0
- package/server/lib/guests/catalog.test.js +390 -0
- package/server/lib/guests/clone.js +297 -0
- package/server/lib/guests/extract-zip.js +136 -0
- package/server/lib/guests/extract-zip.test.js +109 -0
- package/server/lib/guests/files.js +308 -0
- package/server/lib/guests/files.test.js +262 -0
- package/server/lib/guests/grant-scope.js +81 -0
- package/server/lib/guests/grant-scope.test.js +75 -0
- package/server/lib/guests/host-session.js +73 -0
- package/server/lib/guests/host-session.test.js +30 -0
- package/server/lib/guests/html-styles.js +6 -0
- package/server/lib/guests/html-styles.test.js +45 -0
- package/server/lib/guests/html-tokens.js +73 -0
- package/server/lib/guests/html-tokens.test.js +53 -0
- package/server/lib/guests/install.js +406 -0
- package/server/lib/guests/install.test.js +338 -0
- package/server/lib/guests/oauth.js +501 -0
- package/server/lib/guests/oauth.test.js +481 -0
- package/server/lib/guests/persist.js +336 -0
- package/server/lib/guests/persist.test.js +288 -0
- package/server/lib/guests/request.js +112 -0
- package/server/lib/guests/request.test.js +206 -0
- package/server/lib/guests/routes.js +838 -0
- package/server/lib/guests/service.js +538 -0
- package/server/lib/guests/service.test.js +277 -0
- package/server/lib/guests/sockets.js +129 -0
- package/server/lib/guests/sockets.test.js +110 -0
- package/server/lib/guests/ssh-install.test.js +188 -0
- package/server/lib/guests/storage.js +68 -0
- package/server/lib/guests/storage.test.js +59 -0
- package/server/lib/guests/updates.js +294 -0
- package/server/lib/guests/updates.test.js +334 -0
- package/server/lib/guests/upload.js +50 -0
- package/server/lib/guests/upload.test.js +131 -0
- package/server/lib/linear/DOCUMENTATION.md +1 -1
- package/server/lib/message-queue/DOCUMENTATION.md +10 -1
- package/server/lib/message-queue/runtime.js +21 -1
- package/server/lib/message-queue/runtime.test.js +52 -5
- package/server/lib/notifications/routes.js +5 -0
- package/server/lib/openchamber-control/DOCUMENTATION.md +6 -0
- package/server/lib/openchamber-sessions/routes.js +62 -29
- package/server/lib/openchamber-sessions/routes.test.js +96 -0
- package/server/lib/opencode/DOCUMENTATION.md +55 -1
- package/server/lib/opencode/bootstrap-runtime.js +1 -0
- package/server/lib/opencode/core-routes.js +39 -1
- package/server/lib/opencode/feature-routes-runtime.js +15 -0
- package/server/lib/opencode/lifecycle-process.test.js +84 -0
- package/server/lib/opencode/lifecycle.js +54 -67
- package/server/lib/opencode/openchamber-routes.js +72 -14
- package/server/lib/opencode/openchamber-routes.test.js +71 -3
- package/server/lib/opencode/session-runtime.js +68 -0
- package/server/lib/opencode/session-runtime.test.js +43 -0
- package/server/lib/opencode/settings-helpers.js +8 -0
- package/server/lib/opencode/settings-helpers.test.js +13 -1
- package/server/lib/opencode/settings-normalization-runtime.js +2 -0
- package/server/lib/opencode/settings-normalization-runtime.test.js +13 -0
- package/server/lib/opencode/settings-registry.json +15 -0
- package/server/lib/opencode/shutdown-runtime.js +3 -0
- package/server/lib/opencode/shutdown-runtime.test.js +24 -0
- package/server/lib/opencode/theme-archive.js +53 -0
- package/server/lib/opencode/theme-catalog.js +171 -0
- package/server/lib/opencode/theme-catalog.test.js +125 -0
- package/server/lib/opencode/theme-import.test.js +144 -0
- package/server/lib/opencode/theme-runtime.js +100 -28
- package/server/lib/opencode/theme-runtime.test.js +15 -0
- package/server/lib/permission-auto-accept/DOCUMENTATION.md +8 -0
- package/server/lib/permission-auto-accept/runtime.js +13 -0
- package/server/lib/permission-auto-accept/runtime.test.js +37 -1
- package/server/lib/projects/project-config.js +2 -2
- package/server/lib/quota/DOCUMENTATION.md +0 -1
- package/server/lib/quota/providers/index.js +0 -7
- package/server/lib/realtime-proxy-terminal.test.js +150 -0
- package/server/lib/realtime-proxy.js +2 -0
- package/server/lib/routing/DOCUMENTATION.md +83 -0
- package/server/lib/routing/defaults.js +98 -0
- package/server/lib/routing/feature-flag.js +11 -0
- package/server/lib/routing/history.js +34 -0
- package/server/lib/routing/jev.js +98 -0
- package/server/lib/routing/routes.http.test.js +100 -0
- package/server/lib/routing/routes.js +81 -0
- package/server/lib/routing/runtime.js +217 -0
- package/server/lib/routing/runtime.test.js +188 -0
- package/server/lib/routing/store.js +249 -0
- package/server/lib/routing/store.test.js +96 -0
- package/server/lib/scheduled-tasks/runtime.js +2 -2
- package/server/lib/scheduled-tasks/runtime.test.js +14 -0
- package/server/lib/security/request-security.js +9 -5
- package/server/lib/security/request-security.test.js +37 -0
- package/server/lib/small-model/DOCUMENTATION.md +14 -2
- package/server/lib/small-model/call.js +37 -6
- package/server/lib/small-model/call.test.js +182 -0
- package/server/lib/terminal/DOCUMENTATION.md +23 -2
- package/server/lib/terminal/runtime.js +27 -5
- package/server/lib/terminal/runtime.test.js +67 -0
- package/server/lib/terminal/shutdown.js +106 -0
- package/server/lib/terminal/shutdown.test.js +84 -0
- package/server/lib/tts/service.test.js +78 -0
- package/server/lib/ui-auth/DOCUMENTATION.md +8 -0
- package/server/lib/ui-auth/session-cookie.js +51 -0
- package/server/lib/ui-auth/session-cookie.test.js +46 -0
- package/server/lib/ui-auth/ui-auth.js +56 -17
- package/server/lib/ui-auth/ui-auth.test.js +96 -0
- package/dist/assets/DiffView-CyLmb-wF.js +0 -4
- package/dist/assets/DirtyBranchSwitchDialog-pu5kcoir.js +0 -1
- package/dist/assets/FilesView-WtJB0y_l.js +0 -13
- package/dist/assets/GitView-DkNrV0BK.js +0 -6
- package/dist/assets/LinearIssuesView-B0H0Otii.js +0 -1
- package/dist/assets/MarkdownRendererImpl-DSqVN2d8.js +0 -12
- package/dist/assets/PierreDiffViewer-Bsq9z-sw.js +0 -161
- package/dist/assets/PlanView-CAFziKH5.js +0 -5
- package/dist/assets/SettingsView-BRittavZ.js +0 -28
- package/dist/assets/ToolOutputDialog-B0uYaJio.js +0 -15
- package/dist/assets/appThemeRegistry-BK0dHKa6.js +0 -1
- package/dist/assets/de-c7CYLCAW.js +0 -10
- package/dist/assets/es-BKAVPQmQ.js +0 -16
- package/dist/assets/fr-Bj1glqfV.js +0 -14
- package/dist/assets/index-BYFyOu_D.css +0 -1
- package/dist/assets/index-BfwRClDe.js +0 -40
- package/dist/assets/ja-5jwSIbK0.js +0 -16
- package/dist/assets/ko-BaDbg-Eb.js +0 -16
- package/dist/assets/main-CGG2_uHq.js +0 -2
- package/dist/assets/main-CcrSaL4J.js +0 -930
- package/dist/assets/markdownCore-DsmQr3bt.js +0 -5
- package/dist/assets/miniChat-DEaAhP4b.js +0 -2
- package/dist/assets/mobile-BC3rsvv0.js +0 -2
- package/dist/assets/pl-BFKVBDFu.js +0 -16
- package/dist/assets/pt-BR-qKfHvkDL.js +0 -16
- package/dist/assets/registerCustomTheme-BBXUCRnG.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-CR9d1Mr0.js +0 -2
- package/dist/assets/renderMobileApp-Da5dwobZ.js +0 -25
- package/dist/assets/runtimeEndpointReset-CLYVRw2H.js +0 -41
- package/dist/assets/shikiHighlight-D1Z9G58W.js +0 -1
- package/dist/assets/tr-DYXPoEDG.js +0 -7
- package/dist/assets/uk-CwAoloR8.js +0 -16
- package/dist/assets/useAppFontEffects-DeStGcUT.js +0 -7282
- package/dist/assets/useGitComparison-CpxioduU.js +0 -1
- package/dist/assets/useInlineCommentController-gmUnJtrb.js +0 -2
- package/dist/assets/useRootScrollLock--a3cPz8s.js +0 -1
- package/dist/assets/vendor-framer-motion-livSKyyV.js +0 -1
- package/dist/assets/zh-CN-D9MG0lqF.js +0 -16
- package/dist/assets/zh-TW-1HNK7shh.js +0 -16
- package/server/lib/quota/providers/crof.js +0 -96
- package/server/lib/quota/providers/crof.test.js +0 -98
- /package/dist/assets/{vendor-katex-k3Ppq1Ha.js → vendor-katex-CHD9BdDO.js} +0 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { createOpenCodeLifecycleRuntime } from './lifecycle.js';
|
|
6
|
+
|
|
7
|
+
const alive = (pid) => {
|
|
8
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const createRuntime = (waitForReady, state) => createOpenCodeLifecycleRuntime({
|
|
12
|
+
state,
|
|
13
|
+
env: { ENV_CONFIGURED_OPENCODE_PORT: 45678, ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1' },
|
|
14
|
+
syncToHmrState() {}, syncFromHmrState() {},
|
|
15
|
+
ensureOpencodeCliEnv: () => process.execPath,
|
|
16
|
+
applyOpencodeBinaryFromSettings: async () => {},
|
|
17
|
+
ensureLocalOpenCodeServerPassword: async () => 'fixture-only',
|
|
18
|
+
resolveManagedOpenCodeLaunchSpec: (binary) => ({ binary, args: [] }),
|
|
19
|
+
normalizeApiPrefix: (value) => value,
|
|
20
|
+
setOpenCodePort() {}, setDetectedOpenCodeApiPrefix() {},
|
|
21
|
+
waitForReady,
|
|
22
|
+
managedStartupTimeoutMs: 1500,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('managed process lifecycle with real children', () => {
|
|
26
|
+
for (const failure of ['invalid-readiness', 'health-error', 'startup-timeout', 'shutdown-during-startup', 'none']) {
|
|
27
|
+
it(`reaps the server and its child after ${failure}`, async () => {
|
|
28
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-process-'));
|
|
29
|
+
const previousRegistry = process.env.OPENCHAMBER_MANAGED_PROCESS_REGISTRY;
|
|
30
|
+
process.env.OPENCHAMBER_MANAGED_PROCESS_REGISTRY = path.join(root, 'registry');
|
|
31
|
+
const marker = path.join(root, 'pids');
|
|
32
|
+
const childScript = `process.on('SIGTERM', () => {}); require('node:fs').appendFileSync(${JSON.stringify(marker)}, process.pid + '\\n'); process.stdout.write('ready\\n'); setInterval(() => {}, 1000);`;
|
|
33
|
+
let readinessMessage = 'opencode server listening on http://127.0.0.1:45678\n';
|
|
34
|
+
if (failure === 'invalid-readiness') readinessMessage = 'opencode server listening without a URL\n';
|
|
35
|
+
if (failure === 'startup-timeout' || failure === 'shutdown-during-startup') readinessMessage = '';
|
|
36
|
+
// Node is an isolated stand-in for the native OpenCode binary. Lifecycle
|
|
37
|
+
// still launches its real `serve --hostname ... --port ...` command.
|
|
38
|
+
await fs.writeFile(path.join(root, 'serve'), `
|
|
39
|
+
const fs = require('node:fs');
|
|
40
|
+
fs.appendFileSync(${JSON.stringify(marker)}, process.pid + '\\n');
|
|
41
|
+
const child = require('node:child_process').spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
42
|
+
child.stdout.once('data', () => {
|
|
43
|
+
process.stdout.write(${JSON.stringify(readinessMessage)});
|
|
44
|
+
});
|
|
45
|
+
setInterval(() => {}, 1000);
|
|
46
|
+
`);
|
|
47
|
+
try {
|
|
48
|
+
const state = { openCodeWorkingDirectory: root, useWslForOpencode: false };
|
|
49
|
+
const runtime = createRuntime(async () => {
|
|
50
|
+
if (failure === 'health-error') throw new Error('fixture health failure');
|
|
51
|
+
return true;
|
|
52
|
+
}, state);
|
|
53
|
+
if (failure === 'none') {
|
|
54
|
+
const server = await runtime.startOpenCode();
|
|
55
|
+
await Promise.all([server.close(), server.close()]);
|
|
56
|
+
} else if (failure === 'shutdown-during-startup') {
|
|
57
|
+
const starting = runtime.startOpenCode();
|
|
58
|
+
const rejected = expect(starting).rejects.toThrow('exited before serving');
|
|
59
|
+
await expect.poll(async () => (await fs.readFile(marker, 'utf8').catch(() => '')).trim().split('\n').filter(Boolean).length).toBe(2);
|
|
60
|
+
state.isShuttingDown = true;
|
|
61
|
+
await state.openCodeProcess.close();
|
|
62
|
+
await rejected;
|
|
63
|
+
} else if (failure === 'startup-timeout') {
|
|
64
|
+
await expect(runtime.startOpenCode()).rejects.toThrow('Timeout waiting for OpenCode');
|
|
65
|
+
} else {
|
|
66
|
+
await expect(runtime.startOpenCode()).rejects.toThrow(failure === 'health-error' ? 'fixture health failure' : 'Failed to parse server url');
|
|
67
|
+
}
|
|
68
|
+
const pids = (await fs.readFile(marker, 'utf8')).trim().split('\n').map(Number);
|
|
69
|
+
expect(pids).toHaveLength(failure === 'none' || failure === 'shutdown-during-startup' ? 2 : 4);
|
|
70
|
+
await expect.poll(() => pids.filter(alive), { timeout: 3000 }).toEqual([]);
|
|
71
|
+
expect(await fs.readdir(path.join(root, 'registry')).catch(() => [])).toEqual([]);
|
|
72
|
+
} finally {
|
|
73
|
+
const pids = (await fs.readFile(marker, 'utf8').catch(() => '')).trim().split('\n').map(Number).filter((pid) => pid > 0);
|
|
74
|
+
for (const pid of pids.reverse()) {
|
|
75
|
+
if (!alive(pid)) continue;
|
|
76
|
+
try { process.kill(pid, 'SIGKILL'); } catch { /* already exited */ }
|
|
77
|
+
}
|
|
78
|
+
if (previousRegistry === undefined) delete process.env.OPENCHAMBER_MANAGED_PROCESS_REGISTRY;
|
|
79
|
+
else process.env.OPENCHAMBER_MANAGED_PROCESS_REGISTRY = previousRegistry;
|
|
80
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
}, 15_000);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
@@ -109,6 +109,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|
|
109
109
|
reapManagedOrphanedProcesses = reapOrphanedProcesses,
|
|
110
110
|
getWarmupDirectories = async () => [],
|
|
111
111
|
onOpenCodeRestarted = null,
|
|
112
|
+
managedStartupTimeoutMs = 30_000,
|
|
112
113
|
now = Date.now,
|
|
113
114
|
} = deps;
|
|
114
115
|
|
|
@@ -287,7 +288,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|
|
287
288
|
}
|
|
288
289
|
|
|
289
290
|
const pid = child.pid;
|
|
290
|
-
if (!pid || hasChildProcessExited(child)) {
|
|
291
|
+
if (!pid || (process.platform === 'win32' && hasChildProcessExited(child))) {
|
|
291
292
|
await waitForChildProcessClose(child, 250);
|
|
292
293
|
return;
|
|
293
294
|
}
|
|
@@ -301,34 +302,14 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|
|
301
302
|
}
|
|
302
303
|
|
|
303
304
|
try {
|
|
304
|
-
child.kill(signal);
|
|
305
|
+
if (!hasChildProcessExited(child)) child.kill(signal);
|
|
305
306
|
} catch {
|
|
306
307
|
}
|
|
307
308
|
};
|
|
308
309
|
|
|
309
310
|
if (process.platform === 'win32') {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
} catch {
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
if (await waitForChildProcessClose(child, 800)) {
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
try {
|
|
320
|
-
spawnSync('taskkill', ['/pid', String(pid), '/t'], {
|
|
321
|
-
stdio: 'ignore',
|
|
322
|
-
timeout: 3000,
|
|
323
|
-
windowsHide: true,
|
|
324
|
-
});
|
|
325
|
-
} catch {
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
if (await waitForChildProcessClose(child, 1500)) {
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
|
-
|
|
311
|
+
// Windows child.kill() terminates only the parent. Kill the owned tree
|
|
312
|
+
// while its parent still exists, otherwise /T cannot find its children.
|
|
332
313
|
try {
|
|
333
314
|
spawnSync('taskkill', ['/pid', String(pid), '/f', '/t'], {
|
|
334
315
|
stdio: 'ignore',
|
|
@@ -343,11 +324,9 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|
|
343
324
|
}
|
|
344
325
|
|
|
345
326
|
signalProcessTree('SIGTERM');
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
}
|
|
350
|
-
|
|
327
|
+
await waitForChildProcessClose(child, 2500);
|
|
328
|
+
// Parent exit does not prove group exit. Tools can ignore SIGTERM and keep
|
|
329
|
+
// running after their server has exited and closed its own stdio.
|
|
351
330
|
signalProcessTree('SIGKILL');
|
|
352
331
|
|
|
353
332
|
await waitForChildProcessClose(child, 1000);
|
|
@@ -452,7 +431,29 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|
|
452
431
|
child.on('exit', recordManagedProcessExit);
|
|
453
432
|
child.on('close', recordManagedProcessExit);
|
|
454
433
|
|
|
455
|
-
|
|
434
|
+
// Ownership starts at spawn, including processes that never become ready.
|
|
435
|
+
const registration = registerManagedProcess({
|
|
436
|
+
pid: child.pid,
|
|
437
|
+
ownerPid: process.pid,
|
|
438
|
+
port,
|
|
439
|
+
binary,
|
|
440
|
+
runtime: process.env.OPENCHAMBER_RUNTIME || 'web',
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
let closePromise = null;
|
|
444
|
+
const serverProcess = {
|
|
445
|
+
url: null,
|
|
446
|
+
pid: child.pid || null,
|
|
447
|
+
get exitCode() { return observedExitCode ?? child.exitCode; },
|
|
448
|
+
get signalCode() { return observedSignalCode ?? child.signalCode; },
|
|
449
|
+
get stderrTail() { return getManagedProcessSnapshot().stderrTail; },
|
|
450
|
+
close() {
|
|
451
|
+
if (!closePromise) closePromise = registration.then(() => closeManagedOpenCodeChild(child));
|
|
452
|
+
return closePromise;
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const readiness = new Promise((resolve, reject) => {
|
|
456
457
|
let stdout = '';
|
|
457
458
|
let stderr = '';
|
|
458
459
|
let done = false;
|
|
@@ -507,37 +508,21 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|
|
507
508
|
child.stderr?.on('data', onStderr);
|
|
508
509
|
child.on('exit', onExit);
|
|
509
510
|
child.on('error', onError);
|
|
511
|
+
}).catch(async (error) => {
|
|
512
|
+
await serverProcess.close();
|
|
513
|
+
if (state.openCodeProcess === serverProcess) {
|
|
514
|
+
state.openCodeProcess = null;
|
|
515
|
+
syncToHmrState();
|
|
516
|
+
}
|
|
517
|
+
throw error;
|
|
510
518
|
});
|
|
511
519
|
|
|
512
|
-
//
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
pid: child.pid,
|
|
519
|
-
ownerPid: process.pid,
|
|
520
|
-
port,
|
|
521
|
-
binary,
|
|
522
|
-
runtime: process.env.OPENCHAMBER_RUNTIME || 'web',
|
|
523
|
-
});
|
|
524
|
-
|
|
525
|
-
return {
|
|
526
|
-
url,
|
|
527
|
-
pid: child.pid || null,
|
|
528
|
-
get exitCode() {
|
|
529
|
-
return observedExitCode ?? child.exitCode;
|
|
530
|
-
},
|
|
531
|
-
get signalCode() {
|
|
532
|
-
return observedSignalCode ?? child.signalCode;
|
|
533
|
-
},
|
|
534
|
-
get stderrTail() {
|
|
535
|
-
return getManagedProcessSnapshot().stderrTail;
|
|
536
|
-
},
|
|
537
|
-
async close() {
|
|
538
|
-
await closeManagedOpenCodeChild(child);
|
|
539
|
-
},
|
|
540
|
-
};
|
|
520
|
+
// Shutdown must be able to close an in-flight startup, not only a ready server.
|
|
521
|
+
state.openCodeProcess = serverProcess;
|
|
522
|
+
syncToHmrState();
|
|
523
|
+
serverProcess.url = await readiness;
|
|
524
|
+
await registration;
|
|
525
|
+
return serverProcess;
|
|
541
526
|
};
|
|
542
527
|
|
|
543
528
|
const resolveManagedOpenCodePort = async (requestedPort, hostname = '127.0.0.1') => {
|
|
@@ -719,12 +704,14 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|
|
719
704
|
});
|
|
720
705
|
phaseStartedAt = performance.now();
|
|
721
706
|
|
|
707
|
+
let serverInstance;
|
|
722
708
|
try {
|
|
723
|
-
|
|
709
|
+
if (state.isShuttingDown) throw new Error('OpenCode startup cancelled during shutdown');
|
|
710
|
+
serverInstance = await createManagedOpenCodeServerProcess({
|
|
724
711
|
resolvedBinary,
|
|
725
712
|
hostname: env.ENV_CONFIGURED_OPENCODE_HOSTNAME,
|
|
726
713
|
port: spawnPort,
|
|
727
|
-
timeout:
|
|
714
|
+
timeout: managedStartupTimeoutMs,
|
|
728
715
|
cwd: state.openCodeWorkingDirectory,
|
|
729
716
|
shellEnvKeysCount: Object.keys(shellEnv).length,
|
|
730
717
|
env: stripAppImageArgv0Leak(applyProviderEnvAliases({
|
|
@@ -750,7 +737,9 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|
|
750
737
|
const port = parseInt(url.port, 10);
|
|
751
738
|
const prefix = normalizeApiPrefix(url.pathname);
|
|
752
739
|
|
|
753
|
-
|
|
740
|
+
const ready = await waitForReady(serverInstance.url, 10000);
|
|
741
|
+
if (state.isShuttingDown) throw new Error('OpenCode startup cancelled during shutdown');
|
|
742
|
+
if (ready) {
|
|
754
743
|
setOpenCodePort(port);
|
|
755
744
|
setDetectedOpenCodeApiPrefix(prefix);
|
|
756
745
|
|
|
@@ -768,12 +757,10 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|
|
768
757
|
return serverInstance;
|
|
769
758
|
}
|
|
770
759
|
|
|
771
|
-
try {
|
|
772
|
-
await serverInstance.close();
|
|
773
|
-
} catch {
|
|
774
|
-
}
|
|
775
760
|
throw new Error('Server started but health check failed (timeout)');
|
|
776
761
|
} catch (error) {
|
|
762
|
+
await serverInstance?.close();
|
|
763
|
+
if (serverInstance && state.openCodeProcess === serverInstance) state.openCodeProcess = null;
|
|
777
764
|
const message = error instanceof Error ? error.message : String(error);
|
|
778
765
|
state.lastOpenCodeError = message;
|
|
779
766
|
state.openCodePort = null;
|
|
@@ -795,7 +782,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|
|
795
782
|
return await startOpenCodeOnce(attempt);
|
|
796
783
|
} catch (error) {
|
|
797
784
|
lastError = error;
|
|
798
|
-
if (error?.code === 'OPENCODE_BINARY_INVALID') {
|
|
785
|
+
if (state.isShuttingDown || error?.code === 'OPENCODE_BINARY_INVALID') {
|
|
799
786
|
break;
|
|
800
787
|
}
|
|
801
788
|
if (attempt >= START_OPEN_CODE_MAX_ATTEMPTS) {
|
|
@@ -16,6 +16,43 @@ function quotePosixShell(value) {
|
|
|
16
16
|
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* `echo` in batch treats `& | < > ( ) ^` as syntax and `%var%` as expansion,
|
|
21
|
+
* so a line of the log preamble is escaped before it becomes an `echo`.
|
|
22
|
+
*/
|
|
23
|
+
const escapeBatchEchoLine = (line) => {
|
|
24
|
+
if (!line) return 'echo.';
|
|
25
|
+
return `echo ${line.replace(/%/g, '%%').replace(/[&|<>()^]/g, '^$&')}`;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The Windows update runs as a batch file, one command per line, so the
|
|
30
|
+
* `if ... else` block and the multi-line log preamble keep their structure.
|
|
31
|
+
*/
|
|
32
|
+
const buildWindowsUpdateScript = ({ logPreamble, updateCmd, restartCmd }) => [
|
|
33
|
+
'@echo off',
|
|
34
|
+
...logPreamble.split('\n').map(escapeBatchEchoLine),
|
|
35
|
+
// `timeout` refuses redirected stdin, which is what a detached child has;
|
|
36
|
+
// a ping to loopback waits about two seconds without a console.
|
|
37
|
+
'ping -n 3 127.0.0.1 >nul',
|
|
38
|
+
// npm, pnpm and yarn are .cmd shims on Windows. Without `call`, a batch
|
|
39
|
+
// file hands control to them for good and the restart below never runs.
|
|
40
|
+
// Read from a file, cmd expands `%x%` and drops a lone `%`, so a `%` in a
|
|
41
|
+
// host or UI password would change the restart command. Doubling keeps it.
|
|
42
|
+
`call ${updateCmd.replace(/%/g, '%%')}`,
|
|
43
|
+
'if %ERRORLEVEL% EQU 0 (',
|
|
44
|
+
' echo Update successful, restarting OpenChamber...',
|
|
45
|
+
` ${restartCmd ? restartCmd.replace(/%/g, '%%') : 'echo Service manager will restart OpenChamber.'}`,
|
|
46
|
+
') else (',
|
|
47
|
+
' echo Update failed',
|
|
48
|
+
')',
|
|
49
|
+
// The restart command carries the server's own flags, `--ui-password`
|
|
50
|
+
// included, so the file does not outlive the run. Deleting the running
|
|
51
|
+
// batch file on its last line is safe: cmd has already read it.
|
|
52
|
+
'del "%~f0"',
|
|
53
|
+
'',
|
|
54
|
+
].join('\r\n');
|
|
55
|
+
|
|
19
56
|
export const registerOpenChamberRoutes = (app, dependencies) => {
|
|
20
57
|
const {
|
|
21
58
|
fs,
|
|
@@ -300,6 +337,26 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
|
|
|
300
337
|
`logPath=${updateLogPath}`,
|
|
301
338
|
].join('\n');
|
|
302
339
|
|
|
340
|
+
const shell = isWindows ? (process.env.ComSpec || 'cmd.exe') : 'sh';
|
|
341
|
+
const shellFlag = isWindows ? '/c' : '-c';
|
|
342
|
+
// cmd.exe /c takes one command line: a newline inside it ends the
|
|
343
|
+
// command, so a multi-line script passed as the argument ran nothing
|
|
344
|
+
// and exited 0, and the server shut itself down believing the update
|
|
345
|
+
// was under way (#3084). Batch runs from a file instead, written before
|
|
346
|
+
// the client is told to expect a restart.
|
|
347
|
+
const windowsScriptPath = path.join(openchamberDataDir, 'update-install.cmd');
|
|
348
|
+
if (isWindows) {
|
|
349
|
+
try {
|
|
350
|
+
fs.mkdirSync(path.dirname(windowsScriptPath), { recursive: true });
|
|
351
|
+
fs.writeFileSync(windowsScriptPath, buildWindowsUpdateScript({ logPreamble, updateCmd, restartCmd }), 'utf8');
|
|
352
|
+
} catch (scriptError) {
|
|
353
|
+
console.error('Failed to write the update script, update not started:', scriptError);
|
|
354
|
+
return res.status(500).json({
|
|
355
|
+
error: `Could not write the update script at ${windowsScriptPath}: ${scriptError instanceof Error ? scriptError.message : String(scriptError)}`,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
303
360
|
res.json({
|
|
304
361
|
success: true,
|
|
305
362
|
message: 'Update starting, server will restart shortly',
|
|
@@ -314,21 +371,8 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
|
|
|
314
371
|
console.log(`Running: ${updateCmd}`);
|
|
315
372
|
console.log(logPreamble);
|
|
316
373
|
|
|
317
|
-
const shell = isWindows ? (process.env.ComSpec || 'cmd.exe') : 'sh';
|
|
318
|
-
const shellFlag = isWindows ? '/c' : '-c';
|
|
319
374
|
const script = isWindows
|
|
320
|
-
?
|
|
321
|
-
echo ${quoteCmd(logPreamble)}
|
|
322
|
-
timeout /t 2 /nobreak >nul
|
|
323
|
-
${updateCmd}
|
|
324
|
-
if %ERRORLEVEL% EQU 0 (
|
|
325
|
-
echo Update successful, restarting OpenChamber...
|
|
326
|
-
${restartCmd || 'echo Service manager will restart OpenChamber.'}
|
|
327
|
-
) else (
|
|
328
|
-
echo Update failed
|
|
329
|
-
exit /b 1
|
|
330
|
-
)
|
|
331
|
-
`
|
|
375
|
+
? windowsScriptPath
|
|
332
376
|
: `
|
|
333
377
|
printf '%s\n' ${quotePosix(logPreamble)}
|
|
334
378
|
sleep 2
|
|
@@ -350,10 +394,24 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
|
|
|
350
394
|
console.warn('Failed to open update log file, continuing without log capture:', logError);
|
|
351
395
|
}
|
|
352
396
|
|
|
397
|
+
if (isWindows) {
|
|
398
|
+
// On Windows the detached child inherits this process's listening
|
|
399
|
+
// socket, and keeps the port for as long as the batch runs. The
|
|
400
|
+
// restart inside that batch then fails with "port already in use",
|
|
401
|
+
// and the update ends with no server. Closing the listener first
|
|
402
|
+
// leaves nothing to inherit; the process exits right after anyway.
|
|
403
|
+
try {
|
|
404
|
+
server.close();
|
|
405
|
+
} catch (closeError) {
|
|
406
|
+
console.warn('Failed to close the listener before the update script:', closeError);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
353
410
|
const child = spawnChild(shell, [shellFlag, script], {
|
|
354
411
|
detached: true,
|
|
355
412
|
stdio: logFd !== null ? ['ignore', logFd, logFd] : 'ignore',
|
|
356
413
|
env: process.env,
|
|
414
|
+
windowsHide: true,
|
|
357
415
|
});
|
|
358
416
|
child.unref();
|
|
359
417
|
|
|
@@ -18,11 +18,15 @@ const childProcess = await import('child_process');
|
|
|
18
18
|
const packageManager = await import('../package-manager.js');
|
|
19
19
|
const { registerOpenChamberRoutes } = await import('./openchamber-routes.js');
|
|
20
20
|
|
|
21
|
-
const createApp = ({ environment = {}, storedOptions = {}, desktopUpdater } = {}) => {
|
|
21
|
+
const createApp = ({ environment = {}, storedOptions = {}, desktopUpdater, platform = 'linux', execPath = '/usr/bin/node' } = {}) => {
|
|
22
22
|
const app = express();
|
|
23
23
|
const dependencies = {
|
|
24
24
|
fs: {
|
|
25
25
|
existsSync: vi.fn(() => false),
|
|
26
|
+
mkdirSync: vi.fn(),
|
|
27
|
+
writeFileSync: vi.fn(),
|
|
28
|
+
openSync: vi.fn(() => 7),
|
|
29
|
+
closeSync: vi.fn(),
|
|
26
30
|
promises: {
|
|
27
31
|
readFile: vi.fn(async () => JSON.stringify({
|
|
28
32
|
launchMode: 'foreground',
|
|
@@ -34,11 +38,13 @@ const createApp = ({ environment = {}, storedOptions = {}, desktopUpdater } = {}
|
|
|
34
38
|
path,
|
|
35
39
|
process: {
|
|
36
40
|
env: environment,
|
|
37
|
-
platform
|
|
38
|
-
execPath
|
|
41
|
+
platform,
|
|
42
|
+
execPath,
|
|
43
|
+
exit: vi.fn(),
|
|
39
44
|
},
|
|
40
45
|
server: {
|
|
41
46
|
address: () => ({ port: 7897 }),
|
|
47
|
+
close: vi.fn(),
|
|
42
48
|
},
|
|
43
49
|
__dirname: '/opt/openchamber/server',
|
|
44
50
|
openchamberDataDir: '/tmp/openchamber',
|
|
@@ -268,3 +274,65 @@ describe('OpenChamber foreground update route', () => {
|
|
|
268
274
|
});
|
|
269
275
|
});
|
|
270
276
|
});
|
|
277
|
+
|
|
278
|
+
describe('OpenChamber web update route on Windows', () => {
|
|
279
|
+
it('runs the install-and-restart script from a batch file instead of a cmd.exe /c argument', async () => {
|
|
280
|
+
const { app, dependencies } = createApp({
|
|
281
|
+
platform: 'win32',
|
|
282
|
+
execPath: 'C:\\Program Files\\nodejs\\node.exe',
|
|
283
|
+
environment: { ComSpec: 'C:\\Windows\\system32\\cmd.exe' },
|
|
284
|
+
storedOptions: { launchMode: 'daemon', port: 7897, uiPassword: 'pa%ss' },
|
|
285
|
+
});
|
|
286
|
+
childProcess.spawn.mockReturnValue({ unref: vi.fn() });
|
|
287
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
288
|
+
|
|
289
|
+
await request(app).post('/api/openchamber/update-install').expect(200);
|
|
290
|
+
await new Promise((resolve) => setTimeout(resolve, 1300));
|
|
291
|
+
|
|
292
|
+
const scriptPath = path.join('/tmp/openchamber', 'update-install.cmd');
|
|
293
|
+
expect(dependencies.fs.writeFileSync).toHaveBeenCalledWith(scriptPath, expect.any(String), 'utf8');
|
|
294
|
+
const script = dependencies.fs.writeFileSync.mock.calls[0][1];
|
|
295
|
+
const lines = script.split('\r\n');
|
|
296
|
+
expect(lines[0]).toBe('@echo off');
|
|
297
|
+
// Every preamble line is an echo; none is left to run as a command.
|
|
298
|
+
expect(lines.filter((line) => line.startsWith('currentVersion=') || line.startsWith('restartCommand='))).toEqual([]);
|
|
299
|
+
expect(lines).toContain('echo packageManager=npm');
|
|
300
|
+
expect(lines).toContain('echo restartCommand=^("C:\\Program Files\\nodejs\\node.exe" "/opt/openchamber/bin/cli.js" serve --port 7897 --ui-password "pa%%ss"^) ^|^| ^(openchamber serve --port 7897 --ui-password "pa%%ss"^)');
|
|
301
|
+
// A .cmd shim (npm, pnpm, yarn) must be `call`ed or the script ends there.
|
|
302
|
+
expect(lines).toContain('call npm install -g @openchamber/web@latest');
|
|
303
|
+
expect(lines).toContain('ping -n 3 127.0.0.1 >nul');
|
|
304
|
+
expect(lines.some((line) => line.startsWith('timeout '))).toBe(false);
|
|
305
|
+
expect(lines).toContain('if %ERRORLEVEL% EQU 0 (');
|
|
306
|
+
expect(lines.at(-2)).toBe('del "%~f0"');
|
|
307
|
+
// A `%` in the password survives batch expansion only when doubled.
|
|
308
|
+
expect(lines).toContain(' ("C:\\Program Files\\nodejs\\node.exe" "/opt/openchamber/bin/cli.js" serve --port 7897 --ui-password "pa%%ss") || (openchamber serve --port 7897 --ui-password "pa%%ss")');
|
|
309
|
+
|
|
310
|
+
expect(childProcess.spawn).toHaveBeenCalledWith(
|
|
311
|
+
'C:\\Windows\\system32\\cmd.exe',
|
|
312
|
+
['/c', scriptPath],
|
|
313
|
+
expect.objectContaining({ detached: true, windowsHide: true }),
|
|
314
|
+
);
|
|
315
|
+
// The listener is closed before the batch is spawned, so the detached
|
|
316
|
+
// child cannot inherit the socket and hold the port against the restart.
|
|
317
|
+
expect(dependencies.server.close).toHaveBeenCalledOnce();
|
|
318
|
+
expect(dependencies.server.close.mock.invocationCallOrder[0]).toBeLessThan(childProcess.spawn.mock.invocationCallOrder[0]);
|
|
319
|
+
expect(dependencies.process.exit).toHaveBeenCalledWith(0);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('answers 500 and keeps the server up when the batch file cannot be written', async () => {
|
|
323
|
+
const { app, dependencies } = createApp({ platform: 'win32', storedOptions: { launchMode: 'daemon', port: 7897 } });
|
|
324
|
+
dependencies.fs.writeFileSync.mockImplementation(() => { throw new Error('EACCES'); });
|
|
325
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
326
|
+
const logError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
327
|
+
|
|
328
|
+
const response = await request(app).post('/api/openchamber/update-install').expect(500);
|
|
329
|
+
await new Promise((resolve) => setTimeout(resolve, 1300));
|
|
330
|
+
|
|
331
|
+
expect(response.body.error).toContain('update-install.cmd');
|
|
332
|
+
expect(response.body.error).toContain('EACCES');
|
|
333
|
+
expect(childProcess.spawn).not.toHaveBeenCalled();
|
|
334
|
+
expect(dependencies.process.exit).not.toHaveBeenCalled();
|
|
335
|
+
expect(logError).toHaveBeenCalledOnce();
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
@@ -38,13 +38,76 @@ const extractSessionStatusUpdate = (payload) => {
|
|
|
38
38
|
};
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
+
const readRequestId = (value) => (typeof value === 'string' && value.trim() ? value.trim() : '');
|
|
42
|
+
|
|
41
43
|
export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, broadcastEvent }) => {
|
|
42
44
|
const sessionActivityPhases = new Map();
|
|
43
45
|
const sessionActivityCooldowns = new Map();
|
|
44
46
|
const sessionStates = new Map();
|
|
45
47
|
const sessionAttentionStates = new Map();
|
|
48
|
+
// Pending permission and question requests per session, kept from the same
|
|
49
|
+
// upstream stream. Clients that do not initialize a directory cannot read
|
|
50
|
+
// its pending list from OpenCode (that read creates an instance), so this
|
|
51
|
+
// map is their seed. Entries live until the matching reply, the session's
|
|
52
|
+
// deletion, or an OpenCode restart, which drops every pending request.
|
|
53
|
+
const pendingRequestsBySession = new Map();
|
|
46
54
|
let activeSessionCount = 0;
|
|
47
55
|
|
|
56
|
+
const getOrCreatePendingRequests = (sessionId) => {
|
|
57
|
+
let entry = pendingRequestsBySession.get(sessionId);
|
|
58
|
+
if (!entry) {
|
|
59
|
+
entry = { permissions: new Map(), questions: new Map() };
|
|
60
|
+
pendingRequestsBySession.set(sessionId, entry);
|
|
61
|
+
}
|
|
62
|
+
return entry;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const settlePendingRequest = (kind, sessionId, requestId) => {
|
|
66
|
+
const entry = pendingRequestsBySession.get(sessionId);
|
|
67
|
+
if (!entry) return;
|
|
68
|
+
// OpenCode may omit the request ID on a reply; the session then has no
|
|
69
|
+
// pending request of that kind we can still vouch for.
|
|
70
|
+
if (requestId) entry[kind].delete(requestId);
|
|
71
|
+
else entry[kind].clear();
|
|
72
|
+
if (entry.permissions.size === 0 && entry.questions.size === 0) pendingRequestsBySession.delete(sessionId);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const processBlockingRequestPayload = (payload) => {
|
|
76
|
+
if (!payload || typeof payload.type !== 'string') return;
|
|
77
|
+
const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {};
|
|
78
|
+
if (payload.type === 'permission.asked' || payload.type === 'question.asked') {
|
|
79
|
+
const sessionId = readRequestId(properties.sessionID);
|
|
80
|
+
const requestId = readRequestId(properties.id);
|
|
81
|
+
if (!sessionId || !requestId) return;
|
|
82
|
+
getOrCreatePendingRequests(sessionId)[payload.type === 'permission.asked' ? 'permissions' : 'questions'].set(requestId, properties);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (payload.type === 'permission.replied') {
|
|
86
|
+
settlePendingRequest('permissions', readRequestId(properties.sessionID), readRequestId(properties.requestID));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (payload.type === 'question.replied' || payload.type === 'question.rejected') {
|
|
90
|
+
settlePendingRequest('questions', readRequestId(properties.sessionID), readRequestId(properties.requestID));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (payload.type === 'session.deleted') {
|
|
94
|
+
const info = properties.info && typeof properties.info === 'object' ? properties.info : {};
|
|
95
|
+
const sessionId = readRequestId(properties.sessionID) || readRequestId(info.id);
|
|
96
|
+
if (sessionId) pendingRequestsBySession.delete(sessionId);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const getPendingBlockingRequestsSnapshot = () => {
|
|
101
|
+
const result = {};
|
|
102
|
+
for (const [sessionId, entry] of pendingRequestsBySession) {
|
|
103
|
+
result[sessionId] = {
|
|
104
|
+
permissions: [...entry.permissions.values()],
|
|
105
|
+
questions: [...entry.questions.values()],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
};
|
|
110
|
+
|
|
48
111
|
const getOrCreateAttentionState = (sessionId) => {
|
|
49
112
|
if (!sessionId || typeof sessionId !== 'string') return null;
|
|
50
113
|
|
|
@@ -307,6 +370,8 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, br
|
|
|
307
370
|
}
|
|
308
371
|
}
|
|
309
372
|
|
|
373
|
+
// A restarted OpenCode forgot every pending request with the turns.
|
|
374
|
+
pendingRequestsBySession.clear();
|
|
310
375
|
const eventId = `opencode-restart-${Date.now()}`;
|
|
311
376
|
for (const sessionId of interruptedSessionIds) {
|
|
312
377
|
updateSessionState(sessionId, 'idle', eventId, {
|
|
@@ -354,6 +419,7 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, br
|
|
|
354
419
|
const cleanupInterval = setInterval(cleanupOldSessionStates, SESSION_STATE_CLEANUP_INTERVAL_MS);
|
|
355
420
|
|
|
356
421
|
const processOpenCodeSsePayload = (payload) => {
|
|
422
|
+
processBlockingRequestPayload(payload);
|
|
357
423
|
const update = extractSessionStatusUpdate(payload);
|
|
358
424
|
if (!update) return;
|
|
359
425
|
|
|
@@ -379,6 +445,7 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, br
|
|
|
379
445
|
sessionActivityPhases.clear();
|
|
380
446
|
sessionStates.clear();
|
|
381
447
|
sessionAttentionStates.clear();
|
|
448
|
+
pendingRequestsBySession.clear();
|
|
382
449
|
activeSessionCount = 0;
|
|
383
450
|
};
|
|
384
451
|
|
|
@@ -387,6 +454,7 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, br
|
|
|
387
454
|
getSessionActivitySnapshot,
|
|
388
455
|
getActiveSessionCount,
|
|
389
456
|
getSessionStateSnapshot,
|
|
457
|
+
getPendingBlockingRequestsSnapshot,
|
|
390
458
|
getSessionAttentionSnapshot,
|
|
391
459
|
getSessionState,
|
|
392
460
|
getSessionAttentionState,
|
|
@@ -12,6 +12,49 @@ describe('session runtime', () => {
|
|
|
12
12
|
runtimes.length = 0;
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
+
it('keeps pending permission and question requests until they are answered', () => {
|
|
16
|
+
const runtime = createSessionRuntime({
|
|
17
|
+
writeSseEvent() {},
|
|
18
|
+
getNotificationClients: () => new Set(),
|
|
19
|
+
broadcastEvent: () => {},
|
|
20
|
+
});
|
|
21
|
+
runtimes.push(runtime);
|
|
22
|
+
const permission = { id: 'perm-1', sessionID: 'session-1', permission: 'bash', patterns: ['rm *'], metadata: {}, always: [] };
|
|
23
|
+
const question = { id: 'q-1', sessionID: 'session-2', questions: [{ header: 'Pick', question: 'Which?', options: [] }] };
|
|
24
|
+
|
|
25
|
+
runtime.processOpenCodeSsePayload({ type: 'permission.asked', properties: permission });
|
|
26
|
+
runtime.processOpenCodeSsePayload({ type: 'permission.asked', properties: permission });
|
|
27
|
+
runtime.processOpenCodeSsePayload({ type: 'question.asked', properties: question });
|
|
28
|
+
expect(runtime.getPendingBlockingRequestsSnapshot()).toEqual({
|
|
29
|
+
'session-1': { permissions: [permission], questions: [] },
|
|
30
|
+
'session-2': { permissions: [], questions: [question] },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
runtime.processOpenCodeSsePayload({ type: 'permission.replied', properties: { sessionID: 'session-1', requestID: 'perm-1' } });
|
|
34
|
+
runtime.processOpenCodeSsePayload({ type: 'question.rejected', properties: { sessionID: 'session-2' } });
|
|
35
|
+
expect(runtime.getPendingBlockingRequestsSnapshot()).toEqual({});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('drops pending requests when the session is deleted or OpenCode restarts', () => {
|
|
39
|
+
const runtime = createSessionRuntime({
|
|
40
|
+
writeSseEvent() {},
|
|
41
|
+
getNotificationClients: () => new Set(),
|
|
42
|
+
broadcastEvent: () => {},
|
|
43
|
+
});
|
|
44
|
+
runtimes.push(runtime);
|
|
45
|
+
const ask = (sessionID, id) => runtime.processOpenCodeSsePayload({
|
|
46
|
+
type: 'permission.asked', properties: { id, sessionID, permission: 'edit', patterns: [], metadata: {}, always: [] },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
ask('session-1', 'perm-1');
|
|
50
|
+
ask('session-2', 'perm-2');
|
|
51
|
+
runtime.processOpenCodeSsePayload({ type: 'session.deleted', properties: { info: { id: 'session-1' } } });
|
|
52
|
+
expect(Object.keys(runtime.getPendingBlockingRequestsSnapshot())).toEqual(['session-2']);
|
|
53
|
+
|
|
54
|
+
runtime.interruptBusySessionsAfterRestart();
|
|
55
|
+
expect(runtime.getPendingBlockingRequestsSnapshot()).toEqual({});
|
|
56
|
+
});
|
|
57
|
+
|
|
15
58
|
it('broadcasts attention clears through the shared broadcaster', () => {
|
|
16
59
|
const events = [];
|
|
17
60
|
const runtime = createSessionRuntime({
|