History

Merge pull request #3 from RhysSullivan/feat/cloudflare-sandbox-runtime

Add Cloudflare Workers sandbox runtime for isolated code execution

Rhys Sullivan committed 9 months ago

Commit ae1b0332 parents 9f5774e 0b5549c42 files · +4059 −731Browse files

Unified · Split

42 files
  1. .gitignore +9 −0
  2. bun.lock +151 −2
  3. dev.ts +24 −6
  4. executor/apps/web/src/components/code-editor.tsx +53 −11
  5. executor/apps/web/src/components/tools-view.tsx +485 −348
  6. executor/bun.lock +11 −0
  7. executor/convex/_generated/api.d.ts +97 −0
  8. executor/convex/database.ts +168 −0
  9. executor/convex/executor.ts +61 −1
  10. executor/convex/executorNode.ts +218 −61
  11. executor/convex/http.ts +0 −85
  12. executor/convex/runtimeCallbacks.ts +88 −0
  13. executor/convex/schema.ts +26 −0
  14. executor/lib/adapters/in-process-execution-adapter.test.ts +1 −1
  15. executor/lib/adapters/in_process_execution_adapter.ts +14 −2
  16. executor/lib/execution_constants.ts +1 −0
  17. executor/lib/openapi-cache.test.ts +2 −1
  18. executor/lib/runtimes/cloudflare-worker-loader-runtime.test.ts +182 −0
  19. executor/lib/runtimes/cloudflare_worker_loader_runtime.ts +130 −0
  20. executor/lib/runtimes/runtime-core.test.ts +3 −1
  21. executor/lib/runtimes/runtime_catalog.ts +80 −0
  22. executor/lib/runtimes/runtime_core.ts +101 −103
  23. executor/lib/runtimes/transpile.ts +78 −0
  24. executor/lib/tool-sources.test.ts +84 −0
  25. executor/lib/tool_sources.ts +418 −9
  26. executor/lib/typechecker.test.ts +41 −1
  27. executor/lib/typechecker.ts +139 −98
  28. executor/lib/types.ts +25 −1
  29. executor/package.json +1 −0
  30. executor/packages/sandbox-host/.dev.vars.example +2 −0
  31. executor/packages/sandbox-host/package.json +20 −0
  32. executor/packages/sandbox-host/src/index.ts +405 −0
  33. executor/packages/sandbox-host/src/isolate/globals.isolate.js +2 −0
  34. executor/packages/sandbox-host/src/isolate/harness.isolate.js +108 −0
  35. executor/packages/sandbox-host/tsconfig.json +17 −0
  36. executor/packages/sandbox-host/wrangler.jsonc +28 −0
  37. package.json +3 −0
  38. sources/package.json +16 −0
  39. sources/scraper.ts +384 −0
  40. sources/server.ts +192 −0
  41. sources/supplements.json +171 −0
  42. sources/tsconfig.json +20 −0
