@alisio/server 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +47 -0
- package/dist/auth/guard.d.ts +37 -0
- package/dist/auth/guard.js +83 -0
- package/dist/auth/token.d.ts +6 -0
- package/dist/auth/token.js +22 -0
- package/dist/bridges/approval-bridge.d.ts +53 -0
- package/dist/bridges/approval-bridge.js +162 -0
- package/dist/bridges/interaction-bridge.d.ts +52 -0
- package/dist/bridges/interaction-bridge.js +163 -0
- package/dist/host/git.d.ts +22 -0
- package/dist/host/git.js +99 -0
- package/dist/host/presets.d.ts +21 -0
- package/dist/host/presets.js +60 -0
- package/dist/host/run-scheduler.d.ts +67 -0
- package/dist/host/run-scheduler.js +143 -0
- package/dist/host/sessions.d.ts +81 -0
- package/dist/host/sessions.js +211 -0
- package/dist/host/workspace-host.d.ts +87 -0
- package/dist/host/workspace-host.js +195 -0
- package/dist/http/body.d.ts +7 -0
- package/dist/http/body.js +33 -0
- package/dist/http/errors.d.ts +15 -0
- package/dist/http/errors.js +58 -0
- package/dist/http/router.d.ts +37 -0
- package/dist/http/router.js +62 -0
- package/dist/http/static.d.ts +27 -0
- package/dist/http/static.js +132 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.js +350 -0
- package/dist/log.d.ts +21 -0
- package/dist/log.js +19 -0
- package/dist/routes/approvals.d.ts +8 -0
- package/dist/routes/approvals.js +33 -0
- package/dist/routes/blobs.d.ts +13 -0
- package/dist/routes/blobs.js +46 -0
- package/dist/routes/commands.d.ts +21 -0
- package/dist/routes/commands.js +149 -0
- package/dist/routes/events.d.ts +14 -0
- package/dist/routes/events.js +32 -0
- package/dist/routes/files.d.ts +21 -0
- package/dist/routes/files.js +262 -0
- package/dist/routes/health.d.ts +18 -0
- package/dist/routes/health.js +38 -0
- package/dist/routes/images.d.ts +12 -0
- package/dist/routes/images.js +60 -0
- package/dist/routes/management.d.ts +32 -0
- package/dist/routes/management.js +326 -0
- package/dist/routes/prompts.d.ts +22 -0
- package/dist/routes/prompts.js +153 -0
- package/dist/routes/providers.d.ts +3 -0
- package/dist/routes/providers.js +230 -0
- package/dist/routes/session-views.d.ts +12 -0
- package/dist/routes/session-views.js +76 -0
- package/dist/routes/sessions.d.ts +16 -0
- package/dist/routes/sessions.js +160 -0
- package/dist/routes/workspaces.d.ts +7 -0
- package/dist/routes/workspaces.js +32 -0
- package/dist/schemas.d.ts +25 -0
- package/dist/schemas.js +54 -0
- package/dist/sse/hub.d.ts +80 -0
- package/dist/sse/hub.js +287 -0
- package/dist/sse/inflight.d.ts +16 -0
- package/dist/sse/inflight.js +84 -0
- package/dist/web/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/web/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/web/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/web/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/web/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/web/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/web/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/web/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/web/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/web/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/web/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/web/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/web/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/web/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/web/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/web/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/web/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/web/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/web/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/web/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/web/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/web/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/web/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/web/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/web/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/web/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/web/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/web/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/web/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/web/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/web/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/web/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/web/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/web/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/web/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/web/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/web/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/web/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/web/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/web/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/web/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/web/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/web/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/web/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/web/assets/KaTeX_Size3-Regular-gV2CO0n9.woff2 +0 -0
- package/dist/web/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/web/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/web/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/web/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/web/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/web/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/web/assets/SettingsModal-Dj5G3JIz.js +1 -0
- package/dist/web/assets/SettingsModal-YCahs6O5.css +1 -0
- package/dist/web/assets/abnfDiagram-VCTEODGH-Ct0-FsLe.js +1 -0
- package/dist/web/assets/arc-Pry-7G1_.js +1 -0
- package/dist/web/assets/architecture-7GRP2DOG-B5Qnm7io.js +1 -0
- package/dist/web/assets/architectureDiagram-5GKGNRK7-BvjnUlvj.js +36 -0
- package/dist/web/assets/array-BifhSqXX.js +1 -0
- package/dist/web/assets/blockDiagram-I7D4REHJ-CBHCj2Mn.js +129 -0
- package/dist/web/assets/c4Diagram-7LVT6UL2-9I13K7Yy.js +38 -0
- package/dist/web/assets/channel-B-TykkBN.js +1 -0
- package/dist/web/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/web/assets/chunk-4HAMMTFA-Du4xf36t.js +62 -0
- package/dist/web/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/web/assets/chunk-75Z2AOVW-93KpRXCT.js +2 -0
- package/dist/web/assets/chunk-DU6HZSFF-DP6dsL6K.js +125 -0
- package/dist/web/assets/chunk-F27PBJKO-CxLLRIN4.js +1 -0
- package/dist/web/assets/chunk-FOHPRMQF-DzxwRta7.js +161 -0
- package/dist/web/assets/chunk-GMAD6QVW-BGv500CR.js +72 -0
- package/dist/web/assets/chunk-GVQU2GXP-BmXv3St8.js +1 -0
- package/dist/web/assets/chunk-IMKFNOWR-tmbCLM1n.js +231 -0
- package/dist/web/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/web/assets/chunk-L3NEJ4N5-BULbKNtl.js +1 -0
- package/dist/web/assets/chunk-OSK3NFVY-DyvADXPn.js +10 -0
- package/dist/web/assets/chunk-P2QGCYS3-YhA2sAhR.js +1 -0
- package/dist/web/assets/chunk-POPQ4Y6H-BLr1KktA.js +1 -0
- package/dist/web/assets/chunk-PWAF6VOD-DJCZC9ie.js +1 -0
- package/dist/web/assets/chunk-SHT3W25Y-B8xH9z5t.js +168 -0
- package/dist/web/assets/chunk-SVP7TREG-DxynkM9y.js +88 -0
- package/dist/web/assets/chunk-TICWLB2K-CZPXqwaW.js +206 -0
- package/dist/web/assets/chunk-XXDRQBXY-DqAYeljN.js +1 -0
- package/dist/web/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/web/assets/classDiagram-ZZMXUADV-B6LKfKSk.js +1 -0
- package/dist/web/assets/classDiagram-v2-VYDZK3BY-B6LKfKSk.js +1 -0
- package/dist/web/assets/cose-bilkent-JH36ORCC-BEpcWcmK.js +1 -0
- package/dist/web/assets/css-BsVw1vtW.js +1 -0
- package/dist/web/assets/cynefin-OW5HDTMX-rct1-Bf_.js +1 -0
- package/dist/web/assets/cynefinDiagram-5FMLGOSQ-CuBJbnvx.js +62 -0
- package/dist/web/assets/cytoscape.esm-Yq6u8L66.js +321 -0
- package/dist/web/assets/dagre-C-rnJpZF.js +1 -0
- package/dist/web/assets/dagre-GXQ25YYZ-w8zs2moB.js +4 -0
- package/dist/web/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/web/assets/diagram-S7CK7UJ4-BMBhFewM.js +30 -0
- package/dist/web/assets/diagram-UQ7AKVKN-C7BN6534.js +41 -0
- package/dist/web/assets/diagram-VSXAHHWV-Dd8_wR6U.js +3 -0
- package/dist/web/assets/diagram-VX7I27RA-DR3aOC1b.js +24 -0
- package/dist/web/assets/diagram-Z3DM3KII-5aBBRZ2Z.js +24 -0
- package/dist/web/assets/diff-woXpYk--.js +1 -0
- package/dist/web/assets/dist-v5Q1xZ2K.js +1 -0
- package/dist/web/assets/ebnfDiagram-PWID7BFC-46Gc2APT.js +1 -0
- package/dist/web/assets/engine-CknQqOvU.js +44 -0
- package/dist/web/assets/erDiagram-RLTQ6QDP-CNfUFQmD.js +99 -0
- package/dist/web/assets/eventmodeling-NTZA5JFV-Dx_J4ait.js +1 -0
- package/dist/web/assets/flowDiagram-HODETNUW-Bs7QheBl.js +1 -0
- package/dist/web/assets/ganttDiagram-EL5Y4UJY-B1RLLE0F.js +292 -0
- package/dist/web/assets/gitGraph-4MIJSDKK-DmzdoCuA.js +1 -0
- package/dist/web/assets/gitGraphDiagram-WWUBYQGX-DN5-fMOS.js +106 -0
- package/dist/web/assets/github-dark-C-LZuMrd.js +1 -0
- package/dist/web/assets/github-light-EUqPIrTm.js +1 -0
- package/dist/web/assets/go-rLFTqkRN.js +1 -0
- package/dist/web/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/web/assets/highlight-C1LfZ1aI.js +152 -0
- package/dist/web/assets/index-CGolUSWa.js +75 -0
- package/dist/web/assets/index-vUqwyH-2.css +1 -0
- package/dist/web/assets/info-A6RAGUB7-CebZRx1l.js +1 -0
- package/dist/web/assets/infoDiagram-27XIBGKW-BeY-irSX.js +2 -0
- package/dist/web/assets/init-C-OQMol4.js +1 -0
- package/dist/web/assets/ishikawaDiagram-5VMMS53U-Djn1dIqp.js +70 -0
- package/dist/web/assets/javascript-BgS3c2Ky.js +1 -0
- package/dist/web/assets/journeyDiagram-3NMN7TZE-CV_b2MnE.js +139 -0
- package/dist/web/assets/json-qhed-kSA.js +1 -0
- package/dist/web/assets/jsx-Bz0zcwM4.js +1 -0
- package/dist/web/assets/jsxRuntime-D9k5B_Bf.js +1 -0
- package/dist/web/assets/kanban-definition-UXKFOSKX-CXTukynL.js +89 -0
- package/dist/web/assets/katex-ZlcWpGUi.js +257 -0
- package/dist/web/assets/line-DIt63x6x.js +1 -0
- package/dist/web/assets/linear-SzVmGSw4.js +1 -0
- package/dist/web/assets/mermaid-parser.core-UsYAxtjN.js +7 -0
- package/dist/web/assets/mindmap-definition-YA3MSWOX-G0K_7S5N.js +96 -0
- package/dist/web/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/web/assets/packet-AYTQ26CC-D3_L-O-_.js +1 -0
- package/dist/web/assets/path-fybaL0A-.js +1 -0
- package/dist/web/assets/pegDiagram-XKGWAZYB-BoSJtVsR.js +1 -0
- package/dist/web/assets/pie-WAS4IAKB-DUARjLYc.js +1 -0
- package/dist/web/assets/pieDiagram-E7YTZNPT-DaFXBymx.js +39 -0
- package/dist/web/assets/purify.es--06brOip.js +3 -0
- package/dist/web/assets/python-gzcpVVnB.js +1 -0
- package/dist/web/assets/quadrantDiagram-AXDQQJYC-DvJJGxS3.js +7 -0
- package/dist/web/assets/radar-RG4KPBEZ-CoTA2z0h.js +1 -0
- package/dist/web/assets/railroad-74A4TZTK-D22_YeTS.js +1 -0
- package/dist/web/assets/railroad-abnf-HS5TGJTU-Mvl9LvJX.js +1 -0
- package/dist/web/assets/railroad-ebnf-LZEXJU2U-hvVPkAs6.js +1 -0
- package/dist/web/assets/railroad-peg-WCYAUIDC-XX_5aYEK.js +1 -0
- package/dist/web/assets/railroadDiagram-O6MQD6OU-Cxnojv6Q.js +1 -0
- package/dist/web/assets/requirementDiagram-BXWQKSXE-gW2PlZJn.js +84 -0
- package/dist/web/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/dist/web/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/dist/web/assets/rust-Cfkwpbl8.js +1 -0
- package/dist/web/assets/sankeyDiagram-P5KCCOFB-DcoJ0sYw.js +40 -0
- package/dist/web/assets/sequenceDiagram-WJ2MYXX4-kRLbkAfp.js +162 -0
- package/dist/web/assets/shellscript-CLZ0U2zV.js +1 -0
- package/dist/web/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/dist/web/assets/sql-BsFa4tDR.js +1 -0
- package/dist/web/assets/src-B6xuSHsQ.js +1 -0
- package/dist/web/assets/stateDiagram-D77RDMKH-CEWdwxTm.js +1 -0
- package/dist/web/assets/stateDiagram-v2-MP3YSRHH-DZ8dTBXf.js +1 -0
- package/dist/web/assets/swimlanes-42K2YHIH-oyoSxsbH.js +1 -0
- package/dist/web/assets/swimlanesDiagram-VR7AAH4N-McbEeiYz.js +8 -0
- package/dist/web/assets/timeline-definition-24CTP7MA-D97QYUq5.js +120 -0
- package/dist/web/assets/toml-CcmNWLt0.js +1 -0
- package/dist/web/assets/treeView-Q6P3EWNA-FwhRnXTt.js +1 -0
- package/dist/web/assets/treemap-WGGIJYW6-ByFNzzzU.js +1 -0
- package/dist/web/assets/tsx-udAQXfEw.js +1 -0
- package/dist/web/assets/typescript-bsJCZSQ-.js +1 -0
- package/dist/web/assets/vennDiagram-4TSXK5OY-CcqjaQy9.js +34 -0
- package/dist/web/assets/view-0zE8CdnP.js +1 -0
- package/dist/web/assets/view-BQZA99Bp.js +1 -0
- package/dist/web/assets/view-BqpAWkcP.css +1 -0
- package/dist/web/assets/view-C9vvQ0AK.js +1 -0
- package/dist/web/assets/view-CS-jPsy6.js +2 -0
- package/dist/web/assets/view-CYRKKlxP.js +1 -0
- package/dist/web/assets/view-CyO_m5dR.css +1 -0
- package/dist/web/assets/view-D7KCHoy1.css +1 -0
- package/dist/web/assets/view-DE2OI6Vv.js +5 -0
- package/dist/web/assets/view-DPqI4qBt.css +1 -0
- package/dist/web/assets/view-DWcZ1gKW.css +1 -0
- package/dist/web/assets/view-Dl8ihdis.css +1 -0
- package/dist/web/assets/view-DrJSW55A.css +1 -0
- package/dist/web/assets/view-DzlpughI.js +257 -0
- package/dist/web/assets/view-NDb0VvVU.js +6 -0
- package/dist/web/assets/wardley-WFR3VGLG-Bt4CePrl.js +1 -0
- package/dist/web/assets/wardleyDiagram-VM6X3IG4-CcgXimi3.js +78 -0
- package/dist/web/assets/xychartDiagram-S5SC5T6Z-0Jx20wNz.js +7 -0
- package/dist/web/assets/yaml-rwi0_p6S.js +1 -0
- package/dist/web/index.html +17 -0
- package/package.json +55 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { CommandCatalog } from "@alisio/core";
|
|
2
|
+
import { readJson } from "../http/body.js";
|
|
3
|
+
import { HttpError } from "../http/errors.js";
|
|
4
|
+
import { is, validate } from "../schemas.js";
|
|
5
|
+
import { RecentRequests } from "./prompts.js";
|
|
6
|
+
/** Commands that change the session's own run state: refused while it runs or compacts. */
|
|
7
|
+
const EXCLUSIVE = new Set(["model", "compact"]);
|
|
8
|
+
/**
|
|
9
|
+
* The `/ask` prompt: the TUI sends the same instruction (packages/cli/src/tui/app.ts); both turn
|
|
10
|
+
* the user's question into a multiple-choice `ask_user_question` call.
|
|
11
|
+
*/
|
|
12
|
+
export const askPrompt = (question) => `The user has a question of their own and wants help turning it into a multiple-choice ` +
|
|
13
|
+
`question: "${question}"\n\nPropose 2-4 concrete, mutually distinct options that ` +
|
|
14
|
+
`would resolve it. Mark at most one option "recommended" only if you have a clear, ` +
|
|
15
|
+
`well-justified opinion; it is a suggestion, never forced on the user. Then ` +
|
|
16
|
+
`immediately call ask_user_question with exactly one question built from this ` +
|
|
17
|
+
`(reuse the user's own wording for the question text). Do not answer in plain text ` +
|
|
18
|
+
`first; call the tool right away.`;
|
|
19
|
+
const helpText = (commands) => [
|
|
20
|
+
"**Commands**",
|
|
21
|
+
"",
|
|
22
|
+
...commands.map((c) => `- \`/${c.name}${c.argumentHint ? ` ${c.argumentHint}` : ""}\` — ${c.description}${c.aliases?.length ? ` (${c.aliases.map((a) => `/${a}`).join(", ")})` : ""}`),
|
|
23
|
+
"",
|
|
24
|
+
"**Keys**: Enter send · Shift+Enter newline · ↑↓ prompt history · `/` focus the composer · Esc close popovers",
|
|
25
|
+
].join("\n");
|
|
26
|
+
/**
|
|
27
|
+
* The shared slash-command catalog for the web (RF-07): `GET /api/commands` lists the commands
|
|
28
|
+
* whose surfaces include `web`; `POST /api/sessions/:sid/commands` runs core commands and expands
|
|
29
|
+
* prompt templates, skills and `/ask` into a prompt the client sends itself.
|
|
30
|
+
*/
|
|
31
|
+
export function registerCommandRoutes(router, ctx) {
|
|
32
|
+
const { sessions, scheduler } = ctx;
|
|
33
|
+
const recent = ctx.recent ?? new RecentRequests();
|
|
34
|
+
const catalogOf = (opened) => new CommandCatalog(opened?.app);
|
|
35
|
+
router.get("/api/commands", async ({ url }) => {
|
|
36
|
+
const sid = url.searchParams.get("session");
|
|
37
|
+
const wid = url.searchParams.get("workspace");
|
|
38
|
+
let opened;
|
|
39
|
+
if (sid)
|
|
40
|
+
opened = await sessions.app(sessions.get(sid));
|
|
41
|
+
else if (wid) {
|
|
42
|
+
const path = await ctx.workspaces.pathOf(wid);
|
|
43
|
+
if (!path)
|
|
44
|
+
throw new HttpError("not_found", "Workspace not found");
|
|
45
|
+
opened = await ctx.workspaces.openPath(path);
|
|
46
|
+
}
|
|
47
|
+
return { body: catalogOf(opened).list("web") };
|
|
48
|
+
});
|
|
49
|
+
router.post("/api/sessions/:sid/commands", async ({ req, params }) => {
|
|
50
|
+
const session = sessions.get(params.sid ?? "");
|
|
51
|
+
const input = validate(await readJson(req), {
|
|
52
|
+
requestId: { check: is.requestId(), required: true },
|
|
53
|
+
name: { check: is.nonEmpty(200), required: true },
|
|
54
|
+
args: { check: is.string(100_000) },
|
|
55
|
+
});
|
|
56
|
+
if (recent.has(session.id, input.requestId))
|
|
57
|
+
return { body: { duplicate: true } };
|
|
58
|
+
const opened = await sessions.app(session);
|
|
59
|
+
const catalog = catalogOf(opened);
|
|
60
|
+
const name = input.name.replace(/^\//, "");
|
|
61
|
+
const descriptor = catalog.resolve(name);
|
|
62
|
+
if (!descriptor || !descriptor.surfaces.includes("web"))
|
|
63
|
+
throw new HttpError("unknown_command", `Unknown command /${name}`);
|
|
64
|
+
const args = (input.args ?? "").trim();
|
|
65
|
+
const busy = () => scheduler.busy(session.id) ||
|
|
66
|
+
scheduler.compacting(session.id) ||
|
|
67
|
+
opened.app.runner.isRunning(session.id);
|
|
68
|
+
if (EXCLUSIVE.has(descriptor.name) && busy())
|
|
69
|
+
throw new HttpError("session_busy", `/${descriptor.name} is unavailable while the session runs`);
|
|
70
|
+
recent.add(session.id, input.requestId);
|
|
71
|
+
const display = `/${descriptor.name}${args ? ` ${args}` : ""}`;
|
|
72
|
+
const fail = (error) => {
|
|
73
|
+
throw new HttpError("validation_failed", error instanceof Error ? error.message : "Command failed", { fields: ["args"] });
|
|
74
|
+
};
|
|
75
|
+
let outcome;
|
|
76
|
+
try {
|
|
77
|
+
outcome = await run();
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error instanceof HttpError)
|
|
81
|
+
throw error;
|
|
82
|
+
return fail(error);
|
|
83
|
+
}
|
|
84
|
+
sessions.notify(session.id);
|
|
85
|
+
return { body: outcome };
|
|
86
|
+
async function run() {
|
|
87
|
+
if (!descriptor)
|
|
88
|
+
throw new HttpError("unknown_command", `Unknown command /${name}`);
|
|
89
|
+
if (descriptor.source === "prompt") {
|
|
90
|
+
const expanded = opened.app.expandPrompt(display);
|
|
91
|
+
if (!expanded)
|
|
92
|
+
throw new HttpError("unknown_command", `Unknown command /${name}`);
|
|
93
|
+
return { prompt: { text: expanded.text, display: expanded.display } };
|
|
94
|
+
}
|
|
95
|
+
if (descriptor.source === "skill") {
|
|
96
|
+
const skill = await opened.app.skills.load(descriptor.name.slice("skill:".length));
|
|
97
|
+
return { prompt: { text: `${skill}\n\nUser request: ${args}`, display } };
|
|
98
|
+
}
|
|
99
|
+
if (descriptor.name === "help")
|
|
100
|
+
return { output: helpText(catalog.list("web")) };
|
|
101
|
+
if (descriptor.name === "ask") {
|
|
102
|
+
if (!args)
|
|
103
|
+
throw new Error("Usage: /ask <question>");
|
|
104
|
+
return { prompt: { text: askPrompt(args), display } };
|
|
105
|
+
}
|
|
106
|
+
if (descriptor.name === "effort") {
|
|
107
|
+
// Per session on the web (the TUI's /effort changes the global setting).
|
|
108
|
+
if (!args)
|
|
109
|
+
return {
|
|
110
|
+
output: `Reasoning effort: ${String(session.options?.effort ?? "model default")}`,
|
|
111
|
+
tone: "notice",
|
|
112
|
+
};
|
|
113
|
+
const effort = args === "!clear" ? undefined : args;
|
|
114
|
+
sessions.setOptions(session.id, { effort });
|
|
115
|
+
return {
|
|
116
|
+
output: effort ? `Reasoning effort set to ${effort}` : "Reasoning effort cleared",
|
|
117
|
+
tone: "notice",
|
|
118
|
+
effects: ["effort"],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (descriptor.name === "compact") {
|
|
122
|
+
const work = opened.app.runner.compact(session.id, args ? { focus: args } : {});
|
|
123
|
+
scheduler.track(session.id, opened.id, work, {
|
|
124
|
+
onDone: () => sessions.notify(session.id),
|
|
125
|
+
});
|
|
126
|
+
sessions.notify(session.id);
|
|
127
|
+
const result = await work;
|
|
128
|
+
return result
|
|
129
|
+
? {
|
|
130
|
+
output: `Compacted ${result.replaced} messages: ~${result.before} → ~${result.after} tokens`,
|
|
131
|
+
tone: "notice",
|
|
132
|
+
effects: ["messages"],
|
|
133
|
+
}
|
|
134
|
+
: { output: "Not enough history to compact", tone: "notice" };
|
|
135
|
+
}
|
|
136
|
+
const result = await catalog.execute(descriptor.name, args, { sessionId: session.id });
|
|
137
|
+
if (result.sessionId && result.sessionId !== session.id)
|
|
138
|
+
sessions.notify(result.sessionId);
|
|
139
|
+
return {
|
|
140
|
+
...(result.text ? { output: result.text } : {}),
|
|
141
|
+
...(result.tone ? { tone: result.tone } : {}),
|
|
142
|
+
...(result.sessionId && result.sessionId !== session.id
|
|
143
|
+
? { sessionId: result.sessionId }
|
|
144
|
+
: {}),
|
|
145
|
+
...(descriptor.name === "model" ? { effects: ["model"] } : {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { SessionService } from "../host/sessions.ts";
|
|
2
|
+
import type { Router } from "../http/router.ts";
|
|
3
|
+
import type { SseHub } from "../sse/hub.ts";
|
|
4
|
+
/**
|
|
5
|
+
* `GET /api/events?session=<sid>&session=…`: one multiplexed SSE stream per tab. Every
|
|
6
|
+
* (re)connection gets a full snapshot per session (v1 ignores `Last-Event-ID` beyond that:
|
|
7
|
+
* the snapshot cursor tells the client which durable events it already has).
|
|
8
|
+
*/
|
|
9
|
+
export declare function registerEventRoutes(router: Router, ctx: {
|
|
10
|
+
hub: SseHub;
|
|
11
|
+
sessions: SessionService;
|
|
12
|
+
maxStreams?: number;
|
|
13
|
+
maxSessionsPerStream?: number;
|
|
14
|
+
}): void;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { HttpError } from "../http/errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* `GET /api/events?session=<sid>&session=…`: one multiplexed SSE stream per tab. Every
|
|
4
|
+
* (re)connection gets a full snapshot per session (v1 ignores `Last-Event-ID` beyond that:
|
|
5
|
+
* the snapshot cursor tells the client which durable events it already has).
|
|
6
|
+
*/
|
|
7
|
+
export function registerEventRoutes(router, ctx) {
|
|
8
|
+
const maxStreams = ctx.maxStreams ?? 16;
|
|
9
|
+
const maxSessions = ctx.maxSessionsPerStream ?? 8;
|
|
10
|
+
router.get("/api/events", ({ req, res, url }) => {
|
|
11
|
+
const ids = [...new Set(url.searchParams.getAll("session").filter(Boolean))];
|
|
12
|
+
if (ids.length > maxSessions)
|
|
13
|
+
throw new HttpError("validation_failed", `At most ${maxSessions} sessions per stream`, {
|
|
14
|
+
fields: ["session"],
|
|
15
|
+
});
|
|
16
|
+
for (const id of ids)
|
|
17
|
+
ctx.sessions.get(id);
|
|
18
|
+
if (ctx.hub.size >= maxStreams)
|
|
19
|
+
throw new HttpError("stream_limit", `At most ${maxStreams} event streams at once`);
|
|
20
|
+
res.writeHead(200, {
|
|
21
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
22
|
+
"Cache-Control": "no-store",
|
|
23
|
+
Connection: "keep-alive",
|
|
24
|
+
"X-Accel-Buffering": "no",
|
|
25
|
+
});
|
|
26
|
+
res.flushHeaders();
|
|
27
|
+
req.socket.setNoDelay(true);
|
|
28
|
+
const detach = ctx.hub.attach(res, ids);
|
|
29
|
+
res.on("close", detach);
|
|
30
|
+
return undefined;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type SQLiteStore } from "@alisio/core";
|
|
2
|
+
import type { SessionService } from "../host/sessions.ts";
|
|
3
|
+
import type { WorkspaceHost } from "../host/workspace-host.ts";
|
|
4
|
+
import type { Router } from "../http/router.ts";
|
|
5
|
+
/** Entries per tree page (RF-12). */
|
|
6
|
+
export declare const TREE_PAGE = 1000;
|
|
7
|
+
/** Default and maximum bytes of a previewed file (RF-12: larger files are truncated). */
|
|
8
|
+
export declare const FILE_PREVIEW_BYTES: number;
|
|
9
|
+
/**
|
|
10
|
+
* Resolves a workspace-relative (or absolute) path inside `root`. `mustExist` also requires the
|
|
11
|
+
* target to exist (404 otherwise); either way its realpath must stay inside the root (403).
|
|
12
|
+
*/
|
|
13
|
+
export declare function confine(root: string, path: string, mustExist?: boolean): Promise<{
|
|
14
|
+
abs: string;
|
|
15
|
+
rel: string;
|
|
16
|
+
}>;
|
|
17
|
+
export declare function registerFileRoutes(router: Router, ctx: {
|
|
18
|
+
workspaces: WorkspaceHost;
|
|
19
|
+
catalog: SQLiteStore;
|
|
20
|
+
sessions: SessionService;
|
|
21
|
+
}): void;
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace files for the web dock (RF-12, spec §8.2/§9.4): lazy tree, bounded file reads,
|
|
3
|
+
* git diffs and the files a session changed. Every path is confined to the workspace with the
|
|
4
|
+
* core's `safePath` rules (resolve against the canonical root, no symlink segment) and then
|
|
5
|
+
* re-checked on its `realpath`, so neither `..` nor a symlink ever leaves the workspace (403).
|
|
6
|
+
*/
|
|
7
|
+
import { lstat, open, readdir, realpath, stat } from "node:fs/promises";
|
|
8
|
+
import { basename, extname, relative, resolve, sep } from "node:path";
|
|
9
|
+
import { clipLines, inside, safePath, unifiedPatch } from "@alisio/core";
|
|
10
|
+
import { gitIgnored, gitRoot, gitStatus, runGit } from "../host/git.js";
|
|
11
|
+
import { HttpError } from "../http/errors.js";
|
|
12
|
+
import { sniffImage } from "./images.js";
|
|
13
|
+
/** Entries per tree page (RF-12). */
|
|
14
|
+
export const TREE_PAGE = 1000;
|
|
15
|
+
/** Default and maximum bytes of a previewed file (RF-12: larger files are truncated). */
|
|
16
|
+
export const FILE_PREVIEW_BYTES = 2 * 1024 * 1024;
|
|
17
|
+
const DIFF_LIMIT = 200 * 1024;
|
|
18
|
+
const toPosix = (path) => path.split(sep).join("/");
|
|
19
|
+
const errno = (error) => error?.code;
|
|
20
|
+
/**
|
|
21
|
+
* Resolves a workspace-relative (or absolute) path inside `root`. `mustExist` also requires the
|
|
22
|
+
* target to exist (404 otherwise); either way its realpath must stay inside the root (403).
|
|
23
|
+
*/
|
|
24
|
+
export async function confine(root, path, mustExist = true) {
|
|
25
|
+
let canonical;
|
|
26
|
+
let target;
|
|
27
|
+
try {
|
|
28
|
+
canonical = await realpath(root);
|
|
29
|
+
target = await safePath(canonical, path || ".");
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (errno(error) === "ENOENT")
|
|
33
|
+
throw new HttpError("not_found", "Workspace not found");
|
|
34
|
+
throw new HttpError("path_outside_workspace", "The path is outside the workspace");
|
|
35
|
+
}
|
|
36
|
+
let real = target;
|
|
37
|
+
try {
|
|
38
|
+
real = await realpath(target);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (mustExist || errno(error) !== "ENOENT")
|
|
42
|
+
throw new HttpError("not_found", "File not found");
|
|
43
|
+
}
|
|
44
|
+
if (!inside(canonical, real))
|
|
45
|
+
throw new HttpError("path_outside_workspace", "The path is outside the workspace");
|
|
46
|
+
return { abs: real, rel: toPosix(relative(canonical, real)) };
|
|
47
|
+
}
|
|
48
|
+
const kindOf = (entry) => entry.isSymbolicLink()
|
|
49
|
+
? "symlink"
|
|
50
|
+
: entry.isDirectory()
|
|
51
|
+
? "dir"
|
|
52
|
+
: entry.isFile()
|
|
53
|
+
? "file"
|
|
54
|
+
: "other";
|
|
55
|
+
async function readHeadBytes(path, max) {
|
|
56
|
+
const handle = await open(path, "r");
|
|
57
|
+
try {
|
|
58
|
+
const buffer = Buffer.alloc(max);
|
|
59
|
+
const { bytesRead } = await handle.read(buffer, 0, max, 0);
|
|
60
|
+
return buffer.subarray(0, bytesRead);
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await handle.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const looksBinary = (bytes) => bytes.subarray(0, 8192).includes(0);
|
|
67
|
+
export function registerFileRoutes(router, ctx) {
|
|
68
|
+
const { workspaces, catalog, sessions } = ctx;
|
|
69
|
+
const rootOf = async (wid) => {
|
|
70
|
+
const path = await workspaces.pathOf(wid ?? "");
|
|
71
|
+
if (!path)
|
|
72
|
+
throw new HttpError("not_found", "Workspace not found");
|
|
73
|
+
return path;
|
|
74
|
+
};
|
|
75
|
+
router.get("/api/workspaces/:wid/tree", async ({ params, url }) => {
|
|
76
|
+
const root = await rootOf(params.wid);
|
|
77
|
+
const { abs, rel } = await confine(root, url.searchParams.get("path") ?? "");
|
|
78
|
+
if (!(await stat(abs)).isDirectory())
|
|
79
|
+
throw new HttpError("validation_failed", "path is not a directory", { fields: ["path"] });
|
|
80
|
+
const offset = Math.max(0, Number.parseInt(url.searchParams.get("cursor") ?? "0", 10) || 0);
|
|
81
|
+
const dirents = (await readdir(abs, { withFileTypes: true })).filter((d) => d.name !== ".git");
|
|
82
|
+
const prefix = rel ? `${rel}/` : "";
|
|
83
|
+
const all = dirents
|
|
84
|
+
.map((d) => ({ name: d.name, path: `${prefix}${d.name}`, type: kindOf(d) }))
|
|
85
|
+
.sort((a, b) => (a.type === "dir") !== (b.type === "dir")
|
|
86
|
+
? a.type === "dir"
|
|
87
|
+
? -1
|
|
88
|
+
: 1
|
|
89
|
+
: a.name < b.name
|
|
90
|
+
? -1
|
|
91
|
+
: a.name > b.name
|
|
92
|
+
? 1
|
|
93
|
+
: 0);
|
|
94
|
+
const page = all.slice(offset, offset + TREE_PAGE);
|
|
95
|
+
// `.gitignore` applies when git is available; ignored entries of this page are hidden.
|
|
96
|
+
const ignored = await gitIgnored(abs, page.map((e) => (e.type === "dir" ? `${e.name}/` : e.name)));
|
|
97
|
+
const entries = [];
|
|
98
|
+
for (const entry of page) {
|
|
99
|
+
if (ignored.has(entry.name) || ignored.has(`${entry.name}/`))
|
|
100
|
+
continue;
|
|
101
|
+
const info = { ...entry };
|
|
102
|
+
if (entry.type === "file")
|
|
103
|
+
try {
|
|
104
|
+
const s = await lstat(resolve(abs, entry.name));
|
|
105
|
+
info.size = s.size;
|
|
106
|
+
info.mtime = Math.round(s.mtimeMs);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
/* vanished meanwhile */
|
|
110
|
+
}
|
|
111
|
+
entries.push(info);
|
|
112
|
+
}
|
|
113
|
+
const next = offset + TREE_PAGE < all.length ? String(offset + TREE_PAGE) : undefined;
|
|
114
|
+
return { body: { entries, ...(next ? { next } : {}) } };
|
|
115
|
+
});
|
|
116
|
+
router.get("/api/workspaces/:wid/file", async ({ params, url, res }) => {
|
|
117
|
+
const root = await rootOf(params.wid);
|
|
118
|
+
const { abs } = await confine(root, url.searchParams.get("path") ?? "");
|
|
119
|
+
const info = await stat(abs);
|
|
120
|
+
if (!info.isFile())
|
|
121
|
+
throw new HttpError("validation_failed", "path is not a file", { fields: ["path"] });
|
|
122
|
+
const name = basename(abs).replace(/["\\\r\n]/g, "_");
|
|
123
|
+
if (url.searchParams.get("download") === "1") {
|
|
124
|
+
res.writeHead(200, {
|
|
125
|
+
"Content-Type": "application/octet-stream",
|
|
126
|
+
"Content-Length": info.size,
|
|
127
|
+
"Content-Disposition": `attachment; filename="${name}"`,
|
|
128
|
+
});
|
|
129
|
+
const handle = await open(abs, "r");
|
|
130
|
+
try {
|
|
131
|
+
for await (const chunk of handle.createReadStream())
|
|
132
|
+
res.write(chunk);
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
await handle.close().catch(() => { });
|
|
136
|
+
}
|
|
137
|
+
res.end();
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
const requested = Number.parseInt(url.searchParams.get("maxBytes") ?? "", 10);
|
|
141
|
+
const max = Number.isFinite(requested) && requested > 0
|
|
142
|
+
? Math.min(requested, FILE_PREVIEW_BYTES)
|
|
143
|
+
: FILE_PREVIEW_BYTES;
|
|
144
|
+
const bytes = await readHeadBytes(abs, Math.min(max, info.size));
|
|
145
|
+
const image = sniffImage(bytes);
|
|
146
|
+
const type = image
|
|
147
|
+
? image.mimeType
|
|
148
|
+
: looksBinary(bytes)
|
|
149
|
+
? "application/octet-stream"
|
|
150
|
+
: "text/plain; charset=utf-8";
|
|
151
|
+
res.writeHead(200, {
|
|
152
|
+
"Content-Type": type,
|
|
153
|
+
"Content-Length": bytes.length,
|
|
154
|
+
"Content-Disposition": `${type === "application/octet-stream" ? "attachment" : "inline"}; filename="${name}"`,
|
|
155
|
+
"X-Truncated": String(bytes.length < info.size),
|
|
156
|
+
"X-File-Size": String(info.size),
|
|
157
|
+
});
|
|
158
|
+
res.end(bytes);
|
|
159
|
+
return undefined;
|
|
160
|
+
});
|
|
161
|
+
router.get("/api/workspaces/:wid/diff", async ({ params, url }) => {
|
|
162
|
+
const root = await rootOf(params.wid);
|
|
163
|
+
const { abs, rel } = await confine(root, url.searchParams.get("path") ?? "", false);
|
|
164
|
+
const top = await gitRoot(root);
|
|
165
|
+
if (!top)
|
|
166
|
+
throw new HttpError("not_a_git_repo", "The workspace is not a git repository");
|
|
167
|
+
const canonical = await realpath(root);
|
|
168
|
+
let patch = "";
|
|
169
|
+
const tracked = await runGit(canonical, ["ls-files", "--error-unmatch", "--", rel], {
|
|
170
|
+
literal: true,
|
|
171
|
+
});
|
|
172
|
+
if (tracked.code === 0) {
|
|
173
|
+
const diff = await runGit(canonical, ["diff", "--no-ext-diff", "--no-textconv", "--no-color", "HEAD", "--", rel], { literal: true });
|
|
174
|
+
patch = diff.code === 0 ? diff.stdout : "";
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
// Untracked (or a repository without commits): the whole file is new.
|
|
178
|
+
try {
|
|
179
|
+
const bytes = await readHeadBytes(abs, 1024 * 1024);
|
|
180
|
+
patch = looksBinary(bytes) ? "" : unifiedPatch(rel, undefined, bytes.toString("utf8"));
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
throw new HttpError("not_found", "File not found");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const { text, clipped } = clipLines(patch, DIFF_LIMIT);
|
|
187
|
+
const lang = extname(rel).slice(1).toLowerCase();
|
|
188
|
+
return {
|
|
189
|
+
body: {
|
|
190
|
+
kind: "diff",
|
|
191
|
+
path: rel,
|
|
192
|
+
patch: text,
|
|
193
|
+
...(lang ? { lang } : {}),
|
|
194
|
+
...(clipped ? { caption: `${rel} (diff truncated to 200 KB)` } : {}),
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
router.get("/api/sessions/:sid/changes", async ({ params }) => {
|
|
199
|
+
const session = sessions.get(params.sid ?? "");
|
|
200
|
+
const workspace = await realpath(session.workspace).catch(() => session.workspace);
|
|
201
|
+
// The session and its children (subagents write too), depth-first.
|
|
202
|
+
const ids = [];
|
|
203
|
+
const walk = (id) => {
|
|
204
|
+
ids.push(id);
|
|
205
|
+
for (const child of catalog.children(id))
|
|
206
|
+
walk(child.id);
|
|
207
|
+
};
|
|
208
|
+
walk(session.id);
|
|
209
|
+
const latest = new Map();
|
|
210
|
+
for (const id of ids) {
|
|
211
|
+
const calls = catalog.effectCalls(id, "write");
|
|
212
|
+
if (!calls.length)
|
|
213
|
+
continue;
|
|
214
|
+
const wanted = new Map(calls.map((c) => [c.callId, c]));
|
|
215
|
+
let after = -1;
|
|
216
|
+
for (;;) {
|
|
217
|
+
const page = catalog.messagesPage(id, { after, limit: 500, compacted: true });
|
|
218
|
+
for (const { message } of page.items) {
|
|
219
|
+
if (message.role !== "assistant")
|
|
220
|
+
continue;
|
|
221
|
+
for (const call of message.calls) {
|
|
222
|
+
const meta = wanted.get(call.id);
|
|
223
|
+
if (!meta)
|
|
224
|
+
continue;
|
|
225
|
+
let path;
|
|
226
|
+
try {
|
|
227
|
+
path = JSON.parse(call.arguments).path;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (typeof path !== "string" || !path)
|
|
233
|
+
continue;
|
|
234
|
+
const absolute = resolve(workspace, path);
|
|
235
|
+
if (!inside(workspace, absolute))
|
|
236
|
+
continue;
|
|
237
|
+
const rel = toPosix(relative(workspace, absolute));
|
|
238
|
+
const at = meta.startedAt ?? 0;
|
|
239
|
+
const prev = latest.get(rel);
|
|
240
|
+
if (!prev || at >= prev.at)
|
|
241
|
+
latest.set(rel, { ...(meta.runId ? { runId: meta.runId } : {}), at });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const last = page.items.at(-1);
|
|
245
|
+
if (!page.hasMore || !last)
|
|
246
|
+
break;
|
|
247
|
+
after = last.seq;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const top = latest.size ? await gitRoot(workspace) : undefined;
|
|
251
|
+
const marks = top ? await gitStatus(workspace, top) : new Map();
|
|
252
|
+
const files = [...latest.entries()]
|
|
253
|
+
.sort((a, b) => b[1].at - a[1].at || (a[0] < b[0] ? -1 : 1))
|
|
254
|
+
.map(([path, meta]) => ({
|
|
255
|
+
path,
|
|
256
|
+
...(meta.runId ? { lastRunId: meta.runId } : {}),
|
|
257
|
+
effect: "write",
|
|
258
|
+
...(marks.has(path) ? { gitStatus: marks.get(path) } : {}),
|
|
259
|
+
}));
|
|
260
|
+
return { body: { files } };
|
|
261
|
+
});
|
|
262
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Router } from "../http/router.ts";
|
|
2
|
+
/** Web protocol version (§8.9); independent of `RunEvent.schemaVersion`. */
|
|
3
|
+
export declare const PROTOCOL_VERSION: 1;
|
|
4
|
+
export interface ServerStats {
|
|
5
|
+
shuttingDown: boolean;
|
|
6
|
+
activeRuns: number;
|
|
7
|
+
queuedRuns: number;
|
|
8
|
+
openWorkspaces: number;
|
|
9
|
+
subscribers: number;
|
|
10
|
+
pendingApprovals: number;
|
|
11
|
+
sseDropped: number;
|
|
12
|
+
}
|
|
13
|
+
/** `/api/health` (unauthenticated), `/api/ready` and `/api/metrics`. */
|
|
14
|
+
export declare function registerHealthRoutes(router: Router, options: {
|
|
15
|
+
version: string;
|
|
16
|
+
remote: boolean;
|
|
17
|
+
stats: () => ServerStats;
|
|
18
|
+
}): void;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { UI_BLOCK_KINDS } from "@alisio/sdk";
|
|
2
|
+
import { HttpError } from "../http/errors.js";
|
|
3
|
+
/** Web protocol version (§8.9); independent of `RunEvent.schemaVersion`. */
|
|
4
|
+
export const PROTOCOL_VERSION = 1;
|
|
5
|
+
/** `/api/health` (unauthenticated), `/api/ready` and `/api/metrics`. */
|
|
6
|
+
export function registerHealthRoutes(router, options) {
|
|
7
|
+
router.get("/api/health", () => {
|
|
8
|
+
const body = {
|
|
9
|
+
name: "alisio",
|
|
10
|
+
version: options.version,
|
|
11
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
12
|
+
capabilities: {
|
|
13
|
+
sse: true,
|
|
14
|
+
websocket: false,
|
|
15
|
+
multiWorkspace: true,
|
|
16
|
+
// Uploads (`POST /api/blobs`) arrive in phase 4.
|
|
17
|
+
attachments: false,
|
|
18
|
+
uiBlocks: [...UI_BLOCK_KINDS],
|
|
19
|
+
mcpApps: false,
|
|
20
|
+
automation: false,
|
|
21
|
+
remote: options.remote,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
return { body };
|
|
25
|
+
});
|
|
26
|
+
router.get("/api/ready", () => {
|
|
27
|
+
const stats = options.stats();
|
|
28
|
+
if (stats.shuttingDown)
|
|
29
|
+
throw new HttpError("shutting_down", "Server is shutting down");
|
|
30
|
+
return {
|
|
31
|
+
body: { ready: true, workspaces: stats.openWorkspaces, activeRuns: stats.activeRuns },
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
router.get("/api/metrics", () => {
|
|
35
|
+
const { shuttingDown: _, ...metrics } = options.stats();
|
|
36
|
+
return { body: metrics };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image detection by magic bytes (never by name or declared type) for uploads and file previews:
|
|
3
|
+
* PNG, JPEG, GIF and WebP, the formats the TUI and providers accept (spec §11.1). Dimensions are
|
|
4
|
+
* read from the header when present; they are advisory metadata, not validation.
|
|
5
|
+
*/
|
|
6
|
+
export interface SniffedImage {
|
|
7
|
+
mimeType: "image/png" | "image/jpeg" | "image/gif" | "image/webp";
|
|
8
|
+
width?: number;
|
|
9
|
+
height?: number;
|
|
10
|
+
}
|
|
11
|
+
/** The image type (and size when readable) of `bytes`, or undefined when it is not one. */
|
|
12
|
+
export declare function sniffImage(bytes: Uint8Array): SniffedImage | undefined;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const ascii = (bytes, at, text) => bytes.length >= at + text.length &&
|
|
2
|
+
[...text].every((ch, i) => bytes[at + i] === ch.charCodeAt(0));
|
|
3
|
+
const dims = (width, height) => (width > 0 && height > 0 ? { width, height } : {});
|
|
4
|
+
function jpegSize(b) {
|
|
5
|
+
let i = 2;
|
|
6
|
+
while (i + 9 < b.length) {
|
|
7
|
+
if (b[i] !== 0xff)
|
|
8
|
+
return {};
|
|
9
|
+
const marker = b[i + 1];
|
|
10
|
+
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
11
|
+
i += 2;
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
const length = (b[i + 2] << 8) | b[i + 3];
|
|
15
|
+
// SOF0–SOF15 except DHT (C4), JPG (C8) and DAC (CC) carry the frame size.
|
|
16
|
+
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker))
|
|
17
|
+
return dims((b[i + 7] << 8) | b[i + 8], (b[i + 5] << 8) | b[i + 6]);
|
|
18
|
+
if (length < 2)
|
|
19
|
+
return {};
|
|
20
|
+
i += 2 + length;
|
|
21
|
+
}
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
function webpSize(b) {
|
|
25
|
+
const u24 = (at) => b[at] | (b[at + 1] << 8) | (b[at + 2] << 16);
|
|
26
|
+
if (ascii(b, 12, "VP8X") && b.length >= 30)
|
|
27
|
+
return dims(u24(24) + 1, u24(27) + 1);
|
|
28
|
+
if (ascii(b, 12, "VP8 ") && b.length >= 30)
|
|
29
|
+
return dims((b[26] | (b[27] << 8)) & 0x3fff, (b[28] | (b[29] << 8)) & 0x3fff);
|
|
30
|
+
if (ascii(b, 12, "VP8L") && b.length >= 25) {
|
|
31
|
+
const bits = b[21] |
|
|
32
|
+
(b[22] << 8) |
|
|
33
|
+
(b[23] << 16) |
|
|
34
|
+
(b[24] << 24);
|
|
35
|
+
return dims((bits & 0x3fff) + 1, ((bits >>> 14) & 0x3fff) + 1);
|
|
36
|
+
}
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
/** The image type (and size when readable) of `bytes`, or undefined when it is not one. */
|
|
40
|
+
export function sniffImage(bytes) {
|
|
41
|
+
const b = bytes;
|
|
42
|
+
if (b.length >= 8 && b[0] === 0x89 && ascii(b, 1, "PNG\r\n\x1a\n")) {
|
|
43
|
+
const size = b.length >= 24 && ascii(b, 12, "IHDR")
|
|
44
|
+
? dims(Buffer.from(b.subarray(16, 20)).readUInt32BE(0), Buffer.from(b.subarray(20, 24)).readUInt32BE(0))
|
|
45
|
+
: {};
|
|
46
|
+
return { mimeType: "image/png", ...size };
|
|
47
|
+
}
|
|
48
|
+
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff)
|
|
49
|
+
return { mimeType: "image/jpeg", ...jpegSize(b) };
|
|
50
|
+
if (ascii(b, 0, "GIF87a") || ascii(b, 0, "GIF89a"))
|
|
51
|
+
return {
|
|
52
|
+
mimeType: "image/gif",
|
|
53
|
+
...(b.length >= 10
|
|
54
|
+
? dims(b[6] | (b[7] << 8), b[8] | (b[9] << 8))
|
|
55
|
+
: {}),
|
|
56
|
+
};
|
|
57
|
+
if (ascii(b, 0, "RIFF") && ascii(b, 8, "WEBP"))
|
|
58
|
+
return { mimeType: "image/webp", ...webpSize(b) };
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ServerFrame } from "@alisio/sdk";
|
|
2
|
+
import type { RunScheduler } from "../host/run-scheduler.ts";
|
|
3
|
+
import type { OpenWorkspace, ServerAppOptions, WorkspaceHost } from "../host/workspace-host.ts";
|
|
4
|
+
import type { Router } from "../http/router.ts";
|
|
5
|
+
type CatalogScope = Extract<ServerFrame, {
|
|
6
|
+
t: "catalog_changed";
|
|
7
|
+
}>["scope"];
|
|
8
|
+
export interface ManagementContext {
|
|
9
|
+
workspaces: WorkspaceHost;
|
|
10
|
+
scheduler: RunScheduler;
|
|
11
|
+
base: ServerAppOptions;
|
|
12
|
+
broadcast: (frame: ServerFrame) => void;
|
|
13
|
+
}
|
|
14
|
+
/** Resolves `?workspace=` / `{workspace}` (an opaque id or an absolute path) to its open app. */
|
|
15
|
+
export declare function workspaceApp(workspaces: WorkspaceHost, reference: string | null | undefined): Promise<OpenWorkspace>;
|
|
16
|
+
/**
|
|
17
|
+
* Plugin and skill toggles persist a project override; the running app reflects plugin changes
|
|
18
|
+
* only after a restart. The server recycles the workspace app as soon as it has no runs (now,
|
|
19
|
+
* or when its last run finishes), so the change applies without restarting `alisio serve`.
|
|
20
|
+
*/
|
|
21
|
+
export declare class WorkspaceRecycler {
|
|
22
|
+
private ctx;
|
|
23
|
+
private pending;
|
|
24
|
+
constructor(ctx: ManagementContext);
|
|
25
|
+
/** Recycles now when idle; otherwise once the workspace's runs finish. */
|
|
26
|
+
request(id: string): Promise<OpenWorkspace | undefined>;
|
|
27
|
+
/** Called when a run finishes: applies a deferred recycle once nothing runs. */
|
|
28
|
+
idle(id: string): void;
|
|
29
|
+
announce(workspaceId: string, scopes: CatalogScope[]): void;
|
|
30
|
+
}
|
|
31
|
+
export declare function registerManagementRoutes(router: Router, ctx: ManagementContext, recycler: WorkspaceRecycler): void;
|
|
32
|
+
export {};
|