@prismshadow/penguin-server 0.2.2 → 0.2.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/README.md +1 -0
- package/dist/api/types.d.ts +311 -22
- package/dist/chunk-3QINBW3W.js +565 -0
- package/dist/chunk-3QINBW3W.js.map +1 -0
- package/dist/hmr/manifest.d.ts +44 -0
- package/dist/hmr/manifest.js +27 -0
- package/dist/hmr/manifest.js.map +1 -0
- package/dist/index.js +1995 -796
- package/dist/index.js.map +1 -1
- package/dist/reset-admin-password.d.ts +28 -0
- package/dist/reset-admin-password.js +39 -0
- package/dist/reset-admin-password.js.map +1 -0
- package/package.json +11 -3
- package/web-dist/assets/cpp-DIPi6g--.js +1 -0
- package/web-dist/assets/highlighter-Nvj8c0Yw.js +152 -0
- package/web-dist/assets/index--uKtXcEB.js +183 -0
- package/web-dist/assets/index-BqF51KT4.css +1 -0
- package/web-dist/assets/ruby-B13GW9FM.js +1 -0
- package/web-dist/index.html +2 -2
- package/web-dist/assets/abap-BdImnpbu.js +0 -1
- package/web-dist/assets/actionscript-3-CoDkCxhg.js +0 -1
- package/web-dist/assets/ada-bCR0ucgS.js +0 -1
- package/web-dist/assets/andromeeda-C4gqWexZ.js +0 -1
- package/web-dist/assets/angular-html-DA-rfuFy.js +0 -1
- package/web-dist/assets/angular-ts-BrjP3tb8.js +0 -1
- package/web-dist/assets/apache-Pmp26Uib.js +0 -1
- package/web-dist/assets/apex-Dqspr-GT.js +0 -1
- package/web-dist/assets/apl-CORt7UWP.js +0 -1
- package/web-dist/assets/applescript-Co6uUVPk.js +0 -1
- package/web-dist/assets/ara-BRHolxvo.js +0 -1
- package/web-dist/assets/asciidoc-Ve4PFQV2.js +0 -1
- package/web-dist/assets/asm-D_Q5rh1f.js +0 -1
- package/web-dist/assets/astro-HNnZUWAn.js +0 -1
- package/web-dist/assets/aurora-x-D-2ljcwZ.js +0 -1
- package/web-dist/assets/awk-DMzUqQB5.js +0 -1
- package/web-dist/assets/ayu-dark-DYE7WIF3.js +0 -1
- package/web-dist/assets/ayu-light-BA47KaF1.js +0 -1
- package/web-dist/assets/ayu-mirage-32ctXXKs.js +0 -1
- package/web-dist/assets/ballerina-BFfxhgS-.js +0 -1
- package/web-dist/assets/bat-BkioyH1T.js +0 -1
- package/web-dist/assets/beancount-k_qm7-4y.js +0 -1
- package/web-dist/assets/berry-uYugtg8r.js +0 -1
- package/web-dist/assets/bibtex-CHM0blh-.js +0 -1
- package/web-dist/assets/bicep-Bmn6On1c.js +0 -1
- package/web-dist/assets/bird2-BIv1doCn.js +0 -1
- package/web-dist/assets/blade-2xfisSek.js +0 -1
- package/web-dist/assets/bsl-BO_Y6i37.js +0 -1
- package/web-dist/assets/c3-MRO5bC_T.js +0 -1
- package/web-dist/assets/cadence-Bv_4Rxtq.js +0 -1
- package/web-dist/assets/cairo-KRGpt6FW.js +0 -1
- package/web-dist/assets/catppuccin-frappe-DFWUc33u.js +0 -1
- package/web-dist/assets/catppuccin-latte-C9dUb6Cb.js +0 -1
- package/web-dist/assets/catppuccin-macchiato-DQyhUUbL.js +0 -1
- package/web-dist/assets/catppuccin-mocha-D87Tk5Gz.js +0 -1
- package/web-dist/assets/clarity-D53aC0YG.js +0 -1
- package/web-dist/assets/clojure-P80f7IUj.js +0 -1
- package/web-dist/assets/cmake-D1j8_8rp.js +0 -1
- package/web-dist/assets/cobol-nBiQ_Alo.js +0 -1
- package/web-dist/assets/codeowners-Bp6g37R7.js +0 -1
- package/web-dist/assets/codeql-DsOJ9woJ.js +0 -1
- package/web-dist/assets/coffee-Ch7k5sss.js +0 -1
- package/web-dist/assets/common-lisp-Cg-RD9OK.js +0 -1
- package/web-dist/assets/coq-DkFqJrB1.js +0 -1
- package/web-dist/assets/cpp-UfJy6YNI.js +0 -1
- package/web-dist/assets/crystal-DGywbUpC.js +0 -1
- package/web-dist/assets/csharp-DSvCPggb.js +0 -1
- package/web-dist/assets/csv-fuZLfV_i.js +0 -1
- package/web-dist/assets/cue-D82EKSYY.js +0 -1
- package/web-dist/assets/cypher-COkxafJQ.js +0 -1
- package/web-dist/assets/d-85-TOEBH.js +0 -1
- package/web-dist/assets/dark-plus-C3mMm8J8.js +0 -1
- package/web-dist/assets/dart-bE4Kk8sk.js +0 -1
- package/web-dist/assets/dax-CEL-wOlO.js +0 -1
- package/web-dist/assets/desktop-BmXAJ9_W.js +0 -1
- package/web-dist/assets/docker-BcOcwvcX.js +0 -1
- package/web-dist/assets/dotenv-Da5cRb03.js +0 -1
- package/web-dist/assets/dracula-BzJJZx-M.js +0 -1
- package/web-dist/assets/dracula-soft-BXkSAIEj.js +0 -1
- package/web-dist/assets/dream-maker-BtqSS_iP.js +0 -1
- package/web-dist/assets/edge-FbVlp4U3.js +0 -1
- package/web-dist/assets/elixir-CkH2-t6x.js +0 -1
- package/web-dist/assets/elm-DbKCFpqz.js +0 -1
- package/web-dist/assets/emacs-lisp-CXvaQtF9.js +0 -1
- package/web-dist/assets/erb-Dm6A9KJ5.js +0 -1
- package/web-dist/assets/erlang-DsQrWhSR.js +0 -1
- package/web-dist/assets/everforest-dark-BgDCqdQA.js +0 -1
- package/web-dist/assets/everforest-light-C8M2exoo.js +0 -1
- package/web-dist/assets/fennel-BYunw83y.js +0 -1
- package/web-dist/assets/fish-BvzEVeQv.js +0 -1
- package/web-dist/assets/fluent-C4IJs8-o.js +0 -1
- package/web-dist/assets/fortran-fixed-form-CkoXwp7k.js +0 -1
- package/web-dist/assets/fortran-free-form-BxgE0vQu.js +0 -1
- package/web-dist/assets/fsharp-CXgrBDvD.js +0 -1
- package/web-dist/assets/gdresource-BOOCDP_w.js +0 -1
- package/web-dist/assets/gdscript-C5YyOfLZ.js +0 -1
- package/web-dist/assets/gdshader-DkwncUOv.js +0 -1
- package/web-dist/assets/genie-D0YGMca9.js +0 -1
- package/web-dist/assets/gherkin-DyxjwDmM.js +0 -1
- package/web-dist/assets/git-commit-F4YmCXRG.js +0 -1
- package/web-dist/assets/git-rebase-r7XF79zn.js +0 -1
- package/web-dist/assets/github-dark-default-Cuk6v7N8.js +0 -1
- package/web-dist/assets/github-dark-dimmed-DH5Ifo-i.js +0 -1
- package/web-dist/assets/github-dark-high-contrast-E3gJ1_iC.js +0 -1
- package/web-dist/assets/github-light-default-D7oLnXFd.js +0 -1
- package/web-dist/assets/github-light-high-contrast-BfjtVDDH.js +0 -1
- package/web-dist/assets/gleam-BspZqrRM.js +0 -1
- package/web-dist/assets/glimmer-js-ByusRIyA.js +0 -1
- package/web-dist/assets/glimmer-ts-BfAWNZQY.js +0 -1
- package/web-dist/assets/glsl-DplSGwfg.js +0 -1
- package/web-dist/assets/gn-n2N0HUVH.js +0 -1
- package/web-dist/assets/gnuplot-DdkO51Og.js +0 -1
- package/web-dist/assets/graphql-ChdNCCLP.js +0 -1
- package/web-dist/assets/groovy-gcz8RCvz.js +0 -1
- package/web-dist/assets/gruvbox-dark-hard-CFHQjOhq.js +0 -1
- package/web-dist/assets/gruvbox-dark-medium-GsRaNv29.js +0 -1
- package/web-dist/assets/gruvbox-dark-soft-CVdnzihN.js +0 -1
- package/web-dist/assets/gruvbox-light-hard-CH1njM8p.js +0 -1
- package/web-dist/assets/gruvbox-light-medium-DRw_LuNl.js +0 -1
- package/web-dist/assets/gruvbox-light-soft-hJgmCMqR.js +0 -1
- package/web-dist/assets/hack-DbPARsA_.js +0 -1
- package/web-dist/assets/haml-D5jkg6IW.js +0 -1
- package/web-dist/assets/handlebars-BpdQsYii.js +0 -1
- package/web-dist/assets/haskell-Df6bDoY_.js +0 -1
- package/web-dist/assets/haxe-CzTSHFRz.js +0 -1
- package/web-dist/assets/hcl-BWvSN4gD.js +0 -1
- package/web-dist/assets/hjson-D5-asLiD.js +0 -1
- package/web-dist/assets/hlsl-D3lLCCz7.js +0 -1
- package/web-dist/assets/horizon-BUw7H-hv.js +0 -1
- package/web-dist/assets/horizon-bright-CUuTKBJd.js +0 -1
- package/web-dist/assets/houston-DnULxvSX.js +0 -1
- package/web-dist/assets/html-derivative-DlHx6ybY.js +0 -1
- package/web-dist/assets/http-jrhK8wxY.js +0 -1
- package/web-dist/assets/hurl-irOxFIW8.js +0 -1
- package/web-dist/assets/hxml-Bvhsp5Yf.js +0 -1
- package/web-dist/assets/hy-DFXneXwc.js +0 -1
- package/web-dist/assets/imba-DGztddWO.js +0 -1
- package/web-dist/assets/index-B3puu-Hr.js +0 -153
- package/web-dist/assets/index-CaDuyrN0.css +0 -1
- package/web-dist/assets/index-rgHg3m6g.js +0 -181
- package/web-dist/assets/jinja-f2NsQr07.js +0 -1
- package/web-dist/assets/jison-wvAkD_A8.js +0 -1
- package/web-dist/assets/json5-C9tS-k6U.js +0 -1
- package/web-dist/assets/jsonc-Des-eS-w.js +0 -1
- package/web-dist/assets/jsonl-DcaNXYhu.js +0 -1
- package/web-dist/assets/jsonnet-DFQXde-d.js +0 -1
- package/web-dist/assets/jssm-C2t-YnRu.js +0 -1
- package/web-dist/assets/julia-D7OTSIA_.js +0 -1
- package/web-dist/assets/just-CUsbIsdP.js +0 -1
- package/web-dist/assets/kanagawa-dragon-CkXjmgJE.js +0 -1
- package/web-dist/assets/kanagawa-lotus-CfQXZHmo.js +0 -1
- package/web-dist/assets/kanagawa-wave-DWedfzmr.js +0 -1
- package/web-dist/assets/kdl-DV7GczEv.js +0 -1
- package/web-dist/assets/kotlin-BdnUsdx6.js +0 -1
- package/web-dist/assets/kusto-wEQ09or8.js +0 -1
- package/web-dist/assets/laserwave-DUszq2jm.js +0 -1
- package/web-dist/assets/latex-CaSxy8MP.js +0 -1
- package/web-dist/assets/lean-BZvkOJ9d.js +0 -1
- package/web-dist/assets/less-B1dDrJ26.js +0 -1
- package/web-dist/assets/light-plus-B7mTdjB0.js +0 -1
- package/web-dist/assets/liquid-C0sCDyMI.js +0 -1
- package/web-dist/assets/llvm-DjAJT7YJ.js +0 -1
- package/web-dist/assets/logo-BtOb2qkB.js +0 -1
- package/web-dist/assets/lua-BaeVxFsk.js +0 -1
- package/web-dist/assets/luau-KW6xsasC.js +0 -1
- package/web-dist/assets/make-CHLpvVh8.js +0 -1
- package/web-dist/assets/marko-DjSrsDqO.js +0 -1
- package/web-dist/assets/material-theme-D5KoaKCx.js +0 -1
- package/web-dist/assets/material-theme-darker-BfHTSMKl.js +0 -1
- package/web-dist/assets/material-theme-lighter-B0m2ddpp.js +0 -1
- package/web-dist/assets/material-theme-ocean-CyktbL80.js +0 -1
- package/web-dist/assets/material-theme-palenight-Csfq5Kiy.js +0 -1
- package/web-dist/assets/matlab-D7o27uSR.js +0 -1
- package/web-dist/assets/mdc-DTYItulj.js +0 -1
- package/web-dist/assets/mdx-Cmh6b_Ma.js +0 -1
- package/web-dist/assets/mermaid-mWjccvbQ.js +0 -1
- package/web-dist/assets/min-dark-CafNBF8u.js +0 -1
- package/web-dist/assets/min-light-CTRr51gU.js +0 -1
- package/web-dist/assets/mipsasm-CKIfxQSi.js +0 -1
- package/web-dist/assets/mojo-rZm6bMo-.js +0 -1
- package/web-dist/assets/monokai-D4h5O-jR.js +0 -1
- package/web-dist/assets/moonbit-_H4v1dQx.js +0 -1
- package/web-dist/assets/move-IF9eRakj.js +0 -1
- package/web-dist/assets/narrat-DRg8JJMk.js +0 -1
- package/web-dist/assets/nextflow-C-mBbutL.js +0 -1
- package/web-dist/assets/nextflow-groovy-vE_lwT2v.js +0 -1
- package/web-dist/assets/nginx-BpAMiNFr.js +0 -1
- package/web-dist/assets/night-owl-C39BiMTA.js +0 -1
- package/web-dist/assets/night-owl-light-CMTm3GFP.js +0 -1
- package/web-dist/assets/nim-BIad80T-.js +0 -1
- package/web-dist/assets/nix-CwoSXNpI.js +0 -1
- package/web-dist/assets/nord-Ddv68eIx.js +0 -1
- package/web-dist/assets/nushell-Cz2AlsmD.js +0 -1
- package/web-dist/assets/objective-c-DXmwc3jG.js +0 -1
- package/web-dist/assets/objective-cpp-CLxacb5B.js +0 -1
- package/web-dist/assets/ocaml-C0hk2d4L.js +0 -1
- package/web-dist/assets/odin-BBf5iR-q.js +0 -1
- package/web-dist/assets/one-dark-pro-DVMEJ2y_.js +0 -1
- package/web-dist/assets/one-light-C3Wv6jpd.js +0 -1
- package/web-dist/assets/openscad-C4EeE6gA.js +0 -1
- package/web-dist/assets/pascal-D93ZcfNL.js +0 -1
- package/web-dist/assets/perl-B9cMNwum.js +0 -1
- package/web-dist/assets/pkl-u5AG7uiY.js +0 -1
- package/web-dist/assets/plastic-3e1v2bzS.js +0 -1
- package/web-dist/assets/plsql-ChMvpjG-.js +0 -1
- package/web-dist/assets/po-BTJTHyun.js +0 -1
- package/web-dist/assets/poimandres-CS3Unz2-.js +0 -1
- package/web-dist/assets/polar-C0HS_06l.js +0 -1
- package/web-dist/assets/postcss-CXtECtnM.js +0 -1
- package/web-dist/assets/powerquery-CEu0bR-o.js +0 -1
- package/web-dist/assets/powershell-Dpen1YoG.js +0 -1
- package/web-dist/assets/prisma-Dd19v3D-.js +0 -1
- package/web-dist/assets/prolog-CbFg5uaA.js +0 -1
- package/web-dist/assets/proto-C7zT0LnQ.js +0 -1
- package/web-dist/assets/pug-DKIMFp6K.js +0 -1
- package/web-dist/assets/puppet-BMWR74SV.js +0 -1
- package/web-dist/assets/purescript-CklMAg4u.js +0 -1
- package/web-dist/assets/qml-3beO22l8.js +0 -1
- package/web-dist/assets/qmldir-C8lEn-DE.js +0 -1
- package/web-dist/assets/qss-IeuSbFQv.js +0 -1
- package/web-dist/assets/r-Dspwwk_N.js +0 -1
- package/web-dist/assets/racket-BqYA7rlc.js +0 -1
- package/web-dist/assets/raku-DXvB9xmW.js +0 -1
- package/web-dist/assets/razor-BjBPvh-w.js +0 -1
- package/web-dist/assets/red-bN70gL4F.js +0 -1
- package/web-dist/assets/reg-C-SQnVFl.js +0 -1
- package/web-dist/assets/regexp-CDVJQ6XC.js +0 -1
- package/web-dist/assets/rel-C3B-1QV4.js +0 -1
- package/web-dist/assets/riscv-BM1_JUlF.js +0 -1
- package/web-dist/assets/ron-D8l8udqQ.js +0 -1
- package/web-dist/assets/rose-pine-dawn-DHQR4-dF.js +0 -1
- package/web-dist/assets/rose-pine-moon-D4_iv3hh.js +0 -1
- package/web-dist/assets/rose-pine-qdsjHGoJ.js +0 -1
- package/web-dist/assets/rosmsg-BJDFO7_C.js +0 -1
- package/web-dist/assets/rst-CpCqk9r5.js +0 -1
- package/web-dist/assets/ruby-DyJCeAvU.js +0 -1
- package/web-dist/assets/sas-DEy46yEz.js +0 -1
- package/web-dist/assets/sass-Cj5Yp3dK.js +0 -1
- package/web-dist/assets/scala-C151Ov-r.js +0 -1
- package/web-dist/assets/scheme-C98Dy4si.js +0 -1
- package/web-dist/assets/scss-D5BDwBP9.js +0 -1
- package/web-dist/assets/sdbl-DVxCFoDh.js +0 -1
- package/web-dist/assets/shaderlab-Dg9Lc6iA.js +0 -1
- package/web-dist/assets/shellsession-BADoaaVG.js +0 -1
- package/web-dist/assets/slack-dark-BthQWCQV.js +0 -1
- package/web-dist/assets/slack-ochin-DqwNpetd.js +0 -1
- package/web-dist/assets/smalltalk-BERRCDM3.js +0 -1
- package/web-dist/assets/snazzy-light-Bw305WKR.js +0 -1
- package/web-dist/assets/solarized-dark-DXbdFlpD.js +0 -1
- package/web-dist/assets/solarized-light-L9t79GZl.js +0 -1
- package/web-dist/assets/solidity-rGO070M0.js +0 -1
- package/web-dist/assets/soy-8wufbnw4.js +0 -1
- package/web-dist/assets/sparql-rVzFXLq3.js +0 -1
- package/web-dist/assets/splunk-BtCnVYZw.js +0 -1
- package/web-dist/assets/ssh-config-_ykCGR6B.js +0 -1
- package/web-dist/assets/stata-DI20mbqo.js +0 -1
- package/web-dist/assets/stylus-BEDo0Tqx.js +0 -1
- package/web-dist/assets/surrealql-Bq5Q-fJD.js +0 -1
- package/web-dist/assets/svelte-Cy7k_4gC.js +0 -1
- package/web-dist/assets/swift-D82vCrfD.js +0 -1
- package/web-dist/assets/synthwave-84-CbfX1IO0.js +0 -1
- package/web-dist/assets/system-verilog-CnnmHF94.js +0 -1
- package/web-dist/assets/systemd-4A_iFExJ.js +0 -1
- package/web-dist/assets/talonscript-CkByrt1z.js +0 -1
- package/web-dist/assets/tasl-QIJgUcNo.js +0 -1
- package/web-dist/assets/tcl-dwOrl1Do.js +0 -1
- package/web-dist/assets/templ-DhtptRzy.js +0 -1
- package/web-dist/assets/terraform-BETggiCN.js +0 -1
- package/web-dist/assets/tex-idrVyKtj.js +0 -1
- package/web-dist/assets/tokyo-night-hegEt444.js +0 -1
- package/web-dist/assets/ts-tags-D351s5mN.js +0 -1
- package/web-dist/assets/tsv-B_m7g4N7.js +0 -1
- package/web-dist/assets/turtle-BsS91CYL.js +0 -1
- package/web-dist/assets/twig-CW1WmMYd.js +0 -1
- package/web-dist/assets/typespec-CAFt9gP4.js +0 -1
- package/web-dist/assets/typst-DHCkPAjA.js +0 -1
- package/web-dist/assets/v-BcVCzyr7.js +0 -1
- package/web-dist/assets/vala-CsfeWuGM.js +0 -1
- package/web-dist/assets/vb-D17OF-Vu.js +0 -1
- package/web-dist/assets/verilog-BQ8w6xss.js +0 -1
- package/web-dist/assets/vesper-DRje8inN.js +0 -1
- package/web-dist/assets/vhdl-CeAyd5Ju.js +0 -1
- package/web-dist/assets/viml-CJc9bBzg.js +0 -1
- package/web-dist/assets/vitesse-black-Bkuqu6BP.js +0 -1
- package/web-dist/assets/vitesse-dark-D0r3Knsf.js +0 -1
- package/web-dist/assets/vitesse-light-CVO1_9PV.js +0 -1
- package/web-dist/assets/vue-D2xRrEX4.js +0 -1
- package/web-dist/assets/vue-html-AaS7Mt5G.js +0 -1
- package/web-dist/assets/vue-vine-BoDAl6tE.js +0 -1
- package/web-dist/assets/vyper-CDx5xZoG.js +0 -1
- package/web-dist/assets/wasm-CG6Dc4jp.js +0 -1
- package/web-dist/assets/wasm-MzD3tlZU.js +0 -1
- package/web-dist/assets/wenyan-BV7otONQ.js +0 -1
- package/web-dist/assets/wgsl-Dx-B1_4e.js +0 -1
- package/web-dist/assets/wikitext-BhOHFoWU.js +0 -1
- package/web-dist/assets/wit-5i3qLPDT.js +0 -1
- package/web-dist/assets/wolfram-lXgVvXCa.js +0 -1
- package/web-dist/assets/xsl-CtQFsRM5.js +0 -1
- package/web-dist/assets/zenscript-DVFEvuxE.js +0 -1
- package/web-dist/assets/zig-VOosw3JB.js +0 -1
package/dist/index.js
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ADMIN_USER_ID,
|
|
3
|
+
AuthService,
|
|
4
|
+
AuthSessionsRepo,
|
|
5
|
+
HttpError,
|
|
6
|
+
MIN_PASSWORD_LENGTH,
|
|
7
|
+
UsersRepo,
|
|
8
|
+
errorBody,
|
|
9
|
+
handleError,
|
|
10
|
+
hashPassword,
|
|
11
|
+
isMissingCredential,
|
|
12
|
+
modelCredentialMissing,
|
|
13
|
+
openDatabase,
|
|
14
|
+
toUserInfo
|
|
15
|
+
} from "./chunk-3QINBW3W.js";
|
|
1
16
|
import {
|
|
2
17
|
acquireServerLock,
|
|
3
18
|
liveServerLock,
|
|
@@ -11,20 +26,26 @@ import {
|
|
|
11
26
|
} from "./chunk-UUJCF46V.js";
|
|
12
27
|
|
|
13
28
|
// src/index.ts
|
|
14
|
-
import
|
|
29
|
+
import fs24 from "fs";
|
|
15
30
|
import path19 from "path";
|
|
16
31
|
import { config as loadDotenv } from "dotenv";
|
|
17
32
|
import { serve } from "@hono/node-server";
|
|
18
33
|
|
|
19
34
|
// src/app.ts
|
|
20
|
-
import
|
|
21
|
-
import
|
|
35
|
+
import fs22 from "fs";
|
|
36
|
+
import fsp2 from "fs/promises";
|
|
22
37
|
import path17 from "path";
|
|
23
|
-
import { Hono as
|
|
38
|
+
import { Hono as Hono26 } from "hono";
|
|
24
39
|
import { bodyLimit } from "hono/body-limit";
|
|
25
40
|
|
|
26
41
|
// src/net/proxy.ts
|
|
27
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
Agent,
|
|
44
|
+
EnvHttpProxyAgent,
|
|
45
|
+
ProxyAgent,
|
|
46
|
+
fetch as undiciFetch,
|
|
47
|
+
setGlobalDispatcher
|
|
48
|
+
} from "undici";
|
|
28
49
|
var LOOPBACK_NO_PROXY = ["localhost", "127.0.0.1", "::1"];
|
|
29
50
|
function mergedNoProxy(env = process.env) {
|
|
30
51
|
const raw = env.no_proxy ?? env.NO_PROXY ?? "";
|
|
@@ -45,12 +66,14 @@ function normalizeProxyUrl(raw) {
|
|
|
45
66
|
} catch {
|
|
46
67
|
return null;
|
|
47
68
|
}
|
|
48
|
-
if (url.
|
|
49
|
-
|
|
50
|
-
|
|
69
|
+
if (url.hostname === "") return null;
|
|
70
|
+
const normalized = url.pathname === "/" && url.search === "" && url.hash === "" ? url.href.slice(0, -1) : url.href;
|
|
71
|
+
try {
|
|
72
|
+
void new ProxyAgent({ uri: normalized }).close();
|
|
73
|
+
} catch {
|
|
51
74
|
return null;
|
|
52
75
|
}
|
|
53
|
-
return
|
|
76
|
+
return normalized;
|
|
54
77
|
}
|
|
55
78
|
function envProxyAgentOptions(proxyUrl, env) {
|
|
56
79
|
const noProxy = mergedNoProxy(env);
|
|
@@ -75,175 +98,6 @@ function applyProxySettings(settings) {
|
|
|
75
98
|
if (installed) setGlobalDispatcher(buildProxyDispatcher(current));
|
|
76
99
|
}
|
|
77
100
|
|
|
78
|
-
// src/db/database.ts
|
|
79
|
-
import { mkdirSync } from "fs";
|
|
80
|
-
import path from "path";
|
|
81
|
-
|
|
82
|
-
// src/db/schema.ts
|
|
83
|
-
var SCHEMA_SQL = `
|
|
84
|
-
CREATE TABLE IF NOT EXISTS users (
|
|
85
|
-
user_id TEXT PRIMARY KEY, -- semantic id doubles as login name: ^[a-z][a-z0-9_-]{1,31}$
|
|
86
|
-
password_hash TEXT NOT NULL, -- scrypt$N$r$p$salt$hash(base64)
|
|
87
|
-
is_admin INTEGER NOT NULL DEFAULT 0, -- 1 for the built-in admin (seeded at startup)
|
|
88
|
-
password_is_initial INTEGER NOT NULL DEFAULT 0, -- 1=initial password (seeded/admin-set); cleared once the user changes it
|
|
89
|
-
created_at TEXT NOT NULL
|
|
90
|
-
);
|
|
91
|
-
CREATE TABLE IF NOT EXISTS auth_sessions (
|
|
92
|
-
token_hash TEXT PRIMARY KEY, -- sha256(token) hex; the cookie stores only the raw token
|
|
93
|
-
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
|
94
|
-
created_at TEXT NOT NULL,
|
|
95
|
-
expires_at TEXT NOT NULL, -- 7-day sliding renewal (topped up when <6 days remain)
|
|
96
|
-
via TEXT -- 'password' | 'desktop'; NULL = legacy row (password)
|
|
97
|
-
);
|
|
98
|
-
CREATE TABLE IF NOT EXISTS projects (
|
|
99
|
-
project_id TEXT PRIMARY KEY, -- directory name doubles as id; display name lives in project_config.toml
|
|
100
|
-
owner_user_id TEXT NOT NULL REFERENCES users(user_id),
|
|
101
|
-
created_at TEXT NOT NULL
|
|
102
|
-
);
|
|
103
|
-
CREATE TABLE IF NOT EXISTS project_members ( -- member grants only; owners are not stored here
|
|
104
|
-
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
|
105
|
-
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
|
106
|
-
created_at TEXT NOT NULL,
|
|
107
|
-
PRIMARY KEY (project_id, user_id)
|
|
108
|
-
);
|
|
109
|
-
CREATE TABLE IF NOT EXISTS agents ( -- index only; name/description live in system_config.yaml
|
|
110
|
-
project_id TEXT NOT NULL,
|
|
111
|
-
agent_id TEXT NOT NULL,
|
|
112
|
-
created_at TEXT NOT NULL,
|
|
113
|
-
PRIMARY KEY (project_id, agent_id)
|
|
114
|
-
);
|
|
115
|
-
CREATE TABLE IF NOT EXISTS sessions (
|
|
116
|
-
session_id TEXT PRIMARY KEY,
|
|
117
|
-
project_id TEXT NOT NULL,
|
|
118
|
-
agent_id TEXT NOT NULL,
|
|
119
|
-
provider TEXT NOT NULL, -- provider group of the session model (paired with model_id as a model reference)
|
|
120
|
-
model_id TEXT NOT NULL, -- upstream model id (sent to AgentHub as-is; never concatenate <provider>/<id>)
|
|
121
|
-
workspace TEXT NOT NULL,
|
|
122
|
-
approval_mode TEXT NOT NULL DEFAULT 'allow-all', -- allow-all|deny-all|read-only|always-ask
|
|
123
|
-
title TEXT, -- auto-generated by the model after the first exchange; NULL=not yet (frontend shows "New chat")
|
|
124
|
-
archived_at TEXT, -- archive time; NULL=not archived (shown by default; archived ones move under "Archived")
|
|
125
|
-
client TEXT, -- creating client: 'web' (created via the Web App) | 'cli' (adopted from a CLI Trace); NULL = legacy row, treated as web
|
|
126
|
-
has_trace INTEGER NOT NULL DEFAULT 0, -- cache: a Trace record exists (set at task start / adoption / subagent registration; lazily backfilled by list hydration)
|
|
127
|
-
created_at TEXT NOT NULL
|
|
128
|
-
); -- the schedule/subagent SOURCE is NOT stored: session_meta in the Trace is the single source of truth (see runtime/session-sources.ts); "client" is a different, DB-only axis (who created the row)
|
|
129
|
-
CREATE INDEX IF NOT EXISTS idx_sessions_agent_created ON sessions(project_id, agent_id, created_at DESC);
|
|
130
|
-
CREATE TABLE IF NOT EXISTS usage_records (
|
|
131
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
132
|
-
ts TEXT NOT NULL,
|
|
133
|
-
date TEXT NOT NULL, -- local date yyyy-mm-dd (aggregation key; same convention as Trace date directories)
|
|
134
|
-
project_id TEXT NOT NULL,
|
|
135
|
-
agent_id TEXT NOT NULL,
|
|
136
|
-
session_id TEXT NOT NULL, -- top-level Session (child-session usage counts toward its main Session)
|
|
137
|
-
origin_session_id TEXT, -- immediate source child Session (last of the origin chain); NULL=main session
|
|
138
|
-
provider TEXT NOT NULL, -- provider group: paired with model_id as the attribution key; always GROUP BY provider, model_id
|
|
139
|
-
model_id TEXT NOT NULL, -- upstream model id (paired with provider; same model_id across providers aggregates separately)
|
|
140
|
-
cache_read INTEGER NOT NULL,
|
|
141
|
-
cache_write INTEGER NOT NULL,
|
|
142
|
-
output INTEGER NOT NULL,
|
|
143
|
-
total INTEGER NOT NULL, -- from token_usage.request (one row per Request)
|
|
144
|
-
status TEXT NOT NULL DEFAULT 'completed' -- request outcome: completed=success (with tokens); others=failure (0 tokens, for success rate)
|
|
145
|
-
); -- cost is not stored: computed at query time from current pricing
|
|
146
|
-
CREATE INDEX IF NOT EXISTS idx_usage_project_date ON usage_records(project_id, date);
|
|
147
|
-
CREATE INDEX IF NOT EXISTS idx_usage_session ON usage_records(session_id);
|
|
148
|
-
CREATE TABLE IF NOT EXISTS error_records ( -- server-side error capture (the Costs page's "Errors" tab)
|
|
149
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
150
|
-
ts TEXT NOT NULL,
|
|
151
|
-
date TEXT NOT NULL, -- local date yyyy-mm-dd (same convention as usage_records)
|
|
152
|
-
project_id TEXT, -- nullable: sign-in/registration and process-level errors have no Project context
|
|
153
|
-
agent_id TEXT,
|
|
154
|
-
session_id TEXT,
|
|
155
|
-
source TEXT NOT NULL, -- http | session | usage | title | subagent | process | llm | environment | compaction | schedule
|
|
156
|
-
kind TEXT NOT NULL, -- expected (HttpError, business 4xx) | unexpected (500/runtime)
|
|
157
|
-
code TEXT NOT NULL, -- HttpError.code / internal / session_run_failed / ...
|
|
158
|
-
status INTEGER, -- HTTP status code; NULL for non-HTTP sources
|
|
159
|
-
message TEXT NOT NULL -- truncated to 500 chars (no stack stored: stacks go to logs only)
|
|
160
|
-
);
|
|
161
|
-
CREATE INDEX IF NOT EXISTS idx_error_project_date ON error_records(project_id, date);
|
|
162
|
-
CREATE TABLE IF NOT EXISTS schedule_state ( -- schedule runtime state (files are declarative intent; the system never writes back)
|
|
163
|
-
project_id TEXT NOT NULL,
|
|
164
|
-
agent_id TEXT NOT NULL,
|
|
165
|
-
name TEXT NOT NULL, -- file name (without .toml) doubles as the identifier
|
|
166
|
-
creator_user_id TEXT, -- creator (recorded on API create; hand-edited file reconciliation falls back to the Project owner)
|
|
167
|
-
start_at_ms INTEGER NOT NULL, -- defines identity: a start_at change counts as a new task instance, resetting fire state
|
|
168
|
-
def_hash TEXT NOT NULL, -- file content fingerprint: any change clears the invalid flag (the file takes effect again after edits)
|
|
169
|
-
last_slot_ms INTEGER, -- latest due slot already consumed (advances on fire or skip; no re-fire across restarts)
|
|
170
|
-
last_fired_at TEXT, -- last actual fire time (for display)
|
|
171
|
-
fired_once INTEGER NOT NULL DEFAULT 0, -- one-shot task has fired
|
|
172
|
-
missed INTEGER NOT NULL DEFAULT 0, -- one-shot task was missed (flagged by startup/registration reconciliation; not backfilled)
|
|
173
|
-
invalid_reason TEXT, -- invalidation reason (e.g. the bound Session was deleted); NULL=healthy
|
|
174
|
-
PRIMARY KEY (project_id, agent_id, name)
|
|
175
|
-
);
|
|
176
|
-
CREATE TABLE IF NOT EXISTS goal_state ( -- goal-mode runtime state (GOAL.yaml on disk is the model-facing protocol; this row is what the UI reads)
|
|
177
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT, -- one row per goal run; a Session may run goals repeatedly, latest row wins for display
|
|
178
|
-
session_id TEXT NOT NULL,
|
|
179
|
-
project_id TEXT NOT NULL,
|
|
180
|
-
agent_id TEXT NOT NULL,
|
|
181
|
-
objective TEXT NOT NULL,
|
|
182
|
-
status TEXT NOT NULL, -- active | complete | blocked | budget_limited | aborted (aborted is server-side only; on-disk status stays active)
|
|
183
|
-
budget INTEGER NOT NULL, -- token budget; -1 = unlimited
|
|
184
|
-
used INTEGER NOT NULL DEFAULT 0, -- uncached input + output, refreshed per round (mirrors the runner's accounting)
|
|
185
|
-
rounds INTEGER NOT NULL DEFAULT 0,
|
|
186
|
-
created_at TEXT NOT NULL,
|
|
187
|
-
updated_at TEXT NOT NULL
|
|
188
|
-
);
|
|
189
|
-
CREATE INDEX IF NOT EXISTS idx_goal_session ON goal_state(session_id);
|
|
190
|
-
CREATE TABLE IF NOT EXISTS ui_prefs (
|
|
191
|
-
user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
|
|
192
|
-
prefs_json TEXT NOT NULL -- {theme?, lastProjectId?, ...} free-form JSON
|
|
193
|
-
);
|
|
194
|
-
CREATE TABLE IF NOT EXISTS server_settings ( -- admin-level server-global settings (GET/PUT /api/admin/settings)
|
|
195
|
-
key TEXT PRIMARY KEY, -- setting name, e.g. 'proxy_for_app'
|
|
196
|
-
value TEXT NOT NULL -- JSON-encoded value; an absent row means the setting's built-in default
|
|
197
|
-
);
|
|
198
|
-
CREATE TABLE IF NOT EXISTS trace_files ( -- DERIVED CACHE of the on-disk Trace tree (services/trace-index.ts): the directories stay the single source of truth, every row is rebuildable from disk, and a row is never authority for absence \u2014 consumers reconcile + retry on a miss, so a stale index costs one extra scan, never a false 404
|
|
199
|
-
project_id TEXT NOT NULL,
|
|
200
|
-
agent_id TEXT NOT NULL,
|
|
201
|
-
session_id TEXT NOT NULL,
|
|
202
|
-
file_index INTEGER NOT NULL, -- shard index NNN of <session_id>_NNN.jsonl (name/path are reconstructed from the row, never stored: the data root may move)
|
|
203
|
-
date TEXT NOT NULL, -- date directory name (local yyyy-mm-dd)
|
|
204
|
-
size_bytes INTEGER NOT NULL, -- last observed size (listings stat-refresh the returned page and write back; an actively-appended shard may lag in between)
|
|
205
|
-
page_stats TEXT, -- cached message-window scan state at this shard's END (services/message-window.ts ShardPrefixRecord JSON); immutable shards only, NULL = not yet computed, nulled whenever size_bytes changes
|
|
206
|
-
PRIMARY KEY (project_id, agent_id, session_id, file_index)
|
|
207
|
-
);
|
|
208
|
-
CREATE INDEX IF NOT EXISTS idx_trace_files_agent_date ON trace_files(project_id, agent_id, date);
|
|
209
|
-
CREATE INDEX IF NOT EXISTS idx_trace_files_session ON trace_files(session_id);
|
|
210
|
-
CREATE TABLE IF NOT EXISTS trace_sessions ( -- per-session facts read ONCE at registration from the earliest shard's head (same derived-cache rules as trace_files; listing needs zero head-reads)
|
|
211
|
-
session_id TEXT PRIMARY KEY,
|
|
212
|
-
project_id TEXT NOT NULL,
|
|
213
|
-
agent_id TEXT NOT NULL,
|
|
214
|
-
source TEXT, -- session_meta origin: 'subagent' | 'schedule' | NULL = user-created (or head not yet readable)
|
|
215
|
-
workspace TEXT NOT NULL DEFAULT '',
|
|
216
|
-
title TEXT, -- first-prompt fallback title (sessions.title always wins when present)
|
|
217
|
-
provider TEXT, -- model reference from session_meta (CLI adoption reads it from here; NULL = meta unreadable / legacy without provider)
|
|
218
|
-
model_id TEXT,
|
|
219
|
-
first_ts TEXT, -- first record's timestamp (adoption's createdAt fallback when the id embeds no time)
|
|
220
|
-
meta_read INTEGER NOT NULL DEFAULT 0 -- 1 once the head parsed; 0 = facts unknown, retried by the next reconcile that touches the session
|
|
221
|
-
);
|
|
222
|
-
CREATE INDEX IF NOT EXISTS idx_trace_sessions_agent ON trace_sessions(project_id, agent_id);
|
|
223
|
-
`;
|
|
224
|
-
|
|
225
|
-
// src/db/database.ts
|
|
226
|
-
var sqlite = process.getBuiltinModule("node:sqlite");
|
|
227
|
-
function openDatabase(dbPath) {
|
|
228
|
-
if (dbPath !== ":memory:") {
|
|
229
|
-
mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
230
|
-
}
|
|
231
|
-
const db = new sqlite.DatabaseSync(dbPath);
|
|
232
|
-
db.exec("PRAGMA journal_mode = WAL;");
|
|
233
|
-
db.exec("PRAGMA foreign_keys = ON;");
|
|
234
|
-
db.exec(SCHEMA_SQL);
|
|
235
|
-
ensureColumn(db, "sessions", "client", "TEXT");
|
|
236
|
-
ensureColumn(db, "sessions", "has_trace", "INTEGER NOT NULL DEFAULT 0");
|
|
237
|
-
ensureColumn(db, "auth_sessions", "via", "TEXT");
|
|
238
|
-
ensureColumn(db, "trace_files", "page_stats", "TEXT");
|
|
239
|
-
return db;
|
|
240
|
-
}
|
|
241
|
-
function ensureColumn(db, table, column, ddl) {
|
|
242
|
-
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
243
|
-
if (cols.some((c) => c.name === column)) return;
|
|
244
|
-
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddl}`);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
101
|
// src/db/repos/agents.ts
|
|
248
102
|
var AgentsRepo = class {
|
|
249
103
|
constructor(db) {
|
|
@@ -274,45 +128,6 @@ var AgentsRepo = class {
|
|
|
274
128
|
}
|
|
275
129
|
};
|
|
276
130
|
|
|
277
|
-
// src/db/repos/auth-sessions.ts
|
|
278
|
-
var AuthSessionsRepo = class {
|
|
279
|
-
constructor(db) {
|
|
280
|
-
this.db = db;
|
|
281
|
-
}
|
|
282
|
-
db;
|
|
283
|
-
insert(row) {
|
|
284
|
-
this.db.prepare(
|
|
285
|
-
"INSERT INTO auth_sessions (token_hash, user_id, created_at, expires_at, via) VALUES (?, ?, ?, ?, ?)"
|
|
286
|
-
).run(row.tokenHash, row.userId, row.createdAt, row.expiresAt, row.via);
|
|
287
|
-
}
|
|
288
|
-
findByTokenHash(tokenHash) {
|
|
289
|
-
const r = this.db.prepare("SELECT * FROM auth_sessions WHERE token_hash = ?").get(tokenHash);
|
|
290
|
-
if (!r) return null;
|
|
291
|
-
return {
|
|
292
|
-
tokenHash: r.token_hash,
|
|
293
|
-
userId: r.user_id,
|
|
294
|
-
createdAt: r.created_at,
|
|
295
|
-
expiresAt: r.expires_at,
|
|
296
|
-
via: r.via ?? null
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
/** Sliding renewal: update the expiration time. */
|
|
300
|
-
touch(tokenHash, expiresAt) {
|
|
301
|
-
this.db.prepare("UPDATE auth_sessions SET expires_at = ? WHERE token_hash = ?").run(expiresAt, tokenHash);
|
|
302
|
-
}
|
|
303
|
-
delete(tokenHash) {
|
|
304
|
-
this.db.prepare("DELETE FROM auth_sessions WHERE token_hash = ?").run(tokenHash);
|
|
305
|
-
}
|
|
306
|
-
/** Opportunistically clean up expired sessions (called during login/validation). */
|
|
307
|
-
deleteExpired(nowIso) {
|
|
308
|
-
this.db.prepare("DELETE FROM auth_sessions WHERE expires_at < ?").run(nowIso);
|
|
309
|
-
}
|
|
310
|
-
/** Clear all sessions for a user (forces re-login after an admin resets the password). */
|
|
311
|
-
deleteByUser(userId) {
|
|
312
|
-
this.db.prepare("DELETE FROM auth_sessions WHERE user_id = ?").run(userId);
|
|
313
|
-
}
|
|
314
|
-
};
|
|
315
|
-
|
|
316
131
|
// src/db/repos/errors.ts
|
|
317
132
|
var MAX_ROWS = 2e4;
|
|
318
133
|
var PRUNE_EVERY = 200;
|
|
@@ -712,11 +527,41 @@ var SchedulesRepo = class {
|
|
|
712
527
|
}
|
|
713
528
|
};
|
|
714
529
|
|
|
530
|
+
// src/services/attachment-limits.ts
|
|
531
|
+
var MB = 1024 * 1024;
|
|
532
|
+
var DEFAULT_ATTACHMENT_MAX_MB = 100;
|
|
533
|
+
var DEFAULT_ATTACHMENT_TOTAL_MB = 120;
|
|
534
|
+
var MIN_ATTACHMENT_MB = 1;
|
|
535
|
+
var MAX_ATTACHMENT_MB = 200;
|
|
536
|
+
var MAX_ATTACHMENT_COUNT = 20;
|
|
537
|
+
var INLINE_IMAGE_MAX_MB = 20;
|
|
538
|
+
function base64Length(bytes) {
|
|
539
|
+
return Math.ceil(bytes / 3) * 4;
|
|
540
|
+
}
|
|
541
|
+
var BODY_HEADROOM_BYTES = base64Length(INLINE_IMAGE_MAX_MB * MB) + 4 * MB;
|
|
542
|
+
function toAttachmentLimits(mb) {
|
|
543
|
+
return {
|
|
544
|
+
maxBytes: mb.attachmentMaxMb * MB,
|
|
545
|
+
totalBytes: mb.attachmentTotalMb * MB,
|
|
546
|
+
maxCount: MAX_ATTACHMENT_COUNT
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
var INLINE_IMAGE_MAX_BYTES = INLINE_IMAGE_MAX_MB * MB;
|
|
550
|
+
function bodyLimitBytes(mb) {
|
|
551
|
+
return base64Length(mb.attachmentTotalMb * MB) + BODY_HEADROOM_BYTES;
|
|
552
|
+
}
|
|
553
|
+
function clampAttachmentMb(value, fallback) {
|
|
554
|
+
if (!Number.isFinite(value) || !Number.isInteger(value)) return fallback;
|
|
555
|
+
return Math.min(MAX_ATTACHMENT_MB, Math.max(MIN_ATTACHMENT_MB, value));
|
|
556
|
+
}
|
|
557
|
+
|
|
715
558
|
// src/db/repos/server-settings.ts
|
|
716
559
|
var PROXY_FOR_APP_KEY = "proxy_for_app";
|
|
717
560
|
var PROXY_FOR_AGENT_KEY = "proxy_for_agent";
|
|
718
561
|
var LEGACY_USE_SYSTEM_PROXY_KEY = "use_system_proxy";
|
|
719
562
|
var PROXY_URL_KEY = "proxy_url";
|
|
563
|
+
var ATTACHMENT_MAX_MB_KEY = "attachment_max_mb";
|
|
564
|
+
var ATTACHMENT_TOTAL_MB_KEY = "attachment_total_mb";
|
|
720
565
|
var ServerSettingsRepo = class {
|
|
721
566
|
constructor(db) {
|
|
722
567
|
this.db = db;
|
|
@@ -771,6 +616,48 @@ var ServerSettingsRepo = class {
|
|
|
771
616
|
setProxyUrl(value) {
|
|
772
617
|
this.set(PROXY_URL_KEY, JSON.stringify(value));
|
|
773
618
|
}
|
|
619
|
+
/**
|
|
620
|
+
* Shared read for the two attachment limits: the stored whole-MB number, clamped back into the
|
|
621
|
+
* legal range, or the built-in default when the row is absent or unreadable. Values only ever
|
|
622
|
+
* enter through the validated PUT, so the clamp is for a database that predates a change to the
|
|
623
|
+
* bounds (or was hand-edited) — using the nearest legal number is a better answer there than
|
|
624
|
+
* refusing to serve uploads at all.
|
|
625
|
+
*/
|
|
626
|
+
getAttachmentMb(key, fallback) {
|
|
627
|
+
const raw = this.get(key);
|
|
628
|
+
if (raw === null) return fallback;
|
|
629
|
+
try {
|
|
630
|
+
const value = JSON.parse(raw);
|
|
631
|
+
return typeof value === "number" ? clampAttachmentMb(value, fallback) : fallback;
|
|
632
|
+
} catch {
|
|
633
|
+
return fallback;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
/** Per-file composer attachment cap, in whole MB. */
|
|
637
|
+
getAttachmentMaxMb() {
|
|
638
|
+
return this.getAttachmentMb(ATTACHMENT_MAX_MB_KEY, DEFAULT_ATTACHMENT_MAX_MB);
|
|
639
|
+
}
|
|
640
|
+
setAttachmentMaxMb(value) {
|
|
641
|
+
this.set(ATTACHMENT_MAX_MB_KEY, JSON.stringify(value));
|
|
642
|
+
}
|
|
643
|
+
/** Per-message total attachment cap (decoded bytes), in whole MB. */
|
|
644
|
+
getAttachmentTotalMb() {
|
|
645
|
+
return this.getAttachmentMb(ATTACHMENT_TOTAL_MB_KEY, DEFAULT_ATTACHMENT_TOTAL_MB);
|
|
646
|
+
}
|
|
647
|
+
setAttachmentTotalMb(value) {
|
|
648
|
+
this.set(ATTACHMENT_TOTAL_MB_KEY, JSON.stringify(value));
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* The pair as one value — what the validators, the body cap and `/api/me` all read. Kept here
|
|
652
|
+
* so no caller has to remember that the two numbers are only meaningful together (the total is
|
|
653
|
+
* never below the per-file cap; the PUT enforces that against the effective post-write pair).
|
|
654
|
+
*/
|
|
655
|
+
getAttachmentLimitsMb() {
|
|
656
|
+
return {
|
|
657
|
+
attachmentMaxMb: this.getAttachmentMaxMb(),
|
|
658
|
+
attachmentTotalMb: this.getAttachmentTotalMb()
|
|
659
|
+
};
|
|
660
|
+
}
|
|
774
661
|
};
|
|
775
662
|
|
|
776
663
|
// src/db/repos/sessions.ts
|
|
@@ -783,10 +670,14 @@ function mapRow4(r) {
|
|
|
783
670
|
modelId: r.model_id,
|
|
784
671
|
workspace: r.workspace,
|
|
785
672
|
approvalMode: r.approval_mode,
|
|
673
|
+
thinkingLevel: r.thinking_level ?? null,
|
|
786
674
|
title: r.title ?? null,
|
|
787
675
|
archivedAt: r.archived_at ?? null,
|
|
788
676
|
client: r.client ?? null,
|
|
789
677
|
hasTrace: r.has_trace === 1,
|
|
678
|
+
// The open-time backfill leaves no NULLs; the coalesce only hardens against a row
|
|
679
|
+
// somehow inserted as NULL (degrades to createdAt instead of surfacing undefined).
|
|
680
|
+
lastActiveAt: r.last_active_at ?? r.created_at,
|
|
790
681
|
createdAt: r.created_at
|
|
791
682
|
};
|
|
792
683
|
}
|
|
@@ -796,28 +687,47 @@ var SessionsRepo = class {
|
|
|
796
687
|
}
|
|
797
688
|
db;
|
|
798
689
|
insert(row) {
|
|
799
|
-
this.
|
|
800
|
-
`INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, client, has_trace, created_at)
|
|
801
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
802
|
-
).run(
|
|
803
|
-
row.sessionId,
|
|
804
|
-
row.projectId,
|
|
805
|
-
row.agentId,
|
|
806
|
-
row.provider,
|
|
807
|
-
row.modelId,
|
|
808
|
-
row.workspace,
|
|
809
|
-
row.approvalMode,
|
|
810
|
-
row.title,
|
|
811
|
-
row.client ?? null,
|
|
812
|
-
row.hasTrace ? 1 : 0,
|
|
813
|
-
row.createdAt
|
|
814
|
-
);
|
|
690
|
+
this.runInsert("INSERT", row);
|
|
815
691
|
}
|
|
816
692
|
/** Idempotent insert: used when Trace directory discovery backfills a row (concurrent listing discovering the same Session no longer triggers a UNIQUE violation). */
|
|
817
693
|
insertOrIgnore(row) {
|
|
694
|
+
this.runInsert("INSERT OR IGNORE", row);
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Allocate the source Session's next persistent fork number and insert its fork atomically.
|
|
698
|
+
* Keeping both writes in one transaction prevents concurrent requests from sharing a number,
|
|
699
|
+
* while retaining `fork_count` on the source means deleting an older fork never reuses it.
|
|
700
|
+
*/
|
|
701
|
+
insertFork(sourceSessionId, row) {
|
|
702
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
703
|
+
try {
|
|
704
|
+
const source = this.db.prepare(
|
|
705
|
+
`UPDATE sessions SET fork_count = fork_count + 1 WHERE session_id = ?
|
|
706
|
+
RETURNING title, fork_count`
|
|
707
|
+
).get(sourceSessionId);
|
|
708
|
+
if (!source) throw new Error(`Source Session not found: ${sourceSessionId}`);
|
|
709
|
+
const forkTitle = source.title ? `${source.title} (${source.fork_count})` : `(${source.fork_count})`;
|
|
710
|
+
const forkRow = { ...row, title: forkTitle };
|
|
711
|
+
this.runInsert("INSERT", forkRow);
|
|
712
|
+
this.db.exec("COMMIT");
|
|
713
|
+
return forkRow;
|
|
714
|
+
} catch (err) {
|
|
715
|
+
this.db.exec("ROLLBACK");
|
|
716
|
+
throw err;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* The two inserts' shared column list and binding order (they differ only in conflict
|
|
721
|
+
* handling). `last_active_at` defaults **in SQL** (`COALESCE(?, ?)` over the row's own
|
|
722
|
+
* created_at, bound twice — SQLite forbids referencing a sibling column inside VALUES,
|
|
723
|
+
* and a column added by ALTER TABLE cannot be given a DEFAULT): the column is nullable
|
|
724
|
+
* for legacy reasons, so this is the one place that can guarantee it is never written
|
|
725
|
+
* NULL, whatever a caller passes.
|
|
726
|
+
*/
|
|
727
|
+
runInsert(verb, row) {
|
|
818
728
|
this.db.prepare(
|
|
819
|
-
|
|
820
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
729
|
+
`${verb} INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, client, has_trace, last_active_at, created_at)
|
|
730
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, ?), ?)`
|
|
821
731
|
).run(
|
|
822
732
|
row.sessionId,
|
|
823
733
|
row.projectId,
|
|
@@ -829,13 +739,34 @@ var SessionsRepo = class {
|
|
|
829
739
|
row.title,
|
|
830
740
|
row.client ?? null,
|
|
831
741
|
row.hasTrace ? 1 : 0,
|
|
742
|
+
row.lastActiveAt ?? null,
|
|
743
|
+
row.createdAt,
|
|
832
744
|
row.createdAt
|
|
833
745
|
);
|
|
834
746
|
}
|
|
835
|
-
/** Flip the has_trace cache once a Trace record exists (
|
|
747
|
+
/** Flip the has_trace cache once a Trace record exists (discovery hydration); idempotent. */
|
|
836
748
|
markHasTrace(sessionId) {
|
|
837
749
|
this.db.prepare("UPDATE sessions SET has_trace = 1 WHERE session_id = ?").run(sessionId);
|
|
838
750
|
}
|
|
751
|
+
/**
|
|
752
|
+
* A driven run touched this Session: flip the has_trace cache and stamp last_active_at in
|
|
753
|
+
* ONE statement (drive's run start — one WAL commit instead of two).
|
|
754
|
+
* `MAX(COALESCE(...))` keeps the stamp monotonic: a backwards clock step (NTP, resume
|
|
755
|
+
* from suspend) leaves the stored value alone rather than regressing the row.
|
|
756
|
+
*/
|
|
757
|
+
markDriven(sessionId, at) {
|
|
758
|
+
this.db.prepare(
|
|
759
|
+
`UPDATE sessions SET has_trace = 1, last_active_at = MAX(COALESCE(last_active_at, ''), ?)
|
|
760
|
+
WHERE session_id = ?`
|
|
761
|
+
).run(at, sessionId);
|
|
762
|
+
}
|
|
763
|
+
/** Stamp last_active_at alone (drive's run end); monotonic like markDriven. */
|
|
764
|
+
touchLastActive(sessionId, at) {
|
|
765
|
+
this.db.prepare(
|
|
766
|
+
`UPDATE sessions SET last_active_at = MAX(COALESCE(last_active_at, ''), ?)
|
|
767
|
+
WHERE session_id = ?`
|
|
768
|
+
).run(at, sessionId);
|
|
769
|
+
}
|
|
839
770
|
findById(sessionId) {
|
|
840
771
|
const r = this.db.prepare("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
|
|
841
772
|
return r ? mapRow4(r) : null;
|
|
@@ -860,6 +791,10 @@ var SessionsRepo = class {
|
|
|
860
791
|
updateApprovalMode(sessionId, mode) {
|
|
861
792
|
this.db.prepare("UPDATE sessions SET approval_mode = ? WHERE session_id = ?").run(mode, sessionId);
|
|
862
793
|
}
|
|
794
|
+
/** Pin the session's thinking level (runs without one of their own then use it). */
|
|
795
|
+
updateThinkingLevel(sessionId, level) {
|
|
796
|
+
this.db.prepare("UPDATE sessions SET thinking_level = ? WHERE session_id = ?").run(level, sessionId);
|
|
797
|
+
}
|
|
863
798
|
updateTitle(sessionId, title) {
|
|
864
799
|
this.db.prepare("UPDATE sessions SET title = ? WHERE session_id = ?").run(title, sessionId);
|
|
865
800
|
}
|
|
@@ -1199,92 +1134,8 @@ var UsageRepo = class {
|
|
|
1199
1134
|
}
|
|
1200
1135
|
};
|
|
1201
1136
|
|
|
1202
|
-
// src/db/repos/users.ts
|
|
1203
|
-
function mapRow5(r) {
|
|
1204
|
-
return {
|
|
1205
|
-
userId: r.user_id,
|
|
1206
|
-
passwordHash: r.password_hash,
|
|
1207
|
-
isAdmin: r.is_admin === 1,
|
|
1208
|
-
passwordIsInitial: r.password_is_initial === 1,
|
|
1209
|
-
createdAt: r.created_at
|
|
1210
|
-
};
|
|
1211
|
-
}
|
|
1212
|
-
var UsersRepo = class {
|
|
1213
|
-
constructor(db) {
|
|
1214
|
-
this.db = db;
|
|
1215
|
-
}
|
|
1216
|
-
db;
|
|
1217
|
-
insert(row) {
|
|
1218
|
-
this.db.prepare(
|
|
1219
|
-
"INSERT INTO users (user_id, password_hash, is_admin, password_is_initial, created_at) VALUES (?, ?, ?, ?, ?)"
|
|
1220
|
-
).run(
|
|
1221
|
-
row.userId,
|
|
1222
|
-
row.passwordHash,
|
|
1223
|
-
row.isAdmin ? 1 : 0,
|
|
1224
|
-
row.passwordIsInitial ? 1 : 0,
|
|
1225
|
-
row.createdAt
|
|
1226
|
-
);
|
|
1227
|
-
}
|
|
1228
|
-
findById(userId) {
|
|
1229
|
-
const r = this.db.prepare("SELECT * FROM users WHERE user_id = ?").get(userId);
|
|
1230
|
-
return r ? mapRow5(r) : null;
|
|
1231
|
-
}
|
|
1232
|
-
/** All users (for the admin user backend), ordered by creation time ascending. */
|
|
1233
|
-
list() {
|
|
1234
|
-
const rows = this.db.prepare("SELECT * FROM users ORDER BY created_at ASC, user_id ASC").all();
|
|
1235
|
-
return rows.map(mapRow5);
|
|
1236
|
-
}
|
|
1237
|
-
count() {
|
|
1238
|
-
const r = this.db.prepare("SELECT COUNT(*) AS n FROM users").get();
|
|
1239
|
-
return r?.n ?? 0;
|
|
1240
|
-
}
|
|
1241
|
-
/** Update the password hash; isInitial marks whether the password was set by someone else (seed / admin). */
|
|
1242
|
-
updatePassword(userId, passwordHash, isInitial) {
|
|
1243
|
-
this.db.prepare("UPDATE users SET password_hash = ?, password_is_initial = ? WHERE user_id = ?").run(passwordHash, isInitial ? 1 : 0, userId);
|
|
1244
|
-
}
|
|
1245
|
-
/** Used by admin user deletion and account-creation compensation paths (owned Projects must be cleaned up first). */
|
|
1246
|
-
delete(userId) {
|
|
1247
|
-
this.db.prepare("DELETE FROM users WHERE user_id = ?").run(userId);
|
|
1248
|
-
}
|
|
1249
|
-
};
|
|
1250
|
-
|
|
1251
1137
|
// src/auth/middleware.ts
|
|
1252
1138
|
import { getCookie } from "hono/cookie";
|
|
1253
|
-
|
|
1254
|
-
// src/http/errors.ts
|
|
1255
|
-
var HttpError = class extends Error {
|
|
1256
|
-
constructor(status, code, message) {
|
|
1257
|
-
super(message);
|
|
1258
|
-
this.status = status;
|
|
1259
|
-
this.code = code;
|
|
1260
|
-
this.name = "HttpError";
|
|
1261
|
-
}
|
|
1262
|
-
status;
|
|
1263
|
-
code;
|
|
1264
|
-
};
|
|
1265
|
-
function errorBody(code, message) {
|
|
1266
|
-
return { error: { code, message } };
|
|
1267
|
-
}
|
|
1268
|
-
function isMissingCredential(err) {
|
|
1269
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1270
|
-
return /missing credentials|api[_ ]?key/i.test(message);
|
|
1271
|
-
}
|
|
1272
|
-
function modelCredentialMissing(modelId) {
|
|
1273
|
-
return new HttpError(
|
|
1274
|
-
400,
|
|
1275
|
-
"model_credential_missing",
|
|
1276
|
-
`Model ${modelId} has no API key yet. Configure it on the Models page first.`
|
|
1277
|
-
);
|
|
1278
|
-
}
|
|
1279
|
-
function handleError(err, c) {
|
|
1280
|
-
if (err instanceof HttpError) {
|
|
1281
|
-
return c.json(errorBody(err.code, err.message), err.status);
|
|
1282
|
-
}
|
|
1283
|
-
console.error(`[server] Unhandled exception: ${err.stack ?? err.message}`);
|
|
1284
|
-
return c.json(errorBody("internal", "Internal server error."), 500);
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
// src/auth/middleware.ts
|
|
1288
1139
|
var SESSION_COOKIE = "penguin_session";
|
|
1289
1140
|
function authMiddleware(auth) {
|
|
1290
1141
|
return async (c, next) => {
|
|
@@ -1299,253 +1150,25 @@ function authMiddleware(auth) {
|
|
|
1299
1150
|
};
|
|
1300
1151
|
}
|
|
1301
1152
|
var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
1153
|
+
var ALLOWED_WRITE_CONTENT_TYPES = [
|
|
1154
|
+
"application/json",
|
|
1155
|
+
"application/gzip",
|
|
1156
|
+
"application/octet-stream"
|
|
1157
|
+
];
|
|
1302
1158
|
var jsonOnlyWrites = async (c, next) => {
|
|
1303
1159
|
if (WRITE_METHODS.has(c.req.method)) {
|
|
1304
|
-
const contentType = c.req.header("content-type");
|
|
1305
|
-
if (contentType && !
|
|
1160
|
+
const contentType = c.req.header("content-type")?.toLowerCase();
|
|
1161
|
+
if (contentType && !ALLOWED_WRITE_CONTENT_TYPES.some((t) => contentType.startsWith(t))) {
|
|
1306
1162
|
throw new HttpError(
|
|
1307
1163
|
415,
|
|
1308
1164
|
"unsupported_media_type",
|
|
1309
|
-
"Write requests only accept application/json."
|
|
1165
|
+
"Write requests only accept application/json (or, for the hot-update web push, a gzip artifact)."
|
|
1310
1166
|
);
|
|
1311
1167
|
}
|
|
1312
1168
|
}
|
|
1313
1169
|
await next();
|
|
1314
1170
|
};
|
|
1315
1171
|
|
|
1316
|
-
// src/auth/service.ts
|
|
1317
|
-
import { createHash, randomBytes as randomBytes2, randomInt } from "crypto";
|
|
1318
|
-
|
|
1319
|
-
// src/auth/password.ts
|
|
1320
|
-
import { randomBytes, scrypt, timingSafeEqual } from "crypto";
|
|
1321
|
-
var SCRYPT_N = 16384;
|
|
1322
|
-
var SCRYPT_R = 8;
|
|
1323
|
-
var SCRYPT_P = 1;
|
|
1324
|
-
var SALT_BYTES = 16;
|
|
1325
|
-
var KEY_BYTES = 64;
|
|
1326
|
-
function scryptAsync(password, salt, keyLen, n, r, p) {
|
|
1327
|
-
return new Promise((resolve, reject) => {
|
|
1328
|
-
scrypt(password, salt, keyLen, { N: n, r, p, maxmem: 128 * 1024 * 1024 }, (err, key) => {
|
|
1329
|
-
if (err) reject(err);
|
|
1330
|
-
else resolve(key);
|
|
1331
|
-
});
|
|
1332
|
-
});
|
|
1333
|
-
}
|
|
1334
|
-
async function hashPassword(password) {
|
|
1335
|
-
const salt = randomBytes(SALT_BYTES);
|
|
1336
|
-
const key = await scryptAsync(password, salt, KEY_BYTES, SCRYPT_N, SCRYPT_R, SCRYPT_P);
|
|
1337
|
-
return [
|
|
1338
|
-
"scrypt",
|
|
1339
|
-
String(SCRYPT_N),
|
|
1340
|
-
String(SCRYPT_R),
|
|
1341
|
-
String(SCRYPT_P),
|
|
1342
|
-
salt.toString("base64"),
|
|
1343
|
-
key.toString("base64")
|
|
1344
|
-
].join("$");
|
|
1345
|
-
}
|
|
1346
|
-
async function verifyPassword(password, stored) {
|
|
1347
|
-
const parts = stored.split("$");
|
|
1348
|
-
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
|
|
1349
|
-
const n = Number(parts[1]);
|
|
1350
|
-
const r = Number(parts[2]);
|
|
1351
|
-
const p = Number(parts[3]);
|
|
1352
|
-
if (!Number.isInteger(n) || !Number.isInteger(r) || !Number.isInteger(p)) return false;
|
|
1353
|
-
let salt;
|
|
1354
|
-
let expected;
|
|
1355
|
-
try {
|
|
1356
|
-
salt = Buffer.from(parts[4], "base64");
|
|
1357
|
-
expected = Buffer.from(parts[5], "base64");
|
|
1358
|
-
} catch {
|
|
1359
|
-
return false;
|
|
1360
|
-
}
|
|
1361
|
-
if (salt.length === 0 || expected.length === 0) return false;
|
|
1362
|
-
let actual;
|
|
1363
|
-
try {
|
|
1364
|
-
actual = await scryptAsync(password, salt, expected.length, n, r, p);
|
|
1365
|
-
} catch {
|
|
1366
|
-
return false;
|
|
1367
|
-
}
|
|
1368
|
-
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
// src/auth/service.ts
|
|
1372
|
-
var MIN_PASSWORD_LENGTH = 8;
|
|
1373
|
-
var ADMIN_USER_ID = "admin";
|
|
1374
|
-
var LOGIN_FREE_ATTEMPTS = 5;
|
|
1375
|
-
var LOGIN_BACKOFF_START_MS = 1e3;
|
|
1376
|
-
var LOGIN_BACKOFF_CAP_MS = 6e4;
|
|
1377
|
-
var LOGIN_FAILURE_IDLE_MS = 15 * 6e4;
|
|
1378
|
-
function generateInitialAdminPassword() {
|
|
1379
|
-
return "penguin-" + String(randomInt(0, 1e4)).padStart(4, "0");
|
|
1380
|
-
}
|
|
1381
|
-
function sha256Hex(value) {
|
|
1382
|
-
return createHash("sha256").update(value).digest("hex");
|
|
1383
|
-
}
|
|
1384
|
-
function toUserInfo(row) {
|
|
1385
|
-
return {
|
|
1386
|
-
userId: row.userId,
|
|
1387
|
-
isAdmin: row.isAdmin,
|
|
1388
|
-
passwordIsInitial: row.passwordIsInitial,
|
|
1389
|
-
createdAt: row.createdAt
|
|
1390
|
-
};
|
|
1391
|
-
}
|
|
1392
|
-
var AuthService = class {
|
|
1393
|
-
constructor(deps2) {
|
|
1394
|
-
this.deps = deps2;
|
|
1395
|
-
this.now = deps2.now ?? (() => /* @__PURE__ */ new Date());
|
|
1396
|
-
}
|
|
1397
|
-
deps;
|
|
1398
|
-
now;
|
|
1399
|
-
/**
|
|
1400
|
-
* Startup seeding (idempotent): creates the built-in admin and adopts
|
|
1401
|
-
* default_project when the users table is empty; if the initial Project fails,
|
|
1402
|
-
* the user row is rolled back and the server retries on next startup.
|
|
1403
|
-
* Returns the initial password when it actually seeded — the caller prints it,
|
|
1404
|
-
* the only place a generated password is ever shown — and null when users
|
|
1405
|
-
* already exist.
|
|
1406
|
-
*/
|
|
1407
|
-
async seedAdmin() {
|
|
1408
|
-
if (this.deps.users.count() > 0) return null;
|
|
1409
|
-
const password = this.deps.seedAdminPassword ?? generateInitialAdminPassword();
|
|
1410
|
-
if (password.length < MIN_PASSWORD_LENGTH) {
|
|
1411
|
-
throw new Error(
|
|
1412
|
-
`PENGUIN_SEED_ADMIN_PASSWORD must be at least ${MIN_PASSWORD_LENGTH} characters.`
|
|
1413
|
-
);
|
|
1414
|
-
}
|
|
1415
|
-
const user = {
|
|
1416
|
-
userId: ADMIN_USER_ID,
|
|
1417
|
-
passwordHash: await hashPassword(password),
|
|
1418
|
-
isAdmin: true,
|
|
1419
|
-
passwordIsInitial: true,
|
|
1420
|
-
createdAt: this.now().toISOString()
|
|
1421
|
-
};
|
|
1422
|
-
this.deps.users.insert(user);
|
|
1423
|
-
try {
|
|
1424
|
-
await this.deps.provisionInitialProject(user, true);
|
|
1425
|
-
} catch (err) {
|
|
1426
|
-
this.deps.users.delete(user.userId);
|
|
1427
|
-
throw err;
|
|
1428
|
-
}
|
|
1429
|
-
return password;
|
|
1430
|
-
}
|
|
1431
|
-
/** Whether the built-in admin still runs on its initial password (drives the startup reminder notice). */
|
|
1432
|
-
adminPasswordIsInitial() {
|
|
1433
|
-
return this.deps.users.findById(ADMIN_USER_ID)?.passwordIsInitial === true;
|
|
1434
|
-
}
|
|
1435
|
-
/** Consecutive login failures per userId (see the throttling comment on the constants). */
|
|
1436
|
-
loginFailures = /* @__PURE__ */ new Map();
|
|
1437
|
-
/** The wait imposed after `failures` consecutive failures (0 while within the free attempts). */
|
|
1438
|
-
loginDelayMs(failures) {
|
|
1439
|
-
const excess = failures - LOGIN_FREE_ATTEMPTS;
|
|
1440
|
-
if (excess <= 0) return 0;
|
|
1441
|
-
return Math.min(LOGIN_BACKOFF_START_MS * 2 ** (excess - 1), LOGIN_BACKOFF_CAP_MS);
|
|
1442
|
-
}
|
|
1443
|
-
async login(userId, password) {
|
|
1444
|
-
const nowMs = this.now().getTime();
|
|
1445
|
-
for (const [key, entry] of this.loginFailures) {
|
|
1446
|
-
if (nowMs - entry.lastFailureAt > LOGIN_FAILURE_IDLE_MS) this.loginFailures.delete(key);
|
|
1447
|
-
}
|
|
1448
|
-
const failed = this.loginFailures.get(userId);
|
|
1449
|
-
if (failed) {
|
|
1450
|
-
const readyAt = failed.lastFailureAt + this.loginDelayMs(failed.failures);
|
|
1451
|
-
if (nowMs < readyAt) {
|
|
1452
|
-
throw new HttpError(
|
|
1453
|
-
429,
|
|
1454
|
-
"too_many_attempts",
|
|
1455
|
-
`Too many failed sign-in attempts. Try again in ${Math.ceil((readyAt - nowMs) / 1e3)}s.`
|
|
1456
|
-
);
|
|
1457
|
-
}
|
|
1458
|
-
}
|
|
1459
|
-
const row = this.deps.users.findById(userId);
|
|
1460
|
-
const ok = row !== null && await verifyPassword(password, row.passwordHash);
|
|
1461
|
-
if (!row || !ok) {
|
|
1462
|
-
this.loginFailures.set(userId, {
|
|
1463
|
-
failures: (failed?.failures ?? 0) + 1,
|
|
1464
|
-
lastFailureAt: this.now().getTime()
|
|
1465
|
-
});
|
|
1466
|
-
throw new HttpError(401, "invalid_credentials", "Incorrect username or password.");
|
|
1467
|
-
}
|
|
1468
|
-
this.loginFailures.delete(userId);
|
|
1469
|
-
this.deps.authSessions.deleteExpired(this.now().toISOString());
|
|
1470
|
-
return { user: toUserInfo(row), token: this.issueSession(row.userId, "password") };
|
|
1471
|
-
}
|
|
1472
|
-
/**
|
|
1473
|
-
* Desktop-mode sign-in: issues an admin session WITHOUT a password check — the caller
|
|
1474
|
-
* (the desktop-login route) has already redeemed the shell's one-shot token, which is
|
|
1475
|
-
* the credential here. Throws if the admin has not been seeded yet (desktop-login runs
|
|
1476
|
-
* after startup seeding, so this only trips on a broken deployment).
|
|
1477
|
-
*/
|
|
1478
|
-
loginDesktop() {
|
|
1479
|
-
const row = this.deps.users.findById(ADMIN_USER_ID);
|
|
1480
|
-
if (!row) {
|
|
1481
|
-
throw new HttpError(500, "internal", "Built-in admin has not been seeded.");
|
|
1482
|
-
}
|
|
1483
|
-
this.deps.authSessions.deleteExpired(this.now().toISOString());
|
|
1484
|
-
return { user: toUserInfo(row), token: this.issueSession(row.userId, "desktop") };
|
|
1485
|
-
}
|
|
1486
|
-
/** Self password change (user settings): validates the old password, and on success clears the initial-password flag; the current session remains valid. */
|
|
1487
|
-
async changePassword(userId, oldPassword, newPassword) {
|
|
1488
|
-
const row = this.deps.users.findById(userId);
|
|
1489
|
-
if (!row || !await verifyPassword(oldPassword, row.passwordHash)) {
|
|
1490
|
-
throw new HttpError(400, "password_mismatch", "Current password is incorrect.");
|
|
1491
|
-
}
|
|
1492
|
-
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
|
1493
|
-
throw new HttpError(400, "invalid_password", "Password must be at least 8 characters.");
|
|
1494
|
-
}
|
|
1495
|
-
this.deps.users.updatePassword(userId, await hashPassword(newPassword), false);
|
|
1496
|
-
this.deps.onPasswordChanged?.(userId);
|
|
1497
|
-
}
|
|
1498
|
-
/**
|
|
1499
|
-
* Desktop-session password set: no old-password check. Only reachable for sessions
|
|
1500
|
-
* established via desktop-login (the me route gates on sessionVia) — the seed password
|
|
1501
|
-
* of a desktop-created root is random and never shown, so its holder has nothing to
|
|
1502
|
-
* type into an old-password field; the shell's token already proved machine ownership.
|
|
1503
|
-
*/
|
|
1504
|
-
async setPasswordDesktop(userId, newPassword) {
|
|
1505
|
-
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
|
1506
|
-
throw new HttpError(400, "invalid_password", "Password must be at least 8 characters.");
|
|
1507
|
-
}
|
|
1508
|
-
this.deps.users.updatePassword(userId, await hashPassword(newPassword), false);
|
|
1509
|
-
this.deps.onPasswordChanged?.(userId);
|
|
1510
|
-
}
|
|
1511
|
-
logout(token) {
|
|
1512
|
-
this.deps.authSessions.delete(sha256Hex(token));
|
|
1513
|
-
}
|
|
1514
|
-
/** Validates the cookie token: returns null if expired/unknown; sliding renewal once less than 6 days remain. */
|
|
1515
|
-
authenticateWithMeta(token) {
|
|
1516
|
-
const tokenHash = sha256Hex(token);
|
|
1517
|
-
const session = this.deps.authSessions.findByTokenHash(tokenHash);
|
|
1518
|
-
if (!session) return null;
|
|
1519
|
-
const now = this.now();
|
|
1520
|
-
const expiresAt = Date.parse(session.expiresAt);
|
|
1521
|
-
if (!(expiresAt > now.getTime())) {
|
|
1522
|
-
this.deps.authSessions.delete(tokenHash);
|
|
1523
|
-
return null;
|
|
1524
|
-
}
|
|
1525
|
-
if (expiresAt - now.getTime() < this.deps.sessionRenewMs) {
|
|
1526
|
-
this.deps.authSessions.touch(
|
|
1527
|
-
tokenHash,
|
|
1528
|
-
new Date(now.getTime() + this.deps.sessionTtlMs).toISOString()
|
|
1529
|
-
);
|
|
1530
|
-
}
|
|
1531
|
-
const user = this.deps.users.findById(session.userId);
|
|
1532
|
-
if (!user) return null;
|
|
1533
|
-
return { user, via: session.via === "desktop" ? "desktop" : "password" };
|
|
1534
|
-
}
|
|
1535
|
-
issueSession(userId, via) {
|
|
1536
|
-
const token = randomBytes2(32).toString("base64url");
|
|
1537
|
-
const now = this.now();
|
|
1538
|
-
this.deps.authSessions.insert({
|
|
1539
|
-
tokenHash: sha256Hex(token),
|
|
1540
|
-
userId,
|
|
1541
|
-
createdAt: now.toISOString(),
|
|
1542
|
-
expiresAt: new Date(now.getTime() + this.deps.sessionTtlMs).toISOString(),
|
|
1543
|
-
via
|
|
1544
|
-
});
|
|
1545
|
-
return token;
|
|
1546
|
-
}
|
|
1547
|
-
};
|
|
1548
|
-
|
|
1549
1172
|
// src/http/routes/admin.ts
|
|
1550
1173
|
import { Hono as Hono2 } from "hono";
|
|
1551
1174
|
|
|
@@ -1749,7 +1372,17 @@ function parseProxyUrl(value) {
|
|
|
1749
1372
|
throw new HttpError(
|
|
1750
1373
|
400,
|
|
1751
1374
|
"invalid_proxy_url",
|
|
1752
|
-
"proxyUrl must be http://host[:port]
|
|
1375
|
+
"proxyUrl must be a proxy URL undici supports \u2014 http(s)://host[:port] or socks5://host[:port] \u2014 or bare host[:port] (empty or null clears it)."
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
function parseAttachmentMb(value, field) {
|
|
1379
|
+
if (typeof value === "number" && Number.isInteger(value)) {
|
|
1380
|
+
if (value >= MIN_ATTACHMENT_MB && value <= MAX_ATTACHMENT_MB) return value;
|
|
1381
|
+
}
|
|
1382
|
+
throw new HttpError(
|
|
1383
|
+
400,
|
|
1384
|
+
"invalid_attachment_limit",
|
|
1385
|
+
`${field} must be a whole number of MB between ${MIN_ATTACHMENT_MB} and ${MAX_ATTACHMENT_MB}.`
|
|
1753
1386
|
);
|
|
1754
1387
|
}
|
|
1755
1388
|
function adminSettingsRoutes(deps2) {
|
|
@@ -1764,7 +1397,8 @@ function adminSettingsRoutes(deps2) {
|
|
|
1764
1397
|
settings: {
|
|
1765
1398
|
proxyForApp: deps2.serverSettingsRepo.getProxyForApp(),
|
|
1766
1399
|
proxyForAgent: deps2.serverSettingsRepo.getProxyForAgent(),
|
|
1767
|
-
proxyUrl: deps2.serverSettingsRepo.getProxyUrl()
|
|
1400
|
+
proxyUrl: deps2.serverSettingsRepo.getProxyUrl(),
|
|
1401
|
+
...deps2.serverSettingsRepo.getAttachmentLimitsMb()
|
|
1768
1402
|
}
|
|
1769
1403
|
});
|
|
1770
1404
|
app2.get("/", (c) => c.json(settings()));
|
|
@@ -1774,9 +1408,25 @@ function adminSettingsRoutes(deps2) {
|
|
|
1774
1408
|
const proxyForAgent = optionalBoolean(body, "proxyForAgent");
|
|
1775
1409
|
const proxyUrlProvided = body.proxyUrl !== void 0;
|
|
1776
1410
|
const proxyUrl = proxyUrlProvided ? parseProxyUrl(body.proxyUrl) : null;
|
|
1411
|
+
const attachmentMaxMb = body.attachmentMaxMb === void 0 ? void 0 : parseAttachmentMb(body.attachmentMaxMb, "attachmentMaxMb");
|
|
1412
|
+
const attachmentTotalMb = body.attachmentTotalMb === void 0 ? void 0 : parseAttachmentMb(body.attachmentTotalMb, "attachmentTotalMb");
|
|
1413
|
+
const stored = deps2.serverSettingsRepo.getAttachmentLimitsMb();
|
|
1414
|
+
const effectiveMax = attachmentMaxMb ?? stored.attachmentMaxMb;
|
|
1415
|
+
const effectiveTotal = attachmentTotalMb ?? stored.attachmentTotalMb;
|
|
1416
|
+
if (effectiveTotal < effectiveMax) {
|
|
1417
|
+
throw new HttpError(
|
|
1418
|
+
400,
|
|
1419
|
+
"invalid_attachment_limit",
|
|
1420
|
+
`attachmentTotalMb (${effectiveTotal}) must not be below attachmentMaxMb (${effectiveMax}).`
|
|
1421
|
+
);
|
|
1422
|
+
}
|
|
1777
1423
|
if (proxyForApp !== void 0) deps2.serverSettingsRepo.setProxyForApp(proxyForApp);
|
|
1778
1424
|
if (proxyForAgent !== void 0) deps2.serverSettingsRepo.setProxyForAgent(proxyForAgent);
|
|
1779
1425
|
if (proxyUrlProvided) deps2.serverSettingsRepo.setProxyUrl(proxyUrl);
|
|
1426
|
+
if (attachmentMaxMb !== void 0) deps2.serverSettingsRepo.setAttachmentMaxMb(attachmentMaxMb);
|
|
1427
|
+
if (attachmentTotalMb !== void 0) {
|
|
1428
|
+
deps2.serverSettingsRepo.setAttachmentTotalMb(attachmentTotalMb);
|
|
1429
|
+
}
|
|
1780
1430
|
applyProxySettings({
|
|
1781
1431
|
proxyForApp: deps2.serverSettingsRepo.getProxyForApp(),
|
|
1782
1432
|
proxyUrl: deps2.serverSettingsRepo.getProxyUrl()
|
|
@@ -1833,12 +1483,12 @@ function authRoutes(deps2) {
|
|
|
1833
1483
|
import { Hono as Hono5 } from "hono";
|
|
1834
1484
|
|
|
1835
1485
|
// src/services/preview-token.ts
|
|
1836
|
-
import { createHmac, randomBytes
|
|
1486
|
+
import { createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
1837
1487
|
var PREVIEW_TOKEN_TTL_MS = 10 * 60 * 1e3;
|
|
1838
1488
|
function b64url(buf) {
|
|
1839
1489
|
return buf.toString("base64url");
|
|
1840
1490
|
}
|
|
1841
|
-
function createPreviewTokenSigner(secret =
|
|
1491
|
+
function createPreviewTokenSigner(secret = randomBytes(32)) {
|
|
1842
1492
|
const mac = (body) => createHmac("sha256", secret).update(body).digest();
|
|
1843
1493
|
return {
|
|
1844
1494
|
sign(payload) {
|
|
@@ -1851,7 +1501,7 @@ function createPreviewTokenSigner(secret = randomBytes3(32)) {
|
|
|
1851
1501
|
const body = token.slice(0, dot);
|
|
1852
1502
|
const provided = Buffer.from(token.slice(dot + 1), "base64url");
|
|
1853
1503
|
const expected = mac(body);
|
|
1854
|
-
if (provided.length !== expected.length || !
|
|
1504
|
+
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) return null;
|
|
1855
1505
|
let payload;
|
|
1856
1506
|
try {
|
|
1857
1507
|
payload = JSON.parse(
|
|
@@ -1937,7 +1587,14 @@ function meRoutes(deps2) {
|
|
|
1937
1587
|
user: toUserInfo(c.var.user),
|
|
1938
1588
|
previewIsolated: target !== null,
|
|
1939
1589
|
desktopMode: deps2.desktop !== null,
|
|
1940
|
-
sessionVia: c.var.sessionVia
|
|
1590
|
+
sessionVia: c.var.sessionVia,
|
|
1591
|
+
uploadLimits: {
|
|
1592
|
+
...deps2.serverSettingsRepo.getAttachmentLimitsMb(),
|
|
1593
|
+
attachmentMaxCount: MAX_ATTACHMENT_COUNT,
|
|
1594
|
+
imageMaxMb: INLINE_IMAGE_MAX_MB,
|
|
1595
|
+
attachmentLimitMinMb: MIN_ATTACHMENT_MB,
|
|
1596
|
+
attachmentLimitMaxMb: MAX_ATTACHMENT_MB
|
|
1597
|
+
}
|
|
1941
1598
|
});
|
|
1942
1599
|
});
|
|
1943
1600
|
app2.put("/password", async (c) => {
|
|
@@ -2123,6 +1780,154 @@ function membersRoutes(deps2) {
|
|
|
2123
1780
|
|
|
2124
1781
|
// src/http/routes/models.ts
|
|
2125
1782
|
import { Hono as Hono9 } from "hono";
|
|
1783
|
+
|
|
1784
|
+
// src/services/protocol-detect.ts
|
|
1785
|
+
import { resolveModelEnv } from "@prismshadow/penguin-core";
|
|
1786
|
+
var PROBE_TIMEOUT_MS = 5e3;
|
|
1787
|
+
function bearerHeaders(apiKey) {
|
|
1788
|
+
return {
|
|
1789
|
+
"content-type": "application/json",
|
|
1790
|
+
...apiKey ? { authorization: `Bearer ${apiKey}` } : {}
|
|
1791
|
+
};
|
|
1792
|
+
}
|
|
1793
|
+
function anthropicHeaders(apiKey) {
|
|
1794
|
+
return {
|
|
1795
|
+
"content-type": "application/json",
|
|
1796
|
+
"anthropic-version": "2023-06-01",
|
|
1797
|
+
...apiKey ? { authorization: `Bearer ${apiKey}`, "x-api-key": apiKey } : {}
|
|
1798
|
+
};
|
|
1799
|
+
}
|
|
1800
|
+
var PROTOCOL_PROBES = [
|
|
1801
|
+
{ clientType: "openai-responses", path: "/responses", headers: bearerHeaders },
|
|
1802
|
+
{ clientType: "ant-messages", path: "/v1/messages", headers: anthropicHeaders },
|
|
1803
|
+
{ clientType: "openai-chat", path: "/chat/completions", headers: bearerHeaders }
|
|
1804
|
+
];
|
|
1805
|
+
function envApiKeyForProtocol(clientType, env = process.env) {
|
|
1806
|
+
const envKey = resolveModelEnv("", clientType)?.envKey;
|
|
1807
|
+
if (!envKey) return void 0;
|
|
1808
|
+
return env[envKey]?.trim() || void 0;
|
|
1809
|
+
}
|
|
1810
|
+
function isHttpUrl(value) {
|
|
1811
|
+
try {
|
|
1812
|
+
const u = new URL(value);
|
|
1813
|
+
return u.protocol === "http:" || u.protocol === "https:";
|
|
1814
|
+
} catch {
|
|
1815
|
+
return false;
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
function probeUrl(baseUrl, path20) {
|
|
1819
|
+
return baseUrl.trim().replace(/\/+$/, "") + path20;
|
|
1820
|
+
}
|
|
1821
|
+
function isApiErrorShape(body) {
|
|
1822
|
+
const err = body.error;
|
|
1823
|
+
if (err !== null && typeof err === "object") return true;
|
|
1824
|
+
if (typeof err === "string" && err !== "") return true;
|
|
1825
|
+
if (body.type === "error") return true;
|
|
1826
|
+
if (body.object === "error") return true;
|
|
1827
|
+
if (typeof body.message === "string" && body.message !== "") return true;
|
|
1828
|
+
const detail = body.detail;
|
|
1829
|
+
if (typeof detail === "string") return detail !== "";
|
|
1830
|
+
if (detail !== null && (typeof detail === "object" || typeof detail === "number")) return true;
|
|
1831
|
+
return false;
|
|
1832
|
+
}
|
|
1833
|
+
function isSuccessShape(clientType, body) {
|
|
1834
|
+
switch (clientType) {
|
|
1835
|
+
case "openai-responses":
|
|
1836
|
+
return body.object === "response" || Array.isArray(body.output);
|
|
1837
|
+
case "ant-messages":
|
|
1838
|
+
return body.type === "message" || Array.isArray(body.content) && "role" in body;
|
|
1839
|
+
case "openai-chat":
|
|
1840
|
+
return body.object === "chat.completion" || Array.isArray(body.choices);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
function classifyProbeResponse(clientType, status, bodyText) {
|
|
1844
|
+
if (status === 404 || status === 405) return "route_missing";
|
|
1845
|
+
let parsed;
|
|
1846
|
+
try {
|
|
1847
|
+
parsed = JSON.parse(bodyText);
|
|
1848
|
+
} catch {
|
|
1849
|
+
return "junk";
|
|
1850
|
+
}
|
|
1851
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return "junk";
|
|
1852
|
+
const body = parsed;
|
|
1853
|
+
if (status >= 200 && status < 300) return isSuccessShape(clientType, body) ? "served" : "junk";
|
|
1854
|
+
if (status >= 500) return "server_error";
|
|
1855
|
+
return isApiErrorShape(body) ? "served" : "junk";
|
|
1856
|
+
}
|
|
1857
|
+
function isTimeoutError(err) {
|
|
1858
|
+
return err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
|
|
1859
|
+
}
|
|
1860
|
+
var MAX_PROBE_BODY_BYTES = 64 * 1024;
|
|
1861
|
+
async function readCappedBody(res) {
|
|
1862
|
+
const body = res.body;
|
|
1863
|
+
if (!body) return { text: await res.text(), truncated: false };
|
|
1864
|
+
const reader = body.getReader();
|
|
1865
|
+
const chunks = [];
|
|
1866
|
+
let size = 0;
|
|
1867
|
+
let truncated = false;
|
|
1868
|
+
try {
|
|
1869
|
+
for (; ; ) {
|
|
1870
|
+
const { done, value } = await reader.read();
|
|
1871
|
+
if (done) break;
|
|
1872
|
+
if (!value) continue;
|
|
1873
|
+
size += value.byteLength;
|
|
1874
|
+
if (size > MAX_PROBE_BODY_BYTES) {
|
|
1875
|
+
truncated = true;
|
|
1876
|
+
break;
|
|
1877
|
+
}
|
|
1878
|
+
chunks.push(value);
|
|
1879
|
+
}
|
|
1880
|
+
} finally {
|
|
1881
|
+
await reader.cancel().catch(() => {
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1884
|
+
return { text: truncated ? "" : Buffer.concat(chunks).toString("utf8"), truncated };
|
|
1885
|
+
}
|
|
1886
|
+
async function runProbe(spec, baseUrl, apiKey, fetchImpl, timeoutMs) {
|
|
1887
|
+
const url = probeUrl(baseUrl, spec.path);
|
|
1888
|
+
try {
|
|
1889
|
+
const res = await fetchImpl(url, {
|
|
1890
|
+
method: "POST",
|
|
1891
|
+
headers: spec.headers(apiKey),
|
|
1892
|
+
body: "{}",
|
|
1893
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
1894
|
+
// Redirects stay followed (fetch's default). It is tempting to pin `redirect: "manual"`
|
|
1895
|
+
// so a base URL cannot bounce the server somewhere it never named, but the SDKs the
|
|
1896
|
+
// real clients wrap follow redirects too — pinning it would report "no protocol" for
|
|
1897
|
+
// the ordinary case of a base URL that 301s http -> https, i.e. break the invariant
|
|
1898
|
+
// that a detected protocol is one that actually works after saving. The probe reply is
|
|
1899
|
+
// reduced to an outcome enum plus a status and is never echoed, so a followed redirect
|
|
1900
|
+
// leaks no response content.
|
|
1901
|
+
});
|
|
1902
|
+
const { text, truncated } = await readCappedBody(res);
|
|
1903
|
+
return {
|
|
1904
|
+
clientType: spec.clientType,
|
|
1905
|
+
url,
|
|
1906
|
+
outcome: truncated ? "junk" : classifyProbeResponse(spec.clientType, res.status, text),
|
|
1907
|
+
status: res.status
|
|
1908
|
+
};
|
|
1909
|
+
} catch (err) {
|
|
1910
|
+
return {
|
|
1911
|
+
clientType: spec.clientType,
|
|
1912
|
+
url,
|
|
1913
|
+
outcome: isTimeoutError(err) ? "timeout" : "network_error"
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
async function detectModelProtocol(options) {
|
|
1918
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
1919
|
+
const timeoutMs = options.timeoutMs ?? PROBE_TIMEOUT_MS;
|
|
1920
|
+
const probes = [];
|
|
1921
|
+
for (const spec of PROTOCOL_PROBES) {
|
|
1922
|
+
const apiKey = options.apiKey ?? envApiKeyForProtocol(spec.clientType, options.env);
|
|
1923
|
+
const probe = await runProbe(spec, options.baseUrl, apiKey, fetchImpl, timeoutMs);
|
|
1924
|
+
probes.push(probe);
|
|
1925
|
+
if (probe.outcome === "served") return { detected: spec.clientType, probes };
|
|
1926
|
+
}
|
|
1927
|
+
return { probes };
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// src/http/routes/models.ts
|
|
2126
1931
|
function publishCredentialsUpdated(deps2, projectId) {
|
|
2127
1932
|
const event = { type: "credentials_updated" };
|
|
2128
1933
|
for (const row of deps2.sessionsRepo.listByProject(projectId)) {
|
|
@@ -2191,6 +1996,12 @@ function parseModelsUpdate(body) {
|
|
|
2191
1996
|
}
|
|
2192
1997
|
entry.maxTokens = m.maxTokens;
|
|
2193
1998
|
}
|
|
1999
|
+
if (m.fastMode !== void 0) {
|
|
2000
|
+
if (typeof m.fastMode !== "boolean") {
|
|
2001
|
+
throw badRequest(`models[${i}].fastMode must be a boolean.`);
|
|
2002
|
+
}
|
|
2003
|
+
entry.fastMode = m.fastMode;
|
|
2004
|
+
}
|
|
2194
2005
|
if (m.pricing !== void 0) {
|
|
2195
2006
|
const p = m.pricing;
|
|
2196
2007
|
if (p === null || typeof p !== "object" || Array.isArray(p)) {
|
|
@@ -2253,18 +2064,76 @@ function modelsRoutes(deps2) {
|
|
|
2253
2064
|
publishCredentialsUpdated(deps2, projectId);
|
|
2254
2065
|
return c.json(res);
|
|
2255
2066
|
});
|
|
2256
|
-
app2.put("/default", async (c) => {
|
|
2067
|
+
app2.put("/default", async (c) => {
|
|
2068
|
+
const projectId = requireValidId(c, "projectId");
|
|
2069
|
+
deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
|
2070
|
+
const body = await readJson(c);
|
|
2071
|
+
const ref = {
|
|
2072
|
+
provider: requireString(body, "provider", { minLen: 1, maxLen: 64 }),
|
|
2073
|
+
modelId: requireString(body, "modelId", { minLen: 1, maxLen: 200 })
|
|
2074
|
+
};
|
|
2075
|
+
const defaultModel = await deps2.projectConfigService.setDefaultModelRef(projectId, ref);
|
|
2076
|
+
return c.json({ defaultModel });
|
|
2077
|
+
});
|
|
2078
|
+
app2.post("/test", async (c) => {
|
|
2079
|
+
const projectId = requireValidId(c, "projectId");
|
|
2080
|
+
deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
|
2081
|
+
const body = await readJson(c);
|
|
2082
|
+
const req = {
|
|
2083
|
+
provider: requireString(body, "provider", { minLen: 1, maxLen: 64 }),
|
|
2084
|
+
modelId: requireString(body, "modelId", { minLen: 1, maxLen: 200 })
|
|
2085
|
+
};
|
|
2086
|
+
if (body.apiKey !== void 0) {
|
|
2087
|
+
if (typeof body.apiKey !== "string") throw badRequest("apiKey must be a string.");
|
|
2088
|
+
if (body.apiKey) req.apiKey = body.apiKey;
|
|
2089
|
+
}
|
|
2090
|
+
if (body.clearApiKey !== void 0) {
|
|
2091
|
+
if (typeof body.clearApiKey !== "boolean") throw badRequest("clearApiKey must be a boolean.");
|
|
2092
|
+
req.clearApiKey = body.clearApiKey;
|
|
2093
|
+
}
|
|
2094
|
+
if (body.speed !== void 0) {
|
|
2095
|
+
if (typeof body.speed !== "boolean") throw badRequest("speed must be a boolean.");
|
|
2096
|
+
req.speed = body.speed;
|
|
2097
|
+
}
|
|
2098
|
+
if (body.baseUrl !== void 0) {
|
|
2099
|
+
if (body.baseUrl !== null && typeof body.baseUrl !== "string") {
|
|
2100
|
+
throw badRequest("baseUrl must be a string or null.");
|
|
2101
|
+
}
|
|
2102
|
+
req.baseUrl = body.baseUrl ? body.baseUrl : null;
|
|
2103
|
+
}
|
|
2104
|
+
if (body.clientType !== void 0) {
|
|
2105
|
+
if (typeof body.clientType !== "string") throw badRequest("clientType must be a string.");
|
|
2106
|
+
if (body.clientType) req.clientType = body.clientType;
|
|
2107
|
+
}
|
|
2108
|
+
if (body.fastMode !== void 0) {
|
|
2109
|
+
if (typeof body.fastMode !== "boolean") throw badRequest("fastMode must be a boolean.");
|
|
2110
|
+
req.fastMode = body.fastMode;
|
|
2111
|
+
}
|
|
2112
|
+
return c.json(await deps2.projectConfigService.testModel(projectId, req));
|
|
2113
|
+
});
|
|
2114
|
+
app2.post("/detect", async (c) => {
|
|
2257
2115
|
const projectId = requireValidId(c, "projectId");
|
|
2258
2116
|
deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
|
2259
2117
|
const body = await readJson(c);
|
|
2260
|
-
const
|
|
2261
|
-
|
|
2262
|
-
modelId: requireString(body, "modelId", { minLen: 1, maxLen: 200 })
|
|
2118
|
+
const req = {
|
|
2119
|
+
baseUrl: requireString(body, "baseUrl", { minLen: 1, maxLen: 2e3 })
|
|
2263
2120
|
};
|
|
2264
|
-
|
|
2265
|
-
|
|
2121
|
+
if (!isHttpUrl(req.baseUrl)) throw badRequest("baseUrl must be an absolute http(s) URL.");
|
|
2122
|
+
if (body.apiKey !== void 0) {
|
|
2123
|
+
if (typeof body.apiKey !== "string") throw badRequest("apiKey must be a string.");
|
|
2124
|
+
if (body.apiKey) req.apiKey = body.apiKey;
|
|
2125
|
+
}
|
|
2126
|
+
if (body.clearApiKey !== void 0) {
|
|
2127
|
+
if (typeof body.clearApiKey !== "boolean") throw badRequest("clearApiKey must be a boolean.");
|
|
2128
|
+
req.clearApiKey = body.clearApiKey;
|
|
2129
|
+
}
|
|
2130
|
+
if (body.provider !== void 0 || body.modelId !== void 0) {
|
|
2131
|
+
req.provider = requireString(body, "provider", { minLen: 1, maxLen: 64 });
|
|
2132
|
+
req.modelId = requireString(body, "modelId", { minLen: 1, maxLen: 200 });
|
|
2133
|
+
}
|
|
2134
|
+
return c.json(await deps2.projectConfigService.detectProtocol(projectId, req));
|
|
2266
2135
|
});
|
|
2267
|
-
app2.post("/
|
|
2136
|
+
app2.post("/detect-vision", async (c) => {
|
|
2268
2137
|
const projectId = requireValidId(c, "projectId");
|
|
2269
2138
|
deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
|
2270
2139
|
const body = await readJson(c);
|
|
@@ -2280,21 +2149,17 @@ function modelsRoutes(deps2) {
|
|
|
2280
2149
|
if (typeof body.clearApiKey !== "boolean") throw badRequest("clearApiKey must be a boolean.");
|
|
2281
2150
|
req.clearApiKey = body.clearApiKey;
|
|
2282
2151
|
}
|
|
2283
|
-
if (body.speed !== void 0) {
|
|
2284
|
-
if (typeof body.speed !== "boolean") throw badRequest("speed must be a boolean.");
|
|
2285
|
-
req.speed = body.speed;
|
|
2286
|
-
}
|
|
2287
2152
|
if (body.baseUrl !== void 0) {
|
|
2288
2153
|
if (body.baseUrl !== null && typeof body.baseUrl !== "string") {
|
|
2289
2154
|
throw badRequest("baseUrl must be a string or null.");
|
|
2290
2155
|
}
|
|
2291
|
-
req.baseUrl = body.baseUrl
|
|
2156
|
+
req.baseUrl = body.baseUrl;
|
|
2292
2157
|
}
|
|
2293
2158
|
if (body.clientType !== void 0) {
|
|
2294
2159
|
if (typeof body.clientType !== "string") throw badRequest("clientType must be a string.");
|
|
2295
2160
|
if (body.clientType) req.clientType = body.clientType;
|
|
2296
2161
|
}
|
|
2297
|
-
return c.json(await deps2.projectConfigService.
|
|
2162
|
+
return c.json(await deps2.projectConfigService.detectVision(projectId, req));
|
|
2298
2163
|
});
|
|
2299
2164
|
return app2;
|
|
2300
2165
|
}
|
|
@@ -2305,7 +2170,7 @@ import { DEFAULT_CHAT_THINKING_LEVELS, isValidId as isValidId2 } from "@prismsha
|
|
|
2305
2170
|
|
|
2306
2171
|
// src/http/routes/sessions.ts
|
|
2307
2172
|
import fs4 from "fs/promises";
|
|
2308
|
-
import
|
|
2173
|
+
import path3 from "path";
|
|
2309
2174
|
import { Hono as Hono10 } from "hono";
|
|
2310
2175
|
import { imageUrlMessage, scratchpadDir, userText } from "@prismshadow/penguin-core";
|
|
2311
2176
|
|
|
@@ -2551,7 +2416,7 @@ async function assertWorkspaceAllowed(args) {
|
|
|
2551
2416
|
// src/services/workspace-files-service.ts
|
|
2552
2417
|
import fs2 from "fs/promises";
|
|
2553
2418
|
import { constants as fsc } from "fs";
|
|
2554
|
-
import
|
|
2419
|
+
import path from "path";
|
|
2555
2420
|
var MAX_READ_BYTES = 50 * 1024 * 1024;
|
|
2556
2421
|
var MAX_UPLOAD_BYTES = 14 * 1024 * 1024;
|
|
2557
2422
|
var CONTENT_TYPES = {
|
|
@@ -2583,7 +2448,7 @@ var WorkspaceFilesService = class {
|
|
|
2583
2448
|
/** Canonical path (realpath) of the Workspace root; 404 if it doesn't exist. */
|
|
2584
2449
|
async realBase(workspace) {
|
|
2585
2450
|
try {
|
|
2586
|
-
return await fs2.realpath(
|
|
2451
|
+
return await fs2.realpath(path.resolve(workspace));
|
|
2587
2452
|
} catch {
|
|
2588
2453
|
throw new HttpError(404, "workspace_missing", "This Session's Workspace no longer exists.");
|
|
2589
2454
|
}
|
|
@@ -2596,13 +2461,13 @@ var WorkspaceFilesService = class {
|
|
|
2596
2461
|
* compared, so a legitimate name like "..foo" isn't mistakenly rejected.
|
|
2597
2462
|
*/
|
|
2598
2463
|
isInside(target, base) {
|
|
2599
|
-
const rel =
|
|
2600
|
-
return rel !== ".." && !rel.startsWith(`..${
|
|
2464
|
+
const rel = path.relative(base, target);
|
|
2465
|
+
return rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
|
|
2601
2466
|
}
|
|
2602
2467
|
/** Lexical prefix check (a relative path, once resolved, must still be inside the Workspace); returns the absolute target path. */
|
|
2603
2468
|
lexicalTarget(base, rel) {
|
|
2604
2469
|
if (rel.includes("\0")) throw badRequest("path is invalid.");
|
|
2605
|
-
const target =
|
|
2470
|
+
const target = path.resolve(base, rel === "" ? "." : rel);
|
|
2606
2471
|
if (!this.isInside(target, base)) {
|
|
2607
2472
|
throw badRequest("path must be inside the Workspace.");
|
|
2608
2473
|
}
|
|
@@ -2622,7 +2487,7 @@ var WorkspaceFilesService = class {
|
|
|
2622
2487
|
*/
|
|
2623
2488
|
async resolveRead(workspace, rel) {
|
|
2624
2489
|
const realBase = await this.realBase(workspace);
|
|
2625
|
-
const target = this.lexicalTarget(
|
|
2490
|
+
const target = this.lexicalTarget(path.resolve(workspace), rel);
|
|
2626
2491
|
let canonical;
|
|
2627
2492
|
try {
|
|
2628
2493
|
canonical = await fs2.realpath(target);
|
|
@@ -2651,10 +2516,10 @@ var WorkspaceFilesService = class {
|
|
|
2651
2516
|
*/
|
|
2652
2517
|
async resolveWriteParent(workspace, rel) {
|
|
2653
2518
|
const realBase = await this.realBase(workspace);
|
|
2654
|
-
const target = this.lexicalTarget(
|
|
2655
|
-
const name =
|
|
2519
|
+
const target = this.lexicalTarget(path.resolve(workspace), rel);
|
|
2520
|
+
const name = path.basename(target);
|
|
2656
2521
|
if (name === "" || name === "." || name === "..") throw badRequest("path must be a file path.");
|
|
2657
|
-
const parent =
|
|
2522
|
+
const parent = path.dirname(target);
|
|
2658
2523
|
let canonicalParent;
|
|
2659
2524
|
try {
|
|
2660
2525
|
canonicalParent = await fs2.realpath(parent);
|
|
@@ -2667,7 +2532,7 @@ var WorkspaceFilesService = class {
|
|
|
2667
2532
|
break;
|
|
2668
2533
|
} catch (probeErr) {
|
|
2669
2534
|
if (probeErr.code !== "ENOENT") throw probeErr;
|
|
2670
|
-
const up =
|
|
2535
|
+
const up = path.dirname(probe);
|
|
2671
2536
|
if (up === probe) throw badRequest("path is invalid.");
|
|
2672
2537
|
probe = up;
|
|
2673
2538
|
}
|
|
@@ -2723,7 +2588,7 @@ var WorkspaceFilesService = class {
|
|
|
2723
2588
|
const listed = await Promise.all(
|
|
2724
2589
|
dirents.map(async (d) => {
|
|
2725
2590
|
try {
|
|
2726
|
-
const canonical = await fs2.realpath(
|
|
2591
|
+
const canonical = await fs2.realpath(path.join(dir, d.name));
|
|
2727
2592
|
this.assertInside(canonical, realBase);
|
|
2728
2593
|
const stat = await fs2.stat(canonical);
|
|
2729
2594
|
return {
|
|
@@ -2775,10 +2640,10 @@ var WorkspaceFilesService = class {
|
|
|
2775
2640
|
} else {
|
|
2776
2641
|
data = await fs2.readFile(file);
|
|
2777
2642
|
}
|
|
2778
|
-
const ext =
|
|
2643
|
+
const ext = path.extname(file).toLowerCase();
|
|
2779
2644
|
return {
|
|
2780
2645
|
data,
|
|
2781
|
-
fileName:
|
|
2646
|
+
fileName: path.basename(file),
|
|
2782
2647
|
contentType: CONTENT_TYPES[ext] ?? "application/octet-stream",
|
|
2783
2648
|
scriptable: ext === ".html" || ext === ".htm" || ext === ".svg",
|
|
2784
2649
|
...truncated ? { truncated: true } : {}
|
|
@@ -2798,7 +2663,7 @@ var WorkspaceFilesService = class {
|
|
|
2798
2663
|
throw new HttpError(413, "file_too_large", "Uploaded file exceeds the 14MB limit.");
|
|
2799
2664
|
}
|
|
2800
2665
|
const { dir, name } = await this.resolveWriteParent(workspace, rel);
|
|
2801
|
-
const file =
|
|
2666
|
+
const file = path.join(dir, name);
|
|
2802
2667
|
if (process.platform === "win32") {
|
|
2803
2668
|
const st = await fs2.lstat(file).catch(() => null);
|
|
2804
2669
|
if (st?.isSymbolicLink()) throw badRequest("path must not be a symlink.");
|
|
@@ -2825,16 +2690,13 @@ var WorkspaceFilesService = class {
|
|
|
2825
2690
|
|
|
2826
2691
|
// src/services/task-attachments.ts
|
|
2827
2692
|
import fs3 from "fs/promises";
|
|
2828
|
-
import
|
|
2829
|
-
import { randomBytes as
|
|
2693
|
+
import path2 from "path";
|
|
2694
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
2830
2695
|
import {
|
|
2831
2696
|
appendAttachmentLines,
|
|
2832
2697
|
attachedFileLine,
|
|
2833
2698
|
modelVisiblePath
|
|
2834
2699
|
} from "@prismshadow/penguin-core";
|
|
2835
|
-
var MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
2836
|
-
var MAX_ATTACHMENT_COUNT = 20;
|
|
2837
|
-
var MAX_TOTAL_ATTACHMENT_BYTES = 12 * 1024 * 1024;
|
|
2838
2700
|
var MAX_STEM_BYTES = 80;
|
|
2839
2701
|
var WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
|
2840
2702
|
var INVISIBLE_CHAR = new RegExp("\\p{C}", "u");
|
|
@@ -2855,7 +2717,7 @@ function truncateBytes(value, maxBytes) {
|
|
|
2855
2717
|
return out;
|
|
2856
2718
|
}
|
|
2857
2719
|
var MAX_NAME_ATTEMPTS = 16;
|
|
2858
|
-
function parseAttachmentPart(part, index, field = "input") {
|
|
2720
|
+
function parseAttachmentPart(part, index, limits, field = "input") {
|
|
2859
2721
|
const fileName = part.fileName;
|
|
2860
2722
|
if (typeof fileName !== "string" || fileName.length === 0 || fileName.includes("/") || fileName.includes("\\") || fileName.includes("..") || fileName.includes("\0")) {
|
|
2861
2723
|
throw badRequest(
|
|
@@ -2863,55 +2725,69 @@ function parseAttachmentPart(part, index, field = "input") {
|
|
|
2863
2725
|
);
|
|
2864
2726
|
}
|
|
2865
2727
|
const dataUrl = part.dataUrl;
|
|
2866
|
-
const match = typeof dataUrl === "string" ? /^data:[^,]
|
|
2728
|
+
const match = typeof dataUrl === "string" ? /^data:([^,]*);base64,([A-Za-z0-9+/=\s]+)$/.exec(dataUrl) : null;
|
|
2867
2729
|
if (!match) {
|
|
2868
2730
|
throw badRequest(`${field}[${index}].dataUrl must be a base64 data: URL of the file's bytes.`);
|
|
2869
2731
|
}
|
|
2870
|
-
const bytes = Buffer.from(match[
|
|
2732
|
+
const bytes = Buffer.from(match[2], "base64");
|
|
2871
2733
|
if (bytes.length === 0) {
|
|
2872
2734
|
throw badRequest(`${field}[${index}].dataUrl decodes to an empty file.`);
|
|
2873
2735
|
}
|
|
2874
|
-
if (bytes.length >
|
|
2736
|
+
if (bytes.length > limits.maxBytes) {
|
|
2875
2737
|
throw new HttpError(
|
|
2876
2738
|
413,
|
|
2877
2739
|
"file_too_large",
|
|
2878
|
-
`Attached file exceeds the ${
|
|
2740
|
+
`Attached file exceeds the ${limits.maxBytes / (1024 * 1024)}MB limit.`
|
|
2879
2741
|
);
|
|
2880
2742
|
}
|
|
2881
|
-
return { fileName, bytes };
|
|
2743
|
+
return { fileName, bytes, mime: match[1] };
|
|
2882
2744
|
}
|
|
2883
|
-
function
|
|
2884
|
-
|
|
2745
|
+
async function readRecalledFiles(files) {
|
|
2746
|
+
const read = await Promise.all(
|
|
2747
|
+
files.map(async (f) => {
|
|
2748
|
+
try {
|
|
2749
|
+
const bytes = await fs3.readFile(f.path);
|
|
2750
|
+
const mime = f.mime || "application/octet-stream";
|
|
2751
|
+
return { fileName: f.fileName, dataUrl: `data:${mime};base64,${bytes.toString("base64")}` };
|
|
2752
|
+
} catch {
|
|
2753
|
+
return null;
|
|
2754
|
+
}
|
|
2755
|
+
})
|
|
2756
|
+
);
|
|
2757
|
+
return read.filter((f) => f !== null);
|
|
2758
|
+
}
|
|
2759
|
+
function assertAttachmentBudget(attachments, limits) {
|
|
2760
|
+
if (attachments.length > limits.maxCount) {
|
|
2885
2761
|
throw new HttpError(
|
|
2886
2762
|
413,
|
|
2887
2763
|
"too_many_files",
|
|
2888
|
-
`A message may carry at most ${
|
|
2764
|
+
`A message may carry at most ${limits.maxCount} attached files.`
|
|
2889
2765
|
);
|
|
2890
2766
|
}
|
|
2891
2767
|
let total = 0;
|
|
2892
2768
|
for (const a of attachments) total += a.bytes.length;
|
|
2893
|
-
if (total >
|
|
2769
|
+
if (total > limits.totalBytes) {
|
|
2894
2770
|
throw new HttpError(
|
|
2895
2771
|
413,
|
|
2896
2772
|
"payload_too_large",
|
|
2897
|
-
`Attached files exceed the ${
|
|
2773
|
+
`Attached files exceed the ${limits.totalBytes / (1024 * 1024)}MB total limit for one message.`
|
|
2898
2774
|
);
|
|
2899
2775
|
}
|
|
2900
2776
|
}
|
|
2901
2777
|
function scratchpadName(fileName) {
|
|
2902
|
-
const ext = sanitizeSegment(
|
|
2903
|
-
const rawStem = fileName.slice(0, fileName.length -
|
|
2778
|
+
const ext = sanitizeSegment(path2.extname(fileName));
|
|
2779
|
+
const rawStem = fileName.slice(0, fileName.length - path2.extname(fileName).length);
|
|
2904
2780
|
const stem = truncateBytes(sanitizeSegment(rawStem), MAX_STEM_BYTES).replace(/[. ]+$/, "");
|
|
2905
2781
|
if (!stem || /^-+$/.test(stem)) return `file${ext}`;
|
|
2906
2782
|
return WINDOWS_RESERVED.test(stem) ? `_${stem}${ext}` : `${stem}${ext}`;
|
|
2907
2783
|
}
|
|
2908
2784
|
async function writeAttachment(dir, attachment) {
|
|
2909
2785
|
const base = scratchpadName(attachment.fileName);
|
|
2910
|
-
const ext =
|
|
2786
|
+
const ext = path2.extname(base);
|
|
2911
2787
|
const stem = base.slice(0, base.length - ext.length);
|
|
2912
2788
|
for (let attempt = 0; attempt < MAX_NAME_ATTEMPTS; attempt++) {
|
|
2913
|
-
const name = attempt === 0 ? base : `${stem}-${
|
|
2914
|
-
const file =
|
|
2789
|
+
const name = attempt === 0 ? base : `${stem}-${randomBytes2(3).toString("hex")}${ext}`;
|
|
2790
|
+
const file = path2.join(dir, name);
|
|
2915
2791
|
try {
|
|
2916
2792
|
await fs3.writeFile(file, attachment.bytes, { flag: "wx" });
|
|
2917
2793
|
return file;
|
|
@@ -2924,10 +2800,10 @@ async function writeAttachment(dir, attachment) {
|
|
|
2924
2800
|
);
|
|
2925
2801
|
}
|
|
2926
2802
|
async function openScratchpadDir(root, sessionId) {
|
|
2927
|
-
const dir =
|
|
2803
|
+
const dir = path2.join(root, sessionId);
|
|
2928
2804
|
await fs3.mkdir(dir, { recursive: true });
|
|
2929
2805
|
const canonicalRoot = await fs3.realpath(root);
|
|
2930
|
-
const rel =
|
|
2806
|
+
const rel = path2.relative(canonicalRoot, await fs3.realpath(dir));
|
|
2931
2807
|
if (rel !== sessionId) {
|
|
2932
2808
|
throw new Error(
|
|
2933
2809
|
`session scratchpad ${dir} resolves outside the agent scratchpad root; refusing to write attachments`
|
|
@@ -2970,7 +2846,14 @@ var APPROVAL_MODES = [
|
|
|
2970
2846
|
"read-only",
|
|
2971
2847
|
"always-ask"
|
|
2972
2848
|
];
|
|
2973
|
-
var THINKING_LEVELS = [
|
|
2849
|
+
var THINKING_LEVELS = [
|
|
2850
|
+
"none",
|
|
2851
|
+
"low",
|
|
2852
|
+
"medium",
|
|
2853
|
+
"high",
|
|
2854
|
+
"xhigh",
|
|
2855
|
+
"max"
|
|
2856
|
+
];
|
|
2974
2857
|
var MESSAGES_PAGE_LIMIT_MAX = 1e3;
|
|
2975
2858
|
var MESSAGES_PAGE_LIMIT_DEFAULT = 200;
|
|
2976
2859
|
function pageLimit(raw, name) {
|
|
@@ -2983,8 +2866,12 @@ function pageLimit(raw, name) {
|
|
|
2983
2866
|
}
|
|
2984
2867
|
function appendPendingInputs(messages, pending) {
|
|
2985
2868
|
if (pending.length === 0) return messages;
|
|
2986
|
-
const
|
|
2987
|
-
|
|
2869
|
+
const envelopeKey = (message) => {
|
|
2870
|
+
const { tracePosition: _tracePosition, ...envelope } = message;
|
|
2871
|
+
return JSON.stringify(envelope);
|
|
2872
|
+
};
|
|
2873
|
+
const tail = new Set(messages.slice(-50).map(envelopeKey));
|
|
2874
|
+
const missing = pending.filter((message) => !tail.has(envelopeKey(message)));
|
|
2988
2875
|
return missing.length > 0 ? [...messages, ...missing] : messages;
|
|
2989
2876
|
}
|
|
2990
2877
|
function messagesPageQuery(c) {
|
|
@@ -3020,28 +2907,44 @@ var IMAGE_DATA_URL = /^data:[^;,]+;base64,[A-Za-z0-9+/=\s]+$/;
|
|
|
3020
2907
|
function requireImageUrl(url, field) {
|
|
3021
2908
|
if (typeof url === "string") {
|
|
3022
2909
|
if (url.startsWith("http://") || url.startsWith("https://")) return url;
|
|
3023
|
-
if (IMAGE_DATA_URL.test(url))
|
|
2910
|
+
if (IMAGE_DATA_URL.test(url)) {
|
|
2911
|
+
const bytes = dataUrlByteLength(url);
|
|
2912
|
+
if (bytes > INLINE_IMAGE_MAX_BYTES) {
|
|
2913
|
+
throw new HttpError(
|
|
2914
|
+
413,
|
|
2915
|
+
"image_too_large",
|
|
2916
|
+
`${field} exceeds the ${INLINE_IMAGE_MAX_MB}MB inline image limit.`
|
|
2917
|
+
);
|
|
2918
|
+
}
|
|
2919
|
+
return url;
|
|
2920
|
+
}
|
|
3024
2921
|
}
|
|
3025
2922
|
throw badRequest(
|
|
3026
2923
|
`${field} must be an http(s) URL or a base64 data: URL (data:<mime>;base64,<bytes>).`
|
|
3027
2924
|
);
|
|
3028
2925
|
}
|
|
2926
|
+
function dataUrlByteLength(dataUrl) {
|
|
2927
|
+
const payload = dataUrl.slice(dataUrl.indexOf(",") + 1).replace(/[=\s]+/g, "");
|
|
2928
|
+
return Math.floor(payload.length * 3 / 4);
|
|
2929
|
+
}
|
|
3029
2930
|
function resolveScratchpadFile(dir, fileName) {
|
|
3030
2931
|
if (!fileName || fileName === "." || fileName === "..") return null;
|
|
3031
2932
|
for (const ch of fileName) {
|
|
3032
2933
|
const code = ch.codePointAt(0);
|
|
3033
2934
|
if (code < 32 || code === 127 || ch === "/" || ch === "\\") return null;
|
|
3034
2935
|
}
|
|
3035
|
-
const resolved =
|
|
3036
|
-
return
|
|
2936
|
+
const resolved = path3.resolve(dir, fileName);
|
|
2937
|
+
return path3.dirname(resolved) === path3.resolve(dir) ? resolved : null;
|
|
3037
2938
|
}
|
|
3038
|
-
function parseTaskInput(body) {
|
|
2939
|
+
function parseTaskInput(body, limits) {
|
|
3039
2940
|
const input = body.input;
|
|
3040
2941
|
if (!Array.isArray(input) || input.length === 0) {
|
|
3041
2942
|
throw badRequest("input must be an array with at least one item.");
|
|
3042
2943
|
}
|
|
3043
2944
|
const messages = [];
|
|
3044
2945
|
const attachments = [];
|
|
2946
|
+
const texts = [];
|
|
2947
|
+
const images = [];
|
|
3045
2948
|
input.forEach((item, i) => {
|
|
3046
2949
|
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
|
3047
2950
|
throw badRequest(`input[${i}] must be an object.`);
|
|
@@ -3052,20 +2955,44 @@ function parseTaskInput(body) {
|
|
|
3052
2955
|
throw badRequest(`input[${i}].text must be a non-empty string.`);
|
|
3053
2956
|
}
|
|
3054
2957
|
messages.push(userText(part.text));
|
|
2958
|
+
texts.push(part.text);
|
|
3055
2959
|
return;
|
|
3056
2960
|
}
|
|
3057
2961
|
if (part.type === "image_url") {
|
|
3058
|
-
|
|
2962
|
+
const url = requireImageUrl(part.imageUrl, `input[${i}].imageUrl`);
|
|
2963
|
+
messages.push(imageUrlMessage(url));
|
|
2964
|
+
images.push(url);
|
|
3059
2965
|
return;
|
|
3060
2966
|
}
|
|
3061
2967
|
if (part.type === "file") {
|
|
3062
|
-
attachments.push(parseAttachmentPart(part, i));
|
|
3063
|
-
assertAttachmentBudget(attachments);
|
|
2968
|
+
attachments.push(parseAttachmentPart(part, i, limits));
|
|
2969
|
+
assertAttachmentBudget(attachments, limits);
|
|
3064
2970
|
return;
|
|
3065
2971
|
}
|
|
3066
2972
|
throw badRequest(`input[${i}].type must be one of text / image_url / file.`);
|
|
3067
2973
|
});
|
|
3068
|
-
return { messages, attachments };
|
|
2974
|
+
return { messages, attachments, texts, images };
|
|
2975
|
+
}
|
|
2976
|
+
function recallStore(text, images, attachments, written) {
|
|
2977
|
+
return {
|
|
2978
|
+
text,
|
|
2979
|
+
images,
|
|
2980
|
+
files: written.map((p, i) => ({
|
|
2981
|
+
fileName: attachments[i].fileName,
|
|
2982
|
+
path: p,
|
|
2983
|
+
mime: attachments[i].mime
|
|
2984
|
+
}))
|
|
2985
|
+
};
|
|
2986
|
+
}
|
|
2987
|
+
async function recalledResponse(recall, thinkingLevel) {
|
|
2988
|
+
const files = await readRecalledFiles(recall.files);
|
|
2989
|
+
await removeAttachments(recall.files.map((f) => f.path));
|
|
2990
|
+
return {
|
|
2991
|
+
text: recall.text,
|
|
2992
|
+
images: recall.images,
|
|
2993
|
+
files,
|
|
2994
|
+
...thinkingLevel !== void 0 ? { thinkingLevel } : {}
|
|
2995
|
+
};
|
|
3069
2996
|
}
|
|
3070
2997
|
function parseSteerImages(body) {
|
|
3071
2998
|
const images = body.images;
|
|
@@ -3073,7 +3000,7 @@ function parseSteerImages(body) {
|
|
|
3073
3000
|
if (!Array.isArray(images)) throw badRequest("images must be an array.");
|
|
3074
3001
|
return images.map((url, i) => requireImageUrl(url, `images[${i}]`));
|
|
3075
3002
|
}
|
|
3076
|
-
function parseSteerFiles(body) {
|
|
3003
|
+
function parseSteerFiles(body, limits) {
|
|
3077
3004
|
const files = body.files;
|
|
3078
3005
|
if (files === void 0) return [];
|
|
3079
3006
|
if (!Array.isArray(files)) throw badRequest("files must be an array.");
|
|
@@ -3082,8 +3009,8 @@ function parseSteerFiles(body) {
|
|
|
3082
3009
|
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
|
3083
3010
|
throw badRequest(`files[${i}] must be an object.`);
|
|
3084
3011
|
}
|
|
3085
|
-
attachments.push(parseAttachmentPart(item, i, "files"));
|
|
3086
|
-
assertAttachmentBudget(attachments);
|
|
3012
|
+
attachments.push(parseAttachmentPart(item, i, limits, "files"));
|
|
3013
|
+
assertAttachmentBudget(attachments, limits);
|
|
3087
3014
|
});
|
|
3088
3015
|
return attachments;
|
|
3089
3016
|
}
|
|
@@ -3197,6 +3124,7 @@ function sessionsRoutes(deps2) {
|
|
|
3197
3124
|
const row = resolveSession(c);
|
|
3198
3125
|
const body = await readJson(c);
|
|
3199
3126
|
const approvalMode = optionalEnum(body, "approvalMode", APPROVAL_MODES);
|
|
3127
|
+
const thinkingLevel = optionalEnum(body, "thinkingLevel", THINKING_LEVELS);
|
|
3200
3128
|
const archivedRaw = body.archived;
|
|
3201
3129
|
const archived = typeof archivedRaw === "boolean" ? archivedRaw : void 0;
|
|
3202
3130
|
const titleRaw = body.title;
|
|
@@ -3214,11 +3142,11 @@ function sessionsRoutes(deps2) {
|
|
|
3214
3142
|
);
|
|
3215
3143
|
}
|
|
3216
3144
|
}
|
|
3217
|
-
if (approvalMode === void 0 && archived === void 0 && title === void 0) {
|
|
3145
|
+
if (approvalMode === void 0 && thinkingLevel === void 0 && archived === void 0 && title === void 0) {
|
|
3218
3146
|
throw new HttpError(
|
|
3219
3147
|
400,
|
|
3220
3148
|
"no_update",
|
|
3221
|
-
"No updatable field provided (approvalMode / archived / title)."
|
|
3149
|
+
"No updatable field provided (approvalMode / thinkingLevel / archived / title)."
|
|
3222
3150
|
);
|
|
3223
3151
|
}
|
|
3224
3152
|
let updated = { ...row };
|
|
@@ -3230,16 +3158,73 @@ function sessionsRoutes(deps2) {
|
|
|
3230
3158
|
deps2.sessionsRepo.updateApprovalMode(row.sessionId, approvalMode);
|
|
3231
3159
|
updated = { ...updated, approvalMode };
|
|
3232
3160
|
}
|
|
3161
|
+
if (thinkingLevel !== void 0) {
|
|
3162
|
+
deps2.sessionsRepo.updateThinkingLevel(row.sessionId, thinkingLevel);
|
|
3163
|
+
updated = { ...updated, thinkingLevel };
|
|
3164
|
+
}
|
|
3233
3165
|
if (archived !== void 0) {
|
|
3234
3166
|
const at = archived ? (/* @__PURE__ */ new Date()).toISOString() : null;
|
|
3235
3167
|
deps2.sessionsRepo.setArchived(row.sessionId, at);
|
|
3236
3168
|
updated = { ...updated, archivedAt: at };
|
|
3237
3169
|
}
|
|
3238
3170
|
const hasTrace = await deps2.sessionService.hasTrace(updated);
|
|
3171
|
+
const fresh = deps2.sessionsRepo.findById(row.sessionId) ?? updated;
|
|
3239
3172
|
return c.json({
|
|
3240
|
-
session: await deps2.sessionService.toInfo(
|
|
3173
|
+
session: await deps2.sessionService.toInfo(fresh, hasTrace)
|
|
3241
3174
|
});
|
|
3242
3175
|
});
|
|
3176
|
+
app2.post("/:sessionId/fork", async (c) => {
|
|
3177
|
+
const row = resolveSession(c);
|
|
3178
|
+
const body = await readJson(c);
|
|
3179
|
+
const raw = body.position;
|
|
3180
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
3181
|
+
throw badRequest("position must be a Trace position object.");
|
|
3182
|
+
}
|
|
3183
|
+
const { fileIndex, ordinal } = raw;
|
|
3184
|
+
if (!Number.isSafeInteger(fileIndex) || fileIndex < 1 || !Number.isSafeInteger(ordinal) || ordinal < 0) {
|
|
3185
|
+
throw badRequest("position.fileIndex must be positive and position.ordinal non-negative.");
|
|
3186
|
+
}
|
|
3187
|
+
const fork = await deps2.manager.atIdleBoundary(
|
|
3188
|
+
row.sessionId,
|
|
3189
|
+
() => deps2.traceService.forkSessionTrace(row.projectId, row.agentId, row.sessionId, {
|
|
3190
|
+
fileIndex,
|
|
3191
|
+
ordinal
|
|
3192
|
+
})
|
|
3193
|
+
);
|
|
3194
|
+
const forkRow = {
|
|
3195
|
+
sessionId: fork.sessionId,
|
|
3196
|
+
projectId: row.projectId,
|
|
3197
|
+
agentId: row.agentId,
|
|
3198
|
+
provider: row.provider,
|
|
3199
|
+
modelId: row.modelId,
|
|
3200
|
+
workspace: row.workspace,
|
|
3201
|
+
approvalMode: row.approvalMode,
|
|
3202
|
+
// insertFork replaces this with the source's current title plus its persistent number.
|
|
3203
|
+
title: null,
|
|
3204
|
+
client: "web",
|
|
3205
|
+
hasTrace: true,
|
|
3206
|
+
lastActiveAt: fork.createdAt,
|
|
3207
|
+
createdAt: fork.createdAt
|
|
3208
|
+
};
|
|
3209
|
+
try {
|
|
3210
|
+
const insertedForkRow = deps2.sessionsRepo.insertFork(row.sessionId, forkRow);
|
|
3211
|
+
deps2.sessionSources.set(fork.sessionId, null);
|
|
3212
|
+
return c.json(
|
|
3213
|
+
{
|
|
3214
|
+
session: await deps2.sessionService.toInfo(insertedForkRow, true)
|
|
3215
|
+
},
|
|
3216
|
+
201
|
|
3217
|
+
);
|
|
3218
|
+
} catch (err) {
|
|
3219
|
+
deps2.sessionsRepo.deleteById(fork.sessionId);
|
|
3220
|
+
await deps2.traceService.deleteSessionTraces(row.projectId, row.agentId, fork.sessionId);
|
|
3221
|
+
await fs4.rm(
|
|
3222
|
+
path3.join(scratchpadDir(deps2.config.root, row.projectId, row.agentId), fork.sessionId),
|
|
3223
|
+
{ recursive: true, force: true }
|
|
3224
|
+
);
|
|
3225
|
+
throw err;
|
|
3226
|
+
}
|
|
3227
|
+
});
|
|
3243
3228
|
app2.delete("/:sessionId", async (c) => {
|
|
3244
3229
|
const row = resolveSession(c);
|
|
3245
3230
|
const runnings = deps2.manager.beginSessionDeletion(row.sessionId);
|
|
@@ -3252,7 +3237,7 @@ function sessionsRoutes(deps2) {
|
|
|
3252
3237
|
}
|
|
3253
3238
|
await deps2.traceService.deleteSessionTraces(row.projectId, row.agentId, row.sessionId);
|
|
3254
3239
|
await fs4.rm(
|
|
3255
|
-
|
|
3240
|
+
path3.join(scratchpadDir(deps2.config.root, row.projectId, row.agentId), row.sessionId),
|
|
3256
3241
|
{ recursive: true, force: true }
|
|
3257
3242
|
);
|
|
3258
3243
|
deps2.sessionsRepo.deleteById(row.sessionId);
|
|
@@ -3267,7 +3252,7 @@ function sessionsRoutes(deps2) {
|
|
|
3267
3252
|
const row = resolveSession(c);
|
|
3268
3253
|
const fileName = c.req.param("fileName") ?? "";
|
|
3269
3254
|
const filePath = resolveScratchpadFile(
|
|
3270
|
-
|
|
3255
|
+
path3.join(scratchpadDir(deps2.config.root, row.projectId, row.agentId), row.sessionId),
|
|
3271
3256
|
fileName
|
|
3272
3257
|
);
|
|
3273
3258
|
if (!filePath) throw new HttpError(404, "file_not_found", "File does not exist.");
|
|
@@ -3284,7 +3269,7 @@ function sessionsRoutes(deps2) {
|
|
|
3284
3269
|
".gif": "image/gif",
|
|
3285
3270
|
".webp": "image/webp"
|
|
3286
3271
|
};
|
|
3287
|
-
const mime = MIME_BY_EXT[
|
|
3272
|
+
const mime = MIME_BY_EXT[path3.extname(fileName).toLowerCase()];
|
|
3288
3273
|
return c.body(new Uint8Array(bytes), 200, {
|
|
3289
3274
|
"content-type": mime ?? "application/octet-stream",
|
|
3290
3275
|
// nosniff: the composer's file attachments land in this same directory, so the bytes
|
|
@@ -3356,14 +3341,16 @@ function sessionsRoutes(deps2) {
|
|
|
3356
3341
|
const row = resolveSession(c);
|
|
3357
3342
|
const channel = deps2.channels.get(row.sessionId);
|
|
3358
3343
|
const pendingSteering = deps2.manager.pendingSteeringOf(row.sessionId);
|
|
3344
|
+
const pendingFollowUps = deps2.manager.pendingFollowUpsOf(row.sessionId);
|
|
3359
3345
|
const initialEvents = [
|
|
3360
3346
|
{
|
|
3361
3347
|
type: "task_state",
|
|
3362
3348
|
state: deps2.manager.statusOf(row.sessionId),
|
|
3363
3349
|
queued: deps2.manager.pendingFollowUpCount(row.sessionId),
|
|
3364
|
-
// Undelivered steering
|
|
3365
|
-
//
|
|
3366
|
-
...pendingSteering.length > 0 ? { pendingSteering } : {}
|
|
3350
|
+
// Undelivered steering and queued follow-ups ride the snapshot too, so the composer's
|
|
3351
|
+
// queued hints (and what they say) survive a reload.
|
|
3352
|
+
...pendingSteering.length > 0 ? { pendingSteering } : {},
|
|
3353
|
+
...pendingFollowUps.length > 0 ? { pendingFollowUps } : {}
|
|
3367
3354
|
},
|
|
3368
3355
|
...deps2.manager.pendingApprovals(row.sessionId).map((p) => ({
|
|
3369
3356
|
type: "approval_request",
|
|
@@ -3378,8 +3365,9 @@ function sessionsRoutes(deps2) {
|
|
|
3378
3365
|
const body = await readJson(c);
|
|
3379
3366
|
const goal = parseGoalField(body);
|
|
3380
3367
|
const thinkingLevel = optionalEnum(body, "thinkingLevel", THINKING_LEVELS);
|
|
3368
|
+
const limits = toAttachmentLimits(deps2.serverSettingsRepo.getAttachmentLimitsMb());
|
|
3381
3369
|
if (goal) {
|
|
3382
|
-
const { messages, attachments } = parseTaskInput(body);
|
|
3370
|
+
const { messages, attachments } = parseTaskInput(body, limits);
|
|
3383
3371
|
const text = messages.filter((m) => m.payload.type === "text").map((m) => m.payload.text).join("\n").trim();
|
|
3384
3372
|
if (!text) {
|
|
3385
3373
|
throw badRequest("goal mode requires a non-empty text objective.");
|
|
@@ -3394,7 +3382,7 @@ function sessionsRoutes(deps2) {
|
|
|
3394
3382
|
});
|
|
3395
3383
|
return c.json({ sessionId }, 202);
|
|
3396
3384
|
}
|
|
3397
|
-
const parsed = parseTaskInput(body);
|
|
3385
|
+
const parsed = parseTaskInput(body, limits);
|
|
3398
3386
|
const queueIfBusy = body.queueIfBusy === true;
|
|
3399
3387
|
deps2.manager.assertCanAcceptTask(row.sessionId, { queueIfBusy });
|
|
3400
3388
|
const { input, written } = await attachFilesToInput(
|
|
@@ -3406,7 +3394,10 @@ function sessionsRoutes(deps2) {
|
|
|
3406
3394
|
try {
|
|
3407
3395
|
const { sessionId, queued } = await deps2.manager.startTask(row.sessionId, input, {
|
|
3408
3396
|
...thinkingLevel !== void 0 ? { thinkingLevel } : {},
|
|
3409
|
-
queueIfBusy
|
|
3397
|
+
queueIfBusy,
|
|
3398
|
+
// Original content, kept while the input waits in the follow-up queue so a recall
|
|
3399
|
+
// (DELETE /follow-ups/:id) can hand it back; unused when the task starts directly.
|
|
3400
|
+
recall: recallStore(parsed.texts.join("\n"), parsed.images, parsed.attachments, written)
|
|
3410
3401
|
});
|
|
3411
3402
|
return c.json({ sessionId, queued }, 202);
|
|
3412
3403
|
} catch (err) {
|
|
@@ -3419,7 +3410,10 @@ function sessionsRoutes(deps2) {
|
|
|
3419
3410
|
const body = await readJson(c);
|
|
3420
3411
|
const text = typeof body.text === "string" ? body.text.trim() : "";
|
|
3421
3412
|
const images = parseSteerImages(body);
|
|
3422
|
-
const files = parseSteerFiles(
|
|
3413
|
+
const files = parseSteerFiles(
|
|
3414
|
+
body,
|
|
3415
|
+
toAttachmentLimits(deps2.serverSettingsRepo.getAttachmentLimitsMb())
|
|
3416
|
+
);
|
|
3423
3417
|
if (!text && images.length === 0 && files.length === 0) {
|
|
3424
3418
|
throw badRequest("text, images or files must carry the steering message.");
|
|
3425
3419
|
}
|
|
@@ -3430,17 +3424,28 @@ function sessionsRoutes(deps2) {
|
|
|
3430
3424
|
row.sessionId
|
|
3431
3425
|
);
|
|
3432
3426
|
try {
|
|
3433
|
-
deps2.manager.steer(row.sessionId, input,
|
|
3434
|
-
text,
|
|
3435
|
-
images: images.length,
|
|
3436
|
-
files: files.length
|
|
3437
|
-
});
|
|
3427
|
+
deps2.manager.steer(row.sessionId, input, recallStore(text, images, files, written));
|
|
3438
3428
|
} catch (err) {
|
|
3439
3429
|
await removeAttachments(written);
|
|
3440
3430
|
throw err;
|
|
3441
3431
|
}
|
|
3442
3432
|
return c.body(null, 202);
|
|
3443
3433
|
});
|
|
3434
|
+
app2.delete("/:sessionId/steer/:steerId", async (c) => {
|
|
3435
|
+
const row = resolveSession(c);
|
|
3436
|
+
const recall = deps2.manager.recallSteering(row.sessionId, pathParam(c, "steerId"));
|
|
3437
|
+
return c.json(await recalledResponse(recall));
|
|
3438
|
+
});
|
|
3439
|
+
app2.delete("/:sessionId/follow-ups/:followUpId", async (c) => {
|
|
3440
|
+
const row = resolveSession(c);
|
|
3441
|
+
const { recall, thinkingLevel } = deps2.manager.recallFollowUp(
|
|
3442
|
+
row.sessionId,
|
|
3443
|
+
pathParam(c, "followUpId")
|
|
3444
|
+
);
|
|
3445
|
+
return c.json(
|
|
3446
|
+
await recalledResponse(recall, thinkingLevel)
|
|
3447
|
+
);
|
|
3448
|
+
});
|
|
3444
3449
|
app2.get("/:sessionId/goal", (c) => {
|
|
3445
3450
|
const row = resolveSession(c);
|
|
3446
3451
|
const g = deps2.goalsRepo.latestForSession(row.sessionId);
|
|
@@ -3498,6 +3503,25 @@ function sessionsRoutes(deps2) {
|
|
|
3498
3503
|
}
|
|
3499
3504
|
return c.body(null, 204);
|
|
3500
3505
|
});
|
|
3506
|
+
app2.delete("/:sessionId/processes/:processId", (c) => {
|
|
3507
|
+
const row = resolveSession(c);
|
|
3508
|
+
const result = deps2.manager.removeProcess(row.sessionId, pathParam(c, "processId"));
|
|
3509
|
+
if (result === "running") {
|
|
3510
|
+
throw new HttpError(
|
|
3511
|
+
409,
|
|
3512
|
+
"process_running",
|
|
3513
|
+
"Process is still running; stop it instead of removing it."
|
|
3514
|
+
);
|
|
3515
|
+
}
|
|
3516
|
+
if (result === "not_found") {
|
|
3517
|
+
throw new HttpError(
|
|
3518
|
+
404,
|
|
3519
|
+
"process_not_found",
|
|
3520
|
+
"Process does not exist or has already been removed."
|
|
3521
|
+
);
|
|
3522
|
+
}
|
|
3523
|
+
return c.body(null, 204);
|
|
3524
|
+
});
|
|
3501
3525
|
app2.post("/:sessionId/retry-now", (c) => {
|
|
3502
3526
|
const row = resolveSession(c);
|
|
3503
3527
|
const skipped = deps2.manager.retryNow(row.sessionId);
|
|
@@ -3745,7 +3769,7 @@ function memoryRoutes(deps2) {
|
|
|
3745
3769
|
}
|
|
3746
3770
|
|
|
3747
3771
|
// src/http/routes/schedules.ts
|
|
3748
|
-
import { createHash
|
|
3772
|
+
import { createHash } from "crypto";
|
|
3749
3773
|
import { Hono as Hono14 } from "hono";
|
|
3750
3774
|
import { isValidId as isValidId3 } from "@prismshadow/penguin-core";
|
|
3751
3775
|
|
|
@@ -3891,7 +3915,7 @@ function nextSlotAfter(def, nowMs) {
|
|
|
3891
3915
|
|
|
3892
3916
|
// src/runtime/schedule-store.ts
|
|
3893
3917
|
import fs6 from "fs/promises";
|
|
3894
|
-
import
|
|
3918
|
+
import path4 from "path";
|
|
3895
3919
|
import { stringify as stringifyToml } from "smol-toml";
|
|
3896
3920
|
import { resolveModelRef, scheduleDir } from "@prismshadow/penguin-core";
|
|
3897
3921
|
|
|
@@ -3988,7 +4012,7 @@ var ScheduleFileCache = class {
|
|
|
3988
4012
|
}
|
|
3989
4013
|
/** Reuses the cached parse while the file's stat still matches; re-reads otherwise. */
|
|
3990
4014
|
async revalidate(dir, name, cached) {
|
|
3991
|
-
const file =
|
|
4015
|
+
const file = path4.join(dir, `${name}.toml`);
|
|
3992
4016
|
let stat;
|
|
3993
4017
|
try {
|
|
3994
4018
|
stat = await fs6.stat(file);
|
|
@@ -4011,7 +4035,7 @@ var ScheduleFileCache = class {
|
|
|
4011
4035
|
}
|
|
4012
4036
|
};
|
|
4013
4037
|
async function readScheduleFile(root, projectId, agentId, name) {
|
|
4014
|
-
const file =
|
|
4038
|
+
const file = path4.join(scheduleDir(root, projectId, agentId), `${name}.toml`);
|
|
4015
4039
|
try {
|
|
4016
4040
|
const raw = await fs6.readFile(file, "utf8");
|
|
4017
4041
|
return { name, raw, parsed: parseScheduleFile(name, raw) };
|
|
@@ -4050,10 +4074,10 @@ async function validateScheduleModelRef(configs, projectId, def) {
|
|
|
4050
4074
|
async function writeScheduleFile(root, projectId, agentId, name, raw) {
|
|
4051
4075
|
const dir = scheduleDir(root, projectId, agentId);
|
|
4052
4076
|
await fs6.mkdir(dir, { recursive: true });
|
|
4053
|
-
await fs6.writeFile(
|
|
4077
|
+
await fs6.writeFile(path4.join(dir, `${name}.toml`), raw, "utf8");
|
|
4054
4078
|
}
|
|
4055
4079
|
async function deleteScheduleFile(root, projectId, agentId, name) {
|
|
4056
|
-
const file =
|
|
4080
|
+
const file = path4.join(scheduleDir(root, projectId, agentId), `${name}.toml`);
|
|
4057
4081
|
try {
|
|
4058
4082
|
await fs6.unlink(file);
|
|
4059
4083
|
return true;
|
|
@@ -4212,7 +4236,7 @@ async function upsert(deps2, userId, projectId, agentId, name, body) {
|
|
|
4212
4236
|
agentId,
|
|
4213
4237
|
name,
|
|
4214
4238
|
startAtMs: parsed.def.startAtMs,
|
|
4215
|
-
defHash:
|
|
4239
|
+
defHash: createHash("sha1").update(raw).digest("hex"),
|
|
4216
4240
|
creatorUserId: userId
|
|
4217
4241
|
});
|
|
4218
4242
|
await deps2.scheduler.reconcileAgent(projectId, agentId);
|
|
@@ -4305,7 +4329,7 @@ function benchmarksRoutes(deps2) {
|
|
|
4305
4329
|
|
|
4306
4330
|
// src/http/routes/skills.ts
|
|
4307
4331
|
import fs7 from "fs/promises";
|
|
4308
|
-
import
|
|
4332
|
+
import path5 from "path";
|
|
4309
4333
|
import { unzipSync, strFromU8, zipSync } from "fflate";
|
|
4310
4334
|
import { Hono as Hono16 } from "hono";
|
|
4311
4335
|
import {
|
|
@@ -4408,7 +4432,7 @@ async function collectSkillArchive(dir, name) {
|
|
|
4408
4432
|
let total = 0;
|
|
4409
4433
|
const walk = async (abs, rel) => {
|
|
4410
4434
|
for (const entry of await fs7.readdir(abs, { withFileTypes: true })) {
|
|
4411
|
-
const absChild =
|
|
4435
|
+
const absChild = path5.join(abs, entry.name);
|
|
4412
4436
|
const relChild = `${rel}/${entry.name}`;
|
|
4413
4437
|
if (entry.isDirectory()) {
|
|
4414
4438
|
await walk(absChild, relChild);
|
|
@@ -4516,9 +4540,9 @@ function agentSkillsRoutes(deps2) {
|
|
|
4516
4540
|
throw badRequest("The zip archive exceeds the 14MB limit.");
|
|
4517
4541
|
}
|
|
4518
4542
|
const skill = parseSkillArchive(archive);
|
|
4519
|
-
const dir =
|
|
4543
|
+
const dir = path5.join(skillsDir(deps2.config.root, projectId, agentId), skill.name);
|
|
4520
4544
|
if (!overwrite) {
|
|
4521
|
-
const installed2 = await fs7.access(
|
|
4545
|
+
const installed2 = await fs7.access(path5.join(dir, "SKILL.md")).then(
|
|
4522
4546
|
() => true,
|
|
4523
4547
|
() => false
|
|
4524
4548
|
);
|
|
@@ -4528,8 +4552,8 @@ function agentSkillsRoutes(deps2) {
|
|
|
4528
4552
|
}
|
|
4529
4553
|
await fs7.rm(dir, { recursive: true, force: true });
|
|
4530
4554
|
for (const [rel, data] of skill.files) {
|
|
4531
|
-
const file =
|
|
4532
|
-
await fs7.mkdir(
|
|
4555
|
+
const file = path5.join(dir, rel);
|
|
4556
|
+
await fs7.mkdir(path5.dirname(file), { recursive: true });
|
|
4533
4557
|
await fs7.writeFile(file, data);
|
|
4534
4558
|
}
|
|
4535
4559
|
return c.json(await listResponse(projectId, agentId), 201);
|
|
@@ -4539,9 +4563,9 @@ function agentSkillsRoutes(deps2) {
|
|
|
4539
4563
|
const agentId = requireValidId(c, "agentId");
|
|
4540
4564
|
deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
4541
4565
|
const name = requireValidId(c, "name");
|
|
4542
|
-
const dir =
|
|
4566
|
+
const dir = path5.join(skillsDir(deps2.config.root, projectId, agentId), name);
|
|
4543
4567
|
try {
|
|
4544
|
-
await fs7.access(
|
|
4568
|
+
await fs7.access(path5.join(dir, "SKILL.md"));
|
|
4545
4569
|
} catch {
|
|
4546
4570
|
throw new HttpError(404, "not_found", `Skill is not installed: ${name}`);
|
|
4547
4571
|
}
|
|
@@ -4563,7 +4587,7 @@ function agentSkillsRoutes(deps2) {
|
|
|
4563
4587
|
const agentId = requireValidId(c, "agentId");
|
|
4564
4588
|
deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
4565
4589
|
const name = requireValidId(c, "name");
|
|
4566
|
-
const file =
|
|
4590
|
+
const file = path5.join(skillsDir(deps2.config.root, projectId, agentId), name, "SKILL.md");
|
|
4567
4591
|
try {
|
|
4568
4592
|
await fs7.access(file);
|
|
4569
4593
|
} catch {
|
|
@@ -4699,7 +4723,7 @@ function agentsRoutes(deps2) {
|
|
|
4699
4723
|
// src/http/routes/dirs.ts
|
|
4700
4724
|
import fs9 from "fs/promises";
|
|
4701
4725
|
import os from "os";
|
|
4702
|
-
import
|
|
4726
|
+
import path6 from "path";
|
|
4703
4727
|
import { Hono as Hono19 } from "hono";
|
|
4704
4728
|
function dirsRoutes(deps2) {
|
|
4705
4729
|
const app2 = new Hono19();
|
|
@@ -4708,7 +4732,7 @@ function dirsRoutes(deps2) {
|
|
|
4708
4732
|
deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
|
4709
4733
|
const raw = c.req.query("path");
|
|
4710
4734
|
const target = raw && raw.trim() ? raw.trim() : os.homedir();
|
|
4711
|
-
if (!
|
|
4735
|
+
if (!path6.isAbsolute(target)) {
|
|
4712
4736
|
throw new HttpError(400, "dir_not_absolute", "Directory must be an absolute path.");
|
|
4713
4737
|
}
|
|
4714
4738
|
let real;
|
|
@@ -4731,8 +4755,8 @@ function dirsRoutes(deps2) {
|
|
|
4731
4755
|
} catch {
|
|
4732
4756
|
dirents = [];
|
|
4733
4757
|
}
|
|
4734
|
-
const entries = dirents.filter((d) => d.isDirectory()).map((d) => ({ name: d.name, path:
|
|
4735
|
-
const parent =
|
|
4758
|
+
const entries = dirents.filter((d) => d.isDirectory()).map((d) => ({ name: d.name, path: path6.join(real, d.name) })).sort((a, b) => a.name.localeCompare(b.name));
|
|
4759
|
+
const parent = path6.dirname(real);
|
|
4736
4760
|
return c.json({
|
|
4737
4761
|
path: real,
|
|
4738
4762
|
parent: parent === real ? null : parent,
|
|
@@ -5206,6 +5230,17 @@ var ChannelHub = class {
|
|
|
5206
5230
|
peek(key) {
|
|
5207
5231
|
return this.channels.get(key);
|
|
5208
5232
|
}
|
|
5233
|
+
/**
|
|
5234
|
+
* Publishes to every live channel whose key starts with `prefix` — global
|
|
5235
|
+
* notices (e.g. `user:` for "the web assets were hot-swapped, reload").
|
|
5236
|
+
* Only reaches channels that exist (i.e. someone connected at least once);
|
|
5237
|
+
* that is the right scope for a notice that only matters to live clients.
|
|
5238
|
+
*/
|
|
5239
|
+
broadcast(prefix, data, event) {
|
|
5240
|
+
for (const [key, ch] of this.channels) {
|
|
5241
|
+
if (key.startsWith(prefix)) ch.publish(data, event);
|
|
5242
|
+
}
|
|
5243
|
+
}
|
|
5209
5244
|
/** Reclaim idle channels (skips active Sessions: no reclaim even without a publish while awaiting approval); `now` is injectable for tests. */
|
|
5210
5245
|
sweep(now = Date.now()) {
|
|
5211
5246
|
for (const [key, ch] of this.channels) {
|
|
@@ -5294,8 +5329,9 @@ var ErrorRecorder = class {
|
|
|
5294
5329
|
};
|
|
5295
5330
|
|
|
5296
5331
|
// src/runtime/session-manager.ts
|
|
5332
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
5297
5333
|
import fs10 from "fs/promises";
|
|
5298
|
-
import
|
|
5334
|
+
import path8 from "path";
|
|
5299
5335
|
import {
|
|
5300
5336
|
createAgent,
|
|
5301
5337
|
findLatestTraceFile,
|
|
@@ -5556,7 +5592,7 @@ var SessionSources = class {
|
|
|
5556
5592
|
|
|
5557
5593
|
// src/runtime/stream-error-watcher.ts
|
|
5558
5594
|
import { isEventMessage, isModelMessage, isSessionMeta } from "@prismshadow/penguin-core";
|
|
5559
|
-
import
|
|
5595
|
+
import path7 from "path";
|
|
5560
5596
|
var TOOL_NAMES_MAX = 1e3;
|
|
5561
5597
|
var ORIGIN_CTX_MAX = 200;
|
|
5562
5598
|
var LLM_FAILURES = {
|
|
@@ -5661,7 +5697,7 @@ var StreamErrorWatcher = class {
|
|
|
5661
5697
|
registerOrigin(msg) {
|
|
5662
5698
|
const key = originKey(msg);
|
|
5663
5699
|
if (!key) return;
|
|
5664
|
-
const agentId =
|
|
5700
|
+
const agentId = path7.basename(path7.dirname(msg.payload.agent_state));
|
|
5665
5701
|
if (!agentId || agentId === "." || agentId === "..") return;
|
|
5666
5702
|
this.originCtx.delete(key);
|
|
5667
5703
|
this.originCtx.set(key, { agentId, sessionId: key });
|
|
@@ -5796,12 +5832,22 @@ var StreamErrorWatcher = class {
|
|
|
5796
5832
|
|
|
5797
5833
|
// src/runtime/session-manager.ts
|
|
5798
5834
|
function compactUnavailable(why) {
|
|
5799
|
-
const
|
|
5800
|
-
unsupported:
|
|
5801
|
-
|
|
5802
|
-
|
|
5835
|
+
const reasons = {
|
|
5836
|
+
unsupported: {
|
|
5837
|
+
code: "compaction_not_configured",
|
|
5838
|
+
message: "This Agent does not have context compaction configured."
|
|
5839
|
+
},
|
|
5840
|
+
empty: {
|
|
5841
|
+
code: "nothing_to_compact",
|
|
5842
|
+
message: "The current context has nothing to compact (no completed conversation turns yet)."
|
|
5843
|
+
},
|
|
5844
|
+
just_compacted: {
|
|
5845
|
+
code: "already_compacted",
|
|
5846
|
+
message: "The context was just compacted and there is no new conversation since; no need to compact again."
|
|
5847
|
+
}
|
|
5803
5848
|
};
|
|
5804
|
-
|
|
5849
|
+
const { code, message } = reasons[why];
|
|
5850
|
+
return new HttpError(409, code, message);
|
|
5805
5851
|
}
|
|
5806
5852
|
function createCoreSessionLoader(root, sources, opts = {}) {
|
|
5807
5853
|
return {
|
|
@@ -5865,6 +5911,14 @@ var EARLY_TITLE_BODY_CHARS = 1e3;
|
|
|
5865
5911
|
function agentKey(projectId, agentId) {
|
|
5866
5912
|
return `${projectId}\0${agentId}`;
|
|
5867
5913
|
}
|
|
5914
|
+
function followUpInfo(f) {
|
|
5915
|
+
return {
|
|
5916
|
+
id: f.id,
|
|
5917
|
+
text: f.recall?.text ?? "",
|
|
5918
|
+
images: f.recall?.images.length ?? 0,
|
|
5919
|
+
files: f.recall?.files.length ?? 0
|
|
5920
|
+
};
|
|
5921
|
+
}
|
|
5868
5922
|
function isDeliveredSteering(msg) {
|
|
5869
5923
|
const p = msg.payload;
|
|
5870
5924
|
if (msg.type !== "model_msg" || p.type !== "text" || p.role !== "user") return false;
|
|
@@ -5909,6 +5963,7 @@ var SessionManager = class {
|
|
|
5909
5963
|
constructor(deps2) {
|
|
5910
5964
|
this.deps = deps2;
|
|
5911
5965
|
this.log = deps2.log ?? ((line) => console.error(line));
|
|
5966
|
+
this.now = deps2.now ?? (() => /* @__PURE__ */ new Date());
|
|
5912
5967
|
this.sweepTimer = setInterval(() => this.sweepIdle(), ENTRY_SWEEP_INTERVAL_MS);
|
|
5913
5968
|
this.sweepTimer.unref?.();
|
|
5914
5969
|
}
|
|
@@ -5928,6 +5983,8 @@ var SessionManager = class {
|
|
|
5928
5983
|
/** Open streaming fragments of running sessions (fed by drive, served to GET /messages; see live-tail.ts). */
|
|
5929
5984
|
liveTail = new LiveTailTracker();
|
|
5930
5985
|
sweepTimer;
|
|
5986
|
+
/** Clock for persisted timestamps (see SessionManagerDeps.now); wall clock unless injected. */
|
|
5987
|
+
now;
|
|
5931
5988
|
// —— Query surface (used by Session listing / Agent active-count / SSE subscription replay) ——
|
|
5932
5989
|
statusOf(sessionId) {
|
|
5933
5990
|
return this.entries.get(sessionId)?.status ?? "idle";
|
|
@@ -5944,7 +6001,11 @@ var SessionManager = class {
|
|
|
5944
6001
|
}
|
|
5945
6002
|
/** Steering messages queued but not yet delivered to the model (display mirror; see RuntimeEntry.pendingSteering). */
|
|
5946
6003
|
pendingSteeringOf(sessionId) {
|
|
5947
|
-
return this.entries.get(sessionId)?.pendingSteering ?? [];
|
|
6004
|
+
return (this.entries.get(sessionId)?.pendingSteering ?? []).map((p) => p.info);
|
|
6005
|
+
}
|
|
6006
|
+
/** Queued follow-up tasks awaiting auto-start, as their display/recall info (id + content summary). */
|
|
6007
|
+
pendingFollowUpsOf(sessionId) {
|
|
6008
|
+
return (this.entries.get(sessionId)?.followUps ?? []).map(followUpInfo);
|
|
5948
6009
|
}
|
|
5949
6010
|
/**
|
|
5950
6011
|
* Live tail of a running session: one synthetic `partial_* start` OmniMessage per open
|
|
@@ -6044,6 +6105,22 @@ var SessionManager = class {
|
|
|
6044
6105
|
const entry = this.entries.get(sessionId);
|
|
6045
6106
|
if (entry && !opts?.queueIfBusy) this.assertIdle(entry);
|
|
6046
6107
|
}
|
|
6108
|
+
/**
|
|
6109
|
+
* Run a metadata operation at an idle Session boundary under the same mutex as Task and
|
|
6110
|
+
* compaction starts. Session fork uses this to snapshot an append-only Trace without a Task
|
|
6111
|
+
* beginning between its status check and final read. An uncached Session is idle by definition;
|
|
6112
|
+
* unlike startTask this path deliberately does not load a heavyweight runtime.
|
|
6113
|
+
*/
|
|
6114
|
+
async atIdleBoundary(sessionId, operation) {
|
|
6115
|
+
return this.withLock(sessionId, async () => {
|
|
6116
|
+
this.assertOpen();
|
|
6117
|
+
this.assertAgentNotDeleting(sessionId);
|
|
6118
|
+
this.assertSessionNotDeleting(sessionId);
|
|
6119
|
+
const entry = this.entries.get(sessionId);
|
|
6120
|
+
if (entry) this.assertIdle(entry);
|
|
6121
|
+
return operation();
|
|
6122
|
+
});
|
|
6123
|
+
}
|
|
6047
6124
|
/**
|
|
6048
6125
|
* Start a Task: get-or-load → 409
|
|
6049
6126
|
* mutual-exclusion check → publish the input messages first → drive run in the
|
|
@@ -6063,8 +6140,11 @@ var SessionManager = class {
|
|
|
6063
6140
|
const entry = await this.ensureEntry(sessionId);
|
|
6064
6141
|
if (entry.status !== "idle" && opts?.queueIfBusy) {
|
|
6065
6142
|
entry.followUps.push({
|
|
6143
|
+
id: randomUUID2(),
|
|
6066
6144
|
input,
|
|
6067
|
-
...opts.thinkingLevel !== void 0 ? { thinkingLevel: opts.thinkingLevel } : {}
|
|
6145
|
+
...opts.thinkingLevel !== void 0 ? { thinkingLevel: opts.thinkingLevel } : {},
|
|
6146
|
+
// The original content rides the queue so a recall can hand it back (see recallFollowUp).
|
|
6147
|
+
...opts.recall !== void 0 ? { recall: opts.recall } : {}
|
|
6068
6148
|
});
|
|
6069
6149
|
entry.lastActivityMs = Date.now();
|
|
6070
6150
|
this.publishState(entry, entry.status);
|
|
@@ -6123,10 +6203,11 @@ var SessionManager = class {
|
|
|
6123
6203
|
objective,
|
|
6124
6204
|
budget: args.budget
|
|
6125
6205
|
});
|
|
6206
|
+
const thinkingLevel = this.runThinkingLevel(entry.sessionId, args.thinkingLevel);
|
|
6126
6207
|
const gen = this.goalStream(entry, {
|
|
6127
6208
|
input: args.input,
|
|
6128
6209
|
budget: args.budget,
|
|
6129
|
-
...
|
|
6210
|
+
...thinkingLevel !== void 0 ? { thinkingLevel } : {},
|
|
6130
6211
|
approve,
|
|
6131
6212
|
signal: ac.signal,
|
|
6132
6213
|
...goalId !== void 0 ? { goalId } : {}
|
|
@@ -6207,7 +6288,18 @@ var SessionManager = class {
|
|
|
6207
6288
|
});
|
|
6208
6289
|
}
|
|
6209
6290
|
/** Shared task launch (fresh tasks and auto-started follow-ups): flips to running, publishes the input, and drives the run with the per-turn thinking level (if any). Caller holds the session lock and has verified idle. */
|
|
6210
|
-
|
|
6291
|
+
/**
|
|
6292
|
+
* Thinking level for one run: the level the request carried wins, else the level pinned
|
|
6293
|
+
* on the Session row (PATCH /api/sessions/:id — the Web App's in-chat picker), else
|
|
6294
|
+
* undefined so core keeps falling back to the Agent config. Resolved at LAUNCH time, so
|
|
6295
|
+
* a queued follow-up that carried no level of its own picks up the pin as it stands when
|
|
6296
|
+
* it finally starts, and a pin set mid-run applies from the next run.
|
|
6297
|
+
*/
|
|
6298
|
+
runThinkingLevel(sessionId, requested) {
|
|
6299
|
+
return requested ?? this.deps.sessions.findById(sessionId)?.thinkingLevel ?? void 0;
|
|
6300
|
+
}
|
|
6301
|
+
launchTask(entry, input, requestedThinkingLevel) {
|
|
6302
|
+
const thinkingLevel = this.runThinkingLevel(entry.sessionId, requestedThinkingLevel);
|
|
6211
6303
|
const channel = this.deps.channels.get(entry.sessionId);
|
|
6212
6304
|
const ac = new AbortController();
|
|
6213
6305
|
entry.status = "running";
|
|
@@ -6293,12 +6385,13 @@ var SessionManager = class {
|
|
|
6293
6385
|
* or the run finished in the race window — the caller falls back to submitting a normal
|
|
6294
6386
|
* task, which carries the same text and images.
|
|
6295
6387
|
*
|
|
6296
|
-
* `
|
|
6297
|
-
* `task_state` (and the SSE subscribe snapshot)
|
|
6298
|
-
* message is observed on the stream — that is what
|
|
6299
|
-
* hint, content included, alive across reloads
|
|
6388
|
+
* `recall` is the queued message's original content: its summary (with a fresh id) is
|
|
6389
|
+
* mirrored on the entry and broadcast via `task_state` (and the SSE subscribe snapshot)
|
|
6390
|
+
* until the delivered `[user_steering]` message is observed on the stream — that is what
|
|
6391
|
+
* keeps the composer's "steering queued" hint, content included, alive across reloads —
|
|
6392
|
+
* and the content itself is what a recall (recallSteering) hands back to the composer.
|
|
6300
6393
|
*/
|
|
6301
|
-
steer(sessionId, input,
|
|
6394
|
+
steer(sessionId, input, recall) {
|
|
6302
6395
|
const entry = this.entries.get(sessionId);
|
|
6303
6396
|
if (!entry || entry.status !== "running" || !entry.session.steer(input)) {
|
|
6304
6397
|
throw new HttpError(
|
|
@@ -6307,9 +6400,68 @@ var SessionManager = class {
|
|
|
6307
6400
|
"This Session has no Task in progress; send the message as a new task instead."
|
|
6308
6401
|
);
|
|
6309
6402
|
}
|
|
6310
|
-
entry.pendingSteering.push(
|
|
6403
|
+
entry.pendingSteering.push({
|
|
6404
|
+
info: {
|
|
6405
|
+
id: randomUUID2(),
|
|
6406
|
+
text: recall.text,
|
|
6407
|
+
images: recall.images.length,
|
|
6408
|
+
files: recall.files.length
|
|
6409
|
+
},
|
|
6410
|
+
input,
|
|
6411
|
+
recall
|
|
6412
|
+
});
|
|
6413
|
+
entry.lastActivityMs = Date.now();
|
|
6414
|
+
this.publishState(entry, entry.status);
|
|
6415
|
+
}
|
|
6416
|
+
/**
|
|
6417
|
+
* Recall an undelivered steering message (#287): withdraw it from core's queue and the
|
|
6418
|
+
* display mirror, re-broadcast state, and hand back its original content for the composer
|
|
6419
|
+
* to restore. 409 `not_pending` when the entry is no longer queued — already delivered to
|
|
6420
|
+
* the model (core's `unsteer` says so authoritatively; the mirror can lag behind delivery
|
|
6421
|
+
* until the `[user_steering]` message is observed on the stream), the run exited, or the
|
|
6422
|
+
* id is unknown — all the same outcome for the caller: nothing left to take back.
|
|
6423
|
+
*/
|
|
6424
|
+
recallSteering(sessionId, steerId) {
|
|
6425
|
+
const entry = this.entries.get(sessionId);
|
|
6426
|
+
const i = entry?.pendingSteering.findIndex((p) => p.info.id === steerId) ?? -1;
|
|
6427
|
+
const pending = i >= 0 ? entry.pendingSteering[i] : void 0;
|
|
6428
|
+
if (!entry || !pending || !(entry.session.unsteer?.(pending.input) ?? false)) {
|
|
6429
|
+
throw new HttpError(
|
|
6430
|
+
409,
|
|
6431
|
+
"not_pending",
|
|
6432
|
+
"This steering message was already delivered to the model and can no longer be recalled."
|
|
6433
|
+
);
|
|
6434
|
+
}
|
|
6435
|
+
entry.pendingSteering.splice(i, 1);
|
|
6436
|
+
entry.lastActivityMs = Date.now();
|
|
6437
|
+
this.publishState(entry, entry.status);
|
|
6438
|
+
return pending.recall;
|
|
6439
|
+
}
|
|
6440
|
+
/**
|
|
6441
|
+
* Recall a queued follow-up task (#287): remove it from the queue before it auto-starts,
|
|
6442
|
+
* re-broadcast state, and hand back its original content (plus the thinking level it was
|
|
6443
|
+
* queued with). 409 `not_pending` when the id is not in the queue — it already
|
|
6444
|
+
* auto-started (or the runtime was evicted/restarted, which empties the queue) — or the
|
|
6445
|
+
* entry has no recall store to give back.
|
|
6446
|
+
*/
|
|
6447
|
+
recallFollowUp(sessionId, followUpId) {
|
|
6448
|
+
const entry = this.entries.get(sessionId);
|
|
6449
|
+
const i = entry?.followUps.findIndex((f) => f.id === followUpId) ?? -1;
|
|
6450
|
+
const queued = i >= 0 ? entry.followUps[i] : void 0;
|
|
6451
|
+
if (!entry || !queued?.recall) {
|
|
6452
|
+
throw new HttpError(
|
|
6453
|
+
409,
|
|
6454
|
+
"not_pending",
|
|
6455
|
+
"This follow-up message already started and can no longer be recalled."
|
|
6456
|
+
);
|
|
6457
|
+
}
|
|
6458
|
+
entry.followUps.splice(i, 1);
|
|
6311
6459
|
entry.lastActivityMs = Date.now();
|
|
6312
6460
|
this.publishState(entry, entry.status);
|
|
6461
|
+
return {
|
|
6462
|
+
recall: queued.recall,
|
|
6463
|
+
...queued.thinkingLevel !== void 0 ? { thinkingLevel: queued.thinkingLevel } : {}
|
|
6464
|
+
};
|
|
6313
6465
|
}
|
|
6314
6466
|
/**
|
|
6315
6467
|
* "Retry now" on the reconnect countdown: skip the in-progress backoff wait and fire
|
|
@@ -6354,6 +6506,32 @@ var SessionManager = class {
|
|
|
6354
6506
|
if (killed) entry.lastActivityMs = Date.now();
|
|
6355
6507
|
return killed;
|
|
6356
6508
|
}
|
|
6509
|
+
/**
|
|
6510
|
+
* Removes one EXITED background command entry from a loaded session's process list.
|
|
6511
|
+
* A running process is refused ("running") — stopping is killProcess's job, and a
|
|
6512
|
+
* bare removal must never surprise-signal a live process group. "not_found" covers
|
|
6513
|
+
* an unloaded session and an unknown id alike: the entry is gone either way. The
|
|
6514
|
+
* removal itself reuses the kill path — core's registry removal signals the (dead)
|
|
6515
|
+
* group defensively and drops the row, exactly like input_command's post-exit reap.
|
|
6516
|
+
* The only race, an entry observed running that exits before a retry, is benign:
|
|
6517
|
+
* the caller gets "running", refreshes, and sees the row exited on the next look.
|
|
6518
|
+
*
|
|
6519
|
+
* Note what leaves with the row: the registry entry owns the ManagedSession holding
|
|
6520
|
+
* that process's captured output, so after a removal input_command on the same
|
|
6521
|
+
* process_id answers "unknown process_id". Removal is a deliberate discard, not just
|
|
6522
|
+
* a list tidy-up — the Web App says so at the button and in the docs.
|
|
6523
|
+
*/
|
|
6524
|
+
removeProcess(sessionId, processId) {
|
|
6525
|
+
const entry = this.entries.get(sessionId);
|
|
6526
|
+
if (!entry) return "not_found";
|
|
6527
|
+
const info = entry.session.listBackgroundCommands?.().find((p) => p.processId === processId);
|
|
6528
|
+
if (!info) return "not_found";
|
|
6529
|
+
if (info.running) return "running";
|
|
6530
|
+
const removed = entry.session.killBackgroundCommand?.(processId) ?? false;
|
|
6531
|
+
if (!removed) return "not_found";
|
|
6532
|
+
entry.lastActivityMs = Date.now();
|
|
6533
|
+
return "removed";
|
|
6534
|
+
}
|
|
6357
6535
|
/**
|
|
6358
6536
|
* Disposes a just-removed entry's runtime — after its in-flight drive (if any)
|
|
6359
6537
|
* settles, so interrupt cleanup never races a dying environment. Deleting a Session /
|
|
@@ -6571,7 +6749,6 @@ var SessionManager = class {
|
|
|
6571
6749
|
* a title): it collects model text for automatic title generation.
|
|
6572
6750
|
*/
|
|
6573
6751
|
async drive(entry, gen, titleSource) {
|
|
6574
|
-
this.deps.sessions.markHasTrace(entry.sessionId);
|
|
6575
6752
|
let earlyTitleFired = false;
|
|
6576
6753
|
let mainBodyChars = 0;
|
|
6577
6754
|
const ctx = {
|
|
@@ -6581,6 +6758,7 @@ var SessionManager = class {
|
|
|
6581
6758
|
provider: entry.provider,
|
|
6582
6759
|
modelId: entry.modelId
|
|
6583
6760
|
};
|
|
6761
|
+
this.touchRow(ctx, (repo, at) => repo.markDriven(ctx.sessionId, at));
|
|
6584
6762
|
const watcher = this.deps.errors ? new StreamErrorWatcher(this.deps.errors, {
|
|
6585
6763
|
projectId: entry.projectId,
|
|
6586
6764
|
agentId: entry.agentId,
|
|
@@ -6675,6 +6853,7 @@ var SessionManager = class {
|
|
|
6675
6853
|
entry.running = null;
|
|
6676
6854
|
entry.pendingSteering = [];
|
|
6677
6855
|
entry.lastActivityMs = Date.now();
|
|
6856
|
+
this.touchRow(ctx, (repo, at) => repo.touchLastActive(ctx.sessionId, at));
|
|
6678
6857
|
this.publishState(entry, "idle");
|
|
6679
6858
|
if (titleSource && titleSource.userExcerpt.trim()) {
|
|
6680
6859
|
this.deps.titles?.maybeGenerate(ctx, entry.session, {
|
|
@@ -6717,10 +6896,11 @@ var SessionManager = class {
|
|
|
6717
6896
|
const childSid = msg.origin[msg.origin.length - 1];
|
|
6718
6897
|
if (children.has(childSid)) return null;
|
|
6719
6898
|
const p = msg.payload;
|
|
6720
|
-
const agentId =
|
|
6899
|
+
const agentId = path8.basename(path8.dirname(p.agent_state));
|
|
6721
6900
|
if (!agentId || agentId === "." || agentId === "..") return null;
|
|
6722
6901
|
const source = asSessionSource(p.source) ?? "subagent";
|
|
6723
6902
|
this.deps.sources.set(childSid, source);
|
|
6903
|
+
const createdAt = this.now().toISOString();
|
|
6724
6904
|
this.deps.sessions.insertOrIgnore({
|
|
6725
6905
|
sessionId: childSid,
|
|
6726
6906
|
projectId: entry.projectId,
|
|
@@ -6734,7 +6914,10 @@ var SessionManager = class {
|
|
|
6734
6914
|
title: null,
|
|
6735
6915
|
// Spawned by this server's run (client NULL = web); its Trace exists by construction.
|
|
6736
6916
|
hasTrace: true,
|
|
6737
|
-
|
|
6917
|
+
// A subagent's own runs are driven through the PARENT entry's drive, so nothing ever
|
|
6918
|
+
// stamps this row: it stays at its registration time (see SessionRow.lastActiveAt).
|
|
6919
|
+
lastActiveAt: createdAt,
|
|
6920
|
+
createdAt
|
|
6738
6921
|
});
|
|
6739
6922
|
this.publishEvent(entry, {
|
|
6740
6923
|
type: "session_created",
|
|
@@ -6753,12 +6936,50 @@ var SessionManager = class {
|
|
|
6753
6936
|
children.set(childSid, child);
|
|
6754
6937
|
return child;
|
|
6755
6938
|
}
|
|
6939
|
+
/**
|
|
6940
|
+
* Runs one `sessions` row write for a driven run's bookkeeping (has_trace /
|
|
6941
|
+
* last_active_at), swallowing failures the same way the per-message `recorder.record`
|
|
6942
|
+
* call does: bookkeeping must never take down a run's lifecycle. The two call sites both
|
|
6943
|
+
* sit outside a covering try — the run-start write precedes drive's try block, the
|
|
6944
|
+
* run-end one lives in its finally — and the realistic failure (the DatabaseSync handle
|
|
6945
|
+
* closed by shutdown while a run outlived its drain window) throws ERR_INVALID_STATE.
|
|
6946
|
+
*/
|
|
6947
|
+
touchRow(ctx, write) {
|
|
6948
|
+
try {
|
|
6949
|
+
write(this.deps.sessions, this.now().toISOString());
|
|
6950
|
+
} catch (err) {
|
|
6951
|
+
this.log(
|
|
6952
|
+
`[session] last-active bookkeeping failed: ${err instanceof Error ? err.message : String(err)}`
|
|
6953
|
+
);
|
|
6954
|
+
this.deps.errors?.record({ source: "session", err, ctx, code: "session_touch_failed" });
|
|
6955
|
+
}
|
|
6956
|
+
}
|
|
6756
6957
|
publishState(entry, state) {
|
|
6757
6958
|
this.publishEvent(entry, {
|
|
6758
6959
|
type: "task_state",
|
|
6759
6960
|
state,
|
|
6760
6961
|
queued: entry.followUps.length,
|
|
6761
|
-
...entry.pendingSteering.length > 0 ? { pendingSteering: entry.pendingSteering } : {}
|
|
6962
|
+
...entry.pendingSteering.length > 0 ? { pendingSteering: entry.pendingSteering.map((p) => p.info) } : {},
|
|
6963
|
+
...entry.followUps.length > 0 ? { pendingFollowUps: entry.followUps.map(followUpInfo) } : {}
|
|
6964
|
+
});
|
|
6965
|
+
const notify = this.deps.notifyProjectUsers;
|
|
6966
|
+
if (!notify) return;
|
|
6967
|
+
let lastActiveAt;
|
|
6968
|
+
let hasTrace;
|
|
6969
|
+
try {
|
|
6970
|
+
const row = this.deps.sessions.findById(entry.sessionId);
|
|
6971
|
+
if (!row) return;
|
|
6972
|
+
lastActiveAt = row.lastActiveAt;
|
|
6973
|
+
hasTrace = row.hasTrace === true || state !== "idle";
|
|
6974
|
+
} catch {
|
|
6975
|
+
return;
|
|
6976
|
+
}
|
|
6977
|
+
notify(entry.projectId, {
|
|
6978
|
+
type: "session_state",
|
|
6979
|
+
sessionId: entry.sessionId,
|
|
6980
|
+
state,
|
|
6981
|
+
lastActiveAt,
|
|
6982
|
+
hasTrace
|
|
6762
6983
|
});
|
|
6763
6984
|
}
|
|
6764
6985
|
publishEvent(entry, event) {
|
|
@@ -6780,7 +7001,7 @@ var SessionManager = class {
|
|
|
6780
7001
|
};
|
|
6781
7002
|
|
|
6782
7003
|
// src/runtime/scheduler.ts
|
|
6783
|
-
import { createHash as
|
|
7004
|
+
import { createHash as createHash2 } from "crypto";
|
|
6784
7005
|
import fs11 from "fs/promises";
|
|
6785
7006
|
import { agentsDir, buildScheduledMessage, userText as userText2 } from "@prismshadow/penguin-core";
|
|
6786
7007
|
var TICK_INTERVAL_MS = 3e4;
|
|
@@ -6957,7 +7178,7 @@ var Scheduler = class {
|
|
|
6957
7178
|
}
|
|
6958
7179
|
/** Register (or sync) run state; set the no-backfill baseline only at first registration / start_at reset. */
|
|
6959
7180
|
registerEntry(projectId, agentId, ownerUserId, def, raw) {
|
|
6960
|
-
const defHash =
|
|
7181
|
+
const defHash = createHash2("sha1").update(raw).digest("hex");
|
|
6961
7182
|
const { row, fresh } = this.deps.repo.registerOrSync({
|
|
6962
7183
|
projectId,
|
|
6963
7184
|
agentId,
|
|
@@ -7240,7 +7461,7 @@ var UsageRecorder = class {
|
|
|
7240
7461
|
};
|
|
7241
7462
|
|
|
7242
7463
|
// src/services/ids.ts
|
|
7243
|
-
import { randomBytes as
|
|
7464
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
7244
7465
|
var SEMANTIC_ID_PATTERN = /^[a-z][a-z0-9_]{1,63}$/;
|
|
7245
7466
|
var USERNAME_PATTERN = /^[a-z][a-z0-9_]{1,31}$/;
|
|
7246
7467
|
var PROJECT_SUFFIX_PATTERN = /^[a-z0-9_]+$/;
|
|
@@ -7317,9 +7538,9 @@ var AdminService = class {
|
|
|
7317
7538
|
};
|
|
7318
7539
|
|
|
7319
7540
|
// src/services/desktop-service.ts
|
|
7320
|
-
import { createHash as
|
|
7541
|
+
import { createHash as createHash3, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
7321
7542
|
function digest(value) {
|
|
7322
|
-
return
|
|
7543
|
+
return createHash3("sha256").update(value).digest();
|
|
7323
7544
|
}
|
|
7324
7545
|
var DesktopService = class {
|
|
7325
7546
|
tokenDigest;
|
|
@@ -7330,7 +7551,7 @@ var DesktopService = class {
|
|
|
7330
7551
|
}
|
|
7331
7552
|
/** Constant-time token check (no consumption). */
|
|
7332
7553
|
verifyToken(candidate) {
|
|
7333
|
-
return
|
|
7554
|
+
return timingSafeEqual2(digest(candidate), this.tokenDigest);
|
|
7334
7555
|
}
|
|
7335
7556
|
/** One-shot login redemption: true exactly once, for the correct token. */
|
|
7336
7557
|
redeemLoginToken(candidate) {
|
|
@@ -7384,20 +7605,64 @@ import {
|
|
|
7384
7605
|
|
|
7385
7606
|
// src/services/project-config-service.ts
|
|
7386
7607
|
import fs12 from "fs/promises";
|
|
7387
|
-
import
|
|
7608
|
+
import path9 from "path";
|
|
7388
7609
|
import { parse as parseToml2 } from "smol-toml";
|
|
7389
7610
|
import {
|
|
7390
7611
|
CHAT_APPROVAL_MODES,
|
|
7391
7612
|
DEFAULT_CHAT_THINKING_LEVELS as DEFAULT_CHAT_THINKING_LEVELS2,
|
|
7392
7613
|
GenerativeModel,
|
|
7614
|
+
canonicalClientType,
|
|
7393
7615
|
catalogEntryFor,
|
|
7394
7616
|
defaultProjectConfig,
|
|
7617
|
+
imageUrlMessage as imageUrlMessage2,
|
|
7395
7618
|
projectConfigFromTable,
|
|
7396
7619
|
projectConfigPath,
|
|
7397
7620
|
renderProjectConfigToml,
|
|
7398
|
-
resolveModelEnv,
|
|
7621
|
+
resolveModelEnv as resolveModelEnv2,
|
|
7399
7622
|
userText as userText3
|
|
7400
7623
|
} from "@prismshadow/penguin-core";
|
|
7624
|
+
|
|
7625
|
+
// src/services/vision-detect.ts
|
|
7626
|
+
var VISION_PROBE_IMAGE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
|
|
7627
|
+
var VISION_PROBE_PROMPT = 'Reply with the single word "ok" and nothing else. Do not think or explain.\n<think></think>';
|
|
7628
|
+
var VISION_PROBE_MAX_TOKENS = 8;
|
|
7629
|
+
var VISION_PROBE_TIMEOUT_MS = 2e4;
|
|
7630
|
+
var UNSUPPORTED_PATTERNS = [
|
|
7631
|
+
"image_url",
|
|
7632
|
+
"image url",
|
|
7633
|
+
"does not support image",
|
|
7634
|
+
"not support image",
|
|
7635
|
+
"unsupported image",
|
|
7636
|
+
"image input",
|
|
7637
|
+
"image content",
|
|
7638
|
+
"invalid image",
|
|
7639
|
+
"vision",
|
|
7640
|
+
"multimodal",
|
|
7641
|
+
"modality",
|
|
7642
|
+
"image is not supported",
|
|
7643
|
+
"no support for image",
|
|
7644
|
+
"content type 'image",
|
|
7645
|
+
'content type "image',
|
|
7646
|
+
"unsupported content",
|
|
7647
|
+
"cannot process image",
|
|
7648
|
+
"image not supported"
|
|
7649
|
+
];
|
|
7650
|
+
function isImageRejection(message) {
|
|
7651
|
+
const m = message.toLowerCase();
|
|
7652
|
+
return UNSUPPORTED_PATTERNS.some((p) => m.includes(p));
|
|
7653
|
+
}
|
|
7654
|
+
function classifyVisionProbe(outcome, sawContent) {
|
|
7655
|
+
if (outcome.status === "completed") return "supported";
|
|
7656
|
+
if (sawContent) return "supported";
|
|
7657
|
+
const detail = "errorMessage" in outcome && outcome.errorMessage ? outcome.errorMessage : outcome.status;
|
|
7658
|
+
return isImageRejection(String(detail)) ? "unsupported" : "failed";
|
|
7659
|
+
}
|
|
7660
|
+
function classifyVisionProbeError(err) {
|
|
7661
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
7662
|
+
return isImageRejection(message) ? "unsupported" : "failed";
|
|
7663
|
+
}
|
|
7664
|
+
|
|
7665
|
+
// src/services/project-config-service.ts
|
|
7401
7666
|
function maskApiKey(key) {
|
|
7402
7667
|
if (key.length <= 12) return "***";
|
|
7403
7668
|
return `${key.slice(0, 4)}\u2026${key.slice(-4)}`;
|
|
@@ -7513,7 +7778,7 @@ var ProjectConfigService = class {
|
|
|
7513
7778
|
*/
|
|
7514
7779
|
async writeRaw(projectId, data) {
|
|
7515
7780
|
const file = this.filePath(projectId);
|
|
7516
|
-
await fs12.mkdir(
|
|
7781
|
+
await fs12.mkdir(path9.dirname(file), { recursive: true });
|
|
7517
7782
|
await fs12.writeFile(file, renderProjectConfigToml(data), { encoding: "utf8", mode: 384 });
|
|
7518
7783
|
await fs12.chmod(file, 384);
|
|
7519
7784
|
this.cache.delete(projectId);
|
|
@@ -7669,6 +7934,59 @@ var ProjectConfigService = class {
|
|
|
7669
7934
|
* Tokens (single-digit output; speed mode spends up to its 64-token cap to have a
|
|
7670
7935
|
* window worth timing), and writes no Trace and records no usage.
|
|
7671
7936
|
*/
|
|
7937
|
+
/**
|
|
7938
|
+
* Vision capability probe: sends one 1x1 PNG plus a one-word prompt and reports whether
|
|
7939
|
+
* the model took it (see services/vision-detect.ts for the verdict rules and why this
|
|
7940
|
+
* one costs real money, unlike the protocol probes).
|
|
7941
|
+
*
|
|
7942
|
+
* Credential resolution is the connectivity test's, verbatim — the request's key, else
|
|
7943
|
+
* the stored one, unless "clear" is checked. The environment layer needs no code here:
|
|
7944
|
+
* omitting apiKey lets AgentHub read the protocol's own variable, which is the same
|
|
7945
|
+
* chain protocol detection spells out by hand because it bypasses the SDK. Nothing
|
|
7946
|
+
* secret is returned; the failure message is the provider's own text, truncated.
|
|
7947
|
+
*/
|
|
7948
|
+
async detectVision(projectId, req) {
|
|
7949
|
+
const raw = await this.readRaw(projectId);
|
|
7950
|
+
const entry = asArray(raw.models).find((m) => entryMatches(m, req.provider, req.modelId)) ?? {};
|
|
7951
|
+
const savedKey = optStr(entry.api_key);
|
|
7952
|
+
const apiKey = req.clearApiKey ? void 0 : req.apiKey ?? savedKey;
|
|
7953
|
+
const savedBaseUrl = optStr(entry.base_url);
|
|
7954
|
+
const baseUrl = req.baseUrl === null ? void 0 : req.baseUrl ?? savedBaseUrl;
|
|
7955
|
+
const clientType = canonicalClientType(req.clientType ?? optStr(entry.client_type));
|
|
7956
|
+
try {
|
|
7957
|
+
const llm = new GenerativeModel({
|
|
7958
|
+
modelId: req.modelId,
|
|
7959
|
+
...apiKey ? { apiKey } : {},
|
|
7960
|
+
...baseUrl ? { baseUrl } : {},
|
|
7961
|
+
...clientType ? { clientType } : {},
|
|
7962
|
+
tools: [],
|
|
7963
|
+
thinkingLevel: "none",
|
|
7964
|
+
maxTokens: VISION_PROBE_MAX_TOKENS,
|
|
7965
|
+
requestTimeoutMs: VISION_PROBE_TIMEOUT_MS
|
|
7966
|
+
});
|
|
7967
|
+
const gen = llm.streamGenerate({
|
|
7968
|
+
newMessages: [userText3(VISION_PROBE_PROMPT), imageUrlMessage2(VISION_PROBE_IMAGE)]
|
|
7969
|
+
});
|
|
7970
|
+
let sawContent = false;
|
|
7971
|
+
for (; ; ) {
|
|
7972
|
+
const step = await gen.next();
|
|
7973
|
+
if (step.done) {
|
|
7974
|
+
const outcome = classifyVisionProbe(step.value, sawContent);
|
|
7975
|
+
if (outcome !== "failed") return { outcome };
|
|
7976
|
+
const detail = "errorMessage" in step.value && step.value.errorMessage ? step.value.errorMessage : step.value.status;
|
|
7977
|
+
return { outcome, message: String(detail).slice(0, 300) };
|
|
7978
|
+
}
|
|
7979
|
+
if (isProbeContent(step.value)) sawContent = true;
|
|
7980
|
+
}
|
|
7981
|
+
} catch (err) {
|
|
7982
|
+
const outcome = classifyVisionProbeError(err);
|
|
7983
|
+
if (outcome !== "failed") return { outcome };
|
|
7984
|
+
return {
|
|
7985
|
+
outcome,
|
|
7986
|
+
message: (err instanceof Error ? err.message : String(err)).slice(0, 300)
|
|
7987
|
+
};
|
|
7988
|
+
}
|
|
7989
|
+
}
|
|
7672
7990
|
async testModel(projectId, req) {
|
|
7673
7991
|
const raw = await this.readRaw(projectId);
|
|
7674
7992
|
const entry = asArray(raw.models).find((m) => entryMatches(m, req.provider, req.modelId)) ?? {};
|
|
@@ -7676,7 +7994,8 @@ var ProjectConfigService = class {
|
|
|
7676
7994
|
const apiKey = req.clearApiKey ? void 0 : req.apiKey ?? savedKey;
|
|
7677
7995
|
const savedBaseUrl = optStr(entry.base_url);
|
|
7678
7996
|
const baseUrl = req.baseUrl === null ? void 0 : req.baseUrl ?? savedBaseUrl;
|
|
7679
|
-
const clientType = req.clientType ?? optStr(entry.client_type);
|
|
7997
|
+
const clientType = canonicalClientType(req.clientType ?? optStr(entry.client_type));
|
|
7998
|
+
const fastMode = req.fastMode ?? entry.fast_mode === true;
|
|
7680
7999
|
const startedAt = Date.now();
|
|
7681
8000
|
try {
|
|
7682
8001
|
const llm = new GenerativeModel({
|
|
@@ -7684,6 +8003,7 @@ var ProjectConfigService = class {
|
|
|
7684
8003
|
...apiKey ? { apiKey } : {},
|
|
7685
8004
|
...baseUrl ? { baseUrl } : {},
|
|
7686
8005
|
...clientType ? { clientType } : {},
|
|
8006
|
+
...fastMode ? { fastMode: true } : {},
|
|
7687
8007
|
tools: [],
|
|
7688
8008
|
thinkingLevel: "none",
|
|
7689
8009
|
// Speed mode pairs a raised cap with a prompt that keeps generating (see
|
|
@@ -7728,6 +8048,31 @@ var ProjectConfigService = class {
|
|
|
7728
8048
|
};
|
|
7729
8049
|
}
|
|
7730
8050
|
}
|
|
8051
|
+
/**
|
|
8052
|
+
* Protocol detection for a custom base URL (see services/protocol-detect.ts for the
|
|
8053
|
+
* probe order and classification). Credential resolution has three layers, in order:
|
|
8054
|
+
* 1. the request body's key (what the user just typed in the dialog);
|
|
8055
|
+
* 2. otherwise, when the optional paired reference names a stored entry and "clear"
|
|
8056
|
+
* isn't checked, that entry's saved key (the frontend only ever sees the mask);
|
|
8057
|
+
* 3. otherwise the environment variable for whichever protocol each probe speaks,
|
|
8058
|
+
* resolved inside detectModelProtocol because the protocol is the thing being
|
|
8059
|
+
* determined (ANTHROPIC_API_KEY for ant-messages, OPENAI_API_KEY for the two
|
|
8060
|
+
* OpenAI protocols).
|
|
8061
|
+
* Layers 2 and 3 are read server-side only and never travel back to the browser.
|
|
8062
|
+
* Detection still runs with no credential at all: a protocol-shaped 401/403 proves the
|
|
8063
|
+
* route. Never throws on probe failures — every outcome is reported per probe.
|
|
8064
|
+
*/
|
|
8065
|
+
async detectProtocol(projectId, req) {
|
|
8066
|
+
let apiKey = req.apiKey;
|
|
8067
|
+
if (apiKey === void 0 && !req.clearApiKey && req.provider && req.modelId) {
|
|
8068
|
+
const raw = await this.readRaw(projectId);
|
|
8069
|
+
const entry = asArray(raw.models).find(
|
|
8070
|
+
(m) => entryMatches(m, req.provider, req.modelId)
|
|
8071
|
+
);
|
|
8072
|
+
apiKey = entry !== void 0 ? optStr(entry.api_key) : void 0;
|
|
8073
|
+
}
|
|
8074
|
+
return detectModelProtocol({ baseUrl: req.baseUrl, ...apiKey ? { apiKey } : {} });
|
|
8075
|
+
}
|
|
7731
8076
|
/**
|
|
7732
8077
|
* GET models view: masks credential (inline fields), flags the default Model;
|
|
7733
8078
|
* the group is the entry's `provider` field, looked up in the built-in catalog by
|
|
@@ -7750,11 +8095,12 @@ var ProjectConfigService = class {
|
|
|
7750
8095
|
cacheWrite: optNum(pricing.cache_write) ?? 0,
|
|
7751
8096
|
output: optNum(pricing.output) ?? 0
|
|
7752
8097
|
} : void 0;
|
|
7753
|
-
const clientType = optStr(m.client_type);
|
|
8098
|
+
const clientType = canonicalClientType(optStr(m.client_type));
|
|
7754
8099
|
const cat = catalogEntryFor(provider, modelId);
|
|
7755
|
-
const envKey =
|
|
8100
|
+
const envKey = resolveModelEnv2(modelId, clientType)?.envKey;
|
|
7756
8101
|
const vision = typeof m.vision === "boolean" ? m.vision : cat?.supportsVision;
|
|
7757
8102
|
const maxTokens = optNum(m.max_tokens);
|
|
8103
|
+
const fastMode = m.fast_mode === true ? true : void 0;
|
|
7758
8104
|
const displayName = optStr(m.display_name) ?? cat?.displayName;
|
|
7759
8105
|
const apiKey = optStr(m.api_key);
|
|
7760
8106
|
const credBaseUrl = optStr(m.base_url);
|
|
@@ -7768,6 +8114,7 @@ var ProjectConfigService = class {
|
|
|
7768
8114
|
...clientType ? { clientType } : {},
|
|
7769
8115
|
...vision !== void 0 ? { vision } : {},
|
|
7770
8116
|
...maxTokens !== void 0 ? { maxTokens } : {},
|
|
8117
|
+
...fastMode !== void 0 ? { fastMode } : {},
|
|
7771
8118
|
...envKey ? { envKey } : {},
|
|
7772
8119
|
...pricingDto ? { pricing: pricingDto } : {},
|
|
7773
8120
|
...apiKey !== void 0 || credBaseUrl !== void 0 ? {
|
|
@@ -7829,6 +8176,7 @@ var ProjectConfigService = class {
|
|
|
7829
8176
|
delete next2.client_type;
|
|
7830
8177
|
delete next2.vision;
|
|
7831
8178
|
delete next2.max_tokens;
|
|
8179
|
+
delete next2.fast_mode;
|
|
7832
8180
|
delete next2.pricing;
|
|
7833
8181
|
delete next2.display_name;
|
|
7834
8182
|
delete next2.request_model_id;
|
|
@@ -7837,9 +8185,10 @@ var ProjectConfigService = class {
|
|
|
7837
8185
|
next2.display_name = entry.displayName;
|
|
7838
8186
|
}
|
|
7839
8187
|
if (entry.contextWindow !== void 0) next2.context_window = entry.contextWindow;
|
|
7840
|
-
if (entry.clientType) next2.client_type = entry.clientType;
|
|
8188
|
+
if (entry.clientType) next2.client_type = canonicalClientType(entry.clientType);
|
|
7841
8189
|
if (entry.vision !== void 0) next2.vision = entry.vision;
|
|
7842
8190
|
if (entry.maxTokens !== void 0) next2.max_tokens = entry.maxTokens;
|
|
8191
|
+
if (entry.fastMode === true) next2.fast_mode = true;
|
|
7843
8192
|
if (entry.pricing !== void 0) {
|
|
7844
8193
|
next2.pricing = {
|
|
7845
8194
|
unit: "usd_per_mtok",
|
|
@@ -7939,7 +8288,14 @@ function probeTps(outputTokens, windowMs) {
|
|
|
7939
8288
|
}
|
|
7940
8289
|
|
|
7941
8290
|
// src/services/agent-config-service.ts
|
|
7942
|
-
var THINKING_LEVELS2 = [
|
|
8291
|
+
var THINKING_LEVELS2 = [
|
|
8292
|
+
"none",
|
|
8293
|
+
"low",
|
|
8294
|
+
"medium",
|
|
8295
|
+
"high",
|
|
8296
|
+
"xhigh",
|
|
8297
|
+
"max"
|
|
8298
|
+
];
|
|
7943
8299
|
var COMPACTION_MODES = ["summarize", "discard"];
|
|
7944
8300
|
function asRecord(v) {
|
|
7945
8301
|
return v !== null && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
@@ -8304,7 +8660,7 @@ function validateMcpServers(value) {
|
|
|
8304
8660
|
|
|
8305
8661
|
// src/services/memory-service.ts
|
|
8306
8662
|
import fs14 from "fs/promises";
|
|
8307
|
-
import
|
|
8663
|
+
import path10 from "path";
|
|
8308
8664
|
import {
|
|
8309
8665
|
MEMORY_INDEX_FILENAME,
|
|
8310
8666
|
USER_SCOPE_KEY,
|
|
@@ -8365,7 +8721,7 @@ var MemoryService = class {
|
|
|
8365
8721
|
} catch {
|
|
8366
8722
|
}
|
|
8367
8723
|
const workspaces = await Promise.all(
|
|
8368
|
-
entries.map((key) => this.scopeInfo(
|
|
8724
|
+
entries.map((key) => this.scopeInfo(path10.join(base, key), key, "workspace"))
|
|
8369
8725
|
);
|
|
8370
8726
|
workspaces.sort((a, b) => {
|
|
8371
8727
|
if (a.updatedAt && b.updatedAt) return b.updatedAt.localeCompare(a.updatedAt);
|
|
@@ -8373,7 +8729,7 @@ var MemoryService = class {
|
|
|
8373
8729
|
if (b.updatedAt) return 1;
|
|
8374
8730
|
return a.scopeKey.localeCompare(b.scopeKey);
|
|
8375
8731
|
});
|
|
8376
|
-
const userScope = await this.scopeInfo(
|
|
8732
|
+
const userScope = await this.scopeInfo(path10.join(base, USER_SCOPE_KEY), USER_SCOPE_KEY, "user");
|
|
8377
8733
|
return [userScope, ...workspaces];
|
|
8378
8734
|
}
|
|
8379
8735
|
async scopeInfo(dir, key, kind) {
|
|
@@ -8381,7 +8737,7 @@ var MemoryService = class {
|
|
|
8381
8737
|
let latest = 0;
|
|
8382
8738
|
for (const name of files) {
|
|
8383
8739
|
try {
|
|
8384
|
-
const stat = await fs14.stat(
|
|
8740
|
+
const stat = await fs14.stat(path10.join(dir, name));
|
|
8385
8741
|
latest = Math.max(latest, stat.mtimeMs);
|
|
8386
8742
|
} catch {
|
|
8387
8743
|
}
|
|
@@ -8447,7 +8803,7 @@ var MemoryService = class {
|
|
|
8447
8803
|
}
|
|
8448
8804
|
/** Removes index lines linking to a deleted file. Best-effort: a missing or unreadable index is left alone. */
|
|
8449
8805
|
async pruneIndexLines(dir, fileName) {
|
|
8450
|
-
const indexPath =
|
|
8806
|
+
const indexPath = path10.join(dir, MEMORY_INDEX_FILENAME);
|
|
8451
8807
|
let content;
|
|
8452
8808
|
try {
|
|
8453
8809
|
content = await fs14.readFile(indexPath, "utf8");
|
|
@@ -8464,8 +8820,8 @@ var MemoryService = class {
|
|
|
8464
8820
|
async fileInfo(dir, name) {
|
|
8465
8821
|
try {
|
|
8466
8822
|
const [content, stat] = await Promise.all([
|
|
8467
|
-
fs14.readFile(
|
|
8468
|
-
fs14.stat(
|
|
8823
|
+
fs14.readFile(path10.join(dir, name), "utf8"),
|
|
8824
|
+
fs14.stat(path10.join(dir, name))
|
|
8469
8825
|
]);
|
|
8470
8826
|
return this.describe(name, content, stat.size, stat.mtime);
|
|
8471
8827
|
} catch {
|
|
@@ -8524,7 +8880,7 @@ var MemoryService = class {
|
|
|
8524
8880
|
async requireRealScopeDir(projectId, agentId, scopeKey, dir) {
|
|
8525
8881
|
try {
|
|
8526
8882
|
const realBase = await fs14.realpath(memoryDir(this.root, projectId, agentId));
|
|
8527
|
-
if (await fs14.realpath(dir) ===
|
|
8883
|
+
if (await fs14.realpath(dir) === path10.join(realBase, scopeKey)) return dir;
|
|
8528
8884
|
} catch {
|
|
8529
8885
|
}
|
|
8530
8886
|
throw new HttpError(
|
|
@@ -8544,9 +8900,9 @@ var MemoryService = class {
|
|
|
8544
8900
|
`File name must be a Markdown topic file \u2014 no path, no leading dot, and not the ${MEMORY_INDEX_FILENAME} index.`
|
|
8545
8901
|
);
|
|
8546
8902
|
}
|
|
8547
|
-
const target =
|
|
8548
|
-
const rel =
|
|
8549
|
-
if (rel !== fileName || rel.includes(
|
|
8903
|
+
const target = path10.join(dir, fileName);
|
|
8904
|
+
const rel = path10.relative(dir, target);
|
|
8905
|
+
if (rel !== fileName || rel.includes(path10.sep)) {
|
|
8550
8906
|
throw badRequest("File name must not contain a path.");
|
|
8551
8907
|
}
|
|
8552
8908
|
return target;
|
|
@@ -8555,7 +8911,7 @@ var MemoryService = class {
|
|
|
8555
8911
|
|
|
8556
8912
|
// src/services/agent-service.ts
|
|
8557
8913
|
import fs15 from "fs/promises";
|
|
8558
|
-
import
|
|
8914
|
+
import path11 from "path";
|
|
8559
8915
|
import {
|
|
8560
8916
|
agentDir,
|
|
8561
8917
|
agentsDir as agentsDir2,
|
|
@@ -8656,7 +9012,7 @@ var AgentService = class {
|
|
|
8656
9012
|
const present = await Promise.all(
|
|
8657
9013
|
dirents.filter((d) => d.isDirectory()).map(async (d) => {
|
|
8658
9014
|
try {
|
|
8659
|
-
await fs15.access(
|
|
9015
|
+
await fs15.access(path11.join(base, d.name, "SKILL.md"));
|
|
8660
9016
|
return true;
|
|
8661
9017
|
} catch {
|
|
8662
9018
|
return false;
|
|
@@ -8677,7 +9033,7 @@ var AgentService = class {
|
|
|
8677
9033
|
const counts = await Promise.all(
|
|
8678
9034
|
scopes.filter((d) => d.isDirectory()).map(async (d) => {
|
|
8679
9035
|
try {
|
|
8680
|
-
const files = await fs15.readdir(
|
|
9036
|
+
const files = await fs15.readdir(path11.join(base, d.name), { withFileTypes: true });
|
|
8681
9037
|
return files.filter((f) => f.isFile() && isTopicFileName(f.name)).length;
|
|
8682
9038
|
} catch {
|
|
8683
9039
|
return 0;
|
|
@@ -8778,7 +9134,7 @@ var AgentService = class {
|
|
|
8778
9134
|
|
|
8779
9135
|
// src/services/benchmark-service.ts
|
|
8780
9136
|
import fs16 from "fs/promises";
|
|
8781
|
-
import
|
|
9137
|
+
import path12 from "path";
|
|
8782
9138
|
import { parse as parseToml3 } from "smol-toml";
|
|
8783
9139
|
import { parse as parseYaml2 } from "yaml";
|
|
8784
9140
|
import { benchmarksDir } from "@prismshadow/penguin-core";
|
|
@@ -8809,8 +9165,8 @@ function stringOr(v) {
|
|
|
8809
9165
|
return typeof v === "string" && v !== "" ? v : void 0;
|
|
8810
9166
|
}
|
|
8811
9167
|
function isWithin(parent, child) {
|
|
8812
|
-
const relative =
|
|
8813
|
-
return relative !== "" && !relative.startsWith("..") && !
|
|
9168
|
+
const relative = path12.relative(parent, child);
|
|
9169
|
+
return relative !== "" && !relative.startsWith("..") && !path12.isAbsolute(relative);
|
|
8814
9170
|
}
|
|
8815
9171
|
function statementTitle(statement, fallback) {
|
|
8816
9172
|
const heading = /^#\s+(.+)$/m.exec(statement)?.[1]?.trim();
|
|
@@ -8906,13 +9262,13 @@ var BenchmarkService = class {
|
|
|
8906
9262
|
}
|
|
8907
9263
|
const benchmarks = [];
|
|
8908
9264
|
for (const item of items.filter((i) => i.isDir).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
8909
|
-
benchmarks.push(await this.readBenchmark(
|
|
9265
|
+
benchmarks.push(await this.readBenchmark(path12.join(dir, item.name), item.name));
|
|
8910
9266
|
}
|
|
8911
9267
|
return { benchmarks };
|
|
8912
9268
|
}
|
|
8913
9269
|
async listCases(projectId, agentId, benchmarkId) {
|
|
8914
9270
|
const baseDir = benchmarksDir(this.root, projectId, agentId);
|
|
8915
|
-
const benchDir =
|
|
9271
|
+
const benchDir = path12.join(baseDir, benchmarkId);
|
|
8916
9272
|
let entries;
|
|
8917
9273
|
let realBaseDir;
|
|
8918
9274
|
let realBenchDir;
|
|
@@ -8937,7 +9293,7 @@ var BenchmarkService = class {
|
|
|
8937
9293
|
entry.name,
|
|
8938
9294
|
"statement"
|
|
8939
9295
|
);
|
|
8940
|
-
const realReadme = await fs16.realpath(
|
|
9296
|
+
const realReadme = await fs16.realpath(path12.join(statementDir, "README.md"));
|
|
8941
9297
|
if (!isWithin(statementDir, realReadme)) throw new Error("README escapes Statement");
|
|
8942
9298
|
cases.push({
|
|
8943
9299
|
id: entry.name,
|
|
@@ -8970,16 +9326,16 @@ var BenchmarkService = class {
|
|
|
8970
9326
|
return this.workspaceFiles.read(materialRoot, rel, options);
|
|
8971
9327
|
}
|
|
8972
9328
|
async caseMaterialRoot(projectId, agentId, benchmarkId, caseId, material) {
|
|
8973
|
-
const benchDir =
|
|
8974
|
-
const caseDir =
|
|
8975
|
-
const materialRoot =
|
|
9329
|
+
const benchDir = path12.join(benchmarksDir(this.root, projectId, agentId), benchmarkId);
|
|
9330
|
+
const caseDir = path12.join(benchDir, caseId);
|
|
9331
|
+
const materialRoot = path12.join(caseDir, material);
|
|
8976
9332
|
try {
|
|
8977
9333
|
const [realBenchDir, realCaseDir, realMaterialRoot] = await Promise.all([
|
|
8978
9334
|
fs16.realpath(benchDir),
|
|
8979
9335
|
fs16.realpath(caseDir),
|
|
8980
9336
|
fs16.realpath(materialRoot)
|
|
8981
9337
|
]);
|
|
8982
|
-
if (!isWithin(realBenchDir, realCaseDir) ||
|
|
9338
|
+
if (!isWithin(realBenchDir, realCaseDir) || path12.dirname(realMaterialRoot) !== realCaseDir || path12.basename(realMaterialRoot) !== material) {
|
|
8983
9339
|
throw new Error("Case material is not canonical");
|
|
8984
9340
|
}
|
|
8985
9341
|
return realMaterialRoot;
|
|
@@ -8993,7 +9349,7 @@ var BenchmarkService = class {
|
|
|
8993
9349
|
let runs;
|
|
8994
9350
|
try {
|
|
8995
9351
|
const config2 = asRecord2(
|
|
8996
|
-
parseToml3(await fs16.readFile(
|
|
9352
|
+
parseToml3(await fs16.readFile(path12.join(benchDir, "benchmark_config.toml"), "utf8"))
|
|
8997
9353
|
);
|
|
8998
9354
|
if (typeof config2.title === "string" && config2.title !== "") title = config2.title;
|
|
8999
9355
|
if (typeof config2.description === "string" && config2.description !== "") {
|
|
@@ -9008,7 +9364,7 @@ var BenchmarkService = class {
|
|
|
9008
9364
|
let evaluations = [];
|
|
9009
9365
|
try {
|
|
9010
9366
|
const scoreboard = asRecord2(
|
|
9011
|
-
parseYaml2(await fs16.readFile(
|
|
9367
|
+
parseYaml2(await fs16.readFile(path12.join(benchDir, "scoreboard.yaml"), "utf8"))
|
|
9012
9368
|
);
|
|
9013
9369
|
if (Array.isArray(scoreboard.evaluations)) {
|
|
9014
9370
|
evaluations = scoreboard.evaluations.map(toEvaluation).filter((e) => e !== null);
|
|
@@ -9033,9 +9389,9 @@ var BenchmarkService = class {
|
|
|
9033
9389
|
};
|
|
9034
9390
|
|
|
9035
9391
|
// src/services/snapshot-service.ts
|
|
9036
|
-
import { randomBytes as
|
|
9392
|
+
import { randomBytes as randomBytes4 } from "crypto";
|
|
9037
9393
|
import fs17 from "fs/promises";
|
|
9038
|
-
import
|
|
9394
|
+
import path13 from "path";
|
|
9039
9395
|
import * as tar from "tar";
|
|
9040
9396
|
import { parse as parseYaml3 } from "yaml";
|
|
9041
9397
|
import {
|
|
@@ -9048,7 +9404,7 @@ import {
|
|
|
9048
9404
|
} from "@prismshadow/penguin-core";
|
|
9049
9405
|
var VAULT_BASENAME = ".vault.toml";
|
|
9050
9406
|
function isVaultEntry(entryPath) {
|
|
9051
|
-
return
|
|
9407
|
+
return path13.posix.basename(entryPath.replaceAll("\\", "/")) === VAULT_BASENAME;
|
|
9052
9408
|
}
|
|
9053
9409
|
var SnapshotService = class {
|
|
9054
9410
|
constructor(root) {
|
|
@@ -9072,14 +9428,14 @@ var SnapshotService = class {
|
|
|
9072
9428
|
async ensureSnapshot(projectId, agentId) {
|
|
9073
9429
|
const version = await this.currentVersion(projectId, agentId);
|
|
9074
9430
|
const dir = snapshotsDir(this.root, projectId, agentId);
|
|
9075
|
-
const file =
|
|
9431
|
+
const file = path13.join(dir, `v${version}.tar.gz`);
|
|
9076
9432
|
try {
|
|
9077
9433
|
await fs17.access(file);
|
|
9078
9434
|
return { version, file };
|
|
9079
9435
|
} catch {
|
|
9080
9436
|
}
|
|
9081
9437
|
await fs17.mkdir(dir, { recursive: true });
|
|
9082
|
-
const tmp = `${file}.tmp-${
|
|
9438
|
+
const tmp = `${file}.tmp-${randomBytes4(4).toString("hex")}`;
|
|
9083
9439
|
await tar.create(
|
|
9084
9440
|
{
|
|
9085
9441
|
gzip: true,
|
|
@@ -9107,12 +9463,12 @@ var SnapshotService = class {
|
|
|
9107
9463
|
async importArchive(projectId, agentId, archive, confirm) {
|
|
9108
9464
|
const current2 = await this.currentVersion(projectId, agentId);
|
|
9109
9465
|
const base = agentDir2(this.root, projectId, agentId);
|
|
9110
|
-
const staging =
|
|
9466
|
+
const staging = path13.join(base, `.import-${randomBytes4(6).toString("hex")}`);
|
|
9111
9467
|
await fs17.mkdir(staging, { recursive: true });
|
|
9112
9468
|
try {
|
|
9113
|
-
const archiveFile =
|
|
9469
|
+
const archiveFile = path13.join(staging, "archive.tar.gz");
|
|
9114
9470
|
await fs17.writeFile(archiveFile, archive);
|
|
9115
|
-
const extractDir =
|
|
9471
|
+
const extractDir = path13.join(staging, "extracted");
|
|
9116
9472
|
await fs17.mkdir(extractDir, { recursive: true });
|
|
9117
9473
|
try {
|
|
9118
9474
|
await tar.extract({
|
|
@@ -9123,7 +9479,7 @@ var SnapshotService = class {
|
|
|
9123
9479
|
} catch {
|
|
9124
9480
|
throw badRequest("Import failed: not a valid tar.gz snapshot package.");
|
|
9125
9481
|
}
|
|
9126
|
-
const configPath =
|
|
9482
|
+
const configPath = path13.join(extractDir, "agent_state", "system_config.yaml");
|
|
9127
9483
|
let parsed;
|
|
9128
9484
|
try {
|
|
9129
9485
|
parsed = parseYaml3(await fs17.readFile(configPath, "utf8"));
|
|
@@ -9149,16 +9505,16 @@ var SnapshotService = class {
|
|
|
9149
9505
|
}
|
|
9150
9506
|
await this.ensureSnapshot(projectId, agentId);
|
|
9151
9507
|
const stateDir = agentStateDir2(this.root, projectId, agentId);
|
|
9152
|
-
const incomingState =
|
|
9508
|
+
const incomingState = path13.join(extractDir, "agent_state");
|
|
9153
9509
|
let vault = null;
|
|
9154
9510
|
try {
|
|
9155
9511
|
vault = await fs17.readFile(agentVaultPath(this.root, projectId, agentId));
|
|
9156
9512
|
} catch {
|
|
9157
9513
|
}
|
|
9158
9514
|
if (vault !== null) {
|
|
9159
|
-
await fs17.writeFile(
|
|
9515
|
+
await fs17.writeFile(path13.join(incomingState, VAULT_BASENAME), vault, { mode: 384 });
|
|
9160
9516
|
}
|
|
9161
|
-
const trash =
|
|
9517
|
+
const trash = path13.join(staging, "replaced-agent_state");
|
|
9162
9518
|
await fs17.rename(stateDir, trash);
|
|
9163
9519
|
try {
|
|
9164
9520
|
await fs17.rename(incomingState, stateDir);
|
|
@@ -9483,7 +9839,7 @@ import { createAgent as createAgent2, isSessionMeta as isSessionMeta5 } from "@p
|
|
|
9483
9839
|
|
|
9484
9840
|
// src/services/trace-index.ts
|
|
9485
9841
|
import fs19 from "fs/promises";
|
|
9486
|
-
import
|
|
9842
|
+
import path14 from "path";
|
|
9487
9843
|
import { agentsDir as agentsDir3, isSessionMeta as isSessionMeta4, tracesDir as tracesDir2 } from "@prismshadow/penguin-core";
|
|
9488
9844
|
|
|
9489
9845
|
// src/internal/trace-head.ts
|
|
@@ -9516,7 +9872,7 @@ async function readTraceHead(filePath) {
|
|
|
9516
9872
|
var TRACE_FILE_RE = /^(.+)_(\d{3})\.jsonl$/;
|
|
9517
9873
|
function traceFilePath(root, row) {
|
|
9518
9874
|
const name = `${row.sessionId}_${String(row.fileIndex).padStart(3, "0")}.jsonl`;
|
|
9519
|
-
return
|
|
9875
|
+
return path14.join(tracesDir2(root, row.projectId, row.agentId), row.date, name);
|
|
9520
9876
|
}
|
|
9521
9877
|
async function listDirs(dir) {
|
|
9522
9878
|
try {
|
|
@@ -9576,9 +9932,10 @@ var TraceIndexService = class {
|
|
|
9576
9932
|
*/
|
|
9577
9933
|
counters = { gateStats: 0, dirScans: 0, headReads: 0 };
|
|
9578
9934
|
/**
|
|
9579
|
-
* Brings one Agent's index in step with disk. Hot path
|
|
9580
|
-
*
|
|
9581
|
-
*
|
|
9935
|
+
* Brings one Agent's index in step with disk. Hot path: one root stat, then the
|
|
9936
|
+
* newest date dir — a change there short-circuits — else the remaining N-1 date dirs
|
|
9937
|
+
* concurrently; zero readdir either way. `force` ignores the mtime gate and diffs
|
|
9938
|
+
* every date dir (the consumers' miss-retry path).
|
|
9582
9939
|
*/
|
|
9583
9940
|
reconcileAgent(projectId, agentId, opts = {}) {
|
|
9584
9941
|
const key = `${projectId}\0${agentId}`;
|
|
@@ -9610,11 +9967,18 @@ var TraceIndexService = class {
|
|
|
9610
9967
|
return;
|
|
9611
9968
|
}
|
|
9612
9969
|
if (!force && cached && cached.root === rootMtime) {
|
|
9613
|
-
const
|
|
9970
|
+
const dates = [...cached.dates.keys()].sort();
|
|
9971
|
+
const newest = dates.pop();
|
|
9614
9972
|
if (newest === void 0) return;
|
|
9615
9973
|
this.counters.gateStats += 1;
|
|
9616
|
-
const
|
|
9617
|
-
|
|
9974
|
+
const newestMtime = await statMtime(path14.join(dir, newest));
|
|
9975
|
+
let changed = newestMtime === null || newestMtime !== cached.dates.get(newest);
|
|
9976
|
+
if (!changed && dates.length > 0) {
|
|
9977
|
+
this.counters.gateStats += dates.length;
|
|
9978
|
+
const older = await Promise.all(dates.map((d) => statMtime(path14.join(dir, d))));
|
|
9979
|
+
changed = older.some((m, i) => m === null || m !== cached.dates.get(dates[i]));
|
|
9980
|
+
}
|
|
9981
|
+
if (!changed) return;
|
|
9618
9982
|
}
|
|
9619
9983
|
this.counters.dirScans += 1;
|
|
9620
9984
|
const next = { root: cacheable(rootMtime), dates: /* @__PURE__ */ new Map() };
|
|
@@ -9627,14 +9991,14 @@ var TraceIndexService = class {
|
|
|
9627
9991
|
const newSessions = /* @__PURE__ */ new Set();
|
|
9628
9992
|
const dateDirs = await listDirs(dir);
|
|
9629
9993
|
for (const date of dateDirs) {
|
|
9630
|
-
const m = await statMtime(
|
|
9994
|
+
const m = await statMtime(path14.join(dir, date));
|
|
9631
9995
|
if (m === null) continue;
|
|
9632
9996
|
next.dates.set(date, cacheable(m));
|
|
9633
9997
|
if (!force && cached?.dates.get(date) === m) continue;
|
|
9634
9998
|
const known = new Map(
|
|
9635
9999
|
(knownByDate.get(date) ?? []).map((r) => [`${r.sessionId}\0${r.fileIndex}`, r])
|
|
9636
10000
|
);
|
|
9637
|
-
for (const file of await listFiles(
|
|
10001
|
+
for (const file of await listFiles(path14.join(dir, date))) {
|
|
9638
10002
|
const match = TRACE_FILE_RE.exec(file);
|
|
9639
10003
|
if (!match) continue;
|
|
9640
10004
|
const sessionId = match[1];
|
|
@@ -9642,7 +10006,7 @@ var TraceIndexService = class {
|
|
|
9642
10006
|
const fileKey = `${sessionId}\0${fileIndex}`;
|
|
9643
10007
|
let size = 0;
|
|
9644
10008
|
try {
|
|
9645
|
-
size = (await fs19.stat(
|
|
10009
|
+
size = (await fs19.stat(path14.join(dir, date, file))).size;
|
|
9646
10010
|
} catch {
|
|
9647
10011
|
continue;
|
|
9648
10012
|
}
|
|
@@ -9748,9 +10112,11 @@ var SessionService = class {
|
|
|
9748
10112
|
modelId: row.modelId,
|
|
9749
10113
|
workspace: row.workspace,
|
|
9750
10114
|
approvalMode: row.approvalMode,
|
|
10115
|
+
...row.thinkingLevel ? { thinkingLevel: row.thinkingLevel } : {},
|
|
9751
10116
|
...row.title !== null ? { title: row.title } : {},
|
|
9752
10117
|
...source !== void 0 ? { source } : {},
|
|
9753
10118
|
createdAt: row.createdAt,
|
|
10119
|
+
lastActiveAt: row.lastActiveAt,
|
|
9754
10120
|
status: this.deps.manager.statusOf(row.sessionId),
|
|
9755
10121
|
pendingApprovalCount: this.deps.manager.pendingApprovalCount(row.sessionId),
|
|
9756
10122
|
pendingFollowUpCount: this.deps.manager.pendingFollowUpCount(row.sessionId),
|
|
@@ -9962,6 +10328,7 @@ var SessionService = class {
|
|
|
9962
10328
|
session.sessionId,
|
|
9963
10329
|
isSessionMeta5(metaMsg) ? asSessionSource(metaMsg.payload.source) ?? null : null
|
|
9964
10330
|
);
|
|
10331
|
+
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
9965
10332
|
const row = {
|
|
9966
10333
|
sessionId: session.sessionId,
|
|
9967
10334
|
projectId: args.projectId,
|
|
@@ -9974,7 +10341,9 @@ var SessionService = class {
|
|
|
9974
10341
|
// Everything created through this service is the Web App's (schedule runs included);
|
|
9975
10342
|
// "cli" is stamped only by Trace adoption. NULL means a legacy row, treated as web.
|
|
9976
10343
|
client: "web",
|
|
9977
|
-
|
|
10344
|
+
// Creation is the first activity; the first driven run advances it (see SessionManager.drive).
|
|
10345
|
+
lastActiveAt: createdAt,
|
|
10346
|
+
createdAt
|
|
9978
10347
|
};
|
|
9979
10348
|
this.deps.sessions.insert(row);
|
|
9980
10349
|
this.deps.manager.adopt(row, session);
|
|
@@ -10029,6 +10398,7 @@ var SessionService = class {
|
|
|
10029
10398
|
if (!facts?.metaRead) return null;
|
|
10030
10399
|
if (facts.provider === null || facts.modelId === null) return null;
|
|
10031
10400
|
this.deps.sources.set(sessionId, facts.source);
|
|
10401
|
+
const createdAt = sessionIdCreatedAt(sessionId) ?? facts.firstTs ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
10032
10402
|
const row = {
|
|
10033
10403
|
sessionId,
|
|
10034
10404
|
projectId,
|
|
@@ -10043,7 +10413,11 @@ var SessionService = class {
|
|
|
10043
10413
|
// (web-only) excludes these rows; the "show CLI sessions" preference includes them.
|
|
10044
10414
|
client: "cli",
|
|
10045
10415
|
hasTrace: true,
|
|
10046
|
-
createdAt
|
|
10416
|
+
createdAt,
|
|
10417
|
+
// The CLI's own activity leaves no mark on this row (this server drives none of its
|
|
10418
|
+
// runs), so an adopted Session reads as last-active at its creation time until it is
|
|
10419
|
+
// resumed here; the Trace tail is not consulted (see SessionRow.lastActiveAt).
|
|
10420
|
+
lastActiveAt: createdAt
|
|
10047
10421
|
};
|
|
10048
10422
|
this.deps.sessions.insertOrIgnore(row);
|
|
10049
10423
|
return row;
|
|
@@ -10056,16 +10430,69 @@ function localDate(d) {
|
|
|
10056
10430
|
|
|
10057
10431
|
// src/services/trace-service.ts
|
|
10058
10432
|
import fs20 from "fs/promises";
|
|
10059
|
-
import
|
|
10433
|
+
import path15 from "path";
|
|
10434
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
10060
10435
|
import {
|
|
10436
|
+
attachedFileLine as attachedFileLine2,
|
|
10437
|
+
attachedImageLine,
|
|
10061
10438
|
isSessionMeta as isSessionMeta6,
|
|
10439
|
+
matchAttachedFileLine,
|
|
10440
|
+
matchAttachedImageLine,
|
|
10441
|
+
modelVisiblePath as modelVisiblePath2,
|
|
10062
10442
|
parseTraceLines as parseTraceLines2,
|
|
10063
10443
|
parseUserSteeringText as parseUserSteeringText3,
|
|
10064
10444
|
readTraceTolerant as readTraceTolerant2,
|
|
10445
|
+
resumeTrace,
|
|
10446
|
+
scratchpadDir as scratchpadDir2,
|
|
10065
10447
|
tracesDir as tracesDir3
|
|
10066
10448
|
} from "@prismshadow/penguin-core";
|
|
10067
10449
|
var IMPORT_SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
10068
10450
|
var MAX_SUBAGENT_DEPTH = 4;
|
|
10451
|
+
function forkSessionId(date) {
|
|
10452
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
10453
|
+
return `session-${formatLocalDate(date)}-${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}-${randomUUID3().replaceAll("-", "").slice(0, 8)}`;
|
|
10454
|
+
}
|
|
10455
|
+
function isAssistantText(msg) {
|
|
10456
|
+
const p = msg.payload;
|
|
10457
|
+
return msg.type === "model_msg" && p.type === "text" && p.role === "assistant" && typeof p.text === "string" && p.text.trim() !== "";
|
|
10458
|
+
}
|
|
10459
|
+
function isTaskStartingUser(msg) {
|
|
10460
|
+
const p = msg.payload;
|
|
10461
|
+
if (msg.type !== "model_msg") return false;
|
|
10462
|
+
if (p.type === "image_url") return true;
|
|
10463
|
+
if (p.type !== "text" || p.role !== "user" || typeof p.text !== "string") return false;
|
|
10464
|
+
if (parseUserSteeringText3(p.text) !== null) return false;
|
|
10465
|
+
return !p.text.startsWith("[context_summary]") && !p.text.startsWith("<context_summary>");
|
|
10466
|
+
}
|
|
10467
|
+
function rewriteForkText(text, sourceScratchpad, forkScratchpad) {
|
|
10468
|
+
const source = modelVisiblePath2(sourceScratchpad).replace(/\/$/, "");
|
|
10469
|
+
const target = modelVisiblePath2(forkScratchpad).replace(/\/$/, "");
|
|
10470
|
+
const rewriteTarget = (value) => {
|
|
10471
|
+
if (value === source) return target;
|
|
10472
|
+
if (value.startsWith(`${source}/`)) return `${target}${value.slice(source.length)}`;
|
|
10473
|
+
return null;
|
|
10474
|
+
};
|
|
10475
|
+
return text.split("\n").map((line) => {
|
|
10476
|
+
const trimmed = line.trim();
|
|
10477
|
+
const image = matchAttachedImageLine(trimmed);
|
|
10478
|
+
if (image !== null) {
|
|
10479
|
+
const rewritten = rewriteTarget(image);
|
|
10480
|
+
return rewritten === null ? line : line.replace(trimmed, attachedImageLine(rewritten));
|
|
10481
|
+
}
|
|
10482
|
+
const file = matchAttachedFileLine(trimmed);
|
|
10483
|
+
if (file !== null) {
|
|
10484
|
+
const rewritten = rewriteTarget(file);
|
|
10485
|
+
return rewritten === null ? line : line.replace(trimmed, attachedFileLine2(rewritten));
|
|
10486
|
+
}
|
|
10487
|
+
return line;
|
|
10488
|
+
}).join("\n");
|
|
10489
|
+
}
|
|
10490
|
+
function positioned(messages, fileIndex, fromOrdinal = 0) {
|
|
10491
|
+
return messages.map((msg, i) => ({
|
|
10492
|
+
...msg,
|
|
10493
|
+
tracePosition: { fileIndex, ordinal: fromOrdinal + i }
|
|
10494
|
+
}));
|
|
10495
|
+
}
|
|
10069
10496
|
function subagentPointer(msg) {
|
|
10070
10497
|
if (msg.type !== "event_msg") return null;
|
|
10071
10498
|
const p = msg.payload;
|
|
@@ -10084,8 +10511,10 @@ var TraceService = class {
|
|
|
10084
10511
|
/**
|
|
10085
10512
|
* All of this Session's Trace files (sorted by index ascending), served from the
|
|
10086
10513
|
* index. An empty answer force-reconciles once and retries before being believed:
|
|
10087
|
-
* disk is the source of truth, and a gate blind spot (
|
|
10088
|
-
*
|
|
10514
|
+
* disk is the source of truth, and a gate blind spot (a backdated write, or an
|
|
10515
|
+
* in-place append, which moves no directory mtime — see trace-index's header) must
|
|
10516
|
+
* cost one extra scan, never a false 404. Note this retry only covers a WHOLE-session
|
|
10517
|
+
* miss: a session with some shards indexed and a later one missed is served as-is.
|
|
10089
10518
|
*/
|
|
10090
10519
|
async locateAll(projectId, agentId, sessionId) {
|
|
10091
10520
|
await this.deps.index.reconcileAgent(projectId, agentId);
|
|
@@ -10140,7 +10569,8 @@ var TraceService = class {
|
|
|
10140
10569
|
const files = await this.locateAll(projectId, agentId, sessionId);
|
|
10141
10570
|
const out = [];
|
|
10142
10571
|
for (const file of files) {
|
|
10143
|
-
|
|
10572
|
+
const raw = await this.readShard(file.path);
|
|
10573
|
+
out.push(...await this.expandMessages(projectId, positioned(raw, file.index), ctx));
|
|
10144
10574
|
}
|
|
10145
10575
|
return out;
|
|
10146
10576
|
}
|
|
@@ -10349,11 +10779,154 @@ var TraceService = class {
|
|
|
10349
10779
|
const messages = shardMessages.get(pos) ?? await this.readShard(files[pos].path);
|
|
10350
10780
|
const from = pos === start.pos ? start.ordinal : 0;
|
|
10351
10781
|
const to = pos === endPos && endOrdinal !== null ? Math.min(endOrdinal, messages.length) : messages.length;
|
|
10352
|
-
for (let i = from; i < to; i++)
|
|
10782
|
+
for (let i = from; i < to; i++) {
|
|
10783
|
+
windowRaw.push({
|
|
10784
|
+
...messages[i],
|
|
10785
|
+
tracePosition: { fileIndex: files[pos].index, ordinal: i }
|
|
10786
|
+
});
|
|
10787
|
+
}
|
|
10353
10788
|
}
|
|
10354
10789
|
const expanded = await this.expandMessages(projectId, windowRaw, ctx);
|
|
10355
10790
|
return { messages: expanded, ...before !== void 0 ? { before } : {}, prior };
|
|
10356
10791
|
}
|
|
10792
|
+
/**
|
|
10793
|
+
* Clone a source Session through one completed root-assistant reply. The supplied position
|
|
10794
|
+
* names the reply record itself; this method validates it against raw Trace structure and
|
|
10795
|
+
* chooses the exclusive cut after the request's closing records but before a later Task or
|
|
10796
|
+
* compaction. Earlier shards stay intact so the fork renders the same history, while core
|
|
10797
|
+
* resumes from the selected shard exactly as it would after a restart.
|
|
10798
|
+
*/
|
|
10799
|
+
async forkSessionTrace(projectId, agentId, sourceSessionId, position) {
|
|
10800
|
+
const invalid = (message) => new HttpError(400, "invalid_fork_position", message);
|
|
10801
|
+
const files = await this.locateAll(projectId, agentId, sourceSessionId);
|
|
10802
|
+
const targetFilePos = files.findIndex((file) => file.index === position.fileIndex);
|
|
10803
|
+
if (targetFilePos < 0) throw invalid("The selected reply no longer exists in this Session.");
|
|
10804
|
+
const shards = [];
|
|
10805
|
+
for (let i = 0; i <= targetFilePos; i++) shards.push(await this.readShard(files[i].path));
|
|
10806
|
+
const targetShard = shards[targetFilePos];
|
|
10807
|
+
const target = targetShard[position.ordinal];
|
|
10808
|
+
if (!target || !isAssistantText(target)) {
|
|
10809
|
+
throw invalid("The fork position must identify a completed assistant reply.");
|
|
10810
|
+
}
|
|
10811
|
+
let inCompaction = false;
|
|
10812
|
+
for (let filePos = 0; filePos <= targetFilePos; filePos++) {
|
|
10813
|
+
const messages = shards[filePos];
|
|
10814
|
+
const stop = filePos === targetFilePos ? position.ordinal : messages.length;
|
|
10815
|
+
for (let i = 0; i < stop; i++) {
|
|
10816
|
+
const p = messages[i].payload;
|
|
10817
|
+
if (messages[i].type === "event_msg" && p.type === "compaction_begin") {
|
|
10818
|
+
inCompaction = true;
|
|
10819
|
+
} else if (messages[i].type === "event_msg" && p.type === "compaction_end") {
|
|
10820
|
+
inCompaction = false;
|
|
10821
|
+
}
|
|
10822
|
+
}
|
|
10823
|
+
}
|
|
10824
|
+
if (inCompaction) throw invalid("A compaction summary cannot be used as a fork point.");
|
|
10825
|
+
let cutOrdinal = position.ordinal + 1;
|
|
10826
|
+
let completedRequest = false;
|
|
10827
|
+
for (let i = position.ordinal + 1; i < targetShard.length; i++) {
|
|
10828
|
+
const msg = targetShard[i];
|
|
10829
|
+
const p = msg.payload;
|
|
10830
|
+
if (isTaskStartingUser(msg) || msg.type === "event_msg" && p.type === "compaction_begin") {
|
|
10831
|
+
break;
|
|
10832
|
+
}
|
|
10833
|
+
if (isAssistantText(msg)) {
|
|
10834
|
+
throw invalid("The selected record is not the final assistant reply of its Task.");
|
|
10835
|
+
}
|
|
10836
|
+
cutOrdinal = i + 1;
|
|
10837
|
+
if (msg.type === "event_msg" && p.type === "request_end") {
|
|
10838
|
+
if (p.status === "completed") completedRequest = true;
|
|
10839
|
+
else throw invalid("The selected assistant reply belongs to an incomplete request.");
|
|
10840
|
+
}
|
|
10841
|
+
}
|
|
10842
|
+
if (!completedRequest) throw invalid("The selected assistant reply has not completed yet.");
|
|
10843
|
+
const resumed = resumeTrace(targetShard.slice(0, cutOrdinal));
|
|
10844
|
+
if (resumed.contextClosed || resumed.carryOver.length > 0 || !resumed.history.some((message) => message === target)) {
|
|
10845
|
+
throw invalid("The selected reply is not a complete resumable Task boundary.");
|
|
10846
|
+
}
|
|
10847
|
+
const firstMeta = shards.flat().find(isSessionMeta6);
|
|
10848
|
+
if (!firstMeta) throw invalid("The source Trace has no session metadata.");
|
|
10849
|
+
const created = /* @__PURE__ */ new Date();
|
|
10850
|
+
const createdAt = created.toISOString();
|
|
10851
|
+
const newSessionId = forkSessionId(created);
|
|
10852
|
+
const scratchpadRoot = scratchpadDir2(this.root, projectId, agentId);
|
|
10853
|
+
const sourceScratchpad = path15.join(scratchpadRoot, sourceSessionId);
|
|
10854
|
+
const forkScratchpad = path15.join(scratchpadRoot, newSessionId);
|
|
10855
|
+
const written = [];
|
|
10856
|
+
const rewrite = (msg) => {
|
|
10857
|
+
if (isSessionMeta6(msg)) {
|
|
10858
|
+
const sourcePrompt = msg.payload.system_prompt;
|
|
10859
|
+
const sourceVisible = modelVisiblePath2(sourceScratchpad);
|
|
10860
|
+
const forkVisible = modelVisiblePath2(forkScratchpad);
|
|
10861
|
+
const payload = {
|
|
10862
|
+
...msg.payload,
|
|
10863
|
+
session_id: newSessionId,
|
|
10864
|
+
system_prompt: sourcePrompt.split(sourceSessionId).join(newSessionId).split(sourceVisible).join(forkVisible)
|
|
10865
|
+
};
|
|
10866
|
+
delete payload.source;
|
|
10867
|
+
return { ...msg, payload };
|
|
10868
|
+
}
|
|
10869
|
+
const p = msg.payload;
|
|
10870
|
+
if (msg.type === "model_msg" && p.type === "text" && p.role === "user" && typeof p.text === "string") {
|
|
10871
|
+
return {
|
|
10872
|
+
...msg,
|
|
10873
|
+
payload: {
|
|
10874
|
+
...msg.payload,
|
|
10875
|
+
text: rewriteForkText(p.text, sourceScratchpad, forkScratchpad)
|
|
10876
|
+
}
|
|
10877
|
+
};
|
|
10878
|
+
}
|
|
10879
|
+
return msg;
|
|
10880
|
+
};
|
|
10881
|
+
try {
|
|
10882
|
+
try {
|
|
10883
|
+
const stat = await fs20.stat(sourceScratchpad);
|
|
10884
|
+
if (stat.isDirectory()) {
|
|
10885
|
+
await fs20.mkdir(scratchpadRoot, { recursive: true });
|
|
10886
|
+
await fs20.cp(sourceScratchpad, forkScratchpad, {
|
|
10887
|
+
recursive: true,
|
|
10888
|
+
force: false,
|
|
10889
|
+
errorOnExist: true
|
|
10890
|
+
});
|
|
10891
|
+
}
|
|
10892
|
+
} catch (err) {
|
|
10893
|
+
if (err.code !== "ENOENT") throw err;
|
|
10894
|
+
}
|
|
10895
|
+
for (let filePos = 0; filePos <= targetFilePos; filePos++) {
|
|
10896
|
+
const sourceFile = files[filePos];
|
|
10897
|
+
const sourceMessages = shards[filePos];
|
|
10898
|
+
const limit = filePos === targetFilePos ? cutOrdinal : sourceMessages.length;
|
|
10899
|
+
const records = sourceMessages.slice(0, limit).map(rewrite);
|
|
10900
|
+
if (!isSessionMeta6(records[0])) {
|
|
10901
|
+
throw invalid(`Trace shard ${sourceFile.index} does not start with session metadata.`);
|
|
10902
|
+
}
|
|
10903
|
+
const dir = path15.join(tracesDir3(this.root, projectId, agentId), sourceFile.date);
|
|
10904
|
+
await fs20.mkdir(dir, { recursive: true });
|
|
10905
|
+
const file = path15.join(
|
|
10906
|
+
dir,
|
|
10907
|
+
`${newSessionId}_${String(sourceFile.index).padStart(3, "0")}.jsonl`
|
|
10908
|
+
);
|
|
10909
|
+
const body = records.map((record) => JSON.stringify(record)).join("\n") + "\n";
|
|
10910
|
+
await fs20.writeFile(file, body, { encoding: "utf8", flag: "wx" });
|
|
10911
|
+
written.push(file);
|
|
10912
|
+
this.deps.index.registerImportedFile({
|
|
10913
|
+
projectId,
|
|
10914
|
+
agentId,
|
|
10915
|
+
sessionId: newSessionId,
|
|
10916
|
+
fileIndex: sourceFile.index,
|
|
10917
|
+
date: sourceFile.date,
|
|
10918
|
+
sizeBytes: Buffer.byteLength(body, "utf8"),
|
|
10919
|
+
records
|
|
10920
|
+
});
|
|
10921
|
+
}
|
|
10922
|
+
return { sessionId: newSessionId, createdAt };
|
|
10923
|
+
} catch (err) {
|
|
10924
|
+
await Promise.all(written.map((file) => fs20.rm(file, { force: true }).catch(() => void 0)));
|
|
10925
|
+
await fs20.rm(forkScratchpad, { recursive: true, force: true }).catch(() => void 0);
|
|
10926
|
+
this.deps.index.removeSession(projectId, agentId, newSessionId);
|
|
10927
|
+
throw err;
|
|
10928
|
+
}
|
|
10929
|
+
}
|
|
10357
10930
|
/** List of Trace files (index / date / size / mtime). */
|
|
10358
10931
|
async listTraceFiles(projectId, agentId, sessionId) {
|
|
10359
10932
|
const files = await this.locateAll(projectId, agentId, sessionId);
|
|
@@ -10400,6 +10973,7 @@ var TraceService = class {
|
|
|
10400
10973
|
let steeringImages = false;
|
|
10401
10974
|
let sawToolCallThisRequest = false;
|
|
10402
10975
|
let compactionActive = false;
|
|
10976
|
+
let compactionMode = null;
|
|
10403
10977
|
const taskStats = /* @__PURE__ */ new Map();
|
|
10404
10978
|
const ensureTask = (ti) => {
|
|
10405
10979
|
let t = taskStats.get(ti);
|
|
@@ -10447,7 +11021,10 @@ var TraceService = class {
|
|
|
10447
11021
|
requests.push(openRequest);
|
|
10448
11022
|
if (!hasOrigin) {
|
|
10449
11023
|
const t = ensureTask(taskIndex);
|
|
10450
|
-
if (compactionActive)
|
|
11024
|
+
if (compactionActive) {
|
|
11025
|
+
t.compaction = true;
|
|
11026
|
+
if (compactionMode !== null) t.compactionMode = compactionMode;
|
|
11027
|
+
}
|
|
10451
11028
|
if (t.startTs === "") t.startTs = msg.timestamp;
|
|
10452
11029
|
if (pendingFrom !== null) {
|
|
10453
11030
|
for (let k = pendingFrom; k < mi; k++) {
|
|
@@ -10513,11 +11090,14 @@ var TraceService = class {
|
|
|
10513
11090
|
if (!hasOrigin) {
|
|
10514
11091
|
continuation = false;
|
|
10515
11092
|
compactionActive = true;
|
|
11093
|
+
const mode = p.mode;
|
|
11094
|
+
compactionMode = mode === "summarize" || mode === "discard" ? mode : null;
|
|
10516
11095
|
}
|
|
10517
11096
|
} else if (p.type === "compaction_end") {
|
|
10518
11097
|
if (!hasOrigin) {
|
|
10519
11098
|
continuation = false;
|
|
10520
11099
|
compactionActive = false;
|
|
11100
|
+
compactionMode = null;
|
|
10521
11101
|
}
|
|
10522
11102
|
} else if (p.type === "token_usage") {
|
|
10523
11103
|
const request = p.request;
|
|
@@ -10612,6 +11192,13 @@ var TraceService = class {
|
|
|
10612
11192
|
const t = ensureTask(ti);
|
|
10613
11193
|
if (t.messageFrom < 0 || k < t.messageFrom) t.messageFrom = k;
|
|
10614
11194
|
if (k > t.messageTo) t.messageTo = k;
|
|
11195
|
+
const cur = messages[k];
|
|
11196
|
+
const cp = cur.payload;
|
|
11197
|
+
const fromSubagent = cur.origin !== void 0 && cur.origin.length > 0;
|
|
11198
|
+
if (!fromSubagent && cur.type === "event_msg" && cp.type === "compaction_begin") {
|
|
11199
|
+
t.compaction = true;
|
|
11200
|
+
if (cp.mode === "summarize" || cp.mode === "discard") t.compactionMode = cp.mode;
|
|
11201
|
+
}
|
|
10615
11202
|
if (messages[k].type === "session_meta") continue;
|
|
10616
11203
|
const ts = messages[k].timestamp;
|
|
10617
11204
|
if (t.endTs === "" || ts > t.endTs) t.endTs = ts;
|
|
@@ -10812,9 +11399,9 @@ var TraceService = class {
|
|
|
10812
11399
|
const ts = Date.parse(first.timestamp);
|
|
10813
11400
|
const date = formatLocalDate(Number.isNaN(ts) ? /* @__PURE__ */ new Date() : new Date(ts));
|
|
10814
11401
|
const index = 1;
|
|
10815
|
-
const dir =
|
|
11402
|
+
const dir = path15.join(tracesDir3(this.root, projectId, agentId), date);
|
|
10816
11403
|
await fs20.mkdir(dir, { recursive: true });
|
|
10817
|
-
const file =
|
|
11404
|
+
const file = path15.join(dir, `${sessionId}_${String(index).padStart(3, "0")}.jsonl`);
|
|
10818
11405
|
const body = content.replace(/\n+$/, "") + "\n";
|
|
10819
11406
|
try {
|
|
10820
11407
|
await fs20.writeFile(file, body, { encoding: "utf8", flag: "wx" });
|
|
@@ -11132,11 +11719,573 @@ var UsageService = class {
|
|
|
11132
11719
|
}
|
|
11133
11720
|
};
|
|
11134
11721
|
|
|
11135
|
-
// src/
|
|
11722
|
+
// src/hmr/host.ts
|
|
11723
|
+
import crypto from "crypto";
|
|
11724
|
+
import fs21 from "fs";
|
|
11725
|
+
import fsp from "fs/promises";
|
|
11726
|
+
import path16 from "path";
|
|
11727
|
+
import zlib from "zlib";
|
|
11728
|
+
import { pathToFileURL } from "url";
|
|
11729
|
+
import { boot, initialDoc, upgrade } from "@prismshadow/penguin-core/kernel";
|
|
11730
|
+
|
|
11731
|
+
// src/hmr/resources.ts
|
|
11732
|
+
var HotResources = class {
|
|
11733
|
+
map = /* @__PURE__ */ new Map();
|
|
11734
|
+
register(id, resource, dispose) {
|
|
11735
|
+
this.map.set(id, { resource, dispose });
|
|
11736
|
+
}
|
|
11737
|
+
claim(id) {
|
|
11738
|
+
return this.map.get(id)?.resource;
|
|
11739
|
+
}
|
|
11740
|
+
release(id) {
|
|
11741
|
+
this.map.delete(id);
|
|
11742
|
+
}
|
|
11743
|
+
/**
|
|
11744
|
+
* Process-exit sweep: run every registered disposer. Not part of any upgrade path —
|
|
11745
|
+
* resources are meant to outlive swaps. A throwing disposer must not strand the rest.
|
|
11746
|
+
*/
|
|
11747
|
+
disposeAll() {
|
|
11748
|
+
for (const entry of this.map.values()) {
|
|
11749
|
+
try {
|
|
11750
|
+
entry.dispose?.();
|
|
11751
|
+
} catch {
|
|
11752
|
+
}
|
|
11753
|
+
}
|
|
11754
|
+
this.map.clear();
|
|
11755
|
+
}
|
|
11756
|
+
};
|
|
11757
|
+
|
|
11758
|
+
// src/platform/platform.ts
|
|
11759
|
+
import { defineIface, schema, type } from "@prismshadow/penguin-core/kernel";
|
|
11760
|
+
var PlatformIface = defineIface({
|
|
11761
|
+
name: "platform",
|
|
11762
|
+
version: 1,
|
|
11763
|
+
context: schema(type({ motd: "string" })),
|
|
11764
|
+
methods: ["park", "info"]
|
|
11765
|
+
});
|
|
11766
|
+
var platformImpl = {
|
|
11767
|
+
create(_ctx, context) {
|
|
11768
|
+
return {
|
|
11769
|
+
park: () => ({ motd: context.motd }),
|
|
11770
|
+
info: () => ({
|
|
11771
|
+
impl: "packaged",
|
|
11772
|
+
ifaceVersion: PlatformIface.version,
|
|
11773
|
+
motd: context.motd
|
|
11774
|
+
})
|
|
11775
|
+
};
|
|
11776
|
+
}
|
|
11777
|
+
};
|
|
11778
|
+
var packagedPlatform = {
|
|
11779
|
+
id: "packaged",
|
|
11780
|
+
iface: PlatformIface,
|
|
11781
|
+
impl: platformImpl,
|
|
11782
|
+
context: { motd: "hello from the penguin hot platform" }
|
|
11783
|
+
};
|
|
11784
|
+
|
|
11785
|
+
// src/hmr/host.ts
|
|
11786
|
+
var STORE_KEEP = 2;
|
|
11787
|
+
var HmrHost = class {
|
|
11788
|
+
constructor(root) {
|
|
11789
|
+
this.root = root;
|
|
11790
|
+
this.hmrDir = path16.join(root, "hmr");
|
|
11791
|
+
this.storeDir = path16.join(this.hmrDir, "store");
|
|
11792
|
+
this.manifestPath = path16.join(this.hmrDir, "harness.json");
|
|
11793
|
+
}
|
|
11794
|
+
root;
|
|
11795
|
+
resources = new HotResources();
|
|
11796
|
+
instance = null;
|
|
11797
|
+
implId = packagedPlatform.id;
|
|
11798
|
+
hmrDir;
|
|
11799
|
+
storeDir;
|
|
11800
|
+
manifestPath;
|
|
11801
|
+
/**
|
|
11802
|
+
* Single-flight guard for the first boot (see ensure()): every concurrent caller before the
|
|
11803
|
+
* first successful boot awaits the SAME promise, so restore() and the packaged-default
|
|
11804
|
+
* fallback can never run twice and race each other into assigning `this.instance` (a double
|
|
11805
|
+
* boot, or a restored version clobbered by a concurrently-booted packaged default).
|
|
11806
|
+
*/
|
|
11807
|
+
initPromise = null;
|
|
11808
|
+
/** The freeze, as a queue: everything the HTTP layer gates on chains here. */
|
|
11809
|
+
opQueue = Promise.resolve();
|
|
11810
|
+
warn(msg) {
|
|
11811
|
+
process.stderr.write(`[hmr] ${msg}
|
|
11812
|
+
`);
|
|
11813
|
+
}
|
|
11814
|
+
currentImplId() {
|
|
11815
|
+
return this.implId;
|
|
11816
|
+
}
|
|
11817
|
+
/**
|
|
11818
|
+
* The request gate: resolves once no swap is in flight. Requests arriving
|
|
11819
|
+
* during an upgrade wait here — they observe latency, never an error.
|
|
11820
|
+
*/
|
|
11821
|
+
waitIdle() {
|
|
11822
|
+
return this.opQueue.then(
|
|
11823
|
+
() => void 0,
|
|
11824
|
+
() => void 0
|
|
11825
|
+
);
|
|
11826
|
+
}
|
|
11827
|
+
/**
|
|
11828
|
+
* Lazy first boot. Resumes the last committed version (platform + cli + web)
|
|
11829
|
+
* from disk when present; otherwise boots the packaged platform v1 with a
|
|
11830
|
+
* fresh document. Concurrent callers before the first boot completes share one
|
|
11831
|
+
* in-flight promise (see `initPromise`) rather than each racing their own
|
|
11832
|
+
* restore()/packaged-boot.
|
|
11833
|
+
*/
|
|
11834
|
+
ensure() {
|
|
11835
|
+
if (this.instance !== null) return Promise.resolve(this.instance);
|
|
11836
|
+
this.initPromise ??= this.initialize();
|
|
11837
|
+
return this.initPromise;
|
|
11838
|
+
}
|
|
11839
|
+
/** Runs exactly once per process (guarded by `initPromise` in ensure()). */
|
|
11840
|
+
async initialize() {
|
|
11841
|
+
try {
|
|
11842
|
+
await this.restore();
|
|
11843
|
+
if (this.instance === null) {
|
|
11844
|
+
const bundle = packagedPlatform;
|
|
11845
|
+
this.instance = await boot(
|
|
11846
|
+
bundle.impl,
|
|
11847
|
+
bundle.iface,
|
|
11848
|
+
initialDoc(bundle.iface, bundle.context),
|
|
11849
|
+
this.resources
|
|
11850
|
+
);
|
|
11851
|
+
}
|
|
11852
|
+
return this.instance;
|
|
11853
|
+
} catch (err) {
|
|
11854
|
+
this.initPromise = null;
|
|
11855
|
+
throw err;
|
|
11856
|
+
}
|
|
11857
|
+
}
|
|
11858
|
+
/**
|
|
11859
|
+
* Resume from harness.json, as ONE unit: the platform bundle, the cli bundle's own
|
|
11860
|
+
* existence, and the web artifact are all read and validated BEFORE anything is
|
|
11861
|
+
* committed to `this.instance` / `this.webMem` — so a failure partway through (a
|
|
11862
|
+
* pruned bundle, a missing web artifact, a missing cli artifact) never leaves
|
|
11863
|
+
* platform and web resumed from different versions. The cli bundle is never
|
|
11864
|
+
* imported here (this process never runs it — only packages/cli's own loader
|
|
11865
|
+
* does); its file just has to exist, so a restore can never leave `cli.bundle`
|
|
11866
|
+
* pointing at nothing. Any failure is non-fatal: it warns and leaves the runtime
|
|
11867
|
+
* to boot the packaged default — a bad persisted version must never brick the
|
|
11868
|
+
* runtime. Only ever called once (from initialize(), itself single-flighted by
|
|
11869
|
+
* ensure()'s `initPromise`).
|
|
11870
|
+
*
|
|
11871
|
+
* Boots the restored bundle against its OWN fresh initial context (bundle.context),
|
|
11872
|
+
* not a resumed state — state is never written to disk (see the module doc), so a
|
|
11873
|
+
* restart always resumes the pushed CODE with a clean slate, never last run's doc.
|
|
11874
|
+
*/
|
|
11875
|
+
async restore() {
|
|
11876
|
+
let manifest;
|
|
11877
|
+
try {
|
|
11878
|
+
manifest = JSON.parse(await fsp.readFile(this.manifestPath, "utf8"));
|
|
11879
|
+
} catch {
|
|
11880
|
+
return;
|
|
11881
|
+
}
|
|
11882
|
+
if (manifest.platform === void 0 && manifest.web === void 0) {
|
|
11883
|
+
return;
|
|
11884
|
+
}
|
|
11885
|
+
try {
|
|
11886
|
+
if (manifest.platform === void 0) {
|
|
11887
|
+
throw new Error("harness.json has no `platform` entry");
|
|
11888
|
+
}
|
|
11889
|
+
if (manifest.cli === void 0) {
|
|
11890
|
+
throw new Error("harness.json has no `cli` entry");
|
|
11891
|
+
}
|
|
11892
|
+
if (manifest.web === void 0) {
|
|
11893
|
+
throw new Error("harness.json has no `web` entry");
|
|
11894
|
+
}
|
|
11895
|
+
const cliPath = path16.join(this.hmrDir, manifest.cli.bundle);
|
|
11896
|
+
if (!fs21.existsSync(cliPath)) {
|
|
11897
|
+
throw new Error(`cli bundle '${manifest.cli.bundle}' does not exist`);
|
|
11898
|
+
}
|
|
11899
|
+
const bundle = await this.importBundleFile(path16.join(this.hmrDir, manifest.platform.bundle));
|
|
11900
|
+
const gz = await fsp.readFile(path16.join(this.hmrDir, manifest.web.manifest));
|
|
11901
|
+
const webMem = filesMapFromGzip(gz);
|
|
11902
|
+
const instance = await boot(
|
|
11903
|
+
bundle.impl,
|
|
11904
|
+
bundle.iface,
|
|
11905
|
+
initialDoc(bundle.iface, bundle.context),
|
|
11906
|
+
this.resources
|
|
11907
|
+
);
|
|
11908
|
+
this.instance = instance;
|
|
11909
|
+
this.implId = bundle.id;
|
|
11910
|
+
this.webMem = webMem;
|
|
11911
|
+
} catch (err) {
|
|
11912
|
+
this.warn(
|
|
11913
|
+
`persisted version failed to restore (platform+cli+web are committed as one unit); using the packaged default: ${errMsg(err)}`
|
|
11914
|
+
);
|
|
11915
|
+
}
|
|
11916
|
+
}
|
|
11917
|
+
/** Strictly request-driven; serialized on the op queue (never auto-triggered). */
|
|
11918
|
+
upgradeAll(target) {
|
|
11919
|
+
const run = this.opQueue.then(() => this.doUpgradeAll(target));
|
|
11920
|
+
this.opQueue = run.then(
|
|
11921
|
+
() => void 0,
|
|
11922
|
+
() => void 0
|
|
11923
|
+
);
|
|
11924
|
+
return run;
|
|
11925
|
+
}
|
|
11926
|
+
async doUpgradeAll(target) {
|
|
11927
|
+
const current2 = await this.ensure();
|
|
11928
|
+
if (typeof target.web["index.html"] !== "string") {
|
|
11929
|
+
throw new Error("web dist manifest has no index.html");
|
|
11930
|
+
}
|
|
11931
|
+
const webMem = /* @__PURE__ */ new Map();
|
|
11932
|
+
for (const [rel, b64] of Object.entries(target.web)) {
|
|
11933
|
+
if (!isSafeRelPath(rel)) throw new Error(`unsafe path in web dist manifest: ${rel}`);
|
|
11934
|
+
webMem.set(rel, Buffer.from(b64, "base64"));
|
|
11935
|
+
}
|
|
11936
|
+
const platformPath = await this.writeInlinePlatformBundle(target.platform);
|
|
11937
|
+
const bundle = await this.importBundleFile(platformPath);
|
|
11938
|
+
const source = target.source ?? null;
|
|
11939
|
+
const result = await upgrade({
|
|
11940
|
+
current: current2,
|
|
11941
|
+
impl: bundle.impl,
|
|
11942
|
+
iface: bundle.iface,
|
|
11943
|
+
resources: this.resources
|
|
11944
|
+
});
|
|
11945
|
+
if (result.status === "blocked") {
|
|
11946
|
+
return {
|
|
11947
|
+
status: "blocked",
|
|
11948
|
+
dropped: result.dropped,
|
|
11949
|
+
missing: result.missing,
|
|
11950
|
+
invalid: result.invalid
|
|
11951
|
+
};
|
|
11952
|
+
}
|
|
11953
|
+
this.instance = result.instance;
|
|
11954
|
+
this.implId = bundle.id;
|
|
11955
|
+
this.webMem = webMem;
|
|
11956
|
+
const digest2 = filesDigest(target.web);
|
|
11957
|
+
const gz = zlib.gzipSync(Buffer.from(JSON.stringify({ files: target.web })));
|
|
11958
|
+
const persisted = await this.persistVersion(
|
|
11959
|
+
target.platform,
|
|
11960
|
+
target.cli,
|
|
11961
|
+
gz,
|
|
11962
|
+
digest2.slice(0, 16)
|
|
11963
|
+
);
|
|
11964
|
+
return {
|
|
11965
|
+
status: "ok",
|
|
11966
|
+
mode: result.mode,
|
|
11967
|
+
impl: bundle.id,
|
|
11968
|
+
source,
|
|
11969
|
+
web: { rev: digest2.slice(0, 12) },
|
|
11970
|
+
persisted
|
|
11971
|
+
};
|
|
11972
|
+
}
|
|
11973
|
+
/** Layer (a) core: import one JS file, cache-busted so re-imports load fresh code. */
|
|
11974
|
+
async importBundleFile(file) {
|
|
11975
|
+
const resolved = path16.resolve(file);
|
|
11976
|
+
if (!fs21.existsSync(resolved)) throw new Error(`bundle file '${file}' does not exist`);
|
|
11977
|
+
const url = `${pathToFileURL(resolved).href}?v=${Date.now()}`;
|
|
11978
|
+
const mod = await import(url);
|
|
11979
|
+
if (mod.hotPlatform === void 0) {
|
|
11980
|
+
throw new Error(`${file} does not export 'hotPlatform'`);
|
|
11981
|
+
}
|
|
11982
|
+
if (mod.hotPlatform.context === void 0) {
|
|
11983
|
+
throw new Error(`${file}'s 'hotPlatform' has no 'context'`);
|
|
11984
|
+
}
|
|
11985
|
+
return mod.hotPlatform;
|
|
11986
|
+
}
|
|
11987
|
+
/**
|
|
11988
|
+
* Materializes the inline platform bundle (the single-file ESM sent in the
|
|
11989
|
+
* request body) to disk and returns its path — how a push reaches a runtime
|
|
11990
|
+
* over HTTP alone: the bytes travel in the request, the server writes them,
|
|
11991
|
+
* then loads them via importBundleFile. Only the platform artifact needs
|
|
11992
|
+
* this treatment: it is the only one of the three this process ever
|
|
11993
|
+
* imports and boots. The cli artifact is never imported here (see
|
|
11994
|
+
* persistVersion) — it goes straight from request body to content-addressed
|
|
11995
|
+
* store.
|
|
11996
|
+
*/
|
|
11997
|
+
async writeInlinePlatformBundle(content) {
|
|
11998
|
+
const dir = path16.join(this.hmrDir, "uploads");
|
|
11999
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
12000
|
+
const file = path16.join(dir, `platform-${sha1(content).slice(0, 16)}.mjs`);
|
|
12001
|
+
await fsp.writeFile(file, content, "utf8");
|
|
12002
|
+
return file;
|
|
12003
|
+
}
|
|
12004
|
+
// -- Web platform (the frontend package's built dist) ---------------------
|
|
12005
|
+
/**
|
|
12006
|
+
* The pushed web dist held in memory (relPath → bytes) — always installed
|
|
12007
|
+
* together with a platform + cli version (see doUpgradeAll); never written or
|
|
12008
|
+
* read file-by-file (see persistVersion). Null before anything has ever been
|
|
12009
|
+
* pushed or restored.
|
|
12010
|
+
*/
|
|
12011
|
+
webMem = null;
|
|
12012
|
+
/** Static hosting's resolution: an in-memory pushed/restored dist, or null (the caller falls back to the configured webDist). */
|
|
12013
|
+
resolveWebSource() {
|
|
12014
|
+
return this.webMem !== null ? { kind: "mem", files: this.webMem } : null;
|
|
12015
|
+
}
|
|
12016
|
+
// -- Persistence ----------------------------------------------------------
|
|
12017
|
+
/**
|
|
12018
|
+
* Content-addresses the platform bundle (its CODE only — see the module doc: state
|
|
12019
|
+
* is never persisted), the cli bundle (never imported — just stored, for
|
|
12020
|
+
* packages/cli's own loader to pick up), and the web gzip artifact, then flips
|
|
12021
|
+
* harness.json ONCE — `platform`, `cli`, and `web` all land in the SAME atomic
|
|
12022
|
+
* rename, never three separate commits that could leave one pointer ahead of the
|
|
12023
|
+
* others. `platform.bundle` and `cli.bundle` are genuinely independent files
|
|
12024
|
+
* (distinct content, distinct sha) rather than the same physical bundle under two
|
|
12025
|
+
* manifest keys.
|
|
12026
|
+
*
|
|
12027
|
+
* Returns whether the commit succeeded — the caller surfaces this to clients
|
|
12028
|
+
* (`persisted` in UpgradeOutcome) so a live swap that could not be written to disk
|
|
12029
|
+
* is visibly at risk of reverting on the next restart, rather than silently ok.
|
|
12030
|
+
*/
|
|
12031
|
+
async persistVersion(platformContent, cliContent, webGz, webSha) {
|
|
12032
|
+
try {
|
|
12033
|
+
const platformSha = sha1(platformContent).slice(0, 16);
|
|
12034
|
+
const platformDir = path16.join(this.storeDir, "platform");
|
|
12035
|
+
await fsp.mkdir(platformDir, { recursive: true });
|
|
12036
|
+
await fsp.writeFile(path16.join(platformDir, `${platformSha}.mjs`), platformContent, "utf8");
|
|
12037
|
+
const cliSha = sha1(cliContent).slice(0, 16);
|
|
12038
|
+
const cliDir = path16.join(this.storeDir, "cli");
|
|
12039
|
+
await fsp.mkdir(cliDir, { recursive: true });
|
|
12040
|
+
await fsp.writeFile(path16.join(cliDir, `${cliSha}.mjs`), cliContent, "utf8");
|
|
12041
|
+
const webDir = path16.join(this.storeDir, "web");
|
|
12042
|
+
await fsp.mkdir(webDir, { recursive: true });
|
|
12043
|
+
await fsp.writeFile(path16.join(webDir, `${webSha}.webz`), webGz);
|
|
12044
|
+
await this.commitManifest(() => ({
|
|
12045
|
+
platform: { bundle: `store/platform/${platformSha}.mjs` },
|
|
12046
|
+
cli: { bundle: `store/cli/${cliSha}.mjs` },
|
|
12047
|
+
web: { manifest: `store/web/${webSha}.webz` }
|
|
12048
|
+
}));
|
|
12049
|
+
return true;
|
|
12050
|
+
} catch (err) {
|
|
12051
|
+
this.warn(`update not persisted (filesystem unavailable?): ${errMsg(err)}`);
|
|
12052
|
+
return false;
|
|
12053
|
+
}
|
|
12054
|
+
}
|
|
12055
|
+
/** Writes and atomically replaces harness.json (the single commit point for a whole version). */
|
|
12056
|
+
async commitManifest(next) {
|
|
12057
|
+
await fsp.mkdir(this.hmrDir, { recursive: true });
|
|
12058
|
+
const manifest = next();
|
|
12059
|
+
const tmp = `${this.manifestPath}.${process.pid}.tmp`;
|
|
12060
|
+
await fsp.writeFile(tmp, JSON.stringify(manifest, null, 2));
|
|
12061
|
+
await fsp.rename(tmp, this.manifestPath);
|
|
12062
|
+
await this.pruneStore(manifest);
|
|
12063
|
+
}
|
|
12064
|
+
/**
|
|
12065
|
+
* Store GC: keep at most STORE_KEEP versions — the committed one is always
|
|
12066
|
+
* kept, the rest by recency. Best-effort; ordered after the manifest flip so
|
|
12067
|
+
* nothing referenced can be pruned. `platform`, `cli`, and `web` are three
|
|
12068
|
+
* independent subtrees now (no shared file to piggyback a sweep on), so each
|
|
12069
|
+
* gets its own pass.
|
|
12070
|
+
*/
|
|
12071
|
+
async pruneStore(manifest) {
|
|
12072
|
+
const keepNewest = async (dir, keys, referenced, remove) => {
|
|
12073
|
+
let names;
|
|
12074
|
+
try {
|
|
12075
|
+
names = await fsp.readdir(dir);
|
|
12076
|
+
} catch {
|
|
12077
|
+
return;
|
|
12078
|
+
}
|
|
12079
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
12080
|
+
for (const name of names) {
|
|
12081
|
+
const key = keys(name);
|
|
12082
|
+
if (key === null) continue;
|
|
12083
|
+
try {
|
|
12084
|
+
const mtime = (await fsp.stat(path16.join(dir, name))).mtimeMs;
|
|
12085
|
+
byKey.set(key, Math.max(byKey.get(key) ?? 0, mtime));
|
|
12086
|
+
} catch {
|
|
12087
|
+
}
|
|
12088
|
+
}
|
|
12089
|
+
const ranked = [...byKey.entries()].sort((a, b) => b[1] - a[1]).map(([k]) => k);
|
|
12090
|
+
const kept = new Set(ranked.slice(0, STORE_KEEP));
|
|
12091
|
+
if (referenced !== null) kept.add(referenced);
|
|
12092
|
+
for (const key of ranked) {
|
|
12093
|
+
if (!kept.has(key)) await remove(key).catch(() => void 0);
|
|
12094
|
+
}
|
|
12095
|
+
};
|
|
12096
|
+
const platformDir = path16.join(this.storeDir, "platform");
|
|
12097
|
+
const platformBundleRef = manifest.platform?.bundle.match(/([0-9a-f]+)\.mjs$/)?.[1] ?? null;
|
|
12098
|
+
await keepNewest(
|
|
12099
|
+
platformDir,
|
|
12100
|
+
(name) => /^([0-9a-f]+)\.mjs$/.exec(name)?.[1] ?? null,
|
|
12101
|
+
platformBundleRef,
|
|
12102
|
+
(sha) => fsp.rm(path16.join(platformDir, `${sha}.mjs`), { force: true })
|
|
12103
|
+
);
|
|
12104
|
+
const cliDir = path16.join(this.storeDir, "cli");
|
|
12105
|
+
const cliRef = manifest.cli?.bundle.match(/([0-9a-f]+)\.mjs$/)?.[1] ?? null;
|
|
12106
|
+
await keepNewest(
|
|
12107
|
+
cliDir,
|
|
12108
|
+
(name) => /^([0-9a-f]+)\.mjs$/.exec(name)?.[1] ?? null,
|
|
12109
|
+
cliRef,
|
|
12110
|
+
(sha) => fsp.rm(path16.join(cliDir, `${sha}.mjs`), { force: true })
|
|
12111
|
+
);
|
|
12112
|
+
const webDir = path16.join(this.storeDir, "web");
|
|
12113
|
+
const webRef = manifest.web?.manifest.match(/([0-9a-f]+)\.webz$/)?.[1] ?? null;
|
|
12114
|
+
await keepNewest(
|
|
12115
|
+
webDir,
|
|
12116
|
+
(name) => /^([0-9a-f]+)\.webz$/.exec(name)?.[1] ?? null,
|
|
12117
|
+
webRef,
|
|
12118
|
+
(sha) => fsp.rm(path16.join(webDir, `${sha}.webz`), { force: true })
|
|
12119
|
+
);
|
|
12120
|
+
}
|
|
12121
|
+
/** Process-exit sweep only; never part of an upgrade. */
|
|
12122
|
+
dispose() {
|
|
12123
|
+
this.instance?.dispose();
|
|
12124
|
+
this.instance = null;
|
|
12125
|
+
this.resources.disposeAll();
|
|
12126
|
+
}
|
|
12127
|
+
};
|
|
12128
|
+
function errMsg(err) {
|
|
12129
|
+
return err instanceof Error ? err.message : String(err);
|
|
12130
|
+
}
|
|
12131
|
+
function sha1(content) {
|
|
12132
|
+
return crypto.createHash("sha1").update(content).digest("hex");
|
|
12133
|
+
}
|
|
12134
|
+
function filesDigest(files) {
|
|
12135
|
+
const hash = crypto.createHash("sha1");
|
|
12136
|
+
for (const rel of Object.keys(files).sort()) hash.update(rel).update("\0").update(files[rel]);
|
|
12137
|
+
return hash.digest("hex");
|
|
12138
|
+
}
|
|
12139
|
+
function isSafeRelPath(rel) {
|
|
12140
|
+
if (rel === "" || rel.startsWith("/") || rel.includes("\\")) return false;
|
|
12141
|
+
return rel.split("/").every((seg) => seg !== "" && seg !== "." && seg !== "..");
|
|
12142
|
+
}
|
|
12143
|
+
function filesFromGzip(gz) {
|
|
12144
|
+
let parsed;
|
|
12145
|
+
try {
|
|
12146
|
+
parsed = JSON.parse(zlib.gunzipSync(gz).toString("utf8"));
|
|
12147
|
+
} catch (err) {
|
|
12148
|
+
throw new Error(`invalid gzip web dist artifact: ${errMsg(err)}`);
|
|
12149
|
+
}
|
|
12150
|
+
if (typeof parsed.files !== "object" || parsed.files === null) {
|
|
12151
|
+
throw new Error("gzip web dist artifact has no `files`");
|
|
12152
|
+
}
|
|
12153
|
+
return parsed.files;
|
|
12154
|
+
}
|
|
12155
|
+
function filesMapFromGzip(gz) {
|
|
12156
|
+
const files = filesFromGzip(gz);
|
|
12157
|
+
const mem = /* @__PURE__ */ new Map();
|
|
12158
|
+
for (const [rel, b64] of Object.entries(files)) {
|
|
12159
|
+
if (!isSafeRelPath(rel)) throw new Error(`unsafe path in web dist manifest: ${rel}`);
|
|
12160
|
+
mem.set(rel, Buffer.from(b64, "base64"));
|
|
12161
|
+
}
|
|
12162
|
+
return mem;
|
|
12163
|
+
}
|
|
12164
|
+
|
|
12165
|
+
// src/hmr/routes.ts
|
|
12166
|
+
import zlib2 from "zlib";
|
|
11136
12167
|
import { Hono as Hono24 } from "hono";
|
|
12168
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "::1", "localhost"]);
|
|
12169
|
+
function hmrRoutes(deps2) {
|
|
12170
|
+
const routes = new Hono24();
|
|
12171
|
+
const hmr = deps2.hmr;
|
|
12172
|
+
const cookieAuth = authMiddleware(deps2.authService);
|
|
12173
|
+
routes.use("*", async (c, next) => {
|
|
12174
|
+
if (!LOOPBACK_HOSTS.has(deps2.config.host.toLowerCase())) {
|
|
12175
|
+
const proto = deps2.config.trustProxy ? c.req.header("x-forwarded-proto") ?? new URL(c.req.url).protocol.replace(":", "") : new URL(c.req.url).protocol.replace(":", "");
|
|
12176
|
+
if (proto !== "https") {
|
|
12177
|
+
throw new HttpError(
|
|
12178
|
+
403,
|
|
12179
|
+
"hmr_disabled",
|
|
12180
|
+
"Hot platform APIs are disabled on a non-loopback bind without HTTPS. Serve over HTTPS to enable them."
|
|
12181
|
+
);
|
|
12182
|
+
}
|
|
12183
|
+
}
|
|
12184
|
+
const gated = async () => {
|
|
12185
|
+
if (!c.req.path.endsWith("/upgrade")) await hmr.waitIdle();
|
|
12186
|
+
await next();
|
|
12187
|
+
};
|
|
12188
|
+
return cookieAuth(c, async () => {
|
|
12189
|
+
if (!c.get("user").isAdmin) {
|
|
12190
|
+
throw new HttpError(403, "forbidden", "Hot platform APIs are admin-only.");
|
|
12191
|
+
}
|
|
12192
|
+
await gated();
|
|
12193
|
+
});
|
|
12194
|
+
});
|
|
12195
|
+
routes.post("/upgrade", async (c) => {
|
|
12196
|
+
const contentType = (c.req.header("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
12197
|
+
if (contentType !== "application/gzip" && contentType !== "application/octet-stream") {
|
|
12198
|
+
throw new HttpError(
|
|
12199
|
+
400,
|
|
12200
|
+
"bad_request",
|
|
12201
|
+
"expected a gzip(JSON.stringify({ platform, cli, web })) body (Content-Type application/gzip or application/octet-stream)"
|
|
12202
|
+
);
|
|
12203
|
+
}
|
|
12204
|
+
let payload;
|
|
12205
|
+
try {
|
|
12206
|
+
const gz = Buffer.from(await c.req.arrayBuffer());
|
|
12207
|
+
payload = JSON.parse(zlib2.gunzipSync(gz).toString("utf8"));
|
|
12208
|
+
} catch (err) {
|
|
12209
|
+
throw new HttpError(
|
|
12210
|
+
400,
|
|
12211
|
+
"bad_request",
|
|
12212
|
+
`invalid gzip upgrade payload: ${err instanceof Error ? err.message : String(err)}`
|
|
12213
|
+
);
|
|
12214
|
+
}
|
|
12215
|
+
if (typeof payload.platform !== "string") {
|
|
12216
|
+
throw new HttpError(400, "bad_request", "payload has no `platform` (string)");
|
|
12217
|
+
}
|
|
12218
|
+
if (typeof payload.cli !== "string") {
|
|
12219
|
+
throw new HttpError(400, "bad_request", "payload has no `cli` (string)");
|
|
12220
|
+
}
|
|
12221
|
+
if (typeof payload.web?.files !== "object" || payload.web.files === null) {
|
|
12222
|
+
throw new HttpError(
|
|
12223
|
+
400,
|
|
12224
|
+
"bad_request",
|
|
12225
|
+
"payload has no `web.files` (a { relPath: base64 } map)"
|
|
12226
|
+
);
|
|
12227
|
+
}
|
|
12228
|
+
let outcome;
|
|
12229
|
+
try {
|
|
12230
|
+
outcome = await hmr.upgradeAll({
|
|
12231
|
+
platform: payload.platform,
|
|
12232
|
+
cli: payload.cli,
|
|
12233
|
+
web: payload.web.files,
|
|
12234
|
+
...payload.source ? { source: payload.source } : {}
|
|
12235
|
+
});
|
|
12236
|
+
} catch (err) {
|
|
12237
|
+
throw new HttpError(400, "bad_request", err instanceof Error ? err.message : String(err));
|
|
12238
|
+
}
|
|
12239
|
+
if (outcome.status === "ok") {
|
|
12240
|
+
deps2.channels.broadcast(
|
|
12241
|
+
"user:",
|
|
12242
|
+
{ type: "web_updated", rev: outcome.web.rev },
|
|
12243
|
+
"server_event"
|
|
12244
|
+
);
|
|
12245
|
+
}
|
|
12246
|
+
return c.json(outcome);
|
|
12247
|
+
});
|
|
12248
|
+
return routes;
|
|
12249
|
+
}
|
|
12250
|
+
|
|
12251
|
+
// src/hmr/http-seam.ts
|
|
12252
|
+
var RESERVED_PREFIX = "/api/hmr";
|
|
12253
|
+
function platformHttpSeam(hmr) {
|
|
12254
|
+
return async (c, next) => {
|
|
12255
|
+
if (c.req.path.startsWith(RESERVED_PREFIX)) return next();
|
|
12256
|
+
await hmr.waitIdle();
|
|
12257
|
+
let handler;
|
|
12258
|
+
try {
|
|
12259
|
+
const instance = await hmr.ensure();
|
|
12260
|
+
handler = instance.api.http?.bind(instance.api);
|
|
12261
|
+
} catch {
|
|
12262
|
+
return next();
|
|
12263
|
+
}
|
|
12264
|
+
if (!handler) return next();
|
|
12265
|
+
let response;
|
|
12266
|
+
try {
|
|
12267
|
+
response = await handler(c.req.raw);
|
|
12268
|
+
} catch (err) {
|
|
12269
|
+
return c.json(
|
|
12270
|
+
{
|
|
12271
|
+
error: {
|
|
12272
|
+
code: "platform_error",
|
|
12273
|
+
message: `The platform failed to handle ${c.req.method} ${c.req.path}: ${err instanceof Error ? err.message : String(err)}`
|
|
12274
|
+
}
|
|
12275
|
+
},
|
|
12276
|
+
500
|
|
12277
|
+
);
|
|
12278
|
+
}
|
|
12279
|
+
if (response === null) return next();
|
|
12280
|
+
return response;
|
|
12281
|
+
};
|
|
12282
|
+
}
|
|
12283
|
+
|
|
12284
|
+
// src/http/routes/preview.ts
|
|
12285
|
+
import { Hono as Hono25 } from "hono";
|
|
11137
12286
|
var DIRECTORY_INDEX = "index.html";
|
|
11138
12287
|
function previewRoutes(deps2) {
|
|
11139
|
-
const app2 = new
|
|
12288
|
+
const app2 = new Hono25();
|
|
11140
12289
|
app2.get("/:token/*", async (c) => {
|
|
11141
12290
|
const token = c.req.param("token");
|
|
11142
12291
|
const payload = token ? deps2.previewTokens.verify(token) : null;
|
|
@@ -11180,7 +12329,6 @@ function previewRoutes(deps2) {
|
|
|
11180
12329
|
}
|
|
11181
12330
|
|
|
11182
12331
|
// src/app.ts
|
|
11183
|
-
var MAX_BODY_BYTES = 20 * 1024 * 1024;
|
|
11184
12332
|
function buildAppDeps(config2, overrides = {}) {
|
|
11185
12333
|
const db = openDatabase(config2.dbPath);
|
|
11186
12334
|
const log = overrides.log ?? ((line) => console.log(line));
|
|
@@ -11240,7 +12388,24 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
11240
12388
|
errors,
|
|
11241
12389
|
titles,
|
|
11242
12390
|
log,
|
|
11243
|
-
goals: goalsRepo
|
|
12391
|
+
goals: goalsRepo,
|
|
12392
|
+
// Run-state flips reach the whole login session, not just the tab watching that one
|
|
12393
|
+
// conversation. Audience = the Project's owner plus its members, i.e. exactly who
|
|
12394
|
+
// ProjectsRepo.listAccessible would grant the Project to — nobody learns that a Session
|
|
12395
|
+
// they cannot open changed state.
|
|
12396
|
+
//
|
|
12397
|
+
// `peek`, deliberately not `get`: a user who has never opened an event stream has no
|
|
12398
|
+
// channel, and conjuring one to buffer badge updates nobody is listening to is pure waste
|
|
12399
|
+
// (their next connection fetches the list, which carries the same statuses anyway).
|
|
12400
|
+
notifyProjectUsers: (projectId, event) => {
|
|
12401
|
+
const ownerUserId = projectsRepo.findById(projectId)?.ownerUserId;
|
|
12402
|
+
if (ownerUserId === void 0) return;
|
|
12403
|
+
const audience = /* @__PURE__ */ new Set([ownerUserId, ...membersRepo.list(projectId).map((m) => m.userId)]);
|
|
12404
|
+
for (const userId of audience) {
|
|
12405
|
+
channels.peek(userChannelKey(userId))?.publish(event, "server_event");
|
|
12406
|
+
}
|
|
12407
|
+
},
|
|
12408
|
+
...overrides.now ? { now: overrides.now } : {}
|
|
11244
12409
|
});
|
|
11245
12410
|
managerRef = manager;
|
|
11246
12411
|
const projectService = new ProjectService({
|
|
@@ -11333,11 +12498,12 @@ function buildAppDeps(config2, overrides = {}) {
|
|
|
11333
12498
|
sessionSources,
|
|
11334
12499
|
errors,
|
|
11335
12500
|
desktop: config2.desktopToken !== null ? new DesktopService(config2.desktopToken) : null,
|
|
12501
|
+
hmr: new HmrHost(config2.root),
|
|
11336
12502
|
log
|
|
11337
12503
|
};
|
|
11338
12504
|
}
|
|
11339
12505
|
function createApp(deps2) {
|
|
11340
|
-
const app2 = new
|
|
12506
|
+
const app2 = new Hono26();
|
|
11341
12507
|
app2.onError((err, c) => {
|
|
11342
12508
|
const projectId = attributedProjectId(c, deps2);
|
|
11343
12509
|
deps2.errors.record({
|
|
@@ -11369,22 +12535,35 @@ function createApp(deps2) {
|
|
|
11369
12535
|
await next();
|
|
11370
12536
|
});
|
|
11371
12537
|
}
|
|
11372
|
-
|
|
11373
|
-
|
|
11374
|
-
|
|
11375
|
-
|
|
11376
|
-
|
|
11377
|
-
|
|
11378
|
-
|
|
11379
|
-
|
|
11380
|
-
|
|
11381
|
-
|
|
11382
|
-
|
|
12538
|
+
let capped = null;
|
|
12539
|
+
app2.use("/api/*", (c, next) => {
|
|
12540
|
+
const size = bodyLimitBytes(deps2.serverSettingsRepo.getAttachmentLimitsMb());
|
|
12541
|
+
if (capped === null || capped.size !== size) {
|
|
12542
|
+
capped = {
|
|
12543
|
+
size,
|
|
12544
|
+
mw: bodyLimit({
|
|
12545
|
+
maxSize: size,
|
|
12546
|
+
// Its default is a bare text/plain 413; throw the App's own error instead so the
|
|
12547
|
+
// response stays the documented `payload_too_large` body that every client handles.
|
|
12548
|
+
onError: () => {
|
|
12549
|
+
throw new HttpError(
|
|
12550
|
+
413,
|
|
12551
|
+
"payload_too_large",
|
|
12552
|
+
`Request body exceeds the ${Math.floor(size / (1024 * 1024))}MB limit.`
|
|
12553
|
+
);
|
|
12554
|
+
}
|
|
12555
|
+
})
|
|
12556
|
+
};
|
|
12557
|
+
}
|
|
12558
|
+
return capped.mw(c, next);
|
|
12559
|
+
});
|
|
11383
12560
|
app2.use("/api/*", jsonOnlyWrites);
|
|
11384
12561
|
app2.route("/api/auth", authRoutes(deps2));
|
|
11385
12562
|
if (deps2.desktop) {
|
|
11386
12563
|
app2.route("/api/desktop", desktopRoutes(deps2));
|
|
11387
12564
|
}
|
|
12565
|
+
app2.route("/api/hmr", hmrRoutes(deps2));
|
|
12566
|
+
app2.use("*", platformHttpSeam(deps2.hmr));
|
|
11388
12567
|
const auth = authMiddleware(deps2.authService);
|
|
11389
12568
|
app2.use("/api/*", auth);
|
|
11390
12569
|
app2.route("/api/me", meRoutes(deps2));
|
|
@@ -11411,9 +12590,10 @@ function createApp(deps2) {
|
|
|
11411
12590
|
app2.route("/api/projects/:projectId/usage", usageRoutes(deps2));
|
|
11412
12591
|
app2.route("/api/sessions", sessionsRoutes(deps2));
|
|
11413
12592
|
app2.route("/preview", previewRoutes(deps2));
|
|
11414
|
-
|
|
11415
|
-
|
|
11416
|
-
|
|
12593
|
+
registerStaticRoutes(app2, async () => {
|
|
12594
|
+
await deps2.hmr.ensure();
|
|
12595
|
+
return deps2.hmr.resolveWebSource() ?? { kind: "dir", dir: deps2.config.webDist };
|
|
12596
|
+
});
|
|
11417
12597
|
return app2;
|
|
11418
12598
|
}
|
|
11419
12599
|
function attributedProjectId(c, deps2) {
|
|
@@ -11440,41 +12620,58 @@ var CONTENT_TYPES2 = {
|
|
|
11440
12620
|
".txt": "text/plain; charset=utf-8",
|
|
11441
12621
|
".woff2": "font/woff2"
|
|
11442
12622
|
};
|
|
11443
|
-
function registerStaticRoutes(app2,
|
|
12623
|
+
function registerStaticRoutes(app2, resolveSource) {
|
|
11444
12624
|
app2.get("*", async (c) => {
|
|
11445
12625
|
const reqPath = decodeURIComponent(c.req.path);
|
|
11446
12626
|
if (reqPath.startsWith("/api/")) {
|
|
11447
12627
|
return c.json(errorBody("not_found", "Endpoint does not exist."), 404);
|
|
11448
12628
|
}
|
|
11449
|
-
const rel = reqPath.replace(/^\/+/, "");
|
|
11450
|
-
const
|
|
12629
|
+
const rel = reqPath.replace(/^\/+/, "") || "index.html";
|
|
12630
|
+
const source = await resolveSource();
|
|
12631
|
+
if (source.kind === "mem") {
|
|
12632
|
+
const servedPath = source.files.has(rel) ? rel : "index.html";
|
|
12633
|
+
const content2 = source.files.get(servedPath);
|
|
12634
|
+
if (content2 === void 0) {
|
|
12635
|
+
return c.json(errorBody("not_found", "Resource does not exist."), 404);
|
|
12636
|
+
}
|
|
12637
|
+
const type3 = CONTENT_TYPES2[path17.extname(servedPath).toLowerCase()] ?? "application/octet-stream";
|
|
12638
|
+
return new Response(new Uint8Array(content2), {
|
|
12639
|
+
status: 200,
|
|
12640
|
+
headers: { "Content-Type": type3 }
|
|
12641
|
+
});
|
|
12642
|
+
}
|
|
12643
|
+
const webDist = source.dir;
|
|
12644
|
+
if (!fs22.existsSync(webDist)) {
|
|
12645
|
+
return c.json(errorBody("not_found", "Resource does not exist."), 404);
|
|
12646
|
+
}
|
|
12647
|
+
const resolved = path17.resolve(webDist, rel);
|
|
11451
12648
|
const base = path17.resolve(webDist);
|
|
11452
12649
|
const target = resolved === base || resolved.startsWith(base + path17.sep) ? resolved : path17.join(base, "index.html");
|
|
11453
12650
|
let file = target;
|
|
11454
12651
|
try {
|
|
11455
|
-
const stat = await
|
|
12652
|
+
const stat = await fsp2.stat(file);
|
|
11456
12653
|
if (stat.isDirectory()) file = path17.join(file, "index.html");
|
|
11457
|
-
await
|
|
12654
|
+
await fsp2.access(file);
|
|
11458
12655
|
} catch {
|
|
11459
12656
|
file = path17.join(base, "index.html");
|
|
11460
12657
|
}
|
|
11461
12658
|
let content;
|
|
11462
12659
|
try {
|
|
11463
|
-
content = await
|
|
12660
|
+
content = await fsp2.readFile(file);
|
|
11464
12661
|
} catch {
|
|
11465
12662
|
return c.json(errorBody("not_found", "Resource does not exist."), 404);
|
|
11466
12663
|
}
|
|
11467
|
-
const
|
|
12664
|
+
const type2 = CONTENT_TYPES2[path17.extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
11468
12665
|
return new Response(new Uint8Array(content), {
|
|
11469
12666
|
status: 200,
|
|
11470
|
-
headers: { "Content-Type":
|
|
12667
|
+
headers: { "Content-Type": type2 }
|
|
11471
12668
|
});
|
|
11472
12669
|
});
|
|
11473
12670
|
}
|
|
11474
12671
|
|
|
11475
12672
|
// src/config.ts
|
|
11476
|
-
import { randomBytes as
|
|
11477
|
-
import
|
|
12673
|
+
import { randomBytes as randomBytes5 } from "crypto";
|
|
12674
|
+
import fs23 from "fs";
|
|
11478
12675
|
import path18 from "path";
|
|
11479
12676
|
import { fileURLToPath } from "url";
|
|
11480
12677
|
import { DEFAULT_SERVER_PORT, resolveRoot } from "@prismshadow/penguin-core";
|
|
@@ -11482,7 +12679,7 @@ var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
|
11482
12679
|
function defaultWebDist() {
|
|
11483
12680
|
const here = path18.dirname(fileURLToPath(import.meta.url));
|
|
11484
12681
|
const bundled = path18.resolve(here, "..", "web-dist");
|
|
11485
|
-
if (
|
|
12682
|
+
if (fs23.existsSync(bundled)) return bundled;
|
|
11486
12683
|
return path18.resolve(here, "..", "..", "web", "dist");
|
|
11487
12684
|
}
|
|
11488
12685
|
function normalizePreviewOrigin(raw) {
|
|
@@ -11520,11 +12717,12 @@ function resolveServerConfig(env = process.env) {
|
|
|
11520
12717
|
// mode without a pinned value seeds a FULLY random password rather than the
|
|
11521
12718
|
// printable penguin-<4 digits>: desktop sign-in goes through the shell's token, so
|
|
11522
12719
|
// the seed never needs to be read — and index.ts deliberately does not print it.
|
|
11523
|
-
seedAdminPassword: env.PENGUIN_SEED_ADMIN_PASSWORD?.trim() || (desktopToken !== null ?
|
|
12720
|
+
seedAdminPassword: env.PENGUIN_SEED_ADMIN_PASSWORD?.trim() || (desktopToken !== null ? randomBytes5(24).toString("base64url") : null),
|
|
11524
12721
|
authSessionTtlMs: 7 * DAY_MS,
|
|
11525
12722
|
authSessionRenewMs: 6 * DAY_MS,
|
|
11526
12723
|
desktopToken,
|
|
11527
|
-
portFile: env.PENGUIN_PORT_FILE?.trim() || null
|
|
12724
|
+
portFile: env.PENGUIN_PORT_FILE?.trim() || null,
|
|
12725
|
+
trustProxy: env.PENGUIN_TRUST_PROXY === "1"
|
|
11528
12726
|
};
|
|
11529
12727
|
}
|
|
11530
12728
|
|
|
@@ -11566,11 +12764,11 @@ deps.goalsRepo.abortOrphanedActive();
|
|
|
11566
12764
|
var appHost = loopbackHostRoles(config.host)?.app ?? config.host;
|
|
11567
12765
|
var ipv6Loopback = null;
|
|
11568
12766
|
function writePortFile(file, port) {
|
|
11569
|
-
|
|
12767
|
+
fs24.mkdirSync(path19.dirname(file), { recursive: true });
|
|
11570
12768
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
11571
|
-
|
|
12769
|
+
fs24.writeFileSync(tmp, `${port}
|
|
11572
12770
|
`);
|
|
11573
|
-
|
|
12771
|
+
fs24.renameSync(tmp, file);
|
|
11574
12772
|
}
|
|
11575
12773
|
var server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
|
|
11576
12774
|
console.log(`penguin-server started: http://${appHost}:${info.port}`);
|
|
@@ -11597,7 +12795,7 @@ function cleanupInstanceFiles() {
|
|
|
11597
12795
|
releaseServerLock(config.root);
|
|
11598
12796
|
if (config.portFile !== null) {
|
|
11599
12797
|
try {
|
|
11600
|
-
|
|
12798
|
+
fs24.rmSync(config.portFile, { force: true });
|
|
11601
12799
|
} catch {
|
|
11602
12800
|
}
|
|
11603
12801
|
}
|
|
@@ -11609,6 +12807,7 @@ async function shutdown(signal, exitCode = 0) {
|
|
|
11609
12807
|
console.log(`Received ${signal}, shutting down\u2026`);
|
|
11610
12808
|
deps.scheduler.stop();
|
|
11611
12809
|
await deps.manager.shutdown(5e3);
|
|
12810
|
+
deps.hmr.dispose();
|
|
11612
12811
|
deps.channels.dispose();
|
|
11613
12812
|
ipv6Loopback?.close();
|
|
11614
12813
|
server.close(() => {
|