mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-09 10:26:25 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8981e61f53 |
@@ -123,6 +123,7 @@
|
||||
"@agentclientprotocol/sdk": "1.2.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/gateway": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
@@ -516,6 +517,25 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/gateway": {
|
||||
"name": "@opencode-ai/gateway",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@effect/sql-sqlite-bun": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
"modal": "0.9.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.18.15",
|
||||
@@ -1511,6 +1531,18 @@
|
||||
|
||||
"@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-darwin-x64": ["@cbor-extract/cbor-extract-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-linux-arm": ["@cbor-extract/cbor-extract-linux-arm@2.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-linux-arm64": ["@cbor-extract/cbor-extract-linux-arm64@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-linux-x64": ["@cbor-extract/cbor-extract-linux-x64@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-win32-x64": ["@cbor-extract/cbor-extract-win32-x64@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA=="],
|
||||
|
||||
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
|
||||
|
||||
"@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="],
|
||||
@@ -1711,6 +1743,10 @@
|
||||
|
||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||
|
||||
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
|
||||
|
||||
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
|
||||
|
||||
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.17", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ=="],
|
||||
@@ -1805,6 +1841,8 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
|
||||
|
||||
"@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
|
||||
|
||||
"@jsep-plugin/assignment": ["@jsep-plugin/assignment@1.3.0", "", { "peerDependencies": { "jsep": "^0.4.0||^1.0.0" } }, "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ=="],
|
||||
@@ -2033,6 +2071,8 @@
|
||||
|
||||
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
|
||||
|
||||
"@opencode-ai/gateway": ["@opencode-ai/gateway@workspace:packages/gateway"],
|
||||
|
||||
"@opencode-ai/http-recorder": ["@opencode-ai/http-recorder@workspace:packages/http-recorder"],
|
||||
|
||||
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
|
||||
@@ -3295,6 +3335,8 @@
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
"abort-controller-x": ["abort-controller-x@0.5.0", "", {}, "sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
@@ -3539,6 +3581,10 @@
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="],
|
||||
|
||||
"cbor-extract": ["cbor-extract@2.2.2", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", "@cbor-extract/cbor-extract-linux-arm": "2.2.2", "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", "@cbor-extract/cbor-extract-linux-x64": "2.2.2", "@cbor-extract/cbor-extract-win32-x64": "2.2.2" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng=="],
|
||||
|
||||
"cbor-x": ["cbor-x@1.6.5", "", { "optionalDependencies": { "cbor-extract": "^2.2.2" } }, "sha512-yO64CxnSh6kp+pHNRK9IfwnMvCB+c8HvmUjQY/9l9YRF0/cAPka/tUHLwS64QqUpFCq3/OtbKziVJYXH2EaRig=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
@@ -4587,6 +4633,8 @@
|
||||
|
||||
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
||||
|
||||
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
|
||||
|
||||
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
|
||||
|
||||
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||
@@ -4815,6 +4863,8 @@
|
||||
|
||||
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
||||
|
||||
"modal": ["modal@0.9.0", "", { "dependencies": { "cbor-x": "^1.6.0", "long": "^5.3.1", "nice-grpc": "^2.1.12", "protobufjs": "^7.5.0", "smol-toml": "^1.3.3", "uuid": "^11.1.0" } }, "sha512-kCXcdJkhbJorf/q/6T9Wdlg6in9JmRnCNQnV6rVBMyeqNV/iXI6BYk4IzY4cvZ6dbauNeDMjk/Q08cbxvoIaXg=="],
|
||||
|
||||
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
|
||||
|
||||
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
|
||||
@@ -4855,6 +4905,10 @@
|
||||
|
||||
"nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="],
|
||||
|
||||
"nice-grpc": ["nice-grpc@2.1.17", "", { "dependencies": { "@grpc/grpc-js": "^1.14.0", "abort-controller-x": "^0.5.0", "nice-grpc-common": "^2.0.4" } }, "sha512-pu9xYPlWSeqoYQOCqb2ftQqZi/P2DaI70PSuwnxvoyIRiilaOVtktyPe6LmuXegWB4Ic1sPuDGCnCCASbwzK0w=="],
|
||||
|
||||
"nice-grpc-common": ["nice-grpc-common@2.0.4", "", { "dependencies": { "ts-error": "^1.0.6" } }, "sha512-gOEXlD6ShXZMZ8k+49wm/bA2j1+3IKbEFV9WYREJcvZV4kM5L1KkILYTX9VYBzZF2OuYCe1wp8c82bQkvR0fHw=="],
|
||||
|
||||
"nimma": ["nimma@0.2.3", "", { "dependencies": { "@jsep-plugin/regex": "^1.0.1", "@jsep-plugin/ternary": "^1.0.2", "astring": "^1.8.1", "jsep": "^1.2.0" }, "optionalDependencies": { "jsonpath-plus": "^6.0.1 || ^10.1.0", "lodash.topath": "^4.5.2" } }, "sha512-1ZOI8J+1PKKGceo/5CT5GfQOG6H8I2BencSK06YarZ2wXwH37BSSUWldqJmMJYA5JfqDqffxDXynt6f11AyKcA=="],
|
||||
|
||||
"nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="],
|
||||
@@ -5667,6 +5721,8 @@
|
||||
|
||||
"ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="],
|
||||
|
||||
"ts-error": ["ts-error@1.0.6", "", {}, "sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA=="],
|
||||
|
||||
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||
|
||||
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
|
||||
@@ -6651,6 +6707,8 @@
|
||||
|
||||
"builder-util/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
|
||||
|
||||
"cbor-extract/node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-test": "build-test.js", "node-gyp-build-optional-packages-optional": "optional.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="],
|
||||
|
||||
"cliui/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=="],
|
||||
|
||||
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
@@ -6813,6 +6871,8 @@
|
||||
|
||||
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||
|
||||
"modal/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
|
||||
|
||||
"motion/framer-motion": ["framer-motion@12.43.0", "", { "dependencies": { "motion-dom": "^12.43.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g=="],
|
||||
|
||||
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@agentclientprotocol/sdk": "1.2.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/gateway": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
|
||||
@@ -266,6 +266,26 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
}),
|
||||
],
|
||||
}),
|
||||
Spec.make("gateway", {
|
||||
description: "Run the Modal sandbox gateway",
|
||||
commands: [
|
||||
Spec.make("serve", {
|
||||
description: "Start the gateway server",
|
||||
params: {
|
||||
hostname: Flag.string("hostname").pipe(Flag.optional),
|
||||
port: Flag.integer("port").pipe(Flag.optional),
|
||||
database: Flag.string("database").pipe(Flag.optional),
|
||||
app: Flag.string("app").pipe(Flag.withDefault("opencode-gateway-dev")),
|
||||
volume: Flag.string("volume").pipe(Flag.withDefault("opencode-gateway-workspaces-dev")),
|
||||
environment: Flag.string("environment").pipe(Flag.optional),
|
||||
root: Flag.string("root").pipe(Flag.withDefault("/persist/project")),
|
||||
image: Flag.string("image").pipe(Flag.withDefault("oven/bun:1.3.14")),
|
||||
repository: Flag.string("repository").pipe(Flag.withDefault("https://github.com/anomalyco/opencode.git")),
|
||||
branch: Flag.string("branch").pipe(Flag.withDefault("v2")),
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
Spec.make("pair", { description: "Show server pairing information" }),
|
||||
Spec.make("serve", {
|
||||
description: "Start the v2 API and web server",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { GatewayProcess } from "@opencode-ai/gateway/process"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect, Option } from "effect"
|
||||
import path from "path"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { OPENCODE_VERSION } from "../../../version"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.gateway.commands.serve,
|
||||
Effect.fnUntraced(function* (input) {
|
||||
const password = process.env.OPENCODE_GATEWAY_PASSWORD
|
||||
if (!password) return yield* Effect.fail(new Error("OPENCODE_GATEWAY_PASSWORD is required"))
|
||||
const global = yield* Global.Service
|
||||
const credentialDatabase = process.env.OPENCODE_DB ?? "opencode.db"
|
||||
const control = yield* ServerConnection.resolve({ mismatch: "ignore" })
|
||||
const gateway = yield* GatewayProcess.start({
|
||||
hostname: Option.getOrUndefined(input.hostname),
|
||||
port: Option.getOrUndefined(input.port),
|
||||
password,
|
||||
version: OPENCODE_VERSION,
|
||||
root: input.root,
|
||||
upstreamPassword: process.env.OPENCODE_GATEWAY_UPSTREAM_PASSWORD ?? password,
|
||||
database: Option.getOrUndefined(input.database) ?? path.join(global.data, "gateway.db"),
|
||||
credentialDatabase: path.isAbsolute(credentialDatabase)
|
||||
? credentialDatabase
|
||||
: path.join(global.data, credentialDatabase),
|
||||
controlPlane: {
|
||||
url: control.endpoint.url,
|
||||
headers: Service.headers(control.endpoint) ?? {},
|
||||
},
|
||||
modal: {
|
||||
app: input.app,
|
||||
volume: input.volume,
|
||||
environment: Option.getOrUndefined(input.environment),
|
||||
image: input.image,
|
||||
repository: input.repository,
|
||||
branch: input.branch,
|
||||
},
|
||||
})
|
||||
yield* Effect.logInfo("gateway listening", { url: gateway.url.toString() })
|
||||
return yield* Effect.never
|
||||
}),
|
||||
)
|
||||
@@ -43,6 +43,9 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
mini: () => import("./commands/handlers/mini"),
|
||||
run: () => import("./commands/handlers/run"),
|
||||
pair: () => import("./commands/handlers/pair"),
|
||||
gateway: {
|
||||
serve: () => import("./commands/handlers/gateway/serve"),
|
||||
},
|
||||
service: {
|
||||
start: () => import("./commands/handlers/service/start"),
|
||||
restart: () => import("./commands/handlers/service/restart"),
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# OpenCode Gateway
|
||||
|
||||
The gateway presents one OpenCode endpoint while routing each workspace to an OpenCode server in a Modal sandbox.
|
||||
|
||||
Agent, model, provider, integration, credential, plugin, configuration, and migration endpoints are served by the normal local OpenCode background service. Session, filesystem, VCS, shell, and PTY operations are routed to the owning sandbox.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Authenticate the Modal CLI and create the shared Volume v2 once:
|
||||
|
||||
```sh
|
||||
modal volume create --version=2 opencode-gateway-workspaces-dev
|
||||
```
|
||||
|
||||
## Start
|
||||
|
||||
From this repository:
|
||||
|
||||
```sh
|
||||
export OPENCODE_GATEWAY_PASSWORD="choose-a-password"
|
||||
|
||||
bun run --cwd packages/cli --conditions=browser src/index.ts gateway serve \
|
||||
--hostname 127.0.0.1 \
|
||||
--port 4097 \
|
||||
--app opencode-gateway-dev \
|
||||
--volume opencode-gateway-workspaces-dev \
|
||||
--root /persist/project
|
||||
```
|
||||
|
||||
Connect the full development TUI:
|
||||
|
||||
```sh
|
||||
OPENCODE_PASSWORD="$OPENCODE_GATEWAY_PASSWORD" \
|
||||
bun run dev --server http://127.0.0.1:4097
|
||||
```
|
||||
|
||||
The home screen reads its agent/model/provider/integration catalog from the local OpenCode control plane. Submitting the first prompt calls `POST /api/session`, which creates a workspace subpath and Modal sandbox, starts OpenCode, registers the initial session, and begins proxying its event stream.
|
||||
|
||||
Session creation can also be called directly:
|
||||
|
||||
```sh
|
||||
curl -u "opencode:$OPENCODE_GATEWAY_PASSWORD" \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{}' \
|
||||
http://127.0.0.1:4097/api/session
|
||||
```
|
||||
|
||||
At gateway startup, complete credential rows are snapshotted from the local OpenCode database. Each new sandbox receives that snapshot through stdin, initializes its independent OpenCode database, imports the credential rows transactionally, deletes the temporary payload, and then starts its OpenCode server. OAuth refresh tokens are retained because these are long-lived sessions and must refresh normally. The source database defaults to the normal local `opencode.db` and follows `OPENCODE_DB` when set.
|
||||
|
||||
The default sandbox image builds OpenCode from `https://github.com/anomalyco/opencode.git` on branch `v2`. Override it with `--image`, `--repository`, or `--branch`. Set `OPENCODE_GATEWAY_UPSTREAM_PASSWORD` when the sandbox OpenCode password should differ from the gateway password.
|
||||
|
||||
Gateway routing metadata is stored separately from sandbox OpenCode databases. The default local path is `~/.local/share/opencode/gateway.db`; override it with `--database`.
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/gateway",
|
||||
"version": "1.18.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
"./backend": "./src/backend.ts",
|
||||
"./control": "./src/control.ts",
|
||||
"./credentials": "./src/credentials.ts",
|
||||
"./database": "./src/database.ts",
|
||||
"./handler": "./src/handler.ts",
|
||||
"./modal": "./src/modal.ts",
|
||||
"./process": "./src/process.ts",
|
||||
"./provision": "./src/provision.ts",
|
||||
"./registry": "./src/registry.ts",
|
||||
"./reconcile": "./src/reconcile.ts",
|
||||
"./router": "./src/router.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"drive:gateway": "bun run script/drive-gateway.ts",
|
||||
"drive:check": "bun run script/drive-check.ts",
|
||||
"modal:check": "bun run script/modal-check.ts",
|
||||
"spike:modal": "bun run script/modal-spike.ts",
|
||||
"test": "bun test --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/sql-sqlite-bun": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
"modal": "0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { OpenCode, type OpenCodeEvent } from "@opencode-ai/client"
|
||||
|
||||
const gatewayURL = process.env.OPENCODE_GATEWAY_URL ?? "http://127.0.0.1:38100"
|
||||
const gatewayPassword = process.env.OPENCODE_GATEWAY_PASSWORD ?? "gateway-drive"
|
||||
const upstreamURL = process.env.DRIVE_UPSTREAM_URL
|
||||
const upstreamPassword = process.env.DRIVE_UPSTREAM_PASSWORD
|
||||
|
||||
if (!upstreamURL) throw new Error("DRIVE_UPSTREAM_URL is required")
|
||||
if (!upstreamPassword) throw new Error("DRIVE_UPSTREAM_PASSWORD is required")
|
||||
|
||||
const gateway = OpenCode.make({
|
||||
baseUrl: gatewayURL,
|
||||
headers: { authorization: `Basic ${btoa(`opencode:${gatewayPassword}`)}` },
|
||||
})
|
||||
const upstream = OpenCode.make({
|
||||
baseUrl: upstreamURL,
|
||||
headers: { authorization: `Basic ${btoa(`opencode:${upstreamPassword}`)}` },
|
||||
})
|
||||
|
||||
const health = await gateway.health.get()
|
||||
assert(health.healthy, "gateway health")
|
||||
|
||||
const initial = await gateway.session.list({ parentID: null, limit: 50, order: "desc" })
|
||||
const first = initial.data[0]
|
||||
assert(first, "initial session")
|
||||
assert(first.location.workspaceID, "gateway workspace translation")
|
||||
assert(typeof first.time.created === "number", "encoded session timestamp")
|
||||
|
||||
const location = { directory: first.location.directory, workspace: first.location.workspaceID }
|
||||
await Promise.all([
|
||||
gateway.agent.list({ location }),
|
||||
gateway.model.list({ location }),
|
||||
gateway.provider.list({ location }),
|
||||
gateway.command.list({ location }),
|
||||
gateway.skill.list({ location }),
|
||||
gateway.vcs.get({ location }),
|
||||
])
|
||||
|
||||
const repeated = await Promise.all(
|
||||
Array.from({ length: 10 }, () => gateway.session.list({ parentID: null, limit: 50, order: "desc" })),
|
||||
)
|
||||
assert(
|
||||
repeated.every((response) => response.data.some((session) => session.id === first.id)),
|
||||
"stable aggregate reads",
|
||||
)
|
||||
|
||||
const events = gateway.event.subscribe()[Symbol.asyncIterator]()
|
||||
assert((await nextEvent(events)).type === "server.connected", "gateway connected event")
|
||||
|
||||
const created = await upstream.session.create({ location: { directory: first.location.directory } })
|
||||
const createdEvent = await waitForEvent(
|
||||
events,
|
||||
(event) => event.type === "session.created" && event.data.sessionID === created.id,
|
||||
)
|
||||
if (createdEvent.type !== "session.created") throw new Error("Expected session.created")
|
||||
assert(createdEvent.location?.workspaceID === first.location.workspaceID, "event workspace translation")
|
||||
assert(createdEvent.data.location.workspaceID === first.location.workspaceID, "created event data translation")
|
||||
|
||||
const discovered = await gateway.session.get({ sessionID: created.id })
|
||||
assert(discovered.location.workspaceID === first.location.workspaceID, "event registration before routing")
|
||||
|
||||
await gateway.session.rename({ sessionID: created.id, title: "Gateway consistency check" })
|
||||
await waitForEvent(events, (event) => event.type === "session.renamed" && event.data.sessionID === created.id)
|
||||
|
||||
const active = await gateway.session.active()
|
||||
assert(typeof active === "object" && active !== null, "aggregate active sessions")
|
||||
|
||||
await gateway.session.remove({ sessionID: created.id })
|
||||
await waitForEvent(events, (event) => event.type === "session.deleted" && event.data.sessionID === created.id)
|
||||
await events.return?.()
|
||||
|
||||
const afterDelete = await gateway.session.list({ parentID: null, limit: 50, order: "desc" })
|
||||
assert(!afterDelete.data.some((session) => session.id === created.id), "deleted session removal")
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
health: "passed",
|
||||
catalogs: "passed",
|
||||
aggregateReads: repeated.length,
|
||||
eventFanIn: "passed",
|
||||
eventRegistration: "passed",
|
||||
eventTranslation: "passed",
|
||||
sessionMutation: "passed",
|
||||
sessionDeletion: "passed",
|
||||
},
|
||||
undefined,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
function assert(value: unknown, label: string): asserts value {
|
||||
if (!value) throw new Error(`Drive gateway check failed: ${label}`)
|
||||
}
|
||||
|
||||
async function nextEvent(iterator: AsyncIterator<OpenCodeEvent>) {
|
||||
const result = await Promise.race([
|
||||
iterator.next(),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Timed out waiting for gateway event")), 10_000),
|
||||
),
|
||||
])
|
||||
if (result.done) throw new Error("Gateway event stream closed")
|
||||
return result.value
|
||||
}
|
||||
|
||||
async function waitForEvent(iterator: AsyncIterator<OpenCodeEvent>, predicate: (event: OpenCodeEvent) => boolean) {
|
||||
while (true) {
|
||||
const event = await nextEvent(iterator)
|
||||
if (predicate(event)) return event
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { OpenCode } from "@opencode-ai/client/effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { GatewayAggregate } from "../src/aggregate"
|
||||
import { GatewayBackend } from "../src/backend"
|
||||
import { GatewayDatabase } from "../src/database"
|
||||
import { GatewayControl } from "../src/control"
|
||||
import { GatewayEvents } from "../src/events"
|
||||
import { GatewayProcess } from "../src/process"
|
||||
import { GatewayProvision } from "../src/provision"
|
||||
import { GatewayRegistry } from "../src/registry"
|
||||
|
||||
const upstream = process.env.DRIVE_UPSTREAM_URL
|
||||
const upstreamPassword = process.env.DRIVE_UPSTREAM_PASSWORD
|
||||
const gatewayPassword = process.env.OPENCODE_GATEWAY_PASSWORD ?? "gateway-drive"
|
||||
const databasePath = process.env.OPENCODE_GATEWAY_DB ?? "/tmp/opencode/gateway-drive.db"
|
||||
const port = Number(process.env.OPENCODE_GATEWAY_PORT ?? 38100)
|
||||
|
||||
if (!upstream) throw new Error("DRIVE_UPSTREAM_URL is required")
|
||||
if (!upstreamPassword) throw new Error("DRIVE_UPSTREAM_PASSWORD is required")
|
||||
|
||||
const database = GatewayDatabase.layer({ path: databasePath })
|
||||
const registry = GatewayRegistry.layer.pipe(Layer.provide(database))
|
||||
const backend = Layer.succeed(
|
||||
GatewayBackend.Service,
|
||||
GatewayBackend.Service.of({
|
||||
connect: () =>
|
||||
Effect.succeed({
|
||||
url: upstream,
|
||||
headers: { authorization: `Basic ${btoa(`opencode:${upstreamPassword}`)}` },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const dependencies = Layer.mergeAll(registry, backend, FetchHttpClient.layer)
|
||||
const aggregate = GatewayAggregate.layer.pipe(Layer.provide(dependencies))
|
||||
const events = GatewayEvents.layer().pipe(Layer.provide(dependencies))
|
||||
const provision = Layer.succeed(
|
||||
GatewayProvision.Service,
|
||||
GatewayProvision.Service.of({ create: () => Effect.die(new Error("not used by the Drive harness")) }),
|
||||
)
|
||||
const control = GatewayControl.layer({
|
||||
url: upstream,
|
||||
headers: { authorization: `Basic ${btoa(`opencode:${upstreamPassword}`)}` },
|
||||
})
|
||||
const services = Layer.mergeAll(dependencies, aggregate, events, provision, control)
|
||||
|
||||
const bootstrap = Effect.gen(function* () {
|
||||
const registry = yield* GatewayRegistry.Service
|
||||
const backend = yield* GatewayBackend.Service
|
||||
const httpClient = yield* HttpClient.HttpClient
|
||||
const existing = yield* registry.listWorkspaces
|
||||
const workspace = existing[0] ?? (yield* registry.createWorkspace({ directory: "/drive/project" }))
|
||||
yield* registry.registerSandbox({
|
||||
id: "sb_drive",
|
||||
workspaceID: workspace.id,
|
||||
generation: 1,
|
||||
status: "running",
|
||||
endpoint: upstream,
|
||||
timeCreated: Date.now(),
|
||||
})
|
||||
const connection = yield* backend.connect(workspace.id)
|
||||
const configured = HttpClient.mapRequest(httpClient, HttpClientRequest.setHeaders(connection.headers))
|
||||
const client = yield* OpenCode.make({ baseUrl: connection.url }).pipe(
|
||||
Effect.provideService(HttpClient.HttpClient, configured),
|
||||
)
|
||||
const location = yield* client.location.get()
|
||||
const listed = yield* client.session.list({ parentID: null, limit: 50, order: "desc" })
|
||||
const session = listed.data[0] ?? (yield* client.session.create({ location }))
|
||||
yield* registry.registerProject({
|
||||
workspaceID: workspace.id,
|
||||
projectID: session.projectID,
|
||||
directory: session.location.directory,
|
||||
canonical: session.location.directory,
|
||||
time: Date.now(),
|
||||
})
|
||||
yield* registry.registerSession({
|
||||
id: session.id,
|
||||
workspaceID: workspace.id,
|
||||
projectID: session.projectID,
|
||||
parentID: session.parentID,
|
||||
timeCreated: Date.now(),
|
||||
timeUpdated: Date.now(),
|
||||
})
|
||||
const gatewayEvents = yield* GatewayEvents.Service
|
||||
yield* gatewayEvents.start
|
||||
yield* Effect.logInfo("drive gateway ready", {
|
||||
gateway: `http://127.0.0.1:${port}`,
|
||||
sessionID: session.id,
|
||||
workspaceID: workspace.id,
|
||||
})
|
||||
})
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* GatewayProcess.serve(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
password: gatewayPassword,
|
||||
version: "drive",
|
||||
root: "/drive/project",
|
||||
},
|
||||
services,
|
||||
bootstrap,
|
||||
)
|
||||
return yield* Effect.never
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
import { OpenCode, type OpenCodeEvent } from "@opencode-ai/client"
|
||||
|
||||
const url = process.env.OPENCODE_GATEWAY_URL ?? "http://127.0.0.1:4097"
|
||||
const password = process.env.OPENCODE_GATEWAY_PASSWORD
|
||||
if (!password) throw new Error("OPENCODE_GATEWAY_PASSWORD is required")
|
||||
|
||||
const client = OpenCode.make({
|
||||
baseUrl: url,
|
||||
headers: { authorization: `Basic ${btoa(`opencode:${password}`)}` },
|
||||
})
|
||||
|
||||
assert((await client.health.get()).healthy, "health")
|
||||
const listed = await client.session.list({ parentID: null, limit: 50, order: "desc" })
|
||||
assert(listed.data.length >= 2, "two provisioned sessions")
|
||||
const workspaces = new Set(listed.data.map((session) => session.location.workspaceID))
|
||||
assert(workspaces.size === listed.data.length, "one workspace per user-created session")
|
||||
|
||||
await Promise.all(
|
||||
listed.data.flatMap((session) => {
|
||||
const location = { directory: session.location.directory, workspace: session.location.workspaceID }
|
||||
return [
|
||||
client.session.get({ sessionID: session.id }),
|
||||
client.model.list({ location }),
|
||||
client.agent.list({ location }),
|
||||
]
|
||||
}),
|
||||
)
|
||||
|
||||
const events = client.event.subscribe()[Symbol.asyncIterator]()
|
||||
assert((await nextEvent(events)).type === "server.connected", "connected event")
|
||||
const selected = listed.data[0]
|
||||
const title = `Modal gateway verified ${Date.now()}`
|
||||
await client.session.rename({ sessionID: selected.id, title })
|
||||
const renamed = await waitForEvent(
|
||||
events,
|
||||
(event) => event.type === "session.renamed" && event.data.sessionID === selected.id,
|
||||
)
|
||||
assert(renamed.location?.workspaceID === selected.location.workspaceID, "event workspace")
|
||||
await events.return?.()
|
||||
|
||||
const repeated = await Promise.all(
|
||||
Array.from({ length: 10 }, () => client.session.list({ parentID: null, limit: 50, order: "desc" })),
|
||||
)
|
||||
assert(
|
||||
repeated.every((response) => response.data.length === listed.data.length),
|
||||
"consistent aggregate listing",
|
||||
)
|
||||
await client.session.active()
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
sessions: listed.data.length,
|
||||
distinctWorkspaces: workspaces.size,
|
||||
routing: "passed",
|
||||
catalogs: "passed",
|
||||
events: "passed",
|
||||
aggregateReads: repeated.length,
|
||||
},
|
||||
undefined,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
function assert(value: unknown, label: string): asserts value {
|
||||
if (!value) throw new Error(`Modal gateway check failed: ${label}`)
|
||||
}
|
||||
|
||||
async function nextEvent(iterator: AsyncIterator<OpenCodeEvent>) {
|
||||
const result = await Promise.race([
|
||||
iterator.next(),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Timed out waiting for gateway event")), 10_000),
|
||||
),
|
||||
])
|
||||
if (result.done) throw new Error("Gateway event stream closed")
|
||||
return result.value
|
||||
}
|
||||
|
||||
async function waitForEvent(iterator: AsyncIterator<OpenCodeEvent>, predicate: (event: OpenCodeEvent) => boolean) {
|
||||
while (true) {
|
||||
const event = await nextEvent(iterator)
|
||||
if (predicate(event)) return event
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { ModalClient, Probe, type App, type Image, type Sandbox, type Volume } from "modal"
|
||||
|
||||
const APP_NAME = "opencode-gateway-dev"
|
||||
const VOLUME_NAME = "opencode-gateway-workspaces-dev"
|
||||
const PORT = 4096
|
||||
const GATEWAY_ID = "gw_modal_spike"
|
||||
const WORKSPACE_ID = `wrk_spike_${crypto.randomUUID().replaceAll("-", "")}`
|
||||
const SUBPATH = `/spikes/${WORKSPACE_ID}`
|
||||
const SERVER = String.raw`
|
||||
const encoder = new TextEncoder()
|
||||
Bun.serve({
|
||||
port: 4096,
|
||||
fetch(request, server) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/health")
|
||||
return Response.json({ healthy: true, pid: process.pid })
|
||||
if (url.pathname === "/api/event")
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode("data: connected\n\n"))
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
if (url.pathname === "/ws" && server.upgrade(request)) return
|
||||
return new Response("not found", { status: 404 })
|
||||
},
|
||||
websocket: {
|
||||
message(socket, message) {
|
||||
socket.send(message)
|
||||
},
|
||||
},
|
||||
})
|
||||
await new Promise(() => {})
|
||||
`
|
||||
|
||||
const client = new ModalClient()
|
||||
const sandboxes: Sandbox[] = []
|
||||
|
||||
try {
|
||||
const app = await client.apps.fromName(APP_NAME, { createIfMissing: true })
|
||||
const volume = await client.volumes.fromName(VOLUME_NAME)
|
||||
const image = client.images.fromRegistry("oven/bun:1.3.14")
|
||||
const first = await createSandbox(app, image, volume, 1)
|
||||
sandboxes.push(first)
|
||||
await first.waitUntilReady()
|
||||
|
||||
const credentials = await first.createConnectToken({ port: PORT })
|
||||
const health = await fetch(new URL("/api/health", credentials.url), {
|
||||
headers: { authorization: `Bearer ${credentials.token}` },
|
||||
})
|
||||
if (!health.ok) throw new Error(`Health request failed: ${health.status}`)
|
||||
|
||||
const stream = await fetch(new URL("/api/event", credentials.url), {
|
||||
headers: { authorization: `Bearer ${credentials.token}` },
|
||||
})
|
||||
if (!stream.ok || !stream.body) throw new Error(`SSE request failed: ${stream.status}`)
|
||||
const reader = stream.body.getReader()
|
||||
const firstEvent = await reader.read()
|
||||
if (firstEvent.done || !new TextDecoder().decode(firstEvent.value).includes("connected"))
|
||||
throw new Error("SSE stream did not deliver its initial event")
|
||||
|
||||
const websocket = await connectWebSocket(credentials.url, credentials.token)
|
||||
websocket.send("gateway-spike")
|
||||
const echoed = await websocketMessage(websocket)
|
||||
if (echoed !== "gateway-spike") throw new Error(`WebSocket echo mismatch: ${echoed}`)
|
||||
websocket.close()
|
||||
|
||||
const write = await first.exec([
|
||||
"bun",
|
||||
"-e",
|
||||
String.raw`
|
||||
import { Database } from "bun:sqlite"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
await mkdir("/persist/project", { recursive: true })
|
||||
await mkdir("/persist/opencode", { recursive: true })
|
||||
await Bun.write("/persist/project/checkpoint.txt", "volume-v2")
|
||||
const db = new Database("/persist/opencode/opencode.db")
|
||||
db.run("PRAGMA journal_mode = WAL")
|
||||
db.run("CREATE TABLE checkpoint (value TEXT NOT NULL)")
|
||||
db.run("INSERT INTO checkpoint VALUES (?)", ["sqlite-v2"])
|
||||
db.close()
|
||||
`,
|
||||
])
|
||||
if ((await write.wait()) !== 0) throw new Error(`Volume write failed: ${await write.stderr.readText()}`)
|
||||
await sync(first)
|
||||
|
||||
const listed = []
|
||||
for await (const sandbox of client.sandboxes.list({
|
||||
appId: app.appId,
|
||||
tags: { opencode_gateway: GATEWAY_ID },
|
||||
})) {
|
||||
listed.push(sandbox.sandboxId)
|
||||
sandbox.detach()
|
||||
}
|
||||
if (!listed.includes(first.sandboxId)) throw new Error("Owned sandbox was not discoverable by tag")
|
||||
|
||||
await reader.cancel()
|
||||
await first.terminate({ wait: true })
|
||||
sandboxes.splice(sandboxes.indexOf(first), 1)
|
||||
|
||||
const second = await createSandbox(app, image, volume, 2)
|
||||
sandboxes.push(second)
|
||||
await second.waitUntilReady()
|
||||
const read = await second.exec([
|
||||
"bun",
|
||||
"-e",
|
||||
String.raw`
|
||||
import { Database } from "bun:sqlite"
|
||||
const file = await Bun.file("/persist/project/checkpoint.txt").text()
|
||||
const db = new Database("/persist/opencode/opencode.db", { readonly: true })
|
||||
const row = db.query("SELECT value FROM checkpoint").get()
|
||||
db.close()
|
||||
console.log(JSON.stringify({ file, value: row?.value }))
|
||||
`,
|
||||
])
|
||||
if ((await read.wait()) !== 0) throw new Error(`Replacement read failed: ${await read.stderr.readText()}`)
|
||||
const restored = JSON.parse((await read.stdout.readText()).trim())
|
||||
if (restored.file !== "volume-v2" || restored.value !== "sqlite-v2")
|
||||
throw new Error(`Replacement state mismatch: ${JSON.stringify(restored)}`)
|
||||
|
||||
const cleanup = await second.exec(["bash", "-lc", "rm -rf /persist/project /persist/opencode && sync /persist"])
|
||||
if ((await cleanup.wait()) !== 0) throw new Error(`Volume cleanup failed: ${await cleanup.stderr.readText()}`)
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
appID: app.appId,
|
||||
volumeID: volume.volumeId,
|
||||
workspaceID: WORKSPACE_ID,
|
||||
http: "passed",
|
||||
sse: "passed",
|
||||
websocket: "passed",
|
||||
tags: "passed",
|
||||
replacement: "passed",
|
||||
},
|
||||
undefined,
|
||||
2,
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
await Promise.allSettled(sandboxes.map((sandbox) => sandbox.terminate({ wait: true })))
|
||||
client.close()
|
||||
}
|
||||
|
||||
async function createSandbox(app: App, image: Image, volume: Volume, generation: number) {
|
||||
return client.sandboxes.create(app, image, {
|
||||
command: ["bun", "-e", SERVER],
|
||||
volumes: { "/persist": volume.withMountOptions({ subPath: SUBPATH }) },
|
||||
workdir: "/persist",
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
idleTimeoutMs: 60 * 1000,
|
||||
readinessProbe: Probe.withTcp(PORT, { intervalMs: 250 }),
|
||||
tags: {
|
||||
opencode_gateway: GATEWAY_ID,
|
||||
opencode_workspace: WORKSPACE_ID,
|
||||
opencode_generation: String(generation),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function sync(sandbox: Sandbox) {
|
||||
const process = await sandbox.exec(["sync", "/persist"])
|
||||
if ((await process.wait()) !== 0) throw new Error(`Volume sync failed: ${await process.stderr.readText()}`)
|
||||
}
|
||||
|
||||
async function connectWebSocket(base: string, token: string) {
|
||||
const url = new URL("/ws", base)
|
||||
url.protocol = "wss:"
|
||||
url.searchParams.set("_modal_connect_token", token)
|
||||
const socket = new WebSocket(url)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.addEventListener("open", () => resolve(), { once: true })
|
||||
socket.addEventListener("error", () => reject(new Error("WebSocket connection failed")), { once: true })
|
||||
})
|
||||
return socket
|
||||
}
|
||||
|
||||
function websocketMessage(socket: WebSocket) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
socket.addEventListener("message", (event) => resolve(String(event.data)), { once: true })
|
||||
socket.addEventListener("error", () => reject(new Error("WebSocket message failed")), { once: true })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
export * as GatewayAggregate from "./aggregate.js"
|
||||
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Context, DateTime, Effect, Layer } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { GatewayBackend } from "./backend.js"
|
||||
import { GatewayClient } from "./client.js"
|
||||
import { GatewayRegistry } from "./registry.js"
|
||||
|
||||
export interface SessionListInput {
|
||||
readonly workspaceID?: Workspace.ID
|
||||
readonly limit?: number
|
||||
readonly order?: "asc" | "desc"
|
||||
readonly search?: string
|
||||
readonly parentID?: string | null
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly sessions: (input: SessionListInput) => Effect.Effect<ReadonlyArray<Session.Info>>
|
||||
readonly active: Effect.Effect<Readonly<Record<string, { readonly type: "running" }>>>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/gateway/Aggregate") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const backend = yield* GatewayBackend.Service
|
||||
const registry = yield* GatewayRegistry.Service
|
||||
const httpClient = yield* HttpClient.HttpClient
|
||||
|
||||
const client = Effect.fnUntraced(function* (workspaceID: Workspace.ID) {
|
||||
const connection = yield* backend.connect(workspaceID)
|
||||
return yield* GatewayClient.make(connection, httpClient)
|
||||
})
|
||||
|
||||
const sessions = Effect.fn("GatewayAggregate.sessions")(function* (input: SessionListInput) {
|
||||
const workspaces = input.workspaceID
|
||||
? [yield* registry.getWorkspace(input.workspaceID)].filter((item) => item !== undefined)
|
||||
: yield* registry.listWorkspaces
|
||||
const responses = yield* Effect.forEach(
|
||||
workspaces,
|
||||
(workspace) =>
|
||||
client(workspace.id).pipe(
|
||||
Effect.flatMap((api) =>
|
||||
api.session.list({
|
||||
limit: input.limit,
|
||||
order: input.order,
|
||||
search: input.search,
|
||||
parentID:
|
||||
input.parentID === undefined
|
||||
? undefined
|
||||
: input.parentID === null
|
||||
? null
|
||||
: Session.ID.make(input.parentID),
|
||||
}),
|
||||
),
|
||||
Effect.map((response) =>
|
||||
response.data.map((session) => ({
|
||||
...session,
|
||||
location: { ...session.location, workspaceID: workspace.id },
|
||||
})),
|
||||
),
|
||||
Effect.option,
|
||||
),
|
||||
{ concurrency: 8 },
|
||||
)
|
||||
const found = responses.flatMap((response) => (response._tag === "Some" ? response.value : []))
|
||||
yield* Effect.forEach(
|
||||
found,
|
||||
(session) =>
|
||||
registry
|
||||
.registerSession({
|
||||
id: session.id,
|
||||
workspaceID: session.location.workspaceID,
|
||||
projectID: session.projectID,
|
||||
parentID: session.parentID,
|
||||
timeCreated: DateTime.toEpochMillis(session.time.created),
|
||||
timeUpdated: DateTime.toEpochMillis(session.time.updated),
|
||||
})
|
||||
.pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logWarning("gateway session discovery ownership conflict", { sessionID: session.id, error }),
|
||||
),
|
||||
),
|
||||
{ discard: true },
|
||||
)
|
||||
const order = input.order ?? "desc"
|
||||
const sorted = found.toSorted((first, second) => {
|
||||
const firstUpdated = DateTime.toEpochMillis(first.time.updated)
|
||||
const secondUpdated = DateTime.toEpochMillis(second.time.updated)
|
||||
return order === "asc" ? firstUpdated - secondUpdated : secondUpdated - firstUpdated
|
||||
})
|
||||
return input.limit === undefined ? sorted : sorted.slice(0, input.limit)
|
||||
})
|
||||
|
||||
const active = Effect.gen(function* () {
|
||||
const workspaces = yield* registry.listWorkspaces
|
||||
const responses = yield* Effect.forEach(
|
||||
workspaces,
|
||||
(workspace) =>
|
||||
client(workspace.id).pipe(
|
||||
Effect.flatMap((api) => api.session.active()),
|
||||
Effect.option,
|
||||
),
|
||||
{ concurrency: 8 },
|
||||
)
|
||||
return Object.assign({}, ...responses.flatMap((response) => (response._tag === "Some" ? [response.value] : [])))
|
||||
})
|
||||
|
||||
return Service.of({ sessions, active })
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
export * as GatewayBackend from "./backend.js"
|
||||
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { GatewayModal } from "./modal.js"
|
||||
import { GatewayRegistry } from "./registry.js"
|
||||
|
||||
export interface Connection {
|
||||
readonly url: string
|
||||
readonly headers: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export class UnavailableError extends Schema.TaggedErrorClass<UnavailableError>()("GatewayBackend.UnavailableError", {
|
||||
workspaceID: Workspace.ID,
|
||||
reason: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly connect: (workspaceID: Workspace.ID) => Effect.Effect<Connection, UnavailableError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/gateway/Backend") {}
|
||||
|
||||
export const registryLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* GatewayRegistry.Service
|
||||
return Service.of({
|
||||
connect: Effect.fn("GatewayBackend.connect")(function* (workspaceID) {
|
||||
const sandbox = yield* registry.currentSandbox(workspaceID)
|
||||
if (!sandbox?.endpoint)
|
||||
return yield* new UnavailableError({ workspaceID, reason: "workspace has no reachable sandbox endpoint" })
|
||||
return { url: sandbox.endpoint, headers: {} }
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export function modalLayer(options: { readonly port: number; readonly password: string }) {
|
||||
return Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* GatewayRegistry.Service
|
||||
const modal = yield* GatewayModal.Service
|
||||
return Service.of({
|
||||
connect: Effect.fn("GatewayBackend.connect")(function* (workspaceID) {
|
||||
const sandbox = yield* registry.currentSandbox(workspaceID)
|
||||
if (!sandbox) return yield* new UnavailableError({ workspaceID, reason: "workspace has no running sandbox" })
|
||||
const credentials = yield* modal.connect(sandbox.id, options.port).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new UnavailableError({
|
||||
workspaceID,
|
||||
reason: `failed to connect to sandbox: ${error.operation}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const url = new URL(credentials.url)
|
||||
url.searchParams.set("_modal_connect_token", credentials.token)
|
||||
return {
|
||||
url: url.toString(),
|
||||
headers: { authorization: `Basic ${btoa(`opencode:${options.password}`)}` },
|
||||
}
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export * as GatewayClient from "./client.js"
|
||||
|
||||
import { OpenCode } from "@opencode-ai/client/effect"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import type { Connection } from "./backend.js"
|
||||
|
||||
export function make(connection: Connection, httpClient: HttpClient.HttpClient) {
|
||||
const url = new URL(connection.url)
|
||||
const query = [...url.searchParams]
|
||||
url.search = ""
|
||||
const configured = HttpClient.mapRequest(httpClient, (request) =>
|
||||
HttpClientRequest.setUrlParams(HttpClientRequest.setHeaders(request, connection.headers), query),
|
||||
)
|
||||
return OpenCode.make({ baseUrl: url }).pipe(Effect.provideService(HttpClient.HttpClient, configured))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export * as GatewayControl from "./control.js"
|
||||
|
||||
import { Context, Layer } from "effect"
|
||||
import type { Connection } from "./backend.js"
|
||||
|
||||
export interface Interface {
|
||||
readonly connection: Connection
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/gateway/Control") {}
|
||||
|
||||
export function layer(connection: Connection) {
|
||||
return Layer.succeed(Service, Service.of({ connection }))
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export * as GatewayCredentials from "./credentials.js"
|
||||
|
||||
import { Database } from "bun:sqlite"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
|
||||
export interface Row {
|
||||
readonly id: string
|
||||
readonly integration_id: string | null
|
||||
readonly label: string
|
||||
readonly value: string
|
||||
readonly connector_id: string | null
|
||||
readonly method_id: string | null
|
||||
readonly active: number | null
|
||||
readonly time_created: number
|
||||
readonly time_updated: number
|
||||
}
|
||||
|
||||
export type Snapshot = readonly Row[]
|
||||
|
||||
export interface Interface {
|
||||
readonly snapshot: Snapshot
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/gateway/Credentials") {}
|
||||
|
||||
export function layer(path: string) {
|
||||
return Layer.effect(
|
||||
Service,
|
||||
Effect.try({
|
||||
try: () => Service.of({ snapshot: read(path) }),
|
||||
catch: (cause) => new Error(`Failed to snapshot OpenCode credentials from ${path}`, { cause }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function read(path: string) {
|
||||
const database = new Database(path, { readonly: true, strict: true })
|
||||
try {
|
||||
return database
|
||||
.query<Row, []>(
|
||||
`
|
||||
SELECT id, integration_id, label, value, connector_id, method_id, active, time_created, time_updated
|
||||
FROM credential
|
||||
`,
|
||||
)
|
||||
.all()
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
export * as GatewayDatabase from "./database.js"
|
||||
|
||||
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { SqlClient } from "effect/unstable/sql"
|
||||
import { mkdir } from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
export interface Interface {
|
||||
readonly sql: SqlClient.SqlClient
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/gateway/Database") {}
|
||||
|
||||
const fromClient = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqlClient.SqlClient
|
||||
yield* sql`PRAGMA synchronous = NORMAL`
|
||||
yield* sql`PRAGMA foreign_keys = ON`
|
||||
yield* sql`
|
||||
CREATE TABLE IF NOT EXISTS gateway (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
installation_id TEXT NOT NULL UNIQUE,
|
||||
time_created INTEGER NOT NULL
|
||||
)
|
||||
`
|
||||
yield* sql`
|
||||
CREATE TABLE IF NOT EXISTS workspace (
|
||||
id TEXT PRIMARY KEY,
|
||||
volume_subpath TEXT NOT NULL UNIQUE,
|
||||
directory TEXT NOT NULL,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL
|
||||
)
|
||||
`
|
||||
yield* sql`
|
||||
CREATE TABLE IF NOT EXISTS sandbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
generation INTEGER NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('creating', 'running', 'missing', 'finished', 'failed')),
|
||||
endpoint TEXT,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_expires INTEGER,
|
||||
time_connected INTEGER,
|
||||
time_finished INTEGER,
|
||||
error TEXT,
|
||||
UNIQUE (workspace_id, generation)
|
||||
)
|
||||
`
|
||||
yield* sql`
|
||||
CREATE TABLE IF NOT EXISTS project_binding (
|
||||
workspace_id TEXT PRIMARY KEY REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
upstream_project_id TEXT NOT NULL,
|
||||
directory TEXT NOT NULL,
|
||||
canonical TEXT,
|
||||
time_updated INTEGER NOT NULL
|
||||
)
|
||||
`
|
||||
yield* sql`
|
||||
CREATE TABLE IF NOT EXISTS session_binding (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
upstream_project_id TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL
|
||||
)
|
||||
`
|
||||
yield* sql`
|
||||
CREATE INDEX IF NOT EXISTS session_binding_workspace_idx
|
||||
ON session_binding(workspace_id, time_updated DESC)
|
||||
`
|
||||
yield* sql`
|
||||
CREATE TABLE IF NOT EXISTS resource_owner (
|
||||
kind TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
sandbox_id TEXT NOT NULL REFERENCES sandbox(id) ON DELETE CASCADE,
|
||||
time_created INTEGER NOT NULL,
|
||||
PRIMARY KEY (kind, id)
|
||||
)
|
||||
`
|
||||
yield* sql`
|
||||
CREATE TABLE IF NOT EXISTS sandbox_quarantine (
|
||||
sandbox_id TEXT PRIMARY KEY,
|
||||
reason TEXT NOT NULL,
|
||||
tags TEXT NOT NULL,
|
||||
time_observed INTEGER NOT NULL
|
||||
)
|
||||
`
|
||||
return { sql }
|
||||
}).pipe(Effect.orDie),
|
||||
)
|
||||
|
||||
export function layer(options: { readonly path: string }) {
|
||||
return Layer.unwrap(
|
||||
Effect.promise(() => mkdir(path.dirname(options.path), { recursive: true })).pipe(
|
||||
Effect.as(fromClient.pipe(Layer.provide(SqliteClient.layer({ filename: options.path })))),
|
||||
Effect.orDie,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
export * as GatewayEvents from "./events.js"
|
||||
|
||||
import { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Context, Effect, Layer, Ref, Schedule, Schema, Scope, Stream } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { GatewayBackend } from "./backend.js"
|
||||
import { GatewayClient } from "./client.js"
|
||||
import { GatewayRegistry } from "./registry.js"
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const capacity = 256
|
||||
const encodeEvent = Schema.encodeUnknownSync(OpenCodeEvent)
|
||||
|
||||
interface Subscriber {
|
||||
readonly id: number
|
||||
readonly queue: Uint8Array[]
|
||||
controller?: ReadableStreamDefaultController<Uint8Array>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly start: Effect.Effect<void>
|
||||
readonly watch: (workspaceID: Workspace.ID) => Effect.Effect<void>
|
||||
readonly watchControl: (connection: GatewayBackend.Connection) => Effect.Effect<void>
|
||||
readonly publish: (
|
||||
event: OpenCodeEvent,
|
||||
workspaceID: Workspace.ID,
|
||||
) => Effect.Effect<void, GatewayRegistry.OwnershipConflictError | GatewayRegistry.WorkspaceNotFoundError>
|
||||
readonly subscribe: Effect.Effect<Response>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/gateway/Events") {}
|
||||
|
||||
export const layer = (options: { readonly root?: string } = {}) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const backend = yield* GatewayBackend.Service
|
||||
const registry = yield* GatewayRegistry.Service
|
||||
const httpClient = yield* HttpClient.HttpClient
|
||||
const scope = yield* Scope.Scope
|
||||
const started = yield* Ref.make(false)
|
||||
const watched = new Set<Workspace.ID>()
|
||||
const controlStarted = yield* Ref.make(false)
|
||||
const subscribers = new Map<number, Subscriber>()
|
||||
const sequence = { value: 0 }
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
subscribers.forEach((subscriber) => subscriber.controller?.close())
|
||||
subscribers.clear()
|
||||
}),
|
||||
)
|
||||
|
||||
const fanout = (event: unknown) =>
|
||||
Effect.sync(() => {
|
||||
const bytes = encode(event)
|
||||
subscribers.forEach((subscriber) => {
|
||||
if (subscriber.queue.length >= capacity) {
|
||||
subscriber.controller?.error(new Error("Gateway event subscriber overflow"))
|
||||
subscribers.delete(subscriber.id)
|
||||
return
|
||||
}
|
||||
subscriber.queue.push(bytes)
|
||||
drain(subscriber)
|
||||
})
|
||||
})
|
||||
|
||||
const publish = Effect.fn("GatewayEvents.publish")(function* (event: OpenCodeEvent, workspaceID: Workspace.ID) {
|
||||
if (event.type === "session.created") {
|
||||
yield* registry.registerProject({
|
||||
workspaceID,
|
||||
projectID: event.data.projectID,
|
||||
directory: event.data.location.directory,
|
||||
canonical: event.data.location.directory,
|
||||
time: event.created,
|
||||
})
|
||||
yield* registry.registerSession({
|
||||
id: event.data.sessionID,
|
||||
workspaceID,
|
||||
projectID: event.data.projectID,
|
||||
parentID: event.data.parentID,
|
||||
timeCreated: event.created,
|
||||
timeUpdated: event.created,
|
||||
})
|
||||
}
|
||||
if (event.type === "session.forked") {
|
||||
const parent = yield* registry.findSession(event.data.parentID)
|
||||
if (parent)
|
||||
yield* registry.registerSession({
|
||||
id: event.data.sessionID,
|
||||
workspaceID: parent.workspaceID,
|
||||
projectID: parent.projectID,
|
||||
parentID: event.data.parentID,
|
||||
timeCreated: event.created,
|
||||
timeUpdated: event.created,
|
||||
})
|
||||
}
|
||||
if (event.type === "session.deleted") yield* registry.removeSession(event.data.sessionID)
|
||||
yield* fanout(translate(event, workspaceID))
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* (workspaceID: Workspace.ID) {
|
||||
const connection = yield* backend.connect(workspaceID)
|
||||
const client = yield* GatewayClient.make(connection, httpClient)
|
||||
return yield* client.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type !== "server.connected"),
|
||||
Stream.runForEach((event) => publish(event, workspaceID)),
|
||||
)
|
||||
})
|
||||
|
||||
const watch = Effect.fn("GatewayEvents.watch")(function* (workspaceID: Workspace.ID) {
|
||||
if (watched.has(workspaceID)) return
|
||||
if (!(yield* registry.currentSandbox(workspaceID))) return
|
||||
watched.add(workspaceID)
|
||||
yield* run(workspaceID).pipe(
|
||||
Effect.tapError((error) =>
|
||||
Effect.logDebug("gateway upstream event stream disconnected", { workspaceID, error }),
|
||||
),
|
||||
Effect.retry(Schedule.spaced("5 seconds")),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
})
|
||||
|
||||
const watchControl = Effect.fn("GatewayEvents.watchControl")(function* (connection: GatewayBackend.Connection) {
|
||||
if (yield* Ref.getAndSet(controlStarted, true)) return
|
||||
const run = Effect.gen(function* () {
|
||||
const client = yield* GatewayClient.make(connection, httpClient)
|
||||
return yield* client.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type !== "server.connected"),
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* fanout({ ...event, location: { directory: options.root ?? "/persist/project" } })
|
||||
const workspaces = yield* registry.listWorkspaces
|
||||
yield* Effect.forEach(
|
||||
workspaces,
|
||||
(workspace) =>
|
||||
fanout({
|
||||
...event,
|
||||
location: { directory: workspace.directory, workspaceID: workspace.id },
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
yield* run.pipe(
|
||||
Effect.tapError((error) => Effect.logDebug("gateway control event stream disconnected", { error })),
|
||||
Effect.retry(Schedule.spaced("5 seconds")),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
})
|
||||
|
||||
const start = Effect.gen(function* () {
|
||||
if (yield* Ref.getAndSet(started, true)) return
|
||||
const workspaces = yield* registry.listWorkspaces
|
||||
yield* Effect.forEach(workspaces, (workspace) => watch(workspace.id), { discard: true })
|
||||
})
|
||||
|
||||
const subscribe = Effect.sync(() => {
|
||||
const id = ++sequence.value
|
||||
const subscriber: Subscriber = { id, queue: [] }
|
||||
const stream = new ReadableStream<Uint8Array>(
|
||||
{
|
||||
start(controller) {
|
||||
subscriber.controller = controller
|
||||
subscribers.set(id, subscriber)
|
||||
subscriber.queue.push(
|
||||
encode({ id: Event.ID.create(), type: "server.connected", created: Date.now(), data: {} }),
|
||||
)
|
||||
drain(subscriber)
|
||||
},
|
||||
pull() {
|
||||
drain(subscriber)
|
||||
},
|
||||
cancel() {
|
||||
subscribers.delete(id)
|
||||
},
|
||||
},
|
||||
{ highWaterMark: 1 },
|
||||
)
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"cache-control": "no-cache",
|
||||
"content-type": "text/event-stream",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({ start, watch, watchControl, publish, subscribe })
|
||||
}),
|
||||
)
|
||||
|
||||
function translate(event: OpenCodeEvent, workspaceID: Workspace.ID) {
|
||||
const location = event.location ? { ...event.location, workspaceID } : undefined
|
||||
const data =
|
||||
typeof event.data === "object" &&
|
||||
event.data !== null &&
|
||||
"location" in event.data &&
|
||||
locationValue(event.data.location)
|
||||
? { ...event.data, location: { ...event.data.location, workspaceID } }
|
||||
: event.data
|
||||
return { ...event, location, data }
|
||||
}
|
||||
|
||||
function locationValue(value: unknown): value is { readonly directory: string; readonly workspaceID?: Workspace.ID } {
|
||||
return typeof value === "object" && value !== null && "directory" in value && typeof value.directory === "string"
|
||||
}
|
||||
|
||||
function encode(event: unknown) {
|
||||
return encoder.encode(`data: ${JSON.stringify(encodeEvent(event))}\n\n`)
|
||||
}
|
||||
|
||||
function drain(subscriber: Subscriber) {
|
||||
const controller = subscriber.controller
|
||||
if (!controller) return
|
||||
while ((controller.desiredSize ?? 0) > 0 && subscriber.queue.length > 0) {
|
||||
const value = subscriber.queue.shift()
|
||||
if (!value) return
|
||||
controller.enqueue(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
export * as GatewayHandler from "./handler.js"
|
||||
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { GatewayAggregate } from "./aggregate.js"
|
||||
import { GatewayBackend } from "./backend.js"
|
||||
import { GatewayControl } from "./control.js"
|
||||
import { GatewayEvents } from "./events.js"
|
||||
import { GatewayModal } from "./modal.js"
|
||||
import { GatewayProvision } from "./provision.js"
|
||||
import { GatewayRegistry } from "./registry.js"
|
||||
import { GatewayRouter } from "./router.js"
|
||||
|
||||
const decodeWorkspaceID = Schema.decodeUnknownOption(Workspace.ID)
|
||||
const encodeSessions = Schema.encodeSync(Schema.Array(Session.Info))
|
||||
const encodeSession = Schema.encodeSync(Session.Info)
|
||||
const decodeProvision = Schema.decodeUnknownOption(GatewayProvision.Input)
|
||||
const hopByHop = [
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
]
|
||||
|
||||
export interface Options {
|
||||
readonly password: string
|
||||
readonly version: string
|
||||
readonly root?: string
|
||||
}
|
||||
|
||||
export function handle(request: Request, options: Options) {
|
||||
return Effect.gen(function* () {
|
||||
if (!authorized(request, options.password)) return unauthorized()
|
||||
const decision = GatewayRouter.classify(request)
|
||||
if (decision.type === "health") return Response.json({ healthy: true, version: options.version, pid: process.pid })
|
||||
if (decision.type === "server") return Response.json({ urls: [new URL(request.url).origin] })
|
||||
if (decision.type === "empty-projects") return Response.json([])
|
||||
if (decision.type === "empty-saved-permissions") return Response.json({ data: [] })
|
||||
if (decision.type === "default-location") {
|
||||
const directory = options.root ?? "/persist/project"
|
||||
return Response.json({ directory, project: { id: "global", directory, canonical: directory } })
|
||||
}
|
||||
if (decision.type === "sessions") {
|
||||
const input = sessionListInput(new URL(request.url))
|
||||
if (input instanceof Response) return input
|
||||
const aggregate = yield* GatewayAggregate.Service
|
||||
const sessions = yield* aggregate.sessions(input)
|
||||
return Response.json({ data: encodeSessions(sessions), cursor: {} })
|
||||
}
|
||||
if (decision.type === "active-sessions") {
|
||||
const aggregate = yield* GatewayAggregate.Service
|
||||
return Response.json({ data: yield* aggregate.active })
|
||||
}
|
||||
if (decision.type === "events") {
|
||||
const events = yield* GatewayEvents.Service
|
||||
return yield* events.subscribe
|
||||
}
|
||||
if (decision.type === "provision-session") {
|
||||
const body = yield* Effect.tryPromise(() => request.json()).pipe(Effect.option)
|
||||
if (Option.isNone(body)) return Response.json({ code: "invalid_request" }, { status: 400 })
|
||||
const input = decodeProvision(body.value)
|
||||
if (Option.isNone(input)) return Response.json({ code: "invalid_request" }, { status: 400 })
|
||||
const provision = yield* GatewayProvision.Service
|
||||
return yield* provision.create(input.value).pipe(
|
||||
Effect.map((session) => Response.json({ data: encodeSession(session) })),
|
||||
Effect.catchTag("GatewayProvision.ProvisionError", (error) =>
|
||||
Effect.succeed(
|
||||
Response.json(
|
||||
{
|
||||
code: "provision_failed",
|
||||
message:
|
||||
error.cause instanceof GatewayModal.ModalError
|
||||
? `${error.cause.operation}: ${causeMessage(error.cause.cause)}`
|
||||
: causeMessage(error.cause),
|
||||
},
|
||||
{ status: 503 },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (decision.type === "control") {
|
||||
const control = yield* GatewayControl.Service
|
||||
return yield* proxyControl(request, control.connection, options.root ?? "/persist/project")
|
||||
}
|
||||
if (decision.type === "unsupported") return unsupported(decision.reason)
|
||||
|
||||
const registry = yield* GatewayRegistry.Service
|
||||
const workspaceID = yield* resolveWorkspace(decision, registry)
|
||||
if (workspaceID instanceof Response) return workspaceID
|
||||
const backend = yield* GatewayBackend.Service
|
||||
return yield* backend.connect(workspaceID).pipe(
|
||||
Effect.flatMap((connection) => proxy(request, connection, workspaceID, decision, registry)),
|
||||
Effect.catchTag("GatewayBackend.UnavailableError", (error) =>
|
||||
Effect.succeed(
|
||||
Response.json({ code: "workspace_unavailable", workspaceID, message: error.reason }, { status: 503 }),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function resolveWorkspace(
|
||||
decision: Exclude<
|
||||
GatewayRouter.Decision,
|
||||
{
|
||||
readonly type:
|
||||
| "health"
|
||||
| "server"
|
||||
| "empty-projects"
|
||||
| "empty-saved-permissions"
|
||||
| "default-location"
|
||||
| "sessions"
|
||||
| "active-sessions"
|
||||
| "events"
|
||||
| "provision-session"
|
||||
| "control"
|
||||
| "unsupported"
|
||||
}
|
||||
>,
|
||||
registry: GatewayRegistry.Interface,
|
||||
) {
|
||||
if (decision.type === "workspace") {
|
||||
const decoded = decodeWorkspaceID(decision.workspaceID)
|
||||
if (Option.isNone(decoded)) return Effect.succeed(Response.json({ code: "invalid_workspace" }, { status: 400 }))
|
||||
return Effect.succeed(decoded.value)
|
||||
}
|
||||
if (decision.type === "session")
|
||||
return registry
|
||||
.findSession(decision.sessionID)
|
||||
.pipe(
|
||||
Effect.map(
|
||||
(session) =>
|
||||
session?.workspaceID ??
|
||||
Response.json({ code: "session_not_found", sessionID: decision.sessionID }, { status: 404 }),
|
||||
),
|
||||
)
|
||||
return registry.findResource(decision.kind, decision.id).pipe(
|
||||
Effect.flatMap((resource) => {
|
||||
if (!resource)
|
||||
return Effect.succeed(
|
||||
Response.json({ code: "resource_not_found", kind: decision.kind, id: decision.id }, { status: 404 }),
|
||||
)
|
||||
return registry
|
||||
.currentSandbox(resource.workspaceID)
|
||||
.pipe(
|
||||
Effect.map((sandbox) =>
|
||||
sandbox?.id === resource.sandboxID
|
||||
? resource.workspaceID
|
||||
: Response.json({ code: "resource_expired", kind: decision.kind, id: decision.id }, { status: 410 }),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function proxyControl(request: Request, connection: GatewayBackend.Connection, root: string) {
|
||||
return Effect.tryPromise({
|
||||
try: async () => {
|
||||
const source = new URL(request.url)
|
||||
const target = new URL(source.pathname + source.search, connection.url)
|
||||
const workspaceID =
|
||||
target.searchParams.get("workspace") ??
|
||||
target.searchParams.get("location[workspace]") ??
|
||||
target.searchParams.get("location.workspace") ??
|
||||
request.headers.get("x-opencode-workspace") ??
|
||||
undefined
|
||||
const directory =
|
||||
target.searchParams.get("location[directory]") ??
|
||||
target.searchParams.get("location.directory") ??
|
||||
request.headers.get("x-opencode-directory") ??
|
||||
root
|
||||
;["workspace", "location[workspace]", "location.workspace", "location[directory]", "location.directory"].forEach(
|
||||
(name) => target.searchParams.delete(name),
|
||||
)
|
||||
const headers = new Headers(request.headers)
|
||||
headers.delete("authorization")
|
||||
headers.delete("host")
|
||||
headers.delete("x-opencode-workspace")
|
||||
headers.delete("x-opencode-directory")
|
||||
hopByHop.forEach((header) => headers.delete(header))
|
||||
Object.entries(connection.headers).forEach(([name, value]) => headers.set(name, value))
|
||||
const response = await fetch(target, {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
|
||||
redirect: "manual",
|
||||
signal: request.signal,
|
||||
})
|
||||
const responseHeaders = new Headers(response.headers)
|
||||
hopByHop.forEach((header) => responseHeaders.delete(header))
|
||||
if (!response.ok || response.status === 204 || !response.headers.get("content-type")?.includes("json"))
|
||||
return new Response(response.body, { status: response.status, headers: responseHeaders })
|
||||
const body: unknown = await response.json()
|
||||
const translated = controlLocation(body, directory, workspaceID)
|
||||
responseHeaders.delete("content-length")
|
||||
responseHeaders.set("content-type", "application/json")
|
||||
return new Response(JSON.stringify(translated), { status: response.status, headers: responseHeaders })
|
||||
},
|
||||
catch: (cause) => cause,
|
||||
}).pipe(Effect.catch(() => Effect.succeed(Response.json({ code: "control_plane_unavailable" }, { status: 502 }))))
|
||||
}
|
||||
|
||||
function controlLocation(value: unknown, directory: string, workspaceID: string | undefined): unknown {
|
||||
if (!record(value) || !location(value.location)) return value
|
||||
return {
|
||||
...value,
|
||||
location: {
|
||||
...value.location,
|
||||
directory,
|
||||
workspaceID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function sessionListInput(url: URL): GatewayAggregate.SessionListInput | Response {
|
||||
const workspace = url.searchParams.get("workspace")
|
||||
const workspaceID = workspace ? decodeWorkspaceID(workspace) : Option.none()
|
||||
if (workspace && Option.isNone(workspaceID)) return Response.json({ code: "invalid_workspace" }, { status: 400 })
|
||||
const limitValue = url.searchParams.get("limit")
|
||||
const limit = limitValue === null ? undefined : Number(limitValue)
|
||||
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1))
|
||||
return Response.json({ code: "invalid_limit" }, { status: 400 })
|
||||
const orderValue = url.searchParams.get("order")
|
||||
const order = orderValue === "asc" || orderValue === "desc" ? orderValue : undefined
|
||||
return {
|
||||
workspaceID: Option.getOrUndefined(workspaceID),
|
||||
limit,
|
||||
order,
|
||||
search: url.searchParams.get("search") ?? undefined,
|
||||
parentID: url.searchParams.get("parentID") === "null" ? null : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function proxy(
|
||||
request: Request,
|
||||
connection: GatewayBackend.Connection,
|
||||
workspaceID: Workspace.ID,
|
||||
decision: GatewayRouter.Decision,
|
||||
registry: GatewayRegistry.Interface,
|
||||
) {
|
||||
return Effect.tryPromise({
|
||||
try: async () => {
|
||||
const source = new URL(request.url)
|
||||
const target = new URL(connection.url)
|
||||
const connectionQuery = [...target.searchParams]
|
||||
target.pathname = source.pathname
|
||||
target.search = source.search
|
||||
connectionQuery.forEach(([name, value]) => target.searchParams.set(name, value))
|
||||
target.searchParams.delete("workspace")
|
||||
target.searchParams.delete("location[workspace]")
|
||||
target.searchParams.delete("location.workspace")
|
||||
const headers = new Headers(request.headers)
|
||||
headers.delete("authorization")
|
||||
headers.delete("host")
|
||||
headers.delete("x-opencode-workspace")
|
||||
hopByHop.forEach((header) => headers.delete(header))
|
||||
Object.entries(connection.headers).forEach(([name, value]) => headers.set(name, value))
|
||||
const response = await fetch(target, {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
|
||||
redirect: "manual",
|
||||
signal: request.signal,
|
||||
})
|
||||
const responseHeaders = new Headers(response.headers)
|
||||
hopByHop.forEach((header) => responseHeaders.delete(header))
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: responseHeaders,
|
||||
})
|
||||
},
|
||||
catch: (cause) => cause,
|
||||
}).pipe(
|
||||
Effect.flatMap((response) => translate(request, response, workspaceID, decision, registry)),
|
||||
Effect.catch(() =>
|
||||
Effect.succeed(
|
||||
Response.json({ code: "upstream_unavailable", message: "upstream request failed" }, { status: 502 }),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function translate(
|
||||
request: Request,
|
||||
response: Response,
|
||||
workspaceID: Workspace.ID,
|
||||
decision: GatewayRouter.Decision,
|
||||
registry: GatewayRegistry.Interface,
|
||||
) {
|
||||
if (!response.ok) return Effect.succeed(response)
|
||||
if (response.status === 204 || !response.body) return removeResource(request, response, registry)
|
||||
const kind = translation(request, decision)
|
||||
if (!kind) return removeResource(request, response, registry)
|
||||
if (!response.headers.get("content-type")?.includes("json")) return Effect.succeed(response)
|
||||
return Effect.tryPromise(() => response.json()).pipe(
|
||||
Effect.flatMap((body) => {
|
||||
const translated =
|
||||
kind === "location" ? translateLocationResponse(body, workspaceID) : translateSessionResponse(body, workspaceID)
|
||||
return registerSessionResponses(translated, workspaceID, registry).pipe(
|
||||
Effect.andThen(registerResources(request, translated, workspaceID, registry)),
|
||||
Effect.as(jsonResponse(response, translated)),
|
||||
)
|
||||
}),
|
||||
Effect.catch(() => Effect.succeed(Response.json({ code: "invalid_upstream_response" }, { status: 502 }))),
|
||||
)
|
||||
}
|
||||
|
||||
function registerSessionResponses(body: unknown, workspaceID: Workspace.ID, registry: GatewayRegistry.Interface) {
|
||||
if (!record(body) || !("data" in body)) return Effect.void
|
||||
const values = Array.isArray(body.data)
|
||||
? body.data
|
||||
: record(body.data) && record(body.data.info)
|
||||
? [body.data.info]
|
||||
: [body.data]
|
||||
return Effect.forEach(
|
||||
values.filter(sessionValue),
|
||||
(session) =>
|
||||
registry.registerSession({
|
||||
id: session.id,
|
||||
workspaceID,
|
||||
projectID: session.projectID,
|
||||
parentID: typeof session.parentID === "string" ? session.parentID : undefined,
|
||||
timeCreated: session.time.created,
|
||||
timeUpdated: session.time.updated,
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
}
|
||||
|
||||
function sessionValue(value: unknown): value is {
|
||||
readonly id: string
|
||||
readonly projectID: string
|
||||
readonly parentID?: string
|
||||
readonly time: { readonly created: number; readonly updated: number }
|
||||
} {
|
||||
if (!record(value) || typeof value.id !== "string" || typeof value.projectID !== "string" || !record(value.time))
|
||||
return false
|
||||
return typeof value.time.created === "number" && typeof value.time.updated === "number"
|
||||
}
|
||||
|
||||
function translation(request: Request, decision: GatewayRouter.Decision) {
|
||||
if (decision.type === "workspace") return "location" as const
|
||||
if (decision.type !== "session") return undefined
|
||||
const pathname = new URL(request.url).pathname
|
||||
if (request.method === "GET" && pathname === "/api/session") return "session" as const
|
||||
if (new RegExp(`^/api/session/${decision.sessionID}$`).test(pathname)) return "session" as const
|
||||
if (new RegExp(`^/api/session/${decision.sessionID}/(fork|export)$`).test(pathname)) return "session" as const
|
||||
return undefined
|
||||
}
|
||||
|
||||
function translateLocationResponse(value: unknown, workspaceID: Workspace.ID): unknown {
|
||||
if (!record(value)) return value
|
||||
if (location(value)) return { ...value, workspaceID }
|
||||
if (location(value.location)) return { ...value, location: { ...value.location, workspaceID } }
|
||||
return value
|
||||
}
|
||||
|
||||
function translateSessionResponse(value: unknown, workspaceID: Workspace.ID): unknown {
|
||||
if (!record(value) || !("data" in value)) return value
|
||||
if (Array.isArray(value.data))
|
||||
return {
|
||||
...value,
|
||||
data: value.data.map((item) =>
|
||||
record(item) && location(item.location) ? withLocation(item, workspaceID) : item,
|
||||
),
|
||||
}
|
||||
if (!record(value.data)) return value
|
||||
if (location(value.data.location)) return { ...value, data: withLocation(value.data, workspaceID) }
|
||||
if (record(value.data.info) && location(value.data.info.location))
|
||||
return { ...value, data: { ...value.data, info: withLocation(value.data.info, workspaceID) } }
|
||||
return value
|
||||
}
|
||||
|
||||
function withLocation<Value extends Record<string, unknown>>(value: Value, workspaceID: Workspace.ID) {
|
||||
if (!location(value.location)) return value
|
||||
return { ...value, location: { ...value.location, workspaceID } }
|
||||
}
|
||||
|
||||
function registerResources(
|
||||
request: Request,
|
||||
body: unknown,
|
||||
workspaceID: Workspace.ID,
|
||||
registry: GatewayRegistry.Interface,
|
||||
) {
|
||||
const url = new URL(request.url)
|
||||
const match = url.pathname.match(/^\/api\/(pty|shell)$/)
|
||||
if (!match || (request.method !== "GET" && request.method !== "POST")) return Effect.void
|
||||
if (!record(body) || !record(body.location) || !("data" in body)) return Effect.void
|
||||
const values = Array.isArray(body.data) ? body.data : [body.data]
|
||||
return registry.currentSandbox(workspaceID).pipe(
|
||||
Effect.flatMap((sandbox) => {
|
||||
if (!sandbox) return Effect.void
|
||||
return Effect.forEach(
|
||||
values.filter((value): value is Record<string, unknown> => record(value) && typeof value.id === "string"),
|
||||
(value) =>
|
||||
registry.registerResource({
|
||||
kind: match[1],
|
||||
id: String(value.id),
|
||||
workspaceID,
|
||||
sandboxID: sandbox.id,
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function removeResource(request: Request, response: Response, registry: GatewayRegistry.Interface) {
|
||||
if (request.method !== "DELETE") return Effect.succeed(response)
|
||||
const match = new URL(request.url).pathname.match(/^\/api\/(pty|shell)\/([^/]+)$/)
|
||||
if (!match) return Effect.succeed(response)
|
||||
return registry.removeResource(match[1], match[2]).pipe(Effect.as(response))
|
||||
}
|
||||
|
||||
function jsonResponse(response: Response, body: unknown) {
|
||||
const headers = new Headers(response.headers)
|
||||
headers.delete("content-length")
|
||||
headers.set("content-type", "application/json")
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function location(value: unknown): value is Record<string, unknown> & { readonly directory: string } {
|
||||
return record(value) && typeof value.directory === "string"
|
||||
}
|
||||
|
||||
function authorized(request: Request, password: string) {
|
||||
return request.headers.get("authorization") === `Basic ${btoa(`opencode:${password}`)}`
|
||||
}
|
||||
|
||||
function unauthorized() {
|
||||
return new Response(undefined, {
|
||||
status: 401,
|
||||
headers: { "www-authenticate": 'Basic realm="OpenCode Gateway"' },
|
||||
})
|
||||
}
|
||||
|
||||
function unsupported(reason: string) {
|
||||
return Response.json({ code: "unsupported", message: reason }, { status: 501 })
|
||||
}
|
||||
|
||||
function causeMessage(cause: unknown) {
|
||||
if (cause instanceof Error) return cause.message || cause.name
|
||||
return typeof cause === "string" ? cause : "sandbox provisioning failed"
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
export * as GatewayModal from "./modal.js"
|
||||
|
||||
import { Context, Effect, Layer, Ref, Schema } from "effect"
|
||||
import { ModalClient, Probe } from "modal"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import type { Snapshot } from "./credentials.js"
|
||||
|
||||
export const GatewayTag = "opencode_gateway"
|
||||
export const WorkspaceTag = "opencode_workspace"
|
||||
export const GenerationTag = "opencode_generation"
|
||||
|
||||
export interface ListedSandbox {
|
||||
readonly id: string
|
||||
readonly tags: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export class ModalError extends Schema.TaggedErrorClass<ModalError>()("GatewayModal.ModalError", {
|
||||
operation: Schema.String,
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly appID: string
|
||||
readonly volumeID: string
|
||||
readonly listOwned: (installationID: string) => Effect.Effect<ListedSandbox[], ModalError>
|
||||
readonly connect: (
|
||||
sandboxID: string,
|
||||
port: number,
|
||||
) => Effect.Effect<{ readonly url: string; readonly token: string }, ModalError>
|
||||
readonly create: (input: {
|
||||
readonly installationID: string
|
||||
readonly workspaceID: Workspace.ID
|
||||
readonly generation: number
|
||||
readonly volumeSubpath: string
|
||||
readonly root: string
|
||||
readonly upstreamPassword: string
|
||||
readonly credentials: Snapshot
|
||||
}) => Effect.Effect<{ readonly id: string }, ModalError>
|
||||
readonly terminate: (sandboxID: string) => Effect.Effect<void, ModalError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/gateway/Modal") {}
|
||||
|
||||
export interface Options {
|
||||
readonly app: string
|
||||
readonly volume: string
|
||||
readonly environment?: string
|
||||
readonly tokenID?: string
|
||||
readonly tokenSecret?: string
|
||||
readonly image?: string
|
||||
readonly repository?: string
|
||||
readonly branch?: string
|
||||
readonly timeoutMs?: number
|
||||
}
|
||||
|
||||
export function layer(options: Options) {
|
||||
return Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Effect.acquireRelease(
|
||||
Effect.sync(
|
||||
() =>
|
||||
new ModalClient({
|
||||
...(options.environment ? { environment: options.environment } : {}),
|
||||
...(options.tokenID ? { tokenId: options.tokenID } : {}),
|
||||
...(options.tokenSecret ? { tokenSecret: options.tokenSecret } : {}),
|
||||
}),
|
||||
),
|
||||
(active) => Effect.sync(() => active.close()),
|
||||
)
|
||||
const app = yield* request("app.fromName", () =>
|
||||
client.apps.fromName(options.app, {
|
||||
createIfMissing: true,
|
||||
...(options.environment ? { environment: options.environment } : {}),
|
||||
}),
|
||||
)
|
||||
const volume = yield* request("volume.fromName", () =>
|
||||
client.volumes.fromName(options.volume, options.environment ? { environment: options.environment } : undefined),
|
||||
)
|
||||
|
||||
const listOwned = Effect.fn("GatewayModal.listOwned")((installationID: string) =>
|
||||
request("sandbox.list", async () => {
|
||||
const result: ListedSandbox[] = []
|
||||
for await (const sandbox of client.sandboxes.list({
|
||||
appId: app.appId,
|
||||
tags: { [GatewayTag]: installationID },
|
||||
...(options.environment ? { environment: options.environment } : {}),
|
||||
})) {
|
||||
result.push({ id: sandbox.sandboxId, tags: await sandbox.getTags() })
|
||||
sandbox.detach()
|
||||
}
|
||||
return result
|
||||
}),
|
||||
)
|
||||
|
||||
const connect = Effect.fn("GatewayModal.connect")((sandboxID: string, port: number) =>
|
||||
request("sandbox.createConnectToken", async () => {
|
||||
const sandbox = await client.sandboxes.fromId(sandboxID)
|
||||
const credentials = await sandbox.createConnectToken({ port })
|
||||
sandbox.detach()
|
||||
return credentials
|
||||
}),
|
||||
)
|
||||
|
||||
const terminate = Effect.fn("GatewayModal.terminate")((sandboxID: string) =>
|
||||
request("sandbox.terminate", async () => {
|
||||
const sandbox = await client.sandboxes.fromId(sandboxID)
|
||||
await sandbox.terminate({ wait: true })
|
||||
}),
|
||||
)
|
||||
|
||||
const create = Effect.fn("GatewayModal.create")(function* (input: {
|
||||
readonly installationID: string
|
||||
readonly workspaceID: Workspace.ID
|
||||
readonly generation: number
|
||||
readonly volumeSubpath: string
|
||||
readonly root: string
|
||||
readonly upstreamPassword: string
|
||||
readonly credentials: Snapshot
|
||||
}) {
|
||||
const image = client.images
|
||||
.fromRegistry(options.image ?? "oven/bun:1.3.14")
|
||||
.dockerfileCommands([
|
||||
"USER root",
|
||||
"RUN apt-get update && apt-get install -y git ca-certificates python3 build-essential",
|
||||
`RUN git clone --depth 1 --branch ${quote(options.branch ?? "v2")} ${quote(options.repository ?? "https://github.com/anomalyco/opencode.git")} /opt/opencode`,
|
||||
"RUN cd /opt/opencode && bun install",
|
||||
])
|
||||
const retained = yield* Ref.make(false)
|
||||
return yield* Effect.acquireUseRelease(
|
||||
request("sandbox.create", () =>
|
||||
client.sandboxes.create(app, image, {
|
||||
command: ["bash", "-lc", bootstrap({ root: input.root })],
|
||||
env: {
|
||||
OPENCODE_PASSWORD: input.upstreamPassword,
|
||||
OPENCODE_DB: "/persist/opencode/opencode.db",
|
||||
OPENCODE_CONFIG_DIR: "/persist/opencode/config",
|
||||
XDG_DATA_HOME: "/persist/opencode/data",
|
||||
XDG_STATE_HOME: "/persist/opencode/state",
|
||||
XDG_CACHE_HOME: "/persist/opencode/cache",
|
||||
},
|
||||
volumes: { "/persist": volume.withMountOptions({ subPath: input.volumeSubpath }) },
|
||||
workdir: "/persist",
|
||||
timeoutMs: options.timeoutMs ?? 24 * 60 * 60 * 1000,
|
||||
readinessProbe: Probe.withTcp(4096, { intervalMs: 250 }),
|
||||
tags: {
|
||||
[GatewayTag]: input.installationID,
|
||||
[WorkspaceTag]: input.workspaceID,
|
||||
[GenerationTag]: String(input.generation),
|
||||
},
|
||||
}),
|
||||
),
|
||||
(sandbox) =>
|
||||
Effect.gen(function* () {
|
||||
yield* request("sandbox.writeCredentialImporter", () =>
|
||||
sandbox.filesystem.writeText(
|
||||
credentialImporter,
|
||||
"/opt/opencode/packages/core/.gateway-credential-import.ts",
|
||||
),
|
||||
)
|
||||
yield* request("sandbox.writeCredentials", () =>
|
||||
sandbox.stdin.writeText(JSON.stringify(input.credentials)),
|
||||
)
|
||||
yield* request("sandbox.closeCredentialInput", () => sandbox.stdin.close())
|
||||
yield* request("sandbox.waitUntilReady", () => sandbox.waitUntilReady()).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.all([
|
||||
request("sandbox.stdout", () => sandbox.stdout.readText()).pipe(Effect.orElseSucceed(() => "")),
|
||||
request("sandbox.stderr", () => sandbox.stderr.readText()).pipe(Effect.orElseSucceed(() => "")),
|
||||
]).pipe(
|
||||
Effect.flatMap(([stdout, stderr]) =>
|
||||
Effect.fail(
|
||||
new ModalError({
|
||||
operation: error.operation,
|
||||
cause: new Error(
|
||||
[stderr.trim(), stdout.trim()].filter(Boolean).join("\n") || causeText(error.cause),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* Ref.set(retained, true)
|
||||
return { id: sandbox.sandboxId }
|
||||
}),
|
||||
(sandbox) =>
|
||||
Ref.get(retained).pipe(
|
||||
Effect.flatMap((keep) =>
|
||||
keep
|
||||
? Effect.sync(() => sandbox.detach())
|
||||
: request("sandbox.terminate", () => sandbox.terminate({ wait: true })).pipe(Effect.ignore),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({ appID: app.appId, volumeID: volume.volumeId, listOwned, connect, create, terminate })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function bootstrap(input: { readonly root: string }) {
|
||||
return [
|
||||
"set -euo pipefail",
|
||||
"cat > /tmp/opencode-credentials.json",
|
||||
`mkdir -p ${quote(input.root)} /persist/opencode/{config,data,state,cache}`,
|
||||
"cd /opt/opencode",
|
||||
"bun --conditions=browser packages/core/.gateway-credential-import.ts /tmp/opencode-credentials.json",
|
||||
"rm -f /opt/opencode/packages/core/.gateway-credential-import.ts /tmp/opencode-credentials.json",
|
||||
`cd ${quote(input.root)}`,
|
||||
"exec bun run --cwd /opt/opencode/packages/cli --conditions=browser src/index.ts serve --hostname 0.0.0.0 --port 4096",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const credentialImporter = `
|
||||
import { Database } from "bun:sqlite"
|
||||
import { Database as OpenCodeDatabase } from "./src/database/database.ts"
|
||||
import { Effect, Layer } from "effect"
|
||||
const file = process.argv[2]
|
||||
if (!file) throw new Error("Credential snapshot path is required")
|
||||
const rows = await Bun.file(file).json()
|
||||
if (!Array.isArray(rows)) throw new Error("Credential snapshot must be an array")
|
||||
await Effect.runPromise(Effect.scoped(Layer.build(OpenCodeDatabase.layer({ path: process.env.OPENCODE_DB }))))
|
||||
const database = new Database(process.env.OPENCODE_DB)
|
||||
const insert = database.prepare(\`
|
||||
INSERT INTO credential (
|
||||
id, integration_id, label, value, connector_id, method_id, active, time_created, time_updated
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
\`)
|
||||
database.transaction((values) => {
|
||||
database.run("DELETE FROM credential")
|
||||
for (const row of values)
|
||||
insert.run(
|
||||
row.id,
|
||||
row.integration_id,
|
||||
row.label,
|
||||
row.value,
|
||||
row.connector_id,
|
||||
row.method_id,
|
||||
row.active,
|
||||
row.time_created,
|
||||
row.time_updated,
|
||||
)
|
||||
})(rows)
|
||||
database.close()
|
||||
`
|
||||
|
||||
function quote(value: string) {
|
||||
return `'${value.replaceAll("'", `'\\''`)}'`
|
||||
}
|
||||
|
||||
function causeText(cause: unknown) {
|
||||
if (cause instanceof Error) return cause.message || cause.name
|
||||
return String(cause)
|
||||
}
|
||||
|
||||
function request<A>(operation: string, run: () => Promise<A>) {
|
||||
return Effect.tryPromise({
|
||||
try: run,
|
||||
catch: (cause) => new ModalError({ operation, cause }),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
export * as GatewayProcess from "./process.js"
|
||||
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { GatewayAggregate } from "./aggregate.js"
|
||||
import { GatewayBackend } from "./backend.js"
|
||||
import { GatewayDatabase } from "./database.js"
|
||||
import { GatewayCredentials } from "./credentials.js"
|
||||
import { GatewayControl } from "./control.js"
|
||||
import { GatewayEvents } from "./events.js"
|
||||
import { GatewayHandler } from "./handler.js"
|
||||
import { GatewayModal } from "./modal.js"
|
||||
import { GatewayReconcile } from "./reconcile.js"
|
||||
import { GatewayRegistry } from "./registry.js"
|
||||
import { GatewayProvision } from "./provision.js"
|
||||
|
||||
export interface Options {
|
||||
readonly hostname?: string
|
||||
readonly port?: number
|
||||
readonly password: string
|
||||
readonly version: string
|
||||
readonly database: string
|
||||
readonly modal: GatewayModal.Options
|
||||
readonly upstreamPort?: number
|
||||
readonly root?: string
|
||||
readonly upstreamPassword?: string
|
||||
readonly credentialDatabase: string
|
||||
readonly controlPlane: GatewayBackend.Connection
|
||||
}
|
||||
|
||||
export const start = Effect.fn("GatewayProcess.start")(function* (options: Options) {
|
||||
const database = GatewayDatabase.layer({ path: options.database })
|
||||
const registry = GatewayRegistry.layer.pipe(Layer.provide(database))
|
||||
const modal = GatewayModal.layer(options.modal)
|
||||
const credentials = GatewayCredentials.layer(options.credentialDatabase)
|
||||
const control = GatewayControl.layer(options.controlPlane)
|
||||
const dependencies = Layer.mergeAll(registry, modal, credentials, control, FetchHttpClient.layer)
|
||||
const upstreamPassword = options.upstreamPassword ?? options.password
|
||||
const backend = GatewayBackend.modalLayer({ port: options.upstreamPort ?? 4096, password: upstreamPassword }).pipe(
|
||||
Layer.provide(dependencies),
|
||||
)
|
||||
const upstream = Layer.merge(dependencies, backend)
|
||||
const aggregate = GatewayAggregate.layer.pipe(Layer.provide(upstream))
|
||||
const events = GatewayEvents.layer({ root: options.root }).pipe(Layer.provide(upstream))
|
||||
const provisionDependencies = Layer.mergeAll(upstream, events)
|
||||
const provision = GatewayProvision.layer({ root: options.root ?? "/persist/project", upstreamPassword }).pipe(
|
||||
Layer.provide(provisionDependencies),
|
||||
)
|
||||
const services = Layer.mergeAll(upstream, aggregate, events, provision)
|
||||
const startup = GatewayReconcile.run().pipe(
|
||||
Effect.andThen(
|
||||
Effect.gen(function* () {
|
||||
const service = yield* GatewayEvents.Service
|
||||
yield* service.watchControl(options.controlPlane)
|
||||
yield* service.start
|
||||
}),
|
||||
),
|
||||
)
|
||||
return yield* serve(options, services, startup)
|
||||
})
|
||||
|
||||
export function serve<E, R>(
|
||||
options: Pick<Options, "hostname" | "port" | "password" | "version" | "root">,
|
||||
services: Layer.Layer<
|
||||
| GatewayRegistry.Service
|
||||
| GatewayBackend.Service
|
||||
| GatewayAggregate.Service
|
||||
| GatewayEvents.Service
|
||||
| GatewayControl.Service
|
||||
| GatewayProvision.Service
|
||||
| R,
|
||||
E
|
||||
>,
|
||||
startup: Effect.Effect<
|
||||
unknown,
|
||||
unknown,
|
||||
| GatewayRegistry.Service
|
||||
| GatewayBackend.Service
|
||||
| GatewayAggregate.Service
|
||||
| GatewayEvents.Service
|
||||
| GatewayControl.Service
|
||||
| GatewayProvision.Service
|
||||
| R
|
||||
> = Effect.void,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const runtime = ManagedRuntime.make(services)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => runtime.dispose()))
|
||||
yield* Effect.promise(() => runtime.runPromise(startup))
|
||||
const server = Bun.serve({
|
||||
hostname: options.hostname ?? "127.0.0.1",
|
||||
port: options.port ?? 0,
|
||||
idleTimeout: 0,
|
||||
fetch: (request) =>
|
||||
runtime.runPromise(GatewayHandler.handle(request, options), {
|
||||
signal: request.signal,
|
||||
}),
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => server.stop(true)))
|
||||
return {
|
||||
url: server.url,
|
||||
stop: Effect.promise(() => server.stop(true)),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
export * as GatewayProvision from "./provision.js"
|
||||
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { GatewayBackend } from "./backend.js"
|
||||
import { GatewayClient } from "./client.js"
|
||||
import { GatewayCredentials } from "./credentials.js"
|
||||
import { GatewayEvents } from "./events.js"
|
||||
import { GatewayModal } from "./modal.js"
|
||||
import { GatewayRegistry } from "./registry.js"
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
id: Schema.optional(Session.ID),
|
||||
title: Schema.optional(Schema.String),
|
||||
agent: Schema.optional(Agent.ID),
|
||||
model: Schema.optional(Model.Ref),
|
||||
})
|
||||
export type Input = typeof Input.Type
|
||||
|
||||
export class ProvisionError extends Schema.TaggedErrorClass<ProvisionError>()("GatewayProvision.ProvisionError", {
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly create: (input: Input) => Effect.Effect<Session.Info, ProvisionError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/gateway/Provision") {}
|
||||
|
||||
export function layer(options: { readonly root: string; readonly upstreamPassword: string }) {
|
||||
return Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* GatewayRegistry.Service
|
||||
const modal = yield* GatewayModal.Service
|
||||
const backend = yield* GatewayBackend.Service
|
||||
const credentials = yield* GatewayCredentials.Service
|
||||
const events = yield* GatewayEvents.Service
|
||||
const httpClient = yield* HttpClient.HttpClient
|
||||
|
||||
const existing = Effect.fnUntraced(function* (sessionID: Session.ID) {
|
||||
const binding = yield* registry.findSession(sessionID)
|
||||
if (!binding) return undefined
|
||||
const connection = yield* backend.connect(binding.workspaceID)
|
||||
const client = yield* GatewayClient.make(connection, httpClient)
|
||||
const session = yield* client.session.get({ sessionID })
|
||||
return { ...session, location: { ...session.location, workspaceID: binding.workspaceID } }
|
||||
})
|
||||
|
||||
const create = Effect.fn("GatewayProvision.create")((input: Input) =>
|
||||
Effect.gen(function* () {
|
||||
if (input.id) {
|
||||
const recorded = yield* existing(input.id)
|
||||
if (recorded) return recorded
|
||||
}
|
||||
const installationID = yield* registry.installationID
|
||||
const workspace = yield* registry.createWorkspace({ directory: options.root })
|
||||
const sandbox = yield* modal
|
||||
.create({
|
||||
installationID,
|
||||
workspaceID: workspace.id,
|
||||
generation: 1,
|
||||
volumeSubpath: workspace.volumeSubpath,
|
||||
root: options.root,
|
||||
upstreamPassword: options.upstreamPassword,
|
||||
credentials: credentials.snapshot,
|
||||
})
|
||||
.pipe(Effect.onError(() => registry.removeWorkspace(workspace.id)))
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* registry.registerSandbox({
|
||||
id: sandbox.id,
|
||||
workspaceID: workspace.id,
|
||||
generation: 1,
|
||||
status: "running",
|
||||
timeCreated: Date.now(),
|
||||
})
|
||||
yield* events.watch(workspace.id)
|
||||
const connection = yield* backend.connect(workspace.id)
|
||||
const client = yield* GatewayClient.make(connection, httpClient)
|
||||
const session = yield* client.session.create({
|
||||
...input,
|
||||
location: { directory: AbsolutePath.make(options.root) },
|
||||
})
|
||||
yield* registry.registerProject({
|
||||
workspaceID: workspace.id,
|
||||
projectID: session.projectID,
|
||||
directory: session.location.directory,
|
||||
canonical: session.location.directory,
|
||||
time: DateTime.toEpochMillis(session.time.updated),
|
||||
})
|
||||
yield* registry.registerSession({
|
||||
id: session.id,
|
||||
workspaceID: workspace.id,
|
||||
projectID: session.projectID,
|
||||
parentID: session.parentID,
|
||||
timeCreated: DateTime.toEpochMillis(session.time.created),
|
||||
timeUpdated: DateTime.toEpochMillis(session.time.updated),
|
||||
})
|
||||
return { ...session, location: { ...session.location, workspaceID: workspace.id } }
|
||||
}).pipe(
|
||||
Effect.onError(() =>
|
||||
Effect.all([modal.terminate(sandbox.id).pipe(Effect.ignore), registry.removeWorkspace(workspace.id)], {
|
||||
discard: true,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.mapError((cause) => new ProvisionError({ cause }))),
|
||||
)
|
||||
|
||||
return Service.of({ create })
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
export * as GatewayReconcile from "./reconcile.js"
|
||||
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { GatewayModal } from "./modal.js"
|
||||
import { GatewayRegistry } from "./registry.js"
|
||||
|
||||
const decodeWorkspaceID = Schema.decodeUnknownOption(Workspace.ID)
|
||||
const Generation = Schema.NumberFromString.pipe(Schema.decodeTo(Schema.Int.check(Schema.isGreaterThan(0))))
|
||||
const decodeGeneration = Schema.decodeUnknownOption(Generation)
|
||||
|
||||
export interface Result {
|
||||
readonly discovered: number
|
||||
readonly registered: number
|
||||
readonly quarantined: number
|
||||
}
|
||||
|
||||
export const run = Effect.fn("GatewayReconcile.run")(function* () {
|
||||
const modal = yield* GatewayModal.Service
|
||||
const registry = yield* GatewayRegistry.Service
|
||||
const installationID = yield* registry.installationID
|
||||
const discovered = yield* modal.listOwned(installationID)
|
||||
const results = yield* Effect.forEach(discovered, (sandbox) => reconcile(sandbox, installationID), {
|
||||
concurrency: 8,
|
||||
})
|
||||
const activeIDs = new Set(
|
||||
discovered.flatMap((sandbox, index) => (results[index] === "registered" ? [sandbox.id] : [])),
|
||||
)
|
||||
yield* registry.markMissingSandboxes(activeIDs, Date.now())
|
||||
return {
|
||||
discovered: discovered.length,
|
||||
registered: results.filter((result) => result === "registered").length,
|
||||
quarantined: results.filter((result) => result === "quarantined").length,
|
||||
} satisfies Result
|
||||
})
|
||||
|
||||
function reconcile(
|
||||
sandbox: { readonly id: string; readonly tags: Readonly<Record<string, string>> },
|
||||
installationID: string,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const registry = yield* GatewayRegistry.Service
|
||||
if (sandbox.tags[GatewayModal.GatewayTag] !== installationID)
|
||||
return yield* quarantine(sandbox, "gateway ownership tag does not match")
|
||||
const workspaceID = decodeWorkspaceID(sandbox.tags[GatewayModal.WorkspaceTag])
|
||||
if (Option.isNone(workspaceID)) return yield* quarantine(sandbox, "workspace tag is missing or invalid")
|
||||
const generation = decodeGeneration(sandbox.tags[GatewayModal.GenerationTag])
|
||||
if (Option.isNone(generation)) return yield* quarantine(sandbox, "generation tag is missing or invalid")
|
||||
const workspace = yield* registry.getWorkspace(workspaceID.value)
|
||||
if (!workspace) return yield* quarantine(sandbox, "workspace is not registered by this gateway")
|
||||
return yield* registry
|
||||
.registerSandbox({
|
||||
id: sandbox.id,
|
||||
workspaceID: workspaceID.value,
|
||||
generation: generation.value,
|
||||
status: "running",
|
||||
timeCreated: Date.now(),
|
||||
})
|
||||
.pipe(
|
||||
Effect.as("registered" as const),
|
||||
Effect.catchTag("GatewayRegistry.OwnershipConflictError", () =>
|
||||
quarantine(sandbox, "sandbox ownership conflicts with the gateway registry"),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function quarantine(sandbox: { readonly id: string; readonly tags: Readonly<Record<string, string>> }, reason: string) {
|
||||
return Effect.gen(function* () {
|
||||
const registry = yield* GatewayRegistry.Service
|
||||
yield* registry.quarantine({ sandboxID: sandbox.id, reason, tags: sandbox.tags, time: Date.now() })
|
||||
return "quarantined" as const
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
export * as GatewayRegistry from "./registry.js"
|
||||
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { GatewayDatabase } from "./database.js"
|
||||
|
||||
export const SandboxStatus = Schema.Literals(["creating", "running", "missing", "finished", "failed"])
|
||||
export type SandboxStatus = typeof SandboxStatus.Type
|
||||
const Tags = Schema.Record(Schema.String, Schema.String)
|
||||
const decodeTags = Schema.decodeUnknownSync(Schema.fromJsonString(Tags))
|
||||
|
||||
export interface WorkspaceInfo {
|
||||
readonly id: Workspace.ID
|
||||
readonly volumeSubpath: string
|
||||
readonly directory: string
|
||||
readonly time: { readonly created: number; readonly updated: number }
|
||||
}
|
||||
|
||||
export interface SandboxInfo {
|
||||
readonly id: string
|
||||
readonly workspaceID: Workspace.ID
|
||||
readonly generation: number
|
||||
readonly status: SandboxStatus
|
||||
readonly endpoint?: string
|
||||
readonly time: {
|
||||
readonly created: number
|
||||
readonly expires?: number
|
||||
readonly connected?: number
|
||||
readonly finished?: number
|
||||
}
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
export interface SessionBinding {
|
||||
readonly id: string
|
||||
readonly workspaceID: Workspace.ID
|
||||
readonly projectID: string
|
||||
readonly parentID?: string
|
||||
readonly time: { readonly created: number; readonly updated: number }
|
||||
}
|
||||
|
||||
export interface QuarantinedSandbox {
|
||||
readonly sandboxID: string
|
||||
readonly reason: string
|
||||
readonly tags: Readonly<Record<string, string>>
|
||||
readonly timeObserved: number
|
||||
}
|
||||
|
||||
export interface ResourceOwner {
|
||||
readonly kind: string
|
||||
readonly id: string
|
||||
readonly workspaceID: Workspace.ID
|
||||
readonly sandboxID: string
|
||||
}
|
||||
|
||||
export class WorkspaceNotFoundError extends Schema.TaggedErrorClass<WorkspaceNotFoundError>()(
|
||||
"GatewayRegistry.WorkspaceNotFoundError",
|
||||
{ workspaceID: Workspace.ID },
|
||||
) {}
|
||||
|
||||
export class OwnershipConflictError extends Schema.TaggedErrorClass<OwnershipConflictError>()(
|
||||
"GatewayRegistry.OwnershipConflictError",
|
||||
{
|
||||
resource: Schema.String,
|
||||
id: Schema.String,
|
||||
expectedWorkspaceID: Workspace.ID,
|
||||
actualWorkspaceID: Workspace.ID,
|
||||
},
|
||||
) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly installationID: Effect.Effect<string>
|
||||
readonly createWorkspace: (input: {
|
||||
readonly directory: string
|
||||
readonly volumeSubpathPrefix?: string
|
||||
}) => Effect.Effect<WorkspaceInfo>
|
||||
readonly getWorkspace: (workspaceID: Workspace.ID) => Effect.Effect<WorkspaceInfo | undefined>
|
||||
readonly listWorkspaces: Effect.Effect<WorkspaceInfo[]>
|
||||
readonly removeWorkspace: (workspaceID: Workspace.ID) => Effect.Effect<void>
|
||||
readonly registerSandbox: (input: {
|
||||
readonly id: string
|
||||
readonly workspaceID: Workspace.ID
|
||||
readonly generation: number
|
||||
readonly status: SandboxStatus
|
||||
readonly endpoint?: string
|
||||
readonly timeCreated: number
|
||||
readonly timeExpires?: number
|
||||
}) => Effect.Effect<SandboxInfo, WorkspaceNotFoundError | OwnershipConflictError>
|
||||
readonly listSandboxes: Effect.Effect<SandboxInfo[]>
|
||||
readonly currentSandbox: (workspaceID: Workspace.ID) => Effect.Effect<SandboxInfo | undefined>
|
||||
readonly markMissingSandboxes: (activeIDs: ReadonlySet<string>, time: number) => Effect.Effect<void>
|
||||
readonly registerProject: (input: {
|
||||
readonly workspaceID: Workspace.ID
|
||||
readonly projectID: string
|
||||
readonly directory: string
|
||||
readonly canonical?: string
|
||||
readonly time: number
|
||||
}) => Effect.Effect<void, WorkspaceNotFoundError>
|
||||
readonly registerSession: (input: {
|
||||
readonly id: string
|
||||
readonly workspaceID: Workspace.ID
|
||||
readonly projectID: string
|
||||
readonly parentID?: string
|
||||
readonly timeCreated: number
|
||||
readonly timeUpdated: number
|
||||
}) => Effect.Effect<SessionBinding, WorkspaceNotFoundError | OwnershipConflictError>
|
||||
readonly findSession: (sessionID: string) => Effect.Effect<SessionBinding | undefined>
|
||||
readonly listSessions: Effect.Effect<SessionBinding[]>
|
||||
readonly removeSession: (sessionID: string) => Effect.Effect<void>
|
||||
readonly findResource: (kind: string, id: string) => Effect.Effect<ResourceOwner | undefined>
|
||||
readonly registerResource: (input: ResourceOwner) => Effect.Effect<void, OwnershipConflictError>
|
||||
readonly removeResource: (kind: string, id: string) => Effect.Effect<void>
|
||||
readonly quarantine: (input: {
|
||||
readonly sandboxID: string
|
||||
readonly reason: string
|
||||
readonly tags: Readonly<Record<string, string>>
|
||||
readonly time: number
|
||||
}) => Effect.Effect<void>
|
||||
readonly listQuarantined: Effect.Effect<QuarantinedSandbox[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/gateway/Registry") {}
|
||||
|
||||
type WorkspaceRow = {
|
||||
id: string
|
||||
volume_subpath: string
|
||||
directory: string
|
||||
time_created: number
|
||||
time_updated: number
|
||||
}
|
||||
|
||||
type SandboxRow = {
|
||||
id: string
|
||||
workspace_id: string
|
||||
generation: number
|
||||
status: SandboxStatus
|
||||
endpoint: string | null
|
||||
time_created: number
|
||||
time_expires: number | null
|
||||
time_connected: number | null
|
||||
time_finished: number | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
type SessionRow = {
|
||||
id: string
|
||||
workspace_id: string
|
||||
upstream_project_id: string
|
||||
parent_id: string | null
|
||||
time_created: number
|
||||
time_updated: number
|
||||
}
|
||||
|
||||
function workspace(row: WorkspaceRow): WorkspaceInfo {
|
||||
return {
|
||||
id: Workspace.ID.make(row.id),
|
||||
volumeSubpath: row.volume_subpath,
|
||||
directory: row.directory,
|
||||
time: { created: row.time_created, updated: row.time_updated },
|
||||
}
|
||||
}
|
||||
|
||||
function sandbox(row: SandboxRow): SandboxInfo {
|
||||
return {
|
||||
id: row.id,
|
||||
workspaceID: Workspace.ID.make(row.workspace_id),
|
||||
generation: row.generation,
|
||||
status: row.status,
|
||||
endpoint: row.endpoint ?? undefined,
|
||||
time: {
|
||||
created: row.time_created,
|
||||
expires: row.time_expires ?? undefined,
|
||||
connected: row.time_connected ?? undefined,
|
||||
finished: row.time_finished ?? undefined,
|
||||
},
|
||||
error: row.error ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function session(row: SessionRow): SessionBinding {
|
||||
return {
|
||||
id: row.id,
|
||||
workspaceID: Workspace.ID.make(row.workspace_id),
|
||||
projectID: row.upstream_project_id,
|
||||
parentID: row.parent_id ?? undefined,
|
||||
time: { created: row.time_created, updated: row.time_updated },
|
||||
}
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const database = yield* GatewayDatabase.Service
|
||||
const sql = database.sql
|
||||
|
||||
const getWorkspace = Effect.fn("GatewayRegistry.getWorkspace")(function* (workspaceID: Workspace.ID) {
|
||||
const rows = yield* sql<WorkspaceRow>`SELECT * FROM workspace WHERE id = ${workspaceID}`.pipe(Effect.orDie)
|
||||
return rows[0] ? workspace(rows[0]) : undefined
|
||||
})
|
||||
|
||||
const requireWorkspace = Effect.fnUntraced(function* (workspaceID: Workspace.ID) {
|
||||
const result = yield* getWorkspace(workspaceID)
|
||||
if (!result) return yield* new WorkspaceNotFoundError({ workspaceID })
|
||||
return result
|
||||
})
|
||||
|
||||
const installationID = sql<{
|
||||
installation_id: string
|
||||
}>`SELECT installation_id FROM gateway WHERE singleton = 1`.pipe(
|
||||
Effect.orDie,
|
||||
Effect.flatMap((rows) => {
|
||||
if (rows[0]) return Effect.succeed(rows[0].installation_id)
|
||||
const id = `gw_${randomUUID()}`
|
||||
return sql`INSERT OR IGNORE INTO gateway (singleton, installation_id, time_created) VALUES (1, ${id}, ${Date.now()})`.pipe(
|
||||
Effect.orDie,
|
||||
Effect.andThen(
|
||||
sql<{ installation_id: string }>`SELECT installation_id FROM gateway WHERE singleton = 1`.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((created) => created[0]?.installation_id ?? id),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const createWorkspace = Effect.fn("GatewayRegistry.createWorkspace")(function* (input: {
|
||||
readonly directory: string
|
||||
readonly volumeSubpathPrefix?: string
|
||||
}) {
|
||||
const id = Workspace.ID.create()
|
||||
const prefix = (input.volumeSubpathPrefix ?? "/workspaces").replace(/\/+$/, "")
|
||||
const time = Date.now()
|
||||
yield* sql`
|
||||
INSERT INTO workspace (id, volume_subpath, directory, time_created, time_updated)
|
||||
VALUES (${id}, ${`${prefix}/${id}`}, ${input.directory}, ${time}, ${time})
|
||||
`.pipe(Effect.orDie)
|
||||
const created = yield* getWorkspace(id)
|
||||
if (!created) return yield* Effect.die(new Error(`Workspace was not created: ${id}`))
|
||||
return created
|
||||
})
|
||||
|
||||
const listWorkspaces = sql<WorkspaceRow>`SELECT * FROM workspace ORDER BY time_created, id`.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => rows.map(workspace)),
|
||||
)
|
||||
|
||||
const removeWorkspace = Effect.fn("GatewayRegistry.removeWorkspace")((workspaceID: Workspace.ID) =>
|
||||
sql`DELETE FROM workspace WHERE id = ${workspaceID}`.pipe(Effect.orDie, Effect.asVoid),
|
||||
)
|
||||
|
||||
const registerSandbox = Effect.fn("GatewayRegistry.registerSandbox")(function* (input: {
|
||||
readonly id: string
|
||||
readonly workspaceID: Workspace.ID
|
||||
readonly generation: number
|
||||
readonly status: SandboxStatus
|
||||
readonly endpoint?: string
|
||||
readonly timeCreated: number
|
||||
readonly timeExpires?: number
|
||||
}) {
|
||||
yield* requireWorkspace(input.workspaceID)
|
||||
const existing = yield* sql<{
|
||||
workspace_id: string
|
||||
}>`SELECT workspace_id FROM sandbox WHERE id = ${input.id}`.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => rows[0]),
|
||||
)
|
||||
if (existing && existing.workspace_id !== input.workspaceID)
|
||||
return yield* new OwnershipConflictError({
|
||||
resource: "sandbox",
|
||||
id: input.id,
|
||||
expectedWorkspaceID: Workspace.ID.make(existing.workspace_id),
|
||||
actualWorkspaceID: input.workspaceID,
|
||||
})
|
||||
const generation = yield* sql<{ id: string }>`
|
||||
SELECT id FROM sandbox
|
||||
WHERE workspace_id = ${input.workspaceID} AND generation = ${input.generation}
|
||||
`.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => rows[0]),
|
||||
)
|
||||
if (generation && generation.id !== input.id)
|
||||
return yield* new OwnershipConflictError({
|
||||
resource: "sandbox generation",
|
||||
id: input.id,
|
||||
expectedWorkspaceID: input.workspaceID,
|
||||
actualWorkspaceID: input.workspaceID,
|
||||
})
|
||||
yield* sql`
|
||||
INSERT INTO sandbox (
|
||||
id, workspace_id, generation, status, endpoint, time_created, time_expires
|
||||
) VALUES (
|
||||
${input.id}, ${input.workspaceID}, ${input.generation}, ${input.status}, ${input.endpoint ?? null},
|
||||
${input.timeCreated}, ${input.timeExpires ?? null}
|
||||
)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
endpoint = excluded.endpoint,
|
||||
time_expires = excluded.time_expires,
|
||||
error = NULL,
|
||||
time_finished = NULL
|
||||
`.pipe(Effect.orDie)
|
||||
const rows = yield* sql<SandboxRow>`SELECT * FROM sandbox WHERE id = ${input.id}`.pipe(Effect.orDie)
|
||||
const result = rows[0]
|
||||
if (!result) return yield* Effect.die(new Error(`Sandbox was not registered: ${input.id}`))
|
||||
return sandbox(result)
|
||||
})
|
||||
|
||||
const listSandboxes = sql<SandboxRow>`SELECT * FROM sandbox ORDER BY time_created, id`.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => rows.map(sandbox)),
|
||||
)
|
||||
|
||||
const currentSandbox = Effect.fn("GatewayRegistry.currentSandbox")(function* (workspaceID: Workspace.ID) {
|
||||
const rows = yield* sql<SandboxRow>`
|
||||
SELECT * FROM sandbox
|
||||
WHERE workspace_id = ${workspaceID} AND status = 'running'
|
||||
ORDER BY generation DESC
|
||||
LIMIT 1
|
||||
`.pipe(Effect.orDie)
|
||||
return rows[0] ? sandbox(rows[0]) : undefined
|
||||
})
|
||||
|
||||
const markMissingSandboxes = Effect.fn("GatewayRegistry.markMissingSandboxes")(function* (
|
||||
activeIDs: ReadonlySet<string>,
|
||||
time: number,
|
||||
) {
|
||||
const rows = yield* sql<{ id: string }>`SELECT id FROM sandbox WHERE status IN ('creating', 'running')`.pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
yield* Effect.forEach(
|
||||
rows.filter((row) => !activeIDs.has(row.id)),
|
||||
(row) =>
|
||||
sql`UPDATE sandbox SET status = 'missing', time_finished = ${time} WHERE id = ${row.id}`.pipe(Effect.orDie),
|
||||
{ discard: true },
|
||||
)
|
||||
})
|
||||
|
||||
const registerProject = Effect.fn("GatewayRegistry.registerProject")(function* (input: {
|
||||
readonly workspaceID: Workspace.ID
|
||||
readonly projectID: string
|
||||
readonly directory: string
|
||||
readonly canonical?: string
|
||||
readonly time: number
|
||||
}) {
|
||||
yield* requireWorkspace(input.workspaceID)
|
||||
yield* sql`
|
||||
INSERT INTO project_binding (workspace_id, upstream_project_id, directory, canonical, time_updated)
|
||||
VALUES (${input.workspaceID}, ${input.projectID}, ${input.directory}, ${input.canonical ?? null}, ${input.time})
|
||||
ON CONFLICT(workspace_id) DO UPDATE SET
|
||||
upstream_project_id = excluded.upstream_project_id,
|
||||
directory = excluded.directory,
|
||||
canonical = excluded.canonical,
|
||||
time_updated = excluded.time_updated
|
||||
`.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const registerSession = Effect.fn("GatewayRegistry.registerSession")(function* (input: {
|
||||
readonly id: string
|
||||
readonly workspaceID: Workspace.ID
|
||||
readonly projectID: string
|
||||
readonly parentID?: string
|
||||
readonly timeCreated: number
|
||||
readonly timeUpdated: number
|
||||
}) {
|
||||
yield* requireWorkspace(input.workspaceID)
|
||||
const existing = yield* sql<{
|
||||
workspace_id: string
|
||||
}>`SELECT workspace_id FROM session_binding WHERE id = ${input.id}`.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => rows[0]),
|
||||
)
|
||||
if (existing && existing.workspace_id !== input.workspaceID)
|
||||
return yield* new OwnershipConflictError({
|
||||
resource: "session",
|
||||
id: input.id,
|
||||
expectedWorkspaceID: Workspace.ID.make(existing.workspace_id),
|
||||
actualWorkspaceID: input.workspaceID,
|
||||
})
|
||||
yield* sql`
|
||||
INSERT INTO session_binding (
|
||||
id, workspace_id, upstream_project_id, parent_id, time_created, time_updated
|
||||
) VALUES (
|
||||
${input.id}, ${input.workspaceID}, ${input.projectID}, ${input.parentID ?? null},
|
||||
${input.timeCreated}, ${input.timeUpdated}
|
||||
)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
upstream_project_id = excluded.upstream_project_id,
|
||||
parent_id = excluded.parent_id,
|
||||
time_updated = excluded.time_updated
|
||||
`.pipe(Effect.orDie)
|
||||
const rows = yield* sql<SessionRow>`SELECT * FROM session_binding WHERE id = ${input.id}`.pipe(Effect.orDie)
|
||||
const result = rows[0]
|
||||
if (!result) return yield* Effect.die(new Error(`Session was not registered: ${input.id}`))
|
||||
return session(result)
|
||||
})
|
||||
|
||||
const findSession = Effect.fn("GatewayRegistry.findSession")(function* (sessionID: string) {
|
||||
const rows = yield* sql<SessionRow>`SELECT * FROM session_binding WHERE id = ${sessionID}`.pipe(Effect.orDie)
|
||||
return rows[0] ? session(rows[0]) : undefined
|
||||
})
|
||||
|
||||
const listSessions = sql<SessionRow>`SELECT * FROM session_binding ORDER BY time_updated DESC, id`.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => rows.map(session)),
|
||||
)
|
||||
|
||||
const removeSession = Effect.fn("GatewayRegistry.removeSession")((sessionID: string) =>
|
||||
sql`DELETE FROM session_binding WHERE id = ${sessionID}`.pipe(Effect.orDie, Effect.asVoid),
|
||||
)
|
||||
|
||||
const findResource = Effect.fn("GatewayRegistry.findResource")(function* (kind: string, id: string) {
|
||||
const rows = yield* sql<{
|
||||
kind: string
|
||||
id: string
|
||||
workspace_id: string
|
||||
sandbox_id: string
|
||||
}>`SELECT kind, id, workspace_id, sandbox_id FROM resource_owner WHERE kind = ${kind} AND id = ${id}`.pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
const row = rows[0]
|
||||
if (!row) return undefined
|
||||
return {
|
||||
kind: row.kind,
|
||||
id: row.id,
|
||||
workspaceID: Workspace.ID.make(row.workspace_id),
|
||||
sandboxID: row.sandbox_id,
|
||||
}
|
||||
})
|
||||
|
||||
const registerResource = Effect.fn("GatewayRegistry.registerResource")(function* (input: ResourceOwner) {
|
||||
const existing = yield* sql<{ workspace_id: string }>`
|
||||
SELECT workspace_id FROM resource_owner WHERE kind = ${input.kind} AND id = ${input.id}
|
||||
`.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => rows[0]),
|
||||
)
|
||||
if (existing && existing.workspace_id !== input.workspaceID)
|
||||
return yield* new OwnershipConflictError({
|
||||
resource: input.kind,
|
||||
id: input.id,
|
||||
expectedWorkspaceID: Workspace.ID.make(existing.workspace_id),
|
||||
actualWorkspaceID: input.workspaceID,
|
||||
})
|
||||
yield* sql`
|
||||
INSERT INTO resource_owner (kind, id, workspace_id, sandbox_id, time_created)
|
||||
VALUES (${input.kind}, ${input.id}, ${input.workspaceID}, ${input.sandboxID}, ${Date.now()})
|
||||
ON CONFLICT(kind, id) DO UPDATE SET
|
||||
sandbox_id = excluded.sandbox_id
|
||||
`.pipe(Effect.orDie)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const removeResource = Effect.fn("GatewayRegistry.removeResource")((kind: string, id: string) =>
|
||||
sql`DELETE FROM resource_owner WHERE kind = ${kind} AND id = ${id}`.pipe(Effect.orDie, Effect.asVoid),
|
||||
)
|
||||
|
||||
const quarantine = Effect.fn("GatewayRegistry.quarantine")(
|
||||
(input: {
|
||||
readonly sandboxID: string
|
||||
readonly reason: string
|
||||
readonly tags: Readonly<Record<string, string>>
|
||||
readonly time: number
|
||||
}) =>
|
||||
sql`
|
||||
INSERT INTO sandbox_quarantine (sandbox_id, reason, tags, time_observed)
|
||||
VALUES (${input.sandboxID}, ${input.reason}, ${JSON.stringify(input.tags)}, ${input.time})
|
||||
ON CONFLICT(sandbox_id) DO UPDATE SET
|
||||
reason = excluded.reason,
|
||||
tags = excluded.tags,
|
||||
time_observed = excluded.time_observed
|
||||
`.pipe(Effect.orDie, Effect.asVoid),
|
||||
)
|
||||
|
||||
const listQuarantined = sql<{
|
||||
sandbox_id: string
|
||||
reason: string
|
||||
tags: string
|
||||
time_observed: number
|
||||
}>`SELECT * FROM sandbox_quarantine ORDER BY time_observed DESC, sandbox_id`.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) =>
|
||||
rows.map((row) => ({
|
||||
sandboxID: row.sandbox_id,
|
||||
reason: row.reason,
|
||||
tags: decodeTags(row.tags),
|
||||
timeObserved: row.time_observed,
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
installationID,
|
||||
createWorkspace,
|
||||
getWorkspace,
|
||||
listWorkspaces,
|
||||
removeWorkspace,
|
||||
registerSandbox,
|
||||
listSandboxes,
|
||||
currentSandbox,
|
||||
markMissingSandboxes,
|
||||
registerProject,
|
||||
registerSession,
|
||||
findSession,
|
||||
listSessions,
|
||||
removeSession,
|
||||
findResource,
|
||||
registerResource,
|
||||
removeResource,
|
||||
quarantine,
|
||||
listQuarantined,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
export * as GatewayRouter from "./router.js"
|
||||
|
||||
export type Decision =
|
||||
| { readonly type: "health" }
|
||||
| { readonly type: "server" }
|
||||
| { readonly type: "empty-projects" }
|
||||
| { readonly type: "empty-saved-permissions" }
|
||||
| { readonly type: "default-location" }
|
||||
| { readonly type: "sessions" }
|
||||
| { readonly type: "active-sessions" }
|
||||
| { readonly type: "events" }
|
||||
| { readonly type: "provision-session" }
|
||||
| { readonly type: "control" }
|
||||
| { readonly type: "session"; readonly sessionID: string }
|
||||
| { readonly type: "workspace"; readonly workspaceID: string }
|
||||
| { readonly type: "resource"; readonly kind: "pty" | "shell"; readonly id: string }
|
||||
| { readonly type: "unsupported"; readonly reason: string }
|
||||
|
||||
export function classify(request: Request): Decision {
|
||||
const url = new URL(request.url)
|
||||
const method = request.method.toUpperCase()
|
||||
|
||||
if (method === "GET" && url.pathname === "/api/health") return { type: "health" }
|
||||
if (method === "GET" && url.pathname === "/api/server") return { type: "server" }
|
||||
if (method === "GET" && url.pathname === "/api/project") return { type: "empty-projects" }
|
||||
if (method === "GET" && url.pathname === "/api/permission/saved") return { type: "empty-saved-permissions" }
|
||||
if (control(url.pathname)) return { type: "control" }
|
||||
if (url.pathname.startsWith("/api/worktree/"))
|
||||
return { type: "unsupported", reason: "worktree routes are not supported by the gateway" }
|
||||
if (url.pathname.startsWith("/api/permission/saved"))
|
||||
return { type: "unsupported", reason: "saved permission mutations are not supported by the gateway" }
|
||||
|
||||
const experimentalSession = url.pathname.match(/^\/api\/experimental\/session\/([^/]+)\/log$/)
|
||||
if (experimentalSession) return { type: "session", sessionID: experimentalSession[1] }
|
||||
|
||||
if (url.pathname === "/api/session") {
|
||||
if (method === "GET") {
|
||||
const parentID = url.searchParams.get("parentID")
|
||||
if (parentID && parentID !== "null") return { type: "session", sessionID: parentID }
|
||||
return { type: "sessions" }
|
||||
}
|
||||
if (method === "POST") return { type: "provision-session" }
|
||||
}
|
||||
if (url.pathname === "/api/session/import")
|
||||
return { type: "unsupported", reason: "session import is not implemented yet" }
|
||||
if (url.pathname === "/api/session/active") return { type: "active-sessions" }
|
||||
if (/^\/api\/session\/[^/]+\/move$/.test(url.pathname))
|
||||
return { type: "unsupported", reason: "session movement is not supported by the gateway" }
|
||||
if (/^\/api\/pty\/[^/]+\/connect$/.test(url.pathname))
|
||||
return { type: "unsupported", reason: "PTY WebSocket proxying is not supported by the gateway" }
|
||||
|
||||
if (/^\/api\/session\/global\/form(?:\/|$)/.test(url.pathname)) {
|
||||
const workspaceID = workspace(request, url)
|
||||
return workspaceID
|
||||
? { type: "workspace", workspaceID }
|
||||
: { type: "unsupported", reason: "global forms require an explicit workspace" }
|
||||
}
|
||||
|
||||
const session = url.pathname.match(/^\/api\/session\/([^/]+)(?:\/|$)/)
|
||||
if (session) return { type: "session", sessionID: session[1] }
|
||||
|
||||
const workspaceID = workspace(request, url)
|
||||
if (workspaceID) return { type: "workspace", workspaceID }
|
||||
if (method === "GET" && url.pathname === "/api/location") return { type: "default-location" }
|
||||
|
||||
const resource = url.pathname.match(/^\/api\/(pty|shell)\/([^/]+)(?:\/|$)/)
|
||||
if (resource)
|
||||
return {
|
||||
type: "resource",
|
||||
kind: resource[1] === "pty" ? "pty" : "shell",
|
||||
id: resource[2],
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/event") return { type: "events" }
|
||||
return { type: "unsupported", reason: "request has no session, workspace, or registered resource owner" }
|
||||
}
|
||||
|
||||
function control(pathname: string) {
|
||||
return [
|
||||
"/api/agent",
|
||||
"/api/model",
|
||||
"/api/provider",
|
||||
"/api/integration",
|
||||
"/api/credential",
|
||||
"/api/plugin",
|
||||
"/api/config",
|
||||
"/api/experimental/integration/wellknown",
|
||||
"/api/experimental/migration/v1",
|
||||
].some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"))
|
||||
}
|
||||
|
||||
function workspace(request: Request, url: URL) {
|
||||
return (
|
||||
url.searchParams.get("workspace") ??
|
||||
url.searchParams.get("location[workspace]") ??
|
||||
url.searchParams.get("location.workspace") ??
|
||||
request.headers.get("x-opencode-workspace") ??
|
||||
undefined
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { Database } from "bun:sqlite"
|
||||
import { Effect, ManagedRuntime } from "effect"
|
||||
import { mkdtemp, rm } from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { GatewayCredentials } from "../src/credentials"
|
||||
|
||||
const directories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
test("snapshots complete key and OAuth credentials", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-gateway-credentials-"))
|
||||
directories.push(directory)
|
||||
const file = path.join(directory, "opencode.db")
|
||||
const database = new Database(file)
|
||||
database.run(`
|
||||
CREATE TABLE credential (
|
||||
id TEXT PRIMARY KEY,
|
||||
integration_id TEXT,
|
||||
label TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
connector_id TEXT,
|
||||
method_id TEXT,
|
||||
active INTEGER,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL
|
||||
)
|
||||
`)
|
||||
database
|
||||
.query("INSERT INTO credential VALUES (?, ?, ?, ?, NULL, NULL, NULL, 1, 1)")
|
||||
.run(
|
||||
"cred_oauth",
|
||||
"provider",
|
||||
"OAuth",
|
||||
JSON.stringify({ type: "oauth", methodID: "oauth", access: "access", refresh: "refresh", expires: 1 }),
|
||||
)
|
||||
database
|
||||
.query("INSERT INTO credential VALUES (?, ?, ?, ?, NULL, NULL, NULL, 1, 1)")
|
||||
.run("cred_key", "other", "Key", JSON.stringify({ type: "key", key: "secret" }))
|
||||
database.close()
|
||||
|
||||
const runtime = ManagedRuntime.make(GatewayCredentials.layer(file))
|
||||
try {
|
||||
const snapshot = await runtime.runPromise(
|
||||
GatewayCredentials.Service.use((credentials) => Effect.succeed(credentials.snapshot)),
|
||||
)
|
||||
expect(snapshot).toHaveLength(2)
|
||||
const oauth = snapshot.find((row) => row.id === "cred_oauth")
|
||||
const key = snapshot.find((row) => row.id === "cred_key")
|
||||
expect(oauth).toBeDefined()
|
||||
expect(key).toBeDefined()
|
||||
const oauthValue: unknown = JSON.parse(oauth?.value ?? "")
|
||||
const keyValue: unknown = JSON.parse(key?.value ?? "")
|
||||
expect(oauthValue).toEqual({
|
||||
type: "oauth",
|
||||
methodID: "oauth",
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: 1,
|
||||
})
|
||||
expect(keyValue).toEqual({ type: "key", key: "secret" })
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,384 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/effect"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { mkdtemp, rm } from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { GatewayAggregate } from "../src/aggregate"
|
||||
import { GatewayBackend } from "../src/backend"
|
||||
import { GatewayControl } from "../src/control"
|
||||
import { GatewayDatabase } from "../src/database"
|
||||
import { GatewayEvents } from "../src/events"
|
||||
import { GatewayHandler } from "../src/handler"
|
||||
import { GatewayProcess } from "../src/process"
|
||||
import { GatewayProvision } from "../src/provision"
|
||||
import { GatewayRegistry } from "../src/registry"
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
const password = "gateway-secret"
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function databasePath() {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-gateway-handler-"))
|
||||
temporaryDirectories.push(directory)
|
||||
return path.join(directory, "gateway.db")
|
||||
}
|
||||
|
||||
function authenticated(url: string, init: RequestInit = {}) {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set("authorization", `Basic ${btoa(`opencode:${password}`)}`)
|
||||
return new Request(url, { ...init, headers })
|
||||
}
|
||||
|
||||
function services(databasePath: string, controlURL = "http://127.0.0.1:1") {
|
||||
const database = GatewayDatabase.layer({ path: databasePath })
|
||||
const registry = GatewayRegistry.layer.pipe(Layer.provide(database))
|
||||
const backend = GatewayBackend.registryLayer.pipe(Layer.provide(registry))
|
||||
const upstream = Layer.mergeAll(registry, backend, FetchHttpClient.layer)
|
||||
const aggregate = GatewayAggregate.layer.pipe(Layer.provide(upstream))
|
||||
const events = GatewayEvents.layer().pipe(Layer.provide(upstream))
|
||||
const provision = Layer.succeed(
|
||||
GatewayProvision.Service,
|
||||
GatewayProvision.Service.of({ create: () => Effect.die(new Error("not used")) }),
|
||||
)
|
||||
const control = GatewayControl.layer({ url: controlURL, headers: {} })
|
||||
return Layer.mergeAll(upstream, aggregate, events, provision, control)
|
||||
}
|
||||
|
||||
describe("GatewayHandler", () => {
|
||||
test("routes session and workspace requests to their owning backends", async () => {
|
||||
const firstRequests = { count: 0 }
|
||||
const secondRequests = { count: 0 }
|
||||
using first = Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (request) => {
|
||||
firstRequests.count++
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/model")
|
||||
return Response.json({
|
||||
location: {
|
||||
directory: "/persist/project",
|
||||
project: { id: "same-project", directory: "/persist/project", canonical: "/persist/project" },
|
||||
},
|
||||
data: [],
|
||||
})
|
||||
if (url.pathname === "/api/session/ses_first")
|
||||
return Response.json({
|
||||
data: {
|
||||
id: "ses_first",
|
||||
projectID: "same-project",
|
||||
location: { directory: "/persist/project" },
|
||||
},
|
||||
})
|
||||
if (url.pathname === "/api/shell" && request.method === "GET")
|
||||
return Response.json({
|
||||
location: {
|
||||
directory: "/persist/project",
|
||||
project: { id: "same-project", directory: "/persist/project", canonical: "/persist/project" },
|
||||
},
|
||||
data: [{ id: "sh_first", command: "echo hello" }],
|
||||
})
|
||||
if (url.pathname === "/api/session" && request.method === "GET")
|
||||
return Response.json({ data: [sessionInfo("ses_first")], cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return Response.json({ data: { ses_first: { type: "running" } } })
|
||||
if (url.pathname === "/api/fs/read/checkpoint.txt")
|
||||
return new Response(new Uint8Array([1, 2, 3]), { headers: { "content-type": "application/octet-stream" } })
|
||||
if (url.pathname === "/api/mcp/server/connect") return new Response(undefined, { status: 204 })
|
||||
return Response.json(
|
||||
{
|
||||
backend: "first",
|
||||
path: url.pathname,
|
||||
search: url.search,
|
||||
workspace: request.headers.get("x-opencode-workspace"),
|
||||
body: request.method === "GET" ? undefined : await request.text(),
|
||||
},
|
||||
{ status: 202, headers: { "x-upstream": "first" } },
|
||||
)
|
||||
},
|
||||
})
|
||||
using second = Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
secondRequests.count++
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/session" && request.method === "GET")
|
||||
return Response.json({ data: [sessionInfo("ses_second")], cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return Response.json({ data: { ses_second: { type: "running" } } })
|
||||
return Response.json({ backend: "second", path: new URL(request.url).pathname })
|
||||
},
|
||||
})
|
||||
const runtime = ManagedRuntime.make(services(await databasePath(), first.url.toString()))
|
||||
|
||||
try {
|
||||
const workspaces = await runtime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* GatewayRegistry.Service
|
||||
const firstWorkspace = yield* registry.createWorkspace({ directory: "/persist/project" })
|
||||
const secondWorkspace = yield* registry.createWorkspace({ directory: "/persist/project" })
|
||||
yield* registry.registerSandbox({
|
||||
id: "sb_first",
|
||||
workspaceID: firstWorkspace.id,
|
||||
generation: 1,
|
||||
status: "running",
|
||||
endpoint: first.url.toString(),
|
||||
timeCreated: 1,
|
||||
})
|
||||
yield* registry.registerSandbox({
|
||||
id: "sb_second",
|
||||
workspaceID: secondWorkspace.id,
|
||||
generation: 1,
|
||||
status: "running",
|
||||
endpoint: second.url.toString(),
|
||||
timeCreated: 1,
|
||||
})
|
||||
yield* registry.registerSession({
|
||||
id: "ses_first",
|
||||
workspaceID: firstWorkspace.id,
|
||||
projectID: "same-project",
|
||||
timeCreated: 1,
|
||||
timeUpdated: 1,
|
||||
})
|
||||
yield* registry.registerSession({
|
||||
id: "ses_second",
|
||||
workspaceID: secondWorkspace.id,
|
||||
projectID: "same-project",
|
||||
timeCreated: 1,
|
||||
timeUpdated: 1,
|
||||
})
|
||||
return { first: firstWorkspace, second: secondWorkspace }
|
||||
}),
|
||||
)
|
||||
const handle = (request: Request) =>
|
||||
runtime.runPromise(GatewayHandler.handle(request, { password, version: "test" }))
|
||||
|
||||
const firstResponse = await handle(
|
||||
authenticated("http://gateway.test/api/session/ses_first/prompt", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text: "hello" }),
|
||||
}),
|
||||
)
|
||||
expect(firstResponse.status).toBe(202)
|
||||
expect(firstResponse.headers.get("x-upstream")).toBe("first")
|
||||
expect(await firstResponse.json()).toEqual({
|
||||
backend: "first",
|
||||
path: "/api/session/ses_first/prompt",
|
||||
search: "",
|
||||
workspace: null,
|
||||
body: JSON.stringify({ text: "hello" }),
|
||||
})
|
||||
|
||||
const secondResponse = await handle(authenticated("http://gateway.test/api/session/ses_second/message"))
|
||||
expect(await secondResponse.json()).toEqual({ backend: "second", path: "/api/session/ses_second/message" })
|
||||
|
||||
const location = new URL("http://gateway.test/api/model")
|
||||
location.searchParams.set("location[directory]", "/persist/project")
|
||||
location.searchParams.set("location[workspace]", workspaces.first.id)
|
||||
const locationResponse = await handle(
|
||||
authenticated(location.toString(), { headers: { "x-opencode-workspace": workspaces.first.id } }),
|
||||
)
|
||||
expect(await locationResponse.json()).toEqual({
|
||||
location: {
|
||||
directory: "/persist/project",
|
||||
workspaceID: workspaces.first.id,
|
||||
project: { id: "same-project", directory: "/persist/project", canonical: "/persist/project" },
|
||||
},
|
||||
data: [],
|
||||
})
|
||||
|
||||
const sessionResponse = await handle(authenticated("http://gateway.test/api/session/ses_first"))
|
||||
expect(await sessionResponse.json()).toEqual({
|
||||
data: {
|
||||
id: "ses_first",
|
||||
projectID: "same-project",
|
||||
location: { directory: "/persist/project", workspaceID: workspaces.first.id },
|
||||
},
|
||||
})
|
||||
|
||||
const shells = new URL("http://gateway.test/api/shell")
|
||||
shells.searchParams.set("location[workspace]", workspaces.first.id)
|
||||
expect((await handle(authenticated(shells.toString()))).status).toBe(200)
|
||||
const shellOutput = await handle(authenticated("http://gateway.test/api/shell/sh_first/output"))
|
||||
expect(shellOutput.status).toBe(202)
|
||||
expect((await shellOutput.json()).backend).toBe("first")
|
||||
expect((await handle(authenticated("http://gateway.test/api/shell/sh_first", { method: "DELETE" }))).status).toBe(
|
||||
202,
|
||||
)
|
||||
expect((await handle(authenticated("http://gateway.test/api/shell/sh_first/output"))).status).toBe(404)
|
||||
|
||||
const file = new URL("http://gateway.test/api/fs/read/checkpoint.txt")
|
||||
file.searchParams.set("location[workspace]", workspaces.first.id)
|
||||
expect(new Uint8Array(await (await handle(authenticated(file.toString()))).arrayBuffer())).toEqual(
|
||||
new Uint8Array([1, 2, 3]),
|
||||
)
|
||||
const mcp = new URL("http://gateway.test/api/mcp/server/connect")
|
||||
mcp.searchParams.set("location[workspace]", workspaces.first.id)
|
||||
expect((await handle(authenticated(mcp.toString(), { method: "POST" }))).status).toBe(204)
|
||||
|
||||
const sessions = await handle(authenticated("http://gateway.test/api/session?parentID=null&order=desc"))
|
||||
const sessionBody = record(await sessions.json())
|
||||
if (!Array.isArray(sessionBody.data)) throw new Error("Expected aggregate session data")
|
||||
expect(sessionBody.data.map((session) => record(session).id)).toEqual(["ses_first", "ses_second"])
|
||||
expect(sessionBody.data.map((session) => record(record(session).location).workspaceID)).toEqual([
|
||||
workspaces.first.id,
|
||||
workspaces.second.id,
|
||||
])
|
||||
const active = await handle(authenticated("http://gateway.test/api/session/active"))
|
||||
expect(await active.json()).toEqual({
|
||||
data: { ses_first: { type: "running" }, ses_second: { type: "running" } },
|
||||
})
|
||||
|
||||
expect(firstRequests.count).toBe(10)
|
||||
expect(secondRequests.count).toBe(3)
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects unauthorized, unknown, aggregate, and project-only requests without an upstream", async () => {
|
||||
const requests = { count: 0 }
|
||||
using upstream = Bun.serve({
|
||||
port: 0,
|
||||
fetch: () => {
|
||||
requests.count++
|
||||
return new Response("unexpected")
|
||||
},
|
||||
})
|
||||
const runtime = ManagedRuntime.make(services(await databasePath()))
|
||||
|
||||
try {
|
||||
const handle = (request: Request) =>
|
||||
runtime.runPromise(GatewayHandler.handle(request, { password, version: "test" }))
|
||||
|
||||
expect((await handle(new Request("http://gateway.test/api/health"))).status).toBe(401)
|
||||
expect((await handle(authenticated("http://gateway.test/api/session/ses_unknown"))).status).toBe(404)
|
||||
const sessions = await handle(authenticated("http://gateway.test/api/session"))
|
||||
expect(sessions.status).toBe(200)
|
||||
expect(await sessions.json()).toEqual({ data: [], cursor: {} })
|
||||
expect((await handle(authenticated("http://gateway.test/api/worktree/same-project"))).status).toBe(501)
|
||||
expect((await handle(authenticated("http://gateway.test/api/permission/saved/perm_1"))).status).toBe(501)
|
||||
|
||||
const projects = await handle(authenticated("http://gateway.test/api/project"))
|
||||
expect(projects.status).toBe(200)
|
||||
expect(await projects.json()).toEqual([])
|
||||
|
||||
const saved = await handle(authenticated("http://gateway.test/api/permission/saved"))
|
||||
expect(saved.status).toBe(200)
|
||||
expect(await saved.json()).toEqual({ data: [] })
|
||||
|
||||
const location = await handle(authenticated("http://gateway.test/api/location"))
|
||||
expect(location.status).toBe(200)
|
||||
expect(await location.json()).toEqual({
|
||||
directory: "/persist/project",
|
||||
project: { id: "global", directory: "/persist/project", canonical: "/persist/project" },
|
||||
})
|
||||
expect(requests.count).toBe(0)
|
||||
void upstream
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("serves the authenticated gateway over a foreground listener", async () => {
|
||||
const layers = services(await databasePath())
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const gateway = yield* GatewayProcess.serve(
|
||||
{ hostname: "127.0.0.1", port: 0, password, version: "test-version" },
|
||||
layers,
|
||||
)
|
||||
const denied = yield* Effect.promise(() => fetch(new URL("/api/health", gateway.url)))
|
||||
expect(denied.status).toBe(401)
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/health", gateway.url), {
|
||||
headers: { authorization: `Basic ${btoa(`opencode:${password}`)}` },
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const body = yield* Effect.promise(() => response.json())
|
||||
expect(body).toEqual({ healthy: true, version: "test-version", pid: process.pid })
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("registers session creation before publishing the translated event", async () => {
|
||||
const runtime = ManagedRuntime.make(services(await databasePath()))
|
||||
try {
|
||||
const workspace = await runtime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* GatewayRegistry.Service
|
||||
return yield* registry.createWorkspace({ directory: "/persist/project" })
|
||||
}),
|
||||
)
|
||||
const response = await runtime.runPromise(
|
||||
GatewayHandler.handle(authenticated("http://gateway.test/api/event"), { password, version: "test" }),
|
||||
)
|
||||
if (!response.body) throw new Error("Expected event stream")
|
||||
const reader = response.body.getReader()
|
||||
expect(record(await readEvent(reader)).type).toBe("server.connected")
|
||||
|
||||
const sessionID = Session.ID.make("ses_created")
|
||||
const projectID = Project.ID.make("same-project")
|
||||
const directory = AbsolutePath.make("/persist/project")
|
||||
const event: Extract<OpenCodeEvent, { readonly type: "session.created" }> = {
|
||||
id: Event.ID.create(),
|
||||
type: "session.created",
|
||||
created: Date.now(),
|
||||
durable: { aggregateID: sessionID, seq: Event.Seq.make(1), version: Event.Version.make(1) },
|
||||
location: { directory },
|
||||
data: {
|
||||
sessionID,
|
||||
projectID,
|
||||
location: { directory },
|
||||
slug: "created-session",
|
||||
version: "test",
|
||||
},
|
||||
}
|
||||
await runtime.runPromise(GatewayEvents.Service.use((events) => events.publish(event, workspace.id)))
|
||||
const registered = await runtime.runPromise(
|
||||
GatewayRegistry.Service.use((registry) => registry.findSession(sessionID)),
|
||||
)
|
||||
expect(registered?.workspaceID).toBe(workspace.id)
|
||||
const published = record(await readEvent(reader))
|
||||
expect(record(record(published.data).location).workspaceID).toBe(workspace.id)
|
||||
expect(record(published.location).workspaceID).toBe(workspace.id)
|
||||
await reader.cancel()
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function sessionInfo(id: string) {
|
||||
return {
|
||||
id,
|
||||
projectID: "same-project",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
location: { directory: "/persist/project" },
|
||||
}
|
||||
}
|
||||
|
||||
async function readEvent(reader: ReadableStreamDefaultReader<Uint8Array>) {
|
||||
const result = await reader.read()
|
||||
if (result.done) throw new Error("Event stream closed")
|
||||
const line = new TextDecoder().decode(result.value).trim()
|
||||
const value: unknown = JSON.parse(line.replace(/^data:\s*/, ""))
|
||||
return value
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Expected object")
|
||||
return Object.fromEntries(Object.entries(value))
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { mkdtemp, rm } from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { GatewayBackend } from "../src/backend"
|
||||
import { GatewayCredentials } from "../src/credentials"
|
||||
import { GatewayDatabase } from "../src/database"
|
||||
import { GatewayEvents } from "../src/events"
|
||||
import { GatewayModal } from "../src/modal"
|
||||
import { GatewayProvision } from "../src/provision"
|
||||
import { GatewayRegistry } from "../src/registry"
|
||||
|
||||
const directories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
test("removes the workspace when Modal creation fails", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-gateway-provision-"))
|
||||
directories.push(directory)
|
||||
const database = GatewayDatabase.layer({ path: path.join(directory, "gateway.db") })
|
||||
const registry = GatewayRegistry.layer.pipe(Layer.provide(database))
|
||||
const modal = Layer.succeed(
|
||||
GatewayModal.Service,
|
||||
GatewayModal.Service.of({
|
||||
appID: "ap_test",
|
||||
volumeID: "vo_test",
|
||||
listOwned: () => Effect.succeed([]),
|
||||
connect: () => Effect.die(new Error("not used")),
|
||||
create: () =>
|
||||
Effect.fail(new GatewayModal.ModalError({ operation: "sandbox.create", cause: new Error("failed") })),
|
||||
terminate: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const backend = Layer.succeed(
|
||||
GatewayBackend.Service,
|
||||
GatewayBackend.Service.of({ connect: () => Effect.die(new Error("not used")) }),
|
||||
)
|
||||
const credentials = Layer.succeed(GatewayCredentials.Service, GatewayCredentials.Service.of({ snapshot: [] }))
|
||||
const events = Layer.succeed(
|
||||
GatewayEvents.Service,
|
||||
GatewayEvents.Service.of({
|
||||
start: Effect.void,
|
||||
watch: () => Effect.void,
|
||||
watchControl: () => Effect.void,
|
||||
publish: () => Effect.void,
|
||||
subscribe: Effect.die(new Error("not used")),
|
||||
}),
|
||||
)
|
||||
const dependencies = Layer.mergeAll(registry, modal, backend, credentials, events, FetchHttpClient.layer)
|
||||
const provision = GatewayProvision.layer({ root: "/persist/project", upstreamPassword: "secret" }).pipe(
|
||||
Layer.provide(dependencies),
|
||||
)
|
||||
const runtime = ManagedRuntime.make(Layer.merge(dependencies, provision))
|
||||
|
||||
try {
|
||||
const result = await runtime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const provision = yield* GatewayProvision.Service
|
||||
const exit = yield* provision.create({}).pipe(Effect.exit)
|
||||
const registry = yield* GatewayRegistry.Service
|
||||
return { exit, workspaces: yield* registry.listWorkspaces }
|
||||
}),
|
||||
)
|
||||
expect(result.exit._tag).toBe("Failure")
|
||||
expect(result.workspaces).toEqual([])
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Effect, Exit, Layer } from "effect"
|
||||
import { mkdtemp, rm } from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { GatewayDatabase } from "../src/database"
|
||||
import { GatewayModal } from "../src/modal"
|
||||
import { GatewayReconcile } from "../src/reconcile"
|
||||
import { GatewayRegistry } from "../src/registry"
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function databasePath() {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-gateway-"))
|
||||
temporaryDirectories.push(directory)
|
||||
return path.join(directory, "gateway.db")
|
||||
}
|
||||
|
||||
function layer(database: string) {
|
||||
return GatewayRegistry.layer.pipe(Layer.provide(GatewayDatabase.layer({ path: database })))
|
||||
}
|
||||
|
||||
function run<A, E>(database: string, effect: Effect.Effect<A, E, GatewayRegistry.Service>) {
|
||||
return Effect.runPromise(effect.pipe(Effect.provide(layer(database)), Effect.scoped))
|
||||
}
|
||||
|
||||
describe("GatewayRegistry", () => {
|
||||
test("preserves installation and workspace identity across restarts", async () => {
|
||||
const database = await databasePath()
|
||||
const created = await run(
|
||||
database,
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* GatewayRegistry.Service
|
||||
const installationID = yield* registry.installationID
|
||||
const workspace = yield* registry.createWorkspace({ directory: "/persist/project" })
|
||||
yield* registry.registerSession({
|
||||
id: "ses_first",
|
||||
workspaceID: workspace.id,
|
||||
projectID: "project",
|
||||
timeCreated: 1,
|
||||
timeUpdated: 1,
|
||||
})
|
||||
return { installationID, workspace }
|
||||
}),
|
||||
)
|
||||
|
||||
const restored = await run(
|
||||
database,
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* GatewayRegistry.Service
|
||||
return {
|
||||
installationID: yield* registry.installationID,
|
||||
workspaces: yield* registry.listWorkspaces,
|
||||
session: yield* registry.findSession("ses_first"),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
expect(restored.installationID).toBe(created.installationID)
|
||||
expect(restored.workspaces).toEqual([created.workspace])
|
||||
expect(restored.session?.workspaceID).toBe(created.workspace.id)
|
||||
})
|
||||
|
||||
test("registers multiple sessions in one workspace and rejects cross-workspace reuse", async () => {
|
||||
const database = await databasePath()
|
||||
const result = await run(
|
||||
database,
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* GatewayRegistry.Service
|
||||
const first = yield* registry.createWorkspace({ directory: "/persist/project" })
|
||||
const second = yield* registry.createWorkspace({ directory: "/persist/project" })
|
||||
yield* registry.registerSession({
|
||||
id: "ses_root",
|
||||
workspaceID: first.id,
|
||||
projectID: "same-project",
|
||||
timeCreated: 1,
|
||||
timeUpdated: 1,
|
||||
})
|
||||
yield* registry.registerSession({
|
||||
id: "ses_child",
|
||||
workspaceID: first.id,
|
||||
projectID: "same-project",
|
||||
parentID: "ses_root",
|
||||
timeCreated: 2,
|
||||
timeUpdated: 2,
|
||||
})
|
||||
const conflict = yield* registry
|
||||
.registerSession({
|
||||
id: "ses_root",
|
||||
workspaceID: second.id,
|
||||
projectID: "same-project",
|
||||
timeCreated: 1,
|
||||
timeUpdated: 3,
|
||||
})
|
||||
.pipe(Effect.exit)
|
||||
return { first, sessions: yield* registry.listSessions, conflict }
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.sessions.map((session) => session.id)).toEqual(["ses_child", "ses_root"])
|
||||
expect(result.sessions.every((session) => session.workspaceID === result.first.id)).toBe(true)
|
||||
expect(Exit.isFailure(result.conflict)).toBe(true)
|
||||
})
|
||||
|
||||
test("reconciles owned sandboxes and quarantines unknown workspaces", async () => {
|
||||
const database = await databasePath()
|
||||
const result = await run(
|
||||
database,
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* GatewayRegistry.Service
|
||||
const installationID = yield* registry.installationID
|
||||
const workspace = yield* registry.createWorkspace({ directory: "/persist/project" })
|
||||
yield* registry.registerSandbox({
|
||||
id: "sb_old",
|
||||
workspaceID: workspace.id,
|
||||
generation: 1,
|
||||
status: "running",
|
||||
timeCreated: 1,
|
||||
})
|
||||
const unknown = Workspace.ID.create()
|
||||
const modal = GatewayModal.Service.of({
|
||||
appID: "ap_test",
|
||||
volumeID: "vo_test",
|
||||
connect: () => Effect.die(new Error("not used")),
|
||||
create: () => Effect.die(new Error("not used")),
|
||||
terminate: () => Effect.die(new Error("not used")),
|
||||
listOwned: () =>
|
||||
Effect.succeed([
|
||||
{
|
||||
id: "sb_current",
|
||||
tags: {
|
||||
[GatewayModal.GatewayTag]: installationID,
|
||||
[GatewayModal.WorkspaceTag]: workspace.id,
|
||||
[GatewayModal.GenerationTag]: "2",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sb_unknown",
|
||||
tags: {
|
||||
[GatewayModal.GatewayTag]: installationID,
|
||||
[GatewayModal.WorkspaceTag]: unknown,
|
||||
[GatewayModal.GenerationTag]: "1",
|
||||
},
|
||||
},
|
||||
]),
|
||||
})
|
||||
const summary = yield* GatewayReconcile.run().pipe(Effect.provideService(GatewayModal.Service, modal))
|
||||
return {
|
||||
summary,
|
||||
sandboxes: yield* registry.listSandboxes,
|
||||
quarantined: yield* registry.listQuarantined,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.summary).toEqual({ discovered: 2, registered: 1, quarantined: 1 })
|
||||
expect(result.sandboxes.map((sandbox) => [sandbox.id, sandbox.status])).toEqual([
|
||||
["sb_old", "missing"],
|
||||
["sb_current", "running"],
|
||||
])
|
||||
expect(result.quarantined).toHaveLength(1)
|
||||
expect(result.quarantined[0]?.sandboxID).toBe("sb_unknown")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user