.gitignore+9−0
@@ -39,6 +39,15 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json · 38 unmodified lines
3939 convex_local_backend.sqlite3
4040 convex_local_storage/
4141
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+
4251 # Generated push env file
4352 executor/.env.executor-push
4453
bun.lock+151−2
@@ -130,6 +130,25 @@ · 129 unmodified lines
130130 "name": "@executor/convex",
131131 "version": "0.0.1",
132132 },
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+ },
133152 },
134153 "trustedDependencies": [
135154 "sharp",
@@ -284,6 +303,26 @@ · 148 unmodified lines
284303
285304 "@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="],
286305
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+
287326 "@convex-dev/migrations": ["@convex-dev/migrations@0.3.1", "", { "peerDependencies": { "convex": "^1.24.8" } }, "sha512-dGiN82vWMN415/AGMcVooDOJk8iTousZahBBs27uA03pL74Uo3ksucUW3CiCfPx75rv+x9UnenEakimQYYNIrA=="],
288327
289328 "@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
294333
295334 "@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=="],
296335
336+ "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
337+
297338 "@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=="],
298339
299340 "@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
@@ -400,6 +441,8 @@ · 100 unmodified lines
400441
401442 "@executor/convex": ["@executor/convex@workspace:executor/convex"],
402443
444+ "@executor/sandbox-host": ["@executor/sandbox-host@workspace:executor/packages/sandbox-host"],
445+
403446 "@executor/web": ["@executor/web@workspace:executor/apps/web"],
404447
405448 "@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
496539
497540 "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
498541
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=="],
500543
501544 "@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=="],
502545
@@ -576,6 +619,14 @@ · 73 unmodified lines
576619
577620 "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
578621
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+
579630 "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
580631
581632 "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
@@ -828,6 +879,8 @@ · 246 unmodified lines
828879
829880 "@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="],
830881
882+ "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
883+
831884 "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
832885
833886 "@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
920973
921974 "@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="],
922975
976+ "@speed-highlight/core": ["@speed-highlight/core@1.2.14", "", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="],
977+
923978 "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
924979
925980 "@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
12081263
12091264 "basic-ftp": ["basic-ftp@5.1.0", "", {}, "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw=="],
12101265
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+
12111268 "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
12121269
12131270 "birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
12141271
1272+ "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
1273+
12151274 "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=="],
12161275
12171276 "bowser": ["bowser@2.13.1", "", {}, "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw=="],
@@ -1394,7 +1453,7 @@ · 176 unmodified lines
13941453
13951454 "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="],
13961455
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=="],
13981457
13991458 "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
14001459
@@ -1410,6 +1469,8 @@ · 9 unmodified lines
14101469
14111470 "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
14121471
1472+ "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
1473+
14131474 "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=="],
14141475
14151476 "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
@@ -1994,6 +2055,8 @@ · 578 unmodified lines
19942055
19952056 "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
19962057
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+
19972060 "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
19982061
19992062 "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -2474,6 +2537,8 @@ · 474 unmodified lines
24742537
24752538 "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
24762539
2540+ "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
2541+
24772542 "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
24782543
24792544 "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
25462611
25472612 "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
25482613
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+
25492618 "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=="],
25502619
25512620 "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
25722641
25732642 "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="],
25742643
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+
25752648 "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
25762649
25772650 "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
25802653
25812654 "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
25822655
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+
25832660 "@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=="],
25842661
25852662 "@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=="],
25862663
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+
25872666 "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
25882667
25892668 "@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
26122691
26132692 "@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=="],
26142693
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+
26152698 "@mariozechner/pi-ai/undici": ["undici@7.21.0", "", {}, "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg=="],
26162699
26172700 "@mistralai/mistralai/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
26182701
26192702 "@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=="],
26202703
2704+ "@proto/sources/@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
2705+
26212706 "@redocly/openapi-core/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
26222707
26232708 "@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
26902775
26912776 "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
26922777
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+
26932782 "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=="],
26942783
26952784 "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
@@ -2728,6 +2817,8 @@ · 32 unmodified lines
27282817
27292818 "vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
27302819
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+
27312822 "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=="],
27322823
27332824 "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
27382829
27392830 "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=="],
27402831
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+
27412836 "@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=="],
27422837
27432838 "@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
27682863
27692864 "@next/eslint-plugin-next/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
27702865
2866+ "@proto/sources/@types/bun/bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
2867+
27712868 "@redocly/openapi-core/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
27722869
27732870 "@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
27902887
27912888 "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
27922889
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+
27932942 "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
27942943
27952944 "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
dev.ts+24−6
@@ -6,10 +6,12 @@ · 5 unmodified lines
66 * Reads all configuration from the root .env file (auto-loaded by Bun).
77 *
88 * 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 ─┘
1315 *
1416 * All processes are killed when this script exits (Ctrl+C).
1517 * 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
2123 const PID_FILE = join(import.meta.dir, ".dev.pids");
2224
2325 const colors = {
26+ sources: "\x1b[33m", // yellow
2427 convex: "\x1b[36m", // cyan
2528 web: "\x1b[34m", // blue
29+ sandbox: "\x1b[91m", // bright red
2630 assistant: "\x1b[32m", // green
2731 bot: "\x1b[35m", // magenta
2832 reset: "\x1b[0m",
@@ -117,7 +121,8 @@ process.on("SIGTERM", shutdown); · 88 unmodified lines
117121 // ── Kill stale processes from a previous run ──
118122
119123 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];
121126
122127 async function killStaleProcesses() {
123128 let killed = 0;
@@ -164,6 +169,11 @@ const urls = resolveExecutorUrls(); · 40 unmodified lines
164169 console.log(prefix("convex", `Using Convex URL: ${urls.convexUrl}`));
165170 console.log(prefix("convex", `Using executor HTTP URL: ${urls.executorUrl}`));
166171
172+// 1. Sources catalog API
173+spawnService("sources", ["bun", "--hot", "server.ts"], {
174+ cwd: "./sources",
175+});
176+
167177 // 2. Start Convex file watcher (non-blocking — repushes on changes)
168178 spawnService("convex", [
169179 "bunx", "convex", "dev",
@@ -172,7 +182,15 @@ spawnService("convex", [ · 2 unmodified lines
172182 cwd: "./executor",
173183 });
174184
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
176194 spawnService("web", ["bun", "run", "dev"], {
177195 cwd: "./executor/apps/web",
178196 });
executor/apps/web/src/components/code-editor.tsx+53−11
@@ -116,20 +116,62 @@ function countAllTools(node: NamespaceNode): number { · 115 unmodified lines
116116
117117 /** TS helper types for OpenAPI indexed access (same as typechecker.ts) */
118118 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>;
120121 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;
121136 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>>>
125142 >;
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+]>;
133175 `;
134176
135177 function generateToolsDts(tools: ToolDescriptor[], dtsSources: Set<string>): string {
executor/apps/web/src/components/tools-view.tsx+485−348
@@ -1,6 +1,6 @@
11 "use client";
22
3−import { useState } from "react";
3+import { useRef, useState } from "react";
44 import {
55 Wrench,
66 Plus,
@@ -11,6 +11,7 @@ import { · 4 unmodified lines
1111 AlertTriangle,
1212 KeyRound,
1313 Pencil,
14+ Loader2,
1415 } from "lucide-react";
1516 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
1617 import { Button } from "@/components/ui/button";
@@ -56,158 +57,27 @@ import type { · 39 unmodified lines
5657 import { parse as parseDomain } from "tldts";
5758 import { toast } from "sonner";
5859 import { cn } from "@/lib/utils";
60+import { Streamdown } from "streamdown";
5961
60−// ── API Presets ──
61−
62−interface ApiPreset {
62+interface CatalogCollectionItem {
63+ id: string;
6364 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;
7272 }
7373
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+}
21181
21282 /** Derive a favicon URL from any URL string via Google's favicon service. */
21383 function faviconForUrl(url: string | undefined | null): string | null {
@@ -220,16 +90,6 @@ function faviconForUrl(url: string | undefined | null): string | null { · 6 unmodified lines
22090 }
22191 }
22292
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−
23393 function getSourceFavicon(source: ToolSourceRecord): string | null {
23494 if (source.type === "mcp") {
23595 return faviconForUrl((source.config.url as string) ?? null);
@@ -237,7 +97,30 @@ function getSourceFavicon(source: ToolSourceRecord): string | null { · 1 unmodified line
23797 if (source.type === "graphql") {
23898 return faviconForUrl((source.config.endpoint as string) ?? null);
23999 }
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) ?? "";
241124 }
242125
243126 function sourceKeyForSource(source: ToolSourceRecord): string | null {
@@ -366,6 +249,39 @@ function inferNameFromUrl(url: string): string { · 122 unmodified lines
366249 }
367250 }
368251
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+
369285 function AddSourceDialog({
370286 existingSourceNames,
371287 }: {
@@ -374,7 +290,7 @@ function AddSourceDialog({ · 2 unmodified lines
374290 const { context } = useSession();
375291 const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource);
376292 const [open, setOpen] = useState(false);
377− const [presetsOpen, setPresetsOpen] = useState(false);
293+ const [view, setView] = useState<"catalog" | "custom">("catalog");
378294 const [type, setType] = useState<"mcp" | "openapi" | "graphql">("mcp");
379295 const [name, setName] = useState("");
380296 const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
@@ -383,7 +299,35 @@ function AddSourceDialog({ · 2 unmodified lines
383299 const [mcpTransport, setMcpTransport] = useState<"auto" | "streamable-http" | "sse">("auto");
384300 const [mcpActorQueryParamKey, setMcpActorQueryParamKey] = useState("userId");
385301 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+ };
387331
388332 const handleEndpointChange = (value: string) => {
389333 setEndpoint(value);
@@ -398,20 +342,131 @@ function AddSourceDialog({ · 8 unmodified lines
398342 setNameManuallyEdited(true);
399343 };
400344
401− const resetForm = () => {
345+ const resetDialogState = () => {
346+ catalogRequestIdRef.current += 1;
347+ catalogInFlightRef.current = false;
348+ setView("catalog");
349+ setType("mcp");
402350 setName("");
403351 setEndpoint("");
404352 setBaseUrl("");
405353 setMcpTransport("auto");
406354 setMcpActorQueryParamKey("userId");
407355 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+ }
410459 };
411460
412461 const handleOpenChange = (isOpen: boolean) => {
413462 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" });
415470 };
416471
417472 const addSource = async (
@@ -430,31 +485,35 @@ function AddSourceDialog({ · 12 unmodified lines
430485 toast.success(`Source "${sourceName}" added — loading tools…`);
431486 };
432487
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);
435495 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");
451503 } finally {
452− setAddingPreset(null);
504+ setAddingCatalogId(null);
453505 }
454506 };
455507
456508 const handleCustomSubmit = async () => {
457509 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+
458517 setSubmitting(true);
459518 try {
460519 const config: Record<string, unknown> =
@@ -470,7 +529,8 @@ function AddSourceDialog({ · 9 unmodified lines
470529 ? { endpoint: endpoint }
471530 : { spec: endpoint, ...(baseUrl ? { baseUrl } : {}) };
472531 await addSource(name.trim(), type, config);
473− resetForm();
532+ reserveSourceName(name.trim());
533+ resetDialogState();
474534 setOpen(false);
475535 } catch (err) {
476536 toast.error(err instanceof Error ? err.message : "Failed to add source");
@@ -495,180 +555,261 @@ function AddSourceDialog({ · 18 unmodified lines
495555 </DialogHeader>
496556
497557 <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+ }}
505617 >
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>
547701 </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+
549732 <div className="space-y-1.5">
550733 <Label className="text-xs text-muted-foreground">
551− Base URL (optional)
734+ {type === "mcp" ? "Endpoint URL" : type === "graphql" ? "GraphQL Endpoint" : "Spec URL"}
552735 </Label>
553736 <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+ }
557746 className="h-8 text-xs font-mono bg-background"
558747 />
559748 </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" && (
585761 <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>
589763 <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"
593767 className="h-8 text-xs font-mono bg-background"
594768 />
595769 </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+ )}
607771
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")}
638779 >
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+ )}
672813 </div>
673814 </DialogContent>
674815 </Dialog>
@@ -918,11 +1059,7 @@ function SourceCard({ · 243 unmodified lines
9181059 )}
9191060 </div>
9201061 <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)}
9261063 </span>
9271064 {source.type === "openapi" && quality && (
9281065 <span className="text-[10px] text-muted-foreground/90 font-mono truncate block mt-0.5">
executor/bun.lock+11−0
@@ -5,12 +5,17 @@ · 4 unmodified lines
55 "": {
66 "name": "executor",
77 "dependencies": {
8+ "better-result": "^2.7.0",
89 "convex": "^1.31.7",
910 "graphql": "^16.12.0",
1011 },
1112 },
1213 },
1314 "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+
1419 "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="],
1520
1621 "@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="],
@@ -63,12 +68,18 @@ · 46 unmodified lines
6368
6469 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="],
6570
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+
6673 "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=="],
6774
6875 "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=="],
6976
7077 "graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="],
7178
79+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
80+
7281 "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=="],
7384 }
7485 }
executor/convex/_generated/api.d.ts+97−0
@@ -276,6 +276,47 @@ export declare const api: { · 275 unmodified lines
276276 any
277277 >;
278278 };
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+ };
279320 workspace: {
280321 bootstrapAnonymousSession: FunctionReference<
281322 "mutation",
@@ -520,6 +561,18 @@ export declare const internal: { · 238 unmodified lines
520561 { sourceId: string; workspaceId: Id<"workspaces"> },
521562 any
522563 >;
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+ >;
523576 getActiveAnonymousOauthSigningKey: FunctionReference<
524577 "query",
525578 "internal",
@@ -551,6 +604,12 @@ export declare const internal: { · 25 unmodified lines
551604 { taskId: string; workspaceId: Id<"workspaces"> },
552605 any
553606 >;
607+ getToolCall: FunctionReference<
608+ "query",
609+ "internal",
610+ { callId: string; taskId: string },
611+ any
612+ >;
554613 listAccessPolicies: FunctionReference<
555614 "query",
556615 "internal",
@@ -598,6 +657,12 @@ export declare const internal: { · 41 unmodified lines
598657 { workspaceId: Id<"workspaces"> },
599658 any
600659 >;
660+ listToolCalls: FunctionReference<
661+ "query",
662+ "internal",
663+ { taskId: string },
664+ any
665+ >;
601666 listToolSources: FunctionReference<
602667 "query",
603668 "internal",
@@ -651,6 +716,12 @@ export declare const internal: { · 47 unmodified lines
651716 },
652717 any
653718 >;
719+ setToolCallPendingApproval: FunctionReference<
720+ "mutation",
721+ "internal",
722+ { approvalId: string; callId: string; taskId: string },
723+ any
724+ >;
654725 storeAnonymousOauthSigningKey: FunctionReference<
655726 "mutation",
656727 "internal",
@@ -690,6 +761,18 @@ export declare const internal: { · 33 unmodified lines
690761 },
691762 any
692763 >;
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+ >;
693776 upsertToolSource: FunctionReference<
694777 "mutation",
695778 "internal",
@@ -716,6 +799,20 @@ export declare const internal: { · 20 unmodified lines
716799 },
717800 any
718801 >;
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+ >;
719816 createTaskInternal: FunctionReference<
720817 "mutation",
721818 "internal",
executor/convex/database.ts+168−0
@@ -16,6 +16,11 @@ const completedTaskStatusValidator = v.union( · 15 unmodified lines
1616 v.literal("denied"),
1717 );
1818 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+);
1924 const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
2025 const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));
2126 const credentialProviderValidator = v.union(
@@ -127,6 +132,23 @@ function mapApproval(doc: Doc<"approvals">) { · 105 unmodified lines
127132 };
128133 }
129134
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+
130152 function mapPolicy(doc: Doc<"accessPolicies">) {
131153 return {
132154 id: doc.policyId,
@@ -309,6 +331,17 @@ async function getApprovalDoc(ctx: { db: QueryCtx["db"] }, approvalId: string) { · 176 unmodified lines
309331 .unique();
310332 }
311333
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+
312345 export const createTask = internalMutation({
313346 args: {
314347 id: v.string(),
@@ -391,6 +424,11 @@ export const listRuntimeTargets = internalQuery({ · 76 unmodified lines
391424 label: "Local JS Runtime",
392425 description: "Runs generated code in-process using Bun",
393426 },
427+ {
428+ id: "cloudflare-worker-loader",
429+ label: "Cloudflare Worker Loader",
430+ description: "Runs generated code in a Cloudflare Worker",
431+ }
394432 ];
395433 },
396434 });
@@ -441,6 +479,10 @@ export const markTaskFinished = internalMutation({ · 44 unmodified lines
441479 return null;
442480 }
443481
482+ if (doc.status === "completed" || doc.status === "failed" || doc.status === "timed_out" || doc.status === "denied") {
483+ return mapTask(doc);
484+ }
485+
444486 const now = Date.now();
445487 await ctx.db.patch(doc._id, {
446488 status: args.status,
@@ -607,6 +649,132 @@ export const getApprovalInWorkspace = internalQuery({ · 160 unmodified lines
607649 },
608650 });
609651
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+
610778 export const bootstrapAnonymousSession = internalMutation({
611779 args: { sessionId: v.optional(v.string()) },
612780 handler: async (ctx, args) => {
executor/convex/executor.ts+61−1
@@ -5,6 +5,7 @@ import type { MutationCtx } from "./_generated/server"; · 4 unmodified lines
55 import { internalMutation } from "./_generated/server";
66 import { workspaceMutation } from "../lib/functionBuilders";
77 import { actorIdForAccount } from "../lib/identity";
8+import { isKnownRuntimeId } from "../lib/runtimes/runtime_catalog";
89 import type { ApprovalRecord, TaskRecord } from "../lib/types";
910
1011 const DEFAULT_TIMEOUT_MS = 300_000;
@@ -21,6 +22,14 @@ async function publishTaskEvent( · 10 unmodified lines
2122 await ctx.runMutation(internal.database.createTaskEvent, input);
2223 }
2324
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+
2433 async function createTaskRecord(
2534 ctx: MutationCtx,
2635 args: {
@@ -38,7 +47,7 @@ async function createTaskRecord( · 11 unmodified lines
3847 }
3948
4049 const runtimeId = args.runtimeId ?? "local-bun";
41− if (runtimeId !== "local-bun") {
50+ if (!isKnownRuntimeId(runtimeId)) {
4251 throw new Error(`Unsupported runtime: ${runtimeId}`);
4352 }
4453
@@ -235,3 +244,54 @@ export const appendRuntimeOutput = internalMutation({ · 190 unmodified lines
235244 return { ok: true as const };
236245 },
237246 });
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+});
executor/convex/executorNode.ts+218−61
@@ -7,8 +7,15 @@ import { action, internalAction } from "./_generated/server"; · 6 unmodified lines
77 import type { ActionCtx } from "./_generated/server";
88 import { InProcessExecutionAdapter } from "../lib/adapters/in_process_execution_adapter";
99 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";
1111 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";
1219 import { runCodeWithAdapter } from "../lib/runtimes/runtime_core";
1320 import { createDiscoverTool } from "../lib/tool_discovery";
1421 import {
@@ -34,6 +41,7 @@ import type { · 19 unmodified lines
3441 ResolvedToolCredential,
3542 TaskRecord,
3643 ToolCallRequest,
44+ ToolCallRecord,
3745 ToolCallResult,
3846 ToolCredentialSpec,
3947 ToolDefinition,
@@ -263,10 +271,6 @@ function normalizeExternalToolSource(raw: { · 223 unmodified lines
263271 return result;
264272 }
265273
266−async function sleep(ms: number): Promise<void> {
267− await new Promise((resolve) => setTimeout(resolve, ms));
268−}
269−
270274 const baseTools = new Map<string, ToolDefinition>(DEFAULT_TOOLS.map((tool) => [tool.path, tool]));
271275 interface DtsStorageEntry {
272276 sourceKey: string;
@@ -293,21 +297,6 @@ async function publish( · 20 unmodified lines
293297 });
294298 }
295299
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−
311300 /**
312301 * Load a prepared OpenAPI spec, using Convex file storage as a persistent cache.
313302 *
@@ -840,6 +829,26 @@ function getGraphqlDecision( · 526 unmodified lines
840829
841830 async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallRequest): Promise<unknown> {
842831 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+
843852 const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId });
844853 const typedPolicies = policies as AccessPolicyRecord[];
845854
@@ -885,14 +894,23 @@ async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallReques · 39 unmodified lines
885894 decision = getToolDecision(task, tool, typedPolicies);
886895 }
887896
897+ const publishToolStarted = persistedCall.status === "requested";
898+
888899 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+ });
889907 await publish(ctx, task.id, "task", "tool.call.denied", {
890908 taskId: task.id,
891909 callId,
892910 toolPath: effectiveToolPath,
893911 reason: "policy_deny",
894912 });
895− throw new Error(`${APPROVAL_DENIED_PREFIX}${effectiveToolPath} (policy denied)`);
913+ throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`);
896914 }
897915
898916 let credential: ResolvedToolCredential | undefined;
@@ -904,40 +922,98 @@ async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallReques · 5 unmodified lines
904922 credential = resolved;
905923 }
906924
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", {
918927 taskId: task.id,
928+ callId,
919929 toolPath: effectiveToolPath,
920− input,
930+ approval: decision === "require_approval" ? "required" : "auto",
931+ input: asPayload(input),
921932 });
933+ }
922934
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, {
925993 taskId: task.id,
926994 callId,
927− toolPath: approval.toolPath,
928− input: asPayload(approval.input),
929− createdAt: approval.createdAt,
995+ approvalId: approval.id,
930996 });
931997
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+ });
9341010 await publish(ctx, task.id, "task", "tool.call.denied", {
9351011 taskId: task.id,
9361012 callId,
9371013 toolPath: effectiveToolPath,
9381014 approvalId: approval.id,
9391015 });
940− throw new Error(`${APPROVAL_DENIED_PREFIX}${effectiveToolPath} (${approval.id})`);
1016+ throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`);
9411017 }
9421018 }
9431019
@@ -951,6 +1027,12 @@ async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallReques · 7 unmodified lines
9511027 isToolAllowed: (path) => isToolAllowedForTask(task, path, workspaceTools ?? baseTools, typedPolicies),
9521028 };
9531029 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+ });
9541036 await publish(ctx, task.id, "task", "tool.call.completed", {
9551037 taskId: task.id,
9561038 callId,
@@ -960,6 +1042,12 @@ async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallReques · 3 unmodified lines
9601042 return value;
9611043 } catch (error) {
9621044 const message = describeError(error);
1045+ await ctx.runMutation(internal.database.finishToolCall, {
1046+ taskId: task.id,
1047+ callId,
1048+ status: "failed",
1049+ error: message,
1050+ });
9631051 await publish(ctx, task.id, "task", "tool.call.failed", {
9641052 taskId: task.id,
9651053 callId,
@@ -1051,6 +1139,7 @@ export const handleExternalToolCall = internalAction({ · 85 unmodified lines
10511139 if (!task) {
10521140 return {
10531141 ok: false,
1142+ kind: "failed",
10541143 error: `Run not found: ${args.runId}`,
10551144 };
10561145 }
@@ -1065,16 +1154,27 @@ export const handleExternalToolCall = internalAction({ · 8 unmodified lines
10651154 return { ok: true, value };
10661155 } catch (error) {
10671156 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+ }
10681167 if (message.startsWith(APPROVAL_DENIED_PREFIX)) {
10691168 return {
10701169 ok: false,
1071− denied: true,
1170+ kind: "denied",
10721171 error: message.replace(APPROVAL_DENIED_PREFIX, "").trim(),
10731172 };
10741173 }
10751174
10761175 return {
10771176 ok: false,
1177+ kind: "failed",
10781178 error: message,
10791179 };
10801180 }
@@ -1089,7 +1189,7 @@ export const runTask = internalAction({ · 8 unmodified lines
10891189 return null;
10901190 }
10911191
1092− if (task.runtimeId !== "local-bun") {
1192+ if (!isKnownRuntimeId(task.runtimeId)) {
10931193 const failed = await ctx.runMutation(internal.database.markTaskFinished, {
10941194 taskId: args.taskId,
10951195 status: "failed",
@@ -1108,6 +1208,25 @@ export const runTask = internalAction({ · 12 unmodified lines
11081208 return null;
11091209 }
11101210
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+
11111230 try {
11121231 const running = (await ctx.runMutation(internal.database.markTaskRunning, {
11131232 taskId: args.taskId,
@@ -1122,27 +1241,65 @@ export const runTask = internalAction({ · 8 unmodified lines
11221241 startedAt: running.startedAt,
11231242 });
11241243
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({
11401246 taskId: args.taskId,
11411247 code: running.code,
11421248 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+ })();
11461303
11471304 const finished = await ctx.runMutation(internal.database.markTaskFinished, {
11481305 taskId: args.taskId,
executor/convex/http.ts+0−85
@@ -9,7 +9,6 @@ import type { AnonymousContext, PendingApprovalRecord, TaskRecord, ToolDescripto · 8 unmodified lines
99 import type { Id } from "./_generated/dataModel";
1010
1111 const http = httpRouter();
12−const internalToken = process.env.EXECUTOR_INTERNAL_TOKEN ?? null;
1312
1413 function getMcpAuthorizationServer(): string | null {
1514 return process.env.MCP_AUTHORIZATION_SERVER
@@ -121,28 +120,6 @@ function parseMcpContext(url: URL): { · 105 unmodified lines
121120 return { workspaceId, clientId, sessionId };
122121 }
123122
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−
146123 const mcpHandler = httpAction(async (ctx, request) => {
147124 const url = new URL(request.url);
148125 const mcpAuthConfig = getMcpAuthConfig();
@@ -313,62 +290,6 @@ const oauthAuthorizationServerProxyHandler = httpAction(async (_ctx, request) => · 164 unmodified lines
313290 });
314291 });
315292
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−
372293 authKit.registerRoutes(http);
373294 registerStripeRoutes(http, components.stripe, {
374295 webhookPath: "/stripe/webhook",
@@ -380,10 +301,4 @@ http.route({ path: "/mcp", method: "DELETE", handler: mcpHandler }); · 5 unmodified lines
380301 http.route({ path: "/.well-known/oauth-protected-resource", method: "GET", handler: oauthProtectedResourceHandler });
381302 http.route({ path: "/.well-known/oauth-authorization-server", method: "GET", handler: oauthAuthorizationServerProxyHandler });
382303
383−http.route({
384− pathPrefix: "/internal/runs/",
385− method: "POST",
386− handler: internalRunsHandler,
387−});
388−
389304 export default http;
executor/convex/runtimeCallbacks.ts+88−0
@@ -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+});
executor/convex/schema.ts+26−0
@@ -34,6 +34,13 @@ const taskStatus = v.union( · 33 unmodified lines
3434 v.literal("denied"),
3535 );
3636 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+);
3744 const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
3845 const credentialScope = v.union(v.literal("workspace"), v.literal("actor"));
3946 const credentialProvider = v.union(
@@ -207,6 +214,25 @@ export default defineSchema({ · 167 unmodified lines
207214 .index("by_workspace_created", ["workspaceId", "createdAt"])
208215 .index("by_workspace_status_created", ["workspaceId", "status", "createdAt"]),
209216
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+
210236 taskEvents: defineTable({
211237 sequence: v.number(),
212238 taskId: v.string(), // references tasks.taskId (not tasks._id)
executor/lib/adapters/in-process-execution-adapter.test.ts+1−1
@@ -45,7 +45,7 @@ test("maps approval denied errors to denied result", async () => { · 44 unmodified lines
4545
4646 expect(result).toEqual({
4747 ok: false,
48− denied: true,
48+ kind: "denied",
4949 error: "approval required",
5050 });
5151 });
executor/lib/adapters/in_process_execution_adapter.ts+14−2
@@ -1,4 +1,4 @@
1−import { APPROVAL_DENIED_PREFIX } from "../execution_constants";
1+import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../execution_constants";
22 import type {
33 ExecutionAdapter,
44 RuntimeOutputEvent,
@@ -20,6 +20,7 @@ export class InProcessExecutionAdapter implements ExecutionAdapter { · 15 unmodified lines
2020 if (call.runId !== this.options.runId) {
2121 return {
2222 ok: false,
23+ kind: "failed",
2324 error: `Run mismatch for call ${call.callId}`,
2425 };
2526 }
@@ -32,13 +33,24 @@ export class InProcessExecutionAdapter implements ExecutionAdapter { · 6 unmodified lines
3233 if (message.startsWith(APPROVAL_DENIED_PREFIX)) {
3334 return {
3435 ok: false,
35− denied: true,
36+ kind: "denied",
3637 error: message.replace(APPROVAL_DENIED_PREFIX, "").trim(),
3738 };
3839 }
3940
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+
4051 return {
4152 ok: false,
53+ kind: "failed",
4254 error: message,
4355 };
4456 }
executor/lib/execution_constants.ts+1−0
@@ -1,2 +1,3 @@
11 export const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:";
2+export const APPROVAL_PENDING_PREFIX = "APPROVAL_PENDING:";
23 export const TASK_TIMEOUT_MARKER = "TASK_TIMEOUT";
executor/lib/openapi-cache.test.ts+2−1
@@ -191,7 +191,8 @@ describe("prepareOpenApiSpec with large specs", () => { · 190 unmodified lines
191191 expect(tools.length).toBeGreaterThan(700);
192192 const deleteTool = tools.find((tool) => tool.metadata?.operationId?.startsWith("delete_"));
193193 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[");
195196 expect(elapsed).toBeLessThan(20_000);
196197 });
197198
executor/lib/runtimes/cloudflare-worker-loader-runtime.test.ts+182−0
@@ -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+});
executor/lib/runtimes/cloudflare_worker_loader_runtime.ts+130−0
@@ -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+}
executor/lib/runtimes/runtime-core.test.ts+3−1
@@ -28,6 +28,7 @@ function createRuntimeAdapter( · 27 unmodified lines
2828 if (!tool) {
2929 return {
3030 ok: false,
31+ kind: "failed",
3132 error: `Tool not found: ${call.toolPath}`,
3233 };
3334 }
@@ -45,6 +46,7 @@ function createRuntimeAdapter( · 11 unmodified lines
4546 } catch (error) {
4647 return {
4748 ok: false,
49+ kind: "failed",
4850 error: error instanceof Error ? error.message : String(error),
4951 };
5052 }
@@ -89,7 +91,7 @@ test("returns denied when adapter marks tool call denied", async () => { · 38 unmodified lines
8991 async invokeTool() {
9092 return {
9193 ok: false,
92− denied: true,
94+ kind: "denied",
9395 error: "policy denied",
9496 };
9597 },
executor/lib/runtimes/runtime_catalog.ts+80−0
@@ -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+}
executor/lib/runtimes/runtime_core.ts+101−103
@@ -5,50 +5,20 @@ · 4 unmodified lines
55 // the core helpers here (formatArgs, createToolsProxy, console proxy, execution
66 // loop, result mapping) should be mirrored there.
77 import { APPROVAL_DENIED_PREFIX, TASK_TIMEOUT_MARKER } from "../execution_constants";
8+import { Result } from "better-result";
89 import { Script, createContext } from "node:vm";
910 import type {
1011 ExecutionAdapter,
1112 SandboxExecutionRequest,
1213 SandboxExecutionResult,
1314 } 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";
4216
4317 function formatArgs(args: unknown[]): string {
4418 return args
4519 .map((value) => {
4620 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));
5222 })
5323 .join(" ");
5424 }
@@ -59,6 +29,10 @@ function fireAndForget(promise: void | Promise<void>): void { · 4 unmodified lines
5929 }
6030 }
6131
32+async function sleep(ms: number): Promise<void> {
33+ await new Promise((resolve) => setTimeout(resolve, ms));
34+}
35+
6236 function createToolsProxy(
6337 adapter: ExecutionAdapter,
6438 runId: string,
@@ -78,26 +52,59 @@ function createToolsProxy( · 13 unmodified lines
7852 }
7953
8054 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+ }
9479 }
95−
96− throw new Error(result.error);
9780 },
9881 });
9982 }
10083
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+
101108 export async function runCodeWithAdapter(
102109 request: SandboxExecutionRequest,
103110 adapter: ExecutionAdapter,
@@ -130,6 +137,26 @@ export async function runCodeWithAdapter( · 26 unmodified lines
130137 );
131138 };
132139
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 ──────────────────────────────────────────────────────
133160 const tools = createToolsProxy(adapter, request.taskId);
134161 const consoleProxy = {
135162 log: (...args: unknown[]) => appendStdout(formatArgs(args)),
@@ -145,15 +172,14 @@ export async function runCodeWithAdapter( · 9 unmodified lines
145172 clearTimeout,
146173 });
147174 const context = createContext(sandbox, {
148− codeGeneration: {
149− strings: false,
150− wasm: false,
151− },
175+ codeGeneration: { strings: false, wasm: false },
152176 });
153177
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+ );
156181
182+ // ── Execute with timeout ───────────────────────────────────────────────
157183 let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
158184 const timeoutPromise = new Promise<never>((_resolve, reject) => {
159185 timeoutHandle = setTimeout(() => {
@@ -161,60 +187,32 @@ export async function runCodeWithAdapter( · 1 unmodified line
161187 }, request.timeoutMs);
162188 });
163189
164− try {
190+ const execution = await Result.tryPromise(async () => {
165191 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+ ),
167197 timeoutPromise,
168198 ]);
199+ return value;
200+ });
169201
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);
206203
204+ if (execution.isErr()) {
205+ const { status, message } = classifyExecutionError(
206+ execution.error.cause,
207+ request,
208+ );
207209 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 });
219211 }
212+
213+ if (execution.value !== undefined) {
214+ appendStdout(`result: ${formatArgs([execution.value])}`);
215+ }
216+
217+ return mkResult("completed", { exitCode: 0 });
220218 }
executor/lib/runtimes/transpile.ts+78−0
@@ -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+}
executor/lib/tool-sources.test.ts+84−0
@@ -321,6 +321,90 @@ test("compiled source artifacts can be materialized and executed", async () => { · 320 unmodified lines
321321 }
322322 });
323323
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+
324408 test("parseGraphqlOperationPaths handles aliases and fragments", async () => {
325409 const parsed = parseGraphqlOperationPaths(
326410 "linear",
executor/lib/tool_sources.ts+418−9
@@ -36,6 +36,10 @@ export interface OpenApiToolSourceConfig { · 35 unmodified lines
3636 sourceId?: string;
3737 sourceKey?: string;
3838 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;
3943 baseUrl?: string;
4044 auth?: OpenApiAuth;
4145 defaultReadApproval?: ToolApprovalMode;
@@ -62,6 +66,23 @@ export type ExternalToolSourceConfig = · 20 unmodified lines
6266 | OpenApiToolSourceConfig
6367 | GraphqlToolSourceConfig;
6468
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+
6586 function sanitizeSegment(value: string): string {
6687 const cleanedBase = value
6788 .toLowerCase()
@@ -665,6 +686,161 @@ function getCredentialSourceKey(config: { · 597 unmodified lines
665686 return config.sourceKey ?? `${config.type}:${config.name}`;
666687 }
667688
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+
668844 function buildOpenApiUrl(
669845 baseUrl: string,
670846 pathTemplate: string,
@@ -828,7 +1004,6 @@ function compactOpenApiPaths( · 157 unmodified lines
8281004 ? jsonSchemaTypeHintFallback(combinedSchema, 0, compSchemas)
8291005 : "{}";
8301006 compactOperation._returnsTypeHint = responseTypeHintFromSchema(responseSchema, responseStatus, compSchemas);
831− compactOperation._usesGeneratedTypes = true;
8321007 const previewKeys = [
8331008 ...mergedParameters.map((param) => String(param.name ?? "")).filter((name) => name.length > 0),
8341009 ...Object.keys(asRecord(requestBodySchema.properties)),
@@ -1105,16 +1280,10 @@ export function buildOpenApiToolsFromPrepared( · 270 unmodified lines
11051280 }
11061281 }
11071282
1108− const usesGeneratedTypes = Boolean(operation._usesGeneratedTypes);
1109− const operationIdKey = JSON.stringify(operationIdRaw);
11101283 const displayArgsType = argPreviewKeys.length > 0
11111284 ? 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);
11181287
11191288 const approval = config.overrides?.[operationIdRaw]?.approval
11201289 ?? (readMethods.has(method)
@@ -1185,7 +1354,234 @@ export function buildOpenApiToolsFromPrepared( · 64 unmodified lines
11851354 return tools;
11861355 }
11871356
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+
11881577 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+
11891585 const prepared = await prepareOpenApiSpec(config.spec, config.name);
11901586 return buildOpenApiToolsFromPrepared(config, prepared);
11911587 }
@@ -1895,6 +2291,7 @@ export interface SerializedTool { · 703 unmodified lines
18952291 * Data needed to reconstruct `run()`. Shape depends on source type.
18962292 * OpenAPI: { kind: "openapi", baseUrl, method, pathTemplate, parameters, authHeaders }
18972293 * MCP: { kind: "mcp", url, transport?, queryParams?, toolName }
2294+ * Postman: { kind: "postman", method, url, headers, queryParams, body?, variables, authHeaders }
18982295 * GraphQL raw: { kind: "graphql_raw", endpoint, authHeaders }
18992296 * GraphQL field: { kind: "graphql_field", endpoint, operationName, operationType, queryTemplate, argNames?, authHeaders }
19002297 * Builtin: { kind: "builtin" } — run comes from DEFAULT_TOOLS
@@ -1915,6 +2312,7 @@ export interface SerializedTool { · 14 unmodified lines
19152312 queryParams?: Record<string, string>;
19162313 toolName: string;
19172314 }
2315+ | PostmanSerializedRunSpec
19182316 | {
19192317 kind: "graphql_raw";
19202318 endpoint: string;
@@ -2062,6 +2460,17 @@ export function rehydrateTools( · 141 unmodified lines
20622460 };
20632461 }
20642462
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+
20652474 if (st.runSpec.kind === "mcp") {
20662475 const { url, transport, queryParams, toolName } = st.runSpec;
20672476 return {
executor/lib/typechecker.test.ts+41−1
@@ -307,6 +307,47 @@ export interface operations { · 306 unmodified lines
307307 expect(bad.errors.length).toBeGreaterThan(0);
308308 });
309309
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+
310351 test("optional parameter can be provided", () => {
311352 const result = typecheckCode(
312353 'const r = await tools.admin.send_announcement({ message: "hi", channel: "general" }); return r.sent;',
@@ -424,4 +465,3 @@ export interface operations { · 111 unmodified lines
424465 });
425466 });
426467
427−
executor/lib/typechecker.ts+139−98
@@ -13,6 +13,7 @@ · 12 unmodified lines
1313 * without needing their own TypeScript setup.
1414 */
1515
16+import { Result } from "better-result";
1617 import type { ToolDescriptor } from "./types";
1718
1819 // ---------------------------------------------------------------------------
@@ -23,11 +24,8 @@ let cachedTypeScript: typeof import("typescript") | null | undefined; · 4 unmodified lines
2324
2425 function getTypeScriptModule(): typeof import("typescript") | null {
2526 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;
3129 }
3230 return cachedTypeScript ?? null;
3331 }
@@ -39,7 +37,7 @@ function isValidTypeExpression(typeExpression: string): boolean { · 5 unmodified lines
3937 return !/[\r\n`]/.test(typeExpression);
4038 }
4139
42− try {
40+ return Result.try(() => {
4341 const sourceFile = ts.createSourceFile(
4442 "_type_expr_check.ts",
4543 `type __T = ${typeExpression};`,
@@ -51,9 +49,7 @@ function isValidTypeExpression(typeExpression: string): boolean { · 5 unmodified lines
5149 sourceFile as unknown as { parseDiagnostics?: import("typescript").Diagnostic[] }
5250 ).parseDiagnostics ?? [];
5351 return diagnostics.length === 0;
54− } catch {
55− return false;
56− }
52+ }).unwrapOr(false);
5753 }
5854
5955 function safeTypeExpression(raw: string | undefined, fallback: string): string {
@@ -63,20 +59,62 @@ function safeTypeExpression(raw: string | undefined, fallback: string): string { · 3 unmodified lines
6359 }
6460
6561 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>;
6764 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;
6879 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>>>
7285 >;
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+]>;
80118 `;
81119
82120 function stripExportKeywordsForTypechecker(dts: string): string {
@@ -233,6 +271,8 @@ export interface TypecheckResult { · 150 unmodified lines
233271 readonly errors: readonly string[];
234272 }
235273
274+const TYPECHECK_OK: TypecheckResult = { ok: true, errors: [] };
275+
236276 let warnedMissingCompilerHostSupport = false;
237277 let warnedSemanticFallback = false;
238278
@@ -245,7 +285,7 @@ function runSyntaxOnlyTypecheck( · 6 unmodified lines
245285 headerLineCount: number,
246286 ) => string,
247287 ): TypecheckResult {
248− try {
288+ return Result.try(() => {
249289 const sourceFile = ts.createSourceFile(
250290 "generated.ts",
251291 wrappedCode,
@@ -256,16 +296,72 @@ function runSyntaxOnlyTypecheck( · 4 unmodified lines
256296 const diagnostics = (
257297 sourceFile as unknown as { parseDiagnostics?: import("typescript").Diagnostic[] }
258298 ).parseDiagnostics ?? [];
259− if (diagnostics.length === 0) {
260− return { ok: true, errors: [] };
261− }
299+ if (diagnostics.length === 0) return TYPECHECK_OK;
262300 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)),
265303 };
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+ });
269365 }
270366
271367 /**
@@ -278,13 +374,8 @@ export function typecheckCode( · 6 unmodified lines
278374 code: string,
279375 toolDeclarations: string,
280376 ): 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;
288379
289380 // Wrap the code in an async function with the tools declaration.
290381 // We declare sandbox globals (console, setTimeout, etc.) ourselves since
@@ -301,12 +392,12 @@ export function typecheckCode( · 10 unmodified lines
301392
302393 const formatError = (
303394 diagnostic: import("typescript").Diagnostic,
304− headerLineCount: number,
395+ hdrLineCount: number,
305396 ): string => {
306397 const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
307398 if (diagnostic.start !== undefined && diagnostic.file) {
308399 const { line } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
309− const adjustedLine = line + 1 - headerLineCount;
400+ const adjustedLine = line + 1 - hdrLineCount;
310401 if (adjustedLine > 0) {
311402 return `Line ${adjustedLine}: ${message}`;
312403 }
@@ -328,66 +419,16 @@ export function typecheckCode( · 15 unmodified lines
328419 return runSyntaxOnlyTypecheck(ts, wrappedCode, headerLineCount, formatError);
329420 }
330421
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;
371424
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+ );
392431 }
432+
433+ return runSyntaxOnlyTypecheck(ts, wrappedCode, headerLineCount, formatError);
393434 }
executor/lib/types.ts+25−1
@@ -4,6 +4,7 @@ import type { Id } from "../convex/_generated/dataModel"; · 3 unmodified lines
44
55 export type TaskStatus = "queued" | "running" | "completed" | "failed" | "timed_out" | "denied";
66 export type ApprovalStatus = "pending" | "approved" | "denied";
7+export type ToolCallStatus = "requested" | "pending_approval" | "completed" | "failed" | "denied";
78 export type PolicyDecision = "allow" | "require_approval" | "deny";
89 export type CredentialScope = "workspace" | "actor";
910 export type CredentialProvider = "managed" | "workos-vault";
@@ -55,6 +56,21 @@ export interface TaskEventRecord { · 45 unmodified lines
5556 createdAt: number;
5657 }
5758
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+
5874 export interface AccessPolicyRecord {
5975 id: string;
6076 workspaceId: Id<"workspaces">;
@@ -167,7 +183,15 @@ export interface ToolCallRequest { · 106 unmodified lines
167183
168184 export type ToolCallResult =
169185 | { 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 };
171195
172196 export type RuntimeOutputStream = "stdout" | "stderr";
173197
executor/package.json+1−0
@@ -14,6 +14,7 @@ · 13 unmodified lines
1414 "build:release": "bun run scripts/build-release.ts"
1515 },
1616 "dependencies": {
17+ "better-result": "^2.7.0",
1718 "convex": "^1.31.7",
1819 "graphql": "^16.12.0"
1920 }
executor/packages/sandbox-host/.dev.vars.example+2−0
@@ -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
executor/packages/sandbox-host/package.json+20−0
@@ -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+}
executor/packages/sandbox-host/src/index.ts+405−0
@@ -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+};
executor/packages/sandbox-host/src/isolate/globals.isolate.js+2−0
@@ -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);
executor/packages/sandbox-host/src/isolate/harness.isolate.js+108−0
@@ -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+};
executor/packages/sandbox-host/tsconfig.json+17−0
@@ -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+}
executor/packages/sandbox-host/wrangler.jsonc+28−0
@@ -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+}
package.json+3−0
@@ -6,6 +6,7 @@ · 5 unmodified lines
66 "executor/convex",
77 "executor/apps/*",
88 "executor/packages/*",
9+ "sources",
910 "assistant",
1011 "assistant/packages/*",
1112 "assistant/packages/reacord"
@@ -14,6 +15,8 @@ · 2 unmodified lines
1415 "with-env": "bun run --env-file=.env --",
1516 "dev": "bun run dev.ts",
1617 "kill:all": "bun run kill-all.ts",
18+ "dev:sources": "bun run --cwd sources dev",
19+ "sources:sync": "bun run --cwd sources sync",
1720 "dev:executor:convex": "bunx --cwd executor convex dev",
1821 "dev:executor:web": "bun run --cwd executor/apps/web dev",
1922 "dev:executor:all": "bun run dev:executor:convex & bun run dev:executor:web",
sources/package.json+16−0
@@ -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+}
sources/scraper.ts+384−0
@@ -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+}
sources/server.ts+192−0
@@ -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);
sources/supplements.json+171−0
@@ -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+}
sources/tsconfig.json+20−0
@@ -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+}