diff --git a/bun.lock b/bun.lock index ce22f86c5e..b99ff61d93 100644 --- a/bun.lock +++ b/bun.lock @@ -678,7 +678,6 @@ "@opencode-ai/util": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:", - "modal": "0.9.0", }, "devDependencies": { "@tsconfig/bun": "catalog:", @@ -1537,18 +1536,6 @@ "@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=="], @@ -1759,10 +1746,6 @@ "@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.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="], @@ -1857,8 +1840,6 @@ "@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=="], "@jsx-email/all": ["@jsx-email/all@2.2.3", "", { "dependencies": { "@jsx-email/body": "1.0.2", "@jsx-email/button": "1.0.4", "@jsx-email/column": "1.0.3", "@jsx-email/container": "1.0.2", "@jsx-email/font": "1.0.3", "@jsx-email/head": "1.0.2", "@jsx-email/heading": "1.0.2", "@jsx-email/hr": "1.0.2", "@jsx-email/html": "1.0.2", "@jsx-email/img": "1.0.2", "@jsx-email/link": "1.0.2", "@jsx-email/markdown": "2.0.4", "@jsx-email/preview": "1.0.2", "@jsx-email/render": "1.1.1", "@jsx-email/row": "1.0.2", "@jsx-email/section": "1.0.2", "@jsx-email/tailwind": "2.4.4", "@jsx-email/text": "1.0.2" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-OBvLe/hVSQc0LlMSTJnkjFoqs3bmxcC4zpy/5pT5agPCSKMvAKQjzmsc2xJ2wO73jSpRV1K/g38GmvdCfrhSoQ=="], @@ -3345,8 +3326,6 @@ "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=="], @@ -3587,10 +3566,6 @@ "caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="], - "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@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], @@ -4623,8 +4598,6 @@ "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=="], @@ -4849,8 +4822,6 @@ "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=="], @@ -4889,10 +4860,6 @@ "nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="], - "nice-grpc": ["nice-grpc@2.1.16", "", { "dependencies": { "@grpc/grpc-js": "^1.14.0", "abort-controller-x": "^0.5.0", "nice-grpc-common": "^2.0.3" } }, "sha512-Cl3Pn00212Hl8/U6bpgMxmhZj5lyv3nWoJov4cd3FjWarktrMHP4DNvSjCnDwkMWYx4W1tyscEia4JX6Y4GVCQ=="], - - "nice-grpc-common": ["nice-grpc-common@2.0.3", "", { "dependencies": { "ts-error": "^1.0.6" } }, "sha512-MEhnD3JMah0mgyivpb9hpRDbOBuXBxI/TVO+OK1h6rC97WM42HsPMR+zzRNQ0C5BqYJTw1nyWiQRD0DucO+pjQ=="], - "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=="], "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], @@ -5695,8 +5662,6 @@ "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=="], @@ -6657,8 +6622,6 @@ "builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], - "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=="], @@ -6807,8 +6770,6 @@ "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.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "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-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="], "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=="], diff --git a/packages/sdk-next/README.md b/packages/sdk-next/README.md index c991008517..92f6601e50 100644 --- a/packages/sdk-next/README.md +++ b/packages/sdk-next/README.md @@ -36,7 +36,15 @@ const program = Effect.gen(function* () { return yield* opencode.sessions.get({ sessionID }) }) -yield * program.pipe(Effect.provide(OpenCode.layer)) +yield * program.pipe(Effect.provide(OpenCode.layer())) ``` -`OpenCode.layer` adapts the silent default `OpenCode.create()` for dependency injection; use `OpenCode.layerWith(options)` to configure the host. +`OpenCode.layer(options)` adapts the scoped `OpenCode.create(options)` convenience constructor for dependency injection. + +Workspace providers are host infrastructure configured when the SDK is constructed. Workspace lifecycle operations remain on the typed facade: + +```ts +const opencode = yield * OpenCode.create({ workspaceProviders: { modal: modalWorkspaceProvider } }) +const workspace = yield * opencode.workspace.create({ provider: "modal" }) +yield * opencode.workspace.destroy({ workspaceID: workspace.id }) +``` diff --git a/packages/sdk-next/src/index.ts b/packages/sdk-next/src/index.ts index 3b23b4dda7..caf97183cd 100644 --- a/packages/sdk-next/src/index.ts +++ b/packages/sdk-next/src/index.ts @@ -27,3 +27,4 @@ export { Session } from "@opencode-ai/schema/session" export { SessionInbox } from "@opencode-ai/schema/session-inbox" export { SessionMessage } from "@opencode-ai/schema/session-message" export { Skill } from "@opencode-ai/schema/skill" +export { Workspace } from "@opencode-ai/schema/workspace" diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index 96a2faf754..a7e9716ba3 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -1,6 +1,8 @@ import { OpenCode } from "@opencode-ai/client/effect" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { SessionRestart } from "@opencode-ai/core/session/execution/restart" +import { Workspace } from "@opencode-ai/core/workspace" +import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver" import type { ServerFetch } from "@opencode-ai/server/fetch" import { createEmbeddedRoutes } from "@opencode-ai/server/routes" import type { ServerOptions } from "@opencode-ai/server/options" @@ -13,13 +15,14 @@ import type { LogOptions } from "./logging" export type CreateOptions = ServerOptions & { readonly log?: LogOptions + readonly workspaceProviders?: Readonly> } /** Host hooks for embedding opencode on a non-default runtime profile (e.g. workerd). */ export type EmbedOptions = ServerFetch.BootOptions export const create = Effect.fn("OpenCode.create")(function* (options: CreateOptions = {}, embed: EmbedOptions = {}) { - const { log, ...server } = options + const { log, workspaceProviders, ...server } = options const runtime = yield* Effect.acquireRelease( Effect.sync(() => ManagedRuntime.make( @@ -29,7 +32,9 @@ export const create = Effect.fn("OpenCode.create")(function* (options: CreateOpt app: { ...server.app, name: server.app?.name ?? "sdk" }, database: { path: ":memory:", ...server.database }, }, - embed.overrides ?? [], + workspaceProviders + ? [...(embed.overrides ?? []), [WorkspaceDriver.node, WorkspaceDriver.registryNode(workspaceProviders)]] + : embed.overrides, ).pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(Logging.layer(log))), ), ), @@ -42,6 +47,7 @@ export const create = Effect.fn("OpenCode.create")(function* (options: CreateOpt // durably recorded by the execution layer. yield* Effect.forkDetach(Context.get(context, SessionRestart.Service).resumeSuspendedSessions) const plugins = Context.get(context, SdkPlugins.Service) + const workspace = Context.get(context, Workspace.Service) const router = Context.get(context, HttpRouter.HttpRouter) const handler = HttpEffect.toWebHandlerWith( Logging.context(context), @@ -56,6 +62,10 @@ export const create = Effect.fn("OpenCode.create")(function* (options: CreateOpt ...client, sessions: client.session, events: client.event, + workspace: { + create: ({ provider }: { readonly provider: string }) => workspace.create(provider), + destroy: ({ workspaceID }: { readonly workspaceID: Workspace.ID }) => workspace.destroy(workspaceID), + }, // The embedded host contributes plugins through the ordinary discovery flow: // each plugin's `effect` runs inside every Location with the real // `PluginContext`, so `ctx.agent.transform` and every other hook behave exactly @@ -69,4 +79,4 @@ export type Interface = Effect.Success> export class Service extends Context.Service()("@opencode-ai/sdk-next/OpenCode") {} -export const layer = Layer.effect(Service, create()) +export const layer = (options: CreateOptions = {}) => Layer.effect(Service, create(options)) diff --git a/packages/sdk-next/src/workerd.ts b/packages/sdk-next/src/workerd.ts index c32f1de8a8..b5c397bc4f 100644 --- a/packages/sdk-next/src/workerd.ts +++ b/packages/sdk-next/src/workerd.ts @@ -3,11 +3,8 @@ export * as OpenCodeWorkerd from "./workerd" import { ServerWorkerd } from "@opencode-ai/server/workerd" import { Layer } from "effect" import * as OpenCode from "./opencode" -import type { LogOptions } from "./logging" -export type CreateOptions = ServerWorkerd.Options & { - readonly log?: LogOptions -} +export type CreateOptions = ServerWorkerd.Options & Pick /** * Boots the embedded opencode SDK on the workerd runtime profile: the full @@ -23,7 +20,10 @@ export type CreateOptions = ServerWorkerd.Options & { * session operations plus the live `events.subscribe()` stream — served over * an in-process fetch transport, so no request leaves the isolate. */ -export const create = ({ log, ...options }: CreateOptions) => - OpenCode.create({ ...ServerWorkerd.serverOptions(options), log }, { overrides: ServerWorkerd.replacements(options) }) +export const create = ({ log, workspaceProviders, ...options }: CreateOptions) => + OpenCode.create( + { ...ServerWorkerd.serverOptions(options), log, workspaceProviders }, + { overrides: ServerWorkerd.replacements(options) }, + ) export const layer = (options: CreateOptions) => Layer.effect(OpenCode.Service, create(options)) diff --git a/packages/sdk-next/test/contract-identity.test.ts b/packages/sdk-next/test/contract-identity.test.ts index ddf10ac58e..1e3c69a6c9 100644 --- a/packages/sdk-next/test/contract-identity.test.ts +++ b/packages/sdk-next/test/contract-identity.test.ts @@ -29,6 +29,7 @@ test("re-exports canonical contracts directly from Schema", () => { expect(SDK.Model).toBe(Model) expect(SDK.WebSearch).toBe(WebSearch) expect(SDK.Session).toBe(Session) + expect(SDK.Workspace).toBe(Workspace) expect(Object.keys(SDK).sort()).toEqual([ "AbsolutePath", "Agent", @@ -58,6 +59,7 @@ test("re-exports canonical contracts directly from Schema", () => { "Skill", "Tool", "WebSearch", + "Workspace", ]) }) diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 9bf9e70048..a1292203ac 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -1,6 +1,8 @@ import fs from "fs/promises" import path from "path" import { expect } from "bun:test" +import { makeMemoryDriver } from "@opencode-ai/core/environment/index" +import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver" import { Deferred, Effect, Latch, Layer, Option, Ref, Schema, Stream } from "effect" import { testEffect } from "../../core/test/lib/effect" import { tmpdir } from "../../core/test/fixture/tmpdir" @@ -429,6 +431,58 @@ it.live("embedded client is available as a Layer service", () => const opencode = yield* fixture.sdk.OpenCode.Service const created = yield* opencode.sessions.create({ id, location: location(fixture) }) expect(created.id).toBe(id) - }).pipe(Effect.provide(fixture.sdk.OpenCode.layer)) + }).pipe(Effect.provide(fixture.sdk.OpenCode.layer())) }), ) + +it.live("configures workspace providers through the SDK facade", () => + withEmbedded("opencode-embedded-workspace-", (fixture) => + Effect.gen(function* () { + const calls: Array<{ readonly operation: string; readonly workspaceID: string }> = [] + const driver = WorkspaceDriver.make({ + create: ({ workspaceID }) => { + calls.push({ operation: "create", workspaceID }) + return Effect.succeed({ binding: { externalID: workspaceID } }) + }, + connect: ({ workspaceID }) => { + calls.push({ operation: "connect", workspaceID }) + return Effect.succeed(makeMemoryDriver()) + }, + suspendForIdle: () => Effect.void, + destroy: ({ workspaceID }) => { + calls.push({ operation: "destroy", workspaceID }) + return Effect.void + }, + }) + const opencode = yield* fixture.sdk.OpenCode.create({ workspaceProviders: { fake: driver } }) + const workspace = yield* opencode.workspace.create({ provider: "fake" }) + + expect(workspace.provider).toBe("fake") + expect(workspace.binding).toEqual({ externalID: workspace.id }) + expect(calls).toEqual([{ operation: "create", workspaceID: workspace.id }]) + + const workspaceLocation = fixture.sdk.Location.Ref.make({ + directory: fixture.sdk.AbsolutePath.make("/"), + workspaceID: workspace.id, + }) + const session = yield* opencode.sessions.create({ location: workspaceLocation }) + expect(session.location.workspaceID).toBe(workspace.id) + + yield* opencode.workspace.destroy({ workspaceID: workspace.id }) + expect(calls.map((call) => call.operation)).toEqual(["create", "destroy"]) + expect((yield* opencode.workspace.destroy({ workspaceID: workspace.id }).pipe(Effect.flip))._tag).toBe( + "Workspace.NotFound", + ) + }), + ), +) + +it.live("preserves unknown workspace provider errors", () => + withEmbedded("opencode-embedded-workspace-provider-", (fixture) => + Effect.gen(function* () { + const opencode = yield* fixture.sdk.OpenCode.create() + const error = yield* opencode.workspace.create({ provider: "missing" }).pipe(Effect.flip) + expect(error).toEqual(new WorkspaceDriver.ProviderNotFound({ provider: "missing" })) + }), + ), +) diff --git a/packages/server/package.json b/packages/server/package.json index 427b2619b7..62c775a9c2 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -21,8 +21,7 @@ "@opencode-ai/simulation": "workspace:*", "@opencode-ai/util": "workspace:*", "drizzle-orm": "catalog:", - "effect": "catalog:", - "modal": "0.9.0" + "effect": "catalog:" }, "devDependencies": { "@tsconfig/bun": "catalog:", diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index b91ff0bd7d..d3d22b59ab 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -27,7 +27,7 @@ import { SessionRestart } from "@opencode-ai/core/session/execution/restart" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { WellKnown } from "@opencode-ai/core/wellknown" -import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver" +import { Workspace } from "@opencode-ai/core/workspace" import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { HttpRouter } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" @@ -43,9 +43,8 @@ import { formLocationLayer } from "./middleware/form-location" import { sessionLocationLayer } from "./middleware/session-location" import { ServerInfo } from "./server-info" import type { ServerOptions } from "./options" -import { modalWorkspaceDriver, provider as modalProvider } from "./workspace/modal-workspace" -const applicationServices = LayerNode.group([ +const applicationServiceNodes = [ Global.node, Database.node, Bus.node, @@ -64,7 +63,9 @@ const applicationServices = LayerNode.group([ PtyEnvironment.node, LocationServiceMap.node, SessionRestart.node, -]) +] as const +const applicationServices = LayerNode.group(applicationServiceNodes) +const embeddedApplicationServices = LayerNode.group([...applicationServiceNodes, Workspace.node]) export function createRoutes( options: ServerOptions = {}, @@ -78,11 +79,12 @@ export function createRoutes( options, serviceURLs, overrides, + false, ) } export function createEmbeddedRoutes(options: ServerOptions = {}, overrides: LayerNode.Replacements = []) { - return makeRoutes(ServerAuth.Config.configLayer({ password: Option.none() }), options, () => [], overrides) + return makeRoutes(ServerAuth.Config.configLayer({ password: Option.none() }), options, () => [], overrides, true) } function makeRoutes( @@ -91,6 +93,7 @@ function makeRoutes( serviceURLs: () => ReadonlyArray, // Runtime-profile replacements (e.g. workerd) applied after the standard set, so later entries win. overrides: LayerNode.Replacements, + embedded: boolean, ) { const pluginRuntimeCell = PluginRuntime.makeCell() const standard: LayerNode.Replacements = [ @@ -124,10 +127,6 @@ function makeRoutes( ], [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)], [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)], - [ - WorkspaceDriver.node, - WorkspaceDriver.registryNode({ [modalProvider]: modalWorkspaceDriver({ app: "opencode-workspaces" }) }), - ], ] const replacements: LayerNode.Replacements = [...standard, ...overrides] const serviceLayer = options.simulation @@ -135,10 +134,13 @@ function makeRoutes( Effect.gen(function* () { const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend")) const simulation = yield* simulationReplacements({ version: App.make(options.app).version }) - return AppNodeBuilder.build(applicationServices, [...replacements, ...simulation]) + return AppNodeBuilder.build(embedded ? embeddedApplicationServices : applicationServices, [ + ...replacements, + ...simulation, + ]) }), ) - : AppNodeBuilder.build(applicationServices, replacements) + : AppNodeBuilder.build(embedded ? embeddedApplicationServices : applicationServices, replacements) return serviceLayer.pipe( Layer.flatMap((context) => { const services = Layer.succeedContext(context) diff --git a/packages/server/src/workspace/modal-workspace.ts b/packages/server/src/workspace/modal-workspace.ts deleted file mode 100644 index eb6769ddf1..0000000000 --- a/packages/server/src/workspace/modal-workspace.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver" -import { Effect, Option, Schema } from "effect" -import type { App, Image, ModalClient, ModalClientParams, Sandbox } from "modal" -import { createModalSandboxWithClient, makeModalDriver, type ModalImageSpec, openModalClient } from "./modal" - -export const provider = "modal" - -export const ModalBinding = Schema.Struct({ - sandboxId: Schema.optional(Schema.String), - snapshotImageId: Schema.optional(Schema.String), -}) -export type ModalBinding = typeof ModalBinding.Type - -export interface ModalWorkspaceOptions { - readonly app: string - readonly client?: ModalClientParams - readonly image?: ModalImageSpec -} - -export const modalWorkspaceDriver = (options: ModalWorkspaceOptions): WorkspaceDriver.Interface => { - const decodeBinding = Schema.decodeUnknownOption(ModalBinding) - let clientPromise: Promise | undefined - let appPromise: Promise | undefined - // The SDK client and app handle are shared for the process lifetime of this driver. - const client = () => (clientPromise ??= openModalClient(options.client)) - const app = () => - (appPromise ??= client().then((value) => value.apps.fromName(options.app, { createIfMissing: true }))) - - const attempt = (run: () => Promise) => - Effect.tryPromise({ try: run, catch: (cause) => new WorkspaceDriver.Error({ cause }) }) - - const binding = (value: WorkspaceDriver.Binding): ModalBinding => Option.getOrElse(decodeBinding(value), () => ({})) - - const live = async (lookup: () => Promise) => { - const { NotFoundError } = await import("modal") - const sandbox = await lookup().catch((error) => { - if (error instanceof NotFoundError) return undefined - throw error - }) - if (sandbox && (await sandbox.poll()) === null) return sandbox - } - - const findLive = async (modalClient: ModalClient, value: ModalBinding, workspaceID: string) => { - if (value.sandboxId) { - const sandboxID = value.sandboxId - const sandbox = await live(() => modalClient.sandboxes.fromId(sandboxID)) - if (sandbox) return sandbox - } - // Name fallback is valid only before the first snapshot; afterward a live named sandbox is stale by design. - if (value.snapshotImageId) return - return live(() => modalClient.sandboxes.fromName(options.app, workspaceID)) - } - - const createSandbox = async (workspaceID: string, image?: Image) => { - const { AlreadyExistsError } = await import("modal") - const modalClient = await client() - return createModalSandboxWithClient( - modalClient, - await app(), - { - image: options.image, - sandbox: { - name: workspaceID, - tags: { workspace: workspaceID }, - timeoutMs: 24 * 60 * 60 * 1000, - }, - }, - image, - ).catch((error) => { - if (error instanceof AlreadyExistsError) return modalClient.sandboxes.fromName(options.app, workspaceID) - throw error - }) - } - - const deleteImage = (modalClient: ModalClient, imageID?: string) => - imageID ? attempt(() => modalClient.images.delete(imageID)).pipe(Effect.ignore) : Effect.void - - const terminate = (sandbox?: Sandbox) => - sandbox ? attempt(() => sandbox.terminate({ wait: true })).pipe(Effect.ignore) : Effect.void - - return WorkspaceDriver.make({ - create: ({ workspaceID }) => - attempt(async () => { - const sandbox = await createSandbox(workspaceID) - return { binding: { sandboxId: sandbox.sandboxId } } - }), - connect: ({ workspaceID, binding: value, saveBinding }) => - Effect.gen(function* () { - const modalBinding = binding(value) - const modalClient = yield* attempt(client) - const sandbox = yield* attempt(async () => { - const existing = await findLive(modalClient, modalBinding, workspaceID) - const image = - existing || !modalBinding.snapshotImageId - ? undefined - : await modalClient.images.fromId(modalBinding.snapshotImageId) - return existing ?? createSandbox(workspaceID, image) - }) - if (modalBinding.sandboxId !== sandbox.sandboxId) { - yield* saveBinding({ ...modalBinding, sandboxId: sandbox.sandboxId }) - } - return makeModalDriver(sandbox) - }), - suspendForIdle: ({ workspaceID, binding: value, saveBinding }) => - Effect.gen(function* () { - const modalBinding = binding(value) - const modalClient = yield* attempt(client) - const sandbox = yield* attempt(() => findLive(modalClient, modalBinding, workspaceID)) - if (!sandbox) return - const snapshot = yield* attempt(() => sandbox.snapshotFilesystem({ ttlMs: null })) - yield* saveBinding({ snapshotImageId: snapshot.imageId }) - yield* Effect.all([deleteImage(modalClient, modalBinding.snapshotImageId), terminate(sandbox)], { - concurrency: "unbounded", - discard: true, - }) - }), - destroy: ({ workspaceID, binding: value }) => - Effect.gen(function* () { - const modalBinding = binding(value) - const modalClient = yield* attempt(client) - const sandbox = yield* attempt(() => findLive(modalClient, modalBinding, workspaceID)) - yield* Effect.all([terminate(sandbox), deleteImage(modalClient, modalBinding.snapshotImageId)], { - concurrency: "unbounded", - discard: true, - }) - }), - }) -} diff --git a/packages/server/src/workspace/modal.ts b/packages/server/src/workspace/modal.ts deleted file mode 100644 index 7a8c4b87bf..0000000000 --- a/packages/server/src/workspace/modal.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { Effect, Sink, Stream } from "effect" -import { systemError } from "effect/PlatformError" -import type { Command, KillOptions } from "effect/unstable/process/ChildProcess" -import { ExitCode, make, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner" -import type { Driver } from "@opencode-ai/core/environment/index" -import type { App, Image, ModalClient, ModalClientParams, Sandbox, SandboxCreateParams } from "modal" - -const INNER_WRAPPER = ` -pidfile=$1 -shift -printf "%s" "$$" > "$pidfile" -trap 'rm -f -- "$pidfile"' EXIT -"$@" -` - -// Modal's VM runtime accepts process-group signals without delivering them -// (kill(-pgid) returns 0 and nothing dies; direct-pid signals work), so the -// group is enumerated from /proc and each member is signalled directly. The -// second pass catches children forked between scan and signal. -const KILL = ` -pidfile=$1 -sig=$2 -i=0 -while [ ! -s "$pidfile" ] && [ "$i" -lt 250 ]; do sleep 0.02; i=$((i + 1)); done -[ -s "$pidfile" ] || exit 47 -target=$(cat "$pidfile") -pass=0 -while [ "$pass" -lt 2 ]; do - for stat in /proc/[0-9]*/stat; do - [ -e "$stat" ] || continue - pid=\${stat#/proc/} - pid=\${pid%/stat} - set -- $(sed "s/.*) //" "$stat" 2>/dev/null) - if [ "\${3:-}" = "$target" ]; then - /bin/kill "-$sig" "$pid" 2>/dev/null || true - fi - done - pass=$((pass + 1)) -done -` - -export interface ModalImageSpec { - readonly registry: string - readonly dockerfileCommands: ReadonlyArray -} - -export interface ModalSandboxCreateOptions { - readonly image?: ModalImageSpec - readonly sandbox?: SandboxCreateParams -} - -export interface ModalSandboxOptions extends ModalSandboxCreateOptions { - readonly app: string - readonly client?: ModalClientParams -} - -/** - * Ubuntu supplies the GNU coreutils and findutils required by the derived Files - * scripts. Busybox images do not satisfy the Environment contract. - */ -export const ubuntuImage: ModalImageSpec = { - registry: "ubuntu:24.04", - dockerfileCommands: [ - "RUN apt-get update && apt-get install -y --no-install-recommends git bash ripgrep ca-certificates coreutils findutils util-linux", - ], -} - -/** Creates a Modal sandbox lazily, keeping the SDK off the server startup path when Modal is unused. */ -export const createModalSandbox = async (options: ModalSandboxOptions) => { - const client = await openModalClient(options.client) - const app = await client.apps.fromName(options.app, { createIfMissing: true }) - const sandbox = await createModalSandboxWithClient(client, app, { - image: options.image, - sandbox: options.sandbox, - }) - return { - driver: makeModalDriver(sandbox), - sandbox, - terminate: () => sandbox.terminate(), - } -} - -export const openModalClient = async (params?: ModalClientParams) => { - const { ModalClient } = await import("modal") - return new ModalClient(params) -} - -export const createModalSandboxWithClient = async ( - client: ModalClient, - app: App, - options: ModalSandboxCreateOptions, - existingImage?: Image, -) => { - const imageSpec = options.image ?? ubuntuImage - const image = - existingImage ?? - client.images.fromRegistry(imageSpec.registry).dockerfileCommands([...imageSpec.dockerfileCommands]) - // Always Modal's Full-VM runtime (beta, enabled per account): a real kernel - // with real device nodes, so workspaces can run Docker and other - // kernel-dependent workloads. Costs versus gVisor, measured Aug 2026: - // per-exec floor ~285-535ms versus ~90-165ms, and filesystem snapshots only - // (no memory snapshots — acceptable; fs-snapshot is the persistence design). - return client.sandboxes.create(app, image, { - ...options.sandbox, - experimentalOptions: { ...options.sandbox?.experimentalOptions, vm_runtime: true }, - }) -} - -/** - * Adapts Modal exec to the Environment driver. Files intentionally has no native - * overrides: exec latency dominates payload work (VM runtime floor measured - * ~285-535ms per exec, Aug 2026), so the derived exec defaults are the simplest - * implementation with no measured loss. - * - * Modal cannot signal a ContainerProcess. Each command therefore starts a new - * process group and records its leader in a unique pid file; kill runs a second - * sandbox command that enumerates that group from /proc and signals each member - * directly (see KILL). Pid files are removed best-effort. - */ -export const makeModalDriver = (sandbox: Sandbox): Driver => { - const spawn = Effect.fnUntraced(function* (command: Command) { - if (command._tag === "PipedCommand") { - return yield* Effect.fail(spawnError("spawn", "piped commands unsupported")) - } - if (command.options.additionalFds) { - return yield* Effect.fail(spawnError("spawn", "additional file descriptors unsupported")) - } - - const pidFile = `/tmp/opencode-process-${crypto.randomUUID()}.pid` - const env = compact(command.options.env) - const isolatedEnv = - command.options.extendEnv === false || (!command.options.extendEnv && command.options.env !== undefined) - const argv = isolatedEnv ? ["env", "-i", ...Object.entries(env ?? {}).map(([key, value]) => `${key}=${value}`)] : [] - const process = yield* Effect.tryPromise({ - try: () => - sandbox.exec( - ["setsid", "--wait", "sh", "-c", INNER_WRAPPER, "sh", pidFile, ...argv, command.command, ...command.args], - { - mode: "binary", - stdout: "pipe", - stderr: "pipe", - workdir: command.options.cwd, - env: isolatedEnv ? undefined : env, - }, - ), - catch: (cause) => spawnError("spawn", undefined, cause), - }) - - const onError = (cause: unknown) => spawnError("process", undefined, cause) - let exited = false - const writer = process.stdin.getWriter() - let closingStdin: Promise | undefined - const waited = process.wait().then((code) => { - exited = true - if (!closingStdin) writer.releaseLock() - return code - }) - const exitCode = Effect.tryPromise({ try: () => waited, catch: onError }).pipe(Effect.map(ExitCode)) - const kill = (options?: KillOptions) => { - if (exited) return Effect.void - return Effect.tryPromise({ - try: async () => { - const killer = await sandbox.exec(["sh", "-c", KILL, "sh", pidFile, options?.killSignal ?? "SIGTERM"], { - stdout: "pipe", - stderr: "pipe", - }) - const code = await killer.wait() - if (code !== 0) throw new Error(`modal kill exited ${code}`) - }, - catch: onError, - }).pipe(Effect.andThen(exitCode), Effect.asVoid) - } - - yield* Effect.addFinalizer(() => kill(command.options).pipe(Effect.ignore)) - - const closeStdin = Effect.tryPromise({ - try: () => (closingStdin ??= writer.close().finally(() => writer.releaseLock())), - catch: onError, - }) - const writeStdin = Sink.forEach((chunk: Uint8Array) => - Effect.tryPromise({ try: () => writer.write(chunk), catch: onError }), - ) - const inputConfig = command.options.stdin - const inputOptions = - inputConfig !== undefined && typeof inputConfig === "object" && !Stream.isStream(inputConfig) - ? inputConfig - : undefined - const input = inputOptions?.stream ?? inputConfig - const stdin = inputOptions?.endOnDone === false ? writeStdin : writeStdin.pipe(Sink.ensuring(closeStdin)) - if (input === "ignore") { - yield* closeStdin - } - if (Stream.isStream(input)) { - yield* Effect.forkScoped(Stream.run(input, stdin)) - } - - const stdout = Stream.fromReadableStream({ evaluate: () => process.stdout, onError }) - const stderr = Stream.fromReadableStream({ evaluate: () => process.stderr, onError }) - return makeHandle({ - pid: ProcessId(crypto.getRandomValues(new Uint32Array(1))[0]), - exitCode, - isRunning: Effect.sync(() => !exited), - kill, - stdin, - stdout, - stderr, - all: Stream.merge(stdout, stderr), - getInputFd: () => Sink.fail(spawnError("getInputFd", "unsupported")), - getOutputFd: () => Stream.fail(spawnError("getOutputFd", "unsupported")), - unref: Effect.succeed(Effect.void), - }) - }) - - return { spawner: make(spawn) } -} - -const compact = (env: Record | undefined) => { - if (!env) return undefined - return Object.fromEntries(Object.entries(env).flatMap(([key, value]) => (value === undefined ? [] : [[key, value]]))) -} - -const spawnError = (method: string, description?: string, cause?: unknown) => - systemError({ _tag: "Unknown", module: "ModalDriver", method, description, cause }) - -export * as ModalDriver from "./modal" diff --git a/packages/server/test/environment-modal.test.ts b/packages/server/test/environment-modal.test.ts deleted file mode 100644 index 3dae4fe53b..0000000000 --- a/packages/server/test/environment-modal.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import fs from "node:fs" -import os from "node:os" -import path from "node:path" -import { afterAll, expect, test } from "bun:test" -import { Effect } from "effect" -import { ChildProcess } from "effect/unstable/process" -import { Failed, makeFiles } from "@opencode-ai/core/environment/index" -import { environmentConformance } from "../../core/test/lib/environment-conformance.js" -import { createModalSandbox } from "../src/workspace/modal" - -const enabled = - !!process.env.OPENCODE_TEST_MODAL && - ((!!process.env.MODAL_TOKEN_ID && !!process.env.MODAL_TOKEN_SECRET) || - fs.existsSync(path.join(os.homedir(), ".modal.toml"))) -const modalTest = enabled ? test : test.skip -const root = `/tmp/opencode-environment-${crypto.randomUUID()}` -const sandbox = enabled - ? createModalSandbox({ - app: "opencode-environment-tests", - sandbox: { timeoutMs: 10 * 60 * 1000 }, - }) - : undefined - -modalTest( - "kills a running modal process", - async () => { - const value = await sandbox! - const started = performance.now() - const exitCode = await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const handle = yield* value.driver.spawner.spawn(ChildProcess.make("sleep", ["300"])) - yield* handle.kill() - return yield* handle.exitCode - }), - ).pipe(Effect.timeout("10 seconds")), - ) - expect(exitCode).not.toBe(0) - expect(performance.now() - started).toBeLessThan(10_000) - }, - 15_000, -) - -environmentConformance( - "modal environment", - () => - Effect.promise(async () => { - const value = await sandbox! - return { - files: makeFiles(value.driver), - root, - symlink: (target, link) => - Effect.scoped(value.driver.spawner.exitCode(ChildProcess.make("ln", ["-s", "--", target, link]))).pipe( - Effect.flatMap((code) => - code === 0 ? Effect.void : Effect.fail(new Failed({ path: link, cause: new Error(`ln exited ${code}`) })), - ), - Effect.mapError((cause) => (cause instanceof Failed ? cause : new Failed({ path: link, cause }))), - ), - } - }), - !enabled, -) - -afterAll(async () => { - if (!sandbox) return - await (await sandbox).terminate() -}) diff --git a/packages/server/test/workspace-modal.test.ts b/packages/server/test/workspace-modal.test.ts deleted file mode 100644 index cc6e54593e..0000000000 --- a/packages/server/test/workspace-modal.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import fs from "node:fs" -import os from "node:os" -import path from "node:path" -import { expect, test } from "bun:test" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { makeFiles } from "@opencode-ai/core/environment/index" -import { Workspace } from "@opencode-ai/core/workspace" -import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver" -import { Effect, Layer } from "effect" -import { TestClock } from "effect/testing" -import { modalWorkspaceDriver, provider } from "../src/workspace/modal-workspace" - -const enabled = - !!process.env.OPENCODE_TEST_MODAL && - ((!!process.env.MODAL_TOKEN_ID && !!process.env.MODAL_TOKEN_SECRET) || - fs.existsSync(path.join(os.homedir(), ".modal.toml"))) - -const testLayer = Layer.provideMerge( - AppNodeBuilder.build(Workspace.configured({ idleThreshold: "1 minute", pollInterval: "1 minute" }), [ - [ - WorkspaceDriver.node, - WorkspaceDriver.registryNode({ [provider]: modalWorkspaceDriver({ app: "opencode-workspace-tests" }) }), - ], - ]), - TestClock.layer(), -) -const modalTest = enabled ? test : test.skip - -modalTest( - "wakes a workspace from its filesystem snapshot", - () => - Effect.runPromise( - Effect.gen(function* () { - const workspace = yield* Workspace.Service - yield* Effect.acquireUseRelease( - workspace.create(provider), - (created) => - Effect.gen(function* () { - const environment = yield* workspace.connect(created.id) - const files = makeFiles(environment) - const file = `/tmp/opencode-workspace-${crypto.randomUUID()}.txt` - yield* files.write(file, new TextEncoder().encode("survived snapshot")) - - yield* TestClock.adjust("2 minutes") - - const restored = yield* files.read(file) - expect(new TextDecoder().decode(restored.bytes)).toBe("survived snapshot") - }), - (created) => workspace.destroy(created.id).pipe(Effect.ignore), - ) - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ), - 180_000, -)