Add Cloudflare Workers sandbox runtime for isolated code execution
Unified · Split
| @@ -39,6 +39,15 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json · 38 unmodified lines | ||
| 39 | 39 | convex_local_backend.sqlite3 |
| 40 | 40 | convex_local_storage/ |
| 41 | 41 | |
| 42 | +# Local OpenAPI sources service state | |
| 43 | +sources/data/*.sqlite | |
| 44 | +sources/data/*.sqlite-shm | |
| 45 | +sources/data/*.sqlite-wal | |
| 46 | + | |
| 47 | +# Cloudflare Workers local state | |
| 48 | +.wrangler/ | |
| 49 | +.dev.vars | |
| 50 | + | |
| 42 | 51 | # Generated push env file |
| 43 | 52 | executor/.env.executor-push |
| 44 | 53 | |
| @@ -130,6 +130,25 @@ · 129 unmodified lines | ||
| 130 | 130 | "name": "@executor/convex", |
| 131 | 131 | "version": "0.0.1", |
| 132 | 132 | }, |
| 133 | + "executor/packages/sandbox-host": { | |
| 134 | + "name": "@executor/sandbox-host", | |
| 135 | + "version": "0.1.0", | |
| 136 | + "dependencies": { | |
| 137 | + "better-result": "^2.7.0", | |
| 138 | + }, | |
| 139 | + "devDependencies": { | |
| 140 | + "@cloudflare/workers-types": "^4.20250214.0", | |
| 141 | + "typescript": "^5.9.3", | |
| 142 | + "wrangler": "^4.0.0", | |
| 143 | + }, | |
| 144 | + }, | |
| 145 | + "sources": { | |
| 146 | + "name": "@proto/sources", | |
| 147 | + "devDependencies": { | |
| 148 | + "@types/bun": "latest", | |
| 149 | + "typescript": "^5.9.3", | |
| 150 | + }, | |
| 151 | + }, | |
| 133 | 152 | }, |
| 134 | 153 | "trustedDependencies": [ |
| 135 | 154 | "sharp", |
| @@ -284,6 +303,26 @@ · 148 unmodified lines | ||
| 284 | 303 | |
| 285 | 304 | "@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="], |
| 286 | 305 | |
| 306 | + "@clack/core": ["@clack/core@0.5.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow=="], | |
| 307 | + | |
| 308 | + "@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="], | |
| 309 | + | |
| 310 | + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], | |
| 311 | + | |
| 312 | + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.12.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20260115.0" }, "optionalPeers": ["workerd"] }, "sha512-tP/Wi+40aBJovonSNJSsS7aFJY0xjuckKplmzDs2Xat06BJ68B6iG7YDUWXJL8gNn0gqW7YC5WhlYhO3QbugQA=="], | |
| 313 | + | |
| 314 | + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260210.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-e3vMgzr8ZM6VjpJVFrnMBhjvFhlMIkhT+BLpBk3pKaWsrXao+azDlmzzxB3Zf4CZ8LmCEtaP7n5d2mNGL6Dqww=="], | |
| 315 | + | |
| 316 | + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260210.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ng2uLJVMrI5VrcAS26gDGM+qxCuWD4ZA8VR4i88RdyM8TLn+AqPFisrvn7AMA+QSv0+ck+ZdFtXek7qNp2gNuA=="], | |
| 317 | + | |
| 318 | + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260210.0", "", { "os": "linux", "cpu": "x64" }, "sha512-frn2/+6DV59h13JbGSk9ATvJw3uORWssFIKZ/G/to+WRrIDQgCpSrjLtGbFSSn5eBEhYOvwxPKc7IrppkmIj/w=="], | |
| 319 | + | |
| 320 | + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260210.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0fmxEHaDcAF+7gcqnBcQdBCOzNvGz3mTMwqxEYJc5xZgFwQf65/dYK5fnV8z56GVNqu88NEnLMG3DD2G7Ey1vw=="], | |
| 321 | + | |
| 322 | + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260210.0", "", { "os": "win32", "cpu": "x64" }, "sha512-G/Apjk/QLNnwbu8B0JO9FuAJKHNr+gl8X3G/7qaUrpwIkPx5JFQElVE6LKk4teSrycvAy5AzLFAL0lOB1xsUIQ=="], | |
| 323 | + | |
| 324 | + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260210.0", "", {}, "sha512-zHaF0RZVYUQwNCJCECnNAJdMur72Lk3FMiD6wU78Dx3Bv7DQRcuXNmPNuJmsGnosVZCcWintHlPTQ/4BEiDG5w=="], | |
| 325 | + | |
| 287 | 326 | "@convex-dev/migrations": ["@convex-dev/migrations@0.3.1", "", { "peerDependencies": { "convex": "^1.24.8" } }, "sha512-dGiN82vWMN415/AGMcVooDOJk8iTousZahBBs27uA03pL74Uo3ksucUW3CiCfPx75rv+x9UnenEakimQYYNIrA=="], |
| 288 | 327 | |
| 289 | 328 | "@convex-dev/stripe": ["@convex-dev/stripe@0.1.3", "", { "dependencies": { "stripe": "^20.0.0" }, "peerDependencies": { "convex": "^1.29.3", "react": "^18.3.1 || ^19.0.0" } }, "sha512-yL0ygrWrjto9f7rprCb4KYp+p3Oka7KG5e3HBoJ9qSzbncpavZI+JTkGZnLCYZjPOo2ByCrBJvtGAxfP169Nzw=="], |
| @@ -294,6 +333,8 @@ · 4 unmodified lines | ||
| 294 | 333 | |
| 295 | 334 | "@convex-dev/workpool": ["@convex-dev/workpool@0.2.19", "", { "peerDependencies": { "convex": ">=1.25.0 <1.35.0", "convex-helpers": "^0.1.94" } }, "sha512-U2KwYnsKILyxW1baWEhDv+ZtnL5FZbYFxBT5owQ0Lw/kseiudMZraA4clH+/6gowHSahWpkq4wndhcOfpfhuOA=="], |
| 296 | 335 | |
| 336 | + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], | |
| 337 | + | |
| 297 | 338 | "@discordjs/builders": ["@discordjs/builders@1.13.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.33", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w=="], |
| 298 | 339 | |
| 299 | 340 | "@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="], |
| @@ -400,6 +441,8 @@ · 100 unmodified lines | ||
| 400 | 441 | |
| 401 | 442 | "@executor/convex": ["@executor/convex@workspace:executor/convex"], |
| 402 | 443 | |
| 444 | + "@executor/sandbox-host": ["@executor/sandbox-host@workspace:executor/packages/sandbox-host"], | |
| 445 | + | |
| 403 | 446 | "@executor/web": ["@executor/web@workspace:executor/apps/web"], |
| 404 | 447 | |
| 405 | 448 | "@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="], |
| @@ -496,7 +539,7 @@ · 90 unmodified lines | ||
| 496 | 539 | |
| 497 | 540 | "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], |
| 498 | 541 | |
| 499 | − "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], | |
| 542 | + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], | |
| 500 | 543 | |
| 501 | 544 | "@mariozechner/pi-ai": ["@mariozechner/pi-ai@0.52.8", "", { "dependencies": { "@anthropic-ai/sdk": "^0.73.0", "@aws-sdk/client-bedrock-runtime": "^3.983.0", "@google/genai": "^1.40.0", "@mistralai/mistralai": "1.10.0", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "chalk": "^5.6.2", "openai": "6.10.0", "partial-json": "^0.1.7", "proxy-agent": "^6.5.0", "undici": "^7.19.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-+aFCUbKJcskDJhr9wPcMBTy0x/xWio5v1dkxRYXUBPWp+Zt9DSdT5Kmd/IIQ+a0TOZDF4ajt4GY/oAw37X7XTw=="], |
| 502 | 545 | |
| @@ -576,6 +619,14 @@ · 73 unmodified lines | ||
| 576 | 619 | |
| 577 | 620 | "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], |
| 578 | 621 | |
| 622 | + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], | |
| 623 | + | |
| 624 | + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], | |
| 625 | + | |
| 626 | + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], | |
| 627 | + | |
| 628 | + "@proto/sources": ["@proto/sources@workspace:sources"], | |
| 629 | + | |
| 579 | 630 | "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], |
| 580 | 631 | |
| 581 | 632 | "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], |
| @@ -828,6 +879,8 @@ · 246 unmodified lines | ||
| 828 | 879 | |
| 829 | 880 | "@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="], |
| 830 | 881 | |
| 882 | + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], | |
| 883 | + | |
| 831 | 884 | "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], |
| 832 | 885 | |
| 833 | 886 | "@smithy/abort-controller": ["@smithy/abort-controller@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw=="], |
| @@ -920,6 +973,8 @@ · 86 unmodified lines | ||
| 920 | 973 | |
| 921 | 974 | "@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="], |
| 922 | 975 | |
| 976 | + "@speed-highlight/core": ["@speed-highlight/core@1.2.14", "", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="], | |
| 977 | + | |
| 923 | 978 | "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], |
| 924 | 979 | |
| 925 | 980 | "@streamdown/code": ["@streamdown/code@1.0.1", "", { "dependencies": { "shiki": "^3.19.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-U9LITfQ28tZYAoY922jdtw1ryg4kgRBdURopqK9hph7G2fBUwPeHthjH7SvaV0fvFv7EqjqCzARJuWUljLe9Ag=="], |
| @@ -1208,10 +1263,14 @@ · 282 unmodified lines | ||
| 1208 | 1263 | |
| 1209 | 1264 | "basic-ftp": ["basic-ftp@5.1.0", "", {}, "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw=="], |
| 1210 | 1265 | |
| 1266 | + "better-result": ["better-result@2.7.0", "", { "dependencies": { "@clack/prompts": "^0.11.0" }, "bin": { "better-result": "bin/cli.mjs" } }, "sha512-7zrmXjAK8u8Z6SOe4R65XObOR5X+Y2I/VVku3t5cPOGQ8/WsBcfFmfnIPiEl5EBMDOzPHRwbiPbMtQBKYdw7RA=="], | |
| 1267 | + | |
| 1211 | 1268 | "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], |
| 1212 | 1269 | |
| 1213 | 1270 | "birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="], |
| 1214 | 1271 | |
| 1272 | + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], | |
| 1273 | + | |
| 1215 | 1274 | "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], |
| 1216 | 1275 | |
| 1217 | 1276 | "bowser": ["bowser@2.13.1", "", {}, "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw=="], |
| @@ -1394,7 +1453,7 @@ · 176 unmodified lines | ||
| 1394 | 1453 | |
| 1395 | 1454 | "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="], |
| 1396 | 1455 | |
| 1397 | − "elysia": ["elysia@1.4.23", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-mFIT/hEnNfrfbjGRUqunLNcURJfSXpKY4j+EWr4vP6Eoulf7feqs0WQLZwlgFZCxhdyfu0mrypIZ4mNJcEVVlQ=="], | |
| 1456 | + "elysia": ["elysia@1.4.24", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-cgMUPXzZYZGafj2VePFnckVPZ8xJKbZNOgv4hlM2nW96cPSG1BL3y2KNRrKmmA2hKEuUw0tvHVwG//pMqSZ5Jw=="], | |
| 1398 | 1457 | |
| 1399 | 1458 | "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], |
| 1400 | 1459 | |
| @@ -1410,6 +1469,8 @@ · 9 unmodified lines | ||
| 1410 | 1469 | |
| 1411 | 1470 | "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], |
| 1412 | 1471 | |
| 1472 | + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], | |
| 1473 | + | |
| 1413 | 1474 | "es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="], |
| 1414 | 1475 | |
| 1415 | 1476 | "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], |
| @@ -1994,6 +2055,8 @@ · 578 unmodified lines | ||
| 1994 | 2055 | |
| 1995 | 2056 | "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], |
| 1996 | 2057 | |
| 2058 | + "miniflare": ["miniflare@4.20260210.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260210.0", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-HXR6m53IOqEzq52DuGF1x7I1K6lSIqzhbCbQXv/cTmPnPJmNkr7EBtLDm4nfSkOvlDtnwDCLUjWII5fyGJI5Tw=="], | |
| 2059 | + | |
| 1997 | 2060 | "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], |
| 1998 | 2061 | |
| 1999 | 2062 | "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], |
| @@ -2474,6 +2537,8 @@ · 474 unmodified lines | ||
| 2474 | 2537 | |
| 2475 | 2538 | "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], |
| 2476 | 2539 | |
| 2540 | + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], | |
| 2541 | + | |
| 2477 | 2542 | "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], |
| 2478 | 2543 | |
| 2479 | 2544 | "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], |
| @@ -2546,6 +2611,10 @@ · 66 unmodified lines | ||
| 2546 | 2611 | |
| 2547 | 2612 | "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], |
| 2548 | 2613 | |
| 2614 | + "workerd": ["workerd@1.20260210.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260210.0", "@cloudflare/workerd-darwin-arm64": "1.20260210.0", "@cloudflare/workerd-linux-64": "1.20260210.0", "@cloudflare/workerd-linux-arm64": "1.20260210.0", "@cloudflare/workerd-windows-64": "1.20260210.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-Sb0WXhrvf+XHQigP2trAxQnXo7wxZFC4PWnn6I7LhFxiTvzxvOAqMEiLkIz58wggRCb54T/KAA8hdjkTniR5FA=="], | |
| 2615 | + | |
| 2616 | + "wrangler": ["wrangler@4.64.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.12.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260210.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260210.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260210.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-0PBiVEbshQT4Av/KLHbOAks4ioIKp/eAO7Xr2BgAX5v7cFYYgeOvudBrbtZa/hDDIA6858QuJnTQ8mI+cm8Vqw=="], | |
| 2617 | + | |
| 2549 | 2618 | "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], |
| 2550 | 2619 | |
| 2551 | 2620 | "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], |
| @@ -2572,6 +2641,10 @@ · 20 unmodified lines | ||
| 2572 | 2641 | |
| 2573 | 2642 | "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], |
| 2574 | 2643 | |
| 2644 | + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], | |
| 2645 | + | |
| 2646 | + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], | |
| 2647 | + | |
| 2575 | 2648 | "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], |
| 2576 | 2649 | |
| 2577 | 2650 | "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], |
| @@ -2580,10 +2653,16 @@ · 2 unmodified lines | ||
| 2580 | 2653 | |
| 2581 | 2654 | "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], |
| 2582 | 2655 | |
| 2656 | + "@assistant/bot/@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], | |
| 2657 | + | |
| 2658 | + "@assistant/server/@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], | |
| 2659 | + | |
| 2583 | 2660 | "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], |
| 2584 | 2661 | |
| 2585 | 2662 | "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], |
| 2586 | 2663 | |
| 2664 | + "@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], | |
| 2665 | + | |
| 2587 | 2666 | "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], |
| 2588 | 2667 | |
| 2589 | 2668 | "@convex-dev/workos/@workos-inc/authkit-react": ["@workos-inc/authkit-react@0.11.0", "", { "dependencies": { "@workos-inc/authkit-js": "0.13.0" }, "peerDependencies": { "react": ">=17" } }, "sha512-67HFSxP4wXC8ECGyvc1yGMwuD5NGkwT2OPt8DavHoKAlO+hRaAlu9wwzqUx1EJrHht0Dcx+l20Byq8Ab0bEhlg=="], |
| @@ -2612,12 +2691,18 @@ · 22 unmodified lines | ||
| 2612 | 2691 | |
| 2613 | 2692 | "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], |
| 2614 | 2693 | |
| 2694 | + "@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], | |
| 2695 | + | |
| 2696 | + "@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], | |
| 2697 | + | |
| 2615 | 2698 | "@mariozechner/pi-ai/undici": ["undici@7.21.0", "", {}, "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg=="], |
| 2616 | 2699 | |
| 2617 | 2700 | "@mistralai/mistralai/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], |
| 2618 | 2701 | |
| 2619 | 2702 | "@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], |
| 2620 | 2703 | |
| 2704 | + "@proto/sources/@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], | |
| 2705 | + | |
| 2621 | 2706 | "@redocly/openapi-core/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], |
| 2622 | 2707 | |
| 2623 | 2708 | "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], |
| @@ -2690,6 +2775,10 @@ · 66 unmodified lines | ||
| 2690 | 2775 | |
| 2691 | 2776 | "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], |
| 2692 | 2777 | |
| 2778 | + "miniflare/undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="], | |
| 2779 | + | |
| 2780 | + "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], | |
| 2781 | + | |
| 2693 | 2782 | "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], |
| 2694 | 2783 | |
| 2695 | 2784 | "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], |
| @@ -2728,6 +2817,8 @@ · 32 unmodified lines | ||
| 2728 | 2817 | |
| 2729 | 2818 | "vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], |
| 2730 | 2819 | |
| 2820 | + "wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], | |
| 2821 | + | |
| 2731 | 2822 | "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], |
| 2732 | 2823 | |
| 2733 | 2824 | "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], |
| @@ -2738,6 +2829,10 @@ · 4 unmodified lines | ||
| 2738 | 2829 | |
| 2739 | 2830 | "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], |
| 2740 | 2831 | |
| 2832 | + "@assistant/bot/@types/bun/bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], | |
| 2833 | + | |
| 2834 | + "@assistant/server/@types/bun/bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], | |
| 2835 | + | |
| 2741 | 2836 | "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], |
| 2742 | 2837 | |
| 2743 | 2838 | "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], |
| @@ -2768,6 +2863,8 @@ · 24 unmodified lines | ||
| 2768 | 2863 | |
| 2769 | 2864 | "@next/eslint-plugin-next/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], |
| 2770 | 2865 | |
| 2866 | + "@proto/sources/@types/bun/bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], | |
| 2867 | + | |
| 2771 | 2868 | "@redocly/openapi-core/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], |
| 2772 | 2869 | |
| 2773 | 2870 | "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], |
| @@ -2790,6 +2887,58 @@ · 16 unmodified lines | ||
| 2790 | 2887 | |
| 2791 | 2888 | "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], |
| 2792 | 2889 | |
| 2890 | + "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], | |
| 2891 | + | |
| 2892 | + "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], | |
| 2893 | + | |
| 2894 | + "wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], | |
| 2895 | + | |
| 2896 | + "wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], | |
| 2897 | + | |
| 2898 | + "wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], | |
| 2899 | + | |
| 2900 | + "wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], | |
| 2901 | + | |
| 2902 | + "wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], | |
| 2903 | + | |
| 2904 | + "wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], | |
| 2905 | + | |
| 2906 | + "wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], | |
| 2907 | + | |
| 2908 | + "wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], | |
| 2909 | + | |
| 2910 | + "wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], | |
| 2911 | + | |
| 2912 | + "wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], | |
| 2913 | + | |
| 2914 | + "wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], | |
| 2915 | + | |
| 2916 | + "wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], | |
| 2917 | + | |
| 2918 | + "wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], | |
| 2919 | + | |
| 2920 | + "wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], | |
| 2921 | + | |
| 2922 | + "wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], | |
| 2923 | + | |
| 2924 | + "wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], | |
| 2925 | + | |
| 2926 | + "wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], | |
| 2927 | + | |
| 2928 | + "wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], | |
| 2929 | + | |
| 2930 | + "wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], | |
| 2931 | + | |
| 2932 | + "wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], | |
| 2933 | + | |
| 2934 | + "wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], | |
| 2935 | + | |
| 2936 | + "wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], | |
| 2937 | + | |
| 2938 | + "wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], | |
| 2939 | + | |
| 2940 | + "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], | |
| 2941 | + | |
| 2793 | 2942 | "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], |
| 2794 | 2943 | |
| 2795 | 2944 | "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], |
| @@ -6,10 +6,12 @@ · 5 unmodified lines | ||
| 6 | 6 | * Reads all configuration from the root .env file (auto-loaded by Bun). |
| 7 | 7 | * |
| 8 | 8 | * Starts: |
| 9 | − * 1. Convex cloud dev function watcher ─┐ | |
| 10 | − * 2. Executor web UI (port 4312) ├─ all started concurrently | |
| 11 | − * 3. Assistant server (port 3000) │ | |
| 12 | − * 4. Discord bot ─┘ | |
| 9 | + * 1. Sources catalog API (port 4343) ─┐ | |
| 10 | + * 2. Convex cloud dev function watcher │ | |
| 11 | + * 3. CF sandbox host worker (port 8787) ├─ all started concurrently | |
| 12 | + * 4. Executor web UI (port 4312) │ | |
| 13 | + * 5. Assistant server (port 3000) │ | |
| 14 | + * 6. Discord bot ─┘ | |
| 13 | 15 | * |
| 14 | 16 | * All processes are killed when this script exits (Ctrl+C). |
| 15 | 17 | * PIDs are written to .dev.pids for use with `bun run kill:all`. |
| @@ -21,8 +23,10 @@ import { unlinkSync } from "node:fs"; · 5 unmodified lines | ||
| 21 | 23 | const PID_FILE = join(import.meta.dir, ".dev.pids"); |
| 22 | 24 | |
| 23 | 25 | const colors = { |
| 26 | + sources: "\x1b[33m", // yellow | |
| 24 | 27 | convex: "\x1b[36m", // cyan |
| 25 | 28 | web: "\x1b[34m", // blue |
| 29 | + sandbox: "\x1b[91m", // bright red | |
| 26 | 30 | assistant: "\x1b[32m", // green |
| 27 | 31 | bot: "\x1b[35m", // magenta |
| 28 | 32 | reset: "\x1b[0m", |
| @@ -117,7 +121,8 @@ process.on("SIGTERM", shutdown); · 88 unmodified lines | ||
| 117 | 121 | // ── Kill stale processes from a previous run ── |
| 118 | 122 | |
| 119 | 123 | const EXECUTOR_WEB_PORT = Number(Bun.env.EXECUTOR_WEB_PORT ?? 4312); |
| 120 | −const DEV_PORTS = [3000, EXECUTOR_WEB_PORT]; | |
| 124 | +const SANDBOX_PORT = Number(Bun.env.SANDBOX_PORT ?? 8787); | |
| 125 | +const DEV_PORTS = [3000, EXECUTOR_WEB_PORT, SANDBOX_PORT]; | |
| 121 | 126 | |
| 122 | 127 | async function killStaleProcesses() { |
| 123 | 128 | let killed = 0; |
| @@ -164,6 +169,11 @@ const urls = resolveExecutorUrls(); · 40 unmodified lines | ||
| 164 | 169 | console.log(prefix("convex", `Using Convex URL: ${urls.convexUrl}`)); |
| 165 | 170 | console.log(prefix("convex", `Using executor HTTP URL: ${urls.executorUrl}`)); |
| 166 | 171 | |
| 172 | +// 1. Sources catalog API | |
| 173 | +spawnService("sources", ["bun", "--hot", "server.ts"], { | |
| 174 | + cwd: "./sources", | |
| 175 | +}); | |
| 176 | + | |
| 167 | 177 | // 2. Start Convex file watcher (non-blocking — repushes on changes) |
| 168 | 178 | spawnService("convex", [ |
| 169 | 179 | "bunx", "convex", "dev", |
| @@ -172,7 +182,15 @@ spawnService("convex", [ · 2 unmodified lines | ||
| 172 | 182 | cwd: "./executor", |
| 173 | 183 | }); |
| 174 | 184 | |
| 175 | −// 3. Everything else in parallel | |
| 185 | +// 3. CF sandbox host worker (local wrangler dev for iterating on the worker) | |
| 186 | +spawnService("sandbox", [ | |
| 187 | + "bunx", "wrangler", "dev", | |
| 188 | + "--port", String(SANDBOX_PORT), | |
| 189 | +], { | |
| 190 | + cwd: "./executor/packages/sandbox-host", | |
| 191 | +}); | |
| 192 | + | |
| 193 | +// 4. Everything else in parallel | |
| 176 | 194 | spawnService("web", ["bun", "run", "dev"], { |
| 177 | 195 | cwd: "./executor/apps/web", |
| 178 | 196 | }); |
| @@ -116,20 +116,62 @@ function countAllTools(node: NamespaceNode): number { · 115 unmodified lines | ||
| 116 | 116 | |
| 117 | 117 | /** TS helper types for OpenAPI indexed access (same as typechecker.ts) */ |
| 118 | 118 | const OPENAPI_HELPER_TYPES = ` |
| 119 | −type _OrEmpty<T> = [T] extends [never] ? {} : T; | |
| 119 | +type _Normalize<T> = Exclude<T, undefined | null>; | |
| 120 | +type _OrEmpty<T> = [_Normalize<T>] extends [never] ? {} : _Normalize<T>; | |
| 120 | 121 | type _Simplify<T> = { [K in keyof T]: T[K] } & {}; |
| 122 | +type _ParamsOf<Op> = | |
| 123 | + Op extends { parameters: infer P } ? P : | |
| 124 | + Op extends { parameters?: infer P } ? P : | |
| 125 | + never; | |
| 126 | +type _ParamAt<Op, K extends "query" | "path" | "header" | "cookie"> = | |
| 127 | + _ParamsOf<Op> extends { [P in K]?: infer V } ? V : never; | |
| 128 | +type _BodyOf<Op> = | |
| 129 | + Op extends { requestBody?: infer B } ? B : | |
| 130 | + Op extends { requestBody: infer B } ? B : | |
| 131 | + never; | |
| 132 | +type _BodyContent<B> = | |
| 133 | + B extends { content: infer C } | |
| 134 | + ? C extends Record<string, infer V> ? V : never | |
| 135 | + : never; | |
| 121 | 136 | type ToolInput<Op> = _Simplify< |
| 122 | − _OrEmpty<Op extends { parameters: { query: infer Q } } ? { [K in keyof Q]: Q[K] } : never> & | |
| 123 | − _OrEmpty<Op extends { parameters: { path: infer P } } ? { [K in keyof P]: P[K] } : never> & | |
| 124 | − _OrEmpty<Op extends { requestBody: { content: { "application/json": infer B } } } ? B : never> | |
| 137 | + _OrEmpty<_ParamAt<Op, "query">> & | |
| 138 | + _OrEmpty<_ParamAt<Op, "path">> & | |
| 139 | + _OrEmpty<_ParamAt<Op, "header">> & | |
| 140 | + _OrEmpty<_ParamAt<Op, "cookie">> & | |
| 141 | + _OrEmpty<_BodyContent<_BodyOf<Op>>> | |
| 125 | 142 | >; |
| 126 | −type ToolOutput<Op> = | |
| 127 | − Op extends { responses: { 200: { content: { "application/json": infer R } } } } ? R : | |
| 128 | − Op extends { responses: { 201: { content: { "application/json": infer R } } } } ? R : | |
| 129 | − Op extends { responses: { 202: { content: { "application/json": infer R } } } } ? R : | |
| 130 | − Op extends { responses: { 204: unknown } } ? void : | |
| 131 | − Op extends { responses: { 205: unknown } } ? void : | |
| 132 | − unknown; | |
| 143 | +type _ResponsesOf<Op> = Op extends { responses: infer R } ? R : never; | |
| 144 | +type _RespAt<Op, Code extends PropertyKey> = | |
| 145 | + _ResponsesOf<Op> extends { [K in Code]?: infer R } ? R : never; | |
| 146 | +type _ResponsePayload<R> = | |
| 147 | + [R] extends [never] ? never : | |
| 148 | + R extends { content: infer C } | |
| 149 | + ? C extends Record<string, infer V> ? V : unknown | |
| 150 | + : R extends { schema: infer S } ? S : unknown; | |
| 151 | +type _HasStatus<Op, Code extends PropertyKey> = | |
| 152 | + [_RespAt<Op, Code>] extends [never] ? false : true; | |
| 153 | +type _PayloadAt<Op, Code extends PropertyKey> = | |
| 154 | + Code extends 204 | 205 | |
| 155 | + ? (_HasStatus<Op, Code> extends true ? void : never) | |
| 156 | + : _ResponsePayload<_RespAt<Op, Code>>; | |
| 157 | +type _FirstKnown<T extends readonly unknown[]> = | |
| 158 | + T extends readonly [infer H, ...infer Rest] | |
| 159 | + ? [H] extends [never] ? _FirstKnown<Rest> : H | |
| 160 | + : unknown; | |
| 161 | +type ToolOutput<Op> = _FirstKnown<[ | |
| 162 | + _PayloadAt<Op, 200>, | |
| 163 | + _PayloadAt<Op, 201>, | |
| 164 | + _PayloadAt<Op, 202>, | |
| 165 | + _PayloadAt<Op, 203>, | |
| 166 | + _PayloadAt<Op, 204>, | |
| 167 | + _PayloadAt<Op, 205>, | |
| 168 | + _PayloadAt<Op, 206>, | |
| 169 | + _PayloadAt<Op, 207>, | |
| 170 | + _PayloadAt<Op, 208>, | |
| 171 | + _PayloadAt<Op, 226>, | |
| 172 | + _PayloadAt<Op, "default">, | |
| 173 | + unknown | |
| 174 | +]>; | |
| 133 | 175 | `; |
| 134 | 176 | |
| 135 | 177 | function generateToolsDts(tools: ToolDescriptor[], dtsSources: Set<string>): string { |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | −import { useState } from "react"; | |
| 3 | +import { useRef, useState } from "react"; | |
| 4 | 4 | import { |
| 5 | 5 | Wrench, |
| 6 | 6 | Plus, |
| @@ -11,6 +11,7 @@ import { · 4 unmodified lines | ||
| 11 | 11 | AlertTriangle, |
| 12 | 12 | KeyRound, |
| 13 | 13 | Pencil, |
| 14 | + Loader2, | |
| 14 | 15 | } from "lucide-react"; |
| 15 | 16 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; |
| 16 | 17 | import { Button } from "@/components/ui/button"; |
| @@ -56,158 +57,27 @@ import type { · 39 unmodified lines | ||
| 56 | 57 | import { parse as parseDomain } from "tldts"; |
| 57 | 58 | import { toast } from "sonner"; |
| 58 | 59 | import { cn } from "@/lib/utils"; |
| 60 | +import { Streamdown } from "streamdown"; | |
| 59 | 61 | |
| 60 | −// ── API Presets ── | |
| 61 | − | |
| 62 | −interface ApiPreset { | |
| 62 | +interface CatalogCollectionItem { | |
| 63 | + id: string; | |
| 63 | 64 | name: string; |
| 64 | − label: string; | |
| 65 | − description: string; | |
| 66 | − type: "openapi" | "mcp" | "graphql"; | |
| 67 | − spec?: string; | |
| 68 | − url?: string; | |
| 69 | − endpoint?: string; | |
| 70 | − baseUrl?: string; | |
| 71 | − authNote?: string; | |
| 65 | + summary: string; | |
| 66 | + specUrl: string; | |
| 67 | + originUrl?: string; | |
| 68 | + providerName: string; | |
| 69 | + logoUrl?: string; | |
| 70 | + categories?: string; | |
| 71 | + version?: string; | |
| 72 | 72 | } |
| 73 | 73 | |
| 74 | −const API_PRESETS: ApiPreset[] = [ | |
| 75 | − { | |
| 76 | − name: "github", | |
| 77 | − label: "GitHub", | |
| 78 | − description: "Repos, issues, PRs, actions, users, orgs", | |
| 79 | − type: "openapi", | |
| 80 | − spec: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml", | |
| 81 | − baseUrl: "https://api.github.com", | |
| 82 | − authNote: "Add a bearer credential with a PAT for authenticated access", | |
| 83 | − }, | |
| 84 | − { | |
| 85 | − name: "vercel", | |
| 86 | − label: "Vercel", | |
| 87 | − description: "Deployments, projects, domains, env vars, teams", | |
| 88 | − type: "openapi", | |
| 89 | − spec: "https://openapi.vercel.sh", | |
| 90 | − baseUrl: "https://api.vercel.com", | |
| 91 | − authNote: "Requires API token as bearer credential", | |
| 92 | − }, | |
| 93 | − { | |
| 94 | − name: "slack", | |
| 95 | − label: "Slack", | |
| 96 | − description: "Messages, channels, users, reactions, files", | |
| 97 | − type: "openapi", | |
| 98 | − spec: "https://api.slack.com/specs/openapi/v2/slack_web.json", | |
| 99 | − baseUrl: "https://slack.com/api", | |
| 100 | − authNote: "Requires a bot token as bearer credential", | |
| 101 | − }, | |
| 102 | − { | |
| 103 | − name: "discord", | |
| 104 | − label: "Discord", | |
| 105 | − description: "Guilds, channels, messages, interactions, webhooks", | |
| 106 | − type: "openapi", | |
| 107 | − spec: "https://raw.githubusercontent.com/discord/discord-api-spec/main/specs/openapi.json", | |
| 108 | − baseUrl: "https://discord.com/api/v10", | |
| 109 | − authNote: "Requires a bot token as bearer credential", | |
| 110 | − }, | |
| 111 | − { | |
| 112 | − name: "stripe", | |
| 113 | − label: "Stripe", | |
| 114 | − description: "Payments, customers, subscriptions, invoices", | |
| 115 | − type: "openapi", | |
| 116 | − spec: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json", | |
| 117 | − baseUrl: "https://api.stripe.com", | |
| 118 | − authNote: "Requires API key as bearer credential", | |
| 119 | − }, | |
| 120 | − | |
| 121 | − { | |
| 122 | − name: "openai", | |
| 123 | − label: "OpenAI", | |
| 124 | − description: "Chat completions, embeddings, images, files", | |
| 125 | − type: "openapi", | |
| 126 | − spec: "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml", | |
| 127 | − baseUrl: "https://api.openai.com", | |
| 128 | − authNote: "Requires API key as bearer credential", | |
| 129 | − }, | |
| 130 | − { | |
| 131 | − name: "cloudflare", | |
| 132 | − label: "Cloudflare", | |
| 133 | − description: "DNS, zones, workers, KV, R2, firewall", | |
| 134 | − type: "openapi", | |
| 135 | − spec: "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml", | |
| 136 | − baseUrl: "https://api.cloudflare.com/client/v4", | |
| 137 | − authNote: "Requires API token as bearer credential", | |
| 138 | − }, | |
| 139 | − { | |
| 140 | − name: "sentry", | |
| 141 | − label: "Sentry", | |
| 142 | − description: "Issues, events, projects, releases, alerts", | |
| 143 | − type: "openapi", | |
| 144 | − spec: "https://raw.githubusercontent.com/getsentry/sentry-api-schema/refs/heads/main/openapi-derefed.json", | |
| 145 | − baseUrl: "https://sentry.io/api/0", | |
| 146 | − authNote: "Requires auth token as bearer credential", | |
| 147 | − }, | |
| 148 | − { | |
| 149 | − name: "jira", | |
| 150 | − label: "Jira", | |
| 151 | − description: "Issues, projects, boards, sprints, users", | |
| 152 | − type: "openapi", | |
| 153 | − spec: "https://developer.atlassian.com/cloud/jira/platform/swagger-v3.v3.json", | |
| 154 | − baseUrl: "https://your-domain.atlassian.net/rest/api/3", | |
| 155 | − authNote: "Requires API token with basic auth (email:token)", | |
| 156 | − }, | |
| 157 | − { | |
| 158 | − name: "pagerduty", | |
| 159 | − label: "PagerDuty", | |
| 160 | − description: "Incidents, services, schedules, escalations", | |
| 161 | − type: "openapi", | |
| 162 | − spec: "https://raw.githubusercontent.com/PagerDuty/api-schema/main/reference/REST/openapiv3.json", | |
| 163 | − baseUrl: "https://api.pagerduty.com", | |
| 164 | − authNote: "Requires API key as bearer credential", | |
| 165 | − }, | |
| 166 | − { | |
| 167 | − name: "digitalocean", | |
| 168 | − label: "DigitalOcean", | |
| 169 | − description: "Droplets, databases, domains, apps, spaces", | |
| 170 | − type: "openapi", | |
| 171 | − spec: "https://api-engineering.nyc3.cdn.digitaloceanspaces.com/spec-ci/DigitalOcean-public.v2.yaml", | |
| 172 | − baseUrl: "https://api.digitalocean.com", | |
| 173 | − authNote: "Requires API token as bearer credential", | |
| 174 | − }, | |
| 175 | − { | |
| 176 | − name: "twilio", | |
| 177 | − label: "Twilio", | |
| 178 | − description: "SMS, calls, conversations, verify, phone numbers", | |
| 179 | − type: "openapi", | |
| 180 | − spec: "https://raw.githubusercontent.com/twilio/twilio-oai/main/spec/json/twilio_api_v2010.json", | |
| 181 | − baseUrl: "https://api.twilio.com", | |
| 182 | − authNote: "Requires Account SID + Auth Token as basic auth", | |
| 183 | − }, | |
| 184 | − { | |
| 185 | − name: "notion", | |
| 186 | − label: "Notion", | |
| 187 | − description: "Pages, databases, blocks, search, users", | |
| 188 | − type: "openapi", | |
| 189 | − spec: "https://developers.notion.com/openapi.json", | |
| 190 | − baseUrl: "https://api.notion.com", | |
| 191 | − authNote: "Requires integration token as bearer credential", | |
| 192 | − }, | |
| 193 | − | |
| 194 | − { | |
| 195 | − name: "resend", | |
| 196 | − label: "Resend", | |
| 197 | − description: "Send emails, manage domains, API keys", | |
| 198 | − type: "openapi", | |
| 199 | − spec: "https://raw.githubusercontent.com/resend/resend-openapi/main/resend.yaml", | |
| 200 | − baseUrl: "https://api.resend.com", | |
| 201 | − }, | |
| 202 | − { | |
| 203 | − name: "linear", | |
| 204 | − label: "Linear", | |
| 205 | − description: "Issues, projects, teams, cycles, labels", | |
| 206 | − type: "graphql", | |
| 207 | − endpoint: "https://api.linear.app/graphql", | |
| 208 | − authNote: "Requires API key as bearer credential", | |
| 209 | − }, | |
| 210 | −]; | |
| 74 | +interface CatalogCollectionsResponse { | |
| 75 | + items?: CatalogCollectionItem[]; | |
| 76 | + totalCount?: number; | |
| 77 | + hasMore?: boolean; | |
| 78 | + error?: string; | |
| 79 | + detail?: string; | |
| 80 | +} | |
| 211 | 81 | |
| 212 | 82 | /** Derive a favicon URL from any URL string via Google's favicon service. */ |
| 213 | 83 | function faviconForUrl(url: string | undefined | null): string | null { |
| @@ -220,16 +90,6 @@ function faviconForUrl(url: string | undefined | null): string | null { · 6 unmodified lines | ||
| 220 | 90 | } |
| 221 | 91 | } |
| 222 | 92 | |
| 223 | −function getFaviconUrl(preset: ApiPreset): string | null { | |
| 224 | − if (preset.type === "openapi") { | |
| 225 | − return faviconForUrl(preset.baseUrl ?? null); | |
| 226 | − } | |
| 227 | − if (preset.type === "graphql") { | |
| 228 | − return faviconForUrl(preset.endpoint ?? null); | |
| 229 | − } | |
| 230 | − return faviconForUrl(preset.url ?? null); | |
| 231 | −} | |
| 232 | − | |
| 233 | 93 | function getSourceFavicon(source: ToolSourceRecord): string | null { |
| 234 | 94 | if (source.type === "mcp") { |
| 235 | 95 | return faviconForUrl((source.config.url as string) ?? null); |
| @@ -237,7 +97,30 @@ function getSourceFavicon(source: ToolSourceRecord): string | null { · 1 unmodified line | ||
| 237 | 97 | if (source.type === "graphql") { |
| 238 | 98 | return faviconForUrl((source.config.endpoint as string) ?? null); |
| 239 | 99 | } |
| 240 | − return faviconForUrl((source.config.baseUrl as string) ?? null); | |
| 100 | + const spec = source.config.spec as string | undefined; | |
| 101 | + if (typeof spec === "string" && spec.startsWith("postman:")) { | |
| 102 | + return null; | |
| 103 | + } | |
| 104 | + const baseUrl = source.config.baseUrl as string | undefined; | |
| 105 | + const collectionUrl = source.config.collectionUrl as string | undefined; | |
| 106 | + const specUrl = typeof spec === "string" && spec.startsWith("http") ? spec : null; | |
| 107 | + return faviconForUrl(baseUrl ?? collectionUrl ?? specUrl); | |
| 108 | +} | |
| 109 | + | |
| 110 | +function sourceEndpointLabel(source: ToolSourceRecord): string { | |
| 111 | + if (source.type === "mcp") return (source.config.url as string) ?? ""; | |
| 112 | + if (source.type === "graphql") return (source.config.endpoint as string) ?? ""; | |
| 113 | + | |
| 114 | + const spec = source.config.spec; | |
| 115 | + if (typeof spec === "string" && spec.startsWith("postman:")) { | |
| 116 | + const uid = spec.slice("postman:".length).trim(); | |
| 117 | + if (uid.length > 0) { | |
| 118 | + return `catalog:${uid}`; | |
| 119 | + } | |
| 120 | + return "catalog:collection"; | |
| 121 | + } | |
| 122 | + | |
| 123 | + return (source.config.spec as string) ?? ""; | |
| 241 | 124 | } |
| 242 | 125 | |
| 243 | 126 | function sourceKeyForSource(source: ToolSourceRecord): string | null { |
| @@ -366,6 +249,39 @@ function inferNameFromUrl(url: string): string { · 122 unmodified lines | ||
| 366 | 249 | } |
| 367 | 250 | } |
| 368 | 251 | |
| 252 | +function sanitizeSourceName(value: string): string { | |
| 253 | + const slug = value | |
| 254 | + .toLowerCase() | |
| 255 | + .replace(/[^a-z0-9]+/g, "-") | |
| 256 | + .replace(/-+/g, "-") | |
| 257 | + .replace(/^-|-$/g, "") | |
| 258 | + .slice(0, 48); | |
| 259 | + return slug || "source"; | |
| 260 | +} | |
| 261 | + | |
| 262 | +function withUniqueSourceName(baseName: string, takenNames: Set<string>): string { | |
| 263 | + const loweredTaken = new Set([...takenNames].map((name) => name.toLowerCase())); | |
| 264 | + const candidate = sanitizeSourceName(baseName); | |
| 265 | + if (!loweredTaken.has(candidate.toLowerCase())) { | |
| 266 | + return candidate; | |
| 267 | + } | |
| 268 | + | |
| 269 | + let suffix = 2; | |
| 270 | + while (true) { | |
| 271 | + const next = `${candidate}-${suffix}`; | |
| 272 | + if (!loweredTaken.has(next.toLowerCase())) { | |
| 273 | + return next; | |
| 274 | + } | |
| 275 | + suffix += 1; | |
| 276 | + } | |
| 277 | +} | |
| 278 | + | |
| 279 | +function catalogSourceName(item: CatalogCollectionItem): string { | |
| 280 | + const owner = sanitizeSourceName(item.providerName || "catalog"); | |
| 281 | + const title = sanitizeSourceName(item.name); | |
| 282 | + return sanitizeSourceName(`${owner}-${title}`); | |
| 283 | +} | |
| 284 | + | |
| 369 | 285 | function AddSourceDialog({ |
| 370 | 286 | existingSourceNames, |
| 371 | 287 | }: { |
| @@ -374,7 +290,7 @@ function AddSourceDialog({ · 2 unmodified lines | ||
| 374 | 290 | const { context } = useSession(); |
| 375 | 291 | const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource); |
| 376 | 292 | const [open, setOpen] = useState(false); |
| 377 | − const [presetsOpen, setPresetsOpen] = useState(false); | |
| 293 | + const [view, setView] = useState<"catalog" | "custom">("catalog"); | |
| 378 | 294 | const [type, setType] = useState<"mcp" | "openapi" | "graphql">("mcp"); |
| 379 | 295 | const [name, setName] = useState(""); |
| 380 | 296 | const [nameManuallyEdited, setNameManuallyEdited] = useState(false); |
| @@ -383,7 +299,35 @@ function AddSourceDialog({ · 2 unmodified lines | ||
| 383 | 299 | const [mcpTransport, setMcpTransport] = useState<"auto" | "streamable-http" | "sse">("auto"); |
| 384 | 300 | const [mcpActorQueryParamKey, setMcpActorQueryParamKey] = useState("userId"); |
| 385 | 301 | const [submitting, setSubmitting] = useState(false); |
| 386 | − const [addingPreset, setAddingPreset] = useState<string | null>(null); | |
| 302 | + const [locallyReservedNames, setLocallyReservedNames] = useState<string[]>([]); | |
| 303 | + const [catalogQuery, setCatalogQuery] = useState(""); | |
| 304 | + const [catalogSort, setCatalogSort] = useState<"popular" | "recent">("popular"); | |
| 305 | + const [catalogItems, setCatalogItems] = useState<CatalogCollectionItem[]>([]); | |
| 306 | + const [catalogOffset, setCatalogOffset] = useState(0); | |
| 307 | + const [catalogHasMore, setCatalogHasMore] = useState(true); | |
| 308 | + const [catalogTotalCount, setCatalogTotalCount] = useState<number | null>(null); | |
| 309 | + const [catalogLoading, setCatalogLoading] = useState(false); | |
| 310 | + const [catalogLoadingMore, setCatalogLoadingMore] = useState(false); | |
| 311 | + const [catalogError, setCatalogError] = useState<string | null>(null); | |
| 312 | + const [addingCatalogId, setAddingCatalogId] = useState<string | null>(null); | |
| 313 | + const catalogRequestIdRef = useRef(0); | |
| 314 | + const catalogInFlightRef = useRef(false); | |
| 315 | + | |
| 316 | + const CATALOG_PAGE_SIZE = 20; | |
| 317 | + | |
| 318 | + const getTakenSourceNames = () => new Set([...existingSourceNames, ...locallyReservedNames]); | |
| 319 | + | |
| 320 | + const reserveSourceName = (sourceName: string) => { | |
| 321 | + setLocallyReservedNames((current) => | |
| 322 | + current.includes(sourceName) | |
| 323 | + ? current | |
| 324 | + : [...current, sourceName] | |
| 325 | + ); | |
| 326 | + }; | |
| 327 | + | |
| 328 | + const getUniqueAutoSourceName = (candidate: string) => { | |
| 329 | + return withUniqueSourceName(candidate, getTakenSourceNames()); | |
| 330 | + }; | |
| 387 | 331 | |
| 388 | 332 | const handleEndpointChange = (value: string) => { |
| 389 | 333 | setEndpoint(value); |
| @@ -398,20 +342,131 @@ function AddSourceDialog({ · 8 unmodified lines | ||
| 398 | 342 | setNameManuallyEdited(true); |
| 399 | 343 | }; |
| 400 | 344 | |
| 401 | − const resetForm = () => { | |
| 345 | + const resetDialogState = () => { | |
| 346 | + catalogRequestIdRef.current += 1; | |
| 347 | + catalogInFlightRef.current = false; | |
| 348 | + setView("catalog"); | |
| 349 | + setType("mcp"); | |
| 402 | 350 | setName(""); |
| 403 | 351 | setEndpoint(""); |
| 404 | 352 | setBaseUrl(""); |
| 405 | 353 | setMcpTransport("auto"); |
| 406 | 354 | setMcpActorQueryParamKey("userId"); |
| 407 | 355 | setNameManuallyEdited(false); |
| 408 | − setPresetsOpen(false); | |
| 409 | − setAddingPreset(null); | |
| 356 | + setLocallyReservedNames([]); | |
| 357 | + setCatalogQuery(""); | |
| 358 | + setCatalogSort("popular"); | |
| 359 | + setCatalogItems([]); | |
| 360 | + setCatalogOffset(0); | |
| 361 | + setCatalogHasMore(true); | |
| 362 | + setCatalogTotalCount(null); | |
| 363 | + setCatalogLoading(false); | |
| 364 | + setCatalogLoadingMore(false); | |
| 365 | + setCatalogError(null); | |
| 366 | + setAddingCatalogId(null); | |
| 367 | + }; | |
| 368 | + | |
| 369 | + const loadCatalogPage = async ({ | |
| 370 | + mode, | |
| 371 | + query, | |
| 372 | + sort, | |
| 373 | + }: { | |
| 374 | + mode: "reset" | "next"; | |
| 375 | + query?: string; | |
| 376 | + sort?: "popular" | "recent"; | |
| 377 | + }) => { | |
| 378 | + const resolvedQuery = (query ?? catalogQuery).trim(); | |
| 379 | + const resolvedSort = sort ?? catalogSort; | |
| 380 | + const nextOffset = mode === "reset" ? 0 : catalogOffset; | |
| 381 | + | |
| 382 | + if (mode === "next") { | |
| 383 | + if (catalogLoading || catalogLoadingMore || catalogInFlightRef.current || !catalogHasMore) { | |
| 384 | + return; | |
| 385 | + } | |
| 386 | + setCatalogLoadingMore(true); | |
| 387 | + } else { | |
| 388 | + setCatalogLoading(true); | |
| 389 | + setCatalogLoadingMore(false); | |
| 390 | + setCatalogError(null); | |
| 391 | + } | |
| 392 | + | |
| 393 | + const requestId = catalogRequestIdRef.current + 1; | |
| 394 | + catalogRequestIdRef.current = requestId; | |
| 395 | + catalogInFlightRef.current = true; | |
| 396 | + | |
| 397 | + try { | |
| 398 | + const params = new URLSearchParams({ | |
| 399 | + sort: resolvedSort, | |
| 400 | + limit: String(CATALOG_PAGE_SIZE), | |
| 401 | + offset: String(nextOffset), | |
| 402 | + }); | |
| 403 | + if (resolvedQuery.length > 0) { | |
| 404 | + params.set("q", resolvedQuery); | |
| 405 | + } | |
| 406 | + | |
| 407 | + const catalogBase = process.env.NEXT_PUBLIC_SOURCES_URL ?? "http://127.0.0.1:4343"; | |
| 408 | + const response = await fetch(`${catalogBase}/collections?${params.toString()}`, { | |
| 409 | + cache: "no-store", | |
| 410 | + }); | |
| 411 | + const body = await response.json() as CatalogCollectionsResponse; | |
| 412 | + | |
| 413 | + if (requestId !== catalogRequestIdRef.current) { | |
| 414 | + return; | |
| 415 | + } | |
| 416 | + | |
| 417 | + if (!response.ok) { | |
| 418 | + throw new Error(body.error || "Failed to load API catalog"); | |
| 419 | + } | |
| 420 | + | |
| 421 | + const nextItems = Array.isArray(body.items) ? body.items : []; | |
| 422 | + setCatalogItems((current) => { | |
| 423 | + if (mode === "reset") { | |
| 424 | + return nextItems; | |
| 425 | + } | |
| 426 | + | |
| 427 | + const merged = [...current]; | |
| 428 | + const seen = new Set(current.map((entry) => entry.id)); | |
| 429 | + for (const item of nextItems) { | |
| 430 | + if (seen.has(item.id)) continue; | |
| 431 | + seen.add(item.id); | |
| 432 | + merged.push(item); | |
| 433 | + } | |
| 434 | + return merged; | |
| 435 | + }); | |
| 436 | + setCatalogOffset(nextOffset + nextItems.length); | |
| 437 | + setCatalogHasMore(typeof body.hasMore === "boolean" ? body.hasMore : nextItems.length >= CATALOG_PAGE_SIZE); | |
| 438 | + setCatalogTotalCount(typeof body.totalCount === "number" ? body.totalCount : null); | |
| 439 | + setCatalogError(null); | |
| 440 | + } catch (error) { | |
| 441 | + if (requestId !== catalogRequestIdRef.current) { | |
| 442 | + return; | |
| 443 | + } | |
| 444 | + | |
| 445 | + if (mode === "reset") { | |
| 446 | + setCatalogItems([]); | |
| 447 | + setCatalogOffset(0); | |
| 448 | + } | |
| 449 | + setCatalogHasMore(false); | |
| 450 | + setCatalogTotalCount(null); | |
| 451 | + setCatalogError(error instanceof Error ? error.message : "Failed to load API catalog"); | |
| 452 | + } finally { | |
| 453 | + if (requestId === catalogRequestIdRef.current) { | |
| 454 | + setCatalogLoading(false); | |
| 455 | + setCatalogLoadingMore(false); | |
| 456 | + catalogInFlightRef.current = false; | |
| 457 | + } | |
| 458 | + } | |
| 410 | 459 | }; |
| 411 | 460 | |
| 412 | 461 | const handleOpenChange = (isOpen: boolean) => { |
| 413 | 462 | setOpen(isOpen); |
| 414 | − if (!isOpen) resetForm(); | |
| 463 | + if (!isOpen) { | |
| 464 | + resetDialogState(); | |
| 465 | + return; | |
| 466 | + } | |
| 467 | + | |
| 468 | + resetDialogState(); | |
| 469 | + void loadCatalogPage({ mode: "reset", query: "", sort: "popular" }); | |
| 415 | 470 | }; |
| 416 | 471 | |
| 417 | 472 | const addSource = async ( |
| @@ -430,31 +485,35 @@ function AddSourceDialog({ · 12 unmodified lines | ||
| 430 | 485 | toast.success(`Source "${sourceName}" added — loading tools…`); |
| 431 | 486 | }; |
| 432 | 487 | |
| 433 | − const handlePresetAdd = async (preset: ApiPreset) => { | |
| 434 | − setAddingPreset(preset.name); | |
| 488 | + const handleCatalogAdd = async (item: CatalogCollectionItem) => { | |
| 489 | + if (!item.specUrl.trim()) { | |
| 490 | + toast.error("Missing OpenAPI spec URL for this API source"); | |
| 491 | + return; | |
| 492 | + } | |
| 493 | + | |
| 494 | + setAddingCatalogId(item.id); | |
| 435 | 495 | try { |
| 436 | − const config: Record<string, unknown> = | |
| 437 | − preset.type === "mcp" | |
| 438 | − ? { url: preset.url } | |
| 439 | − : preset.type === "graphql" | |
| 440 | − ? { endpoint: preset.endpoint } | |
| 441 | − : { | |
| 442 | − spec: preset.spec, | |
| 443 | − ...(preset.baseUrl ? { baseUrl: preset.baseUrl } : {}), | |
| 444 | − }; | |
| 445 | − await addSource(preset.name, preset.type, config); | |
| 446 | − if (preset.authNote) { | |
| 447 | − toast.info(preset.authNote, { duration: 6000 }); | |
| 448 | − } | |
| 449 | − } catch (err) { | |
| 450 | − toast.error(err instanceof Error ? err.message : "Failed to add source"); | |
| 496 | + const sourceName = getUniqueAutoSourceName(catalogSourceName(item)); | |
| 497 | + await addSource(sourceName, "openapi", { | |
| 498 | + spec: item.specUrl, | |
| 499 | + }); | |
| 500 | + reserveSourceName(sourceName); | |
| 501 | + } catch (error) { | |
| 502 | + toast.error(error instanceof Error ? error.message : "Failed to add API source"); | |
| 451 | 503 | } finally { |
| 452 | − setAddingPreset(null); | |
| 504 | + setAddingCatalogId(null); | |
| 453 | 505 | } |
| 454 | 506 | }; |
| 455 | 507 | |
| 456 | 508 | const handleCustomSubmit = async () => { |
| 457 | 509 | if (!context || !name.trim() || !endpoint.trim()) return; |
| 510 | + | |
| 511 | + const takenNames = [...getTakenSourceNames()].map((entry) => entry.toLowerCase()); | |
| 512 | + if (takenNames.includes(name.trim().toLowerCase())) { | |
| 513 | + toast.error(`Source name "${name.trim()}" already exists`); | |
| 514 | + return; | |
| 515 | + } | |
| 516 | + | |
| 458 | 517 | setSubmitting(true); |
| 459 | 518 | try { |
| 460 | 519 | const config: Record<string, unknown> = |
| @@ -470,7 +529,8 @@ function AddSourceDialog({ · 9 unmodified lines | ||
| 470 | 529 | ? { endpoint: endpoint } |
| 471 | 530 | : { spec: endpoint, ...(baseUrl ? { baseUrl } : {}) }; |
| 472 | 531 | await addSource(name.trim(), type, config); |
| 473 | − resetForm(); | |
| 532 | + reserveSourceName(name.trim()); | |
| 533 | + resetDialogState(); | |
| 474 | 534 | setOpen(false); |
| 475 | 535 | } catch (err) { |
| 476 | 536 | toast.error(err instanceof Error ? err.message : "Failed to add source"); |
| @@ -495,180 +555,261 @@ function AddSourceDialog({ · 18 unmodified lines | ||
| 495 | 555 | </DialogHeader> |
| 496 | 556 | |
| 497 | 557 | <div className="p-5 space-y-4"> |
| 498 | − {/* Custom source form — always visible */} | |
| 499 | − <div className="space-y-3"> | |
| 500 | − <div className="space-y-1.5"> | |
| 501 | − <Label className="text-xs text-muted-foreground">Type</Label> | |
| 502 | − <Select | |
| 503 | − value={type} | |
| 504 | − onValueChange={(v) => setType(v as "mcp" | "openapi" | "graphql")} | |
| 558 | + {view === "catalog" ? ( | |
| 559 | + <div className="space-y-3"> | |
| 560 | + <div className="flex items-center gap-2 flex-wrap"> | |
| 561 | + <Input | |
| 562 | + value={catalogQuery} | |
| 563 | + onChange={(event) => setCatalogQuery(event.target.value)} | |
| 564 | + placeholder="Search APIs" | |
| 565 | + className="h-8 text-xs font-mono bg-background flex-1 min-w-[150px]" | |
| 566 | + onKeyDown={(event) => { | |
| 567 | + if (event.key === "Enter") { | |
| 568 | + event.preventDefault(); | |
| 569 | + void loadCatalogPage({ mode: "reset" }); | |
| 570 | + } | |
| 571 | + }} | |
| 572 | + /> | |
| 573 | + <Select | |
| 574 | + value={catalogSort} | |
| 575 | + onValueChange={(value) => { | |
| 576 | + const nextSort = value as "popular" | "recent"; | |
| 577 | + setCatalogSort(nextSort); | |
| 578 | + void loadCatalogPage({ mode: "reset", sort: nextSort }); | |
| 579 | + }} | |
| 580 | + > | |
| 581 | + <SelectTrigger className="h-8 w-[105px] text-xs bg-background shrink-0"> | |
| 582 | + <SelectValue /> | |
| 583 | + </SelectTrigger> | |
| 584 | + <SelectContent> | |
| 585 | + <SelectItem value="popular" className="text-xs">Popular</SelectItem> | |
| 586 | + <SelectItem value="recent" className="text-xs">Recent</SelectItem> | |
| 587 | + </SelectContent> | |
| 588 | + </Select> | |
| 589 | + <Button | |
| 590 | + type="button" | |
| 591 | + variant="outline" | |
| 592 | + size="sm" | |
| 593 | + className="h-8 px-3 text-xs shrink-0" | |
| 594 | + onClick={() => void loadCatalogPage({ mode: "reset" })} | |
| 595 | + disabled={catalogLoading} | |
| 596 | + > | |
| 597 | + {catalogLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Search"} | |
| 598 | + </Button> | |
| 599 | + </div> | |
| 600 | + | |
| 601 | + <p className="text-[11px] text-muted-foreground"> | |
| 602 | + Browse API collections and add them as tool sources. | |
| 603 | + {catalogTotalCount !== null ? ` Found ${catalogTotalCount.toLocaleString()} total.` : ""} | |
| 604 | + </p> | |
| 605 | + | |
| 606 | + <Separator /> | |
| 607 | + | |
| 608 | + <div | |
| 609 | + className="max-h-80 overflow-y-auto overflow-x-hidden space-y-1 pr-1" | |
| 610 | + onScroll={(event) => { | |
| 611 | + const target = event.currentTarget; | |
| 612 | + const nearBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 120; | |
| 613 | + if (nearBottom) { | |
| 614 | + void loadCatalogPage({ mode: "next" }); | |
| 615 | + } | |
| 616 | + }} | |
| 505 | 617 | > |
| 506 | − <SelectTrigger className="h-8 text-xs bg-background"> | |
| 507 | − <SelectValue /> | |
| 508 | − </SelectTrigger> | |
| 509 | − <SelectContent> | |
| 510 | − <SelectItem value="mcp" className="text-xs"> | |
| 511 | − MCP Server | |
| 512 | − </SelectItem> | |
| 513 | − <SelectItem value="openapi" className="text-xs"> | |
| 514 | − OpenAPI Spec | |
| 515 | − </SelectItem> | |
| 516 | − <SelectItem value="graphql" className="text-xs"> | |
| 517 | − GraphQL | |
| 518 | − </SelectItem> | |
| 519 | − </SelectContent> | |
| 520 | − </Select> | |
| 521 | − </div> | |
| 522 | − <div className="space-y-1.5"> | |
| 523 | − <Label className="text-xs text-muted-foreground"> | |
| 524 | − {type === "mcp" ? "Endpoint URL" : type === "graphql" ? "GraphQL Endpoint" : "Spec URL"} | |
| 525 | − </Label> | |
| 526 | − <Input | |
| 527 | − value={endpoint} | |
| 528 | − onChange={(e) => handleEndpointChange(e.target.value)} | |
| 529 | − placeholder={ | |
| 530 | − type === "mcp" | |
| 531 | − ? "https://mcp-server.example.com/sse" | |
| 532 | − : type === "graphql" | |
| 533 | − ? "https://api.example.com/graphql" | |
| 534 | − : "https://api.example.com/openapi.json" | |
| 535 | − } | |
| 536 | − className="h-8 text-xs font-mono bg-background" | |
| 537 | − /> | |
| 538 | − </div> | |
| 539 | − <div className="space-y-1.5"> | |
| 540 | − <Label className="text-xs text-muted-foreground">Name</Label> | |
| 541 | − <Input | |
| 542 | − value={name} | |
| 543 | − onChange={(e) => handleNameChange(e.target.value)} | |
| 544 | − placeholder="e.g. my-service" | |
| 545 | − className="h-8 text-xs font-mono bg-background" | |
| 546 | − /> | |
| 618 | + <button | |
| 619 | + type="button" | |
| 620 | + onClick={() => setView("custom")} | |
| 621 | + className="w-full max-w-full overflow-hidden text-left px-3 py-2 rounded-md border border-border/70 bg-muted/40 hover:bg-accent/30 transition-colors" | |
| 622 | + > | |
| 623 | + <div className="flex items-center gap-2"> | |
| 624 | + <span className="inline-flex h-6 w-6 items-center justify-center rounded bg-muted"> | |
| 625 | + <Plus className="h-3.5 w-3.5 text-muted-foreground" /> | |
| 626 | + </span> | |
| 627 | + <div className="min-w-0 w-0 flex-1 overflow-hidden"> | |
| 628 | + <p className="text-xs font-medium">Add custom source</p> | |
| 629 | + <p className="text-[10px] text-muted-foreground">MCP, OpenAPI, or GraphQL endpoint</p> | |
| 630 | + </div> | |
| 631 | + </div> | |
| 632 | + </button> | |
| 633 | + | |
| 634 | + {catalogItems.map((item) => ( | |
| 635 | + <div | |
| 636 | + key={item.id} | |
| 637 | + className="w-full max-w-full overflow-hidden flex items-start gap-2 px-2 py-2 rounded-md border border-border/50" | |
| 638 | + > | |
| 639 | + {item.logoUrl && ( | |
| 640 | + <img | |
| 641 | + src={item.logoUrl} | |
| 642 | + alt="" | |
| 643 | + className="w-5 h-5 rounded shrink-0 mt-0.5 object-contain" | |
| 644 | + loading="lazy" | |
| 645 | + /> | |
| 646 | + )} | |
| 647 | + <div className="flex-1 min-w-0 w-0 overflow-hidden"> | |
| 648 | + <p className="text-xs font-medium truncate">{item.name}</p> | |
| 649 | + <p className="text-[10px] text-muted-foreground truncate"> | |
| 650 | + {item.providerName} | |
| 651 | + {item.version ? ` · v${item.version}` : ""} | |
| 652 | + </p> | |
| 653 | + {item.summary && ( | |
| 654 | + <div | |
| 655 | + className="mt-0.5 w-full min-w-0 max-w-full overflow-hidden text-[10px] text-muted-foreground/90 leading-relaxed break-words [overflow-wrap:anywhere] [&_*]:max-w-full [&_*]:min-w-0 [&_*]:break-words [&_a]:break-all [&_a]:whitespace-normal [&_code]:break-all [&_code]:whitespace-pre-wrap [&_p]:m-0 [&_pre]:max-w-full [&_pre]:overflow-x-hidden [&_pre]:whitespace-pre-wrap [&_pre]:break-words line-clamp-2" | |
| 656 | + style={{ overflowWrap: "anywhere", wordBreak: "break-word" }} | |
| 657 | + > | |
| 658 | + <Streamdown controls={false}>{item.summary}</Streamdown> | |
| 659 | + </div> | |
| 660 | + )} | |
| 661 | + </div> | |
| 662 | + <div className="flex items-center gap-1 shrink-0"> | |
| 663 | + <Button | |
| 664 | + type="button" | |
| 665 | + size="sm" | |
| 666 | + className="h-7 px-2 text-[11px]" | |
| 667 | + onClick={() => void handleCatalogAdd(item)} | |
| 668 | + disabled={Boolean(addingCatalogId)} | |
| 669 | + > | |
| 670 | + {addingCatalogId === item.id ? "Adding..." : "Add"} | |
| 671 | + </Button> | |
| 672 | + </div> | |
| 673 | + </div> | |
| 674 | + ))} | |
| 675 | + | |
| 676 | + {catalogLoadingMore && ( | |
| 677 | + <div className="flex items-center justify-center py-2 text-[11px] text-muted-foreground"> | |
| 678 | + <Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" /> | |
| 679 | + Loading more... | |
| 680 | + </div> | |
| 681 | + )} | |
| 682 | + | |
| 683 | + {!catalogLoading && !catalogLoadingMore && catalogItems.length === 0 && !catalogError && ( | |
| 684 | + <p className="text-[11px] text-muted-foreground px-1 py-1"> | |
| 685 | + No collections found for this query. | |
| 686 | + </p> | |
| 687 | + )} | |
| 688 | + | |
| 689 | + {catalogError && ( | |
| 690 | + <p className="text-[11px] text-terminal-red px-1 py-1"> | |
| 691 | + {catalogError} | |
| 692 | + </p> | |
| 693 | + )} | |
| 694 | + | |
| 695 | + {!catalogHasMore && catalogItems.length > 0 && ( | |
| 696 | + <p className="text-[10px] text-muted-foreground/70 px-1 py-1"> | |
| 697 | + End of results. | |
| 698 | + </p> | |
| 699 | + )} | |
| 700 | + </div> | |
| 547 | 701 | </div> |
| 548 | − {type === "openapi" && ( | |
| 702 | + ) : ( | |
| 703 | + <div className="space-y-3"> | |
| 704 | + <Button | |
| 705 | + type="button" | |
| 706 | + variant="ghost" | |
| 707 | + size="sm" | |
| 708 | + className="h-7 px-2 text-xs" | |
| 709 | + onClick={() => setView("catalog")} | |
| 710 | + > | |
| 711 | + <ChevronRight className="h-3.5 w-3.5 mr-1 rotate-180" /> | |
| 712 | + Back to API list | |
| 713 | + </Button> | |
| 714 | + | |
| 715 | + <div className="space-y-1.5"> | |
| 716 | + <Label className="text-xs text-muted-foreground">Type</Label> | |
| 717 | + <Select | |
| 718 | + value={type} | |
| 719 | + onValueChange={(value) => setType(value as "mcp" | "openapi" | "graphql")} | |
| 720 | + > | |
| 721 | + <SelectTrigger className="h-8 text-xs bg-background"> | |
| 722 | + <SelectValue /> | |
| 723 | + </SelectTrigger> | |
| 724 | + <SelectContent> | |
| 725 | + <SelectItem value="mcp" className="text-xs">MCP Server</SelectItem> | |
| 726 | + <SelectItem value="openapi" className="text-xs">OpenAPI Spec</SelectItem> | |
| 727 | + <SelectItem value="graphql" className="text-xs">GraphQL</SelectItem> | |
| 728 | + </SelectContent> | |
| 729 | + </Select> | |
| 730 | + </div> | |
| 731 | + | |
| 549 | 732 | <div className="space-y-1.5"> |
| 550 | 733 | <Label className="text-xs text-muted-foreground"> |
| 551 | − Base URL (optional) | |
| 734 | + {type === "mcp" ? "Endpoint URL" : type === "graphql" ? "GraphQL Endpoint" : "Spec URL"} | |
| 552 | 735 | </Label> |
| 553 | 736 | <Input |
| 554 | − value={baseUrl} | |
| 555 | − onChange={(e) => setBaseUrl(e.target.value)} | |
| 556 | − placeholder="https://api.example.com" | |
| 737 | + value={endpoint} | |
| 738 | + onChange={(event) => handleEndpointChange(event.target.value)} | |
| 739 | + placeholder={ | |
| 740 | + type === "mcp" | |
| 741 | + ? "https://mcp-server.example.com/sse" | |
| 742 | + : type === "graphql" | |
| 743 | + ? "https://api.example.com/graphql" | |
| 744 | + : "https://api.example.com/openapi.json" | |
| 745 | + } | |
| 557 | 746 | className="h-8 text-xs font-mono bg-background" |
| 558 | 747 | /> |
| 559 | 748 | </div> |
| 560 | − )} | |
| 561 | − {type === "mcp" && ( | |
| 562 | − <> | |
| 563 | − <div className="space-y-1.5"> | |
| 564 | − <Label className="text-xs text-muted-foreground">Transport</Label> | |
| 565 | − <Select | |
| 566 | − value={mcpTransport} | |
| 567 | − onValueChange={(v) => setMcpTransport(v as "auto" | "streamable-http" | "sse")} | |
| 568 | − > | |
| 569 | − <SelectTrigger className="h-8 text-xs bg-background"> | |
| 570 | − <SelectValue /> | |
| 571 | − </SelectTrigger> | |
| 572 | − <SelectContent> | |
| 573 | − <SelectItem value="auto" className="text-xs"> | |
| 574 | − Auto (streamable, then SSE) | |
| 575 | − </SelectItem> | |
| 576 | − <SelectItem value="streamable-http" className="text-xs"> | |
| 577 | − Streamable HTTP | |
| 578 | − </SelectItem> | |
| 579 | − <SelectItem value="sse" className="text-xs"> | |
| 580 | − SSE | |
| 581 | − </SelectItem> | |
| 582 | − </SelectContent> | |
| 583 | − </Select> | |
| 584 | − </div> | |
| 749 | + | |
| 750 | + <div className="space-y-1.5"> | |
| 751 | + <Label className="text-xs text-muted-foreground">Name</Label> | |
| 752 | + <Input | |
| 753 | + value={name} | |
| 754 | + onChange={(event) => handleNameChange(event.target.value)} | |
| 755 | + placeholder="e.g. my-service" | |
| 756 | + className="h-8 text-xs font-mono bg-background" | |
| 757 | + /> | |
| 758 | + </div> | |
| 759 | + | |
| 760 | + {type === "openapi" && ( | |
| 585 | 761 | <div className="space-y-1.5"> |
| 586 | − <Label className="text-xs text-muted-foreground"> | |
| 587 | − Anon actor query key (optional) | |
| 588 | − </Label> | |
| 762 | + <Label className="text-xs text-muted-foreground">Base URL (optional)</Label> | |
| 589 | 763 | <Input |
| 590 | − value={mcpActorQueryParamKey} | |
| 591 | − onChange={(e) => setMcpActorQueryParamKey(e.target.value)} | |
| 592 | − placeholder="userId" | |
| 764 | + value={baseUrl} | |
| 765 | + onChange={(event) => setBaseUrl(event.target.value)} | |
| 766 | + placeholder="https://api.example.com" | |
| 593 | 767 | className="h-8 text-xs font-mono bg-background" |
| 594 | 768 | /> |
| 595 | 769 | </div> |
| 596 | − </> | |
| 597 | − )} | |
| 598 | − <Button | |
| 599 | − onClick={handleCustomSubmit} | |
| 600 | − disabled={submitting || !name.trim() || !endpoint.trim()} | |
| 601 | − className="w-full h-9" | |
| 602 | − size="sm" | |
| 603 | − > | |
| 604 | − {submitting ? "Adding..." : "Add Source"} | |
| 605 | − </Button> | |
| 606 | − </div> | |
| 770 | + )} | |
| 607 | 771 | |
| 608 | − {/* Collapsible presets */} | |
| 609 | − <Separator /> | |
| 610 | − <Collapsible open={presetsOpen} onOpenChange={setPresetsOpen}> | |
| 611 | − <CollapsibleTrigger className="flex items-center gap-2 w-full py-1 text-xs text-muted-foreground hover:text-foreground transition-colors group"> | |
| 612 | − <ChevronRight | |
| 613 | − className={cn( | |
| 614 | − "h-3.5 w-3.5 transition-transform duration-200", | |
| 615 | − presetsOpen && "rotate-90", | |
| 616 | − )} | |
| 617 | − /> | |
| 618 | − <span>Quick add from catalog</span> | |
| 619 | − <span className="text-[10px] font-mono text-muted-foreground/60"> | |
| 620 | − {API_PRESETS.length} | |
| 621 | − </span> | |
| 622 | − </CollapsibleTrigger> | |
| 623 | − <CollapsibleContent> | |
| 624 | − <div className="pt-3 space-y-1"> | |
| 625 | − {API_PRESETS.map((preset) => { | |
| 626 | − const alreadyAdded = existingSourceNames.has(preset.name); | |
| 627 | − return ( | |
| 628 | − <button | |
| 629 | − key={preset.name} | |
| 630 | − onClick={() => !alreadyAdded && handlePresetAdd(preset)} | |
| 631 | − disabled={alreadyAdded || addingPreset !== null} | |
| 632 | − className={cn( | |
| 633 | − "flex items-center gap-3 w-full px-3 py-2 rounded-md text-left transition-colors", | |
| 634 | − alreadyAdded | |
| 635 | − ? "opacity-50 cursor-default" | |
| 636 | − : "hover:bg-accent/30 cursor-pointer", | |
| 637 | − )} | |
| 772 | + {type === "mcp" && ( | |
| 773 | + <> | |
| 774 | + <div className="space-y-1.5"> | |
| 775 | + <Label className="text-xs text-muted-foreground">Transport</Label> | |
| 776 | + <Select | |
| 777 | + value={mcpTransport} | |
| 778 | + onValueChange={(value) => setMcpTransport(value as "auto" | "streamable-http" | "sse")} | |
| 638 | 779 | > |
| 639 | − <span className="flex items-center justify-center h-6 w-6 rounded bg-muted shrink-0 overflow-hidden"> | |
| 640 | − {(() => { | |
| 641 | − const favicon = getFaviconUrl(preset); | |
| 642 | − return favicon ? ( | |
| 643 | − <img | |
| 644 | − src={favicon} | |
| 645 | − alt="" | |
| 646 | − width={16} | |
| 647 | − height={16} | |
| 648 | − className="w-4 h-4" | |
| 649 | − loading="lazy" | |
| 650 | − /> | |
| 651 | − ) : ( | |
| 652 | − <span className="text-[9px] font-bold font-mono text-muted-foreground"> | |
| 653 | − {preset.name.slice(0, 2).toUpperCase()} | |
| 654 | − </span> | |
| 655 | − ); | |
| 656 | − })()} | |
| 657 | − </span> | |
| 658 | − <div className="flex-1 min-w-0"> | |
| 659 | − <span className="text-xs font-medium text-foreground"> | |
| 660 | − {preset.label} | |
| 661 | − </span> | |
| 662 | − <span className="text-[11px] text-muted-foreground ml-2"> | |
| 663 | − {preset.description} | |
| 664 | − </span> | |
| 665 | − </div> | |
| 666 | − </button> | |
| 667 | − ); | |
| 668 | − })} | |
| 669 | − </div> | |
| 670 | − </CollapsibleContent> | |
| 671 | − </Collapsible> | |
| 780 | + <SelectTrigger className="h-8 text-xs bg-background"> | |
| 781 | + <SelectValue /> | |
| 782 | + </SelectTrigger> | |
| 783 | + <SelectContent> | |
| 784 | + <SelectItem value="auto" className="text-xs">Auto (streamable, then SSE)</SelectItem> | |
| 785 | + <SelectItem value="streamable-http" className="text-xs">Streamable HTTP</SelectItem> | |
| 786 | + <SelectItem value="sse" className="text-xs">SSE</SelectItem> | |
| 787 | + </SelectContent> | |
| 788 | + </Select> | |
| 789 | + </div> | |
| 790 | + | |
| 791 | + <div className="space-y-1.5"> | |
| 792 | + <Label className="text-xs text-muted-foreground">Anon actor query key (optional)</Label> | |
| 793 | + <Input | |
| 794 | + value={mcpActorQueryParamKey} | |
| 795 | + onChange={(event) => setMcpActorQueryParamKey(event.target.value)} | |
| 796 | + placeholder="userId" | |
| 797 | + className="h-8 text-xs font-mono bg-background" | |
| 798 | + /> | |
| 799 | + </div> | |
| 800 | + </> | |
| 801 | + )} | |
| 802 | + | |
| 803 | + <Button | |
| 804 | + onClick={handleCustomSubmit} | |
| 805 | + disabled={submitting || !name.trim() || !endpoint.trim()} | |
| 806 | + className="w-full h-9" | |
| 807 | + size="sm" | |
| 808 | + > | |
| 809 | + {submitting ? "Adding..." : "Add Source"} | |
| 810 | + </Button> | |
| 811 | + </div> | |
| 812 | + )} | |
| 672 | 813 | </div> |
| 673 | 814 | </DialogContent> |
| 674 | 815 | </Dialog> |
| @@ -918,11 +1059,7 @@ function SourceCard({ · 243 unmodified lines | ||
| 918 | 1059 | )} |
| 919 | 1060 | </div> |
| 920 | 1061 | <span className="text-[11px] text-muted-foreground font-mono truncate block"> |
| 921 | − {source.type === "mcp" | |
| 922 | − ? (source.config.url as string) | |
| 923 | − : source.type === "graphql" | |
| 924 | − ? (source.config.endpoint as string) | |
| 925 | − : (source.config.spec as string)} | |
| 1062 | + {sourceEndpointLabel(source)} | |
| 926 | 1063 | </span> |
| 927 | 1064 | {source.type === "openapi" && quality && ( |
| 928 | 1065 | <span className="text-[10px] text-muted-foreground/90 font-mono truncate block mt-0.5"> |
| @@ -5,12 +5,17 @@ · 4 unmodified lines | ||
| 5 | 5 | "": { |
| 6 | 6 | "name": "executor", |
| 7 | 7 | "dependencies": { |
| 8 | + "better-result": "^2.7.0", | |
| 8 | 9 | "convex": "^1.31.7", |
| 9 | 10 | "graphql": "^16.12.0", |
| 10 | 11 | }, |
| 11 | 12 | }, |
| 12 | 13 | }, |
| 13 | 14 | "packages": { |
| 15 | + "@clack/core": ["@clack/core@0.5.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow=="], | |
| 16 | + | |
| 17 | + "@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="], | |
| 18 | + | |
| 14 | 19 | "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="], |
| 15 | 20 | |
| 16 | 21 | "@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="], |
| @@ -63,12 +68,18 @@ · 46 unmodified lines | ||
| 63 | 68 | |
| 64 | 69 | "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="], |
| 65 | 70 | |
| 71 | + "better-result": ["better-result@2.7.0", "", { "dependencies": { "@clack/prompts": "^0.11.0" }, "bin": { "better-result": "bin/cli.mjs" } }, "sha512-7zrmXjAK8u8Z6SOe4R65XObOR5X+Y2I/VVku3t5cPOGQ8/WsBcfFmfnIPiEl5EBMDOzPHRwbiPbMtQBKYdw7RA=="], | |
| 72 | + | |
| 66 | 73 | "convex": ["convex@1.31.7", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-PtNMe1mAIOvA8Yz100QTOaIdgt2rIuWqencVXrb4McdhxBHZ8IJ1eXTnrgCC9HydyilGT1pOn+KNqT14mqn9fQ=="], |
| 67 | 74 | |
| 68 | 75 | "esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], |
| 69 | 76 | |
| 70 | 77 | "graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="], |
| 71 | 78 | |
| 79 | + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], | |
| 80 | + | |
| 72 | 81 | "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], |
| 82 | + | |
| 83 | + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], | |
| 73 | 84 | } |
| 74 | 85 | } |
| @@ -276,6 +276,47 @@ export declare const api: { · 275 unmodified lines | ||
| 276 | 276 | any |
| 277 | 277 | >; |
| 278 | 278 | }; |
| 279 | + runtimeCallbacks: { | |
| 280 | + appendOutput: FunctionReference< | |
| 281 | + "mutation", | |
| 282 | + "public", | |
| 283 | + { | |
| 284 | + internalSecret: string; | |
| 285 | + line: string; | |
| 286 | + runId: string; | |
| 287 | + stream: "stdout" | "stderr"; | |
| 288 | + timestamp?: number; | |
| 289 | + }, | |
| 290 | + any | |
| 291 | + >; | |
| 292 | + completeRun: FunctionReference< | |
| 293 | + "mutation", | |
| 294 | + "public", | |
| 295 | + { | |
| 296 | + durationMs?: number; | |
| 297 | + error?: string; | |
| 298 | + exitCode?: number; | |
| 299 | + internalSecret: string; | |
| 300 | + runId: string; | |
| 301 | + status: "completed" | "failed" | "timed_out" | "denied"; | |
| 302 | + stderr?: string; | |
| 303 | + stdout?: string; | |
| 304 | + }, | |
| 305 | + any | |
| 306 | + >; | |
| 307 | + handleToolCall: FunctionReference< | |
| 308 | + "action", | |
| 309 | + "public", | |
| 310 | + { | |
| 311 | + callId: string; | |
| 312 | + input?: any; | |
| 313 | + internalSecret: string; | |
| 314 | + runId: string; | |
| 315 | + toolPath: string; | |
| 316 | + }, | |
| 317 | + any | |
| 318 | + >; | |
| 319 | + }; | |
| 279 | 320 | workspace: { |
| 280 | 321 | bootstrapAnonymousSession: FunctionReference< |
| 281 | 322 | "mutation", |
| @@ -520,6 +561,18 @@ export declare const internal: { · 238 unmodified lines | ||
| 520 | 561 | { sourceId: string; workspaceId: Id<"workspaces"> }, |
| 521 | 562 | any |
| 522 | 563 | >; |
| 564 | + finishToolCall: FunctionReference< | |
| 565 | + "mutation", | |
| 566 | + "internal", | |
| 567 | + { | |
| 568 | + callId: string; | |
| 569 | + error?: string; | |
| 570 | + output?: any; | |
| 571 | + status: "completed" | "failed" | "denied"; | |
| 572 | + taskId: string; | |
| 573 | + }, | |
| 574 | + any | |
| 575 | + >; | |
| 523 | 576 | getActiveAnonymousOauthSigningKey: FunctionReference< |
| 524 | 577 | "query", |
| 525 | 578 | "internal", |
| @@ -551,6 +604,12 @@ export declare const internal: { · 25 unmodified lines | ||
| 551 | 604 | { taskId: string; workspaceId: Id<"workspaces"> }, |
| 552 | 605 | any |
| 553 | 606 | >; |
| 607 | + getToolCall: FunctionReference< | |
| 608 | + "query", | |
| 609 | + "internal", | |
| 610 | + { callId: string; taskId: string }, | |
| 611 | + any | |
| 612 | + >; | |
| 554 | 613 | listAccessPolicies: FunctionReference< |
| 555 | 614 | "query", |
| 556 | 615 | "internal", |
| @@ -598,6 +657,12 @@ export declare const internal: { · 41 unmodified lines | ||
| 598 | 657 | { workspaceId: Id<"workspaces"> }, |
| 599 | 658 | any |
| 600 | 659 | >; |
| 660 | + listToolCalls: FunctionReference< | |
| 661 | + "query", | |
| 662 | + "internal", | |
| 663 | + { taskId: string }, | |
| 664 | + any | |
| 665 | + >; | |
| 601 | 666 | listToolSources: FunctionReference< |
| 602 | 667 | "query", |
| 603 | 668 | "internal", |
| @@ -651,6 +716,12 @@ export declare const internal: { · 47 unmodified lines | ||
| 651 | 716 | }, |
| 652 | 717 | any |
| 653 | 718 | >; |
| 719 | + setToolCallPendingApproval: FunctionReference< | |
| 720 | + "mutation", | |
| 721 | + "internal", | |
| 722 | + { approvalId: string; callId: string; taskId: string }, | |
| 723 | + any | |
| 724 | + >; | |
| 654 | 725 | storeAnonymousOauthSigningKey: FunctionReference< |
| 655 | 726 | "mutation", |
| 656 | 727 | "internal", |
| @@ -690,6 +761,18 @@ export declare const internal: { · 33 unmodified lines | ||
| 690 | 761 | }, |
| 691 | 762 | any |
| 692 | 763 | >; |
| 764 | + upsertToolCallRequested: FunctionReference< | |
| 765 | + "mutation", | |
| 766 | + "internal", | |
| 767 | + { | |
| 768 | + callId: string; | |
| 769 | + input?: any; | |
| 770 | + taskId: string; | |
| 771 | + toolPath: string; | |
| 772 | + workspaceId: Id<"workspaces">; | |
| 773 | + }, | |
| 774 | + any | |
| 775 | + >; | |
| 693 | 776 | upsertToolSource: FunctionReference< |
| 694 | 777 | "mutation", |
| 695 | 778 | "internal", |
| @@ -716,6 +799,20 @@ export declare const internal: { · 20 unmodified lines | ||
| 716 | 799 | }, |
| 717 | 800 | any |
| 718 | 801 | >; |
| 802 | + completeRuntimeRun: FunctionReference< | |
| 803 | + "mutation", | |
| 804 | + "internal", | |
| 805 | + { | |
| 806 | + durationMs?: number; | |
| 807 | + error?: string; | |
| 808 | + exitCode?: number; | |
| 809 | + runId: string; | |
| 810 | + status: "completed" | "failed" | "timed_out" | "denied"; | |
| 811 | + stderr?: string; | |
| 812 | + stdout?: string; | |
| 813 | + }, | |
| 814 | + any | |
| 815 | + >; | |
| 719 | 816 | createTaskInternal: FunctionReference< |
| 720 | 817 | "mutation", |
| 721 | 818 | "internal", |
| @@ -16,6 +16,11 @@ const completedTaskStatusValidator = v.union( · 15 unmodified lines | ||
| 16 | 16 | v.literal("denied"), |
| 17 | 17 | ); |
| 18 | 18 | const approvalStatusValidator = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied")); |
| 19 | +const terminalToolCallStatusValidator = v.union( | |
| 20 | + v.literal("completed"), | |
| 21 | + v.literal("failed"), | |
| 22 | + v.literal("denied"), | |
| 23 | +); | |
| 19 | 24 | const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); |
| 20 | 25 | const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); |
| 21 | 26 | const credentialProviderValidator = v.union( |
| @@ -127,6 +132,23 @@ function mapApproval(doc: Doc<"approvals">) { · 105 unmodified lines | ||
| 127 | 132 | }; |
| 128 | 133 | } |
| 129 | 134 | |
| 135 | +function mapToolCall(doc: Doc<"toolCalls">) { | |
| 136 | + return { | |
| 137 | + taskId: doc.taskId, | |
| 138 | + callId: doc.callId, | |
| 139 | + workspaceId: doc.workspaceId, | |
| 140 | + toolPath: doc.toolPath, | |
| 141 | + input: doc.input, | |
| 142 | + status: doc.status, | |
| 143 | + approvalId: doc.approvalId, | |
| 144 | + output: doc.output, | |
| 145 | + error: doc.error, | |
| 146 | + createdAt: doc.createdAt, | |
| 147 | + updatedAt: doc.updatedAt, | |
| 148 | + completedAt: doc.completedAt, | |
| 149 | + }; | |
| 150 | +} | |
| 151 | + | |
| 130 | 152 | function mapPolicy(doc: Doc<"accessPolicies">) { |
| 131 | 153 | return { |
| 132 | 154 | id: doc.policyId, |
| @@ -309,6 +331,17 @@ async function getApprovalDoc(ctx: { db: QueryCtx["db"] }, approvalId: string) { · 176 unmodified lines | ||
| 309 | 331 | .unique(); |
| 310 | 332 | } |
| 311 | 333 | |
| 334 | +async function getToolCallDoc( | |
| 335 | + ctx: { db: QueryCtx["db"] }, | |
| 336 | + taskId: string, | |
| 337 | + callId: string, | |
| 338 | +) { | |
| 339 | + return await ctx.db | |
| 340 | + .query("toolCalls") | |
| 341 | + .withIndex("by_task_call", (q) => q.eq("taskId", taskId).eq("callId", callId)) | |
| 342 | + .unique(); | |
| 343 | +} | |
| 344 | + | |
| 312 | 345 | export const createTask = internalMutation({ |
| 313 | 346 | args: { |
| 314 | 347 | id: v.string(), |
| @@ -391,6 +424,11 @@ export const listRuntimeTargets = internalQuery({ · 76 unmodified lines | ||
| 391 | 424 | label: "Local JS Runtime", |
| 392 | 425 | description: "Runs generated code in-process using Bun", |
| 393 | 426 | }, |
| 427 | + { | |
| 428 | + id: "cloudflare-worker-loader", | |
| 429 | + label: "Cloudflare Worker Loader", | |
| 430 | + description: "Runs generated code in a Cloudflare Worker", | |
| 431 | + } | |
| 394 | 432 | ]; |
| 395 | 433 | }, |
| 396 | 434 | }); |
| @@ -441,6 +479,10 @@ export const markTaskFinished = internalMutation({ · 44 unmodified lines | ||
| 441 | 479 | return null; |
| 442 | 480 | } |
| 443 | 481 | |
| 482 | + if (doc.status === "completed" || doc.status === "failed" || doc.status === "timed_out" || doc.status === "denied") { | |
| 483 | + return mapTask(doc); | |
| 484 | + } | |
| 485 | + | |
| 444 | 486 | const now = Date.now(); |
| 445 | 487 | await ctx.db.patch(doc._id, { |
| 446 | 488 | status: args.status, |
| @@ -607,6 +649,132 @@ export const getApprovalInWorkspace = internalQuery({ · 160 unmodified lines | ||
| 607 | 649 | }, |
| 608 | 650 | }); |
| 609 | 651 | |
| 652 | +export const upsertToolCallRequested = internalMutation({ | |
| 653 | + args: { | |
| 654 | + taskId: v.string(), | |
| 655 | + callId: v.string(), | |
| 656 | + workspaceId: v.id("workspaces"), | |
| 657 | + toolPath: v.string(), | |
| 658 | + input: v.optional(v.any()), | |
| 659 | + }, | |
| 660 | + handler: async (ctx, args) => { | |
| 661 | + const existing = await getToolCallDoc(ctx, args.taskId, args.callId); | |
| 662 | + if (existing) { | |
| 663 | + return mapToolCall(existing); | |
| 664 | + } | |
| 665 | + | |
| 666 | + const now = Date.now(); | |
| 667 | + await ctx.db.insert("toolCalls", { | |
| 668 | + taskId: args.taskId, | |
| 669 | + callId: args.callId, | |
| 670 | + workspaceId: args.workspaceId, | |
| 671 | + toolPath: args.toolPath, | |
| 672 | + input: args.input ?? {}, | |
| 673 | + status: "requested", | |
| 674 | + createdAt: now, | |
| 675 | + updatedAt: now, | |
| 676 | + }); | |
| 677 | + | |
| 678 | + const created = await getToolCallDoc(ctx, args.taskId, args.callId); | |
| 679 | + if (!created) { | |
| 680 | + throw new Error(`Failed to create tool call ${args.taskId}/${args.callId}`); | |
| 681 | + } | |
| 682 | + return mapToolCall(created); | |
| 683 | + }, | |
| 684 | +}); | |
| 685 | + | |
| 686 | +export const getToolCall = internalQuery({ | |
| 687 | + args: { | |
| 688 | + taskId: v.string(), | |
| 689 | + callId: v.string(), | |
| 690 | + }, | |
| 691 | + handler: async (ctx, args) => { | |
| 692 | + const doc = await getToolCallDoc(ctx, args.taskId, args.callId); | |
| 693 | + return doc ? mapToolCall(doc) : null; | |
| 694 | + }, | |
| 695 | +}); | |
| 696 | + | |
| 697 | +export const setToolCallPendingApproval = internalMutation({ | |
| 698 | + args: { | |
| 699 | + taskId: v.string(), | |
| 700 | + callId: v.string(), | |
| 701 | + approvalId: v.string(), | |
| 702 | + }, | |
| 703 | + handler: async (ctx, args) => { | |
| 704 | + const doc = await getToolCallDoc(ctx, args.taskId, args.callId); | |
| 705 | + if (!doc) { | |
| 706 | + throw new Error(`Tool call not found: ${args.taskId}/${args.callId}`); | |
| 707 | + } | |
| 708 | + | |
| 709 | + if (doc.status === "completed" || doc.status === "failed" || doc.status === "denied") { | |
| 710 | + return mapToolCall(doc); | |
| 711 | + } | |
| 712 | + | |
| 713 | + const now = Date.now(); | |
| 714 | + await ctx.db.patch(doc._id, { | |
| 715 | + status: "pending_approval", | |
| 716 | + approvalId: args.approvalId, | |
| 717 | + updatedAt: now, | |
| 718 | + }); | |
| 719 | + | |
| 720 | + const updated = await getToolCallDoc(ctx, args.taskId, args.callId); | |
| 721 | + if (!updated) { | |
| 722 | + throw new Error(`Failed to read tool call ${args.taskId}/${args.callId}`); | |
| 723 | + } | |
| 724 | + return mapToolCall(updated); | |
| 725 | + }, | |
| 726 | +}); | |
| 727 | + | |
| 728 | +export const finishToolCall = internalMutation({ | |
| 729 | + args: { | |
| 730 | + taskId: v.string(), | |
| 731 | + callId: v.string(), | |
| 732 | + status: terminalToolCallStatusValidator, | |
| 733 | + output: v.optional(v.any()), | |
| 734 | + error: v.optional(v.string()), | |
| 735 | + }, | |
| 736 | + handler: async (ctx, args) => { | |
| 737 | + const doc = await getToolCallDoc(ctx, args.taskId, args.callId); | |
| 738 | + if (!doc) { | |
| 739 | + throw new Error(`Tool call not found: ${args.taskId}/${args.callId}`); | |
| 740 | + } | |
| 741 | + | |
| 742 | + if (doc.status === "completed" || doc.status === "failed" || doc.status === "denied") { | |
| 743 | + return mapToolCall(doc); | |
| 744 | + } | |
| 745 | + | |
| 746 | + const now = Date.now(); | |
| 747 | + await ctx.db.patch(doc._id, { | |
| 748 | + status: args.status, | |
| 749 | + output: args.output, | |
| 750 | + error: args.error, | |
| 751 | + updatedAt: now, | |
| 752 | + completedAt: now, | |
| 753 | + }); | |
| 754 | + | |
| 755 | + const updated = await getToolCallDoc(ctx, args.taskId, args.callId); | |
| 756 | + if (!updated) { | |
| 757 | + throw new Error(`Failed to read tool call ${args.taskId}/${args.callId}`); | |
| 758 | + } | |
| 759 | + return mapToolCall(updated); | |
| 760 | + }, | |
| 761 | +}); | |
| 762 | + | |
| 763 | +export const listToolCalls = internalQuery({ | |
| 764 | + args: { | |
| 765 | + taskId: v.string(), | |
| 766 | + }, | |
| 767 | + handler: async (ctx, args) => { | |
| 768 | + const docs = await ctx.db | |
| 769 | + .query("toolCalls") | |
| 770 | + .withIndex("by_task_created", (q) => q.eq("taskId", args.taskId)) | |
| 771 | + .order("asc") | |
| 772 | + .collect(); | |
| 773 | + | |
| 774 | + return docs.map(mapToolCall); | |
| 775 | + }, | |
| 776 | +}); | |
| 777 | + | |
| 610 | 778 | export const bootstrapAnonymousSession = internalMutation({ |
| 611 | 779 | args: { sessionId: v.optional(v.string()) }, |
| 612 | 780 | handler: async (ctx, args) => { |
| @@ -5,6 +5,7 @@ import type { MutationCtx } from "./_generated/server"; · 4 unmodified lines | ||
| 5 | 5 | import { internalMutation } from "./_generated/server"; |
| 6 | 6 | import { workspaceMutation } from "../lib/functionBuilders"; |
| 7 | 7 | import { actorIdForAccount } from "../lib/identity"; |
| 8 | +import { isKnownRuntimeId } from "../lib/runtimes/runtime_catalog"; | |
| 8 | 9 | import type { ApprovalRecord, TaskRecord } from "../lib/types"; |
| 9 | 10 | |
| 10 | 11 | const DEFAULT_TIMEOUT_MS = 300_000; |
| @@ -21,6 +22,14 @@ async function publishTaskEvent( · 10 unmodified lines | ||
| 21 | 22 | await ctx.runMutation(internal.database.createTaskEvent, input); |
| 22 | 23 | } |
| 23 | 24 | |
| 25 | +function terminalEventForStatus(status: "completed" | "failed" | "timed_out" | "denied"): | |
| 26 | + "task.completed" | "task.failed" | "task.timed_out" | "task.denied" { | |
| 27 | + if (status === "completed") return "task.completed"; | |
| 28 | + if (status === "timed_out") return "task.timed_out"; | |
| 29 | + if (status === "denied") return "task.denied"; | |
| 30 | + return "task.failed"; | |
| 31 | +} | |
| 32 | + | |
| 24 | 33 | async function createTaskRecord( |
| 25 | 34 | ctx: MutationCtx, |
| 26 | 35 | args: { |
| @@ -38,7 +47,7 @@ async function createTaskRecord( · 11 unmodified lines | ||
| 38 | 47 | } |
| 39 | 48 | |
| 40 | 49 | const runtimeId = args.runtimeId ?? "local-bun"; |
| 41 | − if (runtimeId !== "local-bun") { | |
| 50 | + if (!isKnownRuntimeId(runtimeId)) { | |
| 42 | 51 | throw new Error(`Unsupported runtime: ${runtimeId}`); |
| 43 | 52 | } |
| 44 | 53 | |
| @@ -235,3 +244,54 @@ export const appendRuntimeOutput = internalMutation({ · 190 unmodified lines | ||
| 235 | 244 | return { ok: true as const }; |
| 236 | 245 | }, |
| 237 | 246 | }); |
| 247 | + | |
| 248 | +export const completeRuntimeRun = internalMutation({ | |
| 249 | + args: { | |
| 250 | + runId: v.string(), | |
| 251 | + status: v.union(v.literal("completed"), v.literal("failed"), v.literal("timed_out"), v.literal("denied")), | |
| 252 | + stdout: v.optional(v.string()), | |
| 253 | + stderr: v.optional(v.string()), | |
| 254 | + exitCode: v.optional(v.number()), | |
| 255 | + error: v.optional(v.string()), | |
| 256 | + durationMs: v.optional(v.number()), | |
| 257 | + }, | |
| 258 | + handler: async (ctx, args) => { | |
| 259 | + const task = (await ctx.runQuery(internal.database.getTask, { taskId: args.runId })) as TaskRecord | null; | |
| 260 | + if (!task) { | |
| 261 | + return { ok: false as const, error: `Run not found: ${args.runId}` }; | |
| 262 | + } | |
| 263 | + | |
| 264 | + if (task.status === "completed" || task.status === "failed" || task.status === "timed_out" || task.status === "denied") { | |
| 265 | + return { ok: true as const, alreadyFinal: true as const, task }; | |
| 266 | + } | |
| 267 | + | |
| 268 | + const finished = await ctx.runMutation(internal.database.markTaskFinished, { | |
| 269 | + taskId: args.runId, | |
| 270 | + status: args.status, | |
| 271 | + stdout: args.stdout ?? "", | |
| 272 | + stderr: args.stderr ?? "", | |
| 273 | + exitCode: args.exitCode, | |
| 274 | + error: args.error, | |
| 275 | + }); | |
| 276 | + | |
| 277 | + if (!finished) { | |
| 278 | + return { ok: false as const, error: `Failed to mark run finished: ${args.runId}` }; | |
| 279 | + } | |
| 280 | + | |
| 281 | + await publishTaskEvent(ctx, { | |
| 282 | + taskId: args.runId, | |
| 283 | + eventName: "task", | |
| 284 | + type: terminalEventForStatus(args.status), | |
| 285 | + payload: { | |
| 286 | + taskId: args.runId, | |
| 287 | + status: finished.status, | |
| 288 | + exitCode: finished.exitCode, | |
| 289 | + durationMs: args.durationMs, | |
| 290 | + error: finished.error, | |
| 291 | + completedAt: finished.completedAt, | |
| 292 | + }, | |
| 293 | + }); | |
| 294 | + | |
| 295 | + return { ok: true as const, alreadyFinal: false as const, task: finished }; | |
| 296 | + }, | |
| 297 | +}); | |
| @@ -7,8 +7,15 @@ import { action, internalAction } from "./_generated/server"; · 6 unmodified lines | ||
| 7 | 7 | import type { ActionCtx } from "./_generated/server"; |
| 8 | 8 | import { InProcessExecutionAdapter } from "../lib/adapters/in_process_execution_adapter"; |
| 9 | 9 | import { resolveCredentialPayload } from "../lib/credential_providers"; |
| 10 | −import { APPROVAL_DENIED_PREFIX } from "../lib/execution_constants"; | |
| 10 | +import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../lib/execution_constants"; | |
| 11 | 11 | import { actorIdForAccount } from "../lib/identity"; |
| 12 | +import { dispatchCodeWithCloudflareWorkerLoader } from "../lib/runtimes/cloudflare_worker_loader_runtime"; | |
| 13 | +import { | |
| 14 | + CLOUDFLARE_WORKER_LOADER_RUNTIME_ID, | |
| 15 | + isCloudflareWorkerLoaderConfigured, | |
| 16 | + isKnownRuntimeId, | |
| 17 | + LOCAL_BUN_RUNTIME_ID, | |
| 18 | +} from "../lib/runtimes/runtime_catalog"; | |
| 12 | 19 | import { runCodeWithAdapter } from "../lib/runtimes/runtime_core"; |
| 13 | 20 | import { createDiscoverTool } from "../lib/tool_discovery"; |
| 14 | 21 | import { |
| @@ -34,6 +41,7 @@ import type { · 19 unmodified lines | ||
| 34 | 41 | ResolvedToolCredential, |
| 35 | 42 | TaskRecord, |
| 36 | 43 | ToolCallRequest, |
| 44 | + ToolCallRecord, | |
| 37 | 45 | ToolCallResult, |
| 38 | 46 | ToolCredentialSpec, |
| 39 | 47 | ToolDefinition, |
| @@ -263,10 +271,6 @@ function normalizeExternalToolSource(raw: { · 223 unmodified lines | ||
| 263 | 271 | return result; |
| 264 | 272 | } |
| 265 | 273 | |
| 266 | −async function sleep(ms: number): Promise<void> { | |
| 267 | − await new Promise((resolve) => setTimeout(resolve, ms)); | |
| 268 | −} | |
| 269 | − | |
| 270 | 274 | const baseTools = new Map<string, ToolDefinition>(DEFAULT_TOOLS.map((tool) => [tool.path, tool])); |
| 271 | 275 | interface DtsStorageEntry { |
| 272 | 276 | sourceKey: string; |
| @@ -293,21 +297,6 @@ async function publish( · 20 unmodified lines | ||
| 293 | 297 | }); |
| 294 | 298 | } |
| 295 | 299 | |
| 296 | −async function waitForApproval(ctx: ActionCtx, approvalId: string): Promise<"approved" | "denied"> { | |
| 297 | − while (true) { | |
| 298 | − const approval = await ctx.runQuery(internal.database.getApproval, { approvalId }); | |
| 299 | − if (!approval) { | |
| 300 | − throw new Error(`Approval ${approvalId} not found`); | |
| 301 | − } | |
| 302 | − | |
| 303 | − if (approval.status !== "pending") { | |
| 304 | − return approval.status as "approved" | "denied"; | |
| 305 | − } | |
| 306 | − | |
| 307 | − await sleep(500); | |
| 308 | − } | |
| 309 | −} | |
| 310 | − | |
| 311 | 300 | /** |
| 312 | 301 | * Load a prepared OpenAPI spec, using Convex file storage as a persistent cache. |
| 313 | 302 | * |
| @@ -840,6 +829,26 @@ function getGraphqlDecision( · 526 unmodified lines | ||
| 840 | 829 | |
| 841 | 830 | async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallRequest): Promise<unknown> { |
| 842 | 831 | const { toolPath, input, callId } = call; |
| 832 | + const persistedCall = (await ctx.runMutation(internal.database.upsertToolCallRequested, { | |
| 833 | + taskId: task.id, | |
| 834 | + callId, | |
| 835 | + workspaceId: task.workspaceId, | |
| 836 | + toolPath, | |
| 837 | + input, | |
| 838 | + })) as ToolCallRecord; | |
| 839 | + | |
| 840 | + if (persistedCall.status === "completed") { | |
| 841 | + return persistedCall.output; | |
| 842 | + } | |
| 843 | + | |
| 844 | + if (persistedCall.status === "failed") { | |
| 845 | + throw new Error(persistedCall.error ?? `Tool call failed: ${callId}`); | |
| 846 | + } | |
| 847 | + | |
| 848 | + if (persistedCall.status === "denied") { | |
| 849 | + throw new Error(`${APPROVAL_DENIED_PREFIX}${persistedCall.error ?? persistedCall.toolPath}`); | |
| 850 | + } | |
| 851 | + | |
| 843 | 852 | const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId }); |
| 844 | 853 | const typedPolicies = policies as AccessPolicyRecord[]; |
| 845 | 854 | |
| @@ -885,14 +894,23 @@ async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallReques · 39 unmodified lines | ||
| 885 | 894 | decision = getToolDecision(task, tool, typedPolicies); |
| 886 | 895 | } |
| 887 | 896 | |
| 897 | + const publishToolStarted = persistedCall.status === "requested"; | |
| 898 | + | |
| 888 | 899 | if (decision === "deny") { |
| 900 | + const deniedMessage = `${effectiveToolPath} (policy denied)`; | |
| 901 | + await ctx.runMutation(internal.database.finishToolCall, { | |
| 902 | + taskId: task.id, | |
| 903 | + callId, | |
| 904 | + status: "denied", | |
| 905 | + error: deniedMessage, | |
| 906 | + }); | |
| 889 | 907 | await publish(ctx, task.id, "task", "tool.call.denied", { |
| 890 | 908 | taskId: task.id, |
| 891 | 909 | callId, |
| 892 | 910 | toolPath: effectiveToolPath, |
| 893 | 911 | reason: "policy_deny", |
| 894 | 912 | }); |
| 895 | − throw new Error(`${APPROVAL_DENIED_PREFIX}${effectiveToolPath} (policy denied)`); | |
| 913 | + throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`); | |
| 896 | 914 | } |
| 897 | 915 | |
| 898 | 916 | let credential: ResolvedToolCredential | undefined; |
| @@ -904,40 +922,98 @@ async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallReques · 5 unmodified lines | ||
| 904 | 922 | credential = resolved; |
| 905 | 923 | } |
| 906 | 924 | |
| 907 | − await publish(ctx, task.id, "task", "tool.call.started", { | |
| 908 | − taskId: task.id, | |
| 909 | − callId, | |
| 910 | − toolPath: effectiveToolPath, | |
| 911 | − approval: decision === "require_approval" ? "required" : "auto", | |
| 912 | − input: asPayload(input), | |
| 913 | − }); | |
| 914 | − | |
| 915 | − if (decision === "require_approval") { | |
| 916 | − const approval = await ctx.runMutation(internal.database.createApproval, { | |
| 917 | − id: createApprovalId(), | |
| 925 | + if (publishToolStarted) { | |
| 926 | + await publish(ctx, task.id, "task", "tool.call.started", { | |
| 918 | 927 | taskId: task.id, |
| 928 | + callId, | |
| 919 | 929 | toolPath: effectiveToolPath, |
| 920 | − input, | |
| 930 | + approval: decision === "require_approval" ? "required" : "auto", | |
| 931 | + input: asPayload(input), | |
| 921 | 932 | }); |
| 933 | + } | |
| 922 | 934 | |
| 923 | − await publish(ctx, task.id, "approval", "approval.requested", { | |
| 924 | − approvalId: approval.id, | |
| 935 | + let approvalSatisfied = false; | |
| 936 | + if (persistedCall.approvalId) { | |
| 937 | + const existingApproval = await ctx.runQuery(internal.database.getApproval, { | |
| 938 | + approvalId: persistedCall.approvalId, | |
| 939 | + }); | |
| 940 | + if (!existingApproval) { | |
| 941 | + throw new Error(`Approval ${persistedCall.approvalId} not found for call ${callId}`); | |
| 942 | + } | |
| 943 | + | |
| 944 | + if (existingApproval.status === "pending") { | |
| 945 | + throw new Error(`${APPROVAL_PENDING_PREFIX}${existingApproval.id}`); | |
| 946 | + } | |
| 947 | + | |
| 948 | + if (existingApproval.status === "denied") { | |
| 949 | + const deniedMessage = `${effectiveToolPath} (${existingApproval.id})`; | |
| 950 | + await ctx.runMutation(internal.database.finishToolCall, { | |
| 951 | + taskId: task.id, | |
| 952 | + callId, | |
| 953 | + status: "denied", | |
| 954 | + error: deniedMessage, | |
| 955 | + }); | |
| 956 | + await publish(ctx, task.id, "task", "tool.call.denied", { | |
| 957 | + taskId: task.id, | |
| 958 | + callId, | |
| 959 | + toolPath: effectiveToolPath, | |
| 960 | + approvalId: existingApproval.id, | |
| 961 | + }); | |
| 962 | + throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`); | |
| 963 | + } | |
| 964 | + | |
| 965 | + approvalSatisfied = existingApproval.status === "approved"; | |
| 966 | + } | |
| 967 | + | |
| 968 | + if (decision === "require_approval" && !approvalSatisfied) { | |
| 969 | + const approvalId = persistedCall.approvalId ?? createApprovalId(); | |
| 970 | + let approval = await ctx.runQuery(internal.database.getApproval, { | |
| 971 | + approvalId, | |
| 972 | + }); | |
| 973 | + | |
| 974 | + if (!approval) { | |
| 975 | + approval = await ctx.runMutation(internal.database.createApproval, { | |
| 976 | + id: approvalId, | |
| 977 | + taskId: task.id, | |
| 978 | + toolPath: effectiveToolPath, | |
| 979 | + input, | |
| 980 | + }); | |
| 981 | + | |
| 982 | + await publish(ctx, task.id, "approval", "approval.requested", { | |
| 983 | + approvalId: approval.id, | |
| 984 | + taskId: task.id, | |
| 985 | + callId, | |
| 986 | + toolPath: approval.toolPath, | |
| 987 | + input: asPayload(approval.input), | |
| 988 | + createdAt: approval.createdAt, | |
| 989 | + }); | |
| 990 | + } | |
| 991 | + | |
| 992 | + await ctx.runMutation(internal.database.setToolCallPendingApproval, { | |
| 925 | 993 | taskId: task.id, |
| 926 | 994 | callId, |
| 927 | − toolPath: approval.toolPath, | |
| 928 | − input: asPayload(approval.input), | |
| 929 | − createdAt: approval.createdAt, | |
| 995 | + approvalId: approval.id, | |
| 930 | 996 | }); |
| 931 | 997 | |
| 932 | − const approvalDecision = await waitForApproval(ctx, approval.id); | |
| 933 | − if (approvalDecision === "denied") { | |
| 998 | + if (approval.status === "pending") { | |
| 999 | + throw new Error(`${APPROVAL_PENDING_PREFIX}${approval.id}`); | |
| 1000 | + } | |
| 1001 | + | |
| 1002 | + if (approval.status === "denied") { | |
| 1003 | + const deniedMessage = `${effectiveToolPath} (${approval.id})`; | |
| 1004 | + await ctx.runMutation(internal.database.finishToolCall, { | |
| 1005 | + taskId: task.id, | |
| 1006 | + callId, | |
| 1007 | + status: "denied", | |
| 1008 | + error: deniedMessage, | |
| 1009 | + }); | |
| 934 | 1010 | await publish(ctx, task.id, "task", "tool.call.denied", { |
| 935 | 1011 | taskId: task.id, |
| 936 | 1012 | callId, |
| 937 | 1013 | toolPath: effectiveToolPath, |
| 938 | 1014 | approvalId: approval.id, |
| 939 | 1015 | }); |
| 940 | − throw new Error(`${APPROVAL_DENIED_PREFIX}${effectiveToolPath} (${approval.id})`); | |
| 1016 | + throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`); | |
| 941 | 1017 | } |
| 942 | 1018 | } |
| 943 | 1019 | |
| @@ -951,6 +1027,12 @@ async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallReques · 7 unmodified lines | ||
| 951 | 1027 | isToolAllowed: (path) => isToolAllowedForTask(task, path, workspaceTools ?? baseTools, typedPolicies), |
| 952 | 1028 | }; |
| 953 | 1029 | const value = await tool.run(input, context); |
| 1030 | + await ctx.runMutation(internal.database.finishToolCall, { | |
| 1031 | + taskId: task.id, | |
| 1032 | + callId, | |
| 1033 | + status: "completed", | |
| 1034 | + output: value, | |
| 1035 | + }); | |
| 954 | 1036 | await publish(ctx, task.id, "task", "tool.call.completed", { |
| 955 | 1037 | taskId: task.id, |
| 956 | 1038 | callId, |
| @@ -960,6 +1042,12 @@ async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallReques · 3 unmodified lines | ||
| 960 | 1042 | return value; |
| 961 | 1043 | } catch (error) { |
| 962 | 1044 | const message = describeError(error); |
| 1045 | + await ctx.runMutation(internal.database.finishToolCall, { | |
| 1046 | + taskId: task.id, | |
| 1047 | + callId, | |
| 1048 | + status: "failed", | |
| 1049 | + error: message, | |
| 1050 | + }); | |
| 963 | 1051 | await publish(ctx, task.id, "task", "tool.call.failed", { |
| 964 | 1052 | taskId: task.id, |
| 965 | 1053 | callId, |
| @@ -1051,6 +1139,7 @@ export const handleExternalToolCall = internalAction({ · 85 unmodified lines | ||
| 1051 | 1139 | if (!task) { |
| 1052 | 1140 | return { |
| 1053 | 1141 | ok: false, |
| 1142 | + kind: "failed", | |
| 1054 | 1143 | error: `Run not found: ${args.runId}`, |
| 1055 | 1144 | }; |
| 1056 | 1145 | } |
| @@ -1065,16 +1154,27 @@ export const handleExternalToolCall = internalAction({ · 8 unmodified lines | ||
| 1065 | 1154 | return { ok: true, value }; |
| 1066 | 1155 | } catch (error) { |
| 1067 | 1156 | const message = describeError(error); |
| 1157 | + if (message.startsWith(APPROVAL_PENDING_PREFIX)) { | |
| 1158 | + const approvalId = message.replace(APPROVAL_PENDING_PREFIX, "").trim(); | |
| 1159 | + return { | |
| 1160 | + ok: false, | |
| 1161 | + kind: "pending", | |
| 1162 | + approvalId, | |
| 1163 | + retryAfterMs: 500, | |
| 1164 | + error: "Approval pending", | |
| 1165 | + }; | |
| 1166 | + } | |
| 1068 | 1167 | if (message.startsWith(APPROVAL_DENIED_PREFIX)) { |
| 1069 | 1168 | return { |
| 1070 | 1169 | ok: false, |
| 1071 | − denied: true, | |
| 1170 | + kind: "denied", | |
| 1072 | 1171 | error: message.replace(APPROVAL_DENIED_PREFIX, "").trim(), |
| 1073 | 1172 | }; |
| 1074 | 1173 | } |
| 1075 | 1174 | |
| 1076 | 1175 | return { |
| 1077 | 1176 | ok: false, |
| 1177 | + kind: "failed", | |
| 1078 | 1178 | error: message, |
| 1079 | 1179 | }; |
| 1080 | 1180 | } |
| @@ -1089,7 +1189,7 @@ export const runTask = internalAction({ · 8 unmodified lines | ||
| 1089 | 1189 | return null; |
| 1090 | 1190 | } |
| 1091 | 1191 | |
| 1092 | − if (task.runtimeId !== "local-bun") { | |
| 1192 | + if (!isKnownRuntimeId(task.runtimeId)) { | |
| 1093 | 1193 | const failed = await ctx.runMutation(internal.database.markTaskFinished, { |
| 1094 | 1194 | taskId: args.taskId, |
| 1095 | 1195 | status: "failed", |
| @@ -1108,6 +1208,25 @@ export const runTask = internalAction({ · 12 unmodified lines | ||
| 1108 | 1208 | return null; |
| 1109 | 1209 | } |
| 1110 | 1210 | |
| 1211 | + if (task.runtimeId === CLOUDFLARE_WORKER_LOADER_RUNTIME_ID && !isCloudflareWorkerLoaderConfigured()) { | |
| 1212 | + const failed = await ctx.runMutation(internal.database.markTaskFinished, { | |
| 1213 | + taskId: args.taskId, | |
| 1214 | + status: "failed", | |
| 1215 | + stdout: "", | |
| 1216 | + stderr: "", | |
| 1217 | + error: `Runtime is not configured: ${task.runtimeId}`, | |
| 1218 | + }); | |
| 1219 | + | |
| 1220 | + if (failed) { | |
| 1221 | + await publish(ctx, args.taskId, "task", "task.failed", { | |
| 1222 | + taskId: args.taskId, | |
| 1223 | + status: failed.status, | |
| 1224 | + error: failed.error, | |
| 1225 | + }); | |
| 1226 | + } | |
| 1227 | + return null; | |
| 1228 | + } | |
| 1229 | + | |
| 1111 | 1230 | try { |
| 1112 | 1231 | const running = (await ctx.runMutation(internal.database.markTaskRunning, { |
| 1113 | 1232 | taskId: args.taskId, |
| @@ -1122,27 +1241,65 @@ export const runTask = internalAction({ · 8 unmodified lines | ||
| 1122 | 1241 | startedAt: running.startedAt, |
| 1123 | 1242 | }); |
| 1124 | 1243 | |
| 1125 | − const adapter = new InProcessExecutionAdapter({ | |
| 1126 | − runId: args.taskId, | |
| 1127 | − invokeTool: async (call) => await invokeTool(ctx, running, call), | |
| 1128 | − emitOutput: async (event) => { | |
| 1129 | − await ctx.runMutation(internal.executor.appendRuntimeOutput, { | |
| 1130 | − runId: event.runId, | |
| 1131 | − stream: event.stream, | |
| 1132 | − line: event.line, | |
| 1133 | − timestamp: event.timestamp, | |
| 1134 | − }); | |
| 1135 | − }, | |
| 1136 | − }); | |
| 1137 | − | |
| 1138 | − const runtimeResult = await runCodeWithAdapter( | |
| 1139 | − { | |
| 1244 | + if (running.runtimeId === CLOUDFLARE_WORKER_LOADER_RUNTIME_ID) { | |
| 1245 | + const dispatchResult = await dispatchCodeWithCloudflareWorkerLoader({ | |
| 1140 | 1246 | taskId: args.taskId, |
| 1141 | 1247 | code: running.code, |
| 1142 | 1248 | timeoutMs: running.timeoutMs, |
| 1143 | − }, | |
| 1144 | − adapter, | |
| 1145 | − ); | |
| 1249 | + }); | |
| 1250 | + | |
| 1251 | + if (!dispatchResult.ok) { | |
| 1252 | + const failed = await ctx.runMutation(internal.database.markTaskFinished, { | |
| 1253 | + taskId: args.taskId, | |
| 1254 | + status: "failed", | |
| 1255 | + stdout: "", | |
| 1256 | + stderr: "", | |
| 1257 | + error: dispatchResult.error, | |
| 1258 | + }); | |
| 1259 | + | |
| 1260 | + if (failed) { | |
| 1261 | + await publish(ctx, args.taskId, "task", "task.failed", { | |
| 1262 | + taskId: args.taskId, | |
| 1263 | + status: failed.status, | |
| 1264 | + error: failed.error, | |
| 1265 | + completedAt: failed.completedAt, | |
| 1266 | + }); | |
| 1267 | + } | |
| 1268 | + return null; | |
| 1269 | + } | |
| 1270 | + | |
| 1271 | + await publish(ctx, args.taskId, "task", "task.dispatched", { | |
| 1272 | + taskId: args.taskId, | |
| 1273 | + runtimeId: running.runtimeId, | |
| 1274 | + dispatchId: dispatchResult.dispatchId, | |
| 1275 | + durationMs: dispatchResult.durationMs, | |
| 1276 | + }); | |
| 1277 | + return null; | |
| 1278 | + } | |
| 1279 | + | |
| 1280 | + const runtimeResult = await (async () => { | |
| 1281 | + const adapter = new InProcessExecutionAdapter({ | |
| 1282 | + runId: args.taskId, | |
| 1283 | + invokeTool: async (call) => await invokeTool(ctx, running, call), | |
| 1284 | + emitOutput: async (event) => { | |
| 1285 | + await ctx.runMutation(internal.executor.appendRuntimeOutput, { | |
| 1286 | + runId: event.runId, | |
| 1287 | + stream: event.stream, | |
| 1288 | + line: event.line, | |
| 1289 | + timestamp: event.timestamp, | |
| 1290 | + }); | |
| 1291 | + }, | |
| 1292 | + }); | |
| 1293 | + | |
| 1294 | + return await runCodeWithAdapter( | |
| 1295 | + { | |
| 1296 | + taskId: args.taskId, | |
| 1297 | + code: running.code, | |
| 1298 | + timeoutMs: running.timeoutMs, | |
| 1299 | + }, | |
| 1300 | + adapter, | |
| 1301 | + ); | |
| 1302 | + })(); | |
| 1146 | 1303 | |
| 1147 | 1304 | const finished = await ctx.runMutation(internal.database.markTaskFinished, { |
| 1148 | 1305 | taskId: args.taskId, |
| @@ -9,7 +9,6 @@ import type { AnonymousContext, PendingApprovalRecord, TaskRecord, ToolDescripto · 8 unmodified lines | ||
| 9 | 9 | import type { Id } from "./_generated/dataModel"; |
| 10 | 10 | |
| 11 | 11 | const http = httpRouter(); |
| 12 | −const internalToken = process.env.EXECUTOR_INTERNAL_TOKEN ?? null; | |
| 13 | 12 | |
| 14 | 13 | function getMcpAuthorizationServer(): string | null { |
| 15 | 14 | return process.env.MCP_AUTHORIZATION_SERVER |
| @@ -121,28 +120,6 @@ function parseMcpContext(url: URL): { · 105 unmodified lines | ||
| 121 | 120 | return { workspaceId, clientId, sessionId }; |
| 122 | 121 | } |
| 123 | 122 | |
| 124 | −function isInternalAuthorized(request: Request): boolean { | |
| 125 | − if (!internalToken) return false; | |
| 126 | − const header = request.headers.get("authorization"); | |
| 127 | − if (!header || !header.startsWith("Bearer ")) return false; | |
| 128 | − return header.slice("Bearer ".length) === internalToken; | |
| 129 | −} | |
| 130 | − | |
| 131 | −function parseInternalRunPath(pathname: string): { runId: string; endpoint: "tool-call" | "output" } | null { | |
| 132 | − const parts = pathname.split("/").filter(Boolean); | |
| 133 | − if (parts.length !== 4 || parts[0] !== "internal" || parts[1] !== "runs") { | |
| 134 | − return null; | |
| 135 | − } | |
| 136 | − | |
| 137 | − const runId = parts[2]; | |
| 138 | − const endpoint = parts[3]; | |
| 139 | − if (!runId || (endpoint !== "tool-call" && endpoint !== "output")) { | |
| 140 | − return null; | |
| 141 | − } | |
| 142 | − | |
| 143 | − return { runId, endpoint }; | |
| 144 | −} | |
| 145 | − | |
| 146 | 123 | const mcpHandler = httpAction(async (ctx, request) => { |
| 147 | 124 | const url = new URL(request.url); |
| 148 | 125 | const mcpAuthConfig = getMcpAuthConfig(); |
| @@ -313,62 +290,6 @@ const oauthAuthorizationServerProxyHandler = httpAction(async (_ctx, request) => · 164 unmodified lines | ||
| 313 | 290 | }); |
| 314 | 291 | }); |
| 315 | 292 | |
| 316 | −const internalRunsHandler = httpAction(async (ctx, request) => { | |
| 317 | − if (!isInternalAuthorized(request)) { | |
| 318 | − return Response.json({ error: "Unauthorized internal call" }, { status: 401 }); | |
| 319 | − } | |
| 320 | − | |
| 321 | − const url = new URL(request.url); | |
| 322 | − const parsed = parseInternalRunPath(url.pathname); | |
| 323 | − if (!parsed) { | |
| 324 | − return Response.json({ error: "Invalid internal route" }, { status: 404 }); | |
| 325 | − } | |
| 326 | − | |
| 327 | − let body: unknown; | |
| 328 | − try { | |
| 329 | − body = await request.json(); | |
| 330 | − } catch { | |
| 331 | − body = {}; | |
| 332 | − } | |
| 333 | − const payload = body && typeof body === "object" ? (body as Record<string, unknown>) : {}; | |
| 334 | − | |
| 335 | − if (parsed.endpoint === "tool-call") { | |
| 336 | − const callId = payload.callId; | |
| 337 | − const toolPath = payload.toolPath; | |
| 338 | − if (typeof callId !== "string" || typeof toolPath !== "string") { | |
| 339 | − return Response.json({ error: "callId and toolPath are required" }, { status: 400 }); | |
| 340 | − } | |
| 341 | − | |
| 342 | − const result = await ctx.runAction(internal.executorNode.handleExternalToolCall, { | |
| 343 | − runId: parsed.runId, | |
| 344 | − callId, | |
| 345 | − toolPath, | |
| 346 | − input: payload.input, | |
| 347 | − }); | |
| 348 | − return Response.json(result, { status: 200 }); | |
| 349 | − } | |
| 350 | − | |
| 351 | − const stream = payload.stream; | |
| 352 | − const line = payload.line; | |
| 353 | − if ((stream !== "stdout" && stream !== "stderr") || typeof line !== "string") { | |
| 354 | − return Response.json({ error: "stream and line are required" }, { status: 400 }); | |
| 355 | − } | |
| 356 | − | |
| 357 | − const task = await ctx.runQuery(internal.database.getTask, { taskId: parsed.runId }); | |
| 358 | − if (!task) { | |
| 359 | − return Response.json({ error: `Run not found: ${parsed.runId}` }, { status: 404 }); | |
| 360 | − } | |
| 361 | − | |
| 362 | − await ctx.runMutation(internal.executor.appendRuntimeOutput, { | |
| 363 | − runId: parsed.runId, | |
| 364 | − stream, | |
| 365 | − line, | |
| 366 | − timestamp: typeof payload.timestamp === "number" ? payload.timestamp : Date.now(), | |
| 367 | − }); | |
| 368 | − | |
| 369 | − return Response.json({ ok: true }, { status: 200 }); | |
| 370 | −}); | |
| 371 | − | |
| 372 | 293 | authKit.registerRoutes(http); |
| 373 | 294 | registerStripeRoutes(http, components.stripe, { |
| 374 | 295 | webhookPath: "/stripe/webhook", |
| @@ -380,10 +301,4 @@ http.route({ path: "/mcp", method: "DELETE", handler: mcpHandler }); · 5 unmodified lines | ||
| 380 | 301 | http.route({ path: "/.well-known/oauth-protected-resource", method: "GET", handler: oauthProtectedResourceHandler }); |
| 381 | 302 | http.route({ path: "/.well-known/oauth-authorization-server", method: "GET", handler: oauthAuthorizationServerProxyHandler }); |
| 382 | 303 | |
| 383 | −http.route({ | |
| 384 | − pathPrefix: "/internal/runs/", | |
| 385 | − method: "POST", | |
| 386 | − handler: internalRunsHandler, | |
| 387 | −}); | |
| 388 | − | |
| 389 | 304 | export default http; |
| @@ -0,0 +1,88 @@ | ||
| 1 | +import { v } from "convex/values"; | |
| 2 | +import { internal } from "./_generated/api"; | |
| 3 | +import { action, mutation } from "./_generated/server"; | |
| 4 | +import type { ToolCallResult } from "../lib/types"; | |
| 5 | + | |
| 6 | +function requireInternalSecret(secret: string): void { | |
| 7 | + const expected = process.env.EXECUTOR_INTERNAL_TOKEN; | |
| 8 | + if (!expected) { | |
| 9 | + throw new Error("EXECUTOR_INTERNAL_TOKEN is not configured"); | |
| 10 | + } | |
| 11 | + if (secret !== expected) { | |
| 12 | + throw new Error("Unauthorized: invalid internal secret"); | |
| 13 | + } | |
| 14 | +} | |
| 15 | + | |
| 16 | +export const handleToolCall = action({ | |
| 17 | + args: { | |
| 18 | + internalSecret: v.string(), | |
| 19 | + runId: v.string(), | |
| 20 | + callId: v.string(), | |
| 21 | + toolPath: v.string(), | |
| 22 | + input: v.optional(v.any()), | |
| 23 | + }, | |
| 24 | + handler: async (ctx, args): Promise<ToolCallResult> => { | |
| 25 | + requireInternalSecret(args.internalSecret); | |
| 26 | + return await ctx.runAction(internal.executorNode.handleExternalToolCall, { | |
| 27 | + runId: args.runId, | |
| 28 | + callId: args.callId, | |
| 29 | + toolPath: args.toolPath, | |
| 30 | + input: args.input, | |
| 31 | + }); | |
| 32 | + }, | |
| 33 | +}); | |
| 34 | + | |
| 35 | +export const appendOutput = mutation({ | |
| 36 | + args: { | |
| 37 | + internalSecret: v.string(), | |
| 38 | + runId: v.string(), | |
| 39 | + stream: v.union(v.literal("stdout"), v.literal("stderr")), | |
| 40 | + line: v.string(), | |
| 41 | + timestamp: v.optional(v.number()), | |
| 42 | + }, | |
| 43 | + handler: async (ctx, args) => { | |
| 44 | + requireInternalSecret(args.internalSecret); | |
| 45 | + | |
| 46 | + const task = await ctx.runQuery(internal.database.getTask, { | |
| 47 | + taskId: args.runId, | |
| 48 | + }); | |
| 49 | + if (!task) { | |
| 50 | + return { ok: false as const, error: `Run not found: ${args.runId}` }; | |
| 51 | + } | |
| 52 | + | |
| 53 | + await ctx.runMutation(internal.executor.appendRuntimeOutput, { | |
| 54 | + runId: args.runId, | |
| 55 | + stream: args.stream, | |
| 56 | + line: args.line, | |
| 57 | + timestamp: args.timestamp, | |
| 58 | + }); | |
| 59 | + | |
| 60 | + return { ok: true as const }; | |
| 61 | + }, | |
| 62 | +}); | |
| 63 | + | |
| 64 | +export const completeRun = mutation({ | |
| 65 | + args: { | |
| 66 | + internalSecret: v.string(), | |
| 67 | + runId: v.string(), | |
| 68 | + status: v.union(v.literal("completed"), v.literal("failed"), v.literal("timed_out"), v.literal("denied")), | |
| 69 | + stdout: v.optional(v.string()), | |
| 70 | + stderr: v.optional(v.string()), | |
| 71 | + exitCode: v.optional(v.number()), | |
| 72 | + error: v.optional(v.string()), | |
| 73 | + durationMs: v.optional(v.number()), | |
| 74 | + }, | |
| 75 | + handler: async (ctx, args) => { | |
| 76 | + requireInternalSecret(args.internalSecret); | |
| 77 | + | |
| 78 | + return await ctx.runMutation(internal.executor.completeRuntimeRun, { | |
| 79 | + runId: args.runId, | |
| 80 | + status: args.status, | |
| 81 | + stdout: args.stdout, | |
| 82 | + stderr: args.stderr, | |
| 83 | + exitCode: args.exitCode, | |
| 84 | + error: args.error, | |
| 85 | + durationMs: args.durationMs, | |
| 86 | + }); | |
| 87 | + }, | |
| 88 | +}); | |
| @@ -34,6 +34,13 @@ const taskStatus = v.union( · 33 unmodified lines | ||
| 34 | 34 | v.literal("denied"), |
| 35 | 35 | ); |
| 36 | 36 | const approvalStatus = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied")); |
| 37 | +const toolCallStatus = v.union( | |
| 38 | + v.literal("requested"), | |
| 39 | + v.literal("pending_approval"), | |
| 40 | + v.literal("completed"), | |
| 41 | + v.literal("failed"), | |
| 42 | + v.literal("denied"), | |
| 43 | +); | |
| 37 | 44 | const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); |
| 38 | 45 | const credentialScope = v.union(v.literal("workspace"), v.literal("actor")); |
| 39 | 46 | const credentialProvider = v.union( |
| @@ -207,6 +214,25 @@ export default defineSchema({ · 167 unmodified lines | ||
| 207 | 214 | .index("by_workspace_created", ["workspaceId", "createdAt"]) |
| 208 | 215 | .index("by_workspace_status_created", ["workspaceId", "status", "createdAt"]), |
| 209 | 216 | |
| 217 | + toolCalls: defineTable({ | |
| 218 | + taskId: v.string(), | |
| 219 | + callId: v.string(), | |
| 220 | + workspaceId: v.id("workspaces"), | |
| 221 | + toolPath: v.string(), | |
| 222 | + input: v.any(), | |
| 223 | + status: toolCallStatus, | |
| 224 | + approvalId: v.optional(v.string()), | |
| 225 | + output: v.optional(v.any()), | |
| 226 | + error: v.optional(v.string()), | |
| 227 | + createdAt: v.number(), | |
| 228 | + updatedAt: v.number(), | |
| 229 | + completedAt: v.optional(v.number()), | |
| 230 | + }) | |
| 231 | + .index("by_task_call", ["taskId", "callId"]) | |
| 232 | + .index("by_task_created", ["taskId", "createdAt"]) | |
| 233 | + .index("by_workspace_created", ["workspaceId", "createdAt"]) | |
| 234 | + .index("by_approval_id", ["approvalId"]), | |
| 235 | + | |
| 210 | 236 | taskEvents: defineTable({ |
| 211 | 237 | sequence: v.number(), |
| 212 | 238 | taskId: v.string(), // references tasks.taskId (not tasks._id) |
| @@ -45,7 +45,7 @@ test("maps approval denied errors to denied result", async () => { · 44 unmodified lines | ||
| 45 | 45 | |
| 46 | 46 | expect(result).toEqual({ |
| 47 | 47 | ok: false, |
| 48 | − denied: true, | |
| 48 | + kind: "denied", | |
| 49 | 49 | error: "approval required", |
| 50 | 50 | }); |
| 51 | 51 | }); |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { APPROVAL_DENIED_PREFIX } from "../execution_constants"; | |
| 1 | +import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../execution_constants"; | |
| 2 | 2 | import type { |
| 3 | 3 | ExecutionAdapter, |
| 4 | 4 | RuntimeOutputEvent, |
| @@ -20,6 +20,7 @@ export class InProcessExecutionAdapter implements ExecutionAdapter { · 15 unmodified lines | ||
| 20 | 20 | if (call.runId !== this.options.runId) { |
| 21 | 21 | return { |
| 22 | 22 | ok: false, |
| 23 | + kind: "failed", | |
| 23 | 24 | error: `Run mismatch for call ${call.callId}`, |
| 24 | 25 | }; |
| 25 | 26 | } |
| @@ -32,13 +33,24 @@ export class InProcessExecutionAdapter implements ExecutionAdapter { · 6 unmodified lines | ||
| 32 | 33 | if (message.startsWith(APPROVAL_DENIED_PREFIX)) { |
| 33 | 34 | return { |
| 34 | 35 | ok: false, |
| 35 | − denied: true, | |
| 36 | + kind: "denied", | |
| 36 | 37 | error: message.replace(APPROVAL_DENIED_PREFIX, "").trim(), |
| 37 | 38 | }; |
| 38 | 39 | } |
| 39 | 40 | |
| 41 | + if (message.startsWith(APPROVAL_PENDING_PREFIX)) { | |
| 42 | + return { | |
| 43 | + ok: false, | |
| 44 | + kind: "pending", | |
| 45 | + approvalId: message.replace(APPROVAL_PENDING_PREFIX, "").trim(), | |
| 46 | + retryAfterMs: 500, | |
| 47 | + error: "Approval pending", | |
| 48 | + }; | |
| 49 | + } | |
| 50 | + | |
| 40 | 51 | return { |
| 41 | 52 | ok: false, |
| 53 | + kind: "failed", | |
| 42 | 54 | error: message, |
| 43 | 55 | }; |
| 44 | 56 | } |
| @@ -1,2 +1,3 @@ | ||
| 1 | 1 | export const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:"; |
| 2 | +export const APPROVAL_PENDING_PREFIX = "APPROVAL_PENDING:"; | |
| 2 | 3 | export const TASK_TIMEOUT_MARKER = "TASK_TIMEOUT"; |
| @@ -191,7 +191,8 @@ describe("prepareOpenApiSpec with large specs", () => { · 190 unmodified lines | ||
| 191 | 191 | expect(tools.length).toBeGreaterThan(700); |
| 192 | 192 | const deleteTool = tools.find((tool) => tool.metadata?.operationId?.startsWith("delete_")); |
| 193 | 193 | expect(deleteTool).toBeDefined(); |
| 194 | − expect(deleteTool?.metadata?.displayReturnsType).toContain("ToolOutput<operations["); | |
| 194 | + expect(deleteTool?.metadata?.displayReturnsType).toBeDefined(); | |
| 195 | + expect(deleteTool?.metadata?.displayReturnsType).not.toContain("ToolOutput<operations["); | |
| 195 | 196 | expect(elapsed).toBeLessThan(20_000); |
| 196 | 197 | }); |
| 197 | 198 | |
| @@ -0,0 +1,182 @@ | ||
| 1 | +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | +import { dispatchCodeWithCloudflareWorkerLoader } from "./cloudflare_worker_loader_runtime"; | |
| 3 | + | |
| 4 | +let fakeHostServer: ReturnType<typeof Bun.serve>; | |
| 5 | +let fakeCallbackServer: ReturnType<typeof Bun.serve>; | |
| 6 | + | |
| 7 | +const AUTH_TOKEN = "test-sandbox-token"; | |
| 8 | +const CALLBACK_TOKEN = "test-callback-token"; | |
| 9 | + | |
| 10 | +let hostResponseStatus = 202; | |
| 11 | +let hostResponseBody: Record<string, unknown> = { accepted: true, dispatchId: "dispatch_test" }; | |
| 12 | +type HostRequestBody = { | |
| 13 | + taskId: string; | |
| 14 | + code: string; | |
| 15 | + timeoutMs: number; | |
| 16 | + callback: { convexUrl: string; internalSecret: string }; | |
| 17 | +}; | |
| 18 | + | |
| 19 | +let lastHostRequestBody: HostRequestBody | null = null; | |
| 20 | + | |
| 21 | +beforeAll(() => { | |
| 22 | + fakeCallbackServer = Bun.serve({ | |
| 23 | + port: 0, | |
| 24 | + fetch: () => Response.json({ ok: true }), | |
| 25 | + }); | |
| 26 | + | |
| 27 | + fakeHostServer = Bun.serve({ | |
| 28 | + port: 0, | |
| 29 | + fetch: async (req) => { | |
| 30 | + const url = new URL(req.url); | |
| 31 | + if (url.pathname !== "/v1/runs" || req.method !== "POST") { | |
| 32 | + return Response.json({ error: "Not found" }, { status: 404 }); | |
| 33 | + } | |
| 34 | + | |
| 35 | + const auth = req.headers.get("authorization"); | |
| 36 | + if (auth !== `Bearer ${AUTH_TOKEN}`) { | |
| 37 | + return Response.json({ error: "Unauthorized" }, { status: 401 }); | |
| 38 | + } | |
| 39 | + | |
| 40 | + lastHostRequestBody = await req.json(); | |
| 41 | + return Response.json(hostResponseBody, { status: hostResponseStatus }); | |
| 42 | + }, | |
| 43 | + }); | |
| 44 | + | |
| 45 | + process.env.CLOUDFLARE_SANDBOX_RUN_URL = `http://127.0.0.1:${fakeHostServer.port}/v1/runs`; | |
| 46 | + process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN = AUTH_TOKEN; | |
| 47 | + process.env.CONVEX_SITE_URL = `http://127.0.0.1:${fakeCallbackServer.port}`; | |
| 48 | + process.env.EXECUTOR_INTERNAL_TOKEN = CALLBACK_TOKEN; | |
| 49 | + process.env.CLOUDFLARE_SANDBOX_REQUEST_TIMEOUT_MS = "10000"; | |
| 50 | +}); | |
| 51 | + | |
| 52 | +afterAll(() => { | |
| 53 | + fakeHostServer?.stop(true); | |
| 54 | + fakeCallbackServer?.stop(true); | |
| 55 | + delete process.env.CLOUDFLARE_SANDBOX_RUN_URL; | |
| 56 | + delete process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN; | |
| 57 | + delete process.env.CONVEX_SITE_URL; | |
| 58 | + delete process.env.EXECUTOR_INTERNAL_TOKEN; | |
| 59 | + delete process.env.CLOUDFLARE_SANDBOX_REQUEST_TIMEOUT_MS; | |
| 60 | +}); | |
| 61 | + | |
| 62 | +describe("cloudflare worker loader dispatch", () => { | |
| 63 | + test("dispatches run request and returns accepted response", async () => { | |
| 64 | + hostResponseStatus = 202; | |
| 65 | + hostResponseBody = { accepted: true, dispatchId: "dispatch_abc" }; | |
| 66 | + lastHostRequestBody = null; | |
| 67 | + | |
| 68 | + const result = await dispatchCodeWithCloudflareWorkerLoader({ | |
| 69 | + taskId: `task_${crypto.randomUUID()}`, | |
| 70 | + code: `console.log("hello");`, | |
| 71 | + timeoutMs: 5_000, | |
| 72 | + }); | |
| 73 | + | |
| 74 | + expect(result.ok).toBe(true); | |
| 75 | + if (result.ok) { | |
| 76 | + expect(result.accepted).toBe(true); | |
| 77 | + expect(result.dispatchId).toBe("dispatch_abc"); | |
| 78 | + } | |
| 79 | + | |
| 80 | + expect(lastHostRequestBody).not.toBeNull(); | |
| 81 | + const requestBody = lastHostRequestBody as unknown as HostRequestBody; | |
| 82 | + expect(requestBody.callback.convexUrl).toContain(String(fakeCallbackServer.port)); | |
| 83 | + expect(requestBody.callback.internalSecret).toBe(CALLBACK_TOKEN); | |
| 84 | + }); | |
| 85 | + | |
| 86 | + test("transpiles TypeScript before dispatching", async () => { | |
| 87 | + hostResponseStatus = 202; | |
| 88 | + hostResponseBody = { accepted: true, dispatchId: "dispatch_ts" }; | |
| 89 | + lastHostRequestBody = null; | |
| 90 | + | |
| 91 | + const result = await dispatchCodeWithCloudflareWorkerLoader({ | |
| 92 | + taskId: `task_${crypto.randomUUID()}`, | |
| 93 | + code: ` | |
| 94 | + interface User { name: string } | |
| 95 | + const user: User = { name: "Ada" }; | |
| 96 | + console.log(user.name); | |
| 97 | + `, | |
| 98 | + timeoutMs: 5_000, | |
| 99 | + }); | |
| 100 | + | |
| 101 | + expect(result.ok).toBe(true); | |
| 102 | + expect(lastHostRequestBody).not.toBeNull(); | |
| 103 | + const requestBody = lastHostRequestBody as unknown as HostRequestBody; | |
| 104 | + expect(requestBody.code.includes("interface User")).toBe(false); | |
| 105 | + expect(requestBody.code.includes("const user")).toBe(true); | |
| 106 | + }); | |
| 107 | + | |
| 108 | + test("fails when host does not accept dispatch", async () => { | |
| 109 | + hostResponseStatus = 500; | |
| 110 | + hostResponseBody = { error: "host failure" }; | |
| 111 | + | |
| 112 | + const result = await dispatchCodeWithCloudflareWorkerLoader({ | |
| 113 | + taskId: `task_${crypto.randomUUID()}`, | |
| 114 | + code: `console.log("x");`, | |
| 115 | + timeoutMs: 5_000, | |
| 116 | + }); | |
| 117 | + | |
| 118 | + expect(result.ok).toBe(false); | |
| 119 | + if (!result.ok) { | |
| 120 | + expect(result.error).toContain("500"); | |
| 121 | + } | |
| 122 | + }); | |
| 123 | + | |
| 124 | + test("reports TypeScript transpile errors", async () => { | |
| 125 | + const result = await dispatchCodeWithCloudflareWorkerLoader({ | |
| 126 | + taskId: `task_${crypto.randomUUID()}`, | |
| 127 | + code: `const x: = 5;`, | |
| 128 | + timeoutMs: 5_000, | |
| 129 | + }); | |
| 130 | + | |
| 131 | + expect(result.ok).toBe(false); | |
| 132 | + if (!result.ok) { | |
| 133 | + expect(result.error).toContain("TypeScript transpile error"); | |
| 134 | + } | |
| 135 | + }); | |
| 136 | +}); | |
| 137 | + | |
| 138 | +describe("runtime catalog", () => { | |
| 139 | + test("isKnownRuntimeId recognizes both runtimes", async () => { | |
| 140 | + const { | |
| 141 | + isKnownRuntimeId, | |
| 142 | + LOCAL_BUN_RUNTIME_ID, | |
| 143 | + CLOUDFLARE_WORKER_LOADER_RUNTIME_ID, | |
| 144 | + } = await import("./runtime_catalog"); | |
| 145 | + | |
| 146 | + expect(isKnownRuntimeId(LOCAL_BUN_RUNTIME_ID)).toBe(true); | |
| 147 | + expect(isKnownRuntimeId(CLOUDFLARE_WORKER_LOADER_RUNTIME_ID)).toBe(true); | |
| 148 | + expect(isKnownRuntimeId("unknown-runtime")).toBe(false); | |
| 149 | + }); | |
| 150 | + | |
| 151 | + test("isCloudflareWorkerLoaderConfigured checks env vars", async () => { | |
| 152 | + const { isCloudflareWorkerLoaderConfigured } = await import("./runtime_catalog"); | |
| 153 | + expect(isCloudflareWorkerLoaderConfigured()).toBe(true); | |
| 154 | + }); | |
| 155 | + | |
| 156 | + test("getCloudflareWorkerLoaderConfig reads env vars", async () => { | |
| 157 | + const { getCloudflareWorkerLoaderConfig } = await import("./runtime_catalog"); | |
| 158 | + const config = getCloudflareWorkerLoaderConfig(); | |
| 159 | + | |
| 160 | + expect(config.runUrl).toContain("/v1/runs"); | |
| 161 | + expect(config.authToken).toBe(AUTH_TOKEN); | |
| 162 | + expect(config.callbackConvexUrl).toContain(String(fakeCallbackServer.port)); | |
| 163 | + expect(config.callbackInternalSecret).toBe(CALLBACK_TOKEN); | |
| 164 | + expect(config.requestTimeoutMs).toBe(10_000); | |
| 165 | + }); | |
| 166 | + | |
| 167 | + test("prefers CONVEX_URL over CONVEX_SITE_URL for callback RPC", async () => { | |
| 168 | + const { getCloudflareWorkerLoaderConfig } = await import("./runtime_catalog"); | |
| 169 | + const previousConvexUrl = process.env.CONVEX_URL; | |
| 170 | + const previousSiteUrl = process.env.CONVEX_SITE_URL; | |
| 171 | + try { | |
| 172 | + process.env.CONVEX_URL = "https://example-convex-cloud-url.test"; | |
| 173 | + process.env.CONVEX_SITE_URL = "https://example-convex-site-url.test"; | |
| 174 | + | |
| 175 | + const config = getCloudflareWorkerLoaderConfig(); | |
| 176 | + expect(config.callbackConvexUrl).toBe("https://example-convex-cloud-url.test"); | |
| 177 | + } finally { | |
| 178 | + process.env.CONVEX_URL = previousConvexUrl; | |
| 179 | + process.env.CONVEX_SITE_URL = previousSiteUrl; | |
| 180 | + } | |
| 181 | + }); | |
| 182 | +}); | |
| @@ -0,0 +1,130 @@ | ||
| 1 | +"use node"; | |
| 2 | + | |
| 3 | +import { Result } from "better-result"; | |
| 4 | +import type { SandboxExecutionRequest } from "../types"; | |
| 5 | +import { getCloudflareWorkerLoaderConfig } from "./runtime_catalog"; | |
| 6 | +import { transpileForRuntime } from "./transpile"; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Run agent-generated code via a Cloudflare Worker that uses the Dynamic | |
| 10 | + * Worker Loader API to spawn a sandboxed isolate. | |
| 11 | + * | |
| 12 | + * ## Architecture | |
| 13 | + * | |
| 14 | + * 1. This function (running inside a Convex action) POSTs the code + config to | |
| 15 | + * a **host Worker** deployed on Cloudflare. | |
| 16 | + * | |
| 17 | + * 2. The host Worker uses `env.LOADER.get(id, callback)` to create a dynamic | |
| 18 | + * isolate containing the user code. | |
| 19 | + * | |
| 20 | + * 3. The dynamic isolate's `tools` proxy calls are intercepted by a | |
| 21 | + * `ToolBridge` entrypoint in the host Worker (passed via `env` bindings), | |
| 22 | + * which in turn calls Convex callback RPCs to resolve tools. | |
| 23 | + * | |
| 24 | + * 4. Console output from the isolate is similarly relayed back via callback | |
| 25 | + * RPC mutation. | |
| 26 | + * | |
| 27 | + * 5. The host Worker accepts the run immediately and finishes execution | |
| 28 | + * asynchronously, reporting terminal results back through callback RPC. | |
| 29 | + * | |
| 30 | + * ## Callback authentication | |
| 31 | + * | |
| 32 | + * The host Worker authenticates callback RPCs using `EXECUTOR_INTERNAL_TOKEN`. | |
| 33 | + */ | |
| 34 | +export interface CloudflareDispatchResult { | |
| 35 | + ok: true; | |
| 36 | + accepted: true; | |
| 37 | + dispatchId: string; | |
| 38 | + durationMs: number; | |
| 39 | +} | |
| 40 | + | |
| 41 | +export interface CloudflareDispatchError { | |
| 42 | + ok: false; | |
| 43 | + error: string; | |
| 44 | + durationMs: number; | |
| 45 | +} | |
| 46 | + | |
| 47 | +export async function dispatchCodeWithCloudflareWorkerLoader( | |
| 48 | + request: SandboxExecutionRequest, | |
| 49 | +): Promise<CloudflareDispatchResult | CloudflareDispatchError> { | |
| 50 | + const config = getCloudflareWorkerLoaderConfig(); | |
| 51 | + const startedAt = Date.now(); | |
| 52 | + | |
| 53 | + const mkError = (error: string): CloudflareDispatchError => ({ | |
| 54 | + ok: false, | |
| 55 | + error, | |
| 56 | + durationMs: Date.now() - startedAt, | |
| 57 | + }); | |
| 58 | + | |
| 59 | + // ── Transpile TS → JS on the Convex side ───────────────────────────── | |
| 60 | + const transpiled = transpileForRuntime(request.code); | |
| 61 | + if (transpiled.isErr()) { | |
| 62 | + return mkError(transpiled.error.message); | |
| 63 | + } | |
| 64 | + | |
| 65 | + // ── POST to CF host worker ──────────────────────────────────────────── | |
| 66 | + const controller = new AbortController(); | |
| 67 | + const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs); | |
| 68 | + | |
| 69 | + const response = await Result.tryPromise(() => | |
| 70 | + fetch(config.runUrl, { | |
| 71 | + method: "POST", | |
| 72 | + headers: { | |
| 73 | + "Content-Type": "application/json", | |
| 74 | + Authorization: `Bearer ${config.authToken}`, | |
| 75 | + }, | |
| 76 | + body: JSON.stringify({ | |
| 77 | + taskId: request.taskId, | |
| 78 | + code: transpiled.value, | |
| 79 | + timeoutMs: request.timeoutMs, | |
| 80 | + callback: { | |
| 81 | + convexUrl: config.callbackConvexUrl, | |
| 82 | + internalSecret: config.callbackInternalSecret, | |
| 83 | + }, | |
| 84 | + }), | |
| 85 | + signal: controller.signal, | |
| 86 | + }), | |
| 87 | + ); | |
| 88 | + | |
| 89 | + clearTimeout(timeout); | |
| 90 | + | |
| 91 | + if (response.isErr()) { | |
| 92 | + const cause = response.error.cause; | |
| 93 | + const isAbort = cause instanceof DOMException && cause.name === "AbortError"; | |
| 94 | + if (isAbort) { | |
| 95 | + return mkError(`Cloudflare sandbox dispatch timed out after ${config.requestTimeoutMs}ms`); | |
| 96 | + } | |
| 97 | + const message = cause instanceof Error ? cause.message : String(cause); | |
| 98 | + return mkError(`Cloudflare sandbox dispatch failed: ${message}`); | |
| 99 | + } | |
| 100 | + | |
| 101 | + // ── Handle non-accepted HTTP status ─────────────────────────────────── | |
| 102 | + if (response.value.status !== 202) { | |
| 103 | + const text = await Result.tryPromise(() => response.value.text()); | |
| 104 | + const body = text.unwrapOr(response.value.statusText); | |
| 105 | + return mkError(`Cloudflare sandbox dispatch returned ${response.value.status}: ${body}`); | |
| 106 | + } | |
| 107 | + | |
| 108 | + // ── Parse accepted response JSON ────────────────────────────────────── | |
| 109 | + const body = await Result.tryPromise(() => | |
| 110 | + response.value.json() as Promise<{ | |
| 111 | + accepted?: boolean; | |
| 112 | + dispatchId?: string; | |
| 113 | + }>, | |
| 114 | + ); | |
| 115 | + | |
| 116 | + if (body.isErr()) { | |
| 117 | + return mkError("Cloudflare sandbox dispatch returned invalid JSON"); | |
| 118 | + } | |
| 119 | + | |
| 120 | + if (!body.value.accepted || !body.value.dispatchId) { | |
| 121 | + return mkError("Cloudflare sandbox dispatch response missing accepted/dispatchId"); | |
| 122 | + } | |
| 123 | + | |
| 124 | + return { | |
| 125 | + ok: true, | |
| 126 | + accepted: true, | |
| 127 | + dispatchId: body.value.dispatchId, | |
| 128 | + durationMs: Date.now() - startedAt, | |
| 129 | + }; | |
| 130 | +} | |
| @@ -28,6 +28,7 @@ function createRuntimeAdapter( · 27 unmodified lines | ||
| 28 | 28 | if (!tool) { |
| 29 | 29 | return { |
| 30 | 30 | ok: false, |
| 31 | + kind: "failed", | |
| 31 | 32 | error: `Tool not found: ${call.toolPath}`, |
| 32 | 33 | }; |
| 33 | 34 | } |
| @@ -45,6 +46,7 @@ function createRuntimeAdapter( · 11 unmodified lines | ||
| 45 | 46 | } catch (error) { |
| 46 | 47 | return { |
| 47 | 48 | ok: false, |
| 49 | + kind: "failed", | |
| 48 | 50 | error: error instanceof Error ? error.message : String(error), |
| 49 | 51 | }; |
| 50 | 52 | } |
| @@ -89,7 +91,7 @@ test("returns denied when adapter marks tool call denied", async () => { · 38 unmodified lines | ||
| 89 | 91 | async invokeTool() { |
| 90 | 92 | return { |
| 91 | 93 | ok: false, |
| 92 | − denied: true, | |
| 94 | + kind: "denied", | |
| 93 | 95 | error: "policy denied", |
| 94 | 96 | }; |
| 95 | 97 | }, |
| @@ -0,0 +1,80 @@ | ||
| 1 | +// ── Runtime ID constants ────────────────────────────────────────────────────── | |
| 2 | + | |
| 3 | +export const LOCAL_BUN_RUNTIME_ID = "local-bun"; | |
| 4 | +export const CLOUDFLARE_WORKER_LOADER_RUNTIME_ID = "cloudflare-worker-loader"; | |
| 5 | + | |
| 6 | +const KNOWN_RUNTIME_IDS = new Set([ | |
| 7 | + LOCAL_BUN_RUNTIME_ID, | |
| 8 | + CLOUDFLARE_WORKER_LOADER_RUNTIME_ID, | |
| 9 | +]); | |
| 10 | + | |
| 11 | +export function isKnownRuntimeId(runtimeId: string): boolean { | |
| 12 | + return KNOWN_RUNTIME_IDS.has(runtimeId); | |
| 13 | +} | |
| 14 | + | |
| 15 | +// ── Cloudflare Worker Loader config ────────────────────────────────────────── | |
| 16 | + | |
| 17 | +export interface CloudflareWorkerLoaderConfig { | |
| 18 | + /** The URL of the CF host worker's /v1/runs endpoint. */ | |
| 19 | + runUrl: string; | |
| 20 | + /** Shared-secret bearer token for authenticating with the host worker. */ | |
| 21 | + authToken: string; | |
| 22 | + /** Dispatch request timeout in ms (how long we wait for /v1/runs accepted response). */ | |
| 23 | + requestTimeoutMs: number; | |
| 24 | + /** Convex deployment URL used for runtime callback RPC invocations. */ | |
| 25 | + callbackConvexUrl: string; | |
| 26 | + /** Internal auth secret used for runtime callback RPC auth. */ | |
| 27 | + callbackInternalSecret: string; | |
| 28 | +} | |
| 29 | + | |
| 30 | +/** | |
| 31 | + * Returns true if all required env vars for the Cloudflare Worker Loader | |
| 32 | + * runtime are present. | |
| 33 | + */ | |
| 34 | +export function isCloudflareWorkerLoaderConfigured(): boolean { | |
| 35 | + return Boolean( | |
| 36 | + process.env.CLOUDFLARE_SANDBOX_RUN_URL | |
| 37 | + && process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN, | |
| 38 | + ); | |
| 39 | +} | |
| 40 | + | |
| 41 | +/** | |
| 42 | + * Reads Cloudflare Worker Loader config from environment variables. | |
| 43 | + * Throws if required vars are missing. | |
| 44 | + */ | |
| 45 | +export function getCloudflareWorkerLoaderConfig(): CloudflareWorkerLoaderConfig { | |
| 46 | + const runUrl = process.env.CLOUDFLARE_SANDBOX_RUN_URL; | |
| 47 | + const authToken = process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN; | |
| 48 | + | |
| 49 | + if (!runUrl || !authToken) { | |
| 50 | + throw new Error( | |
| 51 | + "Cloudflare Worker Loader runtime requires CLOUDFLARE_SANDBOX_RUN_URL and CLOUDFLARE_SANDBOX_AUTH_TOKEN", | |
| 52 | + ); | |
| 53 | + } | |
| 54 | + | |
| 55 | + const callbackConvexUrl = process.env.CONVEX_URL ?? process.env.CONVEX_SITE_URL; | |
| 56 | + if (!callbackConvexUrl) { | |
| 57 | + throw new Error( | |
| 58 | + "Cloudflare Worker Loader runtime requires CONVEX_SITE_URL or CONVEX_URL for callback RPC", | |
| 59 | + ); | |
| 60 | + } | |
| 61 | + | |
| 62 | + const callbackInternalSecret = process.env.EXECUTOR_INTERNAL_TOKEN; | |
| 63 | + if (!callbackInternalSecret) { | |
| 64 | + throw new Error( | |
| 65 | + "Cloudflare Worker Loader runtime requires EXECUTOR_INTERNAL_TOKEN for authenticated callback RPC", | |
| 66 | + ); | |
| 67 | + } | |
| 68 | + | |
| 69 | + const requestTimeoutMs = Number( | |
| 70 | + process.env.CLOUDFLARE_SANDBOX_REQUEST_TIMEOUT_MS ?? "90000", | |
| 71 | + ); | |
| 72 | + | |
| 73 | + return { | |
| 74 | + runUrl, | |
| 75 | + authToken, | |
| 76 | + requestTimeoutMs, | |
| 77 | + callbackConvexUrl, | |
| 78 | + callbackInternalSecret, | |
| 79 | + }; | |
| 80 | +} | |
| @@ -5,50 +5,20 @@ · 4 unmodified lines | ||
| 5 | 5 | // the core helpers here (formatArgs, createToolsProxy, console proxy, execution |
| 6 | 6 | // loop, result mapping) should be mirrored there. |
| 7 | 7 | import { APPROVAL_DENIED_PREFIX, TASK_TIMEOUT_MARKER } from "../execution_constants"; |
| 8 | +import { Result } from "better-result"; | |
| 8 | 9 | import { Script, createContext } from "node:vm"; |
| 9 | 10 | import type { |
| 10 | 11 | ExecutionAdapter, |
| 11 | 12 | SandboxExecutionRequest, |
| 12 | 13 | SandboxExecutionResult, |
| 13 | 14 | } from "../types"; |
| 14 | − | |
| 15 | −async function transpileForRuntime(code: string): Promise<string> { | |
| 16 | − let ts: typeof import("typescript"); | |
| 17 | − try { | |
| 18 | − ts = require("typescript"); | |
| 19 | − } catch { | |
| 20 | − return code; | |
| 21 | − } | |
| 22 | − | |
| 23 | − const target = ts.ScriptTarget?.ES2022 ?? ts.ScriptTarget?.ESNext; | |
| 24 | − const moduleKind = ts.ModuleKind?.ESNext; | |
| 25 | − | |
| 26 | − const result = ts.transpileModule(code, { | |
| 27 | − compilerOptions: { | |
| 28 | − ...(target !== undefined ? { target } : {}), | |
| 29 | − ...(moduleKind !== undefined ? { module: moduleKind } : {}), | |
| 30 | − }, | |
| 31 | − reportDiagnostics: true, | |
| 32 | − }); | |
| 33 | − | |
| 34 | − if (result.diagnostics && result.diagnostics.length > 0) { | |
| 35 | − const first = result.diagnostics[0]; | |
| 36 | − const message = ts.flattenDiagnosticMessageText(first.messageText, "\n"); | |
| 37 | − throw new Error(`TypeScript transpile error: ${message}`); | |
| 38 | − } | |
| 39 | − | |
| 40 | − return result.outputText || code; | |
| 41 | −} | |
| 15 | +import { transpileForRuntime } from "./transpile"; | |
| 42 | 16 | |
| 43 | 17 | function formatArgs(args: unknown[]): string { |
| 44 | 18 | return args |
| 45 | 19 | .map((value) => { |
| 46 | 20 | if (typeof value === "string") return value; |
| 47 | − try { | |
| 48 | − return JSON.stringify(value); | |
| 49 | − } catch { | |
| 50 | − return String(value); | |
| 51 | − } | |
| 21 | + return Result.try(() => JSON.stringify(value)).unwrapOr(String(value)); | |
| 52 | 22 | }) |
| 53 | 23 | .join(" "); |
| 54 | 24 | } |
| @@ -59,6 +29,10 @@ function fireAndForget(promise: void | Promise<void>): void { · 4 unmodified lines | ||
| 59 | 29 | } |
| 60 | 30 | } |
| 61 | 31 | |
| 32 | +async function sleep(ms: number): Promise<void> { | |
| 33 | + await new Promise((resolve) => setTimeout(resolve, ms)); | |
| 34 | +} | |
| 35 | + | |
| 62 | 36 | function createToolsProxy( |
| 63 | 37 | adapter: ExecutionAdapter, |
| 64 | 38 | runId: string, |
| @@ -78,26 +52,59 @@ function createToolsProxy( · 13 unmodified lines | ||
| 78 | 52 | } |
| 79 | 53 | |
| 80 | 54 | const input = args.length > 0 ? args[0] : {}; |
| 81 | − const result = await adapter.invokeTool({ | |
| 82 | − runId, | |
| 83 | − callId: `call_${crypto.randomUUID()}`, | |
| 84 | − toolPath, | |
| 85 | − input, | |
| 86 | − }); | |
| 87 | − | |
| 88 | − if (result.ok) { | |
| 89 | − return result.value; | |
| 90 | − } | |
| 91 | − | |
| 92 | − if (result.denied) { | |
| 93 | − throw new Error(`${APPROVAL_DENIED_PREFIX}${result.error}`); | |
| 55 | + const callId = `call_${crypto.randomUUID()}`; | |
| 56 | + | |
| 57 | + while (true) { | |
| 58 | + const result = await adapter.invokeTool({ | |
| 59 | + runId, | |
| 60 | + callId, | |
| 61 | + toolPath, | |
| 62 | + input, | |
| 63 | + }); | |
| 64 | + | |
| 65 | + if (result.ok) { | |
| 66 | + return result.value; | |
| 67 | + } | |
| 68 | + | |
| 69 | + switch (result.kind) { | |
| 70 | + case "pending": | |
| 71 | + await sleep(Math.max(50, result.retryAfterMs ?? 500)); | |
| 72 | + continue; | |
| 73 | + case "denied": | |
| 74 | + throw new Error(`${APPROVAL_DENIED_PREFIX}${result.error}`); | |
| 75 | + case "failed": | |
| 76 | + default: | |
| 77 | + throw new Error(result.error); | |
| 78 | + } | |
| 94 | 79 | } |
| 95 | − | |
| 96 | − throw new Error(result.error); | |
| 97 | 80 | }, |
| 98 | 81 | }); |
| 99 | 82 | } |
| 100 | 83 | |
| 84 | +/** Classify a caught error from VM execution into a result status. */ | |
| 85 | +function classifyExecutionError( | |
| 86 | + error: unknown, | |
| 87 | + request: SandboxExecutionRequest, | |
| 88 | +): { status: SandboxExecutionResult["status"]; message: string } { | |
| 89 | + const message = error instanceof Error ? error.message : String(error); | |
| 90 | + | |
| 91 | + if (message === TASK_TIMEOUT_MARKER || message.includes("Script execution timed out")) { | |
| 92 | + return { | |
| 93 | + status: "timed_out", | |
| 94 | + message: `Execution timed out after ${request.timeoutMs}ms`, | |
| 95 | + }; | |
| 96 | + } | |
| 97 | + | |
| 98 | + if (message.startsWith(APPROVAL_DENIED_PREFIX)) { | |
| 99 | + return { | |
| 100 | + status: "denied", | |
| 101 | + message: message.replace(APPROVAL_DENIED_PREFIX, "").trim(), | |
| 102 | + }; | |
| 103 | + } | |
| 104 | + | |
| 105 | + return { status: "failed", message }; | |
| 106 | +} | |
| 107 | + | |
| 101 | 108 | export async function runCodeWithAdapter( |
| 102 | 109 | request: SandboxExecutionRequest, |
| 103 | 110 | adapter: ExecutionAdapter, |
| @@ -130,6 +137,26 @@ export async function runCodeWithAdapter( · 26 unmodified lines | ||
| 130 | 137 | ); |
| 131 | 138 | }; |
| 132 | 139 | |
| 140 | + const mkResult = ( | |
| 141 | + status: SandboxExecutionResult["status"], | |
| 142 | + opts?: { error?: string; exitCode?: number }, | |
| 143 | + ): SandboxExecutionResult => ({ | |
| 144 | + status, | |
| 145 | + stdout: stdoutLines.join("\n"), | |
| 146 | + stderr: stderrLines.join("\n"), | |
| 147 | + exitCode: opts?.exitCode, | |
| 148 | + error: opts?.error, | |
| 149 | + durationMs: Date.now() - startedAt, | |
| 150 | + }); | |
| 151 | + | |
| 152 | + // ── Transpile ────────────────────────────────────────────────────────── | |
| 153 | + const transpiled = transpileForRuntime(request.code); | |
| 154 | + if (transpiled.isErr()) { | |
| 155 | + appendStderr(transpiled.error.message); | |
| 156 | + return mkResult("failed", { error: transpiled.error.message }); | |
| 157 | + } | |
| 158 | + | |
| 159 | + // ── Sandbox setup ────────────────────────────────────────────────────── | |
| 133 | 160 | const tools = createToolsProxy(adapter, request.taskId); |
| 134 | 161 | const consoleProxy = { |
| 135 | 162 | log: (...args: unknown[]) => appendStdout(formatArgs(args)), |
| @@ -145,15 +172,14 @@ export async function runCodeWithAdapter( · 9 unmodified lines | ||
| 145 | 172 | clearTimeout, |
| 146 | 173 | }); |
| 147 | 174 | const context = createContext(sandbox, { |
| 148 | − codeGeneration: { | |
| 149 | − strings: false, | |
| 150 | − wasm: false, | |
| 151 | − }, | |
| 175 | + codeGeneration: { strings: false, wasm: false }, | |
| 152 | 176 | }); |
| 153 | 177 | |
| 154 | − const executableCode = await transpileForRuntime(request.code); | |
| 155 | − const runnerScript = new Script(`(async () => {\n"use strict";\n${executableCode}\n})()`); | |
| 178 | + const runnerScript = new Script( | |
| 179 | + `(async () => {\n"use strict";\n${transpiled.value}\n})()`, | |
| 180 | + ); | |
| 156 | 181 | |
| 182 | + // ── Execute with timeout ─────────────────────────────────────────────── | |
| 157 | 183 | let timeoutHandle: ReturnType<typeof setTimeout> | undefined; |
| 158 | 184 | const timeoutPromise = new Promise<never>((_resolve, reject) => { |
| 159 | 185 | timeoutHandle = setTimeout(() => { |
| @@ -161,60 +187,32 @@ export async function runCodeWithAdapter( · 1 unmodified line | ||
| 161 | 187 | }, request.timeoutMs); |
| 162 | 188 | }); |
| 163 | 189 | |
| 164 | − try { | |
| 190 | + const execution = await Result.tryPromise(async () => { | |
| 165 | 191 | const value = await Promise.race([ |
| 166 | − Promise.resolve(runnerScript.runInContext(context, { timeout: Math.max(1, request.timeoutMs) })), | |
| 192 | + Promise.resolve( | |
| 193 | + runnerScript.runInContext(context, { | |
| 194 | + timeout: Math.max(1, request.timeoutMs), | |
| 195 | + }), | |
| 196 | + ), | |
| 167 | 197 | timeoutPromise, |
| 168 | 198 | ]); |
| 199 | + return value; | |
| 200 | + }); | |
| 169 | 201 | |
| 170 | − if (value !== undefined) { | |
| 171 | − appendStdout(`result: ${formatArgs([value])}`); | |
| 172 | − } | |
| 173 | − | |
| 174 | − return { | |
| 175 | − status: "completed", | |
| 176 | − stdout: stdoutLines.join("\n"), | |
| 177 | − stderr: stderrLines.join("\n"), | |
| 178 | − exitCode: 0, | |
| 179 | − durationMs: Date.now() - startedAt, | |
| 180 | − }; | |
| 181 | − } catch (error) { | |
| 182 | − const message = error instanceof Error ? error.message : String(error); | |
| 183 | − if (message === TASK_TIMEOUT_MARKER || message.includes("Script execution timed out")) { | |
| 184 | − const timeoutMessage = `Execution timed out after ${request.timeoutMs}ms`; | |
| 185 | − appendStderr(timeoutMessage); | |
| 186 | − return { | |
| 187 | − status: "timed_out", | |
| 188 | − stdout: stdoutLines.join("\n"), | |
| 189 | − stderr: stderrLines.join("\n"), | |
| 190 | − error: timeoutMessage, | |
| 191 | − durationMs: Date.now() - startedAt, | |
| 192 | − }; | |
| 193 | − } | |
| 194 | − | |
| 195 | − if (message.startsWith(APPROVAL_DENIED_PREFIX)) { | |
| 196 | − const deniedMessage = message.replace(APPROVAL_DENIED_PREFIX, "").trim(); | |
| 197 | − appendStderr(deniedMessage); | |
| 198 | − return { | |
| 199 | − status: "denied", | |
| 200 | − stdout: stdoutLines.join("\n"), | |
| 201 | − stderr: stderrLines.join("\n"), | |
| 202 | − error: deniedMessage, | |
| 203 | − durationMs: Date.now() - startedAt, | |
| 204 | − }; | |
| 205 | − } | |
| 202 | + if (timeoutHandle) clearTimeout(timeoutHandle); | |
| 206 | 203 | |
| 204 | + if (execution.isErr()) { | |
| 205 | + const { status, message } = classifyExecutionError( | |
| 206 | + execution.error.cause, | |
| 207 | + request, | |
| 208 | + ); | |
| 207 | 209 | appendStderr(message); |
| 208 | − return { | |
| 209 | − status: "failed", | |
| 210 | − stdout: stdoutLines.join("\n"), | |
| 211 | − stderr: stderrLines.join("\n"), | |
| 212 | − error: message, | |
| 213 | − durationMs: Date.now() - startedAt, | |
| 214 | − }; | |
| 215 | − } finally { | |
| 216 | − if (timeoutHandle) { | |
| 217 | − clearTimeout(timeoutHandle); | |
| 218 | − } | |
| 210 | + return mkResult(status, { error: message }); | |
| 219 | 211 | } |
| 212 | + | |
| 213 | + if (execution.value !== undefined) { | |
| 214 | + appendStdout(`result: ${formatArgs([execution.value])}`); | |
| 215 | + } | |
| 216 | + | |
| 217 | + return mkResult("completed", { exitCode: 0 }); | |
| 220 | 218 | } |
| @@ -0,0 +1,78 @@ | ||
| 1 | +"use node"; | |
| 2 | + | |
| 3 | +import { Result, TaggedError } from "better-result"; | |
| 4 | + | |
| 5 | +// --------------------------------------------------------------------------- | |
| 6 | +// Errors | |
| 7 | +// --------------------------------------------------------------------------- | |
| 8 | + | |
| 9 | +export class TranspileError extends TaggedError("TranspileError")<{ | |
| 10 | + message: string; | |
| 11 | +}>() {} | |
| 12 | + | |
| 13 | +// --------------------------------------------------------------------------- | |
| 14 | +// TypeScript module loader | |
| 15 | +// --------------------------------------------------------------------------- | |
| 16 | + | |
| 17 | +let cachedTypeScript: typeof import("typescript") | null | undefined; | |
| 18 | + | |
| 19 | +function getTypeScriptModule(): typeof import("typescript") | null { | |
| 20 | + if (cachedTypeScript === undefined) { | |
| 21 | + const loaded = Result.try(() => require("typescript") as typeof import("typescript")); | |
| 22 | + cachedTypeScript = loaded.isOk() ? loaded.value : null; | |
| 23 | + } | |
| 24 | + return cachedTypeScript ?? null; | |
| 25 | +} | |
| 26 | + | |
| 27 | +// --------------------------------------------------------------------------- | |
| 28 | +// Public API | |
| 29 | +// --------------------------------------------------------------------------- | |
| 30 | + | |
| 31 | +/** | |
| 32 | + * Transpile TypeScript code to JavaScript using the `typescript` module. | |
| 33 | + * | |
| 34 | + * Returns a Result — either the transpiled JS string or a TranspileError. | |
| 35 | + * If the TypeScript module is not available, the code is returned as-is | |
| 36 | + * (graceful fallback for environments where TS isn't installed). | |
| 37 | + * | |
| 38 | + * Targets ES2022/ESNext so the output can run in modern runtimes (node:vm, | |
| 39 | + * Cloudflare Workers isolates, etc.) without further downlevelling. | |
| 40 | + */ | |
| 41 | +export function transpileForRuntime( | |
| 42 | + code: string, | |
| 43 | +): Result<string, TranspileError> { | |
| 44 | + const ts = getTypeScriptModule(); | |
| 45 | + if (!ts) return Result.ok(code); | |
| 46 | + | |
| 47 | + const target = ts.ScriptTarget?.ES2022 ?? ts.ScriptTarget?.ESNext; | |
| 48 | + const moduleKind = ts.ModuleKind?.ESNext; | |
| 49 | + | |
| 50 | + return Result.try({ | |
| 51 | + try: () => { | |
| 52 | + const result = ts.transpileModule(code, { | |
| 53 | + compilerOptions: { | |
| 54 | + ...(target !== undefined ? { target } : {}), | |
| 55 | + ...(moduleKind !== undefined ? { module: moduleKind } : {}), | |
| 56 | + }, | |
| 57 | + reportDiagnostics: true, | |
| 58 | + }); | |
| 59 | + | |
| 60 | + if (result.diagnostics && result.diagnostics.length > 0) { | |
| 61 | + const first = result.diagnostics[0]; | |
| 62 | + const message = ts.flattenDiagnosticMessageText( | |
| 63 | + first.messageText, | |
| 64 | + "\n", | |
| 65 | + ); | |
| 66 | + throw new TranspileError({ message: `TypeScript transpile error: ${message}` }); | |
| 67 | + } | |
| 68 | + | |
| 69 | + return result.outputText || code; | |
| 70 | + }, | |
| 71 | + catch: (e) => | |
| 72 | + e instanceof TranspileError | |
| 73 | + ? e | |
| 74 | + : new TranspileError({ | |
| 75 | + message: e instanceof Error ? e.message : String(e), | |
| 76 | + }), | |
| 77 | + }); | |
| 78 | +} | |
| @@ -321,6 +321,90 @@ test("compiled source artifacts can be materialized and executed", async () => { · 320 unmodified lines | ||
| 321 | 321 | } |
| 322 | 322 | }); |
| 323 | 323 | |
| 324 | +test("postman collection specs load and execute request tools", async () => { | |
| 325 | + const upstream = Bun.serve({ | |
| 326 | + port: 0, | |
| 327 | + fetch: (req) => { | |
| 328 | + const url = new URL(req.url); | |
| 329 | + if (req.method === "GET" && url.pathname.startsWith("/items/")) { | |
| 330 | + const id = url.pathname.split("/").filter(Boolean)[1] ?? ""; | |
| 331 | + return Response.json({ | |
| 332 | + ok: true, | |
| 333 | + id, | |
| 334 | + expand: url.searchParams.get("expand"), | |
| 335 | + limit: url.searchParams.get("limit"), | |
| 336 | + token: req.headers.get("x-token"), | |
| 337 | + }); | |
| 338 | + } | |
| 339 | + return new Response("not found", { status: 404 }); | |
| 340 | + }, | |
| 341 | + }); | |
| 342 | + | |
| 343 | + const proxy = Bun.serve({ | |
| 344 | + port: 0, | |
| 345 | + fetch: async (req) => { | |
| 346 | + const body = (await req.json()) as { service?: string; method?: string; path?: string }; | |
| 347 | + if (body.service === "sync" && body.method === "GET" && body.path === "/collection/123-demo?populate=true") { | |
| 348 | + return Response.json({ | |
| 349 | + data: { | |
| 350 | + name: "Demo collection", | |
| 351 | + variables: [{ key: "token", value: "Bearer demo-token" }], | |
| 352 | + folders: [{ id: "folder_items", name: "Items" }], | |
| 353 | + requests: [ | |
| 354 | + { | |
| 355 | + id: "request_get_item", | |
| 356 | + name: "Get item", | |
| 357 | + method: "GET", | |
| 358 | + url: `http://127.0.0.1:${upstream.port}/items/{{itemId}}`, | |
| 359 | + folder: "folder_items", | |
| 360 | + headerData: [{ key: "x-token", value: "{{token}}" }], | |
| 361 | + queryParams: [{ key: "expand", value: "details" }], | |
| 362 | + pathVariableData: [{ key: "itemId", value: "default-id" }], | |
| 363 | + }, | |
| 364 | + ], | |
| 365 | + }, | |
| 366 | + }); | |
| 367 | + } | |
| 368 | + return Response.json({ error: "unknown request" }, { status: 400 }); | |
| 369 | + }, | |
| 370 | + }); | |
| 371 | + | |
| 372 | + try { | |
| 373 | + const { tools, warnings } = await loadExternalTools([ | |
| 374 | + { | |
| 375 | + type: "openapi", | |
| 376 | + name: "postman-demo", | |
| 377 | + spec: "postman:123-demo", | |
| 378 | + postmanProxyUrl: `http://127.0.0.1:${proxy.port}`, | |
| 379 | + }, | |
| 380 | + ]); | |
| 381 | + | |
| 382 | + expect(warnings).toHaveLength(0); | |
| 383 | + expect(tools).toHaveLength(1); | |
| 384 | + expect(tools[0]?.source).toBe("catalog:postman-demo"); | |
| 385 | + expect(tools[0]?.path).toContain("items"); | |
| 386 | + | |
| 387 | + const result = await tools[0]!.run( | |
| 388 | + { | |
| 389 | + variables: { itemId: "abc-123" }, | |
| 390 | + query: { limit: 10 }, | |
| 391 | + }, | |
| 392 | + { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true }, | |
| 393 | + ); | |
| 394 | + | |
| 395 | + expect(result).toEqual({ | |
| 396 | + ok: true, | |
| 397 | + id: "abc-123", | |
| 398 | + expand: "details", | |
| 399 | + limit: "10", | |
| 400 | + token: "Bearer demo-token", | |
| 401 | + }); | |
| 402 | + } finally { | |
| 403 | + proxy.stop(true); | |
| 404 | + upstream.stop(true); | |
| 405 | + } | |
| 406 | +}); | |
| 407 | + | |
| 324 | 408 | test("parseGraphqlOperationPaths handles aliases and fragments", async () => { |
| 325 | 409 | const parsed = parseGraphqlOperationPaths( |
| 326 | 410 | "linear", |
| @@ -36,6 +36,10 @@ export interface OpenApiToolSourceConfig { · 35 unmodified lines | ||
| 36 | 36 | sourceId?: string; |
| 37 | 37 | sourceKey?: string; |
| 38 | 38 | spec: string | Record<string, unknown>; |
| 39 | + /** Optional canonical Postman collection URL for display/debugging. */ | |
| 40 | + collectionUrl?: string; | |
| 41 | + /** Optional override for Postman ws/proxy endpoint (primarily for tests). */ | |
| 42 | + postmanProxyUrl?: string; | |
| 39 | 43 | baseUrl?: string; |
| 40 | 44 | auth?: OpenApiAuth; |
| 41 | 45 | defaultReadApproval?: ToolApprovalMode; |
| @@ -62,6 +66,23 @@ export type ExternalToolSourceConfig = · 20 unmodified lines | ||
| 62 | 66 | | OpenApiToolSourceConfig |
| 63 | 67 | | GraphqlToolSourceConfig; |
| 64 | 68 | |
| 69 | +interface PostmanSerializedRunSpec { | |
| 70 | + kind: "postman"; | |
| 71 | + method: string; | |
| 72 | + url: string; | |
| 73 | + headers: Record<string, string>; | |
| 74 | + queryParams: Array<{ key: string; value: string }>; | |
| 75 | + body?: | |
| 76 | + | { kind: "urlencoded"; entries: Array<{ key: string; value: string }> } | |
| 77 | + | { kind: "raw"; text: string }; | |
| 78 | + variables: Record<string, string>; | |
| 79 | + authHeaders: Record<string, string>; | |
| 80 | +} | |
| 81 | + | |
| 82 | +const POSTMAN_SPEC_PREFIX = "postman:"; | |
| 83 | +const DEFAULT_POSTMAN_PROXY_URL = "https://www.postman.com/_api/ws/proxy"; | |
| 84 | +const POSTMAN_TEMPLATE_PATTERN = /\{\{([^{}]+)\}\}/g; | |
| 85 | + | |
| 65 | 86 | function sanitizeSegment(value: string): string { |
| 66 | 87 | const cleanedBase = value |
| 67 | 88 | .toLowerCase() |
| @@ -665,6 +686,161 @@ function getCredentialSourceKey(config: { · 597 unmodified lines | ||
| 665 | 686 | return config.sourceKey ?? `${config.type}:${config.name}`; |
| 666 | 687 | } |
| 667 | 688 | |
| 689 | +export function parsePostmanCollectionUid(spec: string): string | null { | |
| 690 | + if (!spec.startsWith(POSTMAN_SPEC_PREFIX)) { | |
| 691 | + return null; | |
| 692 | + } | |
| 693 | + | |
| 694 | + const uid = spec.slice(POSTMAN_SPEC_PREFIX.length).trim(); | |
| 695 | + if (!uid) { | |
| 696 | + return null; | |
| 697 | + } | |
| 698 | + | |
| 699 | + return uid; | |
| 700 | +} | |
| 701 | + | |
| 702 | +function stringifyTemplateValue(value: unknown): string { | |
| 703 | + if (value === null || value === undefined) return ""; | |
| 704 | + if (typeof value === "string") return value; | |
| 705 | + if (typeof value === "number" || typeof value === "boolean") return String(value); | |
| 706 | + return JSON.stringify(value); | |
| 707 | +} | |
| 708 | + | |
| 709 | +function interpolatePostmanTemplate(value: string, variables: Record<string, string>): string { | |
| 710 | + return value.replace(POSTMAN_TEMPLATE_PATTERN, (_, rawKey: string) => { | |
| 711 | + const key = rawKey.trim(); | |
| 712 | + return Object.prototype.hasOwnProperty.call(variables, key) | |
| 713 | + ? variables[key]! | |
| 714 | + : `{{${key}}}`; | |
| 715 | + }); | |
| 716 | +} | |
| 717 | + | |
| 718 | +function findUnresolvedPostmanTemplateKeys(value: string): string[] { | |
| 719 | + const keys = new Set<string>(); | |
| 720 | + let match: RegExpExecArray | null; | |
| 721 | + const pattern = new RegExp(POSTMAN_TEMPLATE_PATTERN.source, "g"); | |
| 722 | + while ((match = pattern.exec(value)) !== null) { | |
| 723 | + const key = String(match[1] ?? "").trim(); | |
| 724 | + if (key) keys.add(key); | |
| 725 | + } | |
| 726 | + return [...keys]; | |
| 727 | +} | |
| 728 | + | |
| 729 | +function asStringRecord(value: unknown): Record<string, string> { | |
| 730 | + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; | |
| 731 | + const result: Record<string, string> = {}; | |
| 732 | + for (const [key, entry] of Object.entries(value as Record<string, unknown>)) { | |
| 733 | + if (!key) continue; | |
| 734 | + result[key] = stringifyTemplateValue(entry); | |
| 735 | + } | |
| 736 | + return result; | |
| 737 | +} | |
| 738 | + | |
| 739 | +function detectJsonContentType(headers: Record<string, string>): boolean { | |
| 740 | + const contentType = Object.entries(headers).find(([name]) => name.toLowerCase() === "content-type")?.[1] ?? ""; | |
| 741 | + return contentType.toLowerCase().includes("json"); | |
| 742 | +} | |
| 743 | + | |
| 744 | +async function executePostmanRequest( | |
| 745 | + runSpec: PostmanSerializedRunSpec, | |
| 746 | + payload: Record<string, unknown>, | |
| 747 | + credentialHeaders?: Record<string, string>, | |
| 748 | +): Promise<unknown> { | |
| 749 | + const variables = { | |
| 750 | + ...runSpec.variables, | |
| 751 | + ...asStringRecord(payload.variables), | |
| 752 | + }; | |
| 753 | + | |
| 754 | + const interpolatedUrl = interpolatePostmanTemplate(runSpec.url, variables); | |
| 755 | + const unresolvedUrlKeys = findUnresolvedPostmanTemplateKeys(interpolatedUrl); | |
| 756 | + if (unresolvedUrlKeys.length > 0) { | |
| 757 | + throw new Error(`Missing required URL variables: ${unresolvedUrlKeys.join(", ")}`); | |
| 758 | + } | |
| 759 | + | |
| 760 | + let url: URL; | |
| 761 | + try { | |
| 762 | + url = new URL(interpolatedUrl); | |
| 763 | + } catch { | |
| 764 | + throw new Error(`Invalid request URL: ${interpolatedUrl}`); | |
| 765 | + } | |
| 766 | + | |
| 767 | + for (const entry of runSpec.queryParams) { | |
| 768 | + if (!entry.key) continue; | |
| 769 | + const value = interpolatePostmanTemplate(entry.value, variables); | |
| 770 | + if (value.length > 0) { | |
| 771 | + url.searchParams.set(entry.key, value); | |
| 772 | + } | |
| 773 | + } | |
| 774 | + | |
| 775 | + const queryOverrides = asRecord(payload.query); | |
| 776 | + for (const [key, value] of Object.entries(queryOverrides)) { | |
| 777 | + if (!key || value === undefined || value === null) continue; | |
| 778 | + url.searchParams.set(key, stringifyTemplateValue(value)); | |
| 779 | + } | |
| 780 | + | |
| 781 | + const headers: Record<string, string> = {}; | |
| 782 | + for (const [name, value] of Object.entries(runSpec.headers)) { | |
| 783 | + if (!name) continue; | |
| 784 | + headers[name] = interpolatePostmanTemplate(value, variables); | |
| 785 | + } | |
| 786 | + Object.assign(headers, runSpec.authHeaders); | |
| 787 | + Object.assign(headers, credentialHeaders ?? {}); | |
| 788 | + | |
| 789 | + const headerOverrides = asRecord(payload.headers); | |
| 790 | + for (const [name, value] of Object.entries(headerOverrides)) { | |
| 791 | + if (!name || value === undefined || value === null) continue; | |
| 792 | + headers[name] = stringifyTemplateValue(value); | |
| 793 | + } | |
| 794 | + | |
| 795 | + const method = runSpec.method.toUpperCase(); | |
| 796 | + const readMethods = new Set(["GET", "HEAD", "OPTIONS"]); | |
| 797 | + let body: string | undefined; | |
| 798 | + | |
| 799 | + if (!readMethods.has(method)) { | |
| 800 | + const hasExplicitBody = Object.prototype.hasOwnProperty.call(payload, "body"); | |
| 801 | + if (hasExplicitBody) { | |
| 802 | + const bodyValue = payload.body; | |
| 803 | + if (typeof bodyValue === "string") { | |
| 804 | + body = bodyValue; | |
| 805 | + } else if (bodyValue !== undefined) { | |
| 806 | + body = JSON.stringify(bodyValue); | |
| 807 | + if (!Object.keys(headers).some((name) => name.toLowerCase() === "content-type")) { | |
| 808 | + headers["content-type"] = "application/json"; | |
| 809 | + } | |
| 810 | + } | |
| 811 | + } else if (runSpec.body?.kind === "urlencoded") { | |
| 812 | + const params = new URLSearchParams(); | |
| 813 | + for (const entry of runSpec.body.entries) { | |
| 814 | + if (!entry.key) continue; | |
| 815 | + params.set(entry.key, interpolatePostmanTemplate(entry.value, variables)); | |
| 816 | + } | |
| 817 | + body = params.toString(); | |
| 818 | + if (!Object.keys(headers).some((name) => name.toLowerCase() === "content-type")) { | |
| 819 | + headers["content-type"] = "application/x-www-form-urlencoded"; | |
| 820 | + } | |
| 821 | + } else if (runSpec.body?.kind === "raw") { | |
| 822 | + body = interpolatePostmanTemplate(runSpec.body.text, variables); | |
| 823 | + } | |
| 824 | + } | |
| 825 | + | |
| 826 | + const response = await fetch(url.toString(), { | |
| 827 | + method, | |
| 828 | + headers, | |
| 829 | + body, | |
| 830 | + }); | |
| 831 | + | |
| 832 | + if (!response.ok) { | |
| 833 | + const text = await response.text().catch(() => ""); | |
| 834 | + throw new Error(`HTTP ${response.status} ${response.statusText}: ${text.slice(0, 500)}`); | |
| 835 | + } | |
| 836 | + | |
| 837 | + if (detectJsonContentType(headers) || (response.headers.get("content-type") ?? "").includes("json")) { | |
| 838 | + return await response.json(); | |
| 839 | + } | |
| 840 | + | |
| 841 | + return await response.text(); | |
| 842 | +} | |
| 843 | + | |
| 668 | 844 | function buildOpenApiUrl( |
| 669 | 845 | baseUrl: string, |
| 670 | 846 | pathTemplate: string, |
| @@ -828,7 +1004,6 @@ function compactOpenApiPaths( · 157 unmodified lines | ||
| 828 | 1004 | ? jsonSchemaTypeHintFallback(combinedSchema, 0, compSchemas) |
| 829 | 1005 | : "{}"; |
| 830 | 1006 | compactOperation._returnsTypeHint = responseTypeHintFromSchema(responseSchema, responseStatus, compSchemas); |
| 831 | − compactOperation._usesGeneratedTypes = true; | |
| 832 | 1007 | const previewKeys = [ |
| 833 | 1008 | ...mergedParameters.map((param) => String(param.name ?? "")).filter((name) => name.length > 0), |
| 834 | 1009 | ...Object.keys(asRecord(requestBodySchema.properties)), |
| @@ -1105,16 +1280,10 @@ export function buildOpenApiToolsFromPrepared( · 270 unmodified lines | ||
| 1105 | 1280 | } |
| 1106 | 1281 | } |
| 1107 | 1282 | |
| 1108 | − const usesGeneratedTypes = Boolean(operation._usesGeneratedTypes); | |
| 1109 | − const operationIdKey = JSON.stringify(operationIdRaw); | |
| 1110 | 1283 | const displayArgsType = argPreviewKeys.length > 0 |
| 1111 | 1284 | ? compactArgKeysHint(argPreviewKeys) |
| 1112 | − : usesGeneratedTypes | |
| 1113 | − ? `ToolInput<operations[${operationIdKey}]>` | |
| 1114 | − : compactArgTypeHint(argsType); | |
| 1115 | − const displayReturnsType = usesGeneratedTypes | |
| 1116 | − ? `ToolOutput<operations[${operationIdKey}]>` | |
| 1117 | − : compactReturnTypeHint(returnsType); | |
| 1285 | + : compactArgTypeHint(argsType); | |
| 1286 | + const displayReturnsType = compactReturnTypeHint(returnsType); | |
| 1118 | 1287 | |
| 1119 | 1288 | const approval = config.overrides?.[operationIdRaw]?.approval |
| 1120 | 1289 | ?? (readMethods.has(method) |
| @@ -1185,7 +1354,234 @@ export function buildOpenApiToolsFromPrepared( · 64 unmodified lines | ||
| 1185 | 1354 | return tools; |
| 1186 | 1355 | } |
| 1187 | 1356 | |
| 1357 | +function buildPostmanToolPath( | |
| 1358 | + sourceName: string, | |
| 1359 | + requestName: string, | |
| 1360 | + folderPath: string[], | |
| 1361 | + usedPaths: Set<string>, | |
| 1362 | +): string { | |
| 1363 | + const source = sanitizeSegment(sourceName); | |
| 1364 | + const segments = [ | |
| 1365 | + source, | |
| 1366 | + ...folderPath.map((segment) => sanitizeSegment(segment)).filter((segment) => segment.length > 0), | |
| 1367 | + sanitizeSnakeSegment(requestName), | |
| 1368 | + ]; | |
| 1369 | + const basePath = segments.join("."); | |
| 1370 | + | |
| 1371 | + let path = basePath; | |
| 1372 | + let suffix = 2; | |
| 1373 | + while (usedPaths.has(path)) { | |
| 1374 | + path = `${basePath}_${suffix}`; | |
| 1375 | + suffix += 1; | |
| 1376 | + } | |
| 1377 | + usedPaths.add(path); | |
| 1378 | + return path; | |
| 1379 | +} | |
| 1380 | + | |
| 1381 | +function resolvePostmanFolderPath( | |
| 1382 | + folderId: string | undefined, | |
| 1383 | + folderById: Map<string, { name: string; parentId?: string }>, | |
| 1384 | +): string[] { | |
| 1385 | + const path: string[] = []; | |
| 1386 | + let cursor = folderId; | |
| 1387 | + let safety = 0; | |
| 1388 | + while (cursor && safety < 100) { | |
| 1389 | + safety += 1; | |
| 1390 | + const folder = folderById.get(cursor); | |
| 1391 | + if (!folder) break; | |
| 1392 | + path.unshift(folder.name); | |
| 1393 | + cursor = folder.parentId; | |
| 1394 | + } | |
| 1395 | + return path; | |
| 1396 | +} | |
| 1397 | + | |
| 1398 | +function extractPostmanVariableMap(value: unknown): Record<string, string> { | |
| 1399 | + if (!Array.isArray(value)) return {}; | |
| 1400 | + const result: Record<string, string> = {}; | |
| 1401 | + for (const entry of value) { | |
| 1402 | + if (!entry || typeof entry !== "object") continue; | |
| 1403 | + const record = entry as Record<string, unknown>; | |
| 1404 | + const key = typeof record.key === "string" ? record.key.trim() : ""; | |
| 1405 | + if (!key) continue; | |
| 1406 | + if (record.disabled === true) continue; | |
| 1407 | + result[key] = stringifyTemplateValue(record.value); | |
| 1408 | + } | |
| 1409 | + return result; | |
| 1410 | +} | |
| 1411 | + | |
| 1412 | +function extractPostmanHeaderMap(value: unknown): Record<string, string> { | |
| 1413 | + if (!Array.isArray(value)) return {}; | |
| 1414 | + const result: Record<string, string> = {}; | |
| 1415 | + for (const entry of value) { | |
| 1416 | + if (!entry || typeof entry !== "object") continue; | |
| 1417 | + const record = entry as Record<string, unknown>; | |
| 1418 | + const key = typeof record.key === "string" ? record.key.trim() : ""; | |
| 1419 | + if (!key || record.disabled === true) continue; | |
| 1420 | + result[key] = stringifyTemplateValue(record.value); | |
| 1421 | + } | |
| 1422 | + return result; | |
| 1423 | +} | |
| 1424 | + | |
| 1425 | +function extractPostmanQueryEntries(value: unknown): Array<{ key: string; value: string }> { | |
| 1426 | + if (!Array.isArray(value)) return []; | |
| 1427 | + const entries: Array<{ key: string; value: string }> = []; | |
| 1428 | + for (const entry of value) { | |
| 1429 | + if (!entry || typeof entry !== "object") continue; | |
| 1430 | + const record = entry as Record<string, unknown>; | |
| 1431 | + const key = typeof record.key === "string" ? record.key.trim() : ""; | |
| 1432 | + if (!key || record.disabled === true) continue; | |
| 1433 | + entries.push({ key, value: stringifyTemplateValue(record.value) }); | |
| 1434 | + } | |
| 1435 | + return entries; | |
| 1436 | +} | |
| 1437 | + | |
| 1438 | +function extractPostmanBody(record: Record<string, unknown>): PostmanSerializedRunSpec["body"] { | |
| 1439 | + const dataMode = typeof record.dataMode === "string" ? record.dataMode.toLowerCase() : ""; | |
| 1440 | + if (dataMode === "urlencoded" && Array.isArray(record.data)) { | |
| 1441 | + const entries: Array<{ key: string; value: string }> = []; | |
| 1442 | + for (const item of record.data) { | |
| 1443 | + if (!item || typeof item !== "object") continue; | |
| 1444 | + const entry = item as Record<string, unknown>; | |
| 1445 | + const key = typeof entry.key === "string" ? entry.key.trim() : ""; | |
| 1446 | + if (!key || entry.disabled === true) continue; | |
| 1447 | + entries.push({ key, value: stringifyTemplateValue(entry.value) }); | |
| 1448 | + } | |
| 1449 | + return entries.length > 0 ? { kind: "urlencoded", entries } : undefined; | |
| 1450 | + } | |
| 1451 | + | |
| 1452 | + if (typeof record.rawModeData === "string" && record.rawModeData.length > 0) { | |
| 1453 | + return { kind: "raw", text: record.rawModeData }; | |
| 1454 | + } | |
| 1455 | + | |
| 1456 | + return undefined; | |
| 1457 | +} | |
| 1458 | + | |
| 1459 | +async function loadPostmanCollectionTools( | |
| 1460 | + config: OpenApiToolSourceConfig, | |
| 1461 | + collectionUid: string, | |
| 1462 | +): Promise<ToolDefinition[]> { | |
| 1463 | + const proxyUrl = config.postmanProxyUrl ?? DEFAULT_POSTMAN_PROXY_URL; | |
| 1464 | + const payload = { | |
| 1465 | + service: "sync", | |
| 1466 | + method: "GET", | |
| 1467 | + path: `/collection/${collectionUid}?populate=true`, | |
| 1468 | + }; | |
| 1469 | + | |
| 1470 | + const response = await fetch(proxyUrl, { | |
| 1471 | + method: "POST", | |
| 1472 | + headers: { | |
| 1473 | + "content-type": "application/json", | |
| 1474 | + }, | |
| 1475 | + body: JSON.stringify(payload), | |
| 1476 | + }); | |
| 1477 | + | |
| 1478 | + if (!response.ok) { | |
| 1479 | + const text = await response.text().catch(() => ""); | |
| 1480 | + throw new Error(`Failed to fetch API collection ${collectionUid}: HTTP ${response.status} ${text.slice(0, 300)}`); | |
| 1481 | + } | |
| 1482 | + | |
| 1483 | + const raw = await response.json() as Record<string, unknown>; | |
| 1484 | + const collection = asRecord(raw.data); | |
| 1485 | + const requests = Array.isArray(collection.requests) | |
| 1486 | + ? collection.requests.filter((entry): entry is Record<string, unknown> => Boolean(entry && typeof entry === "object")) | |
| 1487 | + : []; | |
| 1488 | + | |
| 1489 | + const folders = Array.isArray(collection.folders) | |
| 1490 | + ? collection.folders.filter((entry): entry is Record<string, unknown> => Boolean(entry && typeof entry === "object")) | |
| 1491 | + : []; | |
| 1492 | + | |
| 1493 | + const folderById = new Map<string, { name: string; parentId?: string }>(); | |
| 1494 | + for (const folder of folders) { | |
| 1495 | + const id = typeof folder.id === "string" ? folder.id : ""; | |
| 1496 | + if (!id) continue; | |
| 1497 | + const name = typeof folder.name === "string" && folder.name.trim().length > 0 ? folder.name : "folder"; | |
| 1498 | + const parentId = typeof folder.folder === "string" ? folder.folder : undefined; | |
| 1499 | + folderById.set(id, { name, parentId }); | |
| 1500 | + } | |
| 1501 | + | |
| 1502 | + const sourceLabel = `catalog:${config.name}`; | |
| 1503 | + const authHeaders = buildStaticAuthHeaders(config.auth); | |
| 1504 | + const credentialSourceKey = getCredentialSourceKey(config); | |
| 1505 | + const credentialSpec = buildCredentialSpec(credentialSourceKey, config.auth); | |
| 1506 | + const readMethods = new Set(["get", "head", "options"]); | |
| 1507 | + const usedPaths = new Set<string>(); | |
| 1508 | + const collectionVariables = extractPostmanVariableMap(collection.variables); | |
| 1509 | + const argsType = "{ variables?: Record<string, string | number | boolean>; query?: Record<string, string | number | boolean>; headers?: Record<string, string>; body?: unknown }"; | |
| 1510 | + const returnsType = "unknown"; | |
| 1511 | + | |
| 1512 | + const tools: ToolDefinition[] = []; | |
| 1513 | + | |
| 1514 | + for (const request of requests) { | |
| 1515 | + const methodRaw = typeof request.method === "string" ? request.method.toLowerCase() : "get"; | |
| 1516 | + const method = methodRaw.length > 0 ? methodRaw : "get"; | |
| 1517 | + const url = typeof request.url === "string" ? request.url : ""; | |
| 1518 | + if (!url) continue; | |
| 1519 | + | |
| 1520 | + const requestId = typeof request.id === "string" ? request.id : ""; | |
| 1521 | + const requestName = typeof request.name === "string" && request.name.trim().length > 0 | |
| 1522 | + ? request.name.trim() | |
| 1523 | + : requestId || `${method.toUpperCase()} request`; | |
| 1524 | + const folderId = typeof request.folder === "string" ? request.folder : undefined; | |
| 1525 | + const folderPath = resolvePostmanFolderPath(folderId, folderById); | |
| 1526 | + const requestVariables = { | |
| 1527 | + ...collectionVariables, | |
| 1528 | + ...extractPostmanVariableMap(request.pathVariableData), | |
| 1529 | + }; | |
| 1530 | + | |
| 1531 | + const runSpec: PostmanSerializedRunSpec = { | |
| 1532 | + kind: "postman", | |
| 1533 | + method, | |
| 1534 | + url, | |
| 1535 | + headers: extractPostmanHeaderMap(request.headerData), | |
| 1536 | + queryParams: extractPostmanQueryEntries(request.queryParams), | |
| 1537 | + body: extractPostmanBody(request), | |
| 1538 | + variables: requestVariables, | |
| 1539 | + authHeaders, | |
| 1540 | + }; | |
| 1541 | + | |
| 1542 | + const approval = config.overrides?.[requestId]?.approval | |
| 1543 | + ?? config.overrides?.[requestName]?.approval | |
| 1544 | + ?? (readMethods.has(method) | |
| 1545 | + ? config.defaultReadApproval ?? "auto" | |
| 1546 | + : config.defaultWriteApproval ?? "required"); | |
| 1547 | + | |
| 1548 | + const tool: ToolDefinition & { _runSpec: SerializedTool["runSpec"] } = { | |
| 1549 | + path: buildPostmanToolPath(config.name, requestName, folderPath, usedPaths), | |
| 1550 | + source: sourceLabel, | |
| 1551 | + approval, | |
| 1552 | + description: typeof request.description === "string" && request.description.trim().length > 0 | |
| 1553 | + ? request.description | |
| 1554 | + : `${method.toUpperCase()} ${url}`, | |
| 1555 | + metadata: { | |
| 1556 | + argsType, | |
| 1557 | + returnsType, | |
| 1558 | + displayArgsType: compactArgTypeHint(argsType), | |
| 1559 | + displayReturnsType: compactReturnTypeHint(returnsType), | |
| 1560 | + argPreviewKeys: ["variables", "query", "headers", "body"], | |
| 1561 | + operationId: requestId || requestName, | |
| 1562 | + }, | |
| 1563 | + credential: credentialSpec, | |
| 1564 | + _runSpec: runSpec, | |
| 1565 | + run: async (input: unknown, context) => { | |
| 1566 | + const payloadRecord = asRecord(input); | |
| 1567 | + return await executePostmanRequest(runSpec, payloadRecord, context.credential?.headers); | |
| 1568 | + }, | |
| 1569 | + }; | |
| 1570 | + | |
| 1571 | + tools.push(tool); | |
| 1572 | + } | |
| 1573 | + | |
| 1574 | + return tools; | |
| 1575 | +} | |
| 1576 | + | |
| 1188 | 1577 | async function loadOpenApiTools(config: OpenApiToolSourceConfig): Promise<ToolDefinition[]> { |
| 1578 | + if (typeof config.spec === "string") { | |
| 1579 | + const collectionUid = parsePostmanCollectionUid(config.spec); | |
| 1580 | + if (collectionUid) { | |
| 1581 | + return await loadPostmanCollectionTools(config, collectionUid); | |
| 1582 | + } | |
| 1583 | + } | |
| 1584 | + | |
| 1189 | 1585 | const prepared = await prepareOpenApiSpec(config.spec, config.name); |
| 1190 | 1586 | return buildOpenApiToolsFromPrepared(config, prepared); |
| 1191 | 1587 | } |
| @@ -1895,6 +2291,7 @@ export interface SerializedTool { · 703 unmodified lines | ||
| 1895 | 2291 | * Data needed to reconstruct `run()`. Shape depends on source type. |
| 1896 | 2292 | * OpenAPI: { kind: "openapi", baseUrl, method, pathTemplate, parameters, authHeaders } |
| 1897 | 2293 | * MCP: { kind: "mcp", url, transport?, queryParams?, toolName } |
| 2294 | + * Postman: { kind: "postman", method, url, headers, queryParams, body?, variables, authHeaders } | |
| 1898 | 2295 | * GraphQL raw: { kind: "graphql_raw", endpoint, authHeaders } |
| 1899 | 2296 | * GraphQL field: { kind: "graphql_field", endpoint, operationName, operationType, queryTemplate, argNames?, authHeaders } |
| 1900 | 2297 | * Builtin: { kind: "builtin" } — run comes from DEFAULT_TOOLS |
| @@ -1915,6 +2312,7 @@ export interface SerializedTool { · 14 unmodified lines | ||
| 1915 | 2312 | queryParams?: Record<string, string>; |
| 1916 | 2313 | toolName: string; |
| 1917 | 2314 | } |
| 2315 | + | PostmanSerializedRunSpec | |
| 1918 | 2316 | | { |
| 1919 | 2317 | kind: "graphql_raw"; |
| 1920 | 2318 | endpoint: string; |
| @@ -2062,6 +2460,17 @@ export function rehydrateTools( · 141 unmodified lines | ||
| 2062 | 2460 | }; |
| 2063 | 2461 | } |
| 2064 | 2462 | |
| 2463 | + if (st.runSpec.kind === "postman") { | |
| 2464 | + const runSpec = st.runSpec; | |
| 2465 | + return { | |
| 2466 | + ...base, | |
| 2467 | + run: async (input: unknown, context) => { | |
| 2468 | + const payload = asRecord(input); | |
| 2469 | + return await executePostmanRequest(runSpec, payload, context.credential?.headers); | |
| 2470 | + }, | |
| 2471 | + }; | |
| 2472 | + } | |
| 2473 | + | |
| 2065 | 2474 | if (st.runSpec.kind === "mcp") { |
| 2066 | 2475 | const { url, transport, queryParams, toolName } = st.runSpec; |
| 2067 | 2476 | return { |
| @@ -307,6 +307,47 @@ export interface operations { · 306 unmodified lines | ||
| 307 | 307 | expect(bad.errors.length).toBeGreaterThan(0); |
| 308 | 308 | }); |
| 309 | 309 | |
| 310 | + test("OpenAPI helper types infer vendor JSON response payloads", () => { | |
| 311 | + const tools: ToolDescriptor[] = [ | |
| 312 | + { | |
| 313 | + path: "github.activity.get_feeds", | |
| 314 | + description: "Get feeds", | |
| 315 | + approval: "auto", | |
| 316 | + source: "openapi:github", | |
| 317 | + operationId: "activity/get-feeds", | |
| 318 | + }, | |
| 319 | + ]; | |
| 320 | + | |
| 321 | + const sourceDts = ` | |
| 322 | +export interface operations { | |
| 323 | + "activity/get-feeds": { | |
| 324 | + parameters: { query?: never; path?: never; header?: never; cookie?: never }; | |
| 325 | + responses: { | |
| 326 | + 200: { content: { "application/vnd.github+json": { current_user_url: string; timeline_url: string } } }; | |
| 327 | + }; | |
| 328 | + }; | |
| 329 | +} | |
| 330 | +`; | |
| 331 | + | |
| 332 | + const declarations = generateToolDeclarations(tools, { | |
| 333 | + sourceDtsBySource: { | |
| 334 | + "openapi:github": sourceDts, | |
| 335 | + }, | |
| 336 | + }); | |
| 337 | + | |
| 338 | + const ok = typecheckCode( | |
| 339 | + "const feed = await tools.github.activity.get_feeds({}); return feed.current_user_url;", | |
| 340 | + declarations, | |
| 341 | + ); | |
| 342 | + expect(ok.ok).toBe(true); | |
| 343 | + | |
| 344 | + const bad = typecheckCode( | |
| 345 | + "const feed = await tools.github.activity.get_feeds({}); return feed.missing_field;", | |
| 346 | + declarations, | |
| 347 | + ); | |
| 348 | + expect(bad.ok).toBe(false); | |
| 349 | + }); | |
| 350 | + | |
| 310 | 351 | test("optional parameter can be provided", () => { |
| 311 | 352 | const result = typecheckCode( |
| 312 | 353 | 'const r = await tools.admin.send_announcement({ message: "hi", channel: "general" }); return r.sent;', |
| @@ -424,4 +465,3 @@ export interface operations { · 111 unmodified lines | ||
| 424 | 465 | }); |
| 425 | 466 | }); |
| 426 | 467 | |
| 427 | − | |
| @@ -13,6 +13,7 @@ · 12 unmodified lines | ||
| 13 | 13 | * without needing their own TypeScript setup. |
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | +import { Result } from "better-result"; | |
| 16 | 17 | import type { ToolDescriptor } from "./types"; |
| 17 | 18 | |
| 18 | 19 | // --------------------------------------------------------------------------- |
| @@ -23,11 +24,8 @@ let cachedTypeScript: typeof import("typescript") | null | undefined; · 4 unmodified lines | ||
| 23 | 24 | |
| 24 | 25 | function getTypeScriptModule(): typeof import("typescript") | null { |
| 25 | 26 | if (cachedTypeScript === undefined) { |
| 26 | − try { | |
| 27 | − cachedTypeScript = require("typescript"); | |
| 28 | − } catch { | |
| 29 | − cachedTypeScript = null; | |
| 30 | − } | |
| 27 | + const loaded = Result.try(() => require("typescript") as typeof import("typescript")); | |
| 28 | + cachedTypeScript = loaded.isOk() ? loaded.value : null; | |
| 31 | 29 | } |
| 32 | 30 | return cachedTypeScript ?? null; |
| 33 | 31 | } |
| @@ -39,7 +37,7 @@ function isValidTypeExpression(typeExpression: string): boolean { · 5 unmodified lines | ||
| 39 | 37 | return !/[\r\n`]/.test(typeExpression); |
| 40 | 38 | } |
| 41 | 39 | |
| 42 | − try { | |
| 40 | + return Result.try(() => { | |
| 43 | 41 | const sourceFile = ts.createSourceFile( |
| 44 | 42 | "_type_expr_check.ts", |
| 45 | 43 | `type __T = ${typeExpression};`, |
| @@ -51,9 +49,7 @@ function isValidTypeExpression(typeExpression: string): boolean { · 5 unmodified lines | ||
| 51 | 49 | sourceFile as unknown as { parseDiagnostics?: import("typescript").Diagnostic[] } |
| 52 | 50 | ).parseDiagnostics ?? []; |
| 53 | 51 | return diagnostics.length === 0; |
| 54 | − } catch { | |
| 55 | − return false; | |
| 56 | − } | |
| 52 | + }).unwrapOr(false); | |
| 57 | 53 | } |
| 58 | 54 | |
| 59 | 55 | function safeTypeExpression(raw: string | undefined, fallback: string): string { |
| @@ -63,20 +59,62 @@ function safeTypeExpression(raw: string | undefined, fallback: string): string { · 3 unmodified lines | ||
| 63 | 59 | } |
| 64 | 60 | |
| 65 | 61 | const OPENAPI_HELPER_TYPES = ` |
| 66 | −type _OrEmpty<T> = [T] extends [never] ? {} : T; | |
| 62 | +type _Normalize<T> = Exclude<T, undefined | null>; | |
| 63 | +type _OrEmpty<T> = [_Normalize<T>] extends [never] ? {} : _Normalize<T>; | |
| 67 | 64 | type _Simplify<T> = { [K in keyof T]: T[K] } & {}; |
| 65 | +type _ParamsOf<Op> = | |
| 66 | + Op extends { parameters: infer P } ? P : | |
| 67 | + Op extends { parameters?: infer P } ? P : | |
| 68 | + never; | |
| 69 | +type _ParamAt<Op, K extends "query" | "path" | "header" | "cookie"> = | |
| 70 | + _ParamsOf<Op> extends { [P in K]?: infer V } ? V : never; | |
| 71 | +type _BodyOf<Op> = | |
| 72 | + Op extends { requestBody?: infer B } ? B : | |
| 73 | + Op extends { requestBody: infer B } ? B : | |
| 74 | + never; | |
| 75 | +type _BodyContent<B> = | |
| 76 | + B extends { content: infer C } | |
| 77 | + ? C extends Record<string, infer V> ? V : never | |
| 78 | + : never; | |
| 68 | 79 | type ToolInput<Op> = _Simplify< |
| 69 | − _OrEmpty<Op extends { parameters: { query: infer Q } } ? { [K in keyof Q]: Q[K] } : never> & | |
| 70 | − _OrEmpty<Op extends { parameters: { path: infer P } } ? { [K in keyof P]: P[K] } : never> & | |
| 71 | − _OrEmpty<Op extends { requestBody: { content: { "application/json": infer B } } } ? B : never> | |
| 80 | + _OrEmpty<_ParamAt<Op, "query">> & | |
| 81 | + _OrEmpty<_ParamAt<Op, "path">> & | |
| 82 | + _OrEmpty<_ParamAt<Op, "header">> & | |
| 83 | + _OrEmpty<_ParamAt<Op, "cookie">> & | |
| 84 | + _OrEmpty<_BodyContent<_BodyOf<Op>>> | |
| 72 | 85 | >; |
| 73 | −type ToolOutput<Op> = | |
| 74 | − Op extends { responses: { 200: { content: { "application/json": infer R } } } } ? R : | |
| 75 | − Op extends { responses: { 201: { content: { "application/json": infer R } } } } ? R : | |
| 76 | − Op extends { responses: { 202: { content: { "application/json": infer R } } } } ? R : | |
| 77 | − Op extends { responses: { 204: unknown } } ? void : | |
| 78 | − Op extends { responses: { 205: unknown } } ? void : | |
| 79 | − unknown; | |
| 86 | +type _ResponsesOf<Op> = Op extends { responses: infer R } ? R : never; | |
| 87 | +type _RespAt<Op, Code extends PropertyKey> = | |
| 88 | + _ResponsesOf<Op> extends { [K in Code]?: infer R } ? R : never; | |
| 89 | +type _ResponsePayload<R> = | |
| 90 | + [R] extends [never] ? never : | |
| 91 | + R extends { content: infer C } | |
| 92 | + ? C extends Record<string, infer V> ? V : unknown | |
| 93 | + : R extends { schema: infer S } ? S : unknown; | |
| 94 | +type _HasStatus<Op, Code extends PropertyKey> = | |
| 95 | + [_RespAt<Op, Code>] extends [never] ? false : true; | |
| 96 | +type _PayloadAt<Op, Code extends PropertyKey> = | |
| 97 | + Code extends 204 | 205 | |
| 98 | + ? (_HasStatus<Op, Code> extends true ? void : never) | |
| 99 | + : _ResponsePayload<_RespAt<Op, Code>>; | |
| 100 | +type _FirstKnown<T extends readonly unknown[]> = | |
| 101 | + T extends readonly [infer H, ...infer Rest] | |
| 102 | + ? [H] extends [never] ? _FirstKnown<Rest> : H | |
| 103 | + : unknown; | |
| 104 | +type ToolOutput<Op> = _FirstKnown<[ | |
| 105 | + _PayloadAt<Op, 200>, | |
| 106 | + _PayloadAt<Op, 201>, | |
| 107 | + _PayloadAt<Op, 202>, | |
| 108 | + _PayloadAt<Op, 203>, | |
| 109 | + _PayloadAt<Op, 204>, | |
| 110 | + _PayloadAt<Op, 205>, | |
| 111 | + _PayloadAt<Op, 206>, | |
| 112 | + _PayloadAt<Op, 207>, | |
| 113 | + _PayloadAt<Op, 208>, | |
| 114 | + _PayloadAt<Op, 226>, | |
| 115 | + _PayloadAt<Op, "default">, | |
| 116 | + unknown | |
| 117 | +]>; | |
| 80 | 118 | `; |
| 81 | 119 | |
| 82 | 120 | function stripExportKeywordsForTypechecker(dts: string): string { |
| @@ -233,6 +271,8 @@ export interface TypecheckResult { · 150 unmodified lines | ||
| 233 | 271 | readonly errors: readonly string[]; |
| 234 | 272 | } |
| 235 | 273 | |
| 274 | +const TYPECHECK_OK: TypecheckResult = { ok: true, errors: [] }; | |
| 275 | + | |
| 236 | 276 | let warnedMissingCompilerHostSupport = false; |
| 237 | 277 | let warnedSemanticFallback = false; |
| 238 | 278 | |
| @@ -245,7 +285,7 @@ function runSyntaxOnlyTypecheck( · 6 unmodified lines | ||
| 245 | 285 | headerLineCount: number, |
| 246 | 286 | ) => string, |
| 247 | 287 | ): TypecheckResult { |
| 248 | − try { | |
| 288 | + return Result.try(() => { | |
| 249 | 289 | const sourceFile = ts.createSourceFile( |
| 250 | 290 | "generated.ts", |
| 251 | 291 | wrappedCode, |
| @@ -256,16 +296,72 @@ function runSyntaxOnlyTypecheck( · 4 unmodified lines | ||
| 256 | 296 | const diagnostics = ( |
| 257 | 297 | sourceFile as unknown as { parseDiagnostics?: import("typescript").Diagnostic[] } |
| 258 | 298 | ).parseDiagnostics ?? []; |
| 259 | − if (diagnostics.length === 0) { | |
| 260 | − return { ok: true, errors: [] }; | |
| 261 | − } | |
| 299 | + if (diagnostics.length === 0) return TYPECHECK_OK; | |
| 262 | 300 | return { |
| 263 | − ok: false, | |
| 264 | − errors: diagnostics.map((d: import("typescript").Diagnostic) => formatError(d, headerLineCount)), | |
| 301 | + ok: false as const, | |
| 302 | + errors: diagnostics.map((d) => formatError(d, headerLineCount)), | |
| 265 | 303 | }; |
| 266 | − } catch { | |
| 267 | − return { ok: true, errors: [] }; | |
| 268 | − } | |
| 304 | + }).unwrapOr(TYPECHECK_OK); | |
| 305 | +} | |
| 306 | + | |
| 307 | +function runSemanticTypecheck( | |
| 308 | + ts: typeof import("typescript"), | |
| 309 | + wrappedCode: string, | |
| 310 | + headerLineCount: number, | |
| 311 | + formatError: ( | |
| 312 | + diagnostic: import("typescript").Diagnostic, | |
| 313 | + headerLineCount: number, | |
| 314 | + ) => string, | |
| 315 | +): Result<TypecheckResult, Error> { | |
| 316 | + return Result.try({ | |
| 317 | + try: () => { | |
| 318 | + const sourceFile = ts.createSourceFile( | |
| 319 | + "generated.ts", | |
| 320 | + wrappedCode, | |
| 321 | + ts.ScriptTarget.ESNext, | |
| 322 | + true, | |
| 323 | + ts.ScriptKind.TS, | |
| 324 | + ); | |
| 325 | + | |
| 326 | + const compilerOptions: import("typescript").CompilerOptions = { | |
| 327 | + target: ts.ScriptTarget.ESNext, | |
| 328 | + module: ts.ModuleKind.ESNext, | |
| 329 | + strict: true, | |
| 330 | + noEmit: true, | |
| 331 | + lib: ["lib.es2022.d.ts"], | |
| 332 | + types: [], // prevent automatic @types/* from conflicting with sandbox declarations | |
| 333 | + }; | |
| 334 | + | |
| 335 | + const host = ts.createCompilerHost(compilerOptions); | |
| 336 | + const originalGetSourceFile = host.getSourceFile.bind(host); | |
| 337 | + host.getSourceFile = (fileName, languageVersion) => { | |
| 338 | + if (fileName === "generated.ts") return sourceFile; | |
| 339 | + return originalGetSourceFile(fileName, languageVersion); | |
| 340 | + }; | |
| 341 | + | |
| 342 | + const program = ts.createProgram(["generated.ts"], compilerOptions, host); | |
| 343 | + const diagnostics = program.getSemanticDiagnostics(sourceFile); | |
| 344 | + | |
| 345 | + if (diagnostics.length === 0) return TYPECHECK_OK; | |
| 346 | + | |
| 347 | + // Filter out errors from the .d.ts header — only report user code errors | |
| 348 | + const userErrors = diagnostics.filter((d) => { | |
| 349 | + if (d.start !== undefined && d.file) { | |
| 350 | + const { line } = d.file.getLineAndCharacterOfPosition(d.start); | |
| 351 | + return line + 1 > headerLineCount; | |
| 352 | + } | |
| 353 | + return false; | |
| 354 | + }); | |
| 355 | + | |
| 356 | + if (userErrors.length === 0) return TYPECHECK_OK; | |
| 357 | + | |
| 358 | + return { | |
| 359 | + ok: false as const, | |
| 360 | + errors: userErrors.map((d) => formatError(d, headerLineCount)), | |
| 361 | + }; | |
| 362 | + }, | |
| 363 | + catch: (e) => (e instanceof Error ? e : new Error(String(e))), | |
| 364 | + }); | |
| 269 | 365 | } |
| 270 | 366 | |
| 271 | 367 | /** |
| @@ -278,13 +374,8 @@ export function typecheckCode( · 6 unmodified lines | ||
| 278 | 374 | code: string, |
| 279 | 375 | toolDeclarations: string, |
| 280 | 376 | ): TypecheckResult { |
| 281 | − let ts: typeof import("typescript"); | |
| 282 | − try { | |
| 283 | − ts = require("typescript"); | |
| 284 | − } catch { | |
| 285 | − // TypeScript not available — skip typechecking | |
| 286 | − return { ok: true, errors: [] }; | |
| 287 | − } | |
| 377 | + const ts = getTypeScriptModule(); | |
| 378 | + if (!ts) return TYPECHECK_OK; | |
| 288 | 379 | |
| 289 | 380 | // Wrap the code in an async function with the tools declaration. |
| 290 | 381 | // We declare sandbox globals (console, setTimeout, etc.) ourselves since |
| @@ -301,12 +392,12 @@ export function typecheckCode( · 10 unmodified lines | ||
| 301 | 392 | |
| 302 | 393 | const formatError = ( |
| 303 | 394 | diagnostic: import("typescript").Diagnostic, |
| 304 | − headerLineCount: number, | |
| 395 | + hdrLineCount: number, | |
| 305 | 396 | ): string => { |
| 306 | 397 | const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"); |
| 307 | 398 | if (diagnostic.start !== undefined && diagnostic.file) { |
| 308 | 399 | const { line } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); |
| 309 | − const adjustedLine = line + 1 - headerLineCount; | |
| 400 | + const adjustedLine = line + 1 - hdrLineCount; | |
| 310 | 401 | if (adjustedLine > 0) { |
| 311 | 402 | return `Line ${adjustedLine}: ${message}`; |
| 312 | 403 | } |
| @@ -328,66 +419,16 @@ export function typecheckCode( · 15 unmodified lines | ||
| 328 | 419 | return runSyntaxOnlyTypecheck(ts, wrappedCode, headerLineCount, formatError); |
| 329 | 420 | } |
| 330 | 421 | |
| 331 | − try { | |
| 332 | − const sourceFile = ts.createSourceFile( | |
| 333 | − "generated.ts", | |
| 334 | − wrappedCode, | |
| 335 | − ts.ScriptTarget.ESNext, | |
| 336 | − true, | |
| 337 | − ts.ScriptKind.TS, | |
| 338 | − ); | |
| 339 | − | |
| 340 | − const compilerOptions: import("typescript").CompilerOptions = { | |
| 341 | − target: ts.ScriptTarget.ESNext, | |
| 342 | − module: ts.ModuleKind.ESNext, | |
| 343 | − strict: true, | |
| 344 | − noEmit: true, | |
| 345 | − lib: ["lib.es2022.d.ts"], | |
| 346 | − types: [], // prevent automatic @types/* (e.g. @types/node) from conflicting with our sandbox declarations | |
| 347 | − }; | |
| 348 | − | |
| 349 | − const host = ts.createCompilerHost(compilerOptions); | |
| 350 | − const originalGetSourceFile = host.getSourceFile.bind(host); | |
| 351 | − host.getSourceFile = (fileName, languageVersion) => { | |
| 352 | − if (fileName === "generated.ts") return sourceFile; | |
| 353 | − return originalGetSourceFile(fileName, languageVersion); | |
| 354 | − }; | |
| 355 | − | |
| 356 | − const program = ts.createProgram(["generated.ts"], compilerOptions, host); | |
| 357 | − const diagnostics = program.getSemanticDiagnostics(sourceFile); | |
| 358 | − | |
| 359 | − if (diagnostics.length === 0) { | |
| 360 | − return { ok: true, errors: [] }; | |
| 361 | − } | |
| 362 | − | |
| 363 | − // Filter out errors from the .d.ts header (circular refs, etc.) — only report user code errors | |
| 364 | − const userErrors = diagnostics.filter((d) => { | |
| 365 | − if (d.start !== undefined && d.file) { | |
| 366 | − const { line } = d.file.getLineAndCharacterOfPosition(d.start); | |
| 367 | − return line + 1 > headerLineCount; | |
| 368 | − } | |
| 369 | − return false; | |
| 370 | − }); | |
| 422 | + const semantic = runSemanticTypecheck(ts, wrappedCode, headerLineCount, formatError); | |
| 423 | + if (semantic.isOk()) return semantic.value; | |
| 371 | 424 | |
| 372 | − if (userErrors.length === 0) { | |
| 373 | − return { ok: true, errors: [] }; | |
| 374 | − } | |
| 375 | − | |
| 376 | − return { | |
| 377 | − ok: false, | |
| 378 | − errors: userErrors.map((d) => formatError(d, headerLineCount)), | |
| 379 | − }; | |
| 380 | − } catch (error) { | |
| 381 | − // Some runtimes (e.g. Convex action isolates) can lack the full Node-backed | |
| 382 | − // TypeScript host environment. If semantic typechecking cannot initialize, | |
| 383 | − // fall back to syntax-only parsing instead of failing the MCP call. | |
| 384 | − if (!warnedSemanticFallback) { | |
| 385 | − warnedSemanticFallback = true; | |
| 386 | − console.warn( | |
| 387 | − `[executor] TypeScript semantic typecheck unavailable, falling back to syntax-only checks: ${error instanceof Error ? error.message : String(error)}`, | |
| 388 | − ); | |
| 389 | − } | |
| 390 | − | |
| 391 | − return runSyntaxOnlyTypecheck(ts, wrappedCode, headerLineCount, formatError); | |
| 425 | + // Semantic typechecking failed to initialize — fall back to syntax-only. | |
| 426 | + if (!warnedSemanticFallback) { | |
| 427 | + warnedSemanticFallback = true; | |
| 428 | + console.warn( | |
| 429 | + `[executor] TypeScript semantic typecheck unavailable, falling back to syntax-only checks: ${semantic.error.message}`, | |
| 430 | + ); | |
| 392 | 431 | } |
| 432 | + | |
| 433 | + return runSyntaxOnlyTypecheck(ts, wrappedCode, headerLineCount, formatError); | |
| 393 | 434 | } |
| @@ -4,6 +4,7 @@ import type { Id } from "../convex/_generated/dataModel"; · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | export type TaskStatus = "queued" | "running" | "completed" | "failed" | "timed_out" | "denied"; |
| 6 | 6 | export type ApprovalStatus = "pending" | "approved" | "denied"; |
| 7 | +export type ToolCallStatus = "requested" | "pending_approval" | "completed" | "failed" | "denied"; | |
| 7 | 8 | export type PolicyDecision = "allow" | "require_approval" | "deny"; |
| 8 | 9 | export type CredentialScope = "workspace" | "actor"; |
| 9 | 10 | export type CredentialProvider = "managed" | "workos-vault"; |
| @@ -55,6 +56,21 @@ export interface TaskEventRecord { · 45 unmodified lines | ||
| 55 | 56 | createdAt: number; |
| 56 | 57 | } |
| 57 | 58 | |
| 59 | +export interface ToolCallRecord { | |
| 60 | + taskId: string; | |
| 61 | + callId: string; | |
| 62 | + workspaceId: Id<"workspaces">; | |
| 63 | + toolPath: string; | |
| 64 | + input: unknown; | |
| 65 | + status: ToolCallStatus; | |
| 66 | + approvalId?: string; | |
| 67 | + output?: unknown; | |
| 68 | + error?: string; | |
| 69 | + createdAt: number; | |
| 70 | + updatedAt: number; | |
| 71 | + completedAt?: number; | |
| 72 | +} | |
| 73 | + | |
| 58 | 74 | export interface AccessPolicyRecord { |
| 59 | 75 | id: string; |
| 60 | 76 | workspaceId: Id<"workspaces">; |
| @@ -167,7 +183,15 @@ export interface ToolCallRequest { · 106 unmodified lines | ||
| 167 | 183 | |
| 168 | 184 | export type ToolCallResult = |
| 169 | 185 | | { ok: true; value: unknown } |
| 170 | − | { ok: false; error: string; denied?: boolean }; | |
| 186 | + | { | |
| 187 | + ok: false; | |
| 188 | + kind: "pending"; | |
| 189 | + approvalId: string; | |
| 190 | + retryAfterMs?: number; | |
| 191 | + error?: string; | |
| 192 | + } | |
| 193 | + | { ok: false; kind: "denied"; error: string } | |
| 194 | + | { ok: false; kind: "failed"; error: string }; | |
| 171 | 195 | |
| 172 | 196 | export type RuntimeOutputStream = "stdout" | "stderr"; |
| 173 | 197 | |
| @@ -14,6 +14,7 @@ · 13 unmodified lines | ||
| 14 | 14 | "build:release": "bun run scripts/build-release.ts" |
| 15 | 15 | }, |
| 16 | 16 | "dependencies": { |
| 17 | + "better-result": "^2.7.0", | |
| 17 | 18 | "convex": "^1.31.7", |
| 18 | 19 | "graphql": "^16.12.0" |
| 19 | 20 | } |
| @@ -0,0 +1,2 @@ | ||
| 1 | +# Shared-secret bearer token — must match CLOUDFLARE_SANDBOX_AUTH_TOKEN on the executor side. | |
| 2 | +AUTH_TOKEN=dev-sandbox-token | |
| @@ -0,0 +1,20 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@executor/sandbox-host", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "scripts": { | |
| 7 | + "dev": "wrangler dev --port 8787", | |
| 8 | + "deploy": "wrangler deploy", | |
| 9 | + "typecheck": "tsc --noEmit" | |
| 10 | + }, | |
| 11 | + "devDependencies": { | |
| 12 | + "@cloudflare/workers-types": "^4.20250214.0", | |
| 13 | + "typescript": "^5.9.3", | |
| 14 | + "wrangler": "^4.0.0" | |
| 15 | + }, | |
| 16 | + "dependencies": { | |
| 17 | + "better-result": "^2.7.0", | |
| 18 | + "convex": "^1.31.7" | |
| 19 | + } | |
| 20 | +} | |
| @@ -0,0 +1,405 @@ | ||
| 1 | +/** | |
| 2 | + * Executor Sandbox Host Worker | |
| 3 | + * | |
| 4 | + * This Cloudflare Worker uses the Dynamic Worker Loader API to run | |
| 5 | + * agent-generated code in sandboxed isolates. It exposes a single HTTP | |
| 6 | + * endpoint (`POST /v1/runs`) that the executor's Convex action calls. | |
| 7 | + * | |
| 8 | + * ## How it works | |
| 9 | + * | |
| 10 | + * 1. Receives a run request with `{ taskId, code, timeoutMs, callback }`. | |
| 11 | + * | |
| 12 | + * 2. Uses `env.LOADER.get(id, () => WorkerCode)` to spawn a dynamic isolate | |
| 13 | + * containing the user's code. | |
| 14 | + * | |
| 15 | + * 3. The isolate's network access is fully blocked (`globalOutbound: null`). | |
| 16 | + * Instead, tool calls are routed through a `ToolBridge` entrypoint class | |
| 17 | + * (passed as a loopback service binding via `ctx.exports`) which invokes | |
| 18 | + * Convex callback RPC functions to resolve them. | |
| 19 | + * | |
| 20 | + * 4. Console output is buffered in the harness and returned in the response. | |
| 21 | + * Output lines are also streamed back to Convex in real-time via the | |
| 22 | + * ToolBridge binding. | |
| 23 | + * | |
| 24 | + * 5. `/v1/runs` returns an accepted dispatch response immediately. Terminal | |
| 25 | + * result status is reported back to Convex through callback RPC. | |
| 26 | + * | |
| 27 | + * ## Code isolation | |
| 28 | + * | |
| 29 | + * User code is placed in a **separate JS module** (`user-code.js`) that | |
| 30 | + * exports a single `run(tools, console)` async function. The harness module | |
| 31 | + * (`harness.js`) imports and calls this function, passing controlled `tools` | |
| 32 | + * and `console` proxies. Because the user code is in a different module, it | |
| 33 | + * cannot access the harness's `fetch` handler scope, `req`, `env`, `ctx`, | |
| 34 | + * or `Response` — preventing IIFE escape attacks and response forgery. | |
| 35 | + */ | |
| 36 | + | |
| 37 | +import { Result } from "better-result"; | |
| 38 | +import { WorkerEntrypoint } from "cloudflare:workers"; | |
| 39 | +import { ConvexHttpClient } from "convex/browser"; | |
| 40 | + | |
| 41 | +const RUNTIME_CALLBACKS = { | |
| 42 | + handleToolCall: "runtimeCallbacks:handleToolCall", | |
| 43 | + appendOutput: "runtimeCallbacks:appendOutput", | |
| 44 | + completeRun: "runtimeCallbacks:completeRun", | |
| 45 | +} as const; | |
| 46 | + | |
| 47 | +// Import isolate modules as raw text — these are loaded as JS modules inside | |
| 48 | +// the dynamic isolate, NOT executed in the host worker. The *.isolate.js | |
| 49 | +// extension is mapped to Text type in wrangler.jsonc rules, so wrangler | |
| 50 | +// bundles them as string constants instead of trying to execute them. | |
| 51 | +// @ts-expect-error — wrangler Text module import (no TS declarations) | |
| 52 | +import GLOBALS_MODULE from "./isolate/globals.isolate.js"; | |
| 53 | +// @ts-expect-error — wrangler Text module import (no TS declarations) | |
| 54 | +import HARNESS_CODE from "./isolate/harness.isolate.js"; | |
| 55 | + | |
| 56 | +// ── Types ──────────────────────────────────────────────────────────────────── | |
| 57 | + | |
| 58 | +interface Env { | |
| 59 | + LOADER: WorkerLoader; | |
| 60 | + AUTH_TOKEN: string; | |
| 61 | +} | |
| 62 | + | |
| 63 | +/** Dynamic Worker Loader binding — provided by the `worker_loaders` config. */ | |
| 64 | +interface WorkerLoader { | |
| 65 | + get(id: string, getCode: () => Promise<WorkerCode>): WorkerStub; | |
| 66 | +} | |
| 67 | + | |
| 68 | +interface WorkerCode { | |
| 69 | + compatibilityDate: string; | |
| 70 | + compatibilityFlags?: string[]; | |
| 71 | + mainModule: string; | |
| 72 | + modules: Record<string, string | { js: string } | { text: string } | { json: object }>; | |
| 73 | + env?: Record<string, unknown>; | |
| 74 | + globalOutbound?: unknown | null; | |
| 75 | +} | |
| 76 | + | |
| 77 | +interface WorkerStub { | |
| 78 | + getEntrypoint(name?: string, options?: { props?: unknown }): EntrypointStub; | |
| 79 | +} | |
| 80 | + | |
| 81 | +interface EntrypointStub { | |
| 82 | + fetch(input: string | Request, init?: RequestInit): Promise<Response>; | |
| 83 | +} | |
| 84 | + | |
| 85 | +interface RunRequest { | |
| 86 | + taskId: string; | |
| 87 | + code: string; | |
| 88 | + timeoutMs: number; | |
| 89 | + callback: { | |
| 90 | + convexUrl: string; | |
| 91 | + internalSecret: string; | |
| 92 | + }; | |
| 93 | +} | |
| 94 | + | |
| 95 | +interface RunResult { | |
| 96 | + status: "completed" | "failed" | "timed_out" | "denied"; | |
| 97 | + stdout: string; | |
| 98 | + stderr: string; | |
| 99 | + error?: string; | |
| 100 | + exitCode?: number; | |
| 101 | +} | |
| 102 | + | |
| 103 | +interface RunDispatchResponse { | |
| 104 | + accepted: true; | |
| 105 | + dispatchId: string; | |
| 106 | +} | |
| 107 | + | |
| 108 | +interface ToolCallResult { | |
| 109 | + ok: true | false; | |
| 110 | + value?: unknown; | |
| 111 | + error?: string; | |
| 112 | + kind?: "pending" | "denied" | "failed"; | |
| 113 | + approvalId?: string; | |
| 114 | + retryAfterMs?: number; | |
| 115 | +} | |
| 116 | + | |
| 117 | +interface BridgeProps { | |
| 118 | + callbackConvexUrl: string; | |
| 119 | + callbackInternalSecret: string; | |
| 120 | + taskId: string; | |
| 121 | +} | |
| 122 | + | |
| 123 | +// ── Auth ────────────────────────────────────────────────────────────────────── | |
| 124 | + | |
| 125 | +/** Constant-time string comparison to prevent timing side-channels. */ | |
| 126 | +function timingSafeEqual(a: string, b: string): boolean { | |
| 127 | + const encoder = new TextEncoder(); | |
| 128 | + const bufA = encoder.encode(a); | |
| 129 | + const bufB = encoder.encode(b); | |
| 130 | + | |
| 131 | + if (bufA.length !== bufB.length) { | |
| 132 | + // Compare against self to keep timing consistent, then return false. | |
| 133 | + let result = 0; | |
| 134 | + for (let i = 0; i < bufA.length; i++) { | |
| 135 | + result |= (bufA[i] ?? 0) ^ (bufA[i] ?? 0); | |
| 136 | + } | |
| 137 | + return false; | |
| 138 | + } | |
| 139 | + | |
| 140 | + let result = 0; | |
| 141 | + for (let i = 0; i < bufA.length; i++) { | |
| 142 | + result |= (bufA[i] ?? 0) ^ (bufB[i] ?? 0); | |
| 143 | + } | |
| 144 | + return result === 0; | |
| 145 | +} | |
| 146 | + | |
| 147 | +// ── Helpers ─────────────────────────────────────────────────────────────────── | |
| 148 | + | |
| 149 | +const failedResult = (error: string): RunResult => ({ | |
| 150 | + status: "failed", | |
| 151 | + stdout: "", | |
| 152 | + stderr: "", | |
| 153 | + error, | |
| 154 | +}); | |
| 155 | + | |
| 156 | +async function sleep(ms: number): Promise<void> { | |
| 157 | + await new Promise((resolve) => setTimeout(resolve, ms)); | |
| 158 | +} | |
| 159 | + | |
| 160 | +// ── Tool Bridge Entrypoint ─────────────────────────────────────────────────── | |
| 161 | +// | |
| 162 | +// This class is exposed as a named entrypoint on the host Worker. A loopback | |
| 163 | +// service binding (via `ctx.exports.ToolBridge({props: ...})`) is passed into | |
| 164 | +// the dynamic isolate's `env`. When the isolate calls | |
| 165 | +// `env.TOOL_BRIDGE.callTool(...)`, the RPC call lands here. | |
| 166 | +// | |
| 167 | +// `this.ctx.props` carries the callback URL and auth token for the specific task. | |
| 168 | + | |
| 169 | +export class ToolBridge extends WorkerEntrypoint<Env> { | |
| 170 | + private get props(): BridgeProps { | |
| 171 | + return (this.ctx as unknown as { props: BridgeProps }).props; | |
| 172 | + } | |
| 173 | + | |
| 174 | + private createConvexClient(): ConvexHttpClient { | |
| 175 | + return new ConvexHttpClient(this.props.callbackConvexUrl, { | |
| 176 | + skipConvexDeploymentUrlCheck: true, | |
| 177 | + }); | |
| 178 | + } | |
| 179 | + | |
| 180 | + /** Forward a tool call to the Convex callback RPC action. */ | |
| 181 | + async callTool(toolPath: string, input: unknown, callId?: string): Promise<ToolCallResult> { | |
| 182 | + const { callbackInternalSecret, taskId } = this.props; | |
| 183 | + const effectiveCallId = callId && callId.trim().length > 0 | |
| 184 | + ? callId | |
| 185 | + : `call_${crypto.randomUUID()}`; | |
| 186 | + | |
| 187 | + const response = await Result.tryPromise(async () => { | |
| 188 | + const convex = this.createConvexClient(); | |
| 189 | + return await convex.action(RUNTIME_CALLBACKS.handleToolCall as any, { | |
| 190 | + internalSecret: callbackInternalSecret, | |
| 191 | + runId: taskId, | |
| 192 | + callId: effectiveCallId, | |
| 193 | + toolPath, | |
| 194 | + input, | |
| 195 | + }); | |
| 196 | + }); | |
| 197 | + | |
| 198 | + if (response.isErr()) { | |
| 199 | + const cause = response.error.cause; | |
| 200 | + const message = cause instanceof Error ? cause.message : String(cause); | |
| 201 | + return { ok: false, kind: "failed", error: `Tool callback failed: ${message}` }; | |
| 202 | + } | |
| 203 | + | |
| 204 | + return response.value as ToolCallResult; | |
| 205 | + } | |
| 206 | + | |
| 207 | + /** Stream a console output line back to Convex (best-effort). */ | |
| 208 | + async emitOutput(stream: "stdout" | "stderr", line: string): Promise<void> { | |
| 209 | + const { callbackInternalSecret, taskId } = this.props; | |
| 210 | + await Result.tryPromise(async () => { | |
| 211 | + const convex = this.createConvexClient(); | |
| 212 | + await convex.mutation(RUNTIME_CALLBACKS.appendOutput as any, { | |
| 213 | + internalSecret: callbackInternalSecret, | |
| 214 | + runId: taskId, | |
| 215 | + stream, | |
| 216 | + line, | |
| 217 | + timestamp: Date.now(), | |
| 218 | + }); | |
| 219 | + }); | |
| 220 | + } | |
| 221 | +} | |
| 222 | + | |
| 223 | +// ── Sandbox Harness ────────────────────────────────────────────────────────── | |
| 224 | +// | |
| 225 | +// The harness is a static ES module loaded as the main module of the dynamic | |
| 226 | +// isolate. User code lives in a **separate** module (`user-code.js`) and is | |
| 227 | +// imported by the harness. This prevents user code from accessing or | |
| 228 | +// manipulating the harness's fetch handler, `req`, `env`, `ctx`, or `Response`. | |
| 229 | +// | |
| 230 | +// Both HARNESS_CODE and GLOBALS_MODULE are imported as raw text from | |
| 231 | +// `./isolate/harness.js` and `./isolate/globals.js` respectively, so they | |
| 232 | +// can be authored as real JS files with proper syntax highlighting and linting. | |
| 233 | + | |
| 234 | +/** | |
| 235 | + * Build the user code module. The code is wrapped in an exported async | |
| 236 | + * function `run(tools, console)` so the harness can call it with controlled | |
| 237 | + * scope bindings. The user code runs in a separate module from the harness | |
| 238 | + * and cannot access `req`, `env`, `ctx`, or `Response`. | |
| 239 | + */ | |
| 240 | +function buildUserModule(userCode: string): string { | |
| 241 | + return `export async function run(tools, console) {\n"use strict";\n${userCode}\n}\n`; | |
| 242 | +} | |
| 243 | + | |
| 244 | +async function executeSandboxRun(request: RunRequest, ctx: ExecutionContext, env: Env): Promise<RunResult> { | |
| 245 | + const timeoutMs = request.timeoutMs ?? 300_000; | |
| 246 | + const isolateId = request.taskId; | |
| 247 | + | |
| 248 | + const ctxExports = (ctx as unknown as { | |
| 249 | + exports: Record<string, (opts: { props: BridgeProps }) => unknown>; | |
| 250 | + }).exports; | |
| 251 | + | |
| 252 | + const toolBridgeBinding = ctxExports.ToolBridge({ | |
| 253 | + props: { | |
| 254 | + callbackConvexUrl: request.callback.convexUrl, | |
| 255 | + callbackInternalSecret: request.callback.internalSecret, | |
| 256 | + taskId: request.taskId, | |
| 257 | + }, | |
| 258 | + }); | |
| 259 | + | |
| 260 | + const worker = env.LOADER.get(isolateId, async () => ({ | |
| 261 | + compatibilityDate: "2025-06-01", | |
| 262 | + mainModule: "harness.js", | |
| 263 | + modules: { | |
| 264 | + "harness.js": HARNESS_CODE, | |
| 265 | + "globals.js": GLOBALS_MODULE, | |
| 266 | + "user-code.js": buildUserModule(request.code), | |
| 267 | + }, | |
| 268 | + env: { | |
| 269 | + TOOL_BRIDGE: toolBridgeBinding, | |
| 270 | + }, | |
| 271 | + globalOutbound: null, | |
| 272 | + })); | |
| 273 | + | |
| 274 | + const entrypoint = worker.getEntrypoint(); | |
| 275 | + | |
| 276 | + const controller = new AbortController(); | |
| 277 | + const timer = setTimeout(() => controller.abort(), timeoutMs); | |
| 278 | + | |
| 279 | + const response = await Result.tryPromise(() => | |
| 280 | + entrypoint.fetch("http://sandbox.internal/run", { | |
| 281 | + method: "POST", | |
| 282 | + signal: controller.signal, | |
| 283 | + }), | |
| 284 | + ); | |
| 285 | + | |
| 286 | + clearTimeout(timer); | |
| 287 | + | |
| 288 | + if (response.isErr()) { | |
| 289 | + const cause = response.error.cause; | |
| 290 | + if (cause instanceof DOMException && cause.name === "AbortError") { | |
| 291 | + return { | |
| 292 | + status: "timed_out", | |
| 293 | + stdout: "", | |
| 294 | + stderr: "", | |
| 295 | + error: `Execution timed out after ${timeoutMs}ms`, | |
| 296 | + }; | |
| 297 | + } | |
| 298 | + throw cause; | |
| 299 | + } | |
| 300 | + | |
| 301 | + const body = await Result.tryPromise(() => response.value.json() as Promise<RunResult>); | |
| 302 | + if (body.isErr()) { | |
| 303 | + return failedResult("Sandbox isolate returned invalid JSON"); | |
| 304 | + } | |
| 305 | + return body.value; | |
| 306 | +} | |
| 307 | + | |
| 308 | +async function reportRunCompletion(request: RunRequest, result: RunResult, durationMs: number): Promise<void> { | |
| 309 | + const convex = new ConvexHttpClient(request.callback.convexUrl, { | |
| 310 | + skipConvexDeploymentUrlCheck: true, | |
| 311 | + }); | |
| 312 | + let lastError: unknown = null; | |
| 313 | + | |
| 314 | + for (let attempt = 1; attempt <= 3; attempt += 1) { | |
| 315 | + const response = await Result.tryPromise(async () => { | |
| 316 | + return await convex.mutation(RUNTIME_CALLBACKS.completeRun as any, { | |
| 317 | + internalSecret: request.callback.internalSecret, | |
| 318 | + runId: request.taskId, | |
| 319 | + status: result.status, | |
| 320 | + stdout: result.stdout, | |
| 321 | + stderr: result.stderr, | |
| 322 | + exitCode: result.exitCode, | |
| 323 | + error: result.error, | |
| 324 | + durationMs, | |
| 325 | + }); | |
| 326 | + }); | |
| 327 | + | |
| 328 | + if (response.isOk()) { | |
| 329 | + return; | |
| 330 | + } | |
| 331 | + | |
| 332 | + lastError = response.error.cause; | |
| 333 | + if (attempt < 3) { | |
| 334 | + await sleep(200 * attempt); | |
| 335 | + } | |
| 336 | + } | |
| 337 | + | |
| 338 | + console.error("Failed to report run completion", { | |
| 339 | + taskId: request.taskId, | |
| 340 | + error: lastError instanceof Error ? lastError.message : String(lastError), | |
| 341 | + }); | |
| 342 | +} | |
| 343 | + | |
| 344 | +// ── Main Handler ───────────────────────────────────────────────────────────── | |
| 345 | + | |
| 346 | +export default { | |
| 347 | + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { | |
| 348 | + const url = new URL(request.url); | |
| 349 | + | |
| 350 | + if (request.method === "GET" && url.pathname === "/health") { | |
| 351 | + return Response.json({ ok: true }); | |
| 352 | + } | |
| 353 | + | |
| 354 | + if (request.method !== "POST" || url.pathname !== "/v1/runs") { | |
| 355 | + return Response.json({ error: "Not found" }, { status: 404 }); | |
| 356 | + } | |
| 357 | + | |
| 358 | + // ── Auth ────────────────────────────────────────────────────────────── | |
| 359 | + const authHeader = request.headers.get("authorization"); | |
| 360 | + if (!authHeader?.startsWith("Bearer ")) { | |
| 361 | + return Response.json({ error: "Unauthorized" }, { status: 401 }); | |
| 362 | + } | |
| 363 | + const token = authHeader.slice("Bearer ".length); | |
| 364 | + if (!timingSafeEqual(token, env.AUTH_TOKEN)) { | |
| 365 | + return Response.json({ error: "Unauthorized" }, { status: 401 }); | |
| 366 | + } | |
| 367 | + | |
| 368 | + // ── Parse body ──────────────────────────────────────────────────────── | |
| 369 | + const parsed = await Result.tryPromise(() => request.json() as Promise<RunRequest>); | |
| 370 | + if (parsed.isErr()) { | |
| 371 | + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); | |
| 372 | + } | |
| 373 | + const body = parsed.value; | |
| 374 | + | |
| 375 | + if (!body.taskId || !body.code || !body.callback?.convexUrl || !body.callback?.internalSecret) { | |
| 376 | + return Response.json( | |
| 377 | + { error: "Missing required fields: taskId, code, callback.convexUrl, callback.internalSecret" }, | |
| 378 | + { status: 400 }, | |
| 379 | + ); | |
| 380 | + } | |
| 381 | + | |
| 382 | + const startedAt = Date.now(); | |
| 383 | + const dispatchId = `dispatch_${body.taskId}_${startedAt}`; | |
| 384 | + | |
| 385 | + ctx.waitUntil((async () => { | |
| 386 | + const runResult = await Result.tryPromise(() => executeSandboxRun(body, ctx, env)); | |
| 387 | + const finalResult = runResult.isOk() | |
| 388 | + ? runResult.value | |
| 389 | + : failedResult( | |
| 390 | + `Sandbox host error: ${runResult.error.cause instanceof Error | |
| 391 | + ? runResult.error.cause.message | |
| 392 | + : String(runResult.error.cause)}`, | |
| 393 | + ); | |
| 394 | + | |
| 395 | + await reportRunCompletion(body, finalResult, Date.now() - startedAt); | |
| 396 | + })()); | |
| 397 | + | |
| 398 | + const response: RunDispatchResponse = { | |
| 399 | + accepted: true, | |
| 400 | + dispatchId, | |
| 401 | + }; | |
| 402 | + | |
| 403 | + return Response.json(response, { status: 202 }); | |
| 404 | + }, | |
| 405 | +}; | |
| @@ -0,0 +1,2 @@ | ||
| 1 | +// Captured before user code module is evaluated (this module is imported first). | |
| 2 | +export const ResponseJson = Response.json.bind(Response); | |
| @@ -0,0 +1,108 @@ | ||
| 1 | +import { ResponseJson as _ResponseJson } from "./globals.js"; | |
| 2 | +import { run } from "./user-code.js"; | |
| 3 | + | |
| 4 | +const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:"; | |
| 5 | + | |
| 6 | +function formatArgs(args) { | |
| 7 | + return args | |
| 8 | + .map((v) => { | |
| 9 | + if (typeof v === "string") return v; | |
| 10 | + try { | |
| 11 | + return JSON.stringify(v); | |
| 12 | + } catch { | |
| 13 | + return String(v); | |
| 14 | + } | |
| 15 | + }) | |
| 16 | + .join(" "); | |
| 17 | +} | |
| 18 | + | |
| 19 | +async function sleep(ms) { | |
| 20 | + await new Promise((resolve) => setTimeout(resolve, ms)); | |
| 21 | +} | |
| 22 | + | |
| 23 | +function createToolsProxy(bridge, path = []) { | |
| 24 | + const callable = () => {}; | |
| 25 | + return new Proxy(callable, { | |
| 26 | + get(_target, prop) { | |
| 27 | + if (prop === "then") return undefined; | |
| 28 | + if (typeof prop !== "string") return undefined; | |
| 29 | + return createToolsProxy(bridge, [...path, prop]); | |
| 30 | + }, | |
| 31 | + async apply(_target, _thisArg, args) { | |
| 32 | + const toolPath = path.join("."); | |
| 33 | + if (!toolPath) throw new Error("Tool path missing"); | |
| 34 | + const input = args.length > 0 ? args[0] : {}; | |
| 35 | + const callId = "call_" + crypto.randomUUID(); | |
| 36 | + | |
| 37 | + while (true) { | |
| 38 | + const result = await bridge.callTool(toolPath, input, callId); | |
| 39 | + if (result.ok) return result.value; | |
| 40 | + if (result.kind === "pending") { | |
| 41 | + await sleep(Math.max(50, result.retryAfterMs ?? 500)); | |
| 42 | + continue; | |
| 43 | + } | |
| 44 | + if (result.kind === "denied") throw new Error(APPROVAL_DENIED_PREFIX + result.error); | |
| 45 | + throw new Error(result.error); | |
| 46 | + } | |
| 47 | + }, | |
| 48 | + }); | |
| 49 | +} | |
| 50 | + | |
| 51 | +export default { | |
| 52 | + async fetch(req, env, ctx) { | |
| 53 | + const stdoutLines = []; | |
| 54 | + const stderrLines = []; | |
| 55 | + | |
| 56 | + const appendStdout = (line) => { | |
| 57 | + stdoutLines.push(line); | |
| 58 | + ctx.waitUntil(env.TOOL_BRIDGE.emitOutput("stdout", line)); | |
| 59 | + }; | |
| 60 | + const appendStderr = (line) => { | |
| 61 | + stderrLines.push(line); | |
| 62 | + ctx.waitUntil(env.TOOL_BRIDGE.emitOutput("stderr", line)); | |
| 63 | + }; | |
| 64 | + | |
| 65 | + const tools = createToolsProxy(env.TOOL_BRIDGE); | |
| 66 | + const console = { | |
| 67 | + log: (...args) => appendStdout(formatArgs(args)), | |
| 68 | + info: (...args) => appendStdout(formatArgs(args)), | |
| 69 | + warn: (...args) => appendStderr(formatArgs(args)), | |
| 70 | + error: (...args) => appendStderr(formatArgs(args)), | |
| 71 | + }; | |
| 72 | + | |
| 73 | + try { | |
| 74 | + const value = await run(tools, console); | |
| 75 | + | |
| 76 | + if (value !== undefined) { | |
| 77 | + appendStdout("result: " + formatArgs([value])); | |
| 78 | + } | |
| 79 | + | |
| 80 | + return _ResponseJson({ | |
| 81 | + status: "completed", | |
| 82 | + stdout: stdoutLines.join("\n"), | |
| 83 | + stderr: stderrLines.join("\n"), | |
| 84 | + exitCode: 0, | |
| 85 | + }); | |
| 86 | + } catch (error) { | |
| 87 | + const message = | |
| 88 | + error instanceof Error ? error.message : String(error); | |
| 89 | + if (message.startsWith(APPROVAL_DENIED_PREFIX)) { | |
| 90 | + const denied = message.replace(APPROVAL_DENIED_PREFIX, "").trim(); | |
| 91 | + appendStderr(denied); | |
| 92 | + return _ResponseJson({ | |
| 93 | + status: "denied", | |
| 94 | + stdout: stdoutLines.join("\n"), | |
| 95 | + stderr: stderrLines.join("\n"), | |
| 96 | + error: denied, | |
| 97 | + }); | |
| 98 | + } | |
| 99 | + appendStderr(message); | |
| 100 | + return _ResponseJson({ | |
| 101 | + status: "failed", | |
| 102 | + stdout: stdoutLines.join("\n"), | |
| 103 | + stderr: stderrLines.join("\n"), | |
| 104 | + error: message, | |
| 105 | + }); | |
| 106 | + } | |
| 107 | + }, | |
| 108 | +}; | |
| @@ -0,0 +1,17 @@ | ||
| 1 | +{ | |
| 2 | + "compilerOptions": { | |
| 3 | + "target": "ESNext", | |
| 4 | + "module": "ESNext", | |
| 5 | + "moduleResolution": "bundler", | |
| 6 | + "lib": ["ESNext"], | |
| 7 | + "types": ["@cloudflare/workers-types"], | |
| 8 | + "strict": true, | |
| 9 | + "noEmit": true, | |
| 10 | + "skipLibCheck": true, | |
| 11 | + "forceConsistentCasingInFileNames": true, | |
| 12 | + "resolveJsonModule": true, | |
| 13 | + "isolatedModules": true, | |
| 14 | + "jsx": "react-jsx" | |
| 15 | + }, | |
| 16 | + "include": ["src/**/*.ts"] | |
| 17 | +} | |
| @@ -0,0 +1,28 @@ | ||
| 1 | +{ | |
| 2 | + "name": "executor-sandbox-host", | |
| 3 | + "main": "src/index.ts", | |
| 4 | + "compatibility_date": "2025-06-01", | |
| 5 | + "compatibility_flags": ["enable_ctx_exports"], | |
| 6 | + | |
| 7 | + // Treat *.isolate.js files as raw text strings so they can be passed to the | |
| 8 | + // Dynamic Worker Loader API as module source code. This lets us author the | |
| 9 | + // isolate harness and globals as real JS files (with syntax highlighting) | |
| 10 | + // while importing them as text in the host worker. | |
| 11 | + "rules": [ | |
| 12 | + { "type": "Text", "globs": ["**/*.isolate.js"], "fallthrough": true } | |
| 13 | + ], | |
| 14 | + | |
| 15 | + // The Worker Loader binding — this is the core of the Dynamic Worker Loader API. | |
| 16 | + // It provides `env.LOADER.get(id, callback)` to spawn sandboxed isolates. | |
| 17 | + "worker_loaders": [ | |
| 18 | + { | |
| 19 | + "binding": "LOADER" | |
| 20 | + } | |
| 21 | + ] | |
| 22 | + | |
| 23 | + // Environment secrets (set via `wrangler secret put AUTH_TOKEN`): | |
| 24 | + // AUTH_TOKEN: shared-secret bearer token that the Convex executor uses | |
| 25 | + // to authenticate run requests. | |
| 26 | + // | |
| 27 | + // See .dev.vars.example for local development values. | |
| 28 | +} | |
| @@ -6,6 +6,7 @@ · 5 unmodified lines | ||
| 6 | 6 | "executor/convex", |
| 7 | 7 | "executor/apps/*", |
| 8 | 8 | "executor/packages/*", |
| 9 | + "sources", | |
| 9 | 10 | "assistant", |
| 10 | 11 | "assistant/packages/*", |
| 11 | 12 | "assistant/packages/reacord" |
| @@ -14,6 +15,8 @@ · 2 unmodified lines | ||
| 14 | 15 | "with-env": "bun run --env-file=.env --", |
| 15 | 16 | "dev": "bun run dev.ts", |
| 16 | 17 | "kill:all": "bun run kill-all.ts", |
| 18 | + "dev:sources": "bun run --cwd sources dev", | |
| 19 | + "sources:sync": "bun run --cwd sources sync", | |
| 17 | 20 | "dev:executor:convex": "bunx --cwd executor convex dev", |
| 18 | 21 | "dev:executor:web": "bun run --cwd executor/apps/web dev", |
| 19 | 22 | "dev:executor:all": "bun run dev:executor:convex & bun run dev:executor:web", |
| @@ -0,0 +1,16 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@proto/sources", | |
| 3 | + "private": true, | |
| 4 | + "type": "module", | |
| 5 | + "scripts": { | |
| 6 | + "dev": "bun --hot server.ts", | |
| 7 | + "start": "bun server.ts", | |
| 8 | + "sync": "bun server.ts --sync-only", | |
| 9 | + "typecheck": "bunx tsc --noEmit -p tsconfig.json" | |
| 10 | + }, | |
| 11 | + "dependencies": {}, | |
| 12 | + "devDependencies": { | |
| 13 | + "@types/bun": "latest", | |
| 14 | + "typescript": "^5.9.3" | |
| 15 | + } | |
| 16 | +} | |
| @@ -0,0 +1,384 @@ | ||
| 1 | +import { Database } from "bun:sqlite"; | |
| 2 | +import { fileURLToPath } from "node:url"; | |
| 3 | + | |
| 4 | +// --------------------------------------------------------------------------- | |
| 5 | +// List.json shape (from APIs.guru / our fork) | |
| 6 | +// --------------------------------------------------------------------------- | |
| 7 | + | |
| 8 | +interface ListOrigin { | |
| 9 | + format: string; | |
| 10 | + url: string; | |
| 11 | + version: string; | |
| 12 | +} | |
| 13 | + | |
| 14 | +interface ListVersionInfo { | |
| 15 | + contact?: { email?: string; name?: string; url?: string }; | |
| 16 | + description?: string; | |
| 17 | + title?: string; | |
| 18 | + version?: string; | |
| 19 | + "x-apisguru-categories"?: string[]; | |
| 20 | + "x-logo"?: { url?: string }; | |
| 21 | + "x-origin"?: ListOrigin[]; | |
| 22 | + "x-providerName"?: string; | |
| 23 | +} | |
| 24 | + | |
| 25 | +interface ListVersion { | |
| 26 | + added?: string; | |
| 27 | + updated?: string; | |
| 28 | + info: ListVersionInfo; | |
| 29 | + swaggerUrl: string; | |
| 30 | + swaggerYamlUrl?: string; | |
| 31 | + openapiVer?: string; | |
| 32 | + link?: string; | |
| 33 | +} | |
| 34 | + | |
| 35 | +interface ListEntry { | |
| 36 | + added?: string; | |
| 37 | + preferred: string; | |
| 38 | + versions: Record<string, ListVersion>; | |
| 39 | +} | |
| 40 | + | |
| 41 | +type ListJson = Record<string, ListEntry>; | |
| 42 | + | |
| 43 | +// --------------------------------------------------------------------------- | |
| 44 | +// Exported types | |
| 45 | +// --------------------------------------------------------------------------- | |
| 46 | + | |
| 47 | +export interface CatalogItem { | |
| 48 | + id: string; | |
| 49 | + name: string; | |
| 50 | + summary: string; | |
| 51 | + specUrl: string; | |
| 52 | + originUrl: string; | |
| 53 | + providerName: string; | |
| 54 | + logoUrl: string; | |
| 55 | + categories: string; | |
| 56 | + version: string; | |
| 57 | +} | |
| 58 | + | |
| 59 | +export interface SyncState { | |
| 60 | + inFlight: Promise<SyncResult> | null; | |
| 61 | + lastSyncedAt: number | null; | |
| 62 | + lastError: string | null; | |
| 63 | + lastCount: number; | |
| 64 | +} | |
| 65 | + | |
| 66 | +export interface SyncResult { | |
| 67 | + count: number; | |
| 68 | + syncedAt: number; | |
| 69 | + trigger: string; | |
| 70 | +} | |
| 71 | + | |
| 72 | +export interface SyncConfig { | |
| 73 | + listUrl: string; | |
| 74 | +} | |
| 75 | + | |
| 76 | +// --------------------------------------------------------------------------- | |
| 77 | +// Constants | |
| 78 | +// --------------------------------------------------------------------------- | |
| 79 | + | |
| 80 | +const DEFAULT_LIST_URL = "https://api.apis.guru/v2/list.json"; | |
| 81 | +const SUPPLEMENTS_PATH = fileURLToPath(new URL("./supplements.json", import.meta.url)); | |
| 82 | + | |
| 83 | +// --------------------------------------------------------------------------- | |
| 84 | +// Text helpers | |
| 85 | +// --------------------------------------------------------------------------- | |
| 86 | + | |
| 87 | +function normalizeText(value: string): string { | |
| 88 | + return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); | |
| 89 | +} | |
| 90 | + | |
| 91 | +function compactText(value: string): string { | |
| 92 | + return value.toLowerCase().replace(/[^a-z0-9]+/g, ""); | |
| 93 | +} | |
| 94 | + | |
| 95 | +function parseTimestamp(value: string | undefined): number { | |
| 96 | + if (!value) return 0; | |
| 97 | + const parsed = Date.parse(value.trim()); | |
| 98 | + return Number.isFinite(parsed) ? parsed : 0; | |
| 99 | +} | |
| 100 | + | |
| 101 | +// --------------------------------------------------------------------------- | |
| 102 | +// Mapping – list.json entry → DB row | |
| 103 | +// --------------------------------------------------------------------------- | |
| 104 | + | |
| 105 | +interface ApiRow { | |
| 106 | + id: string; | |
| 107 | + name: string; | |
| 108 | + summary: string; | |
| 109 | + spec_url: string; | |
| 110 | + origin_url: string; | |
| 111 | + provider_name: string; | |
| 112 | + logo_url: string; | |
| 113 | + categories: string; | |
| 114 | + version: string; | |
| 115 | + updated_at: number; | |
| 116 | + search_text: string; | |
| 117 | + search_compact: string; | |
| 118 | +} | |
| 119 | + | |
| 120 | +function mapEntry(providerKey: string, entry: ListEntry): ApiRow | null { | |
| 121 | + const ver = entry.versions[entry.preferred]; | |
| 122 | + if (!ver) return null; | |
| 123 | + | |
| 124 | + const info = ver.info; | |
| 125 | + const name = (info.title ?? providerKey).trim(); | |
| 126 | + if (!name) return null; | |
| 127 | + | |
| 128 | + const summary = (info.description ?? "").trim(); | |
| 129 | + const specUrl = ver.swaggerUrl; | |
| 130 | + if (!specUrl) return null; | |
| 131 | + | |
| 132 | + const originUrl = info["x-origin"]?.[0]?.url ?? ""; | |
| 133 | + const providerName = (info["x-providerName"] ?? providerKey).trim(); | |
| 134 | + const logoUrl = info["x-logo"]?.url ?? ""; | |
| 135 | + const categories = (info["x-apisguru-categories"] ?? []).join(","); | |
| 136 | + const version = (info.version ?? entry.preferred).trim(); | |
| 137 | + const updatedAt = parseTimestamp(ver.updated) || parseTimestamp(entry.added) || 0; | |
| 138 | + | |
| 139 | + const searchText = normalizeText( | |
| 140 | + `${name} ${providerName} ${providerKey} ${summary.slice(0, 300)} ${categories.replace(/,/g, " ")}`, | |
| 141 | + ); | |
| 142 | + | |
| 143 | + return { | |
| 144 | + id: providerKey, | |
| 145 | + name, | |
| 146 | + summary, | |
| 147 | + spec_url: specUrl, | |
| 148 | + origin_url: originUrl, | |
| 149 | + provider_name: providerName, | |
| 150 | + logo_url: logoUrl, | |
| 151 | + categories, | |
| 152 | + version, | |
| 153 | + updated_at: updatedAt, | |
| 154 | + search_text: searchText, | |
| 155 | + search_compact: compactText(searchText), | |
| 156 | + }; | |
| 157 | +} | |
| 158 | + | |
| 159 | +// --------------------------------------------------------------------------- | |
| 160 | +// SQLite | |
| 161 | +// --------------------------------------------------------------------------- | |
| 162 | + | |
| 163 | +export function createDb(dbPath: string): Database { | |
| 164 | + const db = new Database(dbPath); | |
| 165 | + db.exec(` | |
| 166 | + PRAGMA journal_mode = WAL; | |
| 167 | + PRAGMA synchronous = NORMAL; | |
| 168 | + | |
| 169 | + CREATE TABLE IF NOT EXISTS api_catalog ( | |
| 170 | + id TEXT PRIMARY KEY, | |
| 171 | + name TEXT NOT NULL, | |
| 172 | + summary TEXT NOT NULL, | |
| 173 | + spec_url TEXT NOT NULL, | |
| 174 | + origin_url TEXT NOT NULL DEFAULT '', | |
| 175 | + provider_name TEXT NOT NULL, | |
| 176 | + logo_url TEXT NOT NULL DEFAULT '', | |
| 177 | + categories TEXT NOT NULL DEFAULT '', | |
| 178 | + version TEXT NOT NULL DEFAULT '', | |
| 179 | + updated_at INTEGER NOT NULL DEFAULT 0, | |
| 180 | + search_text TEXT NOT NULL, | |
| 181 | + search_compact TEXT NOT NULL | |
| 182 | + ); | |
| 183 | + | |
| 184 | + CREATE INDEX IF NOT EXISTS idx_catalog_name ON api_catalog(name); | |
| 185 | + CREATE INDEX IF NOT EXISTS idx_catalog_updated ON api_catalog(updated_at DESC); | |
| 186 | + CREATE INDEX IF NOT EXISTS idx_catalog_provider ON api_catalog(provider_name); | |
| 187 | + CREATE INDEX IF NOT EXISTS idx_catalog_search ON api_catalog(search_compact); | |
| 188 | + `); | |
| 189 | + return db; | |
| 190 | +} | |
| 191 | + | |
| 192 | +export function rowCount(db: Database): number { | |
| 193 | + const row = db.query("SELECT COUNT(*) AS count FROM api_catalog").get() as { count: number } | null; | |
| 194 | + return row?.count ?? 0; | |
| 195 | +} | |
| 196 | + | |
| 197 | +function upsertRows(db: Database, rows: ApiRow[]): void { | |
| 198 | + if (rows.length === 0) return; | |
| 199 | + | |
| 200 | + const upsert = db.query(` | |
| 201 | + INSERT INTO api_catalog ( | |
| 202 | + id, name, summary, spec_url, origin_url, | |
| 203 | + provider_name, logo_url, categories, version, | |
| 204 | + updated_at, search_text, search_compact | |
| 205 | + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 206 | + ON CONFLICT(id) DO UPDATE SET | |
| 207 | + name = excluded.name, | |
| 208 | + summary = excluded.summary, | |
| 209 | + spec_url = excluded.spec_url, | |
| 210 | + origin_url = excluded.origin_url, | |
| 211 | + provider_name = excluded.provider_name, | |
| 212 | + logo_url = excluded.logo_url, | |
| 213 | + categories = excluded.categories, | |
| 214 | + version = excluded.version, | |
| 215 | + updated_at = excluded.updated_at, | |
| 216 | + search_text = excluded.search_text, | |
| 217 | + search_compact = excluded.search_compact | |
| 218 | + `); | |
| 219 | + | |
| 220 | + db.exec("BEGIN IMMEDIATE"); | |
| 221 | + try { | |
| 222 | + for (const r of rows) { | |
| 223 | + upsert.run( | |
| 224 | + r.id, r.name, r.summary, r.spec_url, r.origin_url, | |
| 225 | + r.provider_name, r.logo_url, r.categories, r.version, | |
| 226 | + r.updated_at, r.search_text, r.search_compact, | |
| 227 | + ); | |
| 228 | + } | |
| 229 | + db.exec("COMMIT"); | |
| 230 | + } catch (error) { | |
| 231 | + db.exec("ROLLBACK"); | |
| 232 | + throw error; | |
| 233 | + } | |
| 234 | +} | |
| 235 | + | |
| 236 | +// --------------------------------------------------------------------------- | |
| 237 | +// Sync | |
| 238 | +// --------------------------------------------------------------------------- | |
| 239 | + | |
| 240 | +async function loadSupplements(): Promise<ListJson> { | |
| 241 | + try { | |
| 242 | + const file = Bun.file(SUPPLEMENTS_PATH); | |
| 243 | + if (!(await file.exists())) return {}; | |
| 244 | + const data = (await file.json()) as ListJson & { $comment?: string }; | |
| 245 | + delete data.$comment; | |
| 246 | + return data; | |
| 247 | + } catch { | |
| 248 | + return {}; | |
| 249 | + } | |
| 250 | +} | |
| 251 | + | |
| 252 | +async function fetchAndSync(db: Database, config: SyncConfig): Promise<number> { | |
| 253 | + const url = config.listUrl || DEFAULT_LIST_URL; | |
| 254 | + console.log(`[scraper] fetching ${url}`); | |
| 255 | + | |
| 256 | + const response = await fetch(url); | |
| 257 | + if (!response.ok) { | |
| 258 | + throw new Error(`Failed to fetch list.json: HTTP ${response.status}`); | |
| 259 | + } | |
| 260 | + | |
| 261 | + const data = (await response.json()) as ListJson; | |
| 262 | + | |
| 263 | + // Merge local supplements (overrides upstream if same key) | |
| 264 | + const supplements = await loadSupplements(); | |
| 265 | + const supplementCount = Object.keys(supplements).length; | |
| 266 | + if (supplementCount > 0) { | |
| 267 | + Object.assign(data, supplements); | |
| 268 | + console.log(`[scraper] merged ${supplementCount} supplemental entries`); | |
| 269 | + } | |
| 270 | + | |
| 271 | + const entries = Object.entries(data); | |
| 272 | + console.log(`[scraper] parsing ${entries.length} entries`); | |
| 273 | + | |
| 274 | + const rows: ApiRow[] = []; | |
| 275 | + for (const [key, entry] of entries) { | |
| 276 | + const row = mapEntry(key, entry); | |
| 277 | + if (row) rows.push(row); | |
| 278 | + } | |
| 279 | + | |
| 280 | + // Batch upsert in chunks of 500 | |
| 281 | + const CHUNK = 500; | |
| 282 | + for (let i = 0; i < rows.length; i += CHUNK) { | |
| 283 | + upsertRows(db, rows.slice(i, i + CHUNK)); | |
| 284 | + } | |
| 285 | + | |
| 286 | + const count = rowCount(db); | |
| 287 | + console.log(`[scraper] synced ${rows.length} rows (${count} total in db)`); | |
| 288 | + return count; | |
| 289 | +} | |
| 290 | + | |
| 291 | +export function triggerSync( | |
| 292 | + db: Database, | |
| 293 | + state: SyncState, | |
| 294 | + config: SyncConfig, | |
| 295 | + trigger: string, | |
| 296 | +): Promise<SyncResult> { | |
| 297 | + if (state.inFlight) return state.inFlight; | |
| 298 | + | |
| 299 | + const promise = fetchAndSync(db, config) | |
| 300 | + .then((count) => { | |
| 301 | + const syncedAt = Date.now(); | |
| 302 | + state.lastSyncedAt = syncedAt; | |
| 303 | + state.lastError = null; | |
| 304 | + state.lastCount = count; | |
| 305 | + return { count, syncedAt, trigger } satisfies SyncResult; | |
| 306 | + }) | |
| 307 | + .catch((error) => { | |
| 308 | + state.lastError = error instanceof Error ? error.message : String(error); | |
| 309 | + throw error; | |
| 310 | + }) | |
| 311 | + .finally(() => { | |
| 312 | + state.inFlight = null; | |
| 313 | + }); | |
| 314 | + | |
| 315 | + state.inFlight = promise; | |
| 316 | + return promise; | |
| 317 | +} | |
| 318 | + | |
| 319 | +// --------------------------------------------------------------------------- | |
| 320 | +// Query | |
| 321 | +// --------------------------------------------------------------------------- | |
| 322 | + | |
| 323 | +function buildSearchFilter(q: string): { sql: string; params: string[] } { | |
| 324 | + const trimmed = q.trim(); | |
| 325 | + if (!trimmed) return { sql: "", params: [] }; | |
| 326 | + | |
| 327 | + const normalized = normalizeText(trimmed); | |
| 328 | + const compact = compactText(normalized); | |
| 329 | + const tokens = normalized.split(/\s+/).filter(Boolean); | |
| 330 | + | |
| 331 | + const clauses: string[] = []; | |
| 332 | + const params: string[] = []; | |
| 333 | + | |
| 334 | + if (compact.length > 0) { | |
| 335 | + clauses.push("search_compact LIKE ?"); | |
| 336 | + params.push(`%${compact}%`); | |
| 337 | + } | |
| 338 | + | |
| 339 | + for (const token of tokens) { | |
| 340 | + clauses.push("search_text LIKE ?"); | |
| 341 | + params.push(`%${token}%`); | |
| 342 | + } | |
| 343 | + | |
| 344 | + return { | |
| 345 | + sql: `WHERE (${clauses.join(" OR ")})`, | |
| 346 | + params, | |
| 347 | + }; | |
| 348 | +} | |
| 349 | + | |
| 350 | +export function queryCollections(db: Database, input: { | |
| 351 | + q: string; | |
| 352 | + sort: "popular" | "recent"; | |
| 353 | + limit: number; | |
| 354 | + offset: number; | |
| 355 | +}): { items: CatalogItem[]; totalCount: number; hasMore: boolean } { | |
| 356 | + const { sql, params } = buildSearchFilter(input.q); | |
| 357 | + | |
| 358 | + const countRow = db.query( | |
| 359 | + `SELECT COUNT(*) AS count FROM api_catalog ${sql}`, | |
| 360 | + ).get(...params) as { count: number } | null; | |
| 361 | + const totalCount = countRow?.count ?? 0; | |
| 362 | + | |
| 363 | + // "popular" → alphabetical by name (no popularity metric from APIs.guru) | |
| 364 | + // "recent" → by updated_at desc | |
| 365 | + const orderBy = input.sort === "recent" | |
| 366 | + ? "updated_at DESC, name COLLATE NOCASE ASC" | |
| 367 | + : "name COLLATE NOCASE ASC"; | |
| 368 | + | |
| 369 | + const rows = db.query(` | |
| 370 | + SELECT | |
| 371 | + id, name, summary, spec_url AS specUrl, | |
| 372 | + origin_url AS originUrl, provider_name AS providerName, | |
| 373 | + logo_url AS logoUrl, categories, version | |
| 374 | + FROM api_catalog ${sql} | |
| 375 | + ORDER BY ${orderBy} | |
| 376 | + LIMIT ? OFFSET ? | |
| 377 | + `).all(...params, input.limit, input.offset) as CatalogItem[]; | |
| 378 | + | |
| 379 | + return { | |
| 380 | + items: rows, | |
| 381 | + totalCount, | |
| 382 | + hasMore: input.offset + rows.length < totalCount, | |
| 383 | + }; | |
| 384 | +} | |
| @@ -0,0 +1,192 @@ | ||
| 1 | +#!/usr/bin/env bun | |
| 2 | + | |
| 3 | +import { mkdir } from "node:fs/promises"; | |
| 4 | +import { dirname } from "node:path"; | |
| 5 | +import { fileURLToPath } from "node:url"; | |
| 6 | +import { | |
| 7 | + createDb, | |
| 8 | + queryCollections, | |
| 9 | + rowCount, | |
| 10 | + triggerSync, | |
| 11 | + type SyncState, | |
| 12 | + type SyncConfig, | |
| 13 | +} from "./scraper"; | |
| 14 | + | |
| 15 | +// --------------------------------------------------------------------------- | |
| 16 | +// Config (env with defaults) | |
| 17 | +// --------------------------------------------------------------------------- | |
| 18 | + | |
| 19 | +const PORT = Math.max(1, Math.min(65535, Number(process.env.SOURCES_PORT) || 4343)); | |
| 20 | +const DB_PATH = process.env.SOURCES_DB_PATH ?? fileURLToPath(new URL("./data/catalog.sqlite", import.meta.url)); | |
| 21 | +const SYNC_INTERVAL_MS = Math.max(60_000, Number(process.env.SOURCES_SYNC_INTERVAL_MS) || 6 * 60 * 60 * 1000); | |
| 22 | +const LIST_URL = process.env.SOURCES_LIST_URL ?? "https://api.apis.guru/v2/list.json"; | |
| 23 | +const ALLOWED_ORIGINS = process.env.SOURCES_ALLOWED_ORIGINS?.split(",").map((s) => s.trim()).filter(Boolean) ?? []; | |
| 24 | +const DEFAULT_LIMIT = 25; | |
| 25 | +const MAX_LIMIT = 100; | |
| 26 | + | |
| 27 | +// --------------------------------------------------------------------------- | |
| 28 | +// Helpers | |
| 29 | +// --------------------------------------------------------------------------- | |
| 30 | + | |
| 31 | +function parseInteger(input: string | null, fallback: number): number { | |
| 32 | + if (!input) return fallback; | |
| 33 | + const parsed = Number(input); | |
| 34 | + if (!Number.isFinite(parsed)) return fallback; | |
| 35 | + return Math.floor(parsed); | |
| 36 | +} | |
| 37 | + | |
| 38 | +function parseSort(value: string | null): "popular" | "recent" { | |
| 39 | + return value === "recent" ? "recent" : "popular"; | |
| 40 | +} | |
| 41 | + | |
| 42 | +function json(data: unknown, status = 200, corsHeaders?: Record<string, string>): Response { | |
| 43 | + return Response.json(data, { | |
| 44 | + status, | |
| 45 | + headers: { "cache-control": "no-store", ...corsHeaders }, | |
| 46 | + }); | |
| 47 | +} | |
| 48 | + | |
| 49 | +function corsHeaders(request: Request): Record<string, string> { | |
| 50 | + const origin = request.headers.get("origin") ?? ""; | |
| 51 | + // In dev, allow any localhost origin. In prod, check allowlist. | |
| 52 | + const allowed = | |
| 53 | + origin.match(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/) || | |
| 54 | + ALLOWED_ORIGINS.includes(origin); | |
| 55 | + | |
| 56 | + if (!allowed && origin) return {}; | |
| 57 | + | |
| 58 | + return { | |
| 59 | + "access-control-allow-origin": origin || "*", | |
| 60 | + "access-control-allow-methods": "GET, POST, OPTIONS", | |
| 61 | + "access-control-allow-headers": "content-type", | |
| 62 | + "access-control-max-age": "86400", | |
| 63 | + }; | |
| 64 | +} | |
| 65 | + | |
| 66 | +// --------------------------------------------------------------------------- | |
| 67 | +// Init | |
| 68 | +// --------------------------------------------------------------------------- | |
| 69 | + | |
| 70 | +await mkdir(dirname(DB_PATH), { recursive: true }); | |
| 71 | +const db = createDb(DB_PATH); | |
| 72 | +const syncConfig: SyncConfig = { listUrl: LIST_URL }; | |
| 73 | +const state: SyncState = { | |
| 74 | + inFlight: null, | |
| 75 | + lastSyncedAt: null, | |
| 76 | + lastError: null, | |
| 77 | + lastCount: rowCount(db), | |
| 78 | +}; | |
| 79 | + | |
| 80 | +// --------------------------------------------------------------------------- | |
| 81 | +// CLI: --sync-only | |
| 82 | +// --------------------------------------------------------------------------- | |
| 83 | + | |
| 84 | +if (Bun.argv.includes("--sync-only")) { | |
| 85 | + try { | |
| 86 | + const result = await triggerSync(db, state, syncConfig, "cli"); | |
| 87 | + console.log(`synced ${result.count} sources into ${DB_PATH}`); | |
| 88 | + } finally { | |
| 89 | + db.close(); | |
| 90 | + } | |
| 91 | + process.exit(0); | |
| 92 | +} | |
| 93 | + | |
| 94 | +// --------------------------------------------------------------------------- | |
| 95 | +// Server | |
| 96 | +// --------------------------------------------------------------------------- | |
| 97 | + | |
| 98 | +const server = Bun.serve({ | |
| 99 | + port: PORT, | |
| 100 | + fetch: async (request) => { | |
| 101 | + const cors = corsHeaders(request); | |
| 102 | + | |
| 103 | + // Preflight | |
| 104 | + if (request.method === "OPTIONS") { | |
| 105 | + return new Response(null, { status: 204, headers: cors }); | |
| 106 | + } | |
| 107 | + | |
| 108 | + const url = new URL(request.url); | |
| 109 | + | |
| 110 | + // GET /health | |
| 111 | + if (request.method === "GET" && url.pathname === "/health") { | |
| 112 | + return json({ | |
| 113 | + ok: true, | |
| 114 | + count: rowCount(db), | |
| 115 | + lastSyncedAt: state.lastSyncedAt, | |
| 116 | + lastError: state.lastError, | |
| 117 | + lastCount: state.lastCount, | |
| 118 | + }, 200, cors); | |
| 119 | + } | |
| 120 | + | |
| 121 | + // POST|GET /admin/sync | |
| 122 | + if ((request.method === "POST" || request.method === "GET") && url.pathname === "/admin/sync") { | |
| 123 | + try { | |
| 124 | + const result = await triggerSync(db, state, syncConfig, "manual"); | |
| 125 | + return json({ ok: true, ...result }, 200, cors); | |
| 126 | + } catch (error) { | |
| 127 | + return json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500, cors); | |
| 128 | + } | |
| 129 | + } | |
| 130 | + | |
| 131 | + // GET /collections | |
| 132 | + if (request.method === "GET" && url.pathname === "/collections") { | |
| 133 | + try { | |
| 134 | + // Auto-seed on first request if DB is empty | |
| 135 | + if (rowCount(db) === 0) { | |
| 136 | + await triggerSync(db, state, syncConfig, "seed"); | |
| 137 | + } | |
| 138 | + | |
| 139 | + const q = url.searchParams.get("q")?.trim() ?? ""; | |
| 140 | + const sort = parseSort(url.searchParams.get("sort")); | |
| 141 | + const limit = Math.max(1, Math.min(MAX_LIMIT, parseInteger(url.searchParams.get("limit"), DEFAULT_LIMIT))); | |
| 142 | + const offset = Math.max(0, parseInteger(url.searchParams.get("offset"), 0)); | |
| 143 | + | |
| 144 | + const result = queryCollections(db, { q, sort, limit, offset }); | |
| 145 | + return json({ | |
| 146 | + items: result.items, | |
| 147 | + totalCount: result.totalCount, | |
| 148 | + hasMore: result.hasMore, | |
| 149 | + limit, | |
| 150 | + offset, | |
| 151 | + }, 200, cors); | |
| 152 | + } catch (error) { | |
| 153 | + return json({ | |
| 154 | + error: "Failed to load API catalog", | |
| 155 | + detail: error instanceof Error ? error.message : String(error), | |
| 156 | + }, 500, cors); | |
| 157 | + } | |
| 158 | + } | |
| 159 | + | |
| 160 | + return json({ error: "Not found" }, 404, cors); | |
| 161 | + }, | |
| 162 | +}); | |
| 163 | + | |
| 164 | +console.log(`[sources] running on http://127.0.0.1:${server.port}`); | |
| 165 | +console.log(`[sources] db: ${DB_PATH}`); | |
| 166 | +console.log(`[sources] endpoints: GET /collections, GET /health, POST /admin/sync`); | |
| 167 | + | |
| 168 | +// Non-blocking startup sync | |
| 169 | +void triggerSync(db, state, syncConfig, "startup").catch((error) => { | |
| 170 | + console.error(`[sources] initial sync failed: ${error instanceof Error ? error.message : String(error)}`); | |
| 171 | +}); | |
| 172 | + | |
| 173 | +// Periodic sync | |
| 174 | +const syncInterval = setInterval(() => { | |
| 175 | + void triggerSync(db, state, syncConfig, "interval").catch((error) => { | |
| 176 | + console.error(`[sources] scheduled sync failed: ${error instanceof Error ? error.message : String(error)}`); | |
| 177 | + }); | |
| 178 | +}, SYNC_INTERVAL_MS); | |
| 179 | + | |
| 180 | +// Graceful shutdown | |
| 181 | +let shuttingDown = false; | |
| 182 | +const shutdown = () => { | |
| 183 | + if (shuttingDown) return; | |
| 184 | + shuttingDown = true; | |
| 185 | + clearInterval(syncInterval); | |
| 186 | + server.stop(); | |
| 187 | + db.close(); | |
| 188 | + process.exit(0); | |
| 189 | +}; | |
| 190 | + | |
| 191 | +process.on("SIGINT", shutdown); | |
| 192 | +process.on("SIGTERM", shutdown); | |
| @@ -0,0 +1,171 @@ | ||
| 1 | +{ | |
| 2 | + "$comment": "Additional OpenAPI specs not in APIs.guru. Same shape as list.json entries.", | |
| 3 | + "cloudflare.com": { | |
| 4 | + "added": "2026-02-11T00:00:00.000Z", | |
| 5 | + "preferred": "4", | |
| 6 | + "versions": { | |
| 7 | + "4": { | |
| 8 | + "added": "2026-02-11T00:00:00.000Z", | |
| 9 | + "updated": "2026-02-11T00:00:00.000Z", | |
| 10 | + "info": { | |
| 11 | + "title": "Cloudflare API", | |
| 12 | + "description": "Interact with Cloudflare's products and services via the Cloudflare API.", | |
| 13 | + "version": "4", | |
| 14 | + "x-apisguru-categories": ["cloud", "hosting"], | |
| 15 | + "x-logo": { "url": "https://www.cloudflare.com/favicon.ico" }, | |
| 16 | + "x-origin": [{ "format": "openapi", "url": "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json", "version": "3.0" }], | |
| 17 | + "x-providerName": "cloudflare.com" | |
| 18 | + }, | |
| 19 | + "swaggerUrl": "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json", | |
| 20 | + "openapiVer": "3.0.3" | |
| 21 | + } | |
| 22 | + } | |
| 23 | + }, | |
| 24 | + "discord.com": { | |
| 25 | + "added": "2026-02-11T00:00:00.000Z", | |
| 26 | + "preferred": "10", | |
| 27 | + "versions": { | |
| 28 | + "10": { | |
| 29 | + "added": "2026-02-11T00:00:00.000Z", | |
| 30 | + "updated": "2026-02-11T00:00:00.000Z", | |
| 31 | + "info": { | |
| 32 | + "title": "Discord HTTP API", | |
| 33 | + "description": "HTTP API for Discord. Build bots, integrations, and applications that interact with Discord.", | |
| 34 | + "version": "10", | |
| 35 | + "x-apisguru-categories": ["messaging", "social"], | |
| 36 | + "x-logo": { "url": "https://discord.com/assets/favicon.ico" }, | |
| 37 | + "x-origin": [{ "format": "openapi", "url": "https://raw.githubusercontent.com/discord/discord-api-spec/main/specs/openapi.json", "version": "3.1" }], | |
| 38 | + "x-providerName": "discord.com" | |
| 39 | + }, | |
| 40 | + "swaggerUrl": "https://raw.githubusercontent.com/discord/discord-api-spec/main/specs/openapi.json", | |
| 41 | + "openapiVer": "3.1.0" | |
| 42 | + } | |
| 43 | + } | |
| 44 | + }, | |
| 45 | + "paypal.com": { | |
| 46 | + "added": "2026-02-11T00:00:00.000Z", | |
| 47 | + "preferred": "2", | |
| 48 | + "versions": { | |
| 49 | + "2": { | |
| 50 | + "added": "2026-02-11T00:00:00.000Z", | |
| 51 | + "updated": "2026-02-11T00:00:00.000Z", | |
| 52 | + "info": { | |
| 53 | + "title": "PayPal Checkout Orders API", | |
| 54 | + "description": "Use the Orders API to create, update, retrieve, authorize, and capture orders (payments).", | |
| 55 | + "version": "2", | |
| 56 | + "x-apisguru-categories": ["financial", "payment"], | |
| 57 | + "x-logo": { "url": "https://www.paypal.com/favicon.ico" }, | |
| 58 | + "x-origin": [{ "format": "openapi", "url": "https://raw.githubusercontent.com/paypal/paypal-rest-api-specifications/main/openapi/checkout_orders_v2.json", "version": "3.0" }], | |
| 59 | + "x-providerName": "paypal.com" | |
| 60 | + }, | |
| 61 | + "swaggerUrl": "https://raw.githubusercontent.com/paypal/paypal-rest-api-specifications/main/openapi/checkout_orders_v2.json", | |
| 62 | + "openapiVer": "3.0.0" | |
| 63 | + } | |
| 64 | + } | |
| 65 | + }, | |
| 66 | + "resend.com": { | |
| 67 | + "added": "2026-02-11T00:00:00.000Z", | |
| 68 | + "preferred": "1.0.0", | |
| 69 | + "versions": { | |
| 70 | + "1.0.0": { | |
| 71 | + "added": "2026-02-11T00:00:00.000Z", | |
| 72 | + "updated": "2026-02-11T00:00:00.000Z", | |
| 73 | + "info": { | |
| 74 | + "title": "Resend API", | |
| 75 | + "description": "Email for developers. Send transactional and marketing emails with a simple API.", | |
| 76 | + "version": "1.0.0", | |
| 77 | + "x-apisguru-categories": ["email", "messaging"], | |
| 78 | + "x-logo": { "url": "https://resend.com/favicon.ico" }, | |
| 79 | + "x-origin": [{ "format": "openapi", "url": "https://raw.githubusercontent.com/resend/resend-openapi/main/resend.yaml", "version": "3.0" }], | |
| 80 | + "x-providerName": "resend.com" | |
| 81 | + }, | |
| 82 | + "swaggerUrl": "https://raw.githubusercontent.com/resend/resend-openapi/main/resend.yaml", | |
| 83 | + "openapiVer": "3.0.3" | |
| 84 | + } | |
| 85 | + } | |
| 86 | + }, | |
| 87 | + "figma.com": { | |
| 88 | + "added": "2026-02-11T00:00:00.000Z", | |
| 89 | + "preferred": "0.36.0", | |
| 90 | + "versions": { | |
| 91 | + "0.36.0": { | |
| 92 | + "added": "2026-02-11T00:00:00.000Z", | |
| 93 | + "updated": "2026-02-11T00:00:00.000Z", | |
| 94 | + "info": { | |
| 95 | + "title": "Figma REST API", | |
| 96 | + "description": "The Figma REST API. Access files, components, styles, comments, users, projects, and more.", | |
| 97 | + "version": "0.36.0", | |
| 98 | + "x-apisguru-categories": ["design", "collaboration"], | |
| 99 | + "x-logo": { "url": "https://static.figma.com/app/icon/1/favicon.ico" }, | |
| 100 | + "x-origin": [{ "format": "openapi", "url": "https://raw.githubusercontent.com/figma/rest-api-spec/main/openapi/openapi.yaml", "version": "3.1" }], | |
| 101 | + "x-providerName": "figma.com" | |
| 102 | + }, | |
| 103 | + "swaggerUrl": "https://raw.githubusercontent.com/figma/rest-api-spec/main/openapi/openapi.yaml", | |
| 104 | + "openapiVer": "3.1.0" | |
| 105 | + } | |
| 106 | + } | |
| 107 | + }, | |
| 108 | + "sentry.io": { | |
| 109 | + "added": "2026-02-11T00:00:00.000Z", | |
| 110 | + "preferred": "v0", | |
| 111 | + "versions": { | |
| 112 | + "v0": { | |
| 113 | + "added": "2026-02-11T00:00:00.000Z", | |
| 114 | + "updated": "2026-02-11T00:00:00.000Z", | |
| 115 | + "info": { | |
| 116 | + "title": "Sentry API", | |
| 117 | + "description": "Sentry's API for error tracking, performance monitoring, and debugging.", | |
| 118 | + "version": "v0", | |
| 119 | + "x-apisguru-categories": ["monitoring", "developer_tools"], | |
| 120 | + "x-logo": { "url": "https://sentry.io/favicon.ico" }, | |
| 121 | + "x-origin": [{ "format": "openapi", "url": "https://raw.githubusercontent.com/getsentry/sentry-api-schema/main/openapi-derefed.json", "version": "3.0" }], | |
| 122 | + "x-providerName": "sentry.io" | |
| 123 | + }, | |
| 124 | + "swaggerUrl": "https://raw.githubusercontent.com/getsentry/sentry-api-schema/main/openapi-derefed.json", | |
| 125 | + "openapiVer": "3.0.3" | |
| 126 | + } | |
| 127 | + } | |
| 128 | + }, | |
| 129 | + "pagerduty.com": { | |
| 130 | + "added": "2026-02-11T00:00:00.000Z", | |
| 131 | + "preferred": "v2", | |
| 132 | + "versions": { | |
| 133 | + "v2": { | |
| 134 | + "added": "2026-02-11T00:00:00.000Z", | |
| 135 | + "updated": "2026-02-11T00:00:00.000Z", | |
| 136 | + "info": { | |
| 137 | + "title": "PagerDuty API", | |
| 138 | + "description": "PagerDuty's REST API for incident management, on-call scheduling, and alerting.", | |
| 139 | + "version": "v2", | |
| 140 | + "x-apisguru-categories": ["monitoring", "developer_tools"], | |
| 141 | + "x-logo": { "url": "https://www.pagerduty.com/favicon.ico" }, | |
| 142 | + "x-origin": [{ "format": "openapi", "url": "https://raw.githubusercontent.com/PagerDuty/api-schema/main/reference/REST/openapiv3.json", "version": "3.0" }], | |
| 143 | + "x-providerName": "pagerduty.com" | |
| 144 | + }, | |
| 145 | + "swaggerUrl": "https://raw.githubusercontent.com/PagerDuty/api-schema/main/reference/REST/openapiv3.json", | |
| 146 | + "openapiVer": "3.0.1" | |
| 147 | + } | |
| 148 | + } | |
| 149 | + }, | |
| 150 | + "intercom.io": { | |
| 151 | + "added": "2026-02-11T00:00:00.000Z", | |
| 152 | + "preferred": "2.13", | |
| 153 | + "versions": { | |
| 154 | + "2.13": { | |
| 155 | + "added": "2026-02-11T00:00:00.000Z", | |
| 156 | + "updated": "2026-02-11T00:00:00.000Z", | |
| 157 | + "info": { | |
| 158 | + "title": "Intercom API", | |
| 159 | + "description": "The Intercom API. Build customer messaging integrations with contacts, conversations, and more.", | |
| 160 | + "version": "2.13", | |
| 161 | + "x-apisguru-categories": ["messaging", "customer_support"], | |
| 162 | + "x-logo": { "url": "https://www.intercom.com/favicon.ico" }, | |
| 163 | + "x-origin": [{ "format": "openapi", "url": "https://raw.githubusercontent.com/intercom/Intercom-OpenAPI/main/descriptions/2.13/api.intercom.io.yaml", "version": "3.0" }], | |
| 164 | + "x-providerName": "intercom.io" | |
| 165 | + }, | |
| 166 | + "swaggerUrl": "https://raw.githubusercontent.com/intercom/Intercom-OpenAPI/main/descriptions/2.13/api.intercom.io.yaml", | |
| 167 | + "openapiVer": "3.0.1" | |
| 168 | + } | |
| 169 | + } | |
| 170 | + } | |
| 171 | +} | |
| @@ -0,0 +1,20 @@ | ||
| 1 | +{ | |
| 2 | + "compilerOptions": { | |
| 3 | + "target": "ESNext", | |
| 4 | + "module": "ESNext", | |
| 5 | + "moduleResolution": "Bundler", | |
| 6 | + "strict": true, | |
| 7 | + "skipLibCheck": true, | |
| 8 | + "noEmit": true, | |
| 9 | + "types": [ | |
| 10 | + "bun" | |
| 11 | + ], | |
| 12 | + "lib": [ | |
| 13 | + "ESNext", | |
| 14 | + "DOM" | |
| 15 | + ] | |
| 16 | + }, | |
| 17 | + "include": [ | |
| 18 | + "server.ts" | |
| 19 | + ] | |
| 20 | +} | |