From 477c727ec431e763123adc09c0ba01bb9a4377bc Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 07:16:55 +0000 Subject: [PATCH 1/3] fix: derive dashboard protocol from routes --- package.json | 4 + pnpm-lock.yaml | 382 +++++++++++++++ rstest.browser-app.config.ts | 4 + rstest.config.ts | 2 +- src/dashboard/lib.ts | 62 +-- src/mcp/hauler/apps/dashboard.tsx | 500 ++++---------------- tests/browser-app/dashboard.browser.test.ts | 112 +++++ tests/dashboard-route-types.ts | 20 + tests/dashboard.test.ts | 6 +- 9 files changed, 626 insertions(+), 466 deletions(-) create mode 100644 rstest.browser-app.config.ts create mode 100644 tests/browser-app/dashboard.browser.test.ts create mode 100644 tests/dashboard-route-types.ts diff --git a/package.json b/package.json index 8e63bd2d..d259de75 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "inspect": "agent-bundle inspect", "prepack": "agent-bundle prepack", "test": "rstest", + "test:browser": "rstest --config rstest.browser-app.config.ts", "test:routes": "rstest --config rstest.route-unit.config.ts", "typecheck": "tsc -p tsconfig.json --noEmit", "validate": "agent-bundle validate", @@ -62,7 +63,9 @@ "@effect/platform-node": "4.0.0-rc.112", "@effect/tsgo": "0.38.0", "@modelcontextprotocol/server": "2.0.0", + "@rstest/browser": "0.11.12", "@rstest/core": "0.11.12", + "@rstest/playwright": "0.11.12", "@types/node": "^22.18.0", "@types/proper-lockfile": "^4.1.4", "@types/react": "^19.2.18", @@ -73,6 +76,7 @@ "effect": "4.0.0-rc.112", "effect-rstest": "https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f", "proper-lockfile": "^4.1.2", + "playwright": "1.62.1", "react": "^19.2.8", "react-dom": "^19.2.8", "scheduler": "^0.27.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8510b05a..6fd713ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,9 +26,15 @@ importers: '@modelcontextprotocol/server': specifier: 2.0.0 version: 2.0.0 + '@rstest/browser': + specifier: 0.11.12 + version: 0.11.12(@rstest/core@0.11.12)(playwright@1.62.1) '@rstest/core': specifier: 0.11.12 version: 0.11.12 + '@rstest/playwright': + specifier: 0.11.12 + version: 0.11.12(@rstest/core@0.11.12)(playwright@1.62.1) '@types/node': specifier: ^22.18.0 version: 22.20.1 @@ -56,6 +62,9 @@ importers: effect-rstest: specifier: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f version: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f(@rstest/core@0.11.12)(effect@4.0.0-rc.112) + playwright: + specifier: 1.62.1 + version: 1.62.1 proper-lockfile: specifier: ^4.1.2 version: 4.1.2 @@ -294,6 +303,16 @@ packages: peerDependencies: hono: ^4 + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@manypkg/find-root@3.1.0': resolution: {integrity: sha512-BcSqCyKhBVZ5YkSzOiheMCV41kqAFptW6xGqYSTjkVTl9XQpr+pqHhwgGCOHQtjDCv7Is6EFyA14Sm5GVbVABA==} engines: {node: '>=20.0.0'} @@ -380,6 +399,9 @@ packages: resolution: {integrity: sha512-pOr5+q1fLYKwFN3LAJuGZEnfXDcQ73zqgDHMtGy+K+uIoUqyY+6MeDCWFwfu+4EFuq76I5EPFofoNAI+Bmmq4A==} engines: {node: '>=22.13'} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@redis/bloom@6.2.1': resolution: {integrity: sha512-huQgNLaCIZfQ9SeLn4q9124uOUd8HbZDYHwwUzNcRgHqCHiHKl2dDxMqJCeWh8cMqZAoWuHR8XnWbDMIf+o7ag==} engines: {node: '>= 20.0.0'} @@ -609,6 +631,16 @@ packages: jiti: optional: true + '@rstest/browser@0.11.12': + resolution: {integrity: sha512-mCrRj8pXvdGeYpouUrFkV/GVLAbq5mOIx94+5jRCSLSgJNBMU1+OGu+aav3kFgyajORKEI+cKWT35hkPMj6+dQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rstest/core': 0.11.12 + playwright: ^1.49.1 + peerDependenciesMeta: + playwright: + optional: true + '@rstest/core@0.11.12': resolution: {integrity: sha512-dklEYLtL1eip/11K+o3xEchEs2nCGua2u2W8Pycf6PS8TE0PDJH2Bn9y/txuQI4YVHyIQ88PISWtS6fVnxSyXQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -622,6 +654,20 @@ packages: jsdom: optional: true + '@rstest/playwright@0.11.12': + resolution: {integrity: sha512-fU9BjRnCTsJNw2oEOY1aq/ozSt5gGuZMW6NwVHZrdxmi/uwypoYIcWSAE/kWukRzAazaSdO+wYZ4fB2IC8kLCA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rstest/core': ^0.11.9 + playwright: ^1.49.1 + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} @@ -866,6 +912,10 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + cac@7.0.0: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} @@ -882,6 +932,9 @@ packages: resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} engines: {node: '>=22.12.0'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -889,6 +942,18 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.1: + resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -903,6 +968,10 @@ packages: effect@4.0.0-rc.112: resolution: {integrity: sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==} + env-editor@1.3.0: + resolution: {integrity: sha512-EqiD/j01PooUbeWk+etUo2TWoocjoxMfGNYpS9e47glIJ5r8WepycIki+LCbonFbPdwlqY5ETeSTAJVMih4z4w==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -914,6 +983,10 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + fast-check@4.9.0: resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} engines: {node: '>=12.17.0'} @@ -949,6 +1022,10 @@ packages: picomatch: optional: true + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -958,6 +1035,15 @@ packages: engines: {node: '>=20'} hasBin: true + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -977,6 +1063,10 @@ packages: resolution: {integrity: sha512-zPGsiS+dWoTZtZ4AtpA9Y+BdSFSNWvnouNlWNoUFyAM6xHOHmdCvqO3k8AIbdamCOv4gUFUVNPf6rJFfc4UiJw==} hasBin: true + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + ignore@7.0.8: resolution: {integrity: sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==} engines: {node: '>= 4'} @@ -984,6 +1074,11 @@ packages: import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -992,10 +1087,35 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1018,6 +1138,10 @@ packages: launch-editor@2.14.1: resolution: {integrity: sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==} + line-column-path@4.0.0: + resolution: {integrity: sha512-Zvpvd56i9FRV5kaJFiiY1t+FNMEH+dGEaLyQprqKlGHBAxJXmdSk+8tVsh6b9YlxbfyyuLrhJCkzwB+AmOBZ0g==} + engines: {node: '>=20'} + lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} @@ -1035,6 +1159,10 @@ packages: engines: {node: '>=16'} hasBin: true + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + msgpackr-extract@3.0.4: resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} hasBin: true @@ -1050,13 +1178,36 @@ packages: resolution: {integrity: sha512-69XQh3k+dtGa1p+7RaR57IuG3rCko96xr/nUfN4yDYBXbTYICiWcOpsFKLN2GtGE9cyIljE+f1exnaYt9MvM+Q==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + open-editor@6.0.0: + resolution: {integrity: sha512-LGd2Xn6NvFlbx/lg/HK69w6Dbg+21MzJzcPDPQRgDRqc+qiR+2/SN99rzZSo7Qa1ck1hcGYig0CAo53cmXCE0w==} + engines: {node: '>=20'} + + open@11.0.2: + resolution: {integrity: sha512-RWqF+pBSkqecEvCKOn8QYhaNdRMJDZRIrlS/7rTDdLHaPcfXGCZ/h8zb413NfvdeAV0MR7T1yJcA34/q+CSm1Q==} + engines: {node: '>=20'} + package-manager-detector@1.8.0: resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1072,6 +1223,28 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + + powershell-utils@0.2.1: + resolution: {integrity: sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==} + engines: {node: '>=20'} + + pretty-ms@9.3.1: + resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} + engines: {node: '>=18'} + proc-log@7.0.0: resolution: {integrity: sha512-FYgfaA69XZ93zaXLoMNQ+ViDXGGBgR8aLh03txzcFhV+9xOXx7+8DLCULrKKpR9+GsH9ZfHm82aSUPpozX0Ztg==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} @@ -1146,6 +1319,10 @@ packages: peerDependencies: react: ^19.0.0 + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -1172,9 +1349,21 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + tinyexec@1.3.0: resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} engines: {node: '>=18'} @@ -1187,6 +1376,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1202,6 +1395,14 @@ packages: resolution: {integrity: sha512-YQ3WlbqjYMmNpdvDH64jAgLjxuAR9+649calDWhbshYaeQGO2bR4nI94ORJmwI3J9YhoKQnpyGOK+0zlWS5N5Q==} engines: {node: '>=22.19.0'} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + unicorn-magic@0.4.0: + resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} + engines: {node: '>=20'} + validate-npm-package-name@8.0.0: resolution: {integrity: sha512-SCv6OOV6Xj2/3cXy3dGmADluJTNcL3o7hZAglNPTe+WYuEuvxgJzxPrSDLZhF+CwyQOubqgecjMmTJGMVLWjYQ==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} @@ -1223,11 +1424,19 @@ packages: utf-8-validate: optional: true + wsl-utils@1.0.0: + resolution: {integrity: sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==} + engines: {node: '>=20'} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} + engines: {node: '>=18'} + zod@4.5.4: resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} @@ -1478,6 +1687,15 @@ snapshots: dependencies: hono: 4.13.7 + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + '@manypkg/find-root@3.1.0': dependencies: '@manypkg/tools': 2.1.2 @@ -1558,6 +1776,8 @@ snapshots: '@pnpm/deps.graph-sequencer@1100.0.1': {} + '@polka/url@1.0.0-next.29': {} + '@redis/bloom@6.2.1(@redis/client@6.2.1)': dependencies: '@redis/client': 6.2.1 @@ -1721,6 +1941,21 @@ snapshots: optionalDependencies: jiti: 2.7.0 + '@rstest/browser@0.11.12(@rstest/core@0.11.12)(playwright@1.62.1)': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@rstest/core': 0.11.12 + convert-source-map: 2.0.0 + open-editor: 6.0.0 + pathe: 2.0.3 + sirv: 3.0.2 + ws: 8.21.3 + optionalDependencies: + playwright: 1.62.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@rstest/core@0.11.12': dependencies: '@rsbuild/core': 2.2.3 @@ -1729,6 +1964,15 @@ snapshots: - '@module-federation/runtime-tools' - core-js + '@rstest/playwright@0.11.12(@rstest/core@0.11.12)(playwright@1.62.1)': + dependencies: + '@rstest/core': 0.11.12 + playwright: 1.62.1 + + '@sec-ant/readable-stream@0.4.1': {} + + '@sindresorhus/merge-streams@4.0.0': {} + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -1917,6 +2161,10 @@ snapshots: dependencies: fill-range: 7.1.1 + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + cac@7.0.0: {} chokidar@5.0.0: @@ -1927,6 +2175,8 @@ snapshots: commander@15.0.0: {} + convert-source-map@2.0.0: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -1935,6 +2185,15 @@ snapshots: csstype@3.2.3: {} + default-browser-id@5.0.1: {} + + default-browser@5.5.1: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + detect-libc@2.1.2: optional: true @@ -1948,6 +2207,8 @@ snapshots: fast-check: 4.9.0 msgpackr: 2.1.0 + env-editor@1.3.0: {} + es-module-lexer@2.3.2: {} eventsource-parser@3.1.1: {} @@ -1956,6 +2217,21 @@ snapshots: dependencies: eventsource-parser: 3.1.1 + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.1 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.2.0 + fast-check@4.9.0: dependencies: pure-rand: 8.4.2 @@ -1990,12 +2266,24 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 flare-redact@1.6.1: {} + fsevents@2.3.2: + optional: true + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -2010,18 +2298,38 @@ snapshots: human-id@4.2.1: {} + human-signals@8.0.1: {} + ignore@7.0.8: {} import-meta-resolve@4.2.0: {} + is-docker@3.0.0: {} + is-extglob@2.1.1: {} is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-number@7.0.0: {} + is-plain-obj@4.1.0: {} + + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + isexe@2.0.0: {} jiti@2.7.0: {} @@ -2039,6 +2347,10 @@ snapshots: picocolors: 1.1.1 shell-quote: 1.10.0 + line-column-path@4.0.0: + dependencies: + unicorn-magic: 0.4.0 + lru-cache@11.5.2: {} merge2@1.4.1: {} @@ -2050,6 +2362,8 @@ snapshots: mime@4.1.0: {} + mrmime@2.0.1: {} + msgpackr-extract@3.0.4: dependencies: node-gyp-build-optional-packages: 5.2.2 @@ -2078,10 +2392,37 @@ snapshots: semver: 7.8.5 validate-npm-package-name: 8.0.0 + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + open-editor@6.0.0: + dependencies: + env-editor: 1.3.0 + execa: 9.6.1 + line-column-path: 4.0.0 + open: 11.0.2 + + open@11.0.2: + dependencies: + default-browser: 5.5.1 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.2.1 + wsl-utils: 1.0.0 + package-manager-detector@1.8.0: {} + parse-ms@4.0.0: {} + path-key@3.1.1: {} + path-key@4.0.0: {} + + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -2090,6 +2431,22 @@ snapshots: pkce-challenge@5.0.1: {} + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + + powershell-utils@0.1.0: {} + + powershell-utils@0.2.1: {} + + pretty-ms@9.3.1: + dependencies: + parse-ms: 4.0.0 + proc-log@7.0.0: {} proper-lockfile@4.1.2: @@ -2147,6 +2504,8 @@ snapshots: dependencies: react: 19.2.8 + run-applescript@7.1.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -2165,8 +2524,18 @@ snapshots: signal-exit@3.0.7: {} + signal-exit@4.1.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + sisteransi@1.0.5: {} + strip-final-newline@4.0.0: {} + tinyexec@1.3.0: {} tinyglobby@0.2.17: @@ -2178,6 +2547,8 @@ snapshots: dependencies: is-number: 7.0.0 + totalist@3.0.1: {} + tslib@2.8.1: {} typescript@7.0.2: @@ -2207,6 +2578,10 @@ snapshots: undici@8.10.1: {} + unicorn-magic@0.3.0: {} + + unicorn-magic@0.4.0: {} + validate-npm-package-name@8.0.0: {} which@2.0.2: @@ -2215,6 +2590,13 @@ snapshots: ws@8.21.3: {} + wsl-utils@1.0.0: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + yaml@2.9.0: {} + yoctocolors@2.2.0: {} + zod@4.5.4: {} diff --git a/rstest.browser-app.config.ts b/rstest.browser-app.config.ts new file mode 100644 index 00000000..6023c1bf --- /dev/null +++ b/rstest.browser-app.config.ts @@ -0,0 +1,4 @@ +import { defineConfig } from '@rstest/core'; +import { agentBundleBrowserRstest } from 'agent-bundle/rstest'; + +export default defineConfig(await agentBundleBrowserRstest()); diff --git a/rstest.config.ts b/rstest.config.ts index 16192250..f797eebf 100644 --- a/rstest.config.ts +++ b/rstest.config.ts @@ -15,7 +15,7 @@ import { agentBundleRstest } from 'agent-bundle/rstest'; const { resolve } = await agentBundleRstest(); export default defineConfig({ - exclude: ['tests/route-unit/**'], + exclude: ['tests/browser-app/**', 'tests/route-unit/**'], include: ['tests/**/*.test.ts', 'tests/**/*.eval.ts'], resolve, setupFiles: ['./tests/setup/isolate-state.ts'], diff --git a/src/dashboard/lib.ts b/src/dashboard/lib.ts index 8b380ac7..7c0bf4d2 100644 --- a/src/dashboard/lib.ts +++ b/src/dashboard/lib.ts @@ -1,3 +1,4 @@ +import type { AppRouteResult } from 'agent-bundle/app'; import { Effect, Schedule, Stream, type Duration } from 'effect'; import type { @@ -134,62 +135,13 @@ export const pollStatus = ( */ export const percentileMinSamples = 10; -export const metricsWindowIds = ['hour', 'day', 'all'] as const; -export type MetricsWindowId = (typeof metricsWindowIds)[number]; -export const defaultMetricsWindowId: MetricsWindowId = 'day'; - -/** Compile vs execution phases of the leaders that handed their lane back (#92). */ -export interface DashboardPhaseSplit { - readonly count: number; - readonly compileP50Ms: number | null; - readonly executeP50Ms: number | null; - readonly compileTotalMs: number; - readonly executeTotalMs: number; -} - -export interface DashboardMetricsWindowBySubcommand { - readonly subcommand: string; - readonly profile?: string; - readonly count: number; - readonly p50Ms: number | null; - readonly maxMs: number | null; - /** Null when no leader of this population carries the build-finished stamp (pure compiles never do). */ - readonly phases: DashboardPhaseSplit | null; -} +type StatusResult = AppRouteResult<'tool:hauler/hauler_status'>; +export type DashboardMetricsWindow = NonNullable['windows'][number]; +type DashboardPhaseSplit = NonNullable; +export type MetricsWindowId = DashboardMetricsWindow['id']; -/** Queue wait of the window's leaders attributed to its cause (#92). */ -export interface DashboardWaitSplit { - readonly count: number; - readonly laneBoundMs: number; - readonly permitBoundMs: number; - readonly otherMs: number; - readonly permits: number | null; -} - -export interface DashboardHandBack { - readonly leaders: number; - readonly laneReleasedMs: number; -} - -export interface DashboardMetricsWindow { - readonly id: MetricsWindowId; - readonly count: number; - readonly done: number; - readonly failed: number; - readonly killed: number; - readonly runP50Ms: number | null; - readonly runP95Ms: number | null; - readonly runMeanMs: number | null; - readonly waitP50Ms: number | null; - readonly waitP95Ms: number | null; - readonly bySubcommand: readonly DashboardMetricsWindowBySubcommand[]; - /** Sum of leader run time in the window. */ - readonly runTotalMs: number; - /** Sum of leader queue wait in the window. */ - readonly waitTotalMs: number; - readonly waitSplit: DashboardWaitSplit; - readonly handBack: DashboardHandBack; -} +export const metricsWindowIds = ['hour', 'day', 'all'] as const satisfies readonly MetricsWindowId[]; +export const defaultMetricsWindowId: MetricsWindowId = 'day'; export interface PickedMetricsWindow { readonly id: MetricsWindowId; diff --git a/src/mcp/hauler/apps/dashboard.tsx b/src/mcp/hauler/apps/dashboard.tsx index 6bed2e88..a4b10a42 100644 --- a/src/mcp/hauler/apps/dashboard.tsx +++ b/src/mcp/hauler/apps/dashboard.tsx @@ -1,7 +1,7 @@ /// import { RegistryProvider, useAtomRefresh, useAtomSet, useAtomValue } from '@effect/atom-react'; import type { AppRouteConfig } from 'agent-bundle'; -import { createAppClient } from 'agent-bundle/app'; +import { createAppClient, type AppRouteResult } from 'agent-bundle/app'; import { version as dashboardVersion } from 'agent-bundle/meta'; import { Cause, Data, Effect, Option } from 'effect'; import { AsyncResult, Atom } from 'effect/unstable/reactivity'; @@ -56,12 +56,7 @@ import { summaryFirstLine, terminalStatuses, ticketDetailFrom, - type DashboardHandBack, - type DashboardMetricsWindow, - type DashboardPhaseSplit, type DashboardSection, - type DashboardMetricsWindowBySubcommand, - type DashboardWaitSplit, type KachePressureModel, type MetricsWindowId, type StatusPoll, @@ -70,6 +65,7 @@ import { waitMetricsView, waitVsRunView, } from '../../../dashboard/lib.js'; +import { statusResultSchema } from '../../../lib/protocol-schemas.js'; /** * Framework App-route metadata. The compiler extracts it without evaluating @@ -83,158 +79,23 @@ export const config = { template: './dashboard.html', } satisfies AppRouteConfig; -interface SystemLoadShape { - readonly loadAvg1?: unknown; - readonly cores?: unknown; - readonly clampThresholdPerCore?: unknown; - readonly ioWaitPercent?: unknown; - readonly disks?: unknown; - readonly memAvailableBytes?: unknown; - readonly memClamp?: unknown; - readonly memFullAvg10?: unknown; - readonly memPressureLevel?: unknown; - readonly memSomeAvg10?: unknown; - readonly heavy?: unknown; -} - -interface DiskUtilShape { - readonly device?: unknown; - readonly utilPercent?: unknown; -} - -interface StructuredContent { - readonly summary?: unknown; - readonly daemon?: unknown; - readonly pid?: unknown; - readonly maxConcurrent?: unknown; - readonly lanes?: unknown; - readonly active?: unknown; - readonly recent?: unknown; - readonly operation?: unknown; - readonly request?: unknown; - readonly structuredContent?: unknown; - readonly metrics?: unknown; - readonly savings?: unknown; - readonly system?: unknown; - readonly kache?: unknown; -} - -/** The two `metrics` fields the widget reads; the ledger windows carry every timing it shows. */ -interface StatusMetricsShape { - readonly attach_mode?: Readonly>; - readonly windows?: unknown; -} - -interface StatusMetricsWindowBySubcommandShape { - readonly subcommand?: unknown; - readonly profile?: unknown; - readonly count?: unknown; - readonly p50Ms?: unknown; - readonly maxMs?: unknown; - readonly phases?: unknown; -} - -interface StatusMetricsWindowShape { - readonly id?: unknown; - readonly count?: unknown; - readonly done?: unknown; - readonly failed?: unknown; - readonly killed?: unknown; - readonly runP50Ms?: unknown; - readonly runP95Ms?: unknown; - readonly runMeanMs?: unknown; - readonly waitP50Ms?: unknown; - readonly waitP95Ms?: unknown; - readonly bySubcommand?: unknown; - readonly runTotalMs?: unknown; - readonly waitTotalMs?: unknown; - readonly waitSplit?: unknown; - readonly handBack?: unknown; -} - -interface SavingsModeShape { - readonly mode?: unknown; - readonly ridersServed?: unknown; -} - -interface SavingsTotalsShape { - readonly ridersServed?: unknown; - readonly savedComputeMs?: unknown; - readonly savedComputeExactMs?: unknown; - readonly savedComputeEstimatedMs?: unknown; - readonly savedLatencyMs?: unknown; - readonly negativeLatencyRiders?: unknown; -} - -interface SavingsShape { - readonly byMode?: unknown; - readonly totals?: SavingsTotalsShape; -} - -interface RequestRow { - readonly ticket?: unknown; - readonly status?: unknown; - readonly session?: unknown; - readonly host?: unknown; - readonly argv?: unknown; - readonly execArgv?: unknown; - readonly attachedTo?: unknown; - readonly attachMode?: unknown; - readonly waitMs?: unknown; - readonly runMs?: unknown; - readonly createdAtMs?: unknown; - readonly startedAtMs?: unknown; - readonly estimateMs?: unknown; - readonly delayed?: unknown; - readonly queue?: unknown; - readonly admissionHold?: unknown; - readonly quietMs?: unknown; - readonly stall?: unknown; - readonly workspaceRoot?: unknown; - readonly intentJson?: unknown; - readonly errorCount?: unknown; - readonly warningCount?: unknown; - /** A running row's bounded live-output preview; null on every other row (#95). */ - readonly outputPreview?: unknown; -} +export type DashboardStatusResult = AppRouteResult<'tool:hauler/hauler_status'>; +export type DashboardRequestRow = DashboardStatusResult['active'][number]; +type TicketResult = AppRouteResult<'tool:hauler/hauler_result'>; +type SystemLoad = NonNullable; +type StatusMetrics = NonNullable; +type Savings = NonNullable; +type Kache = NonNullable; +type KacheRootRow = Kache['recentHeartbeatRoots'][number]; +type KacheTopCrateRow = Kache['topCrates'][number]; -interface LaneRow { - readonly workspaceRoot?: unknown; - readonly queued?: unknown; - readonly runningTicket?: unknown; - readonly executingTickets?: unknown; -} - -interface KacheRootRow { - readonly count?: unknown; - readonly root?: unknown; -} - -interface KacheTopCrateRow { - readonly crate?: unknown; - readonly ms?: unknown; - readonly profile?: unknown; -} - -interface KacheShape { - readonly available?: unknown; - readonly distinctCrates?: unknown; - readonly entryCount?: unknown; - readonly eventsFreshMs?: unknown; - readonly indexSizeBytes?: unknown; - readonly recentHeartbeatRoots?: unknown; - readonly topCrates?: unknown; - readonly pressure?: unknown; -} - -interface PushedStatus { - readonly receivedAt: number; - readonly value: StructuredContent; -} +type PushedStatus = + | { readonly _tag: 'Accepted'; readonly receivedAt: number; readonly value: DashboardStatusResult } + | { readonly _tag: 'Rejected'; readonly error: string; readonly receivedAt: number }; interface StatusSnapshot { readonly timestamp: number; - readonly value: StructuredContent | null; + readonly value: DashboardStatusResult | null; } type Initialization = @@ -250,9 +111,6 @@ const client = createAppClient({ timeoutMs: 15_000, }); -const asRecord = (value: unknown): StructuredContent | null => - value !== null && typeof value === 'object' ? (value as StructuredContent) : null; - class StatusRpcError extends Data.TaggedError('StatusRpcError')<{ readonly cause: unknown; }> { @@ -262,7 +120,7 @@ class StatusRpcError extends Data.TaggedError('StatusRpcError')<{ } const fetchStatus = Effect.tryPromise({ - try: async () => asRecord(await client.call('tool:hauler/hauler_status', { limit: 40 })), + try: async () => client.call('tool:hauler/hauler_status', { limit: 40 }), catch: (cause) => new StatusRpcError({ cause }), }); @@ -282,155 +140,8 @@ export const statusAtom = Atom.make( * record — the ledger tail once settled, the daemon's full live tail while * the run is in progress. */ -const fetchTicketRecord = async (ticketId: string): Promise => - asRecord(await client.call('tool:hauler/hauler_result', { ticket: ticketId }))?.request ?? null; - -const arrayOrEmpty = (value: unknown): readonly T[] => (Array.isArray(value) ? value : []); - -const asMetricsWindowId = (value: unknown): MetricsWindowId | null => - value === 'hour' || value === 'day' || value === 'all' ? value : null; - -const numberOrNullField = (value: unknown): number | null => - typeof value === 'number' ? value : null; - -const asFields = (value: unknown): Readonly> | null => - value !== null && typeof value === 'object' && !Array.isArray(value) - ? (value as Readonly>) - : null; - -// Readers for the untyped `metrics.windows` payload. The protocol promises -// every field below on every window; a value missing one is not a window -// and is skipped, so the section never renders zeros it did not receive. - -/** `null` when no leader handed back; `undefined` when the value is not the promised shape. */ -const asDashboardPhaseSplit = (value: unknown): DashboardPhaseSplit | null | undefined => { - if (value === null) { - return null; - } - const row = asFields(value); - if ( - row === null || - typeof row.count !== 'number' || - typeof row.compileTotalMs !== 'number' || - typeof row.executeTotalMs !== 'number' - ) { - return undefined; - } - return { - count: row.count, - compileP50Ms: numberOrNullField(row.compileP50Ms), - executeP50Ms: numberOrNullField(row.executeP50Ms), - compileTotalMs: row.compileTotalMs, - executeTotalMs: row.executeTotalMs, - }; -}; - -const asDashboardWaitSplit = (value: unknown): DashboardWaitSplit | null => { - const row = asFields(value); - if ( - row === null || - typeof row.count !== 'number' || - typeof row.laneBoundMs !== 'number' || - typeof row.permitBoundMs !== 'number' || - typeof row.otherMs !== 'number' - ) { - return null; - } - return { - count: row.count, - laneBoundMs: row.laneBoundMs, - otherMs: row.otherMs, - permitBoundMs: row.permitBoundMs, - permits: numberOrNullField(row.permits), - }; -}; - -const asDashboardHandBack = (value: unknown): DashboardHandBack | null => { - const row = asFields(value); - return row === null || typeof row.leaders !== 'number' || typeof row.laneReleasedMs !== 'number' - ? null - : { laneReleasedMs: row.laneReleasedMs, leaders: row.leaders }; -}; - -const asDashboardWindowBySubcommand = ( - value: unknown, -): DashboardMetricsWindowBySubcommand | null => { - const row = asRecord(value) as StatusMetricsWindowBySubcommandShape | null; - if ( - row === null || - typeof row.subcommand !== 'string' || - (row.profile !== undefined && typeof row.profile !== 'string') || - typeof row.count !== 'number' || - (row.p50Ms !== null && typeof row.p50Ms !== 'number') || - (row.maxMs !== null && typeof row.maxMs !== 'number') - ) { - return null; - } - const phases = asDashboardPhaseSplit(row.phases); - if (phases === undefined) { - return null; - } - return { - subcommand: row.subcommand, - ...(typeof row.profile === 'string' ? { profile: row.profile } : {}), - count: row.count, - p50Ms: row.p50Ms ?? null, - maxMs: row.maxMs ?? null, - phases, - }; -}; - -const asDashboardWindow = (value: unknown): DashboardMetricsWindow | null => { - const row = asRecord(value) as StatusMetricsWindowShape | null; - const id = asMetricsWindowId(row?.id); - if ( - row === null || - id === null || - typeof row.count !== 'number' || - typeof row.done !== 'number' || - typeof row.failed !== 'number' || - typeof row.killed !== 'number' || - (row.runP50Ms !== null && typeof row.runP50Ms !== 'number') || - (row.runP95Ms !== null && typeof row.runP95Ms !== 'number') || - (row.runMeanMs !== null && typeof row.runMeanMs !== 'number') || - (row.waitP50Ms !== null && typeof row.waitP50Ms !== 'number') || - (row.waitP95Ms !== null && typeof row.waitP95Ms !== 'number') || - !Array.isArray(row.bySubcommand) || - typeof row.runTotalMs !== 'number' || - typeof row.waitTotalMs !== 'number' - ) { - return null; - } - const waitSplit = asDashboardWaitSplit(row.waitSplit); - const handBack = asDashboardHandBack(row.handBack); - if (waitSplit === null || handBack === null) { - return null; - } - return { - id, - count: row.count, - done: row.done, - failed: row.failed, - killed: row.killed, - runP50Ms: row.runP50Ms ?? null, - runP95Ms: row.runP95Ms ?? null, - runMeanMs: row.runMeanMs ?? null, - waitP50Ms: row.waitP50Ms ?? null, - waitP95Ms: row.waitP95Ms ?? null, - bySubcommand: row.bySubcommand - .map((entry) => asDashboardWindowBySubcommand(entry)) - .filter((entry): entry is DashboardMetricsWindowBySubcommand => entry !== null), - runTotalMs: row.runTotalMs, - waitTotalMs: row.waitTotalMs, - waitSplit, - handBack, - }; -}; - -const dashboardWindows = (value: unknown): readonly DashboardMetricsWindow[] => - arrayOrEmpty(value) - .map((entry) => asDashboardWindow(entry)) - .filter((entry): entry is DashboardMetricsWindow => entry !== null); +const fetchTicketRecord = async (ticketId: string): Promise => + (await client.call('tool:hauler/hauler_result', { ticket: ticketId })).request; const duration = (value: unknown): string => (typeof value === 'number' ? formatMs(value) : '—'); const countValue = (value: unknown): string => @@ -449,7 +160,7 @@ const workspace = (value: unknown): ReactNode => ); -const who = (row: RequestRow): ReactNode => { +const who = (row: DashboardRequestRow): ReactNode => { const host = typeof row.host === 'string' ? row.host : null; const session = typeof row.session === 'string' ? row.session : null; if (host === null && session === null) { @@ -513,7 +224,7 @@ const elapsedCell = ( ); }; -const DiagBadges = ({ row }: { readonly row: RequestRow }): ReactNode => { +const DiagBadges = ({ row }: { readonly row: DashboardRequestRow }): ReactNode => { const badges = diagnosticBadges(row.errorCount, row.warningCount); if (badges.length === 0) { return null; @@ -577,7 +288,7 @@ const waitingCell = ( ); }; -const AttachChip = ({ row }: { readonly row: RequestRow }): ReactNode => { +const AttachChip = ({ row }: { readonly row: DashboardRequestRow }): ReactNode => { if (typeof row.attachedTo !== 'string') { return null; } @@ -620,7 +331,7 @@ const CommandText = ({ ); }; -const Command = ({ row }: { readonly row: RequestRow }): ReactNode => { +const Command = ({ row }: { readonly row: DashboardRequestRow }): ReactNode => { const ranAs = ranAsFor(row.argv, row.execArgv); const execArgv = Array.isArray(row.execArgv) ? row.execArgv.filter((part): part is string => typeof part === 'string' && part !== DEMUX_FLAG) @@ -651,7 +362,7 @@ const Command = ({ row }: { readonly row: RequestRow }): ReactNode => { * `outputPreview` (#95); nothing for rows without one. The drawer, not this * line, is where the whole live tail lives. */ -const OutputPreview = ({ row }: { readonly row: RequestRow }): ReactNode => { +const OutputPreview = ({ row }: { readonly row: DashboardRequestRow }): ReactNode => { const line = outputPreviewLine(row); return line === null ? null : (
@@ -660,7 +371,7 @@ const OutputPreview = ({ row }: { readonly row: RequestRow }): ReactNode => { ); }; -const requestCells = (row: RequestRow): readonly ReactNode[] => [ +const requestCells = (row: DashboardRequestRow): readonly ReactNode[] => [ ticket(row.ticket), <>, workspace(row.workspaceRoot), @@ -779,8 +490,8 @@ const AdmissionMeter = ({ ); }; -const LoadStat = ({ system }: { readonly system: SystemLoadShape | null }): ReactNode => { - if (system === null || typeof system.loadAvg1 !== 'number' || typeof system.cores !== 'number') { +const LoadStat = ({ system }: { readonly system: SystemLoad | null }): ReactNode => { + if (system === null) { return null; } const perCore = system.cores > 0 ? system.loadAvg1 / system.cores : 0; @@ -799,7 +510,7 @@ const LoadStat = ({ system }: { readonly system: SystemLoadShape | null }): Reac ); }; -const MemoryStat = ({ system }: { readonly system: SystemLoadShape | null }): ReactNode => { +const MemoryStat = ({ system }: { readonly system: SystemLoad | null }): ReactNode => { const view = memoryStatView(system ?? {}); const pressureLevel = typeof system?.memPressureLevel === 'number' ? system.memPressureLevel : null; @@ -828,12 +539,9 @@ const MemoryStat = ({ system }: { readonly system: SystemLoadShape | null }): Re * fields when it has an honest Linux /proc delta, so absence renders nothing * rather than a fabricated zero. */ -const DiskIoStat = ({ system }: { readonly system: SystemLoadShape | null }): ReactNode => { - const ioWait = typeof system?.ioWaitPercent === 'number' ? system.ioWaitPercent : null; - const disks = (Array.isArray(system?.disks) ? system.disks : []).filter( - (disk: DiskUtilShape) => - typeof disk.device === 'string' && typeof disk.utilPercent === 'number', - ); +const DiskIoStat = ({ system }: { readonly system: SystemLoad | null }): ReactNode => { + const ioWait = system?.ioWaitPercent ?? null; + const disks = system?.disks ?? []; if (ioWait === null && disks.length === 0) { return null; } @@ -984,16 +692,10 @@ const TicketDrawer = ({ const frequencyText = (entries: readonly (readonly [string, number])[]): string => entries.map(([key, value]) => `${key} ${formatCompactNumber(value)}`).join(' · '); -const ridersByModeText = (savings: SavingsShape): string | null => { - const byMode = arrayOrEmpty(savings.byMode); - const parts = byMode - .map((row) => { - if (typeof row.mode !== 'string' || typeof row.ridersServed !== 'number') { - return null; - } - return `${row.mode} ${formatCompactNumber(row.ridersServed)}`; - }) - .filter((part): part is string => part !== null); +const ridersByModeText = (savings: Savings): string | null => { + const parts = savings.byMode.map( + (row) => `${row.mode} ${formatCompactNumber(row.ridersServed)}`, + ); return parts.length === 0 ? null : parts.join(' · '); }; @@ -1003,11 +705,11 @@ const MetricsSection = ({ savings, rows, }: { - readonly finished: readonly RequestRow[]; - readonly metrics: StatusMetricsShape | null; - readonly savings: SavingsShape | null; + readonly finished: readonly DashboardRequestRow[]; + readonly metrics: StatusMetrics | null; + readonly savings: Savings | null; /** Every visible row (active + recent): attach savings needs leaders in flight too. */ - readonly rows: readonly RequestRow[]; + readonly rows: readonly DashboardRequestRow[]; }): ReactNode => { const [selectedWindowId, setSelectedWindowId] = useState( defaultMetricsWindowId, @@ -1016,7 +718,7 @@ const MetricsSection = ({ // means no daemon metrics at all: the daemon is stopped or did not answer // and the rows on screen come from the ledger. Timings then derive from // those visible finished rows, honestly labelled as such, or stay blank. - const windows = dashboardWindows(metrics?.windows); + const windows = metrics?.windows ?? []; const pickedWindow = pickMetricsWindow(windows, selectedWindowId); const window = pickedWindow.window; const visibleWaits = waitMetricsView( @@ -1048,27 +750,8 @@ const MetricsSection = ({ const bySubcommandCaption = window === null ? `last ${finished.length} finished` : `${metricsWindowLabel(window.id)} window`; const visibleSavings = attachSavings(rows); - const totals = asRecord(savings?.totals) as SavingsTotalsShape | null; - const allTimeComputeMs = - totals !== null && typeof totals.savedComputeMs === 'number' ? totals.savedComputeMs : null; - const allTimeExactMs = - totals !== null && typeof totals.savedComputeExactMs === 'number' ? totals.savedComputeExactMs : null; - const allTimeEstimatedMs = - totals !== null && typeof totals.savedComputeEstimatedMs === 'number' - ? totals.savedComputeEstimatedMs - : null; - const allTimeLatencyMs = - totals !== null && typeof totals.savedLatencyMs === 'number' ? totals.savedLatencyMs : null; - const allTimeNegativeLatencyCount = - totals !== null && typeof totals.negativeLatencyRiders === 'number' - ? totals.negativeLatencyRiders - : null; - const hasLedgerSavings = - allTimeComputeMs !== null && - allTimeExactMs !== null && - allTimeEstimatedMs !== null && - allTimeLatencyMs !== null && - allTimeNegativeLatencyCount !== null; + const totals = savings?.totals ?? null; + const hasLedgerSavings = totals !== null; const fallbackSavedText = visibleSavings.savedExactMs > 0 ? `${formatMs(visibleSavings.savedExactMs)}${ @@ -1079,21 +762,20 @@ const MetricsSection = ({ : visibleSavings.savedEstimatedMs > 0 ? `~${formatMs(visibleSavings.savedEstimatedMs)} est` : null; - const computeValue = hasLedgerSavings - ? formatMs(allTimeComputeMs) - : (fallbackSavedText ?? '—'); - const computeSplitText = hasLedgerSavings - ? `${formatMs(allTimeExactMs)} exact + ~${formatMs(allTimeEstimatedMs)} est` - : null; + const computeValue = totals === null ? (fallbackSavedText ?? '—') : formatMs(totals.savedComputeMs); + const computeSplitText = + totals === null + ? null + : `${formatMs(totals.savedComputeExactMs)} exact + ~${formatMs(totals.savedComputeEstimatedMs)} est`; const latencyStat = - hasLedgerSavings && allTimeLatencyMs !== null - ? latencySavedStat(allTimeLatencyMs) - : { label: 'latency saved (all time)', value: '—' }; + totals === null + ? { label: 'latency saved (all time)', value: '—' } + : latencySavedStat(totals.savedLatencyMs); const latencyTitle = - hasLedgerSavings && allTimeNegativeLatencyCount !== null - ? `counterfactual estimateMs minus actual time-to-result; negative means the rider waited longer than its own solo estimate (${formatCompactNumber(allTimeNegativeLatencyCount)} rider${allTimeNegativeLatencyCount === 1 ? '' : 's'} are negative)` + totals !== null + ? `counterfactual estimateMs minus actual time-to-result; negative means the rider waited longer than its own solo estimate (${formatCompactNumber(totals.negativeLatencyRiders)} rider${totals.negativeLatencyRiders === 1 ? '' : 's'} are negative)` : 'unavailable: the status carried no ledger savings; negative means the rider waited longer than its own solo estimate'; - const ridersByMode = hasLedgerSavings && savings !== null ? ridersByModeText(savings) : null; + const ridersByMode = savings === null ? null : ridersByModeText(savings); const percentileText = (count: number, value: number | null): string => count === 0 ? '—' : (count < percentileMinSamples || value === null) ? `n<${percentileMinSamples}` @@ -1404,22 +1086,14 @@ const KachePressure = ({ pressure }: { readonly pressure: KachePressureModel | n ); }; -const KacheSection = ({ nowMs, value }: { readonly nowMs: number; readonly value: unknown }): ReactNode => { - const kache = asRecord(value) as KacheShape | null; - if (kache?.available !== true) { +const KacheSection = ({ nowMs, value }: { readonly nowMs: number; readonly value: Kache | null }): ReactNode => { + if (value?.available !== true) { return null; } + const kache = value; const pressure = kachePressureView(kache.pressure, nowMs); - const roots = arrayOrEmpty(kache.recentHeartbeatRoots).filter( - (row) => typeof row.root === 'string' && typeof row.count === 'number', - ); - const topCrates = arrayOrEmpty(kache.topCrates).filter( - (row) => - typeof row.crate === 'string' && - typeof row.profile === 'string' && - typeof row.ms === 'number' && - row.ms > 0, - ); + const roots = kache.recentHeartbeatRoots; + const topCrates = kache.topCrates.filter((row) => row.ms > 0); return (

Kache (machine-wide)

@@ -1468,7 +1142,7 @@ const KacheColumns = ({

Compiling roots (last 5m)

{roots.map((row, index) => { - const root = typeof row.root === 'string' ? row.root : ''; + const root = row.root; return (
{shortenPath(root)} @@ -1520,25 +1194,25 @@ const KacheColumns = ({ ); }; -const DashboardContent = ({ structured }: { readonly structured: StructuredContent | null }) => { +const DashboardContent = ({ structured }: { readonly structured: DashboardStatusResult | null }) => { const nowMs = Date.now(); const [drawer, setDrawer] = useState({ _tag: 'Closed' }); const drawerSeq = useRef(0); - const active = arrayOrEmpty(structured?.active); - const recent = arrayOrEmpty(structured?.recent); - const lanes = arrayOrEmpty(structured?.lanes); + const active = structured?.active ?? []; + const recent = structured?.recent ?? []; + const lanes = structured?.lanes ?? []; const running = active.filter((row) => row.status === 'running'); const queued = active.filter((row) => row.status === 'queued' || row.status === 'requested'); const attached = active.filter((row) => typeof row.attachedTo === 'string'); - const maxConcurrent = typeof structured?.maxConcurrent === 'number' ? structured.maxConcurrent : 0; + const maxConcurrent = structured?.maxConcurrent ?? 0; const queueRows = queued.concat(attached.filter((row) => row.status !== 'running')); const activeLanes = lanes.filter(laneIsActive); const finished = recent - .filter((row) => typeof row.status === 'string' && terminalStatuses.has(row.status)) + .filter((row) => terminalStatuses.has(row.status)) .slice(0, 20); - const metrics = (structured?.metrics ?? null) as StatusMetricsShape | null; - const savings = (structured?.savings ?? null) as SavingsShape | null; - const system = (structured?.system ?? null) as SystemLoadShape | null; + const metrics = structured?.metrics ?? null; + const savings = structured?.savings ?? null; + const system = structured?.system ?? null; const permitHolders = running.filter((row) => row.attachedTo == null).length; const riders = running.length - permitHolders; const laneCount = @@ -1552,7 +1226,7 @@ const DashboardContent = ({ structured }: { readonly structured: StructuredConte drawerSeq.current += 1; setDrawer({ _tag: 'Closed' }); }; - const openTicket = (row: RequestRow): void => { + const openTicket = (row: DashboardRequestRow): void => { const base = ticketDetailFrom(row); if (base === null) { return; @@ -1593,8 +1267,7 @@ const DashboardContent = ({ structured }: { readonly structured: StructuredConte }; load(); }; - const selectRow = (row: RequestRow): (() => void) | undefined => - typeof row.ticket === 'string' ? () => openTicket(row) : undefined; + const selectRow = (row: DashboardRequestRow): (() => void) => () => openTicket(row); const renderSection = (section: DashboardSection): ReactNode => { switch (section) { @@ -1690,7 +1363,7 @@ const DashboardContent = ({ structured }: { readonly structured: StructuredConte /> ); case 'kache': - return ; + return ; case 'lanes': return (
@@ -1750,9 +1423,9 @@ const DashboardContent = ({ structured }: { readonly structured: StructuredConte ); }; -type StatusPollResult = AsyncResult.AsyncResult, unknown>; +type StatusPollResult = AsyncResult.AsyncResult, unknown>; -const pollSnapshot = (poll: StatusPoll): StatusSnapshot | null => +const pollSnapshot = (poll: StatusPoll): StatusSnapshot | null => poll.updatedAtMs === null ? null : { timestamp: poll.updatedAtMs, value: poll.value }; const snapshotFrom = (result: StatusPollResult): StatusSnapshot | null => { @@ -1809,9 +1482,15 @@ const Dashboard = ({ pushed }: { readonly pushed: PushedStatus | null }) => { const refresh = useAtomRefresh(statusAtom); const polled = snapshotFrom(result); const pollError = pollErrorFrom(result); + const acceptedPush = pushed?._tag === 'Accepted' ? pushed : null; + const openingError = + pushed?._tag === 'Rejected' && (polled === null || pushed.receivedAt >= polled.timestamp) + ? pushed.error + : null; + const error = openingError ?? pollError; const latest = - pushed !== null && (polled === null || pushed.receivedAt >= polled.timestamp) - ? pushed.value + acceptedPush !== null && (polled === null || acceptedPush.receivedAt >= polled.timestamp) + ? acceptedPush.value : (polled?.value ?? null); const summary = latest === null && result._tag === 'Initial' @@ -1826,9 +1505,9 @@ const Dashboard = ({ pushed }: { readonly pushed: PushedStatus | null }) => { {summary} {result.waiting ? ● : null} - {pollError !== null ? ( + {error !== null ? (
- Error: {pollError}{' '} + Error: {error}{' '}
) : null} @@ -1849,10 +1528,17 @@ const DashboardApp = () => { let active = true; // The opening `hauler_status` result the host pushes beside the App. const stop = client.onToolResult('tool:hauler/hauler_status', (result) => { - const value = asRecord(result); - if (value !== null) { - setPushed({ receivedAt: Date.now(), value }); - } + const parsed = statusResultSchema.safeParse(result); + const receivedAt = Date.now(); + setPushed( + parsed.success + ? { _tag: 'Accepted', receivedAt, value: parsed.data } + : { + _tag: 'Rejected', + error: `Opening status payload rejected: ${parsed.error.issues.map((issue) => issue.message).join('; ')}`, + receivedAt, + }, + ); }); setInitialization({ _tag: 'Initializing' }); diff --git a/tests/browser-app/dashboard.browser.test.ts b/tests/browser-app/dashboard.browser.test.ts new file mode 100644 index 00000000..451c4e52 --- /dev/null +++ b/tests/browser-app/dashboard.browser.test.ts @@ -0,0 +1,112 @@ +import { afterEach, expect, it } from '@rstest/core'; +import { + mountBrowserApp, + type MountedBrowserApp, + type MountBrowserAppOptions, +} from 'agent-bundle/test/browser'; + +type BindingOperations = MountBrowserAppOptions['operations']; +type ToolCallResult = Awaited>; + +const statusTool = { + _meta: { + hauler: { route: 'tool:hauler/hauler_status' }, + ui: { resourceUri: 'ui://cargo-hauler/dashboard.html' }, + }, + description: 'Show cargo-hauler status.', + inputSchema: { properties: {}, type: 'object' }, + name: 'hauler_status', +}; + +const openingHostContext = { + availableDisplayModes: ['inline'], + displayMode: 'inline', + platform: 'desktop', + toolInfo: { tool: statusTool }, +}; + +const status = { + active: [], + daemon: 'stopped', + lanes: [], + maxConcurrent: null, + operation: 'status', + pid: null, + recent: [], + socketPath: '/tmp/cargo-hauler.sock', + startedAtMs: null, + stateRoot: '/tmp/cargo-hauler', + summary: 'Nothing queued or running.', +}; + +const toolResult = { + content: [{ text: status.summary, type: 'text' }], + structuredContent: status, +} as const satisfies ToolCallResult; + +const operations = ( + callTool: BindingOperations['callTool'] = async () => toolResult, +): BindingOperations => ({ + callTool, + closeBinding: async () => true, + readResource: async (_bindingId, request) => ({ + contents: [{ mimeType: 'text/plain', text: '', uri: request.uri }], + }), +}); + +const mounted: MountedBrowserApp[] = []; + +afterEach(async () => { + await Promise.all(mounted.splice(0).map((app) => app.dispose())); +}); + +const waitFor = async (predicate: () => boolean, timeoutMs = 2_000): Promise => { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error('Timed out waiting for the dashboard.'); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +}; + +const mountDashboard = async ( + openingResult: MountBrowserAppOptions['toolResult'], + callTool?: BindingOperations['callTool'], +): Promise => { + const app = await mountBrowserApp('dashboard', { + host: { context: openingHostContext }, + operations: operations(callTool), + serverName: 'hauler', + toolDefinition: statusTool, + toolInput: { limit: 40 }, + toolName: statusTool.name, + toolResult: openingResult, + }); + mounted.push(app); + return app; +}; + +it('renders the canonical opening status result', async () => { + const app = await mountDashboard(toolResult); + await waitFor(() => app.document.querySelector('#status')?.textContent?.includes(status.summary) === true); + + expect(app.document.querySelector('h1')?.textContent).toBe('cargo-hauler'); + expect(app.document.querySelector('.error-line')).toBeNull(); + expect(app.provenance).toMatchObject({ name: 'dashboard', proofLevel: 'browser-app' }); +}); + +it('reports an invalid untrusted opening status payload', async () => { + const pendingCall: BindingOperations['callTool'] = async () => new Promise(() => {}); + const app = await mountDashboard( + { + content: [{ text: 'forged', type: 'text' }], + structuredContent: { summary: 'forged' }, + }, + pendingCall, + ); + await waitFor(() => app.document.querySelector('.error-line') !== null); + + expect(app.document.querySelector('.error-line')?.textContent).toContain( + 'Opening status payload rejected', + ); + expect(app.document.querySelector('#status')?.textContent).not.toContain('forged'); +}); diff --git a/tests/dashboard-route-types.ts b/tests/dashboard-route-types.ts new file mode 100644 index 00000000..851917c3 --- /dev/null +++ b/tests/dashboard-route-types.ts @@ -0,0 +1,20 @@ +import type { AppRouteResult } from 'agent-bundle/app'; + +import type { + DashboardRequestRow, + DashboardStatusResult, +} from '../src/mcp/hauler/apps/dashboard.js'; + +type Equal = + (() => Value extends Left ? 1 : 2) extends + (() => Value extends Right ? 1 : 2) + ? true + : false; +type Assert = Value; + +type StatusResult = AppRouteResult<'tool:hauler/hauler_status'>; + +type _StatusResultIsGenerated = Assert>; +type _RequestRowIsGenerated = Assert< + Equal +>; diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index 9ae4f2b9..cd608c8f 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -186,7 +186,7 @@ describe('pickMetricsWindow (window toggle and fallback)', () => { runMeanMs: 1_050, waitP50Ms: 40, waitP95Ms: 110, - bySubcommand: [{ subcommand: 'check', count: 3, p50Ms: 900, maxMs: 1_800, phases: null }], + bySubcommand: [{ subcommand: 'check', profile: 'debug', count: 3, p50Ms: 900, maxMs: 1_800, phases: null }], runTotalMs: 3_150, waitTotalMs: 180, waitSplit: { count: 3, laneBoundMs: 120, permitBoundMs: 0, otherMs: 60, permits: 5 }, @@ -203,7 +203,7 @@ describe('pickMetricsWindow (window toggle and fallback)', () => { runMeanMs: 1_600, waitP50Ms: 70, waitP95Ms: 300, - bySubcommand: [{ subcommand: 'check', count: 12, p50Ms: 1_200, maxMs: 2_700, phases: null }], + bySubcommand: [{ subcommand: 'check', profile: 'debug', count: 12, p50Ms: 1_200, maxMs: 2_700, phases: null }], runTotalMs: 19_200, waitTotalMs: 1_100, waitSplit: { count: 12, laneBoundMs: 800, permitBoundMs: 100, otherMs: 200, permits: 5 }, @@ -220,7 +220,7 @@ describe('pickMetricsWindow (window toggle and fallback)', () => { runMeanMs: 2_300, waitP50Ms: 120, waitP95Ms: 480, - bySubcommand: [{ subcommand: 'check', count: 55, p50Ms: 1_600, maxMs: 5_000, phases: null }], + bySubcommand: [{ subcommand: 'check', profile: 'debug', count: 55, p50Ms: 1_600, maxMs: 5_000, phases: null }], runTotalMs: 126_500, waitTotalMs: 9_400, waitSplit: { count: 55, laneBoundMs: 7_000, permitBoundMs: 1_400, otherMs: 1_000, permits: 5 }, From 5cf62dc9a180eea85f7dc0a14cd16c10e5434023 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 07:17:19 +0000 Subject: [PATCH 2/3] chore: add dashboard type changeset --- .changeset/generated-dashboard-types.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/generated-dashboard-types.md diff --git a/.changeset/generated-dashboard-types.md b/.changeset/generated-dashboard-types.md new file mode 100644 index 00000000..e42473ed --- /dev/null +++ b/.changeset/generated-dashboard-types.md @@ -0,0 +1,5 @@ +--- +"cargo-hauler": patch +--- + +Keep dashboard payload handling synchronized with generated Agent Bundle route result types. (#165) From 2309399388b9983398e70d4cdcafba14776fd2e1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 07:32:18 +0000 Subject: [PATCH 3/3] test: gate generated dashboard browser app --- AGENTS.md | 4 +- package.json | 3 +- pnpm-lock.yaml | 15 ----- src/dashboard/lib.ts | 99 ++----------------------------- src/mcp/hauler/apps/dashboard.tsx | 2 +- tests/dashboard.test.ts | 19 ++---- 6 files changed, 15 insertions(+), 127 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5d923a7c..297a29b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,8 +8,8 @@ binaries (`dist/bin/hauler.js`, `dist/bin/cargo-hauler.mjs`, `dist/bin/cargo-hauler-install.js`). - `pnpm run check` is the gate: validate, build, typecheck, Effect - diagnostics, `rstest`, and the route-unit suite. Run it before claiming a - change is done. + diagnostics, `rstest`, the route-unit suite, and browser App tests. Run it + before claiming a change is done. - The plugin surface is an agent-bundle application: `src/layout.tsx` (the shell), `src/providers/hauler-daemon.ts` (daemon connection), `src/components` (typed components over `view-models.ts`), `src/mcp/hauler/tools` and diff --git a/package.json b/package.json index 973626cf..00c4c1cb 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ ], "scripts": { "build": "agent-bundle build", - "check": "pnpm validate && pnpm build && pnpm typecheck && pnpm diagnostics && pnpm test && pnpm test:routes", + "check": "pnpm validate && pnpm build && pnpm typecheck && pnpm diagnostics && pnpm test && pnpm test:routes && pnpm test:browser", "changeset": "changeset", "dev": "agent-bundle dev", "diagnostics": "effect-tsgo diagnostics --project tsconfig.json", @@ -65,7 +65,6 @@ "@modelcontextprotocol/server": "2.0.0", "@rstest/browser": "0.11.12", "@rstest/core": "0.11.12", - "@rstest/playwright": "0.11.12", "@types/node": "^22.18.0", "@types/proper-lockfile": "^4.1.4", "@types/react": "^19.2.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8eedea1..aac5958d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,9 +32,6 @@ importers: '@rstest/core': specifier: 0.11.12 version: 0.11.12 - '@rstest/playwright': - specifier: 0.11.12 - version: 0.11.12(@rstest/core@0.11.12)(playwright@1.62.1) '@types/node': specifier: ^22.18.0 version: 22.20.1 @@ -654,13 +651,6 @@ packages: jsdom: optional: true - '@rstest/playwright@0.11.12': - resolution: {integrity: sha512-fU9BjRnCTsJNw2oEOY1aq/ozSt5gGuZMW6NwVHZrdxmi/uwypoYIcWSAE/kWukRzAazaSdO+wYZ4fB2IC8kLCA==} - engines: {node: ^20.19.0 || >=22.12.0} - peerDependencies: - '@rstest/core': ^0.11.9 - playwright: ^1.49.1 - '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -1964,11 +1954,6 @@ snapshots: - '@module-federation/runtime-tools' - core-js - '@rstest/playwright@0.11.12(@rstest/core@0.11.12)(playwright@1.62.1)': - dependencies: - '@rstest/core': 0.11.12 - playwright: 1.62.1 - '@sec-ant/readable-stream@0.4.1': {} '@sindresorhus/merge-streams@4.0.0': {} diff --git a/src/dashboard/lib.ts b/src/dashboard/lib.ts index 7c0bf4d2..02591f24 100644 --- a/src/dashboard/lib.ts +++ b/src/dashboard/lib.ts @@ -1,11 +1,6 @@ import type { AppRouteResult } from 'agent-bundle/app'; import { Effect, Schedule, Stream, type Duration } from 'effect'; -import type { - KacheGcReport, - KacheStoreLimitReport, - KacheStorePressureReport, -} from '../daemon/protocol.js'; import { cargoJsonDemuxFlag, defaultCargoProfile, namedPackagesInArgv } from '../lib/argv.js'; import { isRecord } from '../lib/guards.js'; import { @@ -138,6 +133,7 @@ export const percentileMinSamples = 10; type StatusResult = AppRouteResult<'tool:hauler/hauler_status'>; export type DashboardMetricsWindow = NonNullable['windows'][number]; type DashboardPhaseSplit = NonNullable; +export type DashboardKachePressure = NonNullable['pressure']>; export type MetricsWindowId = DashboardMetricsWindow['id']; export const metricsWindowIds = ['hour', 'day', 'all'] as const satisfies readonly MetricsWindowId[]; @@ -343,94 +339,11 @@ export const handBackView = (window: DashboardMetricsWindow | null): HandBackVie // --------------------------------------------------------------------------- // Kache store pressure (#92) -const finiteOrNull = (value: unknown): number | null => - typeof value === 'number' && Number.isFinite(value) ? value : null; - -const asKacheStoreLimit = (value: unknown): KacheStoreLimitReport | null => { - if (!isRecord(value)) { - return null; - } - if (value.kind === 'known') { - return typeof value.bytes === 'number' && typeof value.source === 'string' - ? { bytes: value.bytes, kind: 'known', source: value.source } - : null; - } - if (value.kind === 'unknown') { - const reason = value.reason; - if ( - reason === 'config-missing' || - reason === 'not-configured' || - reason === 'unparsable' || - reason === 'store-mismatch' - ) { - return { detail: typeof value.detail === 'string' ? value.detail : '', kind: 'unknown', reason }; - } - } - return null; -}; - -const asKacheGc = (value: unknown): KacheGcReport | null => { - if (!isRecord(value)) { - return null; - } - if (value.kind === 'unavailable') { - return value.reason === 'missing' || value.reason === 'unparsable' - ? { kind: 'unavailable', reason: value.reason } - : null; - } - if (value.kind === 'ran' && typeof value.lastRunAtMs === 'number') { - return { - kind: 'ran', - lastRunAtMs: value.lastRunAtMs, - durationMs: finiteOrNull(value.durationMs), - entriesEvicted: finiteOrNull(value.entriesEvicted), - bytesFreed: finiteOrNull(value.bytesFreed), - diskBytesReclaimed: finiteOrNull(value.diskBytesReclaimed), - blobsRemoved: finiteOrNull(value.blobsRemoved), - declined: value.declined === true, - entriesPinned: finiteOrNull(value.entriesPinned), - entriesUnreclaimable: finiteOrNull(value.entriesUnreclaimable), - evictionErrors: finiteOrNull(value.evictionErrors), - evictionErrorSample: - typeof value.evictionErrorSample === 'string' ? value.evictionErrorSample : null, - }; - } - return null; -}; - -/** - * The daemon's `kache.pressure` report from the untyped status payload the - * widget receives; null when absent or not the shape the protocol promises, - * so the panel can say so instead of inventing zeros. - */ -export const asKachePressure = (value: unknown): KacheStorePressureReport | null => { - if (!isRecord(value)) { - return null; - } - const limit = asKacheStoreLimit(value.limit); - const gc = asKacheGc(value.gc); - if (limit === null || gc === null) { - return null; - } - const timing = value.keyTiming; - const keyTiming = - isRecord(timing) && - typeof timing.count === 'number' && - typeof timing.meanMs === 'number' && - typeof timing.p95Ms === 'number' - ? { count: timing.count, meanMs: timing.meanMs, p95Ms: timing.p95Ms } - : null; - return { gc, keyTiming, limit, storeBytes: finiteOrNull(value.storeBytes) }; -}; - -/** - * The kache panel's pressure block from the untyped status payload; null when - * the payload carried no report of the promised shape. - */ -export const kachePressureView = (value: unknown, nowMs: number): KachePressureModel | null => { - const pressure = asKachePressure(value); - return pressure === null ? null : kachePressureModel(pressure, nowMs); -}; +export const kachePressureView = ( + pressure: DashboardKachePressure | null | undefined, + nowMs: number, +): KachePressureModel | null => + pressure == null ? null : kachePressureModel(pressure, nowMs); export const frequencyEntries = ( record: Readonly> | undefined, diff --git a/src/mcp/hauler/apps/dashboard.tsx b/src/mcp/hauler/apps/dashboard.tsx index a4b10a42..d2225d60 100644 --- a/src/mcp/hauler/apps/dashboard.tsx +++ b/src/mcp/hauler/apps/dashboard.tsx @@ -1487,7 +1487,7 @@ const Dashboard = ({ pushed }: { readonly pushed: PushedStatus | null }) => { pushed?._tag === 'Rejected' && (polled === null || pushed.receivedAt >= polled.timestamp) ? pushed.error : null; - const error = openingError ?? pollError; + const error = pollError ?? openingError; const latest = acceptedPush !== null && (polled === null || acceptedPush.receivedAt >= polled.timestamp) ? acceptedPush.value diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index cd608c8f..d9793ec6 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -10,7 +10,6 @@ import { DEMUX_FLAG, argvText, argvTitle, - asKachePressure, attachSavings, compactArgvText, defaultMetricsWindowId, @@ -58,6 +57,7 @@ import { memoryStatView, admissionHoldDetail, heavyAdmissionNote, + type DashboardKachePressure, type DashboardMetricsWindow, } from '../src/dashboard/lib.js'; @@ -1152,7 +1152,7 @@ describe('phaseSplitView (compile vs execution per command)', () => { }); }); -describe('kache store pressure (widget adapter + panel model)', () => { +describe('kache store pressure panel model', () => { const nowMs = Date.parse('2026-09-05T00:00:00Z'); const pressure = { storeBytes: 541 * 1024 ** 3, @@ -1172,15 +1172,10 @@ describe('kache store pressure (widget adapter + panel model)', () => { evictionErrorSample: 'database is locked', }, keyTiming: { count: 4_096, meanMs: 1_020, p95Ms: 2_400 }, - }; + } as const satisfies DashboardKachePressure; - it('accepts the protocol shape and rejects anything else instead of inventing zeros', () => { - expect(asKachePressure(pressure)).toEqual(pressure); - expect(asKachePressure(undefined)).toBeNull(); - expect(asKachePressure({ storeBytes: 1 })).toBeNull(); - expect(asKachePressure({ ...pressure, limit: { kind: 'unknown', reason: 'made-up', detail: '' } })).toBeNull(); - expect(asKachePressure({ ...pressure, gc: { kind: 'ran' } })).toBeNull(); - expect(asKachePressure({ ...pressure, keyTiming: { count: 'many' } })).toMatchObject({ keyTiming: null }); + it('is absent when the generated status result carries no pressure report', () => { + expect(kachePressureView(null, nowMs)).toBeNull(); }); it('warns when the store is over its limit and when the last GC skipped evictions', () => { @@ -1250,8 +1245,4 @@ describe('kache store pressure (widget adapter + panel model)', () => { expect(model.warnings.map((warning) => warning.kind)).toEqual(['over-limit', 'gc-eviction-errors']); }); - it('is null, never an empty store, when the payload carries no report of the promised shape', () => { - expect(kachePressureView(undefined, nowMs)).toBeNull(); - expect(kachePressureView({ storeBytes: 1 }, nowMs)).toBeNull(); - }); });