mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 10:59:49 -04:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bb1fc34a5 | |||
| 9a5b015126 | |||
| 38e7d2302c | |||
| e4c603d631 | |||
| eaa196d574 | |||
| d7ecb3af37 | |||
| 13a415b68a | |||
| dee3f88737 | |||
| 39dab7199f | |||
| 4729a3f9ae | |||
| 9f05331ac4 | |||
| 0b3732aa7c | |||
| 248bfb64d4 | |||
| cbec0989e0 | |||
| 9d284040de | |||
| 4e03ad95a7 | |||
| 8d70adab50 | |||
| 8b8e2d97dc | |||
| 716258d97a | |||
| d6ab618155 | |||
| 1fa3d9dd12 | |||
| 92442bb7ff | |||
| 64c82bedec | |||
| 6cbc71a2eb | |||
| 756ac88039 | |||
| a4eeaefd5b | |||
| 6e0066247c | |||
| 65ac161373 | |||
| 9c0883054b | |||
| 79eed3d3a0 | |||
| be74fad688 | |||
| 5d93a35a1d | |||
| 395eb8a9c8 | |||
| 14e7b547c5 | |||
| 97a5497664 | |||
| e544b8927b | |||
| 44481e1ecc | |||
| 5168652082 | |||
| 977a35c222 | |||
| 5d64dc8bfd | |||
| 479821e621 | |||
| 5ce6626a44 | |||
| eef60bcd64 | |||
| f840b3e3a2 | |||
| b1abfd4bd9 | |||
| 71251dd246 | |||
| 8461226ccd | |||
| e1b863dcb3 | |||
| 56405af7d6 | |||
| 05c3866788 | |||
| 3eca11a7da | |||
| 794283057a |
@@ -678,8 +678,10 @@
|
|||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
|
"modal": "0.9.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@opencode-ai/ai": "workspace:*",
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
@@ -1511,6 +1513,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=="],
|
"@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=="],
|
"@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=="],
|
"@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="],
|
||||||
@@ -1719,6 +1733,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=="],
|
"@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=="],
|
"@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=="],
|
"@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="],
|
||||||
@@ -1813,6 +1831,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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
||||||
@@ -3297,6 +3317,8 @@
|
|||||||
|
|
||||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
"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=="],
|
"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=="],
|
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||||
@@ -3537,6 +3559,10 @@
|
|||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
@@ -4569,6 +4595,8 @@
|
|||||||
|
|
||||||
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
"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.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
|
||||||
|
|
||||||
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||||
@@ -4795,6 +4823,8 @@
|
|||||||
|
|
||||||
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
@@ -4833,6 +4863,10 @@
|
|||||||
|
|
||||||
"nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="],
|
"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=="],
|
"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=="],
|
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
|
||||||
@@ -5631,6 +5665,8 @@
|
|||||||
|
|
||||||
"ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="],
|
"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=="],
|
"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=="],
|
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
|
||||||
@@ -6589,6 +6625,8 @@
|
|||||||
|
|
||||||
"builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
|
"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/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=="],
|
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
@@ -6733,6 +6771,8 @@
|
|||||||
|
|
||||||
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export default Runtime.handler(
|
|||||||
const location = await client.location.get({ location: { directory: process.cwd() } })
|
const location = await client.location.get({ location: { directory: process.cwd() } })
|
||||||
const page = await client.session.list({
|
const page = await client.session.list({
|
||||||
directory: location.directory,
|
directory: location.directory,
|
||||||
workspace: location.workspaceID,
|
|
||||||
parentID: null,
|
parentID: null,
|
||||||
order: "desc",
|
order: "desc",
|
||||||
limit: 50,
|
limit: 50,
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export async function runMini(input: MiniCommandInput) {
|
|||||||
) =>
|
) =>
|
||||||
resolveSessionTarget({
|
resolveSessionTarget({
|
||||||
client,
|
client,
|
||||||
location: { directory: next.location.directory, workspace: next.location.workspaceID },
|
location: { directory: next.location.directory },
|
||||||
agent: next.agent,
|
agent: next.agent,
|
||||||
model: next.model
|
model: next.model
|
||||||
? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
|
? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
|
||||||
|
|||||||
@@ -703,7 +703,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
? Promise.resolve(undefined)
|
? Promise.resolve(undefined)
|
||||||
: input.client.form.request
|
: input.client.form.request
|
||||||
.list({
|
.list({
|
||||||
location: { directory: input.location.directory, workspace: input.location.workspaceID },
|
location: { directory: input.location.directory },
|
||||||
})
|
})
|
||||||
.catch(() => undefined),
|
.catch(() => undefined),
|
||||||
])
|
])
|
||||||
@@ -757,7 +757,6 @@ function formRequestOptions(location: LocationRef | undefined): [] | [{ headers:
|
|||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
"x-opencode-directory": encodeURIComponent(location.directory),
|
"x-opencode-directory": encodeURIComponent(location.directory),
|
||||||
...(location.workspaceID ? { "x-opencode-workspace": location.workspaceID } : {}),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
|
|||||||
next.model ??
|
next.model ??
|
||||||
(options.variant
|
(options.variant
|
||||||
? await client.model
|
? await client.model
|
||||||
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
|
.default({ location: { directory: next.location.directory } })
|
||||||
.then((result) => result.data)
|
.then((result) => result.data)
|
||||||
: undefined)
|
: undefined)
|
||||||
const model = selected
|
const model = selected
|
||||||
|
|||||||
@@ -136,7 +136,6 @@ async function latestSession(
|
|||||||
const page = await client.session.list(
|
const page = await client.session.list(
|
||||||
{
|
{
|
||||||
directory: location.directory,
|
directory: location.directory,
|
||||||
workspace: location.workspaceID,
|
|
||||||
parentID: null,
|
parentID: null,
|
||||||
limit: SESSION_PAGE_LIMIT,
|
limit: SESSION_PAGE_LIMIT,
|
||||||
order: "desc",
|
order: "desc",
|
||||||
|
|||||||
@@ -426,14 +426,13 @@ describe("runNonInteractivePrompt", () => {
|
|||||||
const globalOptions = {
|
const globalOptions = {
|
||||||
headers: {
|
headers: {
|
||||||
"x-opencode-directory": "%2Fwork%20tree",
|
"x-opencode-directory": "%2Fwork%20tree",
|
||||||
"x-opencode-workspace": "wrk_1",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
|
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
|
||||||
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
|
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
|
||||||
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
|
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
|
||||||
expect(sdk.form.request.list).toHaveBeenCalledWith({
|
expect(sdk.form.request.list).toHaveBeenCalledWith({
|
||||||
location: { directory: "/work tree", workspace: "wrk_1" },
|
location: { directory: "/work tree" },
|
||||||
})
|
})
|
||||||
expect(sdk.question.list).not.toHaveBeenCalled()
|
expect(sdk.question.list).not.toHaveBeenCalled()
|
||||||
expect(sdk.question.reject).not.toHaveBeenCalled()
|
expect(sdk.question.reject).not.toHaveBeenCalled()
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import type { Effect, Stream } from "effect"
|
|||||||
import type { Location } from "@opencode-ai/schema/location"
|
import type { Location } from "@opencode-ai/schema/location"
|
||||||
import type { Agent } from "@opencode-ai/schema/agent"
|
import type { Agent } from "@opencode-ai/schema/agent"
|
||||||
import type { Plugin } from "@opencode-ai/schema/plugin"
|
import type { Plugin } from "@opencode-ai/schema/plugin"
|
||||||
import type { Workspace } from "@opencode-ai/schema/workspace"
|
|
||||||
import type { Session } from "@opencode-ai/schema/session"
|
import type { Session } from "@opencode-ai/schema/session"
|
||||||
import type { AbsolutePath } from "@opencode-ai/schema/schema"
|
import type { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||||
import type { Project } from "@opencode-ai/schema/project"
|
import type { Project } from "@opencode-ai/schema/project"
|
||||||
import type { RelativePath } from "@opencode-ai/schema/schema"
|
import type { RelativePath } from "@opencode-ai/schema/schema"
|
||||||
import type { Brand } from "effect"
|
import type { Brand } from "effect"
|
||||||
import type { Model } from "@opencode-ai/schema/model"
|
import type { Model } from "@opencode-ai/schema/model"
|
||||||
|
import type { Workspace } from "@opencode-ai/schema/workspace"
|
||||||
import type { SessionMessage } from "@opencode-ai/schema/session-message"
|
import type { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
|
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||||
import type { AgentAttachment } from "@opencode-ai/schema/prompt"
|
import type { AgentAttachment } from "@opencode-ai/schema/prompt"
|
||||||
@@ -59,9 +59,7 @@ export interface ServerApi<E = never> {
|
|||||||
readonly get: ServerGetOperation<E>
|
readonly get: ServerGetOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint2_0Input = {
|
export type Endpoint2_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint2_0Output = Location.Info
|
export type Endpoint2_0Output = Location.Info
|
||||||
export type LocationGetOperation<E = never> = (input?: Endpoint2_0Input) => Effect.Effect<Endpoint2_0Output, E>
|
export type LocationGetOperation<E = never> = (input?: Endpoint2_0Input) => Effect.Effect<Endpoint2_0Output, E>
|
||||||
|
|
||||||
@@ -69,15 +67,13 @@ export interface LocationApi<E = never> {
|
|||||||
readonly get: LocationGetOperation<E>
|
readonly get: LocationGetOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint3_0Input = {
|
export type Endpoint3_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint3_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Agent.Info> }
|
export type Endpoint3_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Agent.Info> }
|
||||||
export type AgentListOperation<E = never> = (input?: Endpoint3_0Input) => Effect.Effect<Endpoint3_0Output, E>
|
export type AgentListOperation<E = never> = (input?: Endpoint3_0Input) => Effect.Effect<Endpoint3_0Output, E>
|
||||||
|
|
||||||
export type Endpoint3_1Input = {
|
export type Endpoint3_1Input = {
|
||||||
readonly agentID: Agent.ID
|
readonly agentID: Agent.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint3_1Output = { readonly location: Location.Info; readonly data: Agent.Info }
|
export type Endpoint3_1Output = { readonly location: Location.Info; readonly data: Agent.Info }
|
||||||
export type AgentGetOperation<E = never> = (input: Endpoint3_1Input) => Effect.Effect<Endpoint3_1Output, E>
|
export type AgentGetOperation<E = never> = (input: Endpoint3_1Input) => Effect.Effect<Endpoint3_1Output, E>
|
||||||
@@ -87,9 +83,7 @@ export interface AgentApi<E = never> {
|
|||||||
readonly get: AgentGetOperation<E>
|
readonly get: AgentGetOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint4_0Input = {
|
export type Endpoint4_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint4_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
|
export type Endpoint4_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
|
||||||
export type PluginListOperation<E = never> = (input?: Endpoint4_0Input) => Effect.Effect<Endpoint4_0Output, E>
|
export type PluginListOperation<E = never> = (input?: Endpoint4_0Input) => Effect.Effect<Endpoint4_0Output, E>
|
||||||
|
|
||||||
@@ -98,7 +92,6 @@ export interface PluginApi<E = never> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint5_0Input = {
|
export type Endpoint5_0Input = {
|
||||||
readonly workspace?: Workspace.ID | undefined
|
|
||||||
readonly limit?: number | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
@@ -933,15 +926,11 @@ export interface MessageApi<E = never> {
|
|||||||
readonly list: MessageListOperation<E>
|
readonly list: MessageListOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint7_0Input = {
|
export type Endpoint7_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint7_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Model.Info> }
|
export type Endpoint7_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Model.Info> }
|
||||||
export type ModelListOperation<E = never> = (input?: Endpoint7_0Input) => Effect.Effect<Endpoint7_0Output, E>
|
export type ModelListOperation<E = never> = (input?: Endpoint7_0Input) => Effect.Effect<Endpoint7_0Output, E>
|
||||||
|
|
||||||
export type Endpoint7_1Input = {
|
export type Endpoint7_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint7_1Output = { readonly location: Location.Info; readonly data: Model.Info | undefined }
|
export type Endpoint7_1Output = { readonly location: Location.Info; readonly data: Model.Info | undefined }
|
||||||
export type ModelDefaultOperation<E = never> = (input?: Endpoint7_1Input) => Effect.Effect<Endpoint7_1Output, E>
|
export type ModelDefaultOperation<E = never> = (input?: Endpoint7_1Input) => Effect.Effect<Endpoint7_1Output, E>
|
||||||
|
|
||||||
@@ -951,7 +940,7 @@ export interface ModelApi<E = never> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint8_0Input = {
|
export type Endpoint8_0Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly prompt: string
|
readonly prompt: string
|
||||||
readonly model?: Model.Ref | undefined
|
readonly model?: Model.Ref | undefined
|
||||||
}
|
}
|
||||||
@@ -962,15 +951,13 @@ export interface GenerateApi<E = never> {
|
|||||||
readonly text: GenerateTextOperation<E>
|
readonly text: GenerateTextOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint9_0Input = {
|
export type Endpoint9_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint9_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Provider.Info> }
|
export type Endpoint9_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Provider.Info> }
|
||||||
export type ProviderListOperation<E = never> = (input?: Endpoint9_0Input) => Effect.Effect<Endpoint9_0Output, E>
|
export type ProviderListOperation<E = never> = (input?: Endpoint9_0Input) => Effect.Effect<Endpoint9_0Output, E>
|
||||||
|
|
||||||
export type Endpoint9_1Input = {
|
export type Endpoint9_1Input = {
|
||||||
readonly providerID: Provider.ID
|
readonly providerID: Provider.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint9_1Output = { readonly location: Location.Info; readonly data: Provider.Info }
|
export type Endpoint9_1Output = { readonly location: Location.Info; readonly data: Provider.Info }
|
||||||
export type ProviderGetOperation<E = never> = (input: Endpoint9_1Input) => Effect.Effect<Endpoint9_1Output, E>
|
export type ProviderGetOperation<E = never> = (input: Endpoint9_1Input) => Effect.Effect<Endpoint9_1Output, E>
|
||||||
@@ -980,21 +967,19 @@ export interface ProviderApi<E = never> {
|
|||||||
readonly get: ProviderGetOperation<E>
|
readonly get: ProviderGetOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint10_0Input = {
|
export type Endpoint10_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint10_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Integration.Info> }
|
export type Endpoint10_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Integration.Info> }
|
||||||
export type IntegrationListOperation<E = never> = (input?: Endpoint10_0Input) => Effect.Effect<Endpoint10_0Output, E>
|
export type IntegrationListOperation<E = never> = (input?: Endpoint10_0Input) => Effect.Effect<Endpoint10_0Output, E>
|
||||||
|
|
||||||
export type Endpoint10_1Input = {
|
export type Endpoint10_1Input = {
|
||||||
readonly integrationID: Integration.ID
|
readonly integrationID: Integration.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint10_1Output = { readonly location: Location.Info; readonly data: Integration.Info | undefined }
|
export type Endpoint10_1Output = { readonly location: Location.Info; readonly data: Integration.Info | undefined }
|
||||||
export type IntegrationGetOperation<E = never> = (input: Endpoint10_1Input) => Effect.Effect<Endpoint10_1Output, E>
|
export type IntegrationGetOperation<E = never> = (input: Endpoint10_1Input) => Effect.Effect<Endpoint10_1Output, E>
|
||||||
|
|
||||||
export type Endpoint10_2Input = {
|
export type Endpoint10_2Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly url: string
|
readonly url: string
|
||||||
}
|
}
|
||||||
export type Endpoint10_2Output = void
|
export type Endpoint10_2Output = void
|
||||||
@@ -1004,7 +989,7 @@ export type IntegrationWellknownAddOperation<E = never> = (
|
|||||||
|
|
||||||
export type Endpoint10_3Input = {
|
export type Endpoint10_3Input = {
|
||||||
readonly integrationID: Integration.ID
|
readonly integrationID: Integration.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly key: string
|
readonly key: string
|
||||||
readonly label?: string | undefined
|
readonly label?: string | undefined
|
||||||
}
|
}
|
||||||
@@ -1015,7 +1000,7 @@ export type IntegrationConnectKeyOperation<E = never> = (
|
|||||||
|
|
||||||
export type Endpoint10_4Input = {
|
export type Endpoint10_4Input = {
|
||||||
readonly integrationID: Integration.ID
|
readonly integrationID: Integration.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly methodID: Integration.MethodID
|
readonly methodID: Integration.MethodID
|
||||||
readonly inputs: { readonly [x: string]: string }
|
readonly inputs: { readonly [x: string]: string }
|
||||||
readonly label?: string | undefined
|
readonly label?: string | undefined
|
||||||
@@ -1028,7 +1013,7 @@ export type IntegrationOauthConnectOperation<E = never> = (
|
|||||||
export type Endpoint10_5Input = {
|
export type Endpoint10_5Input = {
|
||||||
readonly integrationID: Integration.ID
|
readonly integrationID: Integration.ID
|
||||||
readonly attemptID: Integration.AttemptID
|
readonly attemptID: Integration.AttemptID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint10_5Output = { readonly location: Location.Info; readonly data: Integration.AttemptStatus }
|
export type Endpoint10_5Output = { readonly location: Location.Info; readonly data: Integration.AttemptStatus }
|
||||||
export type IntegrationOauthStatusOperation<E = never> = (
|
export type IntegrationOauthStatusOperation<E = never> = (
|
||||||
@@ -1038,7 +1023,7 @@ export type IntegrationOauthStatusOperation<E = never> = (
|
|||||||
export type Endpoint10_6Input = {
|
export type Endpoint10_6Input = {
|
||||||
readonly integrationID: Integration.ID
|
readonly integrationID: Integration.ID
|
||||||
readonly attemptID: Integration.AttemptID
|
readonly attemptID: Integration.AttemptID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly code?: string | undefined
|
readonly code?: string | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint10_6Output = void
|
export type Endpoint10_6Output = void
|
||||||
@@ -1049,7 +1034,7 @@ export type IntegrationOauthCompleteOperation<E = never> = (
|
|||||||
export type Endpoint10_7Input = {
|
export type Endpoint10_7Input = {
|
||||||
readonly integrationID: Integration.ID
|
readonly integrationID: Integration.ID
|
||||||
readonly attemptID: Integration.AttemptID
|
readonly attemptID: Integration.AttemptID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint10_7Output = void
|
export type Endpoint10_7Output = void
|
||||||
export type IntegrationOauthCancelOperation<E = never> = (
|
export type IntegrationOauthCancelOperation<E = never> = (
|
||||||
@@ -1058,7 +1043,7 @@ export type IntegrationOauthCancelOperation<E = never> = (
|
|||||||
|
|
||||||
export type Endpoint10_8Input = {
|
export type Endpoint10_8Input = {
|
||||||
readonly integrationID: Integration.ID
|
readonly integrationID: Integration.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly methodID: Integration.MethodID
|
readonly methodID: Integration.MethodID
|
||||||
readonly label?: string | undefined
|
readonly label?: string | undefined
|
||||||
}
|
}
|
||||||
@@ -1070,7 +1055,7 @@ export type IntegrationCommandConnectOperation<E = never> = (
|
|||||||
export type Endpoint10_9Input = {
|
export type Endpoint10_9Input = {
|
||||||
readonly integrationID: Integration.ID
|
readonly integrationID: Integration.ID
|
||||||
readonly attemptID: Integration.AttemptID
|
readonly attemptID: Integration.AttemptID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint10_9Output = { readonly location: Location.Info; readonly data: Integration.CommandAttemptStatus }
|
export type Endpoint10_9Output = { readonly location: Location.Info; readonly data: Integration.CommandAttemptStatus }
|
||||||
export type IntegrationCommandStatusOperation<E = never> = (
|
export type IntegrationCommandStatusOperation<E = never> = (
|
||||||
@@ -1080,7 +1065,7 @@ export type IntegrationCommandStatusOperation<E = never> = (
|
|||||||
export type Endpoint10_10Input = {
|
export type Endpoint10_10Input = {
|
||||||
readonly integrationID: Integration.ID
|
readonly integrationID: Integration.ID
|
||||||
readonly attemptID: Integration.AttemptID
|
readonly attemptID: Integration.AttemptID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint10_10Output = void
|
export type Endpoint10_10Output = void
|
||||||
export type IntegrationCommandCancelOperation<E = never> = (
|
export type IntegrationCommandCancelOperation<E = never> = (
|
||||||
@@ -1105,15 +1090,13 @@ export interface IntegrationApi<E = never> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint11_0Input = {
|
export type Endpoint11_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint11_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Mcp.Server> }
|
export type Endpoint11_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Mcp.Server> }
|
||||||
export type McpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E>
|
export type McpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E>
|
||||||
|
|
||||||
export type Endpoint11_1Input = {
|
export type Endpoint11_1Input = {
|
||||||
readonly server: string
|
readonly server: string
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly config: Mcp.LocalConfig | Mcp.RemoteConfig
|
readonly config: Mcp.LocalConfig | Mcp.RemoteConfig
|
||||||
}
|
}
|
||||||
export type Endpoint11_1Output = void
|
export type Endpoint11_1Output = void
|
||||||
@@ -1121,28 +1104,26 @@ export type McpAddOperation<E = never> = (input: Endpoint11_1Input) => Effect.Ef
|
|||||||
|
|
||||||
export type Endpoint11_2Input = {
|
export type Endpoint11_2Input = {
|
||||||
readonly server: string
|
readonly server: string
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint11_2Output = void
|
export type Endpoint11_2Output = void
|
||||||
export type McpRemoveOperation<E = never> = (input: Endpoint11_2Input) => Effect.Effect<Endpoint11_2Output, E>
|
export type McpRemoveOperation<E = never> = (input: Endpoint11_2Input) => Effect.Effect<Endpoint11_2Output, E>
|
||||||
|
|
||||||
export type Endpoint11_3Input = {
|
export type Endpoint11_3Input = {
|
||||||
readonly server: string
|
readonly server: string
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint11_3Output = void
|
export type Endpoint11_3Output = void
|
||||||
export type McpConnectOperation<E = never> = (input: Endpoint11_3Input) => Effect.Effect<Endpoint11_3Output, E>
|
export type McpConnectOperation<E = never> = (input: Endpoint11_3Input) => Effect.Effect<Endpoint11_3Output, E>
|
||||||
|
|
||||||
export type Endpoint11_4Input = {
|
export type Endpoint11_4Input = {
|
||||||
readonly server: string
|
readonly server: string
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint11_4Output = void
|
export type Endpoint11_4Output = void
|
||||||
export type McpDisconnectOperation<E = never> = (input: Endpoint11_4Input) => Effect.Effect<Endpoint11_4Output, E>
|
export type McpDisconnectOperation<E = never> = (input: Endpoint11_4Input) => Effect.Effect<Endpoint11_4Output, E>
|
||||||
|
|
||||||
export type Endpoint11_5Input = {
|
export type Endpoint11_5Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint11_5Output = { readonly location: Location.Info; readonly data: Mcp.ResourceCatalog }
|
export type Endpoint11_5Output = { readonly location: Location.Info; readonly data: Mcp.ResourceCatalog }
|
||||||
export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_5Input) => Effect.Effect<Endpoint11_5Output, E>
|
export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_5Input) => Effect.Effect<Endpoint11_5Output, E>
|
||||||
|
|
||||||
@@ -1157,7 +1138,7 @@ export interface McpApi<E = never> {
|
|||||||
|
|
||||||
export type Endpoint12_0Input = {
|
export type Endpoint12_0Input = {
|
||||||
readonly credentialID: Credential.ID
|
readonly credentialID: Credential.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly label: string
|
readonly label: string
|
||||||
}
|
}
|
||||||
export type Endpoint12_0Output = void
|
export type Endpoint12_0Output = void
|
||||||
@@ -1165,7 +1146,7 @@ export type CredentialUpdateOperation<E = never> = (input: Endpoint12_0Input) =>
|
|||||||
|
|
||||||
export type Endpoint12_1Input = {
|
export type Endpoint12_1Input = {
|
||||||
readonly credentialID: Credential.ID
|
readonly credentialID: Credential.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint12_1Output = void
|
export type Endpoint12_1Output = void
|
||||||
export type CredentialRemoveOperation<E = never> = (input: Endpoint12_1Input) => Effect.Effect<Endpoint12_1Output, E>
|
export type CredentialRemoveOperation<E = never> = (input: Endpoint12_1Input) => Effect.Effect<Endpoint12_1Output, E>
|
||||||
@@ -1178,15 +1159,13 @@ export interface CredentialApi<E = never> {
|
|||||||
export type Endpoint13_0Output = ReadonlyArray<Project.Info>
|
export type Endpoint13_0Output = ReadonlyArray<Project.Info>
|
||||||
export type ProjectListOperation<E = never> = () => Effect.Effect<Endpoint13_0Output, E>
|
export type ProjectListOperation<E = never> = () => Effect.Effect<Endpoint13_0Output, E>
|
||||||
|
|
||||||
export type Endpoint13_1Input = {
|
export type Endpoint13_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint13_1Output = Project.Current
|
export type Endpoint13_1Output = Project.Current
|
||||||
export type ProjectCurrentOperation<E = never> = (input?: Endpoint13_1Input) => Effect.Effect<Endpoint13_1Output, E>
|
export type ProjectCurrentOperation<E = never> = (input?: Endpoint13_1Input) => Effect.Effect<Endpoint13_1Output, E>
|
||||||
|
|
||||||
export type Endpoint13_2Input = {
|
export type Endpoint13_2Input = {
|
||||||
readonly projectID: Project.ID
|
readonly projectID: Project.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint13_2Output = Project.Directories
|
export type Endpoint13_2Output = Project.Directories
|
||||||
export type ProjectDirectoriesOperation<E = never> = (input: Endpoint13_2Input) => Effect.Effect<Endpoint13_2Output, E>
|
export type ProjectDirectoriesOperation<E = never> = (input: Endpoint13_2Input) => Effect.Effect<Endpoint13_2Output, E>
|
||||||
@@ -1197,9 +1176,7 @@ export interface ProjectApi<E = never> {
|
|||||||
readonly directories: ProjectDirectoriesOperation<E>
|
readonly directories: ProjectDirectoriesOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint14_0Input = {
|
export type Endpoint14_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint14_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Form.Info> }
|
export type Endpoint14_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Form.Info> }
|
||||||
export type FormRequestListOperation<E = never> = (input?: Endpoint14_0Input) => Effect.Effect<Endpoint14_0Output, E>
|
export type FormRequestListOperation<E = never> = (input?: Endpoint14_0Input) => Effect.Effect<Endpoint14_0Output, E>
|
||||||
|
|
||||||
@@ -1243,9 +1220,7 @@ export interface FormApi<E = never> {
|
|||||||
readonly cancel: FormCancelOperation<E>
|
readonly cancel: FormCancelOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint15_0Input = {
|
export type Endpoint15_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint15_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Permission.Request> }
|
export type Endpoint15_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Permission.Request> }
|
||||||
export type PermissionRequestListOperation<E = never> = (
|
export type PermissionRequestListOperation<E = never> = (
|
||||||
input?: Endpoint15_0Input,
|
input?: Endpoint15_0Input,
|
||||||
@@ -1303,14 +1278,14 @@ export interface PermissionApi<E = never> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint16_0Input = {
|
export type Endpoint16_0Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly path?: RelativePath | undefined
|
readonly path?: RelativePath | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint16_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileSystem.Entry> }
|
export type Endpoint16_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileSystem.Entry> }
|
||||||
export type FileListOperation<E = never> = (input?: Endpoint16_0Input) => Effect.Effect<Endpoint16_0Output, E>
|
export type FileListOperation<E = never> = (input?: Endpoint16_0Input) => Effect.Effect<Endpoint16_0Output, E>
|
||||||
|
|
||||||
export type Endpoint16_1Input = {
|
export type Endpoint16_1Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly query: string
|
readonly query: string
|
||||||
readonly type?: "file" | "directory" | undefined
|
readonly type?: "file" | "directory" | undefined
|
||||||
readonly limit?: number | undefined
|
readonly limit?: number | undefined
|
||||||
@@ -1323,9 +1298,7 @@ export interface FileApi<E = never> {
|
|||||||
readonly find: FileFindOperation<E>
|
readonly find: FileFindOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint17_0Input = {
|
export type Endpoint17_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint17_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Command.Info> }
|
export type Endpoint17_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Command.Info> }
|
||||||
export type CommandListOperation<E = never> = (input?: Endpoint17_0Input) => Effect.Effect<Endpoint17_0Output, E>
|
export type CommandListOperation<E = never> = (input?: Endpoint17_0Input) => Effect.Effect<Endpoint17_0Output, E>
|
||||||
|
|
||||||
@@ -1333,9 +1306,7 @@ export interface CommandApi<E = never> {
|
|||||||
readonly list: CommandListOperation<E>
|
readonly list: CommandListOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint18_0Input = {
|
export type Endpoint18_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint18_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Skill.Info> }
|
export type Endpoint18_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Skill.Info> }
|
||||||
export type SkillListOperation<E = never> = (input?: Endpoint18_0Input) => Effect.Effect<Endpoint18_0Output, E>
|
export type SkillListOperation<E = never> = (input?: Endpoint18_0Input) => Effect.Effect<Endpoint18_0Output, E>
|
||||||
|
|
||||||
@@ -1350,14 +1321,12 @@ export interface EventApi<E = never> {
|
|||||||
readonly subscribe: EventSubscribeOperation<E>
|
readonly subscribe: EventSubscribeOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint20_0Input = {
|
export type Endpoint20_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint20_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Pty.Info> }
|
export type Endpoint20_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Pty.Info> }
|
||||||
export type PtyListOperation<E = never> = (input?: Endpoint20_0Input) => Effect.Effect<Endpoint20_0Output, E>
|
export type PtyListOperation<E = never> = (input?: Endpoint20_0Input) => Effect.Effect<Endpoint20_0Output, E>
|
||||||
|
|
||||||
export type Endpoint20_1Input = {
|
export type Endpoint20_1Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly command?: string | undefined
|
readonly command?: string | undefined
|
||||||
readonly args?: ReadonlyArray<string> | undefined
|
readonly args?: ReadonlyArray<string> | undefined
|
||||||
readonly cwd?: string | undefined
|
readonly cwd?: string | undefined
|
||||||
@@ -1369,14 +1338,14 @@ export type PtyCreateOperation<E = never> = (input?: Endpoint20_1Input) => Effec
|
|||||||
|
|
||||||
export type Endpoint20_2Input = {
|
export type Endpoint20_2Input = {
|
||||||
readonly ptyID: Pty.ID
|
readonly ptyID: Pty.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint20_2Output = { readonly location: Location.Info; readonly data: Pty.Info }
|
export type Endpoint20_2Output = { readonly location: Location.Info; readonly data: Pty.Info }
|
||||||
export type PtyGetOperation<E = never> = (input: Endpoint20_2Input) => Effect.Effect<Endpoint20_2Output, E>
|
export type PtyGetOperation<E = never> = (input: Endpoint20_2Input) => Effect.Effect<Endpoint20_2Output, E>
|
||||||
|
|
||||||
export type Endpoint20_3Input = {
|
export type Endpoint20_3Input = {
|
||||||
readonly ptyID: Pty.ID
|
readonly ptyID: Pty.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly title?: string | undefined
|
readonly title?: string | undefined
|
||||||
readonly size?: { readonly rows: number; readonly cols: number } | undefined
|
readonly size?: { readonly rows: number; readonly cols: number } | undefined
|
||||||
}
|
}
|
||||||
@@ -1385,7 +1354,7 @@ export type PtyUpdateOperation<E = never> = (input: Endpoint20_3Input) => Effect
|
|||||||
|
|
||||||
export type Endpoint20_4Input = {
|
export type Endpoint20_4Input = {
|
||||||
readonly ptyID: Pty.ID
|
readonly ptyID: Pty.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint20_4Output = void
|
export type Endpoint20_4Output = void
|
||||||
export type PtyRemoveOperation<E = never> = (input: Endpoint20_4Input) => Effect.Effect<Endpoint20_4Output, E>
|
export type PtyRemoveOperation<E = never> = (input: Endpoint20_4Input) => Effect.Effect<Endpoint20_4Output, E>
|
||||||
@@ -1398,14 +1367,12 @@ export interface PtyApi<E = never> {
|
|||||||
readonly remove: PtyRemoveOperation<E>
|
readonly remove: PtyRemoveOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint21_0Input = {
|
export type Endpoint21_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint21_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Shell.Info> }
|
export type Endpoint21_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Shell.Info> }
|
||||||
export type ShellListOperation<E = never> = (input?: Endpoint21_0Input) => Effect.Effect<Endpoint21_0Output, E>
|
export type ShellListOperation<E = never> = (input?: Endpoint21_0Input) => Effect.Effect<Endpoint21_0Output, E>
|
||||||
|
|
||||||
export type Endpoint21_1Input = {
|
export type Endpoint21_1Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly command: string
|
readonly command: string
|
||||||
readonly cwd?: string | undefined
|
readonly cwd?: string | undefined
|
||||||
readonly timeout: number
|
readonly timeout: number
|
||||||
@@ -1416,14 +1383,14 @@ export type ShellCreateOperation<E = never> = (input: Endpoint21_1Input) => Effe
|
|||||||
|
|
||||||
export type Endpoint21_2Input = {
|
export type Endpoint21_2Input = {
|
||||||
readonly id: Shell.ID
|
readonly id: Shell.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint21_2Output = { readonly location: Location.Info; readonly data: Shell.Info }
|
export type Endpoint21_2Output = { readonly location: Location.Info; readonly data: Shell.Info }
|
||||||
export type ShellGetOperation<E = never> = (input: Endpoint21_2Input) => Effect.Effect<Endpoint21_2Output, E>
|
export type ShellGetOperation<E = never> = (input: Endpoint21_2Input) => Effect.Effect<Endpoint21_2Output, E>
|
||||||
|
|
||||||
export type Endpoint21_3Input = {
|
export type Endpoint21_3Input = {
|
||||||
readonly id: Shell.ID
|
readonly id: Shell.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly timeout: number
|
readonly timeout: number
|
||||||
}
|
}
|
||||||
export type Endpoint21_3Output = { readonly location: Location.Info; readonly data: Shell.Info }
|
export type Endpoint21_3Output = { readonly location: Location.Info; readonly data: Shell.Info }
|
||||||
@@ -1431,7 +1398,7 @@ export type ShellTimeoutOperation<E = never> = (input: Endpoint21_3Input) => Eff
|
|||||||
|
|
||||||
export type Endpoint21_4Input = {
|
export type Endpoint21_4Input = {
|
||||||
readonly id: Shell.ID
|
readonly id: Shell.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly cursor?: number | undefined
|
readonly cursor?: number | undefined
|
||||||
readonly limit?: number | undefined
|
readonly limit?: number | undefined
|
||||||
}
|
}
|
||||||
@@ -1448,7 +1415,7 @@ export type ShellOutputOperation<E = never> = (input: Endpoint21_4Input) => Effe
|
|||||||
|
|
||||||
export type Endpoint21_5Input = {
|
export type Endpoint21_5Input = {
|
||||||
readonly id: Shell.ID
|
readonly id: Shell.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint21_5Output = void
|
export type Endpoint21_5Output = void
|
||||||
export type ShellRemoveOperation<E = never> = (input: Endpoint21_5Input) => Effect.Effect<Endpoint21_5Output, E>
|
export type ShellRemoveOperation<E = never> = (input: Endpoint21_5Input) => Effect.Effect<Endpoint21_5Output, E>
|
||||||
@@ -1462,9 +1429,7 @@ export interface ShellApi<E = never> {
|
|||||||
readonly remove: ShellRemoveOperation<E>
|
readonly remove: ShellRemoveOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint22_0Input = {
|
export type Endpoint22_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Question.Request> }
|
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Question.Request> }
|
||||||
export type QuestionRequestListOperation<E = never> = (
|
export type QuestionRequestListOperation<E = never> = (
|
||||||
input?: Endpoint22_0Input,
|
input?: Endpoint22_0Input,
|
||||||
@@ -1493,9 +1458,7 @@ export interface QuestionApi<E = never> {
|
|||||||
readonly reject: QuestionRejectOperation<E>
|
readonly reject: QuestionRejectOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint23_0Input = {
|
export type Endpoint23_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint23_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
|
export type Endpoint23_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
|
||||||
export type ReferenceListOperation<E = never> = (input?: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
|
export type ReferenceListOperation<E = never> = (input?: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
|
||||||
|
|
||||||
@@ -1505,7 +1468,7 @@ export interface ReferenceApi<E = never> {
|
|||||||
|
|
||||||
export type Endpoint24_0Input = {
|
export type Endpoint24_0Input = {
|
||||||
readonly projectID: Project.ID
|
readonly projectID: Project.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly strategy: ProjectCopy.StrategyID
|
readonly strategy: ProjectCopy.StrategyID
|
||||||
readonly directory: AbsolutePath
|
readonly directory: AbsolutePath
|
||||||
readonly name?: string | undefined
|
readonly name?: string | undefined
|
||||||
@@ -1515,7 +1478,7 @@ export type ProjectCopyCreateOperation<E = never> = (input: Endpoint24_0Input) =
|
|||||||
|
|
||||||
export type Endpoint24_1Input = {
|
export type Endpoint24_1Input = {
|
||||||
readonly projectID: Project.ID
|
readonly projectID: Project.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly directory: AbsolutePath
|
readonly directory: AbsolutePath
|
||||||
readonly force: boolean
|
readonly force: boolean
|
||||||
}
|
}
|
||||||
@@ -1524,7 +1487,7 @@ export type ProjectCopyRemoveOperation<E = never> = (input: Endpoint24_1Input) =
|
|||||||
|
|
||||||
export type Endpoint24_2Input = {
|
export type Endpoint24_2Input = {
|
||||||
readonly projectID: Project.ID
|
readonly projectID: Project.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint24_2Output = void
|
export type Endpoint24_2Output = void
|
||||||
export type ProjectCopyRefreshOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
|
export type ProjectCopyRefreshOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
|
||||||
@@ -1535,20 +1498,16 @@ export interface ProjectCopyApi<E = never> {
|
|||||||
readonly refresh: ProjectCopyRefreshOperation<E>
|
readonly refresh: ProjectCopyRefreshOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint25_0Input = {
|
export type Endpoint25_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
|
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
|
||||||
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
|
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
|
||||||
|
|
||||||
export type Endpoint25_1Input = {
|
export type Endpoint25_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
|
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
|
||||||
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
|
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
|
||||||
|
|
||||||
export type Endpoint25_2Input = {
|
export type Endpoint25_2Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly mode: Vcs.Mode
|
readonly mode: Vcs.Mode
|
||||||
readonly context?: number | undefined
|
readonly context?: number | undefined
|
||||||
}
|
}
|
||||||
@@ -1564,9 +1523,7 @@ export interface VcsApi<E = never> {
|
|||||||
export type Endpoint26_0Output = ReadonlyArray<Location.Ref>
|
export type Endpoint26_0Output = ReadonlyArray<Location.Ref>
|
||||||
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
|
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
|
||||||
|
|
||||||
export type Endpoint26_1Input = {
|
export type Endpoint26_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint26_1Output = void
|
export type Endpoint26_1Output = void
|
||||||
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E>
|
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E>
|
||||||
|
|
||||||
@@ -1591,14 +1548,12 @@ export interface MigrationApi<E = never> {
|
|||||||
readonly v1: { readonly status: MigrationV1StatusOperation<E> }
|
readonly v1: { readonly status: MigrationV1StatusOperation<E> }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint28_0Input = {
|
export type Endpoint28_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint28_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
|
export type Endpoint28_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
|
||||||
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
|
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
|
||||||
|
|
||||||
export type Endpoint28_1Input = {
|
export type Endpoint28_1Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly query: string
|
readonly query: string
|
||||||
readonly providerID?: WebSearch.ID | undefined
|
readonly providerID?: WebSearch.ID | undefined
|
||||||
}
|
}
|
||||||
@@ -1610,9 +1565,7 @@ export interface WebsearchApi<E = never> {
|
|||||||
readonly query: WebsearchQueryOperation<E>
|
readonly query: WebsearchQueryOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint29_0Input = {
|
export type Endpoint29_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint29_0Output = ReadonlyArray<Config.Entry>
|
export type Endpoint29_0Output = ReadonlyArray<Config.Entry>
|
||||||
export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
|
export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
|
||||||
|
|
||||||
|
|||||||
@@ -292,7 +292,6 @@ const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0In
|
|||||||
preserveEffect<Endpoint5_0Output>()(
|
preserveEffect<Endpoint5_0Output>()(
|
||||||
raw["session.list"]({
|
raw["session.list"]({
|
||||||
query: {
|
query: {
|
||||||
workspace: input?.["workspace"],
|
|
||||||
limit: input?.["limit"],
|
limit: input?.["limit"],
|
||||||
order: input?.["order"],
|
order: input?.["order"],
|
||||||
search: input?.["search"],
|
search: input?.["search"],
|
||||||
|
|||||||
@@ -448,7 +448,6 @@ export function make(options: ClientOptions) {
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
path: `/api/session`,
|
path: `/api/session`,
|
||||||
query: {
|
query: {
|
||||||
workspace: input?.["workspace"],
|
|
||||||
limit: input?.["limit"],
|
limit: input?.["limit"],
|
||||||
order: input?.["order"],
|
order: input?.["order"],
|
||||||
search: input?.["search"],
|
search: input?.["search"],
|
||||||
@@ -477,7 +476,7 @@ export function make(options: ClientOptions) {
|
|||||||
location: input?.["location"],
|
location: input?.["location"],
|
||||||
},
|
},
|
||||||
successStatus: 200,
|
successStatus: 200,
|
||||||
declaredStatuses: [401, 400],
|
declaredStatuses: [400, 401],
|
||||||
empty: false,
|
empty: false,
|
||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
|
|||||||
@@ -2283,9 +2283,7 @@ export type HealthStopOutput = ServiceStopResponse
|
|||||||
export type ServerGetOutput = { urls: Array<string> }
|
export type ServerGetOutput = { urls: Array<string> }
|
||||||
|
|
||||||
export type LocationGetInput = {
|
export type LocationGetInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LocationGetOutput = {
|
export type LocationGetOutput = {
|
||||||
@@ -2295,9 +2293,7 @@ export type LocationGetOutput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type AgentListInput = {
|
export type AgentListInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AgentListOutput = {
|
export type AgentListOutput = {
|
||||||
@@ -2307,9 +2303,7 @@ export type AgentListOutput = {
|
|||||||
|
|
||||||
export type AgentGetInput = {
|
export type AgentGetInput = {
|
||||||
readonly agentID: { readonly agentID: string }["agentID"]
|
readonly agentID: { readonly agentID: string }["agentID"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AgentGetOutput = {
|
export type AgentGetOutput = {
|
||||||
@@ -2318,9 +2312,7 @@ export type AgentGetOutput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PluginListInput = {
|
export type PluginListInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PluginListOutput = {
|
export type PluginListOutput = {
|
||||||
@@ -2329,19 +2321,7 @@ export type PluginListOutput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type SessionListInput = {
|
export type SessionListInput = {
|
||||||
readonly workspace?: {
|
|
||||||
readonly workspace?: string | undefined
|
|
||||||
readonly limit?: number | undefined
|
|
||||||
readonly order?: "asc" | "desc" | undefined
|
|
||||||
readonly search?: string | undefined
|
|
||||||
readonly parentID?: string | null | undefined
|
|
||||||
readonly directory?: string | undefined
|
|
||||||
readonly project?: string | undefined
|
|
||||||
readonly subpath?: string | undefined
|
|
||||||
readonly cursor?: string | undefined
|
|
||||||
}["workspace"]
|
|
||||||
readonly limit?: {
|
readonly limit?: {
|
||||||
readonly workspace?: string | undefined
|
|
||||||
readonly limit?: number | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
@@ -2352,7 +2332,6 @@ export type SessionListInput = {
|
|||||||
readonly cursor?: string | undefined
|
readonly cursor?: string | undefined
|
||||||
}["limit"]
|
}["limit"]
|
||||||
readonly order?: {
|
readonly order?: {
|
||||||
readonly workspace?: string | undefined
|
|
||||||
readonly limit?: number | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
@@ -2363,7 +2342,6 @@ export type SessionListInput = {
|
|||||||
readonly cursor?: string | undefined
|
readonly cursor?: string | undefined
|
||||||
}["order"]
|
}["order"]
|
||||||
readonly search?: {
|
readonly search?: {
|
||||||
readonly workspace?: string | undefined
|
|
||||||
readonly limit?: number | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
@@ -2374,7 +2352,6 @@ export type SessionListInput = {
|
|||||||
readonly cursor?: string | undefined
|
readonly cursor?: string | undefined
|
||||||
}["search"]
|
}["search"]
|
||||||
readonly parentID?: {
|
readonly parentID?: {
|
||||||
readonly workspace?: string | undefined
|
|
||||||
readonly limit?: number | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
@@ -2385,7 +2362,6 @@ export type SessionListInput = {
|
|||||||
readonly cursor?: string | undefined
|
readonly cursor?: string | undefined
|
||||||
}["parentID"]
|
}["parentID"]
|
||||||
readonly directory?: {
|
readonly directory?: {
|
||||||
readonly workspace?: string | undefined
|
|
||||||
readonly limit?: number | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
@@ -2396,7 +2372,6 @@ export type SessionListInput = {
|
|||||||
readonly cursor?: string | undefined
|
readonly cursor?: string | undefined
|
||||||
}["directory"]
|
}["directory"]
|
||||||
readonly project?: {
|
readonly project?: {
|
||||||
readonly workspace?: string | undefined
|
|
||||||
readonly limit?: number | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
@@ -2407,7 +2382,6 @@ export type SessionListInput = {
|
|||||||
readonly cursor?: string | undefined
|
readonly cursor?: string | undefined
|
||||||
}["project"]
|
}["project"]
|
||||||
readonly subpath?: {
|
readonly subpath?: {
|
||||||
readonly workspace?: string | undefined
|
|
||||||
readonly limit?: number | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
@@ -2418,7 +2392,6 @@ export type SessionListInput = {
|
|||||||
readonly cursor?: string | undefined
|
readonly cursor?: string | undefined
|
||||||
}["subpath"]
|
}["subpath"]
|
||||||
readonly cursor?: {
|
readonly cursor?: {
|
||||||
readonly workspace?: string | undefined
|
|
||||||
readonly limit?: number | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
@@ -3760,9 +3733,7 @@ export type MessageListInput = {
|
|||||||
export type MessageListOutput = SessionMessagesResponse
|
export type MessageListOutput = SessionMessagesResponse
|
||||||
|
|
||||||
export type ModelListInput = {
|
export type ModelListInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ModelListOutput = {
|
export type ModelListOutput = {
|
||||||
@@ -3771,9 +3742,7 @@ export type ModelListOutput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ModelDefaultInput = {
|
export type ModelDefaultInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ModelDefaultOutput = {
|
export type ModelDefaultOutput = {
|
||||||
@@ -3782,9 +3751,7 @@ export type ModelDefaultOutput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type GenerateTextInput = {
|
export type GenerateTextInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
readonly prompt: {
|
readonly prompt: {
|
||||||
readonly prompt: string
|
readonly prompt: string
|
||||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||||
@@ -3798,9 +3765,7 @@ export type GenerateTextInput = {
|
|||||||
export type GenerateTextOutput = GenerateTextResponse["data"]
|
export type GenerateTextOutput = GenerateTextResponse["data"]
|
||||||
|
|
||||||
export type ProviderListInput = {
|
export type ProviderListInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProviderListOutput = {
|
export type ProviderListOutput = {
|
||||||
@@ -3810,9 +3775,7 @@ export type ProviderListOutput = {
|
|||||||
|
|
||||||
export type ProviderGetInput = {
|
export type ProviderGetInput = {
|
||||||
readonly providerID: { readonly providerID: string }["providerID"]
|
readonly providerID: { readonly providerID: string }["providerID"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProviderGetOutput = {
|
export type ProviderGetOutput = {
|
||||||
@@ -3821,9 +3784,7 @@ export type ProviderGetOutput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type IntegrationListInput = {
|
export type IntegrationListInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IntegrationListOutput = {
|
export type IntegrationListOutput = {
|
||||||
@@ -3833,9 +3794,7 @@ export type IntegrationListOutput = {
|
|||||||
|
|
||||||
export type IntegrationGetInput = {
|
export type IntegrationGetInput = {
|
||||||
readonly integrationID: { readonly integrationID: string }["integrationID"]
|
readonly integrationID: { readonly integrationID: string }["integrationID"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IntegrationGetOutput = {
|
export type IntegrationGetOutput = {
|
||||||
@@ -3844,9 +3803,7 @@ export type IntegrationGetOutput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type IntegrationWellknownAddInput = {
|
export type IntegrationWellknownAddInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
readonly url: { readonly url: string }["url"]
|
readonly url: { readonly url: string }["url"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3854,9 +3811,7 @@ export type IntegrationWellknownAddOutput = void
|
|||||||
|
|
||||||
export type IntegrationConnectKeyInput = {
|
export type IntegrationConnectKeyInput = {
|
||||||
readonly integrationID: { readonly integrationID: string }["integrationID"]
|
readonly integrationID: { readonly integrationID: string }["integrationID"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
readonly key: { readonly key: string; readonly label?: string | undefined }["key"]
|
readonly key: { readonly key: string; readonly label?: string | undefined }["key"]
|
||||||
readonly label?: { readonly key: string; readonly label?: string | undefined }["label"]
|
readonly label?: { readonly key: string; readonly label?: string | undefined }["label"]
|
||||||
}
|
}
|
||||||
@@ -3865,9 +3820,7 @@ export type IntegrationConnectKeyOutput = void
|
|||||||
|
|
||||||
export type IntegrationOauthConnectInput = {
|
export type IntegrationOauthConnectInput = {
|
||||||
readonly integrationID: { readonly integrationID: string }["integrationID"]
|
readonly integrationID: { readonly integrationID: string }["integrationID"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
readonly methodID: {
|
readonly methodID: {
|
||||||
readonly methodID: string
|
readonly methodID: string
|
||||||
readonly inputs: { readonly [x: string]: string }
|
readonly inputs: { readonly [x: string]: string }
|
||||||
@@ -3899,9 +3852,7 @@ export type IntegrationOauthConnectOutput = {
|
|||||||
export type IntegrationOauthStatusInput = {
|
export type IntegrationOauthStatusInput = {
|
||||||
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
|
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
|
||||||
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
|
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IntegrationOauthStatusOutput = {
|
export type IntegrationOauthStatusOutput = {
|
||||||
@@ -3912,9 +3863,7 @@ export type IntegrationOauthStatusOutput = {
|
|||||||
export type IntegrationOauthCompleteInput = {
|
export type IntegrationOauthCompleteInput = {
|
||||||
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
|
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
|
||||||
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
|
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
readonly code?: { readonly code?: string | undefined }["code"]
|
readonly code?: { readonly code?: string | undefined }["code"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3923,18 +3872,14 @@ export type IntegrationOauthCompleteOutput = void
|
|||||||
export type IntegrationOauthCancelInput = {
|
export type IntegrationOauthCancelInput = {
|
||||||
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
|
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
|
||||||
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
|
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IntegrationOauthCancelOutput = void
|
export type IntegrationOauthCancelOutput = void
|
||||||
|
|
||||||
export type IntegrationCommandConnectInput = {
|
export type IntegrationCommandConnectInput = {
|
||||||
readonly integrationID: { readonly integrationID: string }["integrationID"]
|
readonly integrationID: { readonly integrationID: string }["integrationID"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
readonly methodID: { readonly methodID: string; readonly label?: string | undefined }["methodID"]
|
readonly methodID: { readonly methodID: string; readonly label?: string | undefined }["methodID"]
|
||||||
readonly label?: { readonly methodID: string; readonly label?: string | undefined }["label"]
|
readonly label?: { readonly methodID: string; readonly label?: string | undefined }["label"]
|
||||||
}
|
}
|
||||||
@@ -3947,9 +3892,7 @@ export type IntegrationCommandConnectOutput = {
|
|||||||
export type IntegrationCommandStatusInput = {
|
export type IntegrationCommandStatusInput = {
|
||||||
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
|
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
|
||||||
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
|
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IntegrationCommandStatusOutput = {
|
export type IntegrationCommandStatusOutput = {
|
||||||
@@ -3960,17 +3903,13 @@ export type IntegrationCommandStatusOutput = {
|
|||||||
export type IntegrationCommandCancelInput = {
|
export type IntegrationCommandCancelInput = {
|
||||||
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
|
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
|
||||||
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
|
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IntegrationCommandCancelOutput = void
|
export type IntegrationCommandCancelOutput = void
|
||||||
|
|
||||||
export type McpListInput = {
|
export type McpListInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type McpListOutput = {
|
export type McpListOutput = {
|
||||||
@@ -3980,9 +3919,7 @@ export type McpListOutput = {
|
|||||||
|
|
||||||
export type McpAddInput = {
|
export type McpAddInput = {
|
||||||
readonly server: { readonly server: string }["server"]
|
readonly server: { readonly server: string }["server"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
readonly config: {
|
readonly config: {
|
||||||
readonly config:
|
readonly config:
|
||||||
| {
|
| {
|
||||||
@@ -4018,35 +3955,27 @@ export type McpAddOutput = void
|
|||||||
|
|
||||||
export type McpRemoveInput = {
|
export type McpRemoveInput = {
|
||||||
readonly server: { readonly server: string }["server"]
|
readonly server: { readonly server: string }["server"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type McpRemoveOutput = void
|
export type McpRemoveOutput = void
|
||||||
|
|
||||||
export type McpConnectInput = {
|
export type McpConnectInput = {
|
||||||
readonly server: { readonly server: string }["server"]
|
readonly server: { readonly server: string }["server"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type McpConnectOutput = void
|
export type McpConnectOutput = void
|
||||||
|
|
||||||
export type McpDisconnectInput = {
|
export type McpDisconnectInput = {
|
||||||
readonly server: { readonly server: string }["server"]
|
readonly server: { readonly server: string }["server"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type McpDisconnectOutput = void
|
export type McpDisconnectOutput = void
|
||||||
|
|
||||||
export type McpResourceCatalogInput = {
|
export type McpResourceCatalogInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type McpResourceCatalogOutput = {
|
export type McpResourceCatalogOutput = {
|
||||||
@@ -4056,9 +3985,7 @@ export type McpResourceCatalogOutput = {
|
|||||||
|
|
||||||
export type CredentialUpdateInput = {
|
export type CredentialUpdateInput = {
|
||||||
readonly credentialID: { readonly credentialID: string }["credentialID"]
|
readonly credentialID: { readonly credentialID: string }["credentialID"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
readonly label: { readonly label: string }["label"]
|
readonly label: { readonly label: string }["label"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4066,9 +3993,7 @@ export type CredentialUpdateOutput = void
|
|||||||
|
|
||||||
export type CredentialRemoveInput = {
|
export type CredentialRemoveInput = {
|
||||||
readonly credentialID: { readonly credentialID: string }["credentialID"]
|
readonly credentialID: { readonly credentialID: string }["credentialID"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CredentialRemoveOutput = void
|
export type CredentialRemoveOutput = void
|
||||||
@@ -4076,26 +4001,20 @@ export type CredentialRemoveOutput = void
|
|||||||
export type ProjectListOutput = Array<Project>
|
export type ProjectListOutput = Array<Project>
|
||||||
|
|
||||||
export type ProjectCurrentInput = {
|
export type ProjectCurrentInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProjectCurrentOutput = ProjectCurrent
|
export type ProjectCurrentOutput = ProjectCurrent
|
||||||
|
|
||||||
export type ProjectDirectoriesInput = {
|
export type ProjectDirectoriesInput = {
|
||||||
readonly projectID: { readonly projectID: string }["projectID"]
|
readonly projectID: { readonly projectID: string }["projectID"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProjectDirectoriesOutput = ProjectDirectories
|
export type ProjectDirectoriesOutput = ProjectDirectories
|
||||||
|
|
||||||
export type FormRequestListInput = {
|
export type FormRequestListInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FormRequestListOutput = {
|
export type FormRequestListOutput = {
|
||||||
@@ -4949,9 +4868,7 @@ export type FormCancelInput = {
|
|||||||
export type FormCancelOutput = void
|
export type FormCancelOutput = void
|
||||||
|
|
||||||
export type PermissionRequestListInput = {
|
export type PermissionRequestListInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PermissionRequestListOutput = {
|
export type PermissionRequestListOutput = {
|
||||||
@@ -5057,9 +4974,7 @@ export type PermissionReplyInput = {
|
|||||||
export type PermissionReplyOutput = void
|
export type PermissionReplyOutput = void
|
||||||
|
|
||||||
export type FileReadInput = {
|
export type FileReadInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
readonly path: string
|
readonly path: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5067,11 +4982,11 @@ export type FileReadOutput = globalThis.Uint8Array
|
|||||||
|
|
||||||
export type FileListInput = {
|
export type FileListInput = {
|
||||||
readonly location?: {
|
readonly location?: {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly path?: string | undefined
|
readonly path?: string | undefined
|
||||||
}["location"]
|
}["location"]
|
||||||
readonly path?: {
|
readonly path?: {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly path?: string | undefined
|
readonly path?: string | undefined
|
||||||
}["path"]
|
}["path"]
|
||||||
}
|
}
|
||||||
@@ -5083,25 +4998,25 @@ export type FileListOutput = {
|
|||||||
|
|
||||||
export type FileFindInput = {
|
export type FileFindInput = {
|
||||||
readonly location?: {
|
readonly location?: {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly query: string
|
readonly query: string
|
||||||
readonly type?: "file" | "directory" | undefined
|
readonly type?: "file" | "directory" | undefined
|
||||||
readonly limit?: number | undefined
|
readonly limit?: number | undefined
|
||||||
}["location"]
|
}["location"]
|
||||||
readonly query: {
|
readonly query: {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly query: string
|
readonly query: string
|
||||||
readonly type?: "file" | "directory" | undefined
|
readonly type?: "file" | "directory" | undefined
|
||||||
readonly limit?: number | undefined
|
readonly limit?: number | undefined
|
||||||
}["query"]
|
}["query"]
|
||||||
readonly type?: {
|
readonly type?: {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly query: string
|
readonly query: string
|
||||||
readonly type?: "file" | "directory" | undefined
|
readonly type?: "file" | "directory" | undefined
|
||||||
readonly limit?: number | undefined
|
readonly limit?: number | undefined
|
||||||
}["type"]
|
}["type"]
|
||||||
readonly limit?: {
|
readonly limit?: {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly query: string
|
readonly query: string
|
||||||
readonly type?: "file" | "directory" | undefined
|
readonly type?: "file" | "directory" | undefined
|
||||||
readonly limit?: number | undefined
|
readonly limit?: number | undefined
|
||||||
@@ -5114,9 +5029,7 @@ export type FileFindOutput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CommandListInput = {
|
export type CommandListInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CommandListOutput = {
|
export type CommandListOutput = {
|
||||||
@@ -5125,9 +5038,7 @@ export type CommandListOutput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type SkillListInput = {
|
export type SkillListInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SkillListOutput = {
|
export type SkillListOutput = {
|
||||||
@@ -5138,9 +5049,7 @@ export type SkillListOutput = {
|
|||||||
export type EventSubscribeOutput = V2Event
|
export type EventSubscribeOutput = V2Event
|
||||||
|
|
||||||
export type PtyListInput = {
|
export type PtyListInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PtyListOutput = {
|
export type PtyListOutput = {
|
||||||
@@ -5149,9 +5058,7 @@ export type PtyListOutput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PtyCreateInput = {
|
export type PtyCreateInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
readonly command?: {
|
readonly command?: {
|
||||||
readonly command?: string
|
readonly command?: string
|
||||||
readonly args?: ReadonlyArray<string>
|
readonly args?: ReadonlyArray<string>
|
||||||
@@ -5196,9 +5103,7 @@ export type PtyCreateOutput = {
|
|||||||
|
|
||||||
export type PtyGetInput = {
|
export type PtyGetInput = {
|
||||||
readonly ptyID: { readonly ptyID: string }["ptyID"]
|
readonly ptyID: { readonly ptyID: string }["ptyID"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PtyGetOutput = {
|
export type PtyGetOutput = {
|
||||||
@@ -5208,9 +5113,7 @@ export type PtyGetOutput = {
|
|||||||
|
|
||||||
export type PtyUpdateInput = {
|
export type PtyUpdateInput = {
|
||||||
readonly ptyID: { readonly ptyID: string }["ptyID"]
|
readonly ptyID: { readonly ptyID: string }["ptyID"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
readonly title?: {
|
readonly title?: {
|
||||||
readonly title?: string
|
readonly title?: string
|
||||||
readonly size?: { readonly rows: number; readonly cols: number }
|
readonly size?: { readonly rows: number; readonly cols: number }
|
||||||
@@ -5225,17 +5128,13 @@ export type PtyUpdateOutput = {
|
|||||||
|
|
||||||
export type PtyRemoveInput = {
|
export type PtyRemoveInput = {
|
||||||
readonly ptyID: { readonly ptyID: string }["ptyID"]
|
readonly ptyID: { readonly ptyID: string }["ptyID"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PtyRemoveOutput = void
|
export type PtyRemoveOutput = void
|
||||||
|
|
||||||
export type ShellListInput = {
|
export type ShellListInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ShellListOutput = {
|
export type ShellListOutput = {
|
||||||
@@ -5244,9 +5143,7 @@ export type ShellListOutput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ShellCreateInput = {
|
export type ShellCreateInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
readonly command: {
|
readonly command: {
|
||||||
readonly command: string
|
readonly command: string
|
||||||
readonly cwd?: string
|
readonly cwd?: string
|
||||||
@@ -5280,9 +5177,7 @@ export type ShellCreateOutput = {
|
|||||||
|
|
||||||
export type ShellGetInput = {
|
export type ShellGetInput = {
|
||||||
readonly id: { readonly id: string }["id"]
|
readonly id: { readonly id: string }["id"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ShellGetOutput = {
|
export type ShellGetOutput = {
|
||||||
@@ -5292,9 +5187,7 @@ export type ShellGetOutput = {
|
|||||||
|
|
||||||
export type ShellTimeoutInput = {
|
export type ShellTimeoutInput = {
|
||||||
readonly id: { readonly id: string }["id"]
|
readonly id: { readonly id: string }["id"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
readonly timeout: { readonly timeout: number }["timeout"]
|
readonly timeout: { readonly timeout: number }["timeout"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5306,17 +5199,17 @@ export type ShellTimeoutOutput = {
|
|||||||
export type ShellOutputInput = {
|
export type ShellOutputInput = {
|
||||||
readonly id: { readonly id: string }["id"]
|
readonly id: { readonly id: string }["id"]
|
||||||
readonly location?: {
|
readonly location?: {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly cursor?: number | undefined
|
readonly cursor?: number | undefined
|
||||||
readonly limit?: number | undefined
|
readonly limit?: number | undefined
|
||||||
}["location"]
|
}["location"]
|
||||||
readonly cursor?: {
|
readonly cursor?: {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly cursor?: number | undefined
|
readonly cursor?: number | undefined
|
||||||
readonly limit?: number | undefined
|
readonly limit?: number | undefined
|
||||||
}["cursor"]
|
}["cursor"]
|
||||||
readonly limit?: {
|
readonly limit?: {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly cursor?: number | undefined
|
readonly cursor?: number | undefined
|
||||||
readonly limit?: number | undefined
|
readonly limit?: number | undefined
|
||||||
}["limit"]
|
}["limit"]
|
||||||
@@ -5329,17 +5222,13 @@ export type ShellOutputOutput = {
|
|||||||
|
|
||||||
export type ShellRemoveInput = {
|
export type ShellRemoveInput = {
|
||||||
readonly id: { readonly id: string }["id"]
|
readonly id: { readonly id: string }["id"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ShellRemoveOutput = void
|
export type ShellRemoveOutput = void
|
||||||
|
|
||||||
export type QuestionRequestListInput = {
|
export type QuestionRequestListInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QuestionRequestListOutput = {
|
export type QuestionRequestListOutput = {
|
||||||
@@ -5367,9 +5256,7 @@ export type QuestionRejectInput = {
|
|||||||
export type QuestionRejectOutput = void
|
export type QuestionRejectOutput = void
|
||||||
|
|
||||||
export type ReferenceListInput = {
|
export type ReferenceListInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReferenceListOutput = {
|
export type ReferenceListOutput = {
|
||||||
@@ -5379,9 +5266,7 @@ export type ReferenceListOutput = {
|
|||||||
|
|
||||||
export type ProjectCopyCreateInput = {
|
export type ProjectCopyCreateInput = {
|
||||||
readonly projectID: { readonly projectID: string }["projectID"]
|
readonly projectID: { readonly projectID: string }["projectID"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
readonly strategy: { readonly strategy: string; readonly directory: string; readonly name?: string }["strategy"]
|
readonly strategy: { readonly strategy: string; readonly directory: string; readonly name?: string }["strategy"]
|
||||||
readonly directory: { readonly strategy: string; readonly directory: string; readonly name?: string }["directory"]
|
readonly directory: { readonly strategy: string; readonly directory: string; readonly name?: string }["directory"]
|
||||||
readonly name?: { readonly strategy: string; readonly directory: string; readonly name?: string }["name"]
|
readonly name?: { readonly strategy: string; readonly directory: string; readonly name?: string }["name"]
|
||||||
@@ -5391,9 +5276,7 @@ export type ProjectCopyCreateOutput = ProjectCopyCopy
|
|||||||
|
|
||||||
export type ProjectCopyRemoveInput = {
|
export type ProjectCopyRemoveInput = {
|
||||||
readonly projectID: { readonly projectID: string }["projectID"]
|
readonly projectID: { readonly projectID: string }["projectID"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
readonly directory: { readonly directory: string; readonly force: boolean }["directory"]
|
readonly directory: { readonly directory: string; readonly force: boolean }["directory"]
|
||||||
readonly force: { readonly directory: string; readonly force: boolean }["force"]
|
readonly force: { readonly directory: string; readonly force: boolean }["force"]
|
||||||
}
|
}
|
||||||
@@ -5402,17 +5285,13 @@ export type ProjectCopyRemoveOutput = void
|
|||||||
|
|
||||||
export type ProjectCopyRefreshInput = {
|
export type ProjectCopyRefreshInput = {
|
||||||
readonly projectID: { readonly projectID: string }["projectID"]
|
readonly projectID: { readonly projectID: string }["projectID"]
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProjectCopyRefreshOutput = void
|
export type ProjectCopyRefreshOutput = void
|
||||||
|
|
||||||
export type VcsGetInput = {
|
export type VcsGetInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VcsGetOutput = {
|
export type VcsGetOutput = {
|
||||||
@@ -5421,9 +5300,7 @@ export type VcsGetOutput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type VcsStatusInput = {
|
export type VcsStatusInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VcsStatusOutput = {
|
export type VcsStatusOutput = {
|
||||||
@@ -5433,17 +5310,17 @@ export type VcsStatusOutput = {
|
|||||||
|
|
||||||
export type VcsDiffInput = {
|
export type VcsDiffInput = {
|
||||||
readonly location?: {
|
readonly location?: {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly mode: "working" | "branch"
|
readonly mode: "working" | "branch"
|
||||||
readonly context?: number | undefined
|
readonly context?: number | undefined
|
||||||
}["location"]
|
}["location"]
|
||||||
readonly mode: {
|
readonly mode: {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly mode: "working" | "branch"
|
readonly mode: "working" | "branch"
|
||||||
readonly context?: number | undefined
|
readonly context?: number | undefined
|
||||||
}["mode"]
|
}["mode"]
|
||||||
readonly context?: {
|
readonly context?: {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly mode: "working" | "branch"
|
readonly mode: "working" | "branch"
|
||||||
readonly context?: number | undefined
|
readonly context?: number | undefined
|
||||||
}["context"]
|
}["context"]
|
||||||
@@ -5457,9 +5334,7 @@ export type VcsDiffOutput = {
|
|||||||
export type DebugLocationListOutput = Array<LocationRef>
|
export type DebugLocationListOutput = Array<LocationRef>
|
||||||
|
|
||||||
export type DebugLocationEvictInput = {
|
export type DebugLocationEvictInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DebugLocationEvictOutput = void
|
export type DebugLocationEvictOutput = void
|
||||||
@@ -5470,9 +5345,7 @@ export type MigrationV1StatusOutput =
|
|||||||
| { status: "error"; error: string }
|
| { status: "error"; error: string }
|
||||||
|
|
||||||
export type WebsearchProvidersInput = {
|
export type WebsearchProvidersInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WebsearchProvidersOutput = {
|
export type WebsearchProvidersOutput = {
|
||||||
@@ -5481,9 +5354,7 @@ export type WebsearchProvidersOutput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type WebsearchQueryInput = {
|
export type WebsearchQueryInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
readonly query: { readonly query: string; readonly providerID?: string }["query"]
|
readonly query: { readonly query: string; readonly providerID?: string }["query"]
|
||||||
readonly providerID?: { readonly query: string; readonly providerID?: string }["providerID"]
|
readonly providerID?: { readonly query: string; readonly providerID?: string }["providerID"]
|
||||||
}
|
}
|
||||||
@@ -5494,9 +5365,7 @@ export type WebsearchQueryOutput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ConfigGetInput = {
|
export type ConfigGetInput = {
|
||||||
readonly location?: {
|
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConfigGetOutput = Array<ConfigEntry>
|
export type ConfigGetOutput = Array<ConfigEntry>
|
||||||
|
|||||||
@@ -1021,13 +1021,12 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
|
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
location
|
location
|
||||||
.execute({ location: { directory: "/tmp", workspace: "workspace-1" } })
|
.execute({ location: { directory: "/tmp" } })
|
||||||
.pipe(Effect.provide(client.layer)),
|
.pipe(Effect.provide(client.layer)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const url = new URL(client.requests[0]!.url)
|
const url = new URL(client.requests[0]!.url)
|
||||||
expect(url.searchParams.get("location[directory]")).toBe("/tmp")
|
expect(url.searchParams.get("location[directory]")).toBe("/tmp")
|
||||||
expect(url.searchParams.get("location[workspace]")).toBe("workspace-1")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("serializes supported simple and form parameter shapes", async () => {
|
test("serializes supported simple and form parameter shapes", async () => {
|
||||||
|
|||||||
+70
-105
@@ -1,15 +1,11 @@
|
|||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "2d214a71-3b0a-48c1-a667-741952c4e188",
|
"id": "2415fa65-3def-4f9f-be85-eb35f4f94ddd",
|
||||||
"prevIds": [
|
"prevIds": [
|
||||||
"f14a9b18-8207-487e-a3d3-227e629ba9ad"
|
"4d283c23-533f-4b87-9aa4-218f7fd36bca"
|
||||||
],
|
],
|
||||||
"ddl": [
|
"ddl": [
|
||||||
{
|
|
||||||
"name": "workspace",
|
|
||||||
"entityType": "tables"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "account_state",
|
"name": "account_state",
|
||||||
"entityType": "tables"
|
"entityType": "tables"
|
||||||
@@ -75,84 +71,8 @@
|
|||||||
"entityType": "tables"
|
"entityType": "tables"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "text",
|
"name": "workspace",
|
||||||
"notNull": false,
|
"entityType": "tables"
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "id",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "workspace"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "type",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "workspace"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": "''",
|
|
||||||
"generated": null,
|
|
||||||
"name": "name",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "workspace"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "branch",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "workspace"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "directory",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "workspace"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "extra",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "workspace"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "project_id",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "workspace"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "integer",
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "time_used",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "workspace"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -1385,18 +1305,63 @@
|
|||||||
"table": "session_v2"
|
"table": "session_v2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"type": "text",
|
||||||
"project_id"
|
"notNull": false,
|
||||||
],
|
"autoincrement": false,
|
||||||
"tableTo": "project",
|
"default": null,
|
||||||
"columnsTo": [
|
"generated": null,
|
||||||
"id"
|
"name": "id",
|
||||||
],
|
"entityType": "columns",
|
||||||
"onUpdate": "NO ACTION",
|
"table": "workspace"
|
||||||
"onDelete": "CASCADE",
|
},
|
||||||
"nameExplicit": false,
|
{
|
||||||
"name": "fk_workspace_project_id_project_id_fk",
|
"type": "text",
|
||||||
"entityType": "fks",
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "provider",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "workspace"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "binding",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "workspace"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "root",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "workspace"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "time_created",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "workspace"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "time_updated",
|
||||||
|
"entityType": "columns",
|
||||||
"table": "workspace"
|
"table": "workspace"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1564,15 +1529,6 @@
|
|||||||
"entityType": "pks",
|
"entityType": "pks",
|
||||||
"table": "instruction_entry"
|
"table": "instruction_entry"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"columns": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
|
||||||
"name": "workspace_pk",
|
|
||||||
"table": "workspace",
|
|
||||||
"entityType": "pks"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": [
|
||||||
"id"
|
"id"
|
||||||
@@ -1690,6 +1646,15 @@
|
|||||||
"table": "session_v2",
|
"table": "session_v2",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "workspace_pk",
|
||||||
|
"table": "workspace",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
|
|
||||||
import { ProjectTable } from "../project/sql"
|
|
||||||
import { Project } from "../project"
|
|
||||||
import { Workspace } from "../workspace"
|
|
||||||
|
|
||||||
export const WorkspaceTable = sqliteTable("workspace", {
|
|
||||||
id: text().$type<Workspace.ID>().primaryKey(),
|
|
||||||
type: text().notNull(),
|
|
||||||
name: text().notNull().default(""),
|
|
||||||
branch: text(),
|
|
||||||
directory: text(),
|
|
||||||
extra: text({ mode: "json" }),
|
|
||||||
project_id: text()
|
|
||||||
.$type<Project.ID>()
|
|
||||||
.notNull()
|
|
||||||
.references(() => ProjectTable.id, { onDelete: "cascade" }),
|
|
||||||
time_used: integer()
|
|
||||||
.notNull()
|
|
||||||
.$default(() => Date.now()),
|
|
||||||
})
|
|
||||||
+2
@@ -42,5 +42,7 @@ export const migrations = (
|
|||||||
import("./migration/20260622202450_simplify_session_input"),
|
import("./migration/20260622202450_simplify_session_input"),
|
||||||
import("./migration/20260804233008_loose_psylocke"),
|
import("./migration/20260804233008_loose_psylocke"),
|
||||||
import("./migration/20260805200742_import_legacy_credentials"),
|
import("./migration/20260805200742_import_legacy_credentials"),
|
||||||
|
import("./migration/20260806150823_remove_workspace"),
|
||||||
|
import("./migration/20260806150824_add_workspace"),
|
||||||
])
|
])
|
||||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Effect } from "effect"
|
||||||
|
import type { DatabaseMigration } from "../migration"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "20260806150823_remove_workspace",
|
||||||
|
up(tx) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
// Legacy control-plane workspace references would otherwise route these
|
||||||
|
// sessions to hosted graphs whose workspace rows no longer exist.
|
||||||
|
yield* tx.run(`UPDATE \`session_v2\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
|
||||||
|
yield* tx.run(`DROP TABLE \`workspace\`;`)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
} satisfies DatabaseMigration.Migration
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Effect } from "effect"
|
||||||
|
import type { DatabaseMigration } from "../migration"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "20260806150824_add_workspace",
|
||||||
|
up(tx) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
yield* tx.run(`
|
||||||
|
CREATE TABLE \`workspace\` (
|
||||||
|
\`id\` text PRIMARY KEY,
|
||||||
|
\`provider\` text NOT NULL,
|
||||||
|
\`binding\` text NOT NULL,
|
||||||
|
\`root\` text NOT NULL,
|
||||||
|
\`time_created\` integer NOT NULL,
|
||||||
|
\`time_updated\` integer NOT NULL
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
} satisfies DatabaseMigration.Migration
|
||||||
@@ -4,19 +4,6 @@ import type { DatabaseMigration } from "./migration"
|
|||||||
export default {
|
export default {
|
||||||
up(tx) {
|
up(tx) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
yield* tx.run(`
|
|
||||||
CREATE TABLE \`workspace\` (
|
|
||||||
\`id\` text PRIMARY KEY,
|
|
||||||
\`type\` text NOT NULL,
|
|
||||||
\`name\` text DEFAULT '' NOT NULL,
|
|
||||||
\`branch\` text,
|
|
||||||
\`directory\` text,
|
|
||||||
\`extra\` text,
|
|
||||||
\`project_id\` text NOT NULL,
|
|
||||||
\`time_used\` integer NOT NULL,
|
|
||||||
CONSTRAINT \`fk_workspace_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
`)
|
|
||||||
yield* tx.run(`
|
yield* tx.run(`
|
||||||
CREATE TABLE \`account_state\` (
|
CREATE TABLE \`account_state\` (
|
||||||
\`id\` integer PRIMARY KEY,
|
\`id\` integer PRIMARY KEY,
|
||||||
@@ -216,6 +203,16 @@ export default {
|
|||||||
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
|
yield* tx.run(`
|
||||||
|
CREATE TABLE \`workspace\` (
|
||||||
|
\`id\` text PRIMARY KEY,
|
||||||
|
\`provider\` text NOT NULL,
|
||||||
|
\`binding\` text NOT NULL,
|
||||||
|
\`root\` text NOT NULL,
|
||||||
|
\`time_created\` integer NOT NULL,
|
||||||
|
\`time_updated\` integer NOT NULL
|
||||||
|
);
|
||||||
|
`)
|
||||||
yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
|
yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
|
||||||
yield* tx.run(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
|
yield* tx.run(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
|
||||||
yield* tx.run(
|
yield* tx.run(
|
||||||
|
|||||||
@@ -1,23 +1,49 @@
|
|||||||
export * as FileMutation from "./file-mutation"
|
export * as FileMutation from "./file-mutation"
|
||||||
|
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { Context, Effect, Layer } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { Bom } from "@opencode-ai/util/bom"
|
import { Bom } from "@opencode-ai/util/bom"
|
||||||
|
import { Formatter } from "./formatter"
|
||||||
|
import { WorkspaceEnvironment } from "./workspace/environment"
|
||||||
|
|
||||||
export interface Target {
|
export interface Target {
|
||||||
readonly canonical: string
|
readonly canonical: string
|
||||||
|
/** Lexical path for entry operations; remove unlinks the name, not the referent. */
|
||||||
|
readonly absolute: string
|
||||||
readonly resource: string
|
readonly resource: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Seam-owned absence so tools never see backend error vocabularies. */
|
||||||
|
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("FileMutation.NotFoundError", {
|
||||||
|
path: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
/** The target resolved to a directory where a file operation was required. */
|
||||||
|
export class NotAFileError extends Schema.TaggedErrorClass<NotAFileError>()("FileMutation.NotAFileError", {
|
||||||
|
path: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
/** A move wrote its destination but failed to remove the source. */
|
||||||
|
export class MoveIncompleteError extends Schema.TaggedErrorClass<MoveIncompleteError>()(
|
||||||
|
"FileMutation.MoveIncompleteError",
|
||||||
|
{
|
||||||
|
from: Schema.String,
|
||||||
|
to: Schema.String,
|
||||||
|
cause: Schema.optional(Schema.Defect()),
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
export interface WriteInput {
|
export interface WriteInput {
|
||||||
readonly target: Target
|
readonly target: Target
|
||||||
readonly content: string | Uint8Array
|
readonly content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TextWriteInput {
|
export interface MoveInput {
|
||||||
readonly target: Target
|
readonly from: Target
|
||||||
|
readonly to: Target
|
||||||
|
/** Text for the destination; may differ from the source content. */
|
||||||
readonly content: string
|
readonly content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,74 +52,208 @@ export interface WriteResult {
|
|||||||
readonly target: string
|
readonly target: string
|
||||||
readonly resource: string
|
readonly resource: string
|
||||||
readonly existed: boolean
|
readonly existed: boolean
|
||||||
|
/** Final text on disk after BOM handling and formatting. */
|
||||||
|
readonly content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
|
/** Read a text file with the BOM stripped. BOM handling stays inside the seam. */
|
||||||
|
readonly read: (target: Target) => Effect.Effect<string, NotFoundError | NotAFileError | FSUtil.Error>
|
||||||
|
/**
|
||||||
|
* Write logical text while retaining an existing UTF-8 BOM and emitting at
|
||||||
|
* most one BOM. Runs configured formatters where the files live and reports
|
||||||
|
* the final text.
|
||||||
|
*/
|
||||||
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
||||||
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
|
/**
|
||||||
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
* Write `content` to `to` with write semantics, then remove `from`. The
|
||||||
|
* destination inherits the source's BOM when it has none of its own.
|
||||||
|
*/
|
||||||
|
readonly move: (input: MoveInput) => Effect.Effect<WriteResult, MoveIncompleteError | FSUtil.Error>
|
||||||
|
readonly remove: (target: Target) => Effect.Effect<void, NotFoundError | FSUtil.Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
||||||
|
|
||||||
|
/** Normalize model-provided text to the BOM-free representation tools consume. */
|
||||||
|
export const normalizeText = (content: string) => Bom.split(content).text
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend primitives the shared seam orchestrates. Errors arrive already in
|
||||||
|
* the seam vocabulary so tool-level messages stay uniform across backends.
|
||||||
|
*/
|
||||||
|
interface Backend {
|
||||||
|
/** Full file bytes, or undefined when the path does not exist. */
|
||||||
|
readonly readOptional: (path: string) => Effect.Effect<Uint8Array | undefined, FSUtil.Error>
|
||||||
|
/** Classifies read failures; absence and errors report false. */
|
||||||
|
readonly isDirectory: (path: string) => Effect.Effect<boolean>
|
||||||
|
/** Creates parent directories, matching FSUtil.writeWithDirs. */
|
||||||
|
readonly write: (path: string, content: Uint8Array) => Effect.Effect<void, FSUtil.Error>
|
||||||
|
/** Unlink the lexical path; a symlink is removed, never its referent. */
|
||||||
|
readonly remove: (path: string) => Effect.Effect<void, NotFoundError | FSUtil.Error>
|
||||||
|
/** Format after write and report the final text, or undefined when no formatter applies. */
|
||||||
|
readonly format?: (path: string, bom: boolean) => Effect.Effect<string | undefined, FSUtil.Error>
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize file changes by canonical target. Conditional writes compare and
|
* Serialize file changes by canonical target. Conditional writes compare and
|
||||||
* write under the same process-local lock so cooperating OpenCode mutations do
|
* write under the same process-local lock so cooperating OpenCode mutations do
|
||||||
* not overwrite changes made from the same stale content.
|
* not overwrite changes made from the same stale content.
|
||||||
*/
|
*/
|
||||||
|
const make = (backend: Backend): Interface => {
|
||||||
|
const locks = KeyedMutex.makeUnsafe<string>()
|
||||||
|
// Locks are acquired in sorted canonical order so multi-target operations
|
||||||
|
// cannot deadlock.
|
||||||
|
const withTargetLocks =
|
||||||
|
(...targets: readonly Target[]) =>
|
||||||
|
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||||
|
[...new Set(targets.map((target) => target.canonical))]
|
||||||
|
.sort()
|
||||||
|
.reduceRight((locked, key) => locks.withLock(key)(locked), Effect.uninterruptible(effect))
|
||||||
|
|
||||||
|
// Happy-path reads are one operation; only the failure path checks whether
|
||||||
|
// the target is a directory.
|
||||||
|
const read = Effect.fn("FileMutation.read")((target: Target) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const content = yield* backend
|
||||||
|
.readOptional(target.canonical)
|
||||||
|
.pipe(
|
||||||
|
Effect.catch((error) =>
|
||||||
|
backend
|
||||||
|
.isDirectory(target.canonical)
|
||||||
|
.pipe(
|
||||||
|
Effect.flatMap((directory) =>
|
||||||
|
Effect.fail(directory ? new NotAFileError({ path: target.canonical }) : error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (content === undefined) return yield* new NotFoundError({ path: target.canonical })
|
||||||
|
return Bom.fromBytes(content).text
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const writeText = (target: Target, content: string, inheritedBom: boolean) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const next = Bom.split(content)
|
||||||
|
const current = yield* backend.readOptional(target.canonical)
|
||||||
|
const bom = Boolean(current && Bom.has(current)) || inheritedBom || next.bom
|
||||||
|
yield* backend.write(target.canonical, encoder.encode(Bom.join(next.text, bom)))
|
||||||
|
// Formatters may rewrite the file, so the backend re-syncs the BOM and
|
||||||
|
// reports the final text.
|
||||||
|
const formatted = backend.format ? yield* backend.format(target.canonical, bom) : undefined
|
||||||
|
return {
|
||||||
|
operation: "write" as const,
|
||||||
|
target: target.canonical,
|
||||||
|
resource: target.resource,
|
||||||
|
existed: current !== undefined,
|
||||||
|
content: formatted ?? next.text,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
||||||
|
withTargetLocks(input.target)(writeText(input.target, input.content, false)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const move = Effect.fn("FileMutation.move")((input: MoveInput) =>
|
||||||
|
withTargetLocks(
|
||||||
|
input.from,
|
||||||
|
input.to,
|
||||||
|
)(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const source = yield* backend.readOptional(input.from.canonical)
|
||||||
|
const result = yield* writeText(input.to, input.content, Boolean(source && Bom.has(source)))
|
||||||
|
yield* backend
|
||||||
|
.remove(input.from.absolute)
|
||||||
|
.pipe(
|
||||||
|
Effect.mapError(
|
||||||
|
(cause) => new MoveIncompleteError({ from: input.from.canonical, to: input.to.canonical, cause }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const remove = Effect.fn("FileMutation.remove")((target: Target) =>
|
||||||
|
withTargetLocks(target)(backend.remove(target.absolute)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Service.of({ read, write, move, remove })
|
||||||
|
}
|
||||||
|
|
||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const locks = KeyedMutex.makeUnsafe<string>()
|
const formatter = yield* Formatter.Service
|
||||||
const withTargetLock =
|
return make({
|
||||||
(target: Target) =>
|
readOptional: (path) =>
|
||||||
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
fs.readFile(path).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))),
|
||||||
locks.withLock(target.canonical)(Effect.uninterruptible(effect))
|
isDirectory: (path) =>
|
||||||
|
fs.stat(path).pipe(
|
||||||
const writeResult = (target: Target, existed: boolean): WriteResult => ({
|
Effect.map((info) => info.type === "Directory"),
|
||||||
operation: "write",
|
Effect.catch(() => Effect.succeed(false)),
|
||||||
target: target.canonical,
|
),
|
||||||
resource: target.resource,
|
write: (path, content) => fs.writeWithDirs(path, content),
|
||||||
existed,
|
remove: (path) =>
|
||||||
|
fs.remove(path).pipe(Effect.catchReason("PlatformError", "NotFound", () => new NotFoundError({ path }))),
|
||||||
|
format: (path, bom) =>
|
||||||
|
formatter
|
||||||
|
.file(path)
|
||||||
|
.pipe(Effect.flatMap((formatted) => (formatted ? Bom.syncFile(fs, path, bom) : Effect.succeed(undefined)))),
|
||||||
})
|
})
|
||||||
|
|
||||||
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
|
||||||
withTargetLock(input.target)(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const existed = yield* fs.exists(input.target.canonical)
|
|
||||||
yield* fs.writeWithDirs(input.target.canonical, input.content)
|
|
||||||
return writeResult(input.target, existed)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
|
|
||||||
withTargetLock(input.target)(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const next = Bom.split(input.content)
|
|
||||||
const current = yield* fs
|
|
||||||
.readFile(input.target.canonical)
|
|
||||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
|
||||||
yield* fs.writeWithDirs(
|
|
||||||
input.target.canonical,
|
|
||||||
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
|
|
||||||
)
|
|
||||||
return writeResult(input.target, current !== undefined)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return Service.of({ write, writeTextPreservingBom })
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Formatter.node] })
|
||||||
|
|
||||||
|
// Same seam through WorkspaceEnvironment.Files. Absence stays typed; other
|
||||||
|
// environment failures surface as filesystem errors. No formatting: formatters
|
||||||
|
// are host binaries and cannot run against provider paths until an environment
|
||||||
|
// formatter runtime exists.
|
||||||
|
const hostedLayer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const env = yield* WorkspaceEnvironment.Service
|
||||||
|
return make({
|
||||||
|
readOptional: (path) =>
|
||||||
|
env.files.read(path).pipe(
|
||||||
|
Effect.catchTag("WorkspaceEnvironment.NotFoundError", () => Effect.succeed(undefined)),
|
||||||
|
Effect.mapError(WorkspaceEnvironment.toFileSystemError("read")),
|
||||||
|
),
|
||||||
|
isDirectory: (path) =>
|
||||||
|
env.files.stat(path).pipe(
|
||||||
|
Effect.map((info) => info.type === "Directory"),
|
||||||
|
Effect.catch(() => Effect.succeed(false)),
|
||||||
|
),
|
||||||
|
write: (path, content) =>
|
||||||
|
env.files.write(path, content).pipe(Effect.mapError(WorkspaceEnvironment.toFileSystemError("write"))),
|
||||||
|
remove: (path) =>
|
||||||
|
env.files
|
||||||
|
.remove(path)
|
||||||
|
.pipe(
|
||||||
|
Effect.mapError((error) =>
|
||||||
|
error._tag === "WorkspaceEnvironment.NotFoundError"
|
||||||
|
? new NotFoundError({ path })
|
||||||
|
: WorkspaceEnvironment.toFileSystemError("remove")(error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const hostedNode = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer: hostedLayer,
|
||||||
|
deps: [WorkspaceEnvironment.node],
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deferred until the corresponding integrations exist.
|
* Deferred until the corresponding integrations exist.
|
||||||
*/
|
*/
|
||||||
// TODO: Add formatter integration after formatter runtime exists.
|
|
||||||
// TODO: Publish watcher/file-edit events after watcher integration exists.
|
// TODO: Publish watcher/file-edit events after watcher integration exists.
|
||||||
// TODO: Add snapshots / undo after snapshot design exists.
|
// TODO: Add snapshots / undo after snapshot design exists.
|
||||||
// TODO: Notify LSP and collect diagnostics after LSP runtime exists.
|
// TODO: Notify LSP and collect diagnostics after LSP runtime exists.
|
||||||
|
|||||||
+242
-43
@@ -7,7 +7,9 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
|||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { PositiveInt, RelativePath } from "./schema"
|
import { PositiveInt, RelativePath } from "./schema"
|
||||||
import { FileSystemSearch } from "./filesystem/search"
|
import { FileSystemSearch } from "./filesystem/search"
|
||||||
import { Entry, FileSystem, FindInput } from "@opencode-ai/schema/filesystem"
|
import { Entry, FileSystem, FindInput, Match } from "@opencode-ai/schema/filesystem"
|
||||||
|
import { WorkspaceEnvironment } from "./workspace/environment"
|
||||||
|
import { Ripgrep } from "./ripgrep"
|
||||||
export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
|
export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
|
||||||
|
|
||||||
export const ReadInput = Schema.Struct({
|
export const ReadInput = Schema.Struct({
|
||||||
@@ -32,7 +34,24 @@ export type ListInput = typeof ListInput.Type
|
|||||||
export { FindInput }
|
export { FindInput }
|
||||||
|
|
||||||
export const DEFAULT_SEARCH_LIMIT = 100
|
export const DEFAULT_SEARCH_LIMIT = 100
|
||||||
export const DEFAULT_SEARCH_TIMEOUT_MS = 30_000
|
/** One second of headroom over the hosted provider-side ripgrep kill. */
|
||||||
|
export const DEFAULT_SEARCH_TIMEOUT_MS = (Ripgrep.HOSTED_KILL_TIMEOUT_SECONDS + 1) * 1_000
|
||||||
|
|
||||||
|
/** Applies the shared search budget, failing with a search-scoped message. */
|
||||||
|
export const searchTimeout =
|
||||||
|
<F>(fail: (message: string) => F) =>
|
||||||
|
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | F, R> =>
|
||||||
|
effect.pipe(
|
||||||
|
Effect.timeoutOrElse({
|
||||||
|
duration: DEFAULT_SEARCH_TIMEOUT_MS,
|
||||||
|
orElse: () =>
|
||||||
|
Effect.fail(
|
||||||
|
fail(
|
||||||
|
`Search timed out after ${DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({
|
export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({
|
||||||
pattern: Schema.String,
|
pattern: Schema.String,
|
||||||
@@ -47,71 +66,251 @@ export class GrepInput extends Schema.Class<GrepInput>("FileSystem.GrepInput")({
|
|||||||
limit: Schema.optionalKey(PositiveInt),
|
limit: Schema.optionalKey(PositiveInt),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
export interface SearchTarget {
|
||||||
|
readonly canonical: string
|
||||||
|
readonly absolute: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlobSearchInput {
|
||||||
|
readonly target: SearchTarget
|
||||||
|
readonly pattern: string
|
||||||
|
readonly limit: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GrepSearchInput {
|
||||||
|
readonly target: SearchTarget
|
||||||
|
readonly pattern: string
|
||||||
|
readonly include?: string
|
||||||
|
readonly limit: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SearchPathError extends Schema.TaggedErrorClass<SearchPathError>()("FileSystem.SearchPathError", {
|
||||||
|
path: Schema.String,
|
||||||
|
reason: Schema.Literals(["not_found", "not_directory"]),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class SearchError extends Schema.TaggedErrorClass<SearchError>()("FileSystem.SearchError", {
|
||||||
|
cause: Schema.Defect(),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class InvalidPatternError extends Schema.TaggedErrorClass<InvalidPatternError>()(
|
||||||
|
"FileSystem.InvalidPatternError",
|
||||||
|
{
|
||||||
|
pattern: Schema.String,
|
||||||
|
message: Schema.String,
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
const mapSearchError = (error: unknown) =>
|
||||||
|
error instanceof SearchPathError || error instanceof SearchError ? error : new SearchError({ cause: error })
|
||||||
|
|
||||||
|
const mapGrepError = (error: unknown) =>
|
||||||
|
error instanceof Ripgrep.InvalidPatternError
|
||||||
|
? new InvalidPatternError({ pattern: error.pattern, message: error.message })
|
||||||
|
: mapSearchError(error)
|
||||||
|
|
||||||
export const Event = FileSystem.Event
|
export const Event = FileSystem.Event
|
||||||
|
|
||||||
|
/** Directories first, then lexicographic; shared by every entry listing. */
|
||||||
|
export const compareEntries = (a: Entry, b: Entry) =>
|
||||||
|
a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
|
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
|
||||||
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
||||||
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
||||||
|
readonly glob: (input: GlobSearchInput) => Effect.Effect<Entry[], SearchPathError | SearchError>
|
||||||
|
readonly grep: (input: GrepSearchInput) => Effect.Effect<Match[], SearchPathError | SearchError | InvalidPatternError>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
|
||||||
|
|
||||||
|
const makeSearch = (
|
||||||
|
location: Location.Interface,
|
||||||
|
ripgrep: Ripgrep.Interface,
|
||||||
|
paths: Pick<typeof path.posix, "resolve" | "relative" | "dirname" | "basename">,
|
||||||
|
stat: (target: SearchTarget) => Effect.Effect<{ readonly type: string }, SearchPathError | SearchError>,
|
||||||
|
) => ({
|
||||||
|
glob: Effect.fn("FileSystem.glob")(function* (input: GlobSearchInput) {
|
||||||
|
if ((yield* stat(input.target)).type !== "Directory")
|
||||||
|
return yield* new SearchPathError({ path: input.target.absolute, reason: "not_directory" })
|
||||||
|
return yield* ripgrep
|
||||||
|
.glob({
|
||||||
|
cwd: input.target.canonical,
|
||||||
|
pattern: input.pattern,
|
||||||
|
limit: input.limit,
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
Effect.map((entries) =>
|
||||||
|
entries.map((entry) =>
|
||||||
|
Entry.make({
|
||||||
|
...entry,
|
||||||
|
path: RelativePath.make(
|
||||||
|
paths.relative(location.directory, paths.resolve(input.target.absolute, entry.path)),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}, Effect.mapError(mapSearchError)),
|
||||||
|
grep: Effect.fn("FileSystem.grep")(function* (input: GrepSearchInput) {
|
||||||
|
const info = yield* stat(input.target)
|
||||||
|
const file = info.type === "File"
|
||||||
|
const cwd = file ? paths.dirname(input.target.absolute) : input.target.canonical
|
||||||
|
const root = file ? paths.dirname(input.target.absolute) : input.target.absolute
|
||||||
|
return yield* ripgrep
|
||||||
|
.grep({
|
||||||
|
cwd,
|
||||||
|
pattern: input.pattern,
|
||||||
|
file: file ? paths.basename(input.target.absolute) : undefined,
|
||||||
|
include: input.include,
|
||||||
|
limit: input.limit,
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
Effect.map((matches) =>
|
||||||
|
matches.map((match) =>
|
||||||
|
Match.make({
|
||||||
|
...match,
|
||||||
|
entry: Entry.make({
|
||||||
|
...match.entry,
|
||||||
|
path: RelativePath.make(paths.relative(location.directory, paths.resolve(root, match.entry.path))),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}, Effect.mapError(mapGrepError)),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry primitives the shared read/list implementations consume. Escape
|
||||||
|
* failures and type mismatches are defects, matching route behavior.
|
||||||
|
*/
|
||||||
|
interface EntryBackend {
|
||||||
|
readonly paths: Pick<typeof path.posix, "resolve" | "relative" | "join" | "sep">
|
||||||
|
readonly contains: (parent: string, child: string) => boolean
|
||||||
|
readonly realPath: (target: string) => Effect.Effect<string>
|
||||||
|
/** Reads one file; non-file targets die. */
|
||||||
|
readonly readFile: (real: string) => Effect.Effect<Uint8Array>
|
||||||
|
/** Lists one directory; non-directory targets die. */
|
||||||
|
readonly listDir: (real: string) => Effect.Effect<readonly FSUtil.DirEntry[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeEntries = (location: Location.Interface, root: string, backend: EntryBackend) => {
|
||||||
|
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
|
||||||
|
const absolute = backend.paths.resolve(location.directory, input ?? ".")
|
||||||
|
if (!backend.contains(location.directory, absolute))
|
||||||
|
return yield* Effect.die(new Error("Path escapes the location"))
|
||||||
|
const real = yield* backend.realPath(absolute)
|
||||||
|
if (!backend.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
|
||||||
|
return { absolute, real }
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
read: Effect.fn("FileSystem.read")(function* (input: ReadInput) {
|
||||||
|
const target = yield* resolve(input.path)
|
||||||
|
return {
|
||||||
|
content: yield* backend.readFile(target.real),
|
||||||
|
mime: FSUtil.mimeType(target.real),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
list: Effect.fn("FileSystem.list")(function* (input: ListInput = {}) {
|
||||||
|
const target = yield* resolve(input.path)
|
||||||
|
const items = yield* backend.listDir(target.real)
|
||||||
|
return items
|
||||||
|
.flatMap((item) => {
|
||||||
|
if (item.type !== "file" && item.type !== "directory") return []
|
||||||
|
const absolute = backend.paths.join(target.absolute, item.name)
|
||||||
|
const relative = backend.paths.relative(location.directory, absolute)
|
||||||
|
return [
|
||||||
|
Entry.make({
|
||||||
|
path: RelativePath.make(relative + (item.type === "directory" ? backend.paths.sep : "")),
|
||||||
|
type: item.type,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.sort(compareEntries)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const baseLayer = Layer.effect(
|
const baseLayer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const search = yield* FileSystemSearch.Service
|
const search = yield* FileSystemSearch.Service
|
||||||
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
|
const ripgrep = yield* Ripgrep.Service
|
||||||
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
|
const searches = makeSearch(location, ripgrep, path, (target) =>
|
||||||
const absolute = path.resolve(location.directory, input ?? ".")
|
fs.stat(target.canonical).pipe(
|
||||||
if (!FSUtil.contains(location.directory, absolute))
|
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||||
return yield* Effect.die(new Error("Path escapes the location"))
|
Effect.fail(new SearchPathError({ path: target.absolute, reason: "not_found" })),
|
||||||
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
|
),
|
||||||
if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
|
Effect.mapError(mapSearchError),
|
||||||
return { absolute, real, directory: location.directory, root }
|
|
||||||
})
|
|
||||||
return Service.of({
|
|
||||||
find: search.find,
|
|
||||||
read: Effect.fn("FileSystem.read")(function* (input) {
|
|
||||||
const target = yield* resolve(input.path)
|
|
||||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
|
||||||
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
|
||||||
return {
|
|
||||||
content: yield* fs.readFile(target.real).pipe(Effect.orDie),
|
|
||||||
mime: FSUtil.mimeType(target.real),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
list: Effect.fn("FileSystem.list")(function* (input = {}) {
|
|
||||||
const target = yield* resolve(input.path)
|
|
||||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
|
||||||
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
|
|
||||||
return yield* fs.readDirectoryEntries(target.real).pipe(
|
|
||||||
Effect.orDie,
|
|
||||||
Effect.map((items) =>
|
|
||||||
items
|
|
||||||
.flatMap((item) => {
|
|
||||||
if (item.type !== "file" && item.type !== "directory") return []
|
|
||||||
const absolute = path.join(target.absolute, item.name)
|
|
||||||
const relative = path.relative(target.directory, absolute)
|
|
||||||
return [
|
|
||||||
Entry.make({
|
|
||||||
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
|
|
||||||
type: item.type,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
})
|
|
||||||
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
|
||||||
|
const entries = makeEntries(location, root, {
|
||||||
|
paths: path,
|
||||||
|
contains: FSUtil.contains,
|
||||||
|
realPath: (target) => fs.realPath(target).pipe(Effect.orDie),
|
||||||
|
readFile: (real) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const info = yield* fs.stat(real).pipe(Effect.orDie)
|
||||||
|
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
||||||
|
return yield* fs.readFile(real).pipe(Effect.orDie)
|
||||||
|
}),
|
||||||
|
listDir: (real) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const info = yield* fs.stat(real).pipe(Effect.orDie)
|
||||||
|
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
|
||||||
|
return yield* fs.readDirectoryEntries(real).pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
return Service.of({ find: search.find, ...searches, ...entries })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer: baseLayer,
|
layer: baseLayer,
|
||||||
deps: [FSUtil.node, Location.node, FileSystemSearch.node],
|
deps: [FSUtil.node, Location.node, FileSystemSearch.node, Ripgrep.node],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mirrors baseLayer over WorkspaceEnvironment.Files with posix path rules.
|
||||||
|
// Host filesystem services never see provider paths. Type mismatches surface
|
||||||
|
// from the environment operation itself; no stat pre-checks.
|
||||||
|
const hostedLayer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const env = yield* WorkspaceEnvironment.Service
|
||||||
|
const location = yield* Location.Service
|
||||||
|
const ripgrep = yield* Ripgrep.Service
|
||||||
|
const searches = makeSearch(location, ripgrep, path.posix, (target) =>
|
||||||
|
env.files.stat(target.canonical).pipe(
|
||||||
|
Effect.catchTag("WorkspaceEnvironment.NotFoundError", () =>
|
||||||
|
Effect.fail(new SearchPathError({ path: target.absolute, reason: "not_found" })),
|
||||||
|
),
|
||||||
|
Effect.mapError(mapSearchError),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const root = yield* env.files.realPath(location.directory).pipe(Effect.orDie)
|
||||||
|
const entries = makeEntries(location, root, {
|
||||||
|
paths: path.posix,
|
||||||
|
contains: FSUtil.containsPosix,
|
||||||
|
realPath: (target) => env.files.realPath(target).pipe(Effect.orDie),
|
||||||
|
readFile: (real) => env.files.read(real).pipe(Effect.orDie),
|
||||||
|
listDir: (real) => env.files.list(real).pipe(Effect.orDie),
|
||||||
|
})
|
||||||
|
return Service.of({
|
||||||
|
find: () => Effect.logWarning("find is not supported for hosted locations yet").pipe(Effect.as([])),
|
||||||
|
...searches,
|
||||||
|
...entries,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const hostedNode = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer: hostedLayer,
|
||||||
|
deps: [WorkspaceEnvironment.node, Location.node, Ripgrep.node],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ const prefixes = {
|
|||||||
part: "prt",
|
part: "prt",
|
||||||
pty: "pty",
|
pty: "pty",
|
||||||
tool: "tool",
|
tool: "tool",
|
||||||
workspace: "wrk",
|
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export function ascending(prefix: keyof typeof prefixes, given?: string) {
|
export function ascending(prefix: keyof typeof prefixes, given?: string) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
|||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { Project } from "./project"
|
import { Project } from "./project"
|
||||||
import { AbsolutePath } from "./schema"
|
import { AbsolutePath } from "./schema"
|
||||||
|
import { WorkspaceEnvironment } from "./workspace/environment"
|
||||||
|
|
||||||
export const Kind = Schema.Literals(["file", "directory"])
|
export const Kind = Schema.Literals(["file", "directory"])
|
||||||
export type Kind = typeof Kind.Type
|
export type Kind = typeof Kind.Type
|
||||||
@@ -25,12 +26,12 @@ export type ResolveInput = typeof ResolveInput.Type
|
|||||||
|
|
||||||
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
|
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
|
||||||
path: Schema.String,
|
path: Schema.String,
|
||||||
reason: Schema.Literal("non_directory_ancestor"),
|
reason: Schema.Literals(["non_directory_ancestor", "outside_workspace"]),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export interface ExternalDirectoryAuthorization {
|
export interface ExternalDirectoryAuthorization {
|
||||||
readonly action: "external_directory"
|
readonly action: "external_directory"
|
||||||
/** Canonical existing directory used as the external approval boundary. */
|
/** Lexical directory used as the external approval boundary. */
|
||||||
readonly directory: string
|
readonly directory: string
|
||||||
/** `external_directory` permission resource. */
|
/** `external_directory` permission resource. */
|
||||||
readonly resource: string
|
readonly resource: string
|
||||||
@@ -46,7 +47,13 @@ export const externalDirectoryPermission = (input: ExternalDirectoryAuthorizatio
|
|||||||
export interface Target {
|
export interface Target {
|
||||||
/** Canonical existing path, or missing path below a canonical directory. */
|
/** Canonical existing path, or missing path below a canonical directory. */
|
||||||
readonly canonical: string
|
readonly canonical: string
|
||||||
/** Permission resource: Location-relative for internal paths, canonical for external paths. */
|
/**
|
||||||
|
* Lexical resolved path before symlink canonicalization. Reads and writes
|
||||||
|
* address the referent (canonical); entry operations like remove address
|
||||||
|
* the name itself.
|
||||||
|
*/
|
||||||
|
readonly absolute: string
|
||||||
|
/** Permission resource: Location-relative for internal paths, lexical absolute for external paths. */
|
||||||
readonly resource: string
|
readonly resource: string
|
||||||
readonly externalDirectory?: ExternalDirectoryAuthorization
|
readonly externalDirectory?: ExternalDirectoryAuthorization
|
||||||
}
|
}
|
||||||
@@ -74,9 +81,56 @@ interface ResolvedPath {
|
|||||||
| "Socket"
|
| "Socket"
|
||||||
| "Unknown"
|
| "Unknown"
|
||||||
readonly directory: string
|
readonly directory: string
|
||||||
|
readonly lexicalDirectory: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const slash = (value: string) => value.replaceAll("\\", "/")
|
/**
|
||||||
|
* Resolution primitives the shared ancestor walk uses; absence reports
|
||||||
|
* undefined so the walk owns the missing-path vocabulary.
|
||||||
|
*/
|
||||||
|
interface WalkBackend<E> {
|
||||||
|
readonly paths: Pick<path.PlatformPath, "dirname" | "resolve" | "relative">
|
||||||
|
readonly realPathOptional: (path: string) => Effect.Effect<string | undefined, E>
|
||||||
|
readonly statOptional: (path: string) => Effect.Effect<{ readonly type: ResolvedPath["type"] } | undefined, E>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonicalize an existing path, or resolve a missing path below its nearest
|
||||||
|
* existing ancestor directory.
|
||||||
|
*/
|
||||||
|
const resolvePathWith = <E>(backend: WalkBackend<E>) =>
|
||||||
|
Effect.fnUntraced(function* (absolute: string) {
|
||||||
|
const existing = yield* backend.realPathOptional(absolute)
|
||||||
|
if (existing !== undefined) {
|
||||||
|
const info = yield* backend.statOptional(existing)
|
||||||
|
if (info === undefined) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
|
||||||
|
return {
|
||||||
|
canonical: existing,
|
||||||
|
type: info.type,
|
||||||
|
directory: info.type === "Directory" ? existing : backend.paths.dirname(existing),
|
||||||
|
lexicalDirectory: info.type === "Directory" ? absolute : backend.paths.dirname(absolute),
|
||||||
|
} satisfies ResolvedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
let anchor = backend.paths.dirname(absolute)
|
||||||
|
while (true) {
|
||||||
|
const canonical = yield* backend.realPathOptional(anchor)
|
||||||
|
if (canonical !== undefined) {
|
||||||
|
const info = yield* backend.statOptional(canonical)
|
||||||
|
if (info === undefined || info.type !== "Directory") {
|
||||||
|
return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
canonical: backend.paths.resolve(canonical, backend.paths.relative(anchor, absolute)),
|
||||||
|
directory: canonical,
|
||||||
|
lexicalDirectory: anchor,
|
||||||
|
} satisfies ResolvedPath
|
||||||
|
}
|
||||||
|
const parent = backend.paths.dirname(anchor)
|
||||||
|
if (parent === anchor) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
|
||||||
|
anchor = parent
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
@@ -88,34 +142,10 @@ const layer = Layer.effect(
|
|||||||
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvePath = Effect.fnUntraced(function* (absolute: string) {
|
const resolvePath = resolvePathWith({
|
||||||
const existing = yield* notFound(fs.realPath(absolute))
|
paths: path,
|
||||||
if (existing !== undefined) {
|
realPathOptional: (target) => notFound(fs.realPath(target)),
|
||||||
const info = yield* fs.stat(existing)
|
statOptional: (target) => notFound(fs.stat(target)),
|
||||||
return {
|
|
||||||
canonical: existing,
|
|
||||||
type: info.type,
|
|
||||||
directory: info.type === "Directory" ? existing : path.dirname(existing),
|
|
||||||
} satisfies ResolvedPath
|
|
||||||
}
|
|
||||||
|
|
||||||
let anchor = path.dirname(absolute)
|
|
||||||
while (true) {
|
|
||||||
const canonical = yield* notFound(fs.realPath(anchor))
|
|
||||||
if (canonical !== undefined) {
|
|
||||||
const info = yield* fs.stat(canonical)
|
|
||||||
if (info.type !== "Directory") {
|
|
||||||
return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
canonical: path.resolve(canonical, path.relative(anchor, absolute)),
|
|
||||||
directory: canonical,
|
|
||||||
} satisfies ResolvedPath
|
|
||||||
}
|
|
||||||
const parent = path.dirname(anchor)
|
|
||||||
if (parent === anchor) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
|
|
||||||
anchor = parent
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
||||||
@@ -126,19 +156,21 @@ const layer = Layer.effect(
|
|||||||
|
|
||||||
const resolved = yield* resolvePath(absolute)
|
const resolved = yield* resolvePath(absolute)
|
||||||
const external = !lexicallyInternal
|
const external = !lexicallyInternal
|
||||||
const resource = external ? slash(resolved.canonical) : slash(path.relative(location.directory, absolute) || ".")
|
const resource = external
|
||||||
const externalDirectory =
|
? FSUtil.slash(absolute)
|
||||||
input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory
|
: FSUtil.slash(path.relative(location.directory, absolute) || ".")
|
||||||
const externalResource = slash(path.join(externalDirectory, "*"))
|
const externalDirectory = resolved.lexicalDirectory
|
||||||
|
const externalResource = FSUtil.slash(path.join(externalDirectory, "*"))
|
||||||
return {
|
return {
|
||||||
canonical: resolved.canonical,
|
canonical: resolved.canonical,
|
||||||
|
absolute,
|
||||||
resource,
|
resource,
|
||||||
externalDirectory: external
|
externalDirectory: external
|
||||||
? {
|
? {
|
||||||
action: "external_directory",
|
action: "external_directory",
|
||||||
directory: externalDirectory,
|
directory: externalDirectory,
|
||||||
resource: externalResource,
|
resource: externalResource,
|
||||||
save: slash(
|
save: FSUtil.slash(
|
||||||
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
|
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -155,3 +187,47 @@ export const node = makeLocationNode({
|
|||||||
layer: layer.pipe(Layer.orDie),
|
layer: layer.pipe(Layer.orDie),
|
||||||
deps: [FSUtil.node, Location.node],
|
deps: [FSUtil.node, Location.node],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Mirrors the local resolve walk over WorkspaceEnvironment.Files with posix
|
||||||
|
// rules. Hosted paths are never external: everything outside the Location is
|
||||||
|
// rejected instead of routed to external_directory approval, because the
|
||||||
|
// approval boundary vocabulary is host-relative.
|
||||||
|
const hostedLayer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const env = yield* WorkspaceEnvironment.Service
|
||||||
|
const location = yield* Location.Service
|
||||||
|
// Canonicalized even when the Location sits on the workspace root: the
|
||||||
|
// environment does not guarantee a canonical directory (local fakes root
|
||||||
|
// at symlinked temp paths).
|
||||||
|
const root = yield* env.files.realPath(location.directory).pipe(Effect.orDie)
|
||||||
|
|
||||||
|
const resolvePath = resolvePathWith({
|
||||||
|
paths: path.posix,
|
||||||
|
realPathOptional: (target) => WorkspaceEnvironment.optional(env.files.realPath(target)),
|
||||||
|
statOptional: (target) => WorkspaceEnvironment.optional(env.files.stat(target)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
||||||
|
const absolute = path.posix.resolve(location.directory, input.path)
|
||||||
|
if (!FSUtil.containsPosix(location.directory, absolute))
|
||||||
|
return yield* new PathError({ path: absolute, reason: "outside_workspace" })
|
||||||
|
const resolved = yield* resolvePath(absolute)
|
||||||
|
if (!FSUtil.containsPosix(root, resolved.canonical))
|
||||||
|
return yield* new PathError({ path: absolute, reason: "outside_workspace" })
|
||||||
|
return {
|
||||||
|
canonical: resolved.canonical,
|
||||||
|
absolute,
|
||||||
|
resource: path.posix.relative(location.directory, absolute) || ".",
|
||||||
|
} satisfies Target
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ resolve })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const hostedNode = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer: hostedLayer.pipe(Layer.orDie),
|
||||||
|
deps: [WorkspaceEnvironment.node, Location.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { FileMutation } from "./file-mutation"
|
|||||||
import { Formatter } from "./formatter"
|
import { Formatter } from "./formatter"
|
||||||
import { FileSystem } from "./filesystem"
|
import { FileSystem } from "./filesystem"
|
||||||
import { FileSystemSearch } from "./filesystem/search"
|
import { FileSystemSearch } from "./filesystem/search"
|
||||||
|
import { Ripgrep } from "./ripgrep"
|
||||||
import { Generate } from "./generate"
|
import { Generate } from "./generate"
|
||||||
import { Form } from "./form"
|
import { Form } from "./form"
|
||||||
import { Image } from "./image"
|
import { Image } from "./image"
|
||||||
@@ -47,6 +48,8 @@ import { McpTool } from "./tool/mcp"
|
|||||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { Vcs } from "./vcs"
|
import { Vcs } from "./vcs"
|
||||||
|
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||||
|
import { WorkspaceEnvironment } from "./workspace/environment"
|
||||||
|
|
||||||
export { LocationServiceMap } from "./location-service-map"
|
export { LocationServiceMap } from "./location-service-map"
|
||||||
|
|
||||||
@@ -104,6 +107,27 @@ export const locationServices = LayerNode.group<typeof locationServiceNodes>(loc
|
|||||||
export type LocationServices = LayerNode.Output<typeof locationServices>
|
export type LocationServices = LayerNode.Output<typeof locationServices>
|
||||||
export type LocationError = LayerNode.Error<typeof locationServices>
|
export type LocationError = LayerNode.Error<typeof locationServices>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosted graphs state their Location (no host discovery), read only global
|
||||||
|
* config sources, and bind the workspace environment; local graphs are
|
||||||
|
* byte-identical to before. Exported so tests can verify that every
|
||||||
|
* Location-path-consuming service is environment-backed in hosted graphs.
|
||||||
|
*/
|
||||||
|
export function hostedReplacements(ref: Location.Ref, workspaceID: Workspace.ID): LayerNode.Replacements {
|
||||||
|
return [
|
||||||
|
[Location.node, Location.hostedBoundNode(ref, workspaceID)],
|
||||||
|
[Config.node, Config.configured({ project: false })],
|
||||||
|
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: false })],
|
||||||
|
[WorkspaceEnvironment.node, WorkspaceEnvironment.hostedNode(workspaceID)],
|
||||||
|
[Ripgrep.node, Ripgrep.hostedNode],
|
||||||
|
[FileSystem.node, FileSystem.hostedNode],
|
||||||
|
[LocationMutation.node, LocationMutation.hostedNode],
|
||||||
|
[FileMutation.node, FileMutation.hostedNode],
|
||||||
|
[ReadToolFileSystem.node, ReadToolFileSystem.hostedNode],
|
||||||
|
[Shell.node, Shell.hostedNode],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
export function buildLocationServiceMap(
|
export function buildLocationServiceMap(
|
||||||
replacements: LayerNode.Replacements = [],
|
replacements: LayerNode.Replacements = [],
|
||||||
): Layer.Layer<LocationServiceMap.Service> {
|
): Layer.Layer<LocationServiceMap.Service> {
|
||||||
@@ -118,7 +142,10 @@ export function buildLocationServiceMap(
|
|||||||
LayerMap.make(
|
LayerMap.make(
|
||||||
(ref: Location.Ref) => {
|
(ref: Location.Ref) => {
|
||||||
const startedAt = performance.now()
|
const startedAt = performance.now()
|
||||||
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
|
const workspaceID = ref.workspaceID
|
||||||
|
const allReplacements = replacements.concat(
|
||||||
|
workspaceID ? hostedReplacements(ref, workspaceID) : [[Location.node, Location.boundNode(ref)]],
|
||||||
|
)
|
||||||
// Apply replacements during hoist, not afterward: replacements can
|
// Apply replacements during hoist, not afterward: replacements can
|
||||||
// introduce new tagged dependencies (Location.boundNode depends on
|
// introduce new tagged dependencies (Location.boundNode depends on
|
||||||
// Project), and the hoist walk is the only pass that can still slice
|
// Project), and the hoist walk is the only pass that can still slice
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import path from "path"
|
||||||
import { Context, Effect, Layer } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import { Info, Ref, response } from "@opencode-ai/schema/location"
|
import { Info, Ref, response } from "@opencode-ai/schema/location"
|
||||||
|
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||||
import { Project } from "./project"
|
import { Project } from "./project"
|
||||||
|
import { WorkspaceEnvironment } from "./workspace/environment"
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { makeLocationNode, tags } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode, tags } from "@opencode-ai/util/effect/app-node"
|
||||||
|
|
||||||
@@ -14,6 +17,12 @@ export interface Interface extends Info {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path rules for a Location's directory. Hosted directories live in the
|
||||||
|
* provider filesystem: posix semantics regardless of the host platform.
|
||||||
|
*/
|
||||||
|
export const paths = (location: Pick<Info, "workspaceID">) => (location.workspaceID ? path.posix : path)
|
||||||
|
|
||||||
export const node = LayerNode.unbound(Service, tags.values.location)
|
export const node = LayerNode.unbound(Service, tags.values.location)
|
||||||
|
|
||||||
const layer = (ref: Ref) =>
|
const layer = (ref: Ref) =>
|
||||||
@@ -37,3 +46,26 @@ export const boundNode = (ref: Ref) =>
|
|||||||
layer: layer(ref),
|
layer: layer(ref),
|
||||||
deps: [Project.node],
|
deps: [Project.node],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosted Locations state their Project instead of discovering it: host git
|
||||||
|
* and filesystem walks must never run against a provider directory. The
|
||||||
|
* Workspace root comes from the already-connected environment, avoiding a
|
||||||
|
* second workspace row read per graph build.
|
||||||
|
*/
|
||||||
|
export const hostedBoundNode = (ref: Ref, workspaceID: Workspace.ID) =>
|
||||||
|
makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer: Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const env = yield* WorkspaceEnvironment.Service
|
||||||
|
return Service.of({
|
||||||
|
directory: ref.directory,
|
||||||
|
workspaceID,
|
||||||
|
project: Project.hostedGlobal(env.directory),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
deps: [WorkspaceEnvironment.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import { Npm } from "@opencode-ai/util/npm"
|
|||||||
import { Permission } from "../permission"
|
import { Permission } from "../permission"
|
||||||
import { Reference } from "../reference"
|
import { Reference } from "../reference"
|
||||||
import { WebSearch } from "../websearch"
|
import { WebSearch } from "../websearch"
|
||||||
import { Ripgrep } from "../ripgrep"
|
|
||||||
import { SessionInstructions } from "../session/instructions"
|
import { SessionInstructions } from "../session/instructions"
|
||||||
import { Shell } from "../shell"
|
import { Shell } from "../shell"
|
||||||
import { Skill } from "../skill"
|
import { Skill } from "../skill"
|
||||||
@@ -89,7 +88,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||||||
const read = yield* ReadToolFileSystem.Service
|
const read = yield* ReadToolFileSystem.Service
|
||||||
const reference = yield* Reference.Service
|
const reference = yield* Reference.Service
|
||||||
const websearch = yield* WebSearch.Service
|
const websearch = yield* WebSearch.Service
|
||||||
const ripgrep = yield* Ripgrep.Service
|
|
||||||
const instructions = yield* SessionInstructions.Service
|
const instructions = yield* SessionInstructions.Service
|
||||||
const shell = yield* Shell.Service
|
const shell = yield* Shell.Service
|
||||||
const skill = yield* Skill.Service
|
const skill = yield* Skill.Service
|
||||||
@@ -121,7 +119,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||||||
Context.make(ReadToolFileSystem.Service, read),
|
Context.make(ReadToolFileSystem.Service, read),
|
||||||
Context.make(Reference.Service, reference),
|
Context.make(Reference.Service, reference),
|
||||||
Context.make(WebSearch.Service, websearch),
|
Context.make(WebSearch.Service, websearch),
|
||||||
Context.make(Ripgrep.Service, ripgrep),
|
|
||||||
Context.make(SessionInstructions.Service, instructions),
|
Context.make(SessionInstructions.Service, instructions),
|
||||||
Context.make(Shell.Service, shell),
|
Context.make(Shell.Service, shell),
|
||||||
Context.make(Skill.Service, skill),
|
Context.make(Skill.Service, skill),
|
||||||
|
|||||||
@@ -282,7 +282,9 @@ const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
const updates = Stream.merge(
|
const updates = Stream.merge(
|
||||||
config.changes().pipe(
|
config.changes().pipe(
|
||||||
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))),
|
Stream.filterEffect((update) =>
|
||||||
|
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
|
||||||
|
),
|
||||||
Stream.merge(Stream.fromPubSub(configuredChanges)),
|
Stream.merge(Stream.fromPubSub(configuredChanges)),
|
||||||
),
|
),
|
||||||
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
|
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
|
||||||
@@ -338,7 +340,6 @@ export const node = makeLocationNode({
|
|||||||
Form.node,
|
Form.node,
|
||||||
ReadToolFileSystem.node,
|
ReadToolFileSystem.node,
|
||||||
Reference.node,
|
Reference.node,
|
||||||
Ripgrep.node,
|
|
||||||
SessionInstructions.node,
|
SessionInstructions.node,
|
||||||
Shell.node,
|
Shell.node,
|
||||||
Skill.node,
|
Skill.node,
|
||||||
|
|||||||
@@ -44,6 +44,18 @@ export interface Resolved {
|
|||||||
readonly vcs?: Vcs
|
readonly vcs?: Vcs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stated Project for hosted Locations: global identity until rediscovery
|
||||||
|
* inside the Workspace finds a repository. Canonical "/" matches the local
|
||||||
|
* non-VCS fallback on posix hosts, so persisting it never repoints the global
|
||||||
|
* row at a provider path.
|
||||||
|
*/
|
||||||
|
export const hostedGlobal = (root: string): Resolved => ({
|
||||||
|
id: ID.global,
|
||||||
|
directory: AbsolutePath.make(root),
|
||||||
|
canonical: AbsolutePath.make("/"),
|
||||||
|
})
|
||||||
|
|
||||||
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
|
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
|
||||||
export const root = Effect.fn("Project.root")(function* (
|
export const root = Effect.fn("Project.root")(function* (
|
||||||
fs: FSUtil.Interface,
|
fs: FSUtil.Interface,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export * as PtyTicket from "./ticket"
|
export * as PtyTicket from "./ticket"
|
||||||
|
|
||||||
import { Workspace } from "../workspace"
|
|
||||||
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
|
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
|
||||||
import { PtyID } from "./schema"
|
import { PtyID } from "./schema"
|
||||||
import { Cache, Context, Duration, Effect, Layer } from "effect"
|
import { Cache, Context, Duration, Effect, Layer } from "effect"
|
||||||
@@ -14,7 +13,6 @@ export const ConnectToken = PtyTicket.ConnectToken
|
|||||||
export type Scope = {
|
export type Scope = {
|
||||||
readonly ptyID: PtyID
|
readonly ptyID: PtyID
|
||||||
readonly directory?: string
|
readonly directory?: string
|
||||||
readonly workspaceID?: Workspace.ID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
@@ -25,9 +23,7 @@ export interface Interface {
|
|||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PtyTicket") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/PtyTicket") {}
|
||||||
|
|
||||||
function matches(record: Scope, input: Scope) {
|
function matches(record: Scope, input: Scope) {
|
||||||
return (
|
return record.ptyID === input.ptyID && record.directory === input.directory
|
||||||
record.ptyID === input.ptyID && record.directory === input.directory && record.workspaceID === input.workspaceID
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is
|
// Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is
|
||||||
|
|||||||
+100
-29
@@ -2,11 +2,13 @@ export * as Ripgrep from "./ripgrep"
|
|||||||
|
|
||||||
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
|
import type { Command } from "effect/unstable/process/ChildProcess"
|
||||||
import { Entry, Match } from "@opencode-ai/schema/filesystem"
|
import { Entry, Match } from "@opencode-ai/schema/filesystem"
|
||||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { AppProcess, collectStream, waitForAbort } from "@opencode-ai/util/process"
|
import { AppProcess, collectStream, waitForAbort } from "@opencode-ai/util/process"
|
||||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
||||||
import { RipgrepBinary } from "./ripgrep/binary"
|
import { RipgrepBinary } from "./ripgrep/binary"
|
||||||
|
import { WorkspaceEnvironment } from "./workspace/environment"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Small core-owned ripgrep execution adapter. It deliberately exposes raw
|
* Small core-owned ripgrep execution adapter. It deliberately exposes raw
|
||||||
@@ -18,6 +20,13 @@ import { RipgrepBinary } from "./ripgrep/binary"
|
|||||||
const ERROR_BYTES = 8 * 1024
|
const ERROR_BYTES = 8 * 1024
|
||||||
const MAX_SUBMATCHES = 100
|
const MAX_SUBMATCHES = 100
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider-side kill for hosted searches, since hosted process kill is not
|
||||||
|
* implemented. Kept below FileSystem.DEFAULT_SEARCH_TIMEOUT_MS so the sandbox
|
||||||
|
* process dies before the caller's timeout fires.
|
||||||
|
*/
|
||||||
|
export const HOSTED_KILL_TIMEOUT_SECONDS = 29
|
||||||
|
|
||||||
const RawMatch = Schema.Struct({
|
const RawMatch = Schema.Struct({
|
||||||
type: Schema.Literal("match"),
|
type: Schema.Literal("match"),
|
||||||
data: Schema.Struct({
|
data: Schema.Struct({
|
||||||
@@ -90,26 +99,37 @@ const failure = (message: string, cause?: unknown) => new Error({ message, cause
|
|||||||
const isInvalidPattern = (stderr: string) =>
|
const isInvalidPattern = (stderr: string) =>
|
||||||
stderr.includes("regex parse error") || stderr.includes("error parsing regex")
|
stderr.includes("regex parse error") || stderr.includes("error parsing regex")
|
||||||
|
|
||||||
const layer = Layer.effect(
|
const normalizeRelativePath = (value: string) =>
|
||||||
Service,
|
value
|
||||||
Effect.gen(function* () {
|
.replace(/^(?:\.[\\/])+/u, "")
|
||||||
const process = yield* AppProcess.Service
|
.replace(/^[\\/]+/u, "")
|
||||||
const binary = yield* RipgrepBinary.Service
|
.replaceAll("\\", "/")
|
||||||
|
|
||||||
|
interface Backend {
|
||||||
|
readonly spawn: AppProcess.Interface["spawn"]
|
||||||
|
readonly awaitTruncated: boolean
|
||||||
|
readonly command: (input: {
|
||||||
|
readonly cwd: string
|
||||||
|
readonly args: string[]
|
||||||
|
readonly limit: number
|
||||||
|
readonly output: "lines" | "matches"
|
||||||
|
}) => Effect.Effect<Command, globalThis.Error>
|
||||||
|
}
|
||||||
|
|
||||||
|
const make = (backend: Backend) => {
|
||||||
const run = <A>(input: {
|
const run = <A>(input: {
|
||||||
readonly cwd: string
|
readonly cwd: string
|
||||||
readonly args: string[]
|
readonly args: string[]
|
||||||
readonly limit: number
|
readonly limit: number
|
||||||
readonly signal?: AbortSignal
|
readonly signal?: AbortSignal
|
||||||
readonly parse: (line: string) => Effect.Effect<A | undefined, Error>
|
readonly parse: (line: string) => Effect.Effect<A | undefined, Error>
|
||||||
|
readonly output: "lines" | "matches"
|
||||||
readonly pattern?: string
|
readonly pattern?: string
|
||||||
readonly onItem?: (item: A) => Effect.Effect<void>
|
readonly onItem?: (item: A) => Effect.Effect<void>
|
||||||
}) => {
|
}) => {
|
||||||
const program = Effect.scoped(
|
const program = Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const handle = yield* process.spawn(
|
const handle = yield* backend.spawn(yield* backend.command(input))
|
||||||
ChildProcess.make(yield* binary.filepath, input.args, { cwd: input.cwd, extendEnv: true, stdin: "ignore" }),
|
|
||||||
)
|
|
||||||
const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe(
|
const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe(
|
||||||
Effect.map((output) => output.buffer.toString("utf8")),
|
Effect.map((output) => output.buffer.toString("utf8")),
|
||||||
Effect.forkScoped,
|
Effect.forkScoped,
|
||||||
@@ -129,7 +149,11 @@ const layer = Layer.effect(
|
|||||||
Effect.map((chunk) => [...chunk]),
|
Effect.map((chunk) => [...chunk]),
|
||||||
)
|
)
|
||||||
const truncated = rows.length > input.limit
|
const truncated = rows.length > input.limit
|
||||||
if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false }
|
if (truncated) {
|
||||||
|
if (backend.awaitTruncated)
|
||||||
|
yield* Effect.all([handle.exitCode, Fiber.join(stderrFiber)], { concurrency: "unbounded" })
|
||||||
|
return { items: rows.slice(0, input.limit), truncated, partial: false }
|
||||||
|
}
|
||||||
|
|
||||||
const code = yield* handle.exitCode
|
const code = yield* handle.exitCode
|
||||||
const stderr = yield* Fiber.join(stderrFiber)
|
const stderr = yield* Fiber.join(stderrFiber)
|
||||||
@@ -158,6 +182,7 @@ const layer = Layer.effect(
|
|||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
limit: input.limit,
|
limit: input.limit,
|
||||||
signal: input.signal,
|
signal: input.signal,
|
||||||
|
output: "lines",
|
||||||
args: [
|
args: [
|
||||||
"--no-config",
|
"--no-config",
|
||||||
"--files",
|
"--files",
|
||||||
@@ -167,13 +192,7 @@ const layer = Layer.effect(
|
|||||||
"--glob=!**/.git/**",
|
"--glob=!**/.git/**",
|
||||||
".",
|
".",
|
||||||
],
|
],
|
||||||
parse: (line) =>
|
parse: (line) => Effect.succeed(normalizeRelativePath(line)),
|
||||||
Effect.succeed(
|
|
||||||
line
|
|
||||||
.replace(/^(?:\.[\\/])+/u, "")
|
|
||||||
.replace(/^[\\/]+/u, "")
|
|
||||||
.replaceAll("\\", "/"),
|
|
||||||
),
|
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.map((result) =>
|
Effect.map((result) =>
|
||||||
result.items.map((relative) =>
|
result.items.map((relative) =>
|
||||||
@@ -190,6 +209,7 @@ const layer = Layer.effect(
|
|||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
limit: input.limit,
|
limit: input.limit,
|
||||||
signal: input.signal,
|
signal: input.signal,
|
||||||
|
output: "lines",
|
||||||
args: [
|
args: [
|
||||||
"--no-config",
|
"--no-config",
|
||||||
"--files",
|
"--files",
|
||||||
@@ -201,13 +221,9 @@ const layer = Layer.effect(
|
|||||||
".",
|
".",
|
||||||
],
|
],
|
||||||
parse: (line) => {
|
parse: (line) => {
|
||||||
const relative = line
|
|
||||||
.replace(/^(?:\.[\\/])+/u, "")
|
|
||||||
.replace(/^[\\/]+/u, "")
|
|
||||||
.replaceAll("\\", "/")
|
|
||||||
return Effect.succeed(
|
return Effect.succeed(
|
||||||
Entry.make({
|
Entry.make({
|
||||||
path: RelativePath.make(relative),
|
path: RelativePath.make(normalizeRelativePath(line)),
|
||||||
type: "file",
|
type: "file",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -220,6 +236,7 @@ const layer = Layer.effect(
|
|||||||
grep: (input) =>
|
grep: (input) =>
|
||||||
run<RawMatchData>({
|
run<RawMatchData>({
|
||||||
...input,
|
...input,
|
||||||
|
output: "matches",
|
||||||
args: [
|
args: [
|
||||||
"--no-config",
|
"--no-config",
|
||||||
"--json",
|
"--json",
|
||||||
@@ -240,7 +257,6 @@ const layer = Layer.effect(
|
|||||||
return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
|
return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
|
||||||
Effect.map((match) => ({
|
Effect.map((match) => ({
|
||||||
...match.data,
|
...match.data,
|
||||||
path: { text: match.data.path.text.replace(/^\.[\\/]/, "") },
|
|
||||||
submatches: match.data.submatches.slice(0, MAX_SUBMATCHES),
|
submatches: match.data.submatches.slice(0, MAX_SUBMATCHES),
|
||||||
})),
|
})),
|
||||||
Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
|
Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
|
||||||
@@ -250,13 +266,9 @@ const layer = Layer.effect(
|
|||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.map((result) =>
|
Effect.map((result) =>
|
||||||
result.items.map((match) => {
|
result.items.map((match) => {
|
||||||
const relative = match.path.text
|
|
||||||
.replace(/^(?:\.[\\/])+/u, "")
|
|
||||||
.replace(/^[\\/]+/u, "")
|
|
||||||
.replaceAll("\\", "/")
|
|
||||||
return Match.make({
|
return Match.make({
|
||||||
entry: Entry.make({
|
entry: Entry.make({
|
||||||
path: RelativePath.make(relative),
|
path: RelativePath.make(normalizeRelativePath(match.path.text)),
|
||||||
type: "file",
|
type: "file",
|
||||||
}),
|
}),
|
||||||
line: match.line_number,
|
line: match.line_number,
|
||||||
@@ -272,7 +284,66 @@ const layer = Layer.effect(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const process = yield* AppProcess.Service
|
||||||
|
const binary = yield* RipgrepBinary.Service
|
||||||
|
return make({
|
||||||
|
spawn: (command) => process.spawn(command),
|
||||||
|
awaitTruncated: false,
|
||||||
|
command: (input) =>
|
||||||
|
binary.filepath.pipe(
|
||||||
|
Effect.map((executable) =>
|
||||||
|
ChildProcess.make(executable, input.args, {
|
||||||
|
cwd: input.cwd,
|
||||||
|
extendEnv: true,
|
||||||
|
stdin: "ignore",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
|
export const node = makeLocationNode({ service: Service, layer, deps: [RipgrepBinary.node, AppProcess.node] })
|
||||||
|
|
||||||
|
export const hostedNode = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer: Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const env = yield* WorkspaceEnvironment.Service
|
||||||
|
return make({
|
||||||
|
spawn: (command) => env.process.spawn(command),
|
||||||
|
awaitTruncated: true,
|
||||||
|
command: (input) =>
|
||||||
|
Effect.succeed(
|
||||||
|
ChildProcess.make(
|
||||||
|
env.shell.executable,
|
||||||
|
[
|
||||||
|
...env.shell.args(
|
||||||
|
input.output === "lines"
|
||||||
|
? `set -o pipefail; limit=$1; shift; timeout --signal=KILL ${HOSTED_KILL_TIMEOUT_SECONDS}s rg "$@" | head -n "$limit"`
|
||||||
|
: `set -o pipefail; limit=$1; shift; timeout --signal=KILL ${HOSTED_KILL_TIMEOUT_SECONDS}s rg "$@" | awk -v limit="$limit" '{ print } /"type":"match"/ { if (++matches >= limit) exit }'`,
|
||||||
|
),
|
||||||
|
"opencode-ripgrep",
|
||||||
|
String(input.limit + 1),
|
||||||
|
...input.args,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: input.cwd,
|
||||||
|
env: env.shell.environmentOverrides,
|
||||||
|
extendEnv: false,
|
||||||
|
detached: env.shell.detached,
|
||||||
|
stdin: "ignore",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
deps: [WorkspaceEnvironment.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
|
|||||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||||
import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, ne, or, type SQL } from "drizzle-orm"
|
import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, ne, or, type SQL } from "drizzle-orm"
|
||||||
import { Project } from "./project"
|
import { Project } from "./project"
|
||||||
import { Workspace } from "./workspace"
|
|
||||||
import { Model } from "./model"
|
import { Model } from "./model"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { SessionMessage } from "./session/message"
|
import { SessionMessage } from "./session/message"
|
||||||
@@ -27,7 +26,8 @@ import { fromRow } from "./session/info"
|
|||||||
import { SessionRunner } from "./session/runner/index"
|
import { SessionRunner } from "./session/runner/index"
|
||||||
import { SessionStore } from "./session/store"
|
import { SessionStore } from "./session/store"
|
||||||
import { SessionExecution } from "./session/execution"
|
import { SessionExecution } from "./session/execution"
|
||||||
import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/error"
|
import { ForkEmptyError, MessageDecodeError, NotFoundError, WorkspaceDirectoryError } from "./session/error"
|
||||||
|
import { Workspace } from "./workspace"
|
||||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { LocationServiceMap } from "./location-service-map"
|
import { LocationServiceMap } from "./location-service-map"
|
||||||
import { SessionEvent } from "./session/event"
|
import { SessionEvent } from "./session/event"
|
||||||
@@ -58,12 +58,9 @@ import { fileURLToPath } from "url"
|
|||||||
|
|
||||||
// - by project
|
// - by project
|
||||||
// - by subpath
|
// - by subpath
|
||||||
// - by workspace (home is special)
|
|
||||||
|
|
||||||
export { ListAnchor }
|
export { ListAnchor }
|
||||||
|
|
||||||
const ListInputBase = {
|
const ListInputBase = {
|
||||||
workspaceID: Workspace.ID.pipe(Schema.optional),
|
|
||||||
search: Schema.String.pipe(Schema.optional),
|
search: Schema.String.pipe(Schema.optional),
|
||||||
limit: PositiveInt.pipe(Schema.optional),
|
limit: PositiveInt.pipe(Schema.optional),
|
||||||
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
|
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
|
||||||
@@ -153,7 +150,9 @@ export interface Interface {
|
|||||||
readonly list: (input?: ListInput) => Effect.Effect<{
|
readonly list: (input?: ListInput) => Effect.Effect<{
|
||||||
readonly data: SessionSchema.Info[]
|
readonly data: SessionSchema.Info[]
|
||||||
}>
|
}>
|
||||||
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
|
readonly create: (
|
||||||
|
input: CreateInput,
|
||||||
|
) => Effect.Effect<SessionSchema.Info, NotFoundError | Workspace.NotFoundError | WorkspaceDirectoryError>
|
||||||
readonly fork: (
|
readonly fork: (
|
||||||
input: ForkInput,
|
input: ForkInput,
|
||||||
) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError | ForkEmptyError>
|
) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError | ForkEmptyError>
|
||||||
@@ -199,7 +198,6 @@ export interface Interface {
|
|||||||
readonly move: (input: {
|
readonly move: (input: {
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
directory: AbsolutePath
|
directory: AbsolutePath
|
||||||
workspaceID?: Location.Ref["workspaceID"]
|
|
||||||
}) => Effect.Effect<void, NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError>
|
}) => Effect.Effect<void, NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError>
|
||||||
readonly prompt: (input: {
|
readonly prompt: (input: {
|
||||||
id?: SessionMessage.ID
|
id?: SessionMessage.ID
|
||||||
@@ -235,7 +233,7 @@ export interface Interface {
|
|||||||
id?: Event.ID
|
id?: Event.ID
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
command: string
|
command: string
|
||||||
}) => Effect.Effect<void, NotFoundError>
|
}) => Effect.Effect<void, NotFoundError | Shell.InvalidCwdError>
|
||||||
readonly skill: (input: {
|
readonly skill: (input: {
|
||||||
id?: SessionMessage.ID
|
id?: SessionMessage.ID
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
@@ -280,6 +278,7 @@ const layer = Layer.effect(
|
|||||||
const db = database.db
|
const db = database.db
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const projects = yield* Project.Service
|
const projects = yield* Project.Service
|
||||||
|
const workspaces = yield* Workspace.Service
|
||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const execution = yield* SessionExecution.Service
|
const execution = yield* SessionExecution.Service
|
||||||
const store = yield* SessionStore.Service
|
const store = yield* SessionStore.Service
|
||||||
@@ -291,6 +290,17 @@ const layer = Layer.effect(
|
|||||||
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||||
|
// Host Project discovery must not run against provider directories; hosted
|
||||||
|
// Sessions state the global Project until rediscovery inside the Workspace.
|
||||||
|
const hostedProject = Effect.fn("Session.hostedProject")(function* (
|
||||||
|
workspaceID: Workspace.ID,
|
||||||
|
directory: AbsolutePath,
|
||||||
|
) {
|
||||||
|
const workspace = yield* workspaces.get(workspaceID)
|
||||||
|
if (!FSUtil.containsPosix(workspace.root, directory))
|
||||||
|
return yield* new WorkspaceDirectoryError({ workspaceID, directory, root: workspace.root })
|
||||||
|
return Project.hostedGlobal(workspace.root)
|
||||||
|
})
|
||||||
const persistProject = (project: Project.Resolved) => {
|
const persistProject = (project: Project.Resolved) => {
|
||||||
const vcs = project.vcs?.type
|
const vcs = project.vcs?.type
|
||||||
return db
|
return db
|
||||||
@@ -328,7 +338,9 @@ const layer = Layer.effect(
|
|||||||
const location = parent?.location ?? input.location
|
const location = parent?.location ?? input.location
|
||||||
if (location === undefined)
|
if (location === undefined)
|
||||||
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
|
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
|
||||||
const project = yield* projects.resolve(location.directory)
|
const project = location.workspaceID
|
||||||
|
? yield* hostedProject(location.workspaceID, location.directory)
|
||||||
|
: yield* projects.resolve(location.directory)
|
||||||
yield* persistProject(project)
|
yield* persistProject(project)
|
||||||
const projected = yield* bus
|
const projected = yield* bus
|
||||||
.publish(
|
.publish(
|
||||||
@@ -340,7 +352,9 @@ const layer = Layer.effect(
|
|||||||
projectID: project.id,
|
projectID: project.id,
|
||||||
parentID: input.parentID,
|
parentID: input.parentID,
|
||||||
location,
|
location,
|
||||||
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
|
subpath: RelativePath.make(
|
||||||
|
FSUtil.slash(Location.paths(location).relative(project.directory, location.directory)),
|
||||||
|
),
|
||||||
title: input.title,
|
title: input.title,
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
model: input.model
|
model: input.model
|
||||||
@@ -427,7 +441,6 @@ const layer = Layer.effect(
|
|||||||
const sortColumn = SessionTable.time_updated
|
const sortColumn = SessionTable.time_updated
|
||||||
const conditions: SQL[] = []
|
const conditions: SQL[] = []
|
||||||
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
||||||
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
|
||||||
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
||||||
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
|
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
|
||||||
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||||
@@ -701,10 +714,10 @@ const layer = Layer.effect(
|
|||||||
const expanded =
|
const expanded =
|
||||||
value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
|
value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
|
||||||
const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded))
|
const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded))
|
||||||
|
if (current.location.directory === directory) return
|
||||||
const info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
const info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
if (!info) return yield* new DestinationNotFoundError({ directory })
|
if (!info) return yield* new DestinationNotFoundError({ directory })
|
||||||
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
|
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
|
||||||
if (current.location.directory === directory && current.location.workspaceID === input.workspaceID) return
|
|
||||||
const project = yield* projects.resolve(directory)
|
const project = yield* projects.resolve(directory)
|
||||||
yield* persistProject(project)
|
yield* persistProject(project)
|
||||||
if ((yield* execution.active).has(input.sessionID)) {
|
if ((yield* execution.active).has(input.sessionID)) {
|
||||||
@@ -715,9 +728,9 @@ const layer = Layer.effect(
|
|||||||
SessionEvent.Moved,
|
SessionEvent.Moved,
|
||||||
{
|
{
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
|
location: Location.Ref.make({ directory }),
|
||||||
projectID: project.id,
|
projectID: project.id,
|
||||||
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
|
subpath: RelativePath.make(FSUtil.slash(path.relative(project.directory, directory))),
|
||||||
},
|
},
|
||||||
{ location: current.location },
|
{ location: current.location },
|
||||||
)
|
)
|
||||||
@@ -1013,6 +1026,7 @@ export const node = makeGlobalNode({
|
|||||||
Database.node,
|
Database.node,
|
||||||
Bus.node,
|
Bus.node,
|
||||||
Project.node,
|
Project.node,
|
||||||
|
Workspace.node,
|
||||||
SessionExecution.node,
|
SessionExecution.node,
|
||||||
SessionStore.node,
|
SessionStore.node,
|
||||||
LocationServiceMap.node,
|
LocationServiceMap.node,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export * as SessionErrors from "./error"
|
|||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { Agent } from "@opencode-ai/schema/agent"
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
|
import { WorkspaceID } from "@opencode-ai/schema/workspace-id"
|
||||||
import { SessionMessage } from "./message"
|
import { SessionMessage } from "./message"
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
@@ -18,6 +19,19 @@ export class ForkEmptyError extends Schema.TaggedErrorClass<ForkEmptyError>()("S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class WorkspaceDirectoryError extends Schema.TaggedErrorClass<WorkspaceDirectoryError>()(
|
||||||
|
"Session.WorkspaceDirectoryError",
|
||||||
|
{
|
||||||
|
workspaceID: WorkspaceID,
|
||||||
|
directory: Schema.String,
|
||||||
|
root: Schema.String,
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
override get message() {
|
||||||
|
return `Directory ${this.directory} is outside workspace ${this.workspaceID} root ${this.root}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
|
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
messageID: SessionMessage.ID,
|
messageID: SessionMessage.ID,
|
||||||
|
|||||||
@@ -7,16 +7,16 @@ import { Bus } from "../bus"
|
|||||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { Model } from "../model"
|
import { Model } from "../model"
|
||||||
import { SessionEvent } from "./event"
|
import { SessionEvent } from "./event"
|
||||||
|
|
||||||
import { SessionMessage } from "./message"
|
import { SessionMessage } from "./message"
|
||||||
import { SessionMessageUpdater } from "./message-updater"
|
import { SessionMessageUpdater } from "./message-updater"
|
||||||
import { SessionPending } from "./pending"
|
import { SessionPending } from "./pending"
|
||||||
import { Workspace } from "../workspace"
|
|
||||||
import { InstructionState } from "./instruction-state"
|
import { InstructionState } from "./instruction-state"
|
||||||
import { SessionPendingTable, SessionMessageTable, SessionTable } from "./sql"
|
import { SessionPendingTable, SessionMessageTable, SessionTable } from "./sql"
|
||||||
import { Slug } from "../util/slug"
|
import { Slug } from "../util/slug"
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import type { SessionSchema } from "./schema"
|
import type { SessionSchema } from "./schema"
|
||||||
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
import { Workspace } from "../workspace"
|
||||||
|
|
||||||
type DatabaseService = Database.Interface["db"]
|
type DatabaseService = Database.Interface["db"]
|
||||||
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
||||||
@@ -376,13 +376,6 @@ const layer = Layer.effectDiscard(
|
|||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (!stored) return yield* Effect.die(new SessionAlreadyProjected())
|
if (!stored) return yield* Effect.die(new SessionAlreadyProjected())
|
||||||
if (!event.data.location.workspaceID) return
|
|
||||||
yield* db
|
|
||||||
.update(WorkspaceTable)
|
|
||||||
.set({ time_used: Date.now() })
|
|
||||||
.where(eq(WorkspaceTable.id, event.data.location.workspaceID))
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* bus.project(SessionEvent.Moved, (event) =>
|
yield* bus.project(SessionEvent.Moved, (event) =>
|
||||||
@@ -393,7 +386,7 @@ const layer = Layer.effectDiscard(
|
|||||||
directory: event.data.location.directory,
|
directory: event.data.location.directory,
|
||||||
path: event.data.subpath,
|
path: event.data.subpath,
|
||||||
...(event.data.projectID ? { project_id: event.data.projectID } : {}),
|
...(event.data.projectID ? { project_id: event.data.projectID } : {}),
|
||||||
workspace_id: event.data.location.workspaceID ? Workspace.ID.make(event.data.location.workspaceID) : null,
|
workspace_id: null,
|
||||||
time_updated: DateTime.toEpochMillis(event.created),
|
time_updated: DateTime.toEpochMillis(event.created),
|
||||||
})
|
})
|
||||||
.where(eq(SessionTable.id, event.data.sessionID))
|
.where(eq(SessionTable.id, event.data.sessionID))
|
||||||
|
|||||||
+99
-17
@@ -6,12 +6,14 @@ import { ChildProcess } from "effect/unstable/process"
|
|||||||
import { produce } from "immer"
|
import { produce } from "immer"
|
||||||
import { Shell } from "@opencode-ai/schema/shell"
|
import { Shell } from "@opencode-ai/schema/shell"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { AppProcess } from "@opencode-ai/util/process"
|
import { AppProcess } from "@opencode-ai/util/process"
|
||||||
import { Config } from "./config"
|
import { Config } from "./config"
|
||||||
import { Bus } from "./bus"
|
import { Bus } from "./bus"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { Global } from "@opencode-ai/util/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { ShellSelect } from "./shell/select"
|
import { ShellSelect } from "./shell/select"
|
||||||
|
import { WorkspaceEnvironment } from "./workspace/environment"
|
||||||
import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"
|
import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"
|
||||||
import { PluginHooks } from "./plugin/hooks"
|
import { PluginHooks } from "./plugin/hooks"
|
||||||
|
|
||||||
@@ -19,6 +21,18 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("She
|
|||||||
id: Shell.ID,
|
id: Shell.ID,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
export class InvalidCwdError extends Schema.TaggedErrorClass<InvalidCwdError>()("Shell.InvalidCwdError", {
|
||||||
|
path: Schema.String,
|
||||||
|
reason: Schema.Literals(["not_found", "not_directory", "unavailable"]),
|
||||||
|
cause: Schema.optional(Schema.Defect()),
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
if (this.reason === "not_found") return `Working directory does not exist: ${this.path}`
|
||||||
|
if (this.reason === "not_directory") return `Working directory is not a directory: ${this.path}`
|
||||||
|
return `Unable to inspect working directory: ${this.path}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Exited processes stay observable (status, exit code, retained output) until removed explicitly.
|
// Exited processes stay observable (status, exit code, retained output) until removed explicitly.
|
||||||
// Cap retention so abandoned commands do not accumulate unbounded state and output files.
|
// Cap retention so abandoned commands do not accumulate unbounded state and output files.
|
||||||
const EXITED_LIMIT = 25
|
const EXITED_LIMIT = 25
|
||||||
@@ -50,7 +64,7 @@ export interface Interface {
|
|||||||
readonly create: <E = never, R = never>(
|
readonly create: <E = never, R = never>(
|
||||||
input: Shell.CreateInput,
|
input: Shell.CreateInput,
|
||||||
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
||||||
) => Effect.Effect<Shell.Info, E, R>
|
) => Effect.Effect<Shell.Info, E | InvalidCwdError, R>
|
||||||
// Currently running commands only; exited shells are retained for get/output but excluded here.
|
// Currently running commands only; exited shells are retained for get/output but excluded here.
|
||||||
readonly list: () => Effect.Effect<Shell.Info[]>
|
readonly list: () => Effect.Effect<Shell.Info[]>
|
||||||
readonly get: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
|
readonly get: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
|
||||||
@@ -65,14 +79,26 @@ export interface Interface {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
|
||||||
|
|
||||||
export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
/** The provider-facing seams; everything else in this service is host-owned bookkeeping. */
|
||||||
|
interface Backend {
|
||||||
|
readonly spawn: (command: ChildProcess.Command) => ReturnType<AppProcess.Interface["spawn"]>
|
||||||
|
/** Shell executable; re-evaluated per command so config changes apply. */
|
||||||
|
readonly shell: Effect.Effect<string>
|
||||||
|
readonly args: (shell: string, command: string) => readonly string[]
|
||||||
|
/** Base environment for spawned commands. Hosted backends must not leak host process.env. */
|
||||||
|
readonly env: Readonly<Record<string, string | undefined>>
|
||||||
|
readonly detached: boolean
|
||||||
|
readonly validateCwd: (cwd: string) => Effect.Effect<void, InvalidCwdError>
|
||||||
|
}
|
||||||
|
|
||||||
|
const layerWith = <E, R>(backend: Effect.Effect<Backend, E, R>) =>
|
||||||
|
Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const config = yield* Config.Service
|
|
||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const appProcess = yield* AppProcess.Service
|
const spawner = yield* backend
|
||||||
const hooks = yield* PluginHooks.Service
|
const hooks = yield* PluginHooks.Service
|
||||||
const context = yield* Effect.context()
|
const context = yield* Effect.context()
|
||||||
const runFork = Effect.runForkWith(context)
|
const runFork = Effect.runForkWith(context)
|
||||||
@@ -141,12 +167,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
|||||||
return session.info
|
return session.info
|
||||||
})
|
})
|
||||||
|
|
||||||
const resolve = () =>
|
const name = () => spawner.shell.pipe(Effect.map(ShellSelect.name))
|
||||||
config
|
|
||||||
.entries()
|
|
||||||
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
|
|
||||||
|
|
||||||
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
|
|
||||||
|
|
||||||
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
||||||
const session = yield* require(id)
|
const session = yield* require(id)
|
||||||
@@ -186,18 +207,19 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
|||||||
command: input.command,
|
command: input.command,
|
||||||
cwd: input.cwd ?? location.directory,
|
cwd: input.cwd ?? location.directory,
|
||||||
timeout: input.timeout,
|
timeout: input.timeout,
|
||||||
shell: yield* resolve(),
|
shell: yield* spawner.shell,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...spawner.env,
|
||||||
TERM: "xterm-256color",
|
TERM: "xterm-256color",
|
||||||
OPENCODE_TERMINAL: "1",
|
OPENCODE_TERMINAL: "1",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
yield* hooks.trigger("shell", "create.before", invocation)
|
yield* hooks.trigger("shell", "create.before", invocation)
|
||||||
if (before) yield* before(invocation)
|
if (before) yield* before(invocation)
|
||||||
|
yield* spawner.validateCwd(invocation.cwd)
|
||||||
|
|
||||||
const id = Shell.ID.ascending()
|
const id = Shell.ID.ascending()
|
||||||
const args = ShellSelect.args(invocation.shell, invocation.command)
|
const args = spawner.args(invocation.shell, invocation.command)
|
||||||
const file = path.join(outputDir, `${id}.out`)
|
const file = path.join(outputDir, `${id}.out`)
|
||||||
|
|
||||||
const info: Info = {
|
const info: Info = {
|
||||||
@@ -218,12 +240,12 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
|||||||
runFork(
|
runFork(
|
||||||
Effect.scoped(
|
Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const handle = yield* appProcess.spawn(
|
const handle = yield* spawner.spawn(
|
||||||
ChildProcess.make(invocation.shell, args, {
|
ChildProcess.make(invocation.shell, args, {
|
||||||
cwd: invocation.cwd,
|
cwd: invocation.cwd,
|
||||||
env: invocation.env,
|
env: invocation.env,
|
||||||
stdin: "ignore",
|
stdin: "ignore",
|
||||||
detached: process.platform !== "win32",
|
detached: spawner.detached,
|
||||||
forceKillAfter: Duration.seconds(3),
|
forceKillAfter: Duration.seconds(3),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -337,14 +359,74 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
|||||||
|
|
||||||
return Service.of({ name, create, list, get, wait, timeout, output, remove })
|
return Service.of({ name, create, list, get, wait, timeout, output, remove })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const validateCwdWith =
|
||||||
|
<E>(stat: (cwd: string) => Effect.Effect<{ readonly type: string }, E>, isNotFound: (error: E) => boolean) =>
|
||||||
|
(cwd: string) =>
|
||||||
|
stat(cwd).pipe(
|
||||||
|
Effect.mapError(
|
||||||
|
(error) =>
|
||||||
|
new InvalidCwdError({ path: cwd, reason: isNotFound(error) ? "not_found" : "unavailable", cause: error }),
|
||||||
|
),
|
||||||
|
Effect.flatMap((info) =>
|
||||||
|
info.type === "Directory"
|
||||||
|
? Effect.void
|
||||||
|
: Effect.fail(new InvalidCwdError({ path: cwd, reason: "not_directory" })),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const layer = (options?: ShellSelect.Options) =>
|
||||||
|
layerWith(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const appProcess = yield* AppProcess.Service
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
|
return {
|
||||||
|
spawn: (command) => appProcess.spawn(command),
|
||||||
|
shell: config
|
||||||
|
.entries()
|
||||||
|
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options))),
|
||||||
|
args: ShellSelect.args,
|
||||||
|
env: process.env,
|
||||||
|
detached: process.platform !== "win32",
|
||||||
|
validateCwd: validateCwdWith(
|
||||||
|
(cwd) => fs.stat(cwd),
|
||||||
|
(error) => error.reason._tag === "NotFound",
|
||||||
|
),
|
||||||
|
} satisfies Backend
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export function configured(options?: ShellSelect.Options) {
|
export function configured(options?: ShellSelect.Options) {
|
||||||
return makeLocationNode({
|
return makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer: layer(options),
|
layer: layer(options),
|
||||||
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, PluginHooks.node],
|
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, FSUtil.node, PluginHooks.node],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const node = configured()
|
export const node = configured()
|
||||||
|
|
||||||
|
// Commands run inside the workspace: provider spawner, image shell lowering,
|
||||||
|
// and no host process.env leakage. Output capture files remain host-owned.
|
||||||
|
export const hostedNode = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer: layerWith(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const env = yield* WorkspaceEnvironment.Service
|
||||||
|
return {
|
||||||
|
spawn: (command) => env.process.spawn(command),
|
||||||
|
shell: Effect.succeed(env.shell.executable),
|
||||||
|
args: (_shell, command) => env.shell.args(command),
|
||||||
|
env: env.shell.environmentOverrides,
|
||||||
|
detached: env.shell.detached,
|
||||||
|
validateCwd: validateCwdWith(
|
||||||
|
(cwd) => env.files.stat(cwd),
|
||||||
|
(error) => error._tag === "WorkspaceEnvironment.NotFoundError",
|
||||||
|
),
|
||||||
|
} satisfies Backend
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
deps: [Bus.node, Location.node, Global.node, WorkspaceEnvironment.node, PluginHooks.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import { FileDiff } from "@opencode-ai/schema/file-diff"
|
|||||||
import { Bom } from "@opencode-ai/util/bom"
|
import { Bom } from "@opencode-ai/util/bom"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { FileMutation } from "../../file-mutation"
|
import { FileMutation } from "../../file-mutation"
|
||||||
import { Formatter } from "../../formatter"
|
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
|
||||||
import { LocationMutation } from "../../location-mutation"
|
import { LocationMutation } from "../../location-mutation"
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
import { fileDiff } from "./file-diff"
|
import { fileDiff } from "./file-diff"
|
||||||
@@ -110,14 +108,11 @@ export const Plugin = {
|
|||||||
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
|
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
|
||||||
const mutation = yield* LocationMutation.Service
|
const mutation = yield* LocationMutation.Service
|
||||||
const files = yield* FileMutation.Service
|
const files = yield* FileMutation.Service
|
||||||
const formatter = yield* Formatter.Service
|
|
||||||
const fs = yield* FSUtil.Service
|
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add(
|
draft.add({
|
||||||
({
|
|
||||||
name,
|
name,
|
||||||
options: { codemode: false, permission: "edit" },
|
options: { codemode: false, permission: "edit" },
|
||||||
description:
|
description:
|
||||||
@@ -153,16 +148,14 @@ export const Plugin = {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = yield* fs.stat(target.canonical).pipe(
|
const source = yield* files.read(target).pipe(
|
||||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
Effect.catchTag("FileMutation.NotFoundError", () =>
|
||||||
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
||||||
),
|
),
|
||||||
|
Effect.catchTag("FileMutation.NotAFileError", () =>
|
||||||
|
Effect.fail(new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if (info.type === "Directory") {
|
|
||||||
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
|
|
||||||
}
|
|
||||||
const original = yield* Bom.readFile(fs, target.canonical)
|
|
||||||
const source = original.text
|
|
||||||
const ending = source.includes(crlf) ? crlf : "\n"
|
const ending = source.includes(crlf) ? crlf : "\n"
|
||||||
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||||
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||||
@@ -170,22 +163,19 @@ export const Plugin = {
|
|||||||
// These one-to-one mappings preserve offsets into the original source.
|
// These one-to-one mappings preserve offsets into the original source.
|
||||||
const unicode =
|
const unicode =
|
||||||
exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString))
|
exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString))
|
||||||
const trailing =
|
const trailing = exact.length > 0 || unicode.length > 0 ? [] : findLineOccurrences(source, oldString)
|
||||||
exact.length > 0 || unicode.length > 0
|
|
||||||
? []
|
|
||||||
: findLineOccurrences(source, oldString)
|
|
||||||
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
|
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
|
||||||
const replacements = matches.length
|
const replacements = matches.length
|
||||||
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
|
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
|
||||||
.toReversed()
|
.toReversed()
|
||||||
.reduce(
|
.reduce(
|
||||||
(content, match) =>
|
(content, match) => `${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
|
||||||
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
|
|
||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
|
const content = FileMutation.normalizeText(replaced)
|
||||||
const preview =
|
const preview =
|
||||||
replacements > 0 && (replacements === 1 || input.replaceAll === true)
|
replacements > 0 && (replacements === 1 || input.replaceAll === true)
|
||||||
? fileDiff(target.resource, source, replaced)
|
? fileDiff(target.resource, source, content)
|
||||||
: undefined
|
: undefined
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
action: "edit",
|
action: "edit",
|
||||||
@@ -206,17 +196,9 @@ export const Plugin = {
|
|||||||
message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`,
|
message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const replacementBom = replaced.startsWith("\uFEFF")
|
const result = yield* files.write({ target, content: replaced })
|
||||||
const result = yield* files.write({
|
|
||||||
target,
|
|
||||||
content: Bom.join(replaced, original.bom || replacementBom),
|
|
||||||
})
|
|
||||||
const bom = original.bom || replacementBom
|
|
||||||
const formatted = (yield* formatter.file(target.canonical))
|
|
||||||
? yield* Bom.syncFile(fs, target.canonical, bom)
|
|
||||||
: (yield* Bom.readFile(fs, target.canonical)).text
|
|
||||||
return {
|
return {
|
||||||
files: [fileDiff(result.resource, source, formatted)],
|
files: [fileDiff(result.resource, source, result.content)],
|
||||||
replacements,
|
replacements,
|
||||||
} satisfies Output
|
} satisfies Output
|
||||||
}).pipe(
|
}).pipe(
|
||||||
@@ -233,7 +215,6 @@ export const Plugin = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
|
|||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { FileSystem } from "../../filesystem"
|
import { FileSystem } from "../../filesystem"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
|
||||||
import { Location } from "../../location"
|
import { Location } from "../../location"
|
||||||
import { LocationMutation } from "../../location-mutation"
|
import { LocationMutation } from "../../location-mutation"
|
||||||
import { Ripgrep } from "../../ripgrep"
|
|
||||||
import { RelativePath } from "../../schema"
|
import { RelativePath } from "../../schema"
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
|
|
||||||
@@ -42,20 +40,18 @@ export const toModelContent = (entries: EncodedOutput, truncated = false) => {
|
|||||||
export const Plugin = {
|
export const Plugin = {
|
||||||
id: "opencode.tool.glob",
|
id: "opencode.tool.glob",
|
||||||
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
|
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
|
||||||
const fs = yield* FSUtil.Service
|
const filesystem = yield* FileSystem.Service
|
||||||
const ripgrep = yield* Ripgrep.Service
|
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
|
const resolve = Location.paths(location).resolve
|
||||||
const mutation = yield* LocationMutation.Service
|
const mutation = yield* LocationMutation.Service
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add(
|
draft.add({
|
||||||
({
|
|
||||||
name,
|
name,
|
||||||
options: { codemode: false },
|
options: { codemode: false },
|
||||||
description:
|
description: 'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
|
||||||
'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
|
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
@@ -84,40 +80,22 @@ export const Plugin = {
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const info = yield* fs
|
|
||||||
.stat(target.canonical)
|
|
||||||
.pipe(
|
|
||||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
|
||||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (info.type !== "Directory")
|
|
||||||
return yield* Effect.fail(
|
|
||||||
new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }),
|
|
||||||
)
|
|
||||||
const root = path.resolve(location.directory, searchPath ?? ".")
|
|
||||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||||
const entries = yield* ripgrep
|
const entries = yield* filesystem
|
||||||
.glob({
|
.glob({
|
||||||
cwd: target.canonical,
|
target,
|
||||||
pattern: input.pattern,
|
pattern: input.pattern,
|
||||||
limit: limit + 1,
|
limit: limit + 1,
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.timeoutOrElse({
|
FileSystem.searchTimeout((message) => new ToolFailure({ message })),
|
||||||
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
|
Effect.catchTag("FileSystem.SearchPathError", (error) =>
|
||||||
orElse: () =>
|
|
||||||
Effect.fail(
|
Effect.fail(
|
||||||
new ToolFailure({
|
new ToolFailure({
|
||||||
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
|
message:
|
||||||
}),
|
error.reason === "not_found"
|
||||||
),
|
? `Search path does not exist: ${searchPath ?? "."}`
|
||||||
}),
|
: `Search path is not a directory: ${searchPath ?? "."}`,
|
||||||
Effect.map((result) =>
|
|
||||||
result.map((entry) =>
|
|
||||||
FileSystem.Entry.make({
|
|
||||||
...entry,
|
|
||||||
path: RelativePath.make(path.relative(location.directory, path.resolve(root, entry.path))),
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -127,7 +105,7 @@ export const Plugin = {
|
|||||||
Effect.map((result) => ({
|
Effect.map((result) => ({
|
||||||
output: result.entries,
|
output: result.entries,
|
||||||
content: toModelContent(
|
content: toModelContent(
|
||||||
result.entries.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
|
result.entries.map((entry) => ({ ...entry, path: resolve(location.directory, entry.path) })),
|
||||||
result.truncated,
|
result.truncated,
|
||||||
),
|
),
|
||||||
metadata: { count: result.entries.length, truncated: result.truncated },
|
metadata: { count: result.entries.length, truncated: result.truncated },
|
||||||
@@ -139,7 +117,6 @@ export const Plugin = {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -5,19 +5,17 @@ import { ToolFailure } from "@opencode-ai/ai"
|
|||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { FileSystem } from "../../filesystem"
|
import { FileSystem } from "../../filesystem"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
|
||||||
import { Location } from "../../location"
|
import { Location } from "../../location"
|
||||||
import { LocationMutation } from "../../location-mutation"
|
import { LocationMutation } from "../../location-mutation"
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
import { Ripgrep } from "../../ripgrep"
|
|
||||||
import { RelativePath } from "../../schema"
|
import { RelativePath } from "../../schema"
|
||||||
|
|
||||||
export const name = "grep"
|
export const name = "grep"
|
||||||
|
|
||||||
export const Input = Schema.Struct({
|
export const Input = Schema.Struct({
|
||||||
pattern: FileSystem.GrepInput.fields.pattern.check(
|
pattern: FileSystem.GrepInput.fields.pattern
|
||||||
Schema.isMinLength(1, { message: "Pattern must not be empty" }),
|
.check(Schema.isMinLength(1, { message: "Pattern must not be empty" }))
|
||||||
).annotate({
|
.annotate({
|
||||||
description: "Regular expression to search for in file contents (ripgrep syntax)",
|
description: "Regular expression to search for in file contents (ripgrep syntax)",
|
||||||
}),
|
}),
|
||||||
path: Schema.optionalKey(RelativePath).annotate({
|
path: Schema.optionalKey(RelativePath).annotate({
|
||||||
@@ -58,16 +56,15 @@ export const toModelContent = (matches: EncodedOutput, truncated = false) => {
|
|||||||
export const Plugin = {
|
export const Plugin = {
|
||||||
id: "opencode.tool.grep",
|
id: "opencode.tool.grep",
|
||||||
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
|
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
|
||||||
const fs = yield* FSUtil.Service
|
const filesystem = yield* FileSystem.Service
|
||||||
const ripgrep = yield* Ripgrep.Service
|
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
|
const resolve = Location.paths(location).resolve
|
||||||
const mutation = yield* LocationMutation.Service
|
const mutation = yield* LocationMutation.Service
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add(
|
draft.add({
|
||||||
({
|
|
||||||
name,
|
name,
|
||||||
options: { codemode: false },
|
options: { codemode: false },
|
||||||
description:
|
description:
|
||||||
@@ -99,46 +96,18 @@ export const Plugin = {
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const root = path.resolve(location.directory, input.path ?? ".")
|
|
||||||
const info = yield* fs
|
|
||||||
.stat(root)
|
|
||||||
.pipe(
|
|
||||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
|
||||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const cwd = info?.type === "Directory" ? root : path.dirname(root)
|
|
||||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||||
const matches = yield* ripgrep
|
const matches = yield* filesystem
|
||||||
.grep({
|
.grep({
|
||||||
cwd,
|
target,
|
||||||
pattern: input.pattern,
|
pattern: input.pattern,
|
||||||
file: info?.type === "File" ? path.basename(root) : undefined,
|
|
||||||
include: input.include,
|
include: input.include,
|
||||||
limit: limit + 1,
|
limit: limit + 1,
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.timeoutOrElse({
|
FileSystem.searchTimeout((message) => new ToolFailure({ message })),
|
||||||
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
|
Effect.catchTag("FileSystem.SearchPathError", () =>
|
||||||
orElse: () =>
|
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
|
||||||
Effect.fail(
|
|
||||||
new ToolFailure({
|
|
||||||
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
Effect.map((result) =>
|
|
||||||
result.map((match) =>
|
|
||||||
FileSystem.Match.make({
|
|
||||||
...match,
|
|
||||||
entry: FileSystem.Entry.make({
|
|
||||||
...match.entry,
|
|
||||||
path: RelativePath.make(
|
|
||||||
path.relative(location.directory, path.resolve(cwd, match.entry.path)),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return { matches: matches.slice(0, limit), truncated: matches.length > limit }
|
return { matches: matches.slice(0, limit), truncated: matches.length > limit }
|
||||||
@@ -148,7 +117,7 @@ export const Plugin = {
|
|||||||
content: toModelContent(
|
content: toModelContent(
|
||||||
result.matches.map((match) => ({
|
result.matches.map((match) => ({
|
||||||
...match,
|
...match,
|
||||||
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
|
entry: { ...match.entry, path: resolve(location.directory, match.entry.path) },
|
||||||
})),
|
})),
|
||||||
result.truncated,
|
result.truncated,
|
||||||
),
|
),
|
||||||
@@ -157,13 +126,12 @@ export const Plugin = {
|
|||||||
Effect.mapError((error) =>
|
Effect.mapError((error) =>
|
||||||
error instanceof ToolFailure
|
error instanceof ToolFailure
|
||||||
? error
|
? error
|
||||||
: error instanceof Ripgrep.InvalidPatternError
|
: error instanceof FileSystem.InvalidPatternError
|
||||||
? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
|
? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
|
||||||
: new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
|
: new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -6,11 +6,8 @@ import { FileDiff } from "@opencode-ai/schema/file-diff"
|
|||||||
import { createTwoFilesPatch, diffLines } from "diff"
|
import { createTwoFilesPatch, diffLines } from "diff"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { PlatformError } from "effect/PlatformError"
|
import { PlatformError } from "effect/PlatformError"
|
||||||
import path from "path"
|
import { FileMutation } from "../../file-mutation"
|
||||||
import { Bom } from "@opencode-ai/util/bom"
|
import { LocationMutation } from "../../location-mutation"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
|
||||||
import { Formatter } from "../../formatter"
|
|
||||||
import { Location } from "../../location"
|
|
||||||
import { Patch } from "@opencode-ai/util/patch"
|
import { Patch } from "@opencode-ai/util/patch"
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
import DESCRIPTION from "../patch.txt"
|
import DESCRIPTION from "../patch.txt"
|
||||||
@@ -57,27 +54,18 @@ type Prepared =
|
|||||||
readonly moveTarget?: Target
|
readonly moveTarget?: Target
|
||||||
})
|
})
|
||||||
|
|
||||||
interface Target {
|
type Target = LocationMutation.Target
|
||||||
readonly canonical: string
|
|
||||||
readonly resource: string
|
|
||||||
readonly externalDirectory?: {
|
|
||||||
readonly directory: string
|
|
||||||
readonly resource: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Plugin = {
|
export const Plugin = {
|
||||||
id: "opencode.tool.patch",
|
id: "opencode.tool.patch",
|
||||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||||
const fs = yield* FSUtil.Service
|
const mutation = yield* LocationMutation.Service
|
||||||
const formatter = yield* Formatter.Service
|
const files = yield* FileMutation.Service
|
||||||
const location = yield* Location.Service
|
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add(
|
draft.add({
|
||||||
({
|
|
||||||
name,
|
name,
|
||||||
options: { codemode: false, permission: "edit" },
|
options: { codemode: false, permission: "edit" },
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
@@ -99,9 +87,7 @@ export const Plugin = {
|
|||||||
}
|
}
|
||||||
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
|
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
|
||||||
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
|
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
|
||||||
Effect.mapError(
|
Effect.mapError((error) => new ToolFailure({ message: `patch verification failed: ${error.message}` })),
|
||||||
(error) => new ToolFailure({ message: `patch verification failed: ${error.message}` }),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
if (hunks.length === 0) {
|
if (hunks.length === 0) {
|
||||||
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||||
@@ -109,39 +95,40 @@ export const Plugin = {
|
|||||||
const prepared: Prepared[] = []
|
const prepared: Prepared[] = []
|
||||||
const targets: Target[] = []
|
const targets: Target[] = []
|
||||||
const updates = new Map<string, string>()
|
const updates = new Map<string, string>()
|
||||||
|
const resolveHunkTarget = (value: string) =>
|
||||||
|
mutation
|
||||||
|
.resolve({ path: value, kind: "file" })
|
||||||
|
.pipe(
|
||||||
|
Effect.mapError(
|
||||||
|
(error) => new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
|
||||||
|
),
|
||||||
|
)
|
||||||
for (const hunk of hunks) {
|
for (const hunk of hunks) {
|
||||||
yield* Effect.gen(function* () {
|
yield* Effect.gen(function* () {
|
||||||
const target = resolveTarget(location, hunk.path)
|
const target = yield* resolveHunkTarget(hunk.path)
|
||||||
targets.push(target)
|
targets.push(target)
|
||||||
if (target.externalDirectory) {
|
if (target.externalDirectory) {
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
action: "external_directory",
|
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
|
||||||
resources: [target.externalDirectory.resource],
|
|
||||||
save: [target.externalDirectory.resource],
|
|
||||||
metadata: {
|
|
||||||
filepath: target.canonical,
|
|
||||||
parentDir: target.externalDirectory.directory,
|
|
||||||
},
|
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (hunk.type === "add") {
|
if (hunk.type === "add") {
|
||||||
|
const contents =
|
||||||
|
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
|
||||||
prepared.push({
|
prepared.push({
|
||||||
...hunk,
|
...hunk,
|
||||||
|
contents,
|
||||||
target,
|
target,
|
||||||
before: "",
|
before: "",
|
||||||
after: Bom.split(
|
after: FileMutation.normalizeText(contents),
|
||||||
hunk.contents.endsWith("\n") || hunk.contents === ""
|
|
||||||
? hunk.contents
|
|
||||||
: `${hunk.contents}\n`,
|
|
||||||
).text,
|
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (hunk.type === "delete") {
|
if (hunk.type === "delete") {
|
||||||
const content = yield* Bom.readFile(fs, target.canonical).pipe(
|
const content = yield* files.read(target).pipe(
|
||||||
Effect.mapError(
|
Effect.mapError(
|
||||||
(error) =>
|
(error) =>
|
||||||
new ToolFailure({
|
new ToolFailure({
|
||||||
@@ -149,53 +136,30 @@ export const Plugin = {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
prepared.push({ ...hunk, target, before: content.text, after: "" })
|
prepared.push({ ...hunk, target, before: content, after: "" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const previous = updates.get(target.canonical)
|
const previous = updates.get(target.canonical)
|
||||||
const original =
|
const original =
|
||||||
previous ??
|
previous ??
|
||||||
(yield* Effect.gen(function* () {
|
(yield* files.read(target).pipe(
|
||||||
const stats = yield* fs.stat(target.canonical).pipe(
|
|
||||||
Effect.mapError(
|
Effect.mapError(
|
||||||
(error) =>
|
(error) =>
|
||||||
new ToolFailure({
|
new ToolFailure({
|
||||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
))
|
||||||
if (stats.type === "Directory") {
|
|
||||||
return yield* new ToolFailure({
|
|
||||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: path is a directory`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const content = yield* Bom.readFile(fs, target.canonical).pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
(error) =>
|
|
||||||
new ToolFailure({
|
|
||||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return Bom.join(content.text, content.bom)
|
|
||||||
}))
|
|
||||||
const before = Bom.split(original).text
|
|
||||||
const update = yield* Effect.try({
|
const update = yield* Effect.try({
|
||||||
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
||||||
catch: (error) =>
|
catch: (error) => new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
|
||||||
new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
|
|
||||||
})
|
})
|
||||||
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
|
const content = FileMutation.normalizeText(update.content)
|
||||||
|
const moveTarget = hunk.movePath ? yield* resolveHunkTarget(hunk.movePath) : undefined
|
||||||
if (moveTarget) targets.push(moveTarget)
|
if (moveTarget) targets.push(moveTarget)
|
||||||
if (moveTarget?.externalDirectory) {
|
if (moveTarget?.externalDirectory) {
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
action: "external_directory",
|
...LocationMutation.externalDirectoryPermission(moveTarget.externalDirectory),
|
||||||
resources: [moveTarget.externalDirectory.resource],
|
|
||||||
save: [moveTarget.externalDirectory.resource],
|
|
||||||
metadata: {
|
|
||||||
filepath: moveTarget.canonical,
|
|
||||||
parentDir: moveTarget.externalDirectory.directory,
|
|
||||||
},
|
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
@@ -205,11 +169,11 @@ export const Plugin = {
|
|||||||
...hunk,
|
...hunk,
|
||||||
target,
|
target,
|
||||||
content: Patch.joinBom(update.content, update.bom),
|
content: Patch.joinBom(update.content, update.bom),
|
||||||
before,
|
before: original,
|
||||||
after: update.content,
|
after: content,
|
||||||
moveTarget,
|
moveTarget,
|
||||||
})
|
})
|
||||||
if (!moveTarget) updates.set(target.canonical, Patch.joinBom(update.content, update.bom))
|
if (!moveTarget) updates.set(target.canonical, content)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.mapError((error) =>
|
Effect.mapError((error) =>
|
||||||
error instanceof ToolFailure
|
error instanceof ToolFailure
|
||||||
@@ -234,21 +198,18 @@ export const Plugin = {
|
|||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// FileMutation.write formats where the files live and reports
|
||||||
|
// the final text, so the diff output reflects disk.
|
||||||
|
const formatted = new Map<string, string>()
|
||||||
yield* Effect.forEach(
|
yield* Effect.forEach(
|
||||||
prepared,
|
prepared,
|
||||||
(change) =>
|
(change) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (change.type === "add") {
|
if (change.type === "add") {
|
||||||
yield* fs
|
const result = yield* files
|
||||||
.writeWithDirs(
|
.write({ target: change.target, content: change.contents })
|
||||||
change.target.canonical,
|
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||||
change.contents.endsWith("\n") || change.contents === ""
|
formatted.set(change.target.canonical, result.content)
|
||||||
? change.contents
|
|
||||||
: `${change.contents}\n`,
|
|
||||||
)
|
|
||||||
.pipe(
|
|
||||||
Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)),
|
|
||||||
)
|
|
||||||
applied.push({
|
applied.push({
|
||||||
type: change.type,
|
type: change.type,
|
||||||
resource: change.target.resource,
|
resource: change.target.resource,
|
||||||
@@ -257,11 +218,9 @@ export const Plugin = {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (change.type === "delete") {
|
if (change.type === "delete") {
|
||||||
yield* fs
|
yield* files
|
||||||
.remove(change.target.canonical)
|
.remove(change.target)
|
||||||
.pipe(
|
.pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)))
|
||||||
Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)),
|
|
||||||
)
|
|
||||||
applied.push({
|
applied.push({
|
||||||
type: change.type,
|
type: change.type,
|
||||||
resource: change.target.resource,
|
resource: change.target.resource,
|
||||||
@@ -271,14 +230,19 @@ export const Plugin = {
|
|||||||
}
|
}
|
||||||
if (change.moveTarget) {
|
if (change.moveTarget) {
|
||||||
const moveTarget = change.moveTarget
|
const moveTarget = change.moveTarget
|
||||||
yield* fs
|
const result = yield* files
|
||||||
.writeWithDirs(moveTarget.canonical, change.content)
|
.move({ from: change.target, to: moveTarget, content: change.content })
|
||||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
|
.pipe(
|
||||||
yield* fs.remove(change.target.canonical).pipe(
|
|
||||||
Effect.mapError((error) =>
|
Effect.mapError((error) =>
|
||||||
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
|
error instanceof FileMutation.MoveIncompleteError
|
||||||
|
? fail(
|
||||||
|
`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`,
|
||||||
|
error.cause,
|
||||||
|
)
|
||||||
|
: fail(`Failed to write ${moveTarget.resource}`, error),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
formatted.set(moveTarget.canonical, result.content)
|
||||||
applied.push({
|
applied.push({
|
||||||
type: change.type,
|
type: change.type,
|
||||||
resource: change.moveTarget.resource,
|
resource: change.moveTarget.resource,
|
||||||
@@ -286,9 +250,10 @@ export const Plugin = {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
yield* fs
|
const result = yield* files
|
||||||
.writeWithDirs(change.target.canonical, change.content)
|
.write({ target: change.target, content: change.content })
|
||||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||||
|
formatted.set(change.target.canonical, result.content)
|
||||||
applied.push({
|
applied.push({
|
||||||
type: change.type,
|
type: change.type,
|
||||||
resource: change.target.resource,
|
resource: change.target.resource,
|
||||||
@@ -297,31 +262,12 @@ export const Plugin = {
|
|||||||
}),
|
}),
|
||||||
{ discard: true },
|
{ discard: true },
|
||||||
)
|
)
|
||||||
const formatted = new Map<string, string>()
|
const fileDiffs = yield* Effect.forEach(prepared, (change) => {
|
||||||
yield* Effect.forEach(
|
|
||||||
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
|
|
||||||
(target) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const current = yield* Bom.readFile(fs, target).pipe(
|
|
||||||
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
|
|
||||||
)
|
|
||||||
formatted.set(
|
|
||||||
target,
|
|
||||||
(yield* formatter.file(target))
|
|
||||||
? yield* Bom.syncFile(fs, target, current.bom).pipe(
|
|
||||||
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
|
|
||||||
)
|
|
||||||
: current.text,
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
{ discard: true },
|
|
||||||
)
|
|
||||||
const files = yield* Effect.forEach(prepared, (change) => {
|
|
||||||
if (change.type === "delete") return Effect.succeed(patchFile(change))
|
if (change.type === "delete") return Effect.succeed(patchFile(change))
|
||||||
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
|
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
|
||||||
return Effect.succeed(patchFile(change, formatted.get(target.canonical)))
|
return Effect.succeed(patchFile(change, formatted.get(target.canonical)))
|
||||||
})
|
})
|
||||||
return { applied, files }
|
return { applied, files: fileDiffs }
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.map((output) => ({
|
Effect.map((output) => ({
|
||||||
output,
|
output,
|
||||||
@@ -329,14 +275,11 @@ export const Plugin = {
|
|||||||
metadata: { files: output.files },
|
metadata: { files: output.files },
|
||||||
})),
|
})),
|
||||||
Effect.mapError((error) =>
|
Effect.mapError((error) =>
|
||||||
error instanceof ToolFailure
|
error instanceof ToolFailure ? error : new ToolFailure({ message: "Unable to apply patch", error }),
|
||||||
? error
|
|
||||||
: new ToolFailure({ message: "Unable to apply patch", error }),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
@@ -354,8 +297,13 @@ export const Plugin = {
|
|||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorMessage(error: unknown) {
|
function errorMessage(error: unknown) {
|
||||||
|
if (error instanceof FileMutation.NotFoundError) return "file does not exist"
|
||||||
|
if (error instanceof FileMutation.NotAFileError) return "path is a directory"
|
||||||
|
if (error instanceof LocationMutation.PathError)
|
||||||
|
return error.reason === "outside_workspace"
|
||||||
|
? `path is outside the workspace: ${error.path}`
|
||||||
|
: `ancestor is not a directory: ${error.path}`
|
||||||
if (error instanceof PlatformError) {
|
if (error instanceof PlatformError) {
|
||||||
if (error.reason._tag === "NotFound") return "file does not exist"
|
if (error.reason._tag === "NotFound") return "file does not exist"
|
||||||
return error.reason.description ?? error.reason.message
|
return error.reason.description ?? error.reason.message
|
||||||
@@ -365,9 +313,7 @@ function errorMessage(error: unknown) {
|
|||||||
|
|
||||||
function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
|
function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
|
||||||
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
|
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
|
||||||
const patch = trimDiff(
|
const patch = trimDiff(createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, after))
|
||||||
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, after),
|
|
||||||
)
|
|
||||||
const counts =
|
const counts =
|
||||||
change.type === "delete"
|
change.type === "delete"
|
||||||
? { additions: 0, deletions: change.before.split("\n").length }
|
? { additions: 0, deletions: change.before.split("\n").length }
|
||||||
@@ -414,24 +360,3 @@ function trimDiff(diff: string) {
|
|||||||
})
|
})
|
||||||
.join("\n")
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTarget(location: Location.Interface, value: string): Target {
|
|
||||||
const canonical =
|
|
||||||
process.platform === "win32"
|
|
||||||
? FSUtil.normalizePath(path.resolve(location.directory, value))
|
|
||||||
: path.resolve(location.directory, value)
|
|
||||||
const projectRoot = path.parse(location.project.directory).root
|
|
||||||
const external =
|
|
||||||
!FSUtil.contains(location.directory, canonical) &&
|
|
||||||
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, canonical))
|
|
||||||
const directory = path.dirname(canonical)
|
|
||||||
const resource =
|
|
||||||
process.platform === "win32"
|
|
||||||
? FSUtil.normalizePathPattern(path.join(directory, "*"))
|
|
||||||
: path.join(directory, "*").replaceAll("\\", "/")
|
|
||||||
return {
|
|
||||||
canonical,
|
|
||||||
resource: path.relative(location.project.directory, canonical).replaceAll("\\", "/") || ".",
|
|
||||||
externalDirectory: external ? { directory, resource } : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,11 +25,7 @@ const LocationInput = Schema.Struct({
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
export const Input = LocationInput
|
export const Input = LocationInput
|
||||||
const Output = Schema.Union([
|
const Output = Schema.Union([ReadToolFileSystem.FileContent, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage])
|
||||||
ReadToolFileSystem.FileContent,
|
|
||||||
ReadToolFileSystem.TextPage,
|
|
||||||
ReadToolFileSystem.ListPage,
|
|
||||||
])
|
|
||||||
|
|
||||||
export const Plugin = {
|
export const Plugin = {
|
||||||
id: "opencode.tool.read",
|
id: "opencode.tool.read",
|
||||||
@@ -43,8 +39,7 @@ export const Plugin = {
|
|||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add(
|
draft.add({
|
||||||
({
|
|
||||||
name,
|
name,
|
||||||
options: { codemode: false },
|
options: { codemode: false },
|
||||||
description:
|
description:
|
||||||
@@ -77,9 +72,9 @@ export const Plugin = {
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const type = yield* reader.inspect(absolute).pipe(
|
const type = yield* reader
|
||||||
Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.canonical)),
|
.inspect(absolute)
|
||||||
)
|
.pipe(Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.canonical)))
|
||||||
const content =
|
const content =
|
||||||
type === "directory"
|
type === "directory"
|
||||||
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
||||||
@@ -93,7 +88,9 @@ export const Plugin = {
|
|||||||
// is discovered); for a file it starts at the file's dirname. External reads are
|
// is discovered); for a file it starts at the file's dirname. External reads are
|
||||||
// skipped, and discovery failures never fail the read.
|
// skipped, and discovery failures never fail the read.
|
||||||
yield* Effect.gen(function* () {
|
yield* Effect.gen(function* () {
|
||||||
if (target.externalDirectory !== undefined) return
|
// External reads skip discovery, and so do hosted Locations:
|
||||||
|
// the walk reads the host filesystem.
|
||||||
|
if (external !== undefined || location.workspaceID !== undefined) return
|
||||||
const resolved = yield* fs.resolve(target.canonical)
|
const resolved = yield* fs.resolve(target.canonical)
|
||||||
const root = yield* fs.resolve(location.directory)
|
const root = yield* fs.resolve(location.directory)
|
||||||
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
||||||
@@ -135,7 +132,6 @@ export const Plugin = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import path from "path"
|
|||||||
import { ToolFailure } from "@opencode-ai/ai"
|
import { ToolFailure } from "@opencode-ai/ai"
|
||||||
import type { Content } from "@opencode-ai/schema/tool"
|
import type { Content } from "@opencode-ai/schema/tool"
|
||||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import { Deferred, Effect, Schema, Scope } from "effect"
|
import { Array, Deferred, Effect, Schema, Scope } from "effect"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
|
||||||
import { LocationMutation } from "../../location-mutation"
|
import { LocationMutation } from "../../location-mutation"
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
import { PluginRuntime } from "../../plugin/runtime"
|
import { PluginRuntime } from "../../plugin/runtime"
|
||||||
@@ -82,7 +81,6 @@ export const Plugin = {
|
|||||||
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
|
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
|
||||||
const runtime = yield* PluginRuntime.Service
|
const runtime = yield* PluginRuntime.Service
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
const fsUtil = yield* FSUtil.Service
|
|
||||||
const mutation = yield* LocationMutation.Service
|
const mutation = yield* LocationMutation.Service
|
||||||
const shell = yield* Shell.Service
|
const shell = yield* Shell.Service
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
@@ -154,10 +152,12 @@ export const Plugin = {
|
|||||||
)
|
)
|
||||||
invocation.cwd = target.canonical
|
invocation.cwd = target.canonical
|
||||||
finalTimeout = invocation.timeout
|
finalTimeout = invocation.timeout
|
||||||
const external = [target, ...directories]
|
const external = Array.dedupeWith(
|
||||||
|
[target, ...directories]
|
||||||
.map((item) => item.externalDirectory)
|
.map((item) => item.externalDirectory)
|
||||||
.filter((item) => item !== undefined)
|
.filter((item) => item !== undefined),
|
||||||
.filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index)
|
(left, right) => left.resource === right.resource,
|
||||||
|
)
|
||||||
if (external.length > 0)
|
if (external.length > 0)
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
action: "external_directory",
|
action: "external_directory",
|
||||||
@@ -176,13 +176,6 @@ export const Plugin = {
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const workdir = yield* fsUtil.stat(target.canonical).pipe(
|
|
||||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
|
||||||
Effect.fail(new Error(`Working directory does not exist: ${target.canonical}`)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (workdir.type !== "Directory")
|
|
||||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* context.progress({ shellID: info.id })
|
yield* context.progress({ shellID: info.id })
|
||||||
|
|||||||
@@ -9,10 +9,7 @@ export * as WriteTool from "./write"
|
|||||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import { ToolFailure } from "@opencode-ai/ai"
|
import { ToolFailure } from "@opencode-ai/ai"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { Bom } from "@opencode-ai/util/bom"
|
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
|
||||||
import { FileMutation } from "../../file-mutation"
|
import { FileMutation } from "../../file-mutation"
|
||||||
import { Formatter } from "../../formatter"
|
|
||||||
import { LocationMutation } from "../../location-mutation"
|
import { LocationMutation } from "../../location-mutation"
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
import { fileDiff } from "./file-diff"
|
import { fileDiff } from "./file-diff"
|
||||||
@@ -48,14 +45,11 @@ export const Plugin = {
|
|||||||
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
|
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
|
||||||
const mutation = yield* LocationMutation.Service
|
const mutation = yield* LocationMutation.Service
|
||||||
const files = yield* FileMutation.Service
|
const files = yield* FileMutation.Service
|
||||||
const formatter = yield* Formatter.Service
|
|
||||||
const fs = yield* FSUtil.Service
|
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add(
|
draft.add({
|
||||||
({
|
|
||||||
name,
|
name,
|
||||||
options: { codemode: false, permission: "edit" },
|
options: { codemode: false, permission: "edit" },
|
||||||
description:
|
description:
|
||||||
@@ -78,15 +72,15 @@ export const Plugin = {
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const current = yield* Bom.readFile(fs, target.canonical).pipe(
|
const current = yield* files
|
||||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
|
.read(target)
|
||||||
)
|
.pipe(Effect.catchTag("FileMutation.NotFoundError", () => Effect.succeed(undefined)))
|
||||||
const next = Bom.split(input.content)
|
const content = FileMutation.normalizeText(input.content)
|
||||||
const preview = fileDiff(
|
const preview = fileDiff(
|
||||||
target.resource,
|
target.resource,
|
||||||
current?.text ?? "",
|
current ?? "",
|
||||||
next.text,
|
content,
|
||||||
current ? "modified" : "added",
|
current === undefined ? "added" : "modified",
|
||||||
)
|
)
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
action: "edit",
|
action: "edit",
|
||||||
@@ -97,16 +91,18 @@ export const Plugin = {
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
|
const result = yield* files.write({ target, content: input.content })
|
||||||
const bom = (yield* Bom.readFile(fs, target.canonical)).bom
|
return {
|
||||||
if (yield* formatter.file(target.canonical)) yield* Bom.syncFile(fs, target.canonical, bom)
|
operation: result.operation,
|
||||||
return result
|
target: result.target,
|
||||||
|
resource: result.resource,
|
||||||
|
existed: result.existed,
|
||||||
|
}
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
||||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ export * as ReadToolFileSystem from "./read-filesystem"
|
|||||||
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
import { Context, Effect, Layer, Option, Schema, Scope } from "effect"
|
||||||
|
import { systemError } from "effect/PlatformError"
|
||||||
import { FileSystem } from "../filesystem"
|
import { FileSystem } from "../filesystem"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "../schema"
|
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "../schema"
|
||||||
|
import { WorkspaceEnvironment } from "../workspace/environment"
|
||||||
|
|
||||||
export const MAX_READ_LINES = 2_000
|
export const MAX_READ_LINES = 2_000
|
||||||
export const MAX_READ_BYTES = 50 * 1024
|
export const MAX_READ_BYTES = 50 * 1024
|
||||||
@@ -109,6 +111,24 @@ export interface Interface {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
|
||||||
|
|
||||||
|
type FileType = WorkspaceEnvironment.FileInfo["type"]
|
||||||
|
|
||||||
|
/** The filesystem surface `read` consumes; FSUtil satisfies it structurally. */
|
||||||
|
export interface ReadSource {
|
||||||
|
readonly realPath: (path: string) => Effect.Effect<string, FSUtil.Error>
|
||||||
|
readonly open: (
|
||||||
|
path: string,
|
||||||
|
options: { readonly flag: "r" },
|
||||||
|
) => Effect.Effect<
|
||||||
|
{
|
||||||
|
readonly stat: Effect.Effect<{ readonly type: FileType; readonly size: bigint | number }, FSUtil.Error>
|
||||||
|
readonly readAlloc: (bytes: number) => Effect.Effect<Option.Option<Uint8Array>, FSUtil.Error>
|
||||||
|
},
|
||||||
|
FSUtil.Error,
|
||||||
|
Scope.Scope
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
const extensions = new Set([
|
const extensions = new Set([
|
||||||
".zip",
|
".zip",
|
||||||
".tar",
|
".tar",
|
||||||
@@ -169,7 +189,10 @@ const decodeUtf8 = (resource: string, decoder: TextDecoder, bytes?: Uint8Array)
|
|||||||
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
|
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
|
||||||
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : decodeUtf8(resource, decoder, bytes)
|
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : decodeUtf8(resource, decoder, bytes)
|
||||||
|
|
||||||
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
|
export const inspect = Effect.fn("ReadTool.inspect")(function* (
|
||||||
|
fs: { readonly stat: (path: string) => Effect.Effect<{ readonly type: FileType }, FSUtil.Error> },
|
||||||
|
input: string,
|
||||||
|
) {
|
||||||
const info = yield* fs.stat(input)
|
const info = yield* fs.stat(input)
|
||||||
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
|
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
|
||||||
if (!type) return yield* Effect.fail(new PathKindError({ resource: input, expected: "a file or directory" }))
|
if (!type) return yield* Effect.fail(new PathKindError({ resource: input, expected: "a file or directory" }))
|
||||||
@@ -177,7 +200,7 @@ export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Inter
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const read = Effect.fn("ReadTool.read")(function* (
|
export const read = Effect.fn("ReadTool.read")(function* (
|
||||||
fs: FSUtil.Interface,
|
fs: ReadSource,
|
||||||
input: string,
|
input: string,
|
||||||
resource: string,
|
resource: string,
|
||||||
page: PageInput = {},
|
page: PageInput = {},
|
||||||
@@ -210,10 +233,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
|||||||
type: "file" as const,
|
type: "file" as const,
|
||||||
uri: pathToFileURL(real).href,
|
uri: pathToFileURL(real).href,
|
||||||
name: path.basename(real),
|
name: path.basename(real),
|
||||||
content: Buffer.concat(
|
content: Buffer.concat(chunks, total).toString("base64"),
|
||||||
chunks.map((chunk) => Buffer.from(chunk)),
|
|
||||||
total,
|
|
||||||
).toString("base64"),
|
|
||||||
encoding: "base64" as const,
|
encoding: "base64" as const,
|
||||||
mime,
|
mime,
|
||||||
}
|
}
|
||||||
@@ -334,8 +354,6 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
|||||||
export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) {
|
export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) {
|
||||||
const real = yield* fs.realPath(input)
|
const real = yield* fs.realPath(input)
|
||||||
const items = yield* fs.readDirectoryEntries(real)
|
const items = yield* fs.readDirectoryEntries(real)
|
||||||
const offset = page.offset || 1
|
|
||||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
|
||||||
const entries = yield* Effect.forEach(
|
const entries = yield* Effect.forEach(
|
||||||
items,
|
items,
|
||||||
(item) =>
|
(item) =>
|
||||||
@@ -353,9 +371,16 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
|
|||||||
}),
|
}),
|
||||||
{ concurrency: 16 },
|
{ concurrency: 16 },
|
||||||
)
|
)
|
||||||
const visible = entries
|
return sortAndPage(
|
||||||
.filter((item): item is FileSystem.Entry => item !== undefined)
|
entries.filter((item): item is FileSystem.Entry => item !== undefined),
|
||||||
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1))
|
page,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortAndPage = (entries: FileSystem.Entry[], page: PageInput) => {
|
||||||
|
const offset = page.offset || 1
|
||||||
|
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||||
|
const visible = entries.sort(FileSystem.compareEntries)
|
||||||
const selected = visible.slice(offset - 1, offset - 1 + limit)
|
const selected = visible.slice(offset - 1, offset - 1 + limit)
|
||||||
const truncated = offset - 1 + selected.length < visible.length
|
const truncated = offset - 1 + selected.length < visible.length
|
||||||
return new ListPage({
|
return new ListPage({
|
||||||
@@ -364,7 +389,7 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
|
|||||||
truncated,
|
truncated,
|
||||||
...(truncated ? { next: offset + selected.length } : {}),
|
...(truncated ? { next: offset + selected.length } : {}),
|
||||||
})
|
})
|
||||||
})
|
}
|
||||||
|
|
||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
@@ -379,3 +404,118 @@ const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
||||||
|
|
||||||
|
// Reads through WorkspaceEnvironment.Files so hosted sessions never touch the
|
||||||
|
// host filesystem. The environment has no ranged read, so file reads transfer
|
||||||
|
// the whole file once and page in memory.
|
||||||
|
const hostedLayer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const env = yield* WorkspaceEnvironment.Service
|
||||||
|
|
||||||
|
// Absence surfaces as a platform NotFound so the read tool's missing-file
|
||||||
|
// handling stays backend-agnostic; other failures become filesystem errors.
|
||||||
|
const mapError =
|
||||||
|
(method: string) =>
|
||||||
|
(error: WorkspaceEnvironment.Error | WorkspaceEnvironment.NotFoundError): FSUtil.Error =>
|
||||||
|
error._tag === "WorkspaceEnvironment.NotFoundError"
|
||||||
|
? systemError({ _tag: "NotFound", module: "FileSystem", method, pathOrDescriptor: error.path, cause: error })
|
||||||
|
: WorkspaceEnvironment.toFileSystemError(method)(error)
|
||||||
|
|
||||||
|
const stats = { stat: (target: string) => env.files.stat(target).pipe(Effect.mapError(mapError("stat"))) }
|
||||||
|
|
||||||
|
const source: ReadSource = {
|
||||||
|
// Paths arrive canonical from LocationMutation.resolve, so identity
|
||||||
|
// avoids a provider canonicalization round trip per read.
|
||||||
|
realPath: Effect.succeed,
|
||||||
|
// The environment has no ranged read, so each read call transfers the
|
||||||
|
// whole file once and pages in memory. Happy-path opens are that one
|
||||||
|
// transfer; only the failure path stats to classify a non-file target,
|
||||||
|
// which the shared read reports as PathKindError.
|
||||||
|
// TODO: Bound and page large hosted reads once environments expose file
|
||||||
|
// size and ranged reads.
|
||||||
|
open: (target) =>
|
||||||
|
env.files.read(target).pipe(
|
||||||
|
Effect.map((bytes) => {
|
||||||
|
let cursor = 0
|
||||||
|
return {
|
||||||
|
stat: Effect.succeed({ type: "File" as const, size: bytes.length }),
|
||||||
|
readAlloc: (size: number) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (cursor >= bytes.length) return Option.none<Uint8Array>()
|
||||||
|
const chunk = bytes.subarray(cursor, Math.min(cursor + size, bytes.length))
|
||||||
|
cursor += chunk.length
|
||||||
|
return Option.some(chunk)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Effect.catchTag("WorkspaceEnvironment.Error", (error) =>
|
||||||
|
stats.stat(target).pipe(
|
||||||
|
Effect.catch(() => Effect.fail(mapError("read")(error))),
|
||||||
|
Effect.flatMap((info) =>
|
||||||
|
info.type === "File"
|
||||||
|
? Effect.fail(mapError("read")(error))
|
||||||
|
: Effect.succeed({
|
||||||
|
stat: Effect.succeed({ type: info.type, size: 0 }),
|
||||||
|
readAlloc: () => Effect.succeed(Option.none<Uint8Array>()),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.catchTag("WorkspaceEnvironment.NotFoundError", (error) => Effect.fail(mapError("read")(error))),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches the local list semantics: symlinks resolve and escape-filter
|
||||||
|
// against the listed directory. Non-symlink types come from the listing
|
||||||
|
// itself, keeping provider round trips proportional to symlink count.
|
||||||
|
const resolveSymlink = Effect.fnUntraced(function* (parent: string, name: string) {
|
||||||
|
const target = yield* env.files
|
||||||
|
.realPath(path.posix.join(parent, name))
|
||||||
|
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
|
if (target === undefined || !FSUtil.containsPosix(parent, target)) return undefined
|
||||||
|
const info = yield* env.files.stat(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
|
if (info?.type === "Directory") return "directory" as const
|
||||||
|
if (info?.type === "File") return "file" as const
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("ReadTool.list")(function* (input: string, page: PageInput = {}) {
|
||||||
|
const items = yield* env.files.list(input).pipe(Effect.mapError(mapError("list")))
|
||||||
|
const entries = yield* Effect.forEach(
|
||||||
|
items,
|
||||||
|
(item) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const type =
|
||||||
|
item.type === "file" || item.type === "directory"
|
||||||
|
? item.type
|
||||||
|
: item.type === "symlink"
|
||||||
|
? yield* resolveSymlink(input, item.name)
|
||||||
|
: undefined
|
||||||
|
if (!type) return
|
||||||
|
return FileSystem.Entry.make({
|
||||||
|
path: RelativePath.make(item.name + (type === "directory" ? "/" : "")),
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
{ concurrency: 4 },
|
||||||
|
)
|
||||||
|
return sortAndPage(
|
||||||
|
entries.filter((item): item is FileSystem.Entry => item !== undefined),
|
||||||
|
page,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
inspect: (target) => inspect(stats, target),
|
||||||
|
read: (target, resource, page) => read(source, target, resource, page),
|
||||||
|
list,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const hostedNode = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer: hostedLayer,
|
||||||
|
deps: [WorkspaceEnvironment.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,80 @@
|
|||||||
export * as Workspace from "./workspace"
|
export * as Workspace from "./workspace"
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { Context, Effect, Exit, Layer, Schema } from "effect"
|
||||||
|
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||||
|
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||||
|
import { Database } from "./database/database"
|
||||||
|
import { WorkspaceDriver } from "./workspace/driver"
|
||||||
|
import { WorkspaceTable } from "./workspace/sql"
|
||||||
|
|
||||||
export const ID = Workspace.ID
|
export const ID = Workspace.ID
|
||||||
export type ID = typeof ID.Type
|
export type ID = typeof ID.Type
|
||||||
|
|
||||||
|
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Workspace.NotFoundError", {
|
||||||
|
id: ID,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||||
|
export const Info = Schema.Struct({
|
||||||
|
id: ID,
|
||||||
|
provider: Schema.String,
|
||||||
|
root: AbsolutePath,
|
||||||
|
}).annotate({ identifier: "Workspace.Info" })
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
/** Allocate through the named driver and persist. Resolves when the Workspace is usable. */
|
||||||
|
readonly create: (input: {
|
||||||
|
readonly provider: string
|
||||||
|
}) => Effect.Effect<Info, WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFoundError>
|
||||||
|
/** Metadata read; never contacts a driver. */
|
||||||
|
readonly get: (id: ID) => Effect.Effect<Info, NotFoundError>
|
||||||
|
/** Provider key and opaque binding for WorkspaceDriver.connect. Hosted Location construction only. */
|
||||||
|
readonly binding: (
|
||||||
|
id: ID,
|
||||||
|
) => Effect.Effect<{ readonly provider: string; readonly binding: WorkspaceDriver.Binding }, NotFoundError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Workspace") {}
|
||||||
|
|
||||||
|
const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
const registry = yield* WorkspaceDriver.RegistryService
|
||||||
|
|
||||||
|
const require = Effect.fn("Workspace.require")(function* (id: ID) {
|
||||||
|
const row = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get().pipe(Effect.orDie)
|
||||||
|
if (!row) return yield* new NotFoundError({ id })
|
||||||
|
return row
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
create: Effect.fn("Workspace.create")(function* (input) {
|
||||||
|
const driver = yield* registry.get(input.provider)
|
||||||
|
const id = ID.create()
|
||||||
|
const created = yield* Effect.acquireUseRelease(
|
||||||
|
driver.create({ workspaceID: id }),
|
||||||
|
(created) =>
|
||||||
|
db
|
||||||
|
.insert(WorkspaceTable)
|
||||||
|
.values({ id, provider: input.provider, binding: created.binding, root: created.root })
|
||||||
|
.pipe(Effect.orDie, Effect.as(created)),
|
||||||
|
(created, exit) => (Exit.isFailure(exit) ? driver.destroy(created.binding).pipe(Effect.ignore) : Effect.void),
|
||||||
|
)
|
||||||
|
return Info.make({ id, provider: input.provider, root: AbsolutePath.make(created.root) })
|
||||||
|
}),
|
||||||
|
get: Effect.fn("Workspace.get")(function* (id) {
|
||||||
|
const row = yield* require(id)
|
||||||
|
return Info.make({ id: row.id, provider: row.provider, root: AbsolutePath.make(row.root) })
|
||||||
|
}),
|
||||||
|
binding: Effect.fn("Workspace.binding")(function* (id) {
|
||||||
|
const row = yield* require(id)
|
||||||
|
return { provider: row.provider, binding: row.binding }
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node, WorkspaceDriver.registryNode] })
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
export * as WorkspaceDriver from "./driver"
|
||||||
|
|
||||||
|
import { Context, Effect, Layer, Schema, Scope } from "effect"
|
||||||
|
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||||
|
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||||
|
import type { WorkspaceEnvironment } from "./environment"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smallest provider-owned JSON value required to reconnect to the same
|
||||||
|
* provider resource. Core stores it opaquely and hands it back; only the
|
||||||
|
* owning driver reads inside.
|
||||||
|
*/
|
||||||
|
export const Binding = Schema.Record(Schema.String, Schema.Json)
|
||||||
|
export type Binding = typeof Binding.Type
|
||||||
|
|
||||||
|
export class Error extends Schema.TaggedErrorClass<Error>()("WorkspaceDriver.Error", {
|
||||||
|
provider: Schema.String,
|
||||||
|
message: Schema.optional(Schema.String),
|
||||||
|
cause: Schema.optional(Schema.Defect()),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
|
||||||
|
"WorkspaceDriver.ProviderNotFoundError",
|
||||||
|
{ provider: Schema.String },
|
||||||
|
) {}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
/** Allocate a new environment. Resolves only when it is ready to use. */
|
||||||
|
readonly create: (input: {
|
||||||
|
readonly workspaceID: Workspace.ID
|
||||||
|
}) => Effect.Effect<{ readonly binding: Binding; readonly root: string }, Error>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binding -> live capabilities; the only way to obtain an environment.
|
||||||
|
* Fresh-create and restart-reconnect both flow through here. Closing the
|
||||||
|
* scope drops the connection, never the provider resource.
|
||||||
|
*/
|
||||||
|
readonly connect: (binding: Binding) => Effect.Effect<WorkspaceEnvironment.Interface, Error, Scope.Scope>
|
||||||
|
|
||||||
|
/** Permanently release provider resources. */
|
||||||
|
readonly destroy: (binding: Binding) => Effect.Effect<void, Error>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Identity constructor so driver definitions get contextual typing. */
|
||||||
|
export const make = (driver: Interface) => driver
|
||||||
|
|
||||||
|
export interface Registry {
|
||||||
|
readonly get: (provider: string) => Effect.Effect<Interface, ProviderNotFoundError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RegistryService extends Context.Service<RegistryService, Registry>()("@opencode/WorkspaceDriverRegistry") {}
|
||||||
|
|
||||||
|
/** Immutable registry fixed at composition time. */
|
||||||
|
export const registry = (drivers: Readonly<Record<string, Interface>>): Registry => ({
|
||||||
|
get: (provider) => {
|
||||||
|
const driver = drivers[provider]
|
||||||
|
return driver ? Effect.succeed(driver) : Effect.fail(new ProviderNotFoundError({ provider }))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defaults to no drivers, so every provider fails with ProviderNotFoundError.
|
||||||
|
* Server composition (or a test) replaces this binding with real drivers;
|
||||||
|
* core never constructs one.
|
||||||
|
*/
|
||||||
|
export const registryNode = makeGlobalNode({
|
||||||
|
service: RegistryService,
|
||||||
|
layer: Layer.succeed(RegistryService, RegistryService.of(registry({}))),
|
||||||
|
deps: [],
|
||||||
|
})
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
export * as WorkspaceEnvironment from "./environment"
|
||||||
|
|
||||||
|
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
|
||||||
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
|
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
|
import { makeLocationNode, tags } from "@opencode-ai/util/effect/app-node"
|
||||||
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
|
import { Workspace } from "../workspace"
|
||||||
|
import { WorkspaceDriver } from "./driver"
|
||||||
|
|
||||||
|
export class Error extends Schema.TaggedErrorClass<Error>()("WorkspaceEnvironment.Error", {
|
||||||
|
operation: Schema.String,
|
||||||
|
path: Schema.optional(Schema.String),
|
||||||
|
cause: Schema.optional(Schema.Defect()),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
/** Distinct so callers (e.g. the LocationMutation ancestor walk) can catch absence. */
|
||||||
|
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("WorkspaceEnvironment.NotFoundError", {
|
||||||
|
path: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
/** Translate environment failures into the host filesystem error vocabulary. */
|
||||||
|
export const toFileSystemError =
|
||||||
|
(method: string) =>
|
||||||
|
(cause: Error | NotFoundError): FSUtil.Error =>
|
||||||
|
new FSUtil.FileSystemError({ method, cause })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap one driver promise, translating the driver's not-found signal into the
|
||||||
|
* environment error vocabulary so drivers and fakes never construct it ad hoc.
|
||||||
|
*/
|
||||||
|
export const tryOperation = <A>(input: {
|
||||||
|
readonly operation: string
|
||||||
|
readonly path: string
|
||||||
|
readonly run: () => Promise<A>
|
||||||
|
readonly isNotFound: (cause: unknown) => boolean
|
||||||
|
}): Effect.Effect<A, Error | NotFoundError> =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: input.run,
|
||||||
|
catch: (cause) =>
|
||||||
|
input.isNotFound(cause)
|
||||||
|
? new NotFoundError({ path: input.path })
|
||||||
|
: new Error({ operation: input.operation, path: input.path, cause }),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface FileInfo {
|
||||||
|
readonly type: FileSystem.File.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectoryEntry {
|
||||||
|
readonly name: string
|
||||||
|
readonly type: "file" | "directory" | "symlink" | "other"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The minimal primitive set the hosted implementations of Filesystem,
|
||||||
|
* LocationMutation, and FileMutation consume. Grow it only when a real
|
||||||
|
* consumer appears. Every method is one provider round trip: type mismatches
|
||||||
|
* (read a directory, list a file) fail with Error instead of requiring a
|
||||||
|
* stat pre-check.
|
||||||
|
*/
|
||||||
|
export interface Files {
|
||||||
|
readonly stat: (path: string) => Effect.Effect<FileInfo, Error | NotFoundError>
|
||||||
|
/** Canonical path with symlinks resolved; identity for permissions and locking. */
|
||||||
|
readonly realPath: (path: string) => Effect.Effect<string, Error | NotFoundError>
|
||||||
|
readonly read: (path: string) => Effect.Effect<Uint8Array, Error | NotFoundError>
|
||||||
|
readonly list: (path: string) => Effect.Effect<readonly DirectoryEntry[], Error | NotFoundError>
|
||||||
|
/** Creates parent directories, matching FSUtil.writeWithDirs. */
|
||||||
|
readonly write: (path: string, content: Uint8Array) => Effect.Effect<void, Error>
|
||||||
|
/** Removes one file. Removing a missing path fails with NotFoundError. */
|
||||||
|
readonly remove: (path: string) => Effect.Effect<void, Error | NotFoundError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Shell {
|
||||||
|
readonly executable: string
|
||||||
|
readonly args: (command: string) => readonly string[]
|
||||||
|
readonly environmentOverrides: Readonly<Record<string, string>>
|
||||||
|
readonly detached: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
/** The Workspace root, absolute in the provider filesystem. */
|
||||||
|
readonly directory: string
|
||||||
|
readonly files: Files
|
||||||
|
readonly process: ChildProcessSpawner["Service"]
|
||||||
|
readonly shell: Shell
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/WorkspaceEnvironment") {}
|
||||||
|
|
||||||
|
/** NotFound becomes undefined; other environment failures become defects. */
|
||||||
|
export const optional = <A>(effect: Effect.Effect<A, Error | NotFoundError>): Effect.Effect<A | undefined> =>
|
||||||
|
effect.pipe(
|
||||||
|
Effect.catchTag("WorkspaceEnvironment.NotFoundError", () => Effect.succeed(undefined)),
|
||||||
|
Effect.orDie,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Present only in hosted Location graphs; local graphs never bind it. */
|
||||||
|
export const node = LayerNode.unbound(Service, tags.values.location)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects on graph boot and stays connected for the graph's cached lifetime:
|
||||||
|
* Layer.effect supplies the build scope, so releasing the LayerMap entry
|
||||||
|
* closes the connection. It never stops or deletes the provider resource.
|
||||||
|
*/
|
||||||
|
export const hostedNode = (workspaceID: Workspace.ID) =>
|
||||||
|
makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
// Connect failures during graph build are defects, matching the local
|
||||||
|
// graph's E = never. Typed availability errors are a later design.
|
||||||
|
layer: Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const workspaces = yield* Workspace.Service
|
||||||
|
const registry = yield* WorkspaceDriver.RegistryService
|
||||||
|
const found = yield* workspaces.binding(workspaceID)
|
||||||
|
const driver = yield* registry.get(found.provider)
|
||||||
|
return yield* driver.connect(found.binding)
|
||||||
|
}),
|
||||||
|
).pipe(Layer.orDie),
|
||||||
|
deps: [Workspace.node, WorkspaceDriver.registryNode],
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Identity constructor so environment literals get contextual typing. */
|
||||||
|
export const make = (environment: Interface) => environment
|
||||||
|
|
||||||
|
/** Default lowering for Linux sandbox images. */
|
||||||
|
export const linuxShell: Shell = {
|
||||||
|
executable: "/bin/bash",
|
||||||
|
args: (command) => ["-c", command],
|
||||||
|
environmentOverrides: {},
|
||||||
|
detached: false,
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||||
|
import { Timestamps } from "../database/schema.sql"
|
||||||
|
import type { Workspace } from "@opencode-ai/schema/workspace"
|
||||||
|
import type { WorkspaceDriver } from "./driver"
|
||||||
|
|
||||||
|
export const WorkspaceTable = sqliteTable("workspace", {
|
||||||
|
id: text().$type<Workspace.ID>().primaryKey(),
|
||||||
|
provider: text().notNull(),
|
||||||
|
binding: text({ mode: "json" }).$type<WorkspaceDriver.Binding>().notNull(),
|
||||||
|
root: text().notNull(),
|
||||||
|
...Timestamps,
|
||||||
|
})
|
||||||
@@ -46,6 +46,7 @@ describe("FileMutation", () => {
|
|||||||
target: target.canonical,
|
target: target.canonical,
|
||||||
resource: "hello.txt",
|
resource: "hello.txt",
|
||||||
existed: true,
|
existed: true,
|
||||||
|
content: "after",
|
||||||
})
|
})
|
||||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("after")
|
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("after")
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
@@ -65,6 +66,7 @@ describe("FileMutation", () => {
|
|||||||
target: target.canonical,
|
target: target.canonical,
|
||||||
resource: "src/nested/hello.txt",
|
resource: "src/nested/hello.txt",
|
||||||
existed: false,
|
existed: false,
|
||||||
|
content: "hello",
|
||||||
})
|
})
|
||||||
expect(yield* Effect.promise(() => fs.readFile(result.target, "utf8"))).toBe("hello")
|
expect(yield* Effect.promise(() => fs.readFile(result.target, "utf8"))).toBe("hello")
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
@@ -80,8 +82,8 @@ describe("FileMutation", () => {
|
|||||||
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
|
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
|
||||||
const files = yield* FileMutation.Service
|
const files = yield* FileMutation.Service
|
||||||
|
|
||||||
yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
|
yield* files.write({ target: preserved, content: "\uFEFFafter" })
|
||||||
yield* files.writeTextPreservingBom({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
|
yield* files.write({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
|
||||||
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter")
|
expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter")
|
||||||
expect(yield* Effect.promise(() => fs.readFile(created.canonical, "utf8"))).toBe("\uFEFFcreated")
|
expect(yield* Effect.promise(() => fs.readFile(created.canonical, "utf8"))).toBe("\uFEFFcreated")
|
||||||
@@ -89,6 +91,31 @@ describe("FileMutation", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("moves a file and transfers its BOM to the destination", () =>
|
||||||
|
withTmp((directory) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fromPath = path.join(directory, "from.txt")
|
||||||
|
yield* Effect.promise(() => fs.writeFile(fromPath, "\uFEFFbefore"))
|
||||||
|
const mutation = yield* LocationMutation.Service
|
||||||
|
const from = yield* mutation.resolve({ path: "from.txt" })
|
||||||
|
const to = yield* mutation.resolve({ path: "to.txt" })
|
||||||
|
|
||||||
|
const result = yield* (yield* FileMutation.Service).move({ from, to, content: "after" })
|
||||||
|
|
||||||
|
expect(result.content).toBe("after")
|
||||||
|
expect(yield* Effect.promise(() => fs.readFile(to.canonical, "utf8"))).toBe("\uFEFFafter")
|
||||||
|
expect(
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
fs.access(fromPath).then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBe(false)
|
||||||
|
}).pipe(provide(directory)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("writes an explicitly resolved external target", () =>
|
it.live("writes an explicitly resolved external target", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
withTmp((outside) =>
|
withTmp((outside) =>
|
||||||
@@ -102,6 +129,7 @@ describe("FileMutation", () => {
|
|||||||
target: target.canonical,
|
target: target.canonical,
|
||||||
resource: target.resource,
|
resource: target.resource,
|
||||||
existed: false,
|
existed: false,
|
||||||
|
content: "external",
|
||||||
})
|
})
|
||||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("external")
|
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("external")
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { mkdir, readdir, readFile, realpath, rm, stat, writeFile } from "fs/promises"
|
||||||
|
import nodePath from "path"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { make } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
|
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { Project } from "@opencode-ai/core/project"
|
||||||
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { Workspace } from "@opencode-ai/core/workspace"
|
||||||
|
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
|
||||||
|
|
||||||
|
export const ROOT = "/workspace"
|
||||||
|
|
||||||
|
export const workspaceID = Workspace.ID.make("wrk_test")
|
||||||
|
|
||||||
|
/** A hosted Location at the workspace root, as hostedBoundNode would state it. */
|
||||||
|
export const hostedLocationLayer = (root: string = ROOT) =>
|
||||||
|
Layer.succeed(
|
||||||
|
Location.Service,
|
||||||
|
Location.Service.of({
|
||||||
|
directory: AbsolutePath.make(root),
|
||||||
|
workspaceID,
|
||||||
|
project: Project.hostedGlobal(root),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface MemoryEnvironment {
|
||||||
|
readonly environment: WorkspaceEnvironment.Interface
|
||||||
|
/** Live view of stored file contents by absolute path. */
|
||||||
|
readonly contents: (path: string) => string | undefined
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Environment backed by a real host directory and spawner: the honest fake
|
||||||
|
* for end-to-end tests. Files go through fs/promises, commands actually run
|
||||||
|
* with the given spawner, so bash output is visible to reads.
|
||||||
|
*/
|
||||||
|
export const directoryEnvironment = (
|
||||||
|
root: string,
|
||||||
|
spawn: ChildProcessSpawner["Service"]["spawn"],
|
||||||
|
): WorkspaceEnvironment.Interface => {
|
||||||
|
const wrap = <A>(operation: string, path: string, run: () => Promise<A>) =>
|
||||||
|
WorkspaceEnvironment.tryOperation({
|
||||||
|
operation,
|
||||||
|
path,
|
||||||
|
run,
|
||||||
|
isNotFound: (cause) => (cause as NodeJS.ErrnoException).code === "ENOENT",
|
||||||
|
})
|
||||||
|
return WorkspaceEnvironment.make({
|
||||||
|
directory: root,
|
||||||
|
files: {
|
||||||
|
stat: (path) =>
|
||||||
|
wrap("stat", path, async () => {
|
||||||
|
const info = await stat(path)
|
||||||
|
return {
|
||||||
|
type: info.isFile()
|
||||||
|
? ("File" as const)
|
||||||
|
: info.isDirectory()
|
||||||
|
? ("Directory" as const)
|
||||||
|
: ("Unknown" as const),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
realPath: (path) => wrap("realPath", path, () => realpath(path)),
|
||||||
|
read: (path) => wrap("read", path, async () => Uint8Array.from(await readFile(path))),
|
||||||
|
list: (path) =>
|
||||||
|
wrap("list", path, async () => {
|
||||||
|
const entries = await readdir(path, { withFileTypes: true })
|
||||||
|
return entries.map((entry) => ({
|
||||||
|
name: entry.name,
|
||||||
|
type: entry.isFile()
|
||||||
|
? ("file" as const)
|
||||||
|
: entry.isDirectory()
|
||||||
|
? ("directory" as const)
|
||||||
|
: entry.isSymbolicLink()
|
||||||
|
? ("symlink" as const)
|
||||||
|
: ("other" as const),
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
write: (path, content) =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: async () => {
|
||||||
|
await mkdir(nodePath.dirname(path), { recursive: true })
|
||||||
|
await writeFile(path, content)
|
||||||
|
},
|
||||||
|
catch: (cause) => new WorkspaceEnvironment.Error({ operation: "write", path, cause }),
|
||||||
|
}),
|
||||||
|
remove: (path) => wrap("remove", path, () => rm(path)),
|
||||||
|
},
|
||||||
|
process: make(spawn),
|
||||||
|
shell: {
|
||||||
|
executable: "/bin/bash",
|
||||||
|
args: (command) => ["-c", command],
|
||||||
|
// Real commands need PATH; nothing else from the host environment leaks.
|
||||||
|
environmentOverrides: { PATH: process.env.PATH ?? "" },
|
||||||
|
detached: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory workspace environment rooted at /workspace: file paths to
|
||||||
|
* contents, directories implied by keys, no symlinks, no processes. Type
|
||||||
|
* mismatches (read a directory, list a file) fail with Error per the Files
|
||||||
|
* contract.
|
||||||
|
*/
|
||||||
|
export const memoryEnvironment = (files: Record<string, string>): MemoryEnvironment => {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
const store = new Map(Object.entries(files).map(([key, value]) => [key, Uint8Array.from(encoder.encode(value))]))
|
||||||
|
const isDirectory = (path: string) =>
|
||||||
|
path === ROOT || Array.from(store.keys()).some((key) => key.startsWith(path + "/"))
|
||||||
|
const exists = (path: string) => store.has(path) || isDirectory(path)
|
||||||
|
const notFound = (path: string) => Effect.fail(new WorkspaceEnvironment.NotFoundError({ path }))
|
||||||
|
const wrongType = (operation: string, path: string) =>
|
||||||
|
Effect.fail(new WorkspaceEnvironment.Error({ operation, path }))
|
||||||
|
const environment = WorkspaceEnvironment.make({
|
||||||
|
directory: ROOT,
|
||||||
|
files: {
|
||||||
|
stat: (path) =>
|
||||||
|
store.has(path)
|
||||||
|
? Effect.succeed({ type: "File" as const })
|
||||||
|
: isDirectory(path)
|
||||||
|
? Effect.succeed({ type: "Directory" as const })
|
||||||
|
: notFound(path),
|
||||||
|
realPath: (path) => (exists(path) ? Effect.succeed(path) : notFound(path)),
|
||||||
|
read: (path) => {
|
||||||
|
const content = store.get(path)
|
||||||
|
if (content) return Effect.succeed(content)
|
||||||
|
return isDirectory(path) ? wrongType("read", path) : notFound(path)
|
||||||
|
},
|
||||||
|
list: (path) => {
|
||||||
|
if (store.has(path)) return wrongType("list", path)
|
||||||
|
if (!isDirectory(path)) return notFound(path)
|
||||||
|
const names = new Map<string, "file" | "directory">()
|
||||||
|
for (const key of store.keys()) {
|
||||||
|
if (!key.startsWith(path + "/")) continue
|
||||||
|
const rest = key.slice(path.length + 1)
|
||||||
|
const [head] = rest.split("/")
|
||||||
|
if (head) names.set(head, rest.includes("/") ? "directory" : "file")
|
||||||
|
}
|
||||||
|
return Effect.succeed(Array.from(names, ([name, type]) => ({ name, type })))
|
||||||
|
},
|
||||||
|
write: (path, content) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
store.set(path, Uint8Array.from(content))
|
||||||
|
}),
|
||||||
|
remove: (path) => {
|
||||||
|
if (store.delete(path)) return Effect.void
|
||||||
|
return isDirectory(path) ? wrongType("remove", path) : notFound(path)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
process: make(() => Effect.die(new Error("no processes in the memory environment"))),
|
||||||
|
shell: WorkspaceEnvironment.linuxShell,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
environment,
|
||||||
|
contents: (path) => {
|
||||||
|
const stored = store.get(path)
|
||||||
|
return stored ? decoder.decode(stored) : undefined
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -163,6 +163,35 @@ describe("LocationMutation", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("authorizes an external symlink by the named path", () =>
|
||||||
|
withTmp((directory) =>
|
||||||
|
withTmp((outside) =>
|
||||||
|
withTmp((referent) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (process.platform === "win32") return
|
||||||
|
const targetPath = path.join(referent, "target.txt")
|
||||||
|
const linkPath = path.join(outside, "link.txt")
|
||||||
|
yield* Effect.promise(async () => {
|
||||||
|
await fs.writeFile(targetPath, "content")
|
||||||
|
await fs.symlink(targetPath, linkPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: linkPath })
|
||||||
|
expect(target).toMatchObject({
|
||||||
|
absolute: linkPath,
|
||||||
|
canonical: yield* Effect.promise(() => fs.realpath(targetPath)),
|
||||||
|
resource: linkPath.replaceAll("\\", "/"),
|
||||||
|
externalDirectory: {
|
||||||
|
directory: outside,
|
||||||
|
resource: path.join(outside, "*").replaceAll("\\", "/"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}).pipe(provide(directory)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("anchors prospective external descendants at their stable existing directory", () =>
|
it.live("anchors prospective external descendants at their stable existing directory", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
withTmp((outside) =>
|
withTmp((outside) =>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Effect, Layer } from "effect"
|
|||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { PtyID } from "@opencode-ai/core/pty/schema"
|
import { PtyID } from "@opencode-ai/core/pty/schema"
|
||||||
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
||||||
import { Workspace } from "@opencode-ai/core/workspace"
|
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
const it = testEffect(LayerNode.compile(PtyTicket.node))
|
const it = testEffect(LayerNode.compile(PtyTicket.node))
|
||||||
@@ -46,17 +45,14 @@ describe("PTY websocket tickets", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("rejects tickets scoped to a different workspace", () =>
|
it.live("rejects tickets scoped to a different pty", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const tickets = yield* PtyTicket.Service
|
const tickets = yield* PtyTicket.Service
|
||||||
const ptyID = PtyID.ascending()
|
const ptyID = PtyID.ascending()
|
||||||
const workspaceID = Workspace.ID.ascending()
|
const issued = yield* tickets.issue({ ptyID })
|
||||||
const issued = yield* tickets.issue({ ptyID, workspaceID })
|
|
||||||
|
|
||||||
expect(yield* tickets.consume({ ptyID, workspaceID: Workspace.ID.ascending(), ticket: issued.ticket })).toBe(
|
expect(yield* tickets.consume({ ptyID: PtyID.ascending(), ticket: issued.ticket })).toBe(false)
|
||||||
false,
|
expect(yield* tickets.consume({ ptyID, ticket: issued.ticket })).toBe(true)
|
||||||
)
|
|
||||||
expect(yield* tickets.consume({ ptyID, workspaceID, ticket: issued.ticket })).toBe(true)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { SessionEvent } from "@opencode-ai/core/session/event"
|
|||||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||||
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
|
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
|
||||||
import { Workspace } from "@opencode-ai/core/workspace"
|
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
|
|
||||||
@@ -127,7 +126,6 @@ describe("Session.create", () => {
|
|||||||
it.effect("stores supplied immutable create attributes", () =>
|
it.effect("stores supplied immutable create attributes", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* Session.Service
|
const session = yield* Session.Service
|
||||||
const workspaceID = Workspace.ID.make("wrk_test")
|
|
||||||
const model = Model.Ref.make({
|
const model = Model.Ref.make({
|
||||||
id: Model.ID.make("sonnet"),
|
id: Model.ID.make("sonnet"),
|
||||||
providerID: Provider.ID.anthropic,
|
providerID: Provider.ID.anthropic,
|
||||||
@@ -136,11 +134,11 @@ describe("Session.create", () => {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
yield* session.create({
|
yield* session.create({
|
||||||
location: Location.Ref.make({ directory: location.directory, workspaceID }),
|
location: Location.Ref.make({ directory: location.directory }),
|
||||||
agent: Agent.ID.make("build"),
|
agent: Agent.ID.make("build"),
|
||||||
model,
|
model,
|
||||||
}),
|
}),
|
||||||
).toMatchObject({ location: { directory: location.directory, workspaceID }, agent: "build", model })
|
).toMatchObject({ location: { directory: location.directory }, agent: "build", model })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
|||||||
import { Session } from "@opencode-ai/core/session"
|
import { Session } from "@opencode-ai/core/session"
|
||||||
import { Tool } from "@opencode-ai/core/tool"
|
import { Tool } from "@opencode-ai/core/tool"
|
||||||
import { PatchTool } from "@opencode-ai/core/tool/plugin/patch"
|
import { PatchTool } from "@opencode-ai/core/tool/plugin/patch"
|
||||||
|
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||||
|
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||||
import { location } from "./fixture/location"
|
import { location } from "./fixture/location"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
@@ -22,7 +24,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
|||||||
const patchToolNode = makeLocationNode({
|
const patchToolNode = makeLocationNode({
|
||||||
name: "test/patch-tool-plugin",
|
name: "test/patch-tool-plugin",
|
||||||
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
||||||
deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
|
deps: [Tool.node, LocationMutation.node, FileMutation.node, Permission.node],
|
||||||
})
|
})
|
||||||
|
|
||||||
const sessionID = Session.ID.make("ses_patch_tool_test")
|
const sessionID = Session.ID.make("ses_patch_tool_test")
|
||||||
@@ -604,6 +606,24 @@ describe("PatchTool", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("preserves a BOM when moving a file", () =>
|
||||||
|
withTempTool((directory, registry) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const source = path.join(directory, "source.txt")
|
||||||
|
const moved = path.join(directory, "moved.txt")
|
||||||
|
yield* Effect.promise(() => fs.writeFile(source, "\uFEFFbefore\n"))
|
||||||
|
yield* executeTool(
|
||||||
|
registry,
|
||||||
|
call(
|
||||||
|
"*** Begin Patch\n*** Update File: source.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(yield* exists(source)).toBe(false)
|
||||||
|
expect(yield* Effect.promise(() => fs.readFile(moved, "utf8"))).toBe("\uFEFFafter\n")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("rejects an update with missing context", () =>
|
it.live("rejects an update with missing context", () =>
|
||||||
withTempTool((directory, registry) =>
|
withTempTool((directory, registry) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -828,7 +848,9 @@ describe("PatchTool", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("treats a sibling path inside the project worktree as internal", () =>
|
// Paths outside the active Location require external approval — the same
|
||||||
|
// boundary the edit and write tools derive from LocationMutation.
|
||||||
|
it.live("requires external approval for sibling paths outside the location", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
(tmp) => {
|
(tmp) => {
|
||||||
@@ -847,7 +869,7 @@ describe("PatchTool", () => {
|
|||||||
call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"),
|
call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"),
|
||||||
),
|
),
|
||||||
).toMatchObject({ status: "completed" })
|
).toMatchObject({ status: "completed" })
|
||||||
expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
||||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
|
||||||
}),
|
}),
|
||||||
tmp.path,
|
tmp.path,
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ const mutation = Layer.succeed(
|
|||||||
const externalResource = path.join(directory, "*").replaceAll("\\", "/")
|
const externalResource = path.join(directory, "*").replaceAll("\\", "/")
|
||||||
return Effect.succeed({
|
return Effect.succeed({
|
||||||
canonical,
|
canonical,
|
||||||
|
absolute: canonical,
|
||||||
resource,
|
resource,
|
||||||
externalDirectory: external
|
externalDirectory: external
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||||
import { Permission } from "@opencode-ai/core/permission"
|
import { Permission } from "@opencode-ai/core/permission"
|
||||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { Session } from "@opencode-ai/core/session"
|
import { Session } from "@opencode-ai/core/session"
|
||||||
import { GlobTool } from "@opencode-ai/core/tool/plugin/glob"
|
import { GlobTool } from "@opencode-ai/core/tool/plugin/glob"
|
||||||
@@ -24,19 +22,12 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
|||||||
const globToolNode = makeLocationNode({
|
const globToolNode = makeLocationNode({
|
||||||
name: "test/glob-tool-plugin",
|
name: "test/glob-tool-plugin",
|
||||||
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
|
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
|
||||||
deps: [
|
deps: [Tool.node, FileSystem.node, Location.node, LocationMutation.node, Permission.node],
|
||||||
Tool.node,
|
|
||||||
FSUtil.node,
|
|
||||||
Ripgrep.node,
|
|
||||||
Location.node,
|
|
||||||
LocationMutation.node,
|
|
||||||
Permission.node,
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
const grepToolNode = makeLocationNode({
|
const grepToolNode = makeLocationNode({
|
||||||
name: "test/grep-tool-plugin",
|
name: "test/grep-tool-plugin",
|
||||||
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
|
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
|
||||||
deps: [Tool.node, FSUtil.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
|
deps: [Tool.node, FileSystem.node, Location.node, LocationMutation.node, Permission.node],
|
||||||
})
|
})
|
||||||
const sessionID = Session.ID.make("ses_search_tool_test")
|
const sessionID = Session.ID.make("ses_search_tool_test")
|
||||||
|
|
||||||
@@ -181,14 +172,37 @@ describe("search tools", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("reports the lexical path when grepping a symlinked file", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) =>
|
||||||
|
Effect.promise(async () => {
|
||||||
|
await fs.mkdir(path.join(tmp.path, "actual"))
|
||||||
|
await fs.writeFile(path.join(tmp.path, "actual", "target.txt"), "needle\n")
|
||||||
|
await fs.symlink(path.join(tmp.path, "actual", "target.txt"), path.join(tmp.path, "link.txt"))
|
||||||
|
}).pipe(
|
||||||
|
Effect.andThen(
|
||||||
|
withTools(tmp.path, (registry) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const result = yield* executeTool(registry, call("grep", { path: "link.txt", pattern: "needle" }))
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: "completed",
|
||||||
|
output: [{ entry: { path: "link.txt" }, line: 1, text: "needle\n" }],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("reports no grep matches", () =>
|
it.live("reports no grep matches", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
(tmp) =>
|
(tmp) =>
|
||||||
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "haystack\n")).pipe(
|
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "haystack\n")).pipe(
|
||||||
Effect.andThen(
|
Effect.andThen(withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" })))),
|
||||||
withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" }))),
|
|
||||||
),
|
|
||||||
Effect.tap((result) =>
|
Effect.tap((result) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
@@ -297,9 +311,7 @@ describe("search tools", () => {
|
|||||||
(tmp) =>
|
(tmp) =>
|
||||||
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "content\n")).pipe(
|
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "content\n")).pipe(
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
withTools(tmp.path, (registry) =>
|
withTools(tmp.path, (registry) => executeTool(registry, call("glob", { path: "file.txt", pattern: "*" }))),
|
||||||
executeTool(registry, call("glob", { path: "file.txt", pattern: "*" })),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Effect.tap((result) =>
|
Effect.tap((result) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
@@ -331,9 +343,7 @@ describe("search tools", () => {
|
|||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
expect(result.status).toBe("completed")
|
expect(result.status).toBe("completed")
|
||||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "glob"])
|
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "glob"])
|
||||||
expect(assertions[0]?.resources).toEqual([
|
expect(assertions[0]?.resources).toEqual([path.join(outside.path, "*").replaceAll("\\", "/")])
|
||||||
path.join(outside.path, "*").replaceAll("\\", "/"),
|
|
||||||
])
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { mkdtempSync } from "fs"
|
||||||
|
import { mkdtemp } from "fs/promises"
|
||||||
|
import { tmpdir } from "os"
|
||||||
|
import path from "path"
|
||||||
|
import { Effect, Layer, Schema } from "effect"
|
||||||
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { Bus } from "@opencode-ai/core/bus"
|
||||||
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
|
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||||
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||||
|
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||||
|
import { Project } from "@opencode-ai/core/project"
|
||||||
|
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||||
|
import { Shell } from "@opencode-ai/core/shell"
|
||||||
|
import { Workspace } from "@opencode-ai/core/workspace"
|
||||||
|
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
|
||||||
|
import { AppProcess } from "@opencode-ai/util/process"
|
||||||
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
|
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||||
|
import { testEffect } from "./lib/effect"
|
||||||
|
import { directoryEnvironment } from "./lib/workspace"
|
||||||
|
|
||||||
|
const FakeBinding = Schema.Struct({ root: Schema.String })
|
||||||
|
|
||||||
|
const connects = { count: 0 }
|
||||||
|
|
||||||
|
// Replacement nodes may introduce tagged dependencies; declaring AppProcess
|
||||||
|
// lets the driver spawn through the real test spawner.
|
||||||
|
const registryLayer = Layer.effect(
|
||||||
|
WorkspaceDriver.RegistryService,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const appProcess = yield* AppProcess.Service
|
||||||
|
const driver = WorkspaceDriver.make({
|
||||||
|
create: () =>
|
||||||
|
Effect.promise(async () => {
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), "opencode-workspace-e2e-"))
|
||||||
|
return { binding: { root }, root }
|
||||||
|
}),
|
||||||
|
connect: (binding) =>
|
||||||
|
Schema.decodeUnknownEffect(FakeBinding)(binding).pipe(
|
||||||
|
Effect.mapError((cause) => new WorkspaceDriver.Error({ provider: "fake", cause })),
|
||||||
|
Effect.map((decoded) => {
|
||||||
|
connects.count++
|
||||||
|
return directoryEnvironment(decoded.root, (command) => appProcess.spawn(command))
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
destroy: () => Effect.void,
|
||||||
|
})
|
||||||
|
return WorkspaceDriver.RegistryService.of(WorkspaceDriver.registry({ fake: driver }))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const registry = makeGlobalNode({
|
||||||
|
service: WorkspaceDriver.RegistryService,
|
||||||
|
layer: registryLayer,
|
||||||
|
deps: [AppProcess.node],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Outer and hoisted location graphs must share one durable database, like the
|
||||||
|
// production file-backed configuration; :memory: would give each its own.
|
||||||
|
const databaseFile = path.join(mkdtempSync(path.join(tmpdir(), "opencode-workspace-db-")), "e2e.db")
|
||||||
|
|
||||||
|
const it = testEffect(
|
||||||
|
AppNodeBuilder.build(
|
||||||
|
LayerNode.group([Database.node, Bus.node, Workspace.node, AppProcess.node, LocationServiceMap.node]),
|
||||||
|
[
|
||||||
|
[Database.node, Database.configured({ path: databaseFile })],
|
||||||
|
[WorkspaceDriver.registryNode, registry],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
describe("hosted workspace end to end", () => {
|
||||||
|
it.live("creates, works inside, evicts, and reconnects", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const workspaces = yield* Workspace.Service
|
||||||
|
const created = yield* workspaces.create({ provider: "fake" })
|
||||||
|
expect(created.provider).toBe("fake")
|
||||||
|
|
||||||
|
// Metadata read round-trips through sqlite without touching the driver.
|
||||||
|
const fetched = yield* workspaces.get(created.id)
|
||||||
|
expect(fetched.root).toBe(created.root)
|
||||||
|
|
||||||
|
const locations = yield* LocationServiceMap.Service
|
||||||
|
const ref = Location.Ref.make({
|
||||||
|
directory: AbsolutePath.make(created.root),
|
||||||
|
workspaceID: created.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* Effect.gen(function* () {
|
||||||
|
const location = yield* Location.Service
|
||||||
|
expect(location.project.id).toBe(Project.ID.global)
|
||||||
|
expect(String(location.directory)).toBe(created.root)
|
||||||
|
|
||||||
|
const mutation = yield* LocationMutation.Service
|
||||||
|
const mutations = yield* FileMutation.Service
|
||||||
|
const target = yield* mutation.resolve({ path: "hello.txt" })
|
||||||
|
yield* mutations.write({ target, content: "hello from the workspace\n" })
|
||||||
|
|
||||||
|
const shell = yield* Shell.Service
|
||||||
|
const command = yield* shell.create({
|
||||||
|
command: "printf 'from-bash' > bash.txt && ln -s /etc escaped && cat hello.txt",
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
const finished = yield* shell.wait(command.id)
|
||||||
|
expect(finished.status).toBe("exited")
|
||||||
|
const output = yield* shell.output(command.id)
|
||||||
|
expect(output.output).toContain("hello from the workspace")
|
||||||
|
|
||||||
|
const filesystem = yield* FileSystem.Service
|
||||||
|
const fromBash = yield* filesystem.read({ path: RelativePath.make("bash.txt") })
|
||||||
|
expect(new TextDecoder().decode(fromBash.content)).toBe("from-bash")
|
||||||
|
const root = yield* mutation.resolve({ path: "." })
|
||||||
|
const entries = yield* filesystem.glob({ target: root, pattern: "*.txt", limit: 10 })
|
||||||
|
expect(entries.map((entry) => String(entry.path)).sort()).toEqual(["bash.txt", "hello.txt"])
|
||||||
|
const limited = yield* filesystem.glob({ target: root, pattern: "*.txt", limit: 1 })
|
||||||
|
expect(limited).toHaveLength(1)
|
||||||
|
const matches = yield* filesystem.grep({
|
||||||
|
target: yield* mutation.resolve({ path: "hello.txt" }),
|
||||||
|
pattern: "workspace",
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
expect(matches).toMatchObject([{ entry: { path: "hello.txt" }, line: 1 }])
|
||||||
|
const invalid = yield* filesystem.grep({ target: root, pattern: "[", limit: 10 }).pipe(Effect.flip)
|
||||||
|
expect(invalid).toMatchObject({ _tag: "FileSystem.InvalidPatternError", pattern: "[" })
|
||||||
|
const escaped = yield* mutation.resolve({ path: "escaped/passwd" }).pipe(Effect.flip)
|
||||||
|
expect(escaped).toMatchObject({ _tag: "LocationMutation.PathError", reason: "outside_workspace" })
|
||||||
|
}).pipe(Effect.provide(locations.get(ref)))
|
||||||
|
|
||||||
|
expect(connects.count).toBe(1)
|
||||||
|
|
||||||
|
// Evict the cached graph, then reconnect through the driver: the
|
||||||
|
// workspace contents survive because the binding names durable state.
|
||||||
|
yield* locations.invalidate(ref)
|
||||||
|
|
||||||
|
yield* Effect.gen(function* () {
|
||||||
|
const filesystem = yield* FileSystem.Service
|
||||||
|
const hello = yield* filesystem.read({ path: RelativePath.make("hello.txt") })
|
||||||
|
expect(new TextDecoder().decode(hello.content)).toBe("hello from the workspace\n")
|
||||||
|
const fromBash = yield* filesystem.read({ path: RelativePath.make("bash.txt") })
|
||||||
|
expect(new TextDecoder().decode(fromBash.content)).toBe("from-bash")
|
||||||
|
}).pipe(Effect.provide(locations.get(ref)))
|
||||||
|
|
||||||
|
expect(connects.count).toBe(2)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { RelativePath } from "@opencode-ai/core/schema"
|
||||||
|
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
|
||||||
|
import { testEffect } from "./lib/effect"
|
||||||
|
import { hostedLocationLayer, memoryEnvironment } from "./lib/workspace"
|
||||||
|
|
||||||
|
const memory = memoryEnvironment({
|
||||||
|
"/workspace/README.md": "# hello\n",
|
||||||
|
"/workspace/src/index.ts": "export {}\n",
|
||||||
|
"/workspace/src/util/deep.ts": "export const deep = 1\n",
|
||||||
|
})
|
||||||
|
|
||||||
|
const it = testEffect(
|
||||||
|
AppNodeBuilder.build(FileSystem.hostedNode, [
|
||||||
|
[WorkspaceEnvironment.node, Layer.succeed(WorkspaceEnvironment.Service, memory.environment)],
|
||||||
|
[Location.node, hostedLocationLayer()],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
describe("hosted FileSystem", () => {
|
||||||
|
it.effect("reads a file through the environment", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const filesystem = yield* FileSystem.Service
|
||||||
|
const result = yield* filesystem.read({ path: RelativePath.make("README.md") })
|
||||||
|
expect(new TextDecoder().decode(result.content)).toBe("# hello\n")
|
||||||
|
expect(result.mime).toBe("text/markdown")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("lists directories before files with posix separators", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const filesystem = yield* FileSystem.Service
|
||||||
|
const entries = yield* filesystem.list({ path: RelativePath.make("src") })
|
||||||
|
expect(entries.map((entry) => String(entry.path))).toEqual(["src/util/", "src/index.ts"])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("lists the root when no path is given", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const filesystem = yield* FileSystem.Service
|
||||||
|
const entries = yield* filesystem.list()
|
||||||
|
expect(entries.map((entry) => String(entry.path))).toEqual(["src/", "README.md"])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("refuses paths that escape the workspace", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const filesystem = yield* FileSystem.Service
|
||||||
|
const exit = yield* filesystem.read({ path: RelativePath.make("../etc/passwd") }).pipe(Effect.exit)
|
||||||
|
expect(exit._tag).toBe("Failure")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { hostedReplacements, locationServices } from "@opencode-ai/core/location-services"
|
||||||
|
import { Workspace } from "@opencode-ai/core/workspace"
|
||||||
|
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||||
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
|
import { AppProcess } from "@opencode-ai/util/process"
|
||||||
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
|
|
||||||
|
// Location-scoped services receive Location-relative and Location-absolute
|
||||||
|
// paths from tools. In a hosted graph those are provider paths (for example
|
||||||
|
// /workspace/src), so any Location-scoped service that still depends on a
|
||||||
|
// host primitive would silently run provider paths against the host machine.
|
||||||
|
// This is the guardrail for the class of bug where the read tool's filesystem
|
||||||
|
// stayed host-backed after the hosted rollout.
|
||||||
|
//
|
||||||
|
// Every entry here is a documented decision, not an exemption by default.
|
||||||
|
// Adding a new Location-scoped service that touches FSUtil or AppProcess
|
||||||
|
// fails this test until the service either gets a hosted replacement in
|
||||||
|
// hostedReplacements or is justified below.
|
||||||
|
const allowed: Record<string, string> = {
|
||||||
|
// Host-side by design: hosted graphs read only global (host) configuration.
|
||||||
|
"@opencode/Config": "global host config sources only (Config.configured({ project: false }))",
|
||||||
|
"@opencode/InstructionDiscovery": "global host instructions only (configured({ project: false }))",
|
||||||
|
"@opencode/Skill": "skills load from host config directories",
|
||||||
|
"@opencode/PluginSupervisor": "plugins are host processes loaded from host paths",
|
||||||
|
"@opencode/Integration": "integrations run host-side CLIs",
|
||||||
|
"@opencode/ProjectCopy": "project copy refresh operates on host-owned global project data",
|
||||||
|
// Known gaps: these never advertise hosted capability, but their services
|
||||||
|
// still exist in hosted graphs and would touch host paths if invoked with
|
||||||
|
// provider paths. Each needs an environment-backed design or an explicit
|
||||||
|
// hosted no-op before workspaces ship publicly.
|
||||||
|
"@opencode/Command": "TODO: `!command` template substitution would run on the host",
|
||||||
|
"@opencode/LocationWatcher": "TODO: watches the provider directory path on the host filesystem",
|
||||||
|
"@opencode/Snapshot": "TODO: hosted snapshots need environment-backed git",
|
||||||
|
"@opencode/Vcs": "TODO: hosted VCS info is stated by Project.hostedGlobal, service methods remain host git",
|
||||||
|
"@opencode/v2/Formatter": "TODO: formatters are host binaries; hosted FileMutation deliberately omits format",
|
||||||
|
"session-instructions": "TODO: per-session instruction file loads read the host filesystem",
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("hosted location graph", () => {
|
||||||
|
test("location-scoped services do not use host primitives without justification", () => {
|
||||||
|
const workspaceID = Workspace.ID.make("wrk_graphguard000000000000000")
|
||||||
|
const ref = Location.Ref.make({ directory: AbsolutePath.make("/workspace"), workspaceID })
|
||||||
|
// Replacement targets may be bare layers in general; every hosted
|
||||||
|
// replacement is a node today, and bare-layer targets have no walkable
|
||||||
|
// dependencies anyway.
|
||||||
|
const replaced = new Map<LayerNode.AnyNode, LayerNode.AnyNode>(
|
||||||
|
hostedReplacements(ref, workspaceID).flatMap(([from, to]) => (LayerNode.isNode(to) ? [[from, to] as const] : [])),
|
||||||
|
)
|
||||||
|
const resolve = (node: LayerNode.AnyNode) => replaced.get(node) ?? node
|
||||||
|
|
||||||
|
const hostPrimitives = new Set<LayerNode.AnyNode>([FSUtil.node, AppProcess.node])
|
||||||
|
const offenders = new Map<string, string[]>()
|
||||||
|
LayerNode.walk<void>(
|
||||||
|
locationServices,
|
||||||
|
(node, context) => {
|
||||||
|
for (const dependency of node.dependencies) {
|
||||||
|
if (hostPrimitives.has(resolve(dependency)) && node.tag === Node.tags.values.location) {
|
||||||
|
offenders.set(node.name, [...(offenders.get(node.name) ?? []), resolve(dependency).name])
|
||||||
|
}
|
||||||
|
context.visit(dependency)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ resolve },
|
||||||
|
)
|
||||||
|
|
||||||
|
const unjustified = [...offenders.entries()]
|
||||||
|
.filter(([name]) => !(name in allowed))
|
||||||
|
.map(([name, primitives]) => `${name} -> ${primitives.join(", ")}`)
|
||||||
|
.sort()
|
||||||
|
expect(unjustified).toEqual([])
|
||||||
|
|
||||||
|
// Stale allowlist entries must be removed so the inventory stays honest.
|
||||||
|
const stale = Object.keys(allowed).filter((name) => !offenders.has(name))
|
||||||
|
expect(stale).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||||
|
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
|
||||||
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
|
||||||
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
|
import { testEffect } from "./lib/effect"
|
||||||
|
import { hostedLocationLayer, memoryEnvironment } from "./lib/workspace"
|
||||||
|
|
||||||
|
const memory = memoryEnvironment({
|
||||||
|
"/workspace/README.md": "# hello\n",
|
||||||
|
"/workspace/src/index.ts": "export {}\n",
|
||||||
|
})
|
||||||
|
|
||||||
|
const it = testEffect(
|
||||||
|
AppNodeBuilder.build(
|
||||||
|
LayerNode.group([LocationMutation.hostedNode, FileMutation.hostedNode, ReadToolFileSystem.hostedNode]),
|
||||||
|
[
|
||||||
|
[WorkspaceEnvironment.node, Layer.succeed(WorkspaceEnvironment.Service, memory.environment)],
|
||||||
|
[Location.node, hostedLocationLayer()],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
describe("hosted mutation", () => {
|
||||||
|
it.effect("resolves an existing file to its canonical path and relative resource", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const mutation = yield* LocationMutation.Service
|
||||||
|
const target = yield* mutation.resolve({ path: "README.md" })
|
||||||
|
expect(target.canonical).toBe("/workspace/README.md")
|
||||||
|
expect(target.resource).toBe("README.md")
|
||||||
|
expect(target.externalDirectory).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("resolves a missing path below an existing directory", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const mutation = yield* LocationMutation.Service
|
||||||
|
const target = yield* mutation.resolve({ path: "src/created/new.ts" })
|
||||||
|
expect(target.canonical).toBe("/workspace/src/created/new.ts")
|
||||||
|
expect(target.resource).toBe("src/created/new.ts")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects paths outside the workspace instead of granting external access", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const mutation = yield* LocationMutation.Service
|
||||||
|
const error = yield* mutation.resolve({ path: "/etc/passwd" }).pipe(Effect.flip)
|
||||||
|
expect(error).toMatchObject({ _tag: "LocationMutation.PathError", reason: "outside_workspace" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("writes new and existing files through the environment", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const mutation = yield* LocationMutation.Service
|
||||||
|
const files = yield* FileMutation.Service
|
||||||
|
|
||||||
|
const created = yield* mutation.resolve({ path: "notes/todo.md" })
|
||||||
|
const first = yield* files.write({ target: created, content: "- ship it\n" })
|
||||||
|
expect(first.existed).toBe(false)
|
||||||
|
expect(memory.contents("/workspace/notes/todo.md")).toBe("- ship it\n")
|
||||||
|
|
||||||
|
const second = yield* files.write({ target: created, content: "- shipped\n" })
|
||||||
|
expect(second.existed).toBe(true)
|
||||||
|
expect(memory.contents("/workspace/notes/todo.md")).toBe("- shipped\n")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// The reader must consume the environment, never the host filesystem:
|
||||||
|
// these paths exist only in the memory store.
|
||||||
|
it.effect("reads files and directories through the environment", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const reader = yield* ReadToolFileSystem.Service
|
||||||
|
|
||||||
|
expect(yield* reader.inspect(AbsolutePath.make("/workspace/README.md"))).toBe("file")
|
||||||
|
expect(yield* reader.inspect(AbsolutePath.make("/workspace/src"))).toBe("directory")
|
||||||
|
|
||||||
|
const content = yield* reader.read(AbsolutePath.make("/workspace/README.md"), "README.md")
|
||||||
|
expect(content).toMatchObject({ type: "file", content: "# hello\n", encoding: "utf8" })
|
||||||
|
|
||||||
|
const paged = yield* reader.read(AbsolutePath.make("/workspace/README.md"), "README.md", { limit: 1 })
|
||||||
|
expect(paged).toMatchObject({ type: "text-page", content: "# hello", offset: 1, truncated: false })
|
||||||
|
|
||||||
|
const listed = yield* reader.list(AbsolutePath.make("/workspace/src"))
|
||||||
|
expect(listed.entries.map((entry) => String(entry.path))).toEqual(["index.ts"])
|
||||||
|
|
||||||
|
const missing = yield* reader.inspect(AbsolutePath.make("/workspace/nope.txt")).pipe(Effect.flip)
|
||||||
|
expect(missing).toMatchObject({ _tag: "PlatformError", reason: { _tag: "NotFound" } })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
|
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||||
|
import { Permission } from "@opencode-ai/core/permission"
|
||||||
|
import { Session } from "@opencode-ai/core/session"
|
||||||
|
import { Tool } from "@opencode-ai/core/tool"
|
||||||
|
import { PatchTool } from "@opencode-ai/core/tool/plugin/patch"
|
||||||
|
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
|
||||||
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
|
import { testEffect } from "./lib/effect"
|
||||||
|
import { toolIdentity, executeTool, registerToolPlugin } from "./lib/tool"
|
||||||
|
import { hostedLocationLayer, memoryEnvironment, ROOT } from "./lib/workspace"
|
||||||
|
import type { MemoryEnvironment } from "./lib/workspace"
|
||||||
|
|
||||||
|
const sessionID = Session.ID.make("ses_workspace_patch_test")
|
||||||
|
|
||||||
|
const assertions: Permission.AssertInput[] = []
|
||||||
|
|
||||||
|
const permission = Layer.succeed(
|
||||||
|
Permission.Service,
|
||||||
|
Permission.Service.of({
|
||||||
|
assert: (input) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
assertions.push(input)
|
||||||
|
}),
|
||||||
|
ask: () => Effect.die("unused"),
|
||||||
|
reply: () => Effect.die("unused"),
|
||||||
|
get: () => Effect.die("unused"),
|
||||||
|
forSession: () => Effect.die("unused"),
|
||||||
|
list: () => Effect.die("unused"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const withTool = <A, E, R>(memory: MemoryEnvironment, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
|
||||||
|
const environment = Layer.succeed(WorkspaceEnvironment.Service, memory.environment)
|
||||||
|
const patchToolNode = makeLocationNode({
|
||||||
|
name: "test/workspace-patch-plugin",
|
||||||
|
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
||||||
|
deps: [Tool.node, LocationMutation.node, FileMutation.node, Permission.node],
|
||||||
|
})
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
assertions.length = 0
|
||||||
|
return yield* body(yield* Tool.Service)
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [
|
||||||
|
[Location.node, hostedLocationLayer()],
|
||||||
|
[LocationMutation.node, LocationMutation.hostedNode],
|
||||||
|
[FileMutation.node, FileMutation.hostedNode],
|
||||||
|
[Permission.node, permission],
|
||||||
|
[WorkspaceEnvironment.node, environment],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const call = (patchText: string, id = "call-patch") => ({
|
||||||
|
sessionID,
|
||||||
|
...toolIdentity,
|
||||||
|
call: { type: "tool-call" as const, id, name: "patch", input: { patchText } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const it = testEffect(Layer.empty)
|
||||||
|
|
||||||
|
describe("PatchTool on a hosted location", () => {
|
||||||
|
it.effect("applies add, update, and delete through the workspace environment", () => {
|
||||||
|
const memory = memoryEnvironment({
|
||||||
|
[`${ROOT}/update.txt`]: "before\n",
|
||||||
|
[`${ROOT}/remove.txt`]: "remove\n",
|
||||||
|
})
|
||||||
|
return withTool(memory, (registry) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const settled = yield* executeTool(
|
||||||
|
registry,
|
||||||
|
call(
|
||||||
|
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Update File: update.txt\n@@\n-before\n+after\n*** Delete File: remove.txt\n*** End Patch",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(settled.status).toBe("completed")
|
||||||
|
if (settled.status !== "completed") return
|
||||||
|
expect(settled.output).toMatchObject({
|
||||||
|
applied: [
|
||||||
|
{ type: "add", resource: "nested/new.txt", target: `${ROOT}/nested/new.txt` },
|
||||||
|
{ type: "update", resource: "update.txt" },
|
||||||
|
{ type: "delete", resource: "remove.txt" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(memory.contents(`${ROOT}/nested/new.txt`)).toBe("created\n")
|
||||||
|
expect(memory.contents(`${ROOT}/update.txt`)).toBe("after\n")
|
||||||
|
expect(memory.contents(`${ROOT}/remove.txt`)).toBeUndefined()
|
||||||
|
expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
||||||
|
expect(assertions[0]?.resources).toEqual(["nested/new.txt", "update.txt", "remove.txt"])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.effect("moves a file through the workspace environment", () => {
|
||||||
|
const memory = memoryEnvironment({ [`${ROOT}/old.txt`]: "before\n" })
|
||||||
|
return withTool(memory, (registry) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const settled = yield* executeTool(
|
||||||
|
registry,
|
||||||
|
call("*** Begin Patch\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch"),
|
||||||
|
)
|
||||||
|
expect(settled.status).toBe("completed")
|
||||||
|
expect(memory.contents(`${ROOT}/old.txt`)).toBeUndefined()
|
||||||
|
expect(memory.contents(`${ROOT}/moved.txt`)).toBe("after\n")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.effect("reports a missing update target as a verification failure", () => {
|
||||||
|
const memory = memoryEnvironment({})
|
||||||
|
return withTool(memory, (registry) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const settled = yield* executeTool(
|
||||||
|
registry,
|
||||||
|
call("*** Begin Patch\n*** Update File: missing.txt\n@@\n-before\n+after\n*** End Patch"),
|
||||||
|
)
|
||||||
|
expect(settled.status).toBe("error")
|
||||||
|
if (settled.status !== "error") return
|
||||||
|
expect(settled.error?.message).toContain("file does not exist")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.effect("reports hosted write failures through the tool error channel", () => {
|
||||||
|
const base = memoryEnvironment({})
|
||||||
|
const memory: MemoryEnvironment = {
|
||||||
|
...base,
|
||||||
|
environment: WorkspaceEnvironment.make({
|
||||||
|
...base.environment,
|
||||||
|
files: {
|
||||||
|
...base.environment.files,
|
||||||
|
write: (path, content) =>
|
||||||
|
path.endsWith("fail.txt")
|
||||||
|
? Effect.fail(new WorkspaceEnvironment.Error({ operation: "write", path }))
|
||||||
|
: base.environment.files.write(path, content),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
return withTool(memory, (registry) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const settled = yield* executeTool(
|
||||||
|
registry,
|
||||||
|
call("*** Begin Patch\n*** Add File: fail.txt\n+created\n*** End Patch"),
|
||||||
|
)
|
||||||
|
expect(settled.status).toBe("error")
|
||||||
|
if (settled.status !== "error") return
|
||||||
|
expect(settled.error?.message).toContain("Failed to write fail.txt")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.effect("rejects targets outside the workspace", () => {
|
||||||
|
const memory = memoryEnvironment({})
|
||||||
|
return withTool(memory, (registry) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const settled = yield* executeTool(
|
||||||
|
registry,
|
||||||
|
call("*** Begin Patch\n*** Add File: /outside/new.txt\n+created\n*** End Patch"),
|
||||||
|
)
|
||||||
|
expect(settled.status).toBe("error")
|
||||||
|
if (settled.status !== "error") return
|
||||||
|
expect(settled.error?.message).toContain("path is outside the workspace")
|
||||||
|
expect(assertions).toEqual([])
|
||||||
|
expect(memory.contents("/outside/new.txt")).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { mkdtempSync } from "fs"
|
||||||
|
import { mkdtemp, readFile } from "fs/promises"
|
||||||
|
import { tmpdir } from "os"
|
||||||
|
import path from "path"
|
||||||
|
import { Effect, Layer, Schema } from "effect"
|
||||||
|
import { LanguageModel } from "@opencode-ai/ai"
|
||||||
|
import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat"
|
||||||
|
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||||
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
||||||
|
import { Bus } from "@opencode-ai/core/bus"
|
||||||
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||||
|
import { Model } from "@opencode-ai/core/model"
|
||||||
|
import { Permission } from "@opencode-ai/core/permission"
|
||||||
|
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||||
|
import { Provider } from "@opencode-ai/core/provider"
|
||||||
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { Session } from "@opencode-ai/core/session"
|
||||||
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||||
|
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||||
|
import { Workspace } from "@opencode-ai/core/workspace"
|
||||||
|
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
|
||||||
|
import { AppProcess } from "@opencode-ai/util/process"
|
||||||
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
|
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||||
|
import { testEffect } from "./lib/effect"
|
||||||
|
import { directoryEnvironment } from "./lib/workspace"
|
||||||
|
|
||||||
|
const FakeBinding = Schema.Struct({ root: Schema.String })
|
||||||
|
|
||||||
|
const connects = { count: 0 }
|
||||||
|
|
||||||
|
const registryLayer = Layer.effect(
|
||||||
|
WorkspaceDriver.RegistryService,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const appProcess = yield* AppProcess.Service
|
||||||
|
const driver = WorkspaceDriver.make({
|
||||||
|
create: () =>
|
||||||
|
Effect.promise(async () => {
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), "opencode-workspace-session-"))
|
||||||
|
return { binding: { root }, root }
|
||||||
|
}),
|
||||||
|
connect: (binding) =>
|
||||||
|
Schema.decodeUnknownEffect(FakeBinding)(binding).pipe(
|
||||||
|
Effect.mapError((cause) => new WorkspaceDriver.Error({ provider: "fake", cause })),
|
||||||
|
Effect.map((decoded) => {
|
||||||
|
connects.count++
|
||||||
|
return directoryEnvironment(decoded.root, (command) => appProcess.spawn(command))
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
destroy: () => Effect.void,
|
||||||
|
})
|
||||||
|
return WorkspaceDriver.RegistryService.of(WorkspaceDriver.registry({ fake: driver }))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const registry = makeGlobalNode({
|
||||||
|
service: WorkspaceDriver.RegistryService,
|
||||||
|
layer: registryLayer,
|
||||||
|
deps: [AppProcess.node],
|
||||||
|
})
|
||||||
|
|
||||||
|
// A gpt-style id so the patch plugin's context hook advertises apply_patch as
|
||||||
|
// the only editor, matching production GPT-5 sessions.
|
||||||
|
const model = LanguageModel.make({ id: "gpt-fake", provider: "fake", route: OpenAIChat.route })
|
||||||
|
const models = Layer.mock(SessionRunnerModel.Service)({
|
||||||
|
resolve: () =>
|
||||||
|
Effect.succeed(
|
||||||
|
SessionRunnerModel.resolved(model, {
|
||||||
|
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||||
|
cost: [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Real permission flow would ask; the runner path under test is admission
|
||||||
|
// through drain, so approve everything.
|
||||||
|
const permission = Layer.mock(Permission.Service)({ assert: () => Effect.void })
|
||||||
|
|
||||||
|
// Outer and hoisted location graphs must share one durable database, like the
|
||||||
|
// production file-backed configuration; :memory: would give each its own.
|
||||||
|
const databaseFile = path.join(mkdtempSync(path.join(tmpdir(), "opencode-workspace-session-db-")), "e2e.db")
|
||||||
|
|
||||||
|
// The real SessionExecution + Session nodes: drains route through
|
||||||
|
// LocationServiceMap.get(session.location), which is the seam under test.
|
||||||
|
// Replacements flow into every hoisted per-location graph via AppNodeBuilder.
|
||||||
|
const it = testEffect(
|
||||||
|
AppNodeBuilder.build(
|
||||||
|
LayerNode.group([
|
||||||
|
Database.node,
|
||||||
|
Bus.node,
|
||||||
|
Workspace.node,
|
||||||
|
AppProcess.node,
|
||||||
|
PluginRuntime.providerNode,
|
||||||
|
LocationServiceMap.node,
|
||||||
|
SessionExecution.node,
|
||||||
|
Session.node,
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
[Database.node, Database.configured({ path: databaseFile })],
|
||||||
|
[WorkspaceDriver.registryNode, registry],
|
||||||
|
[LayerNodePlatform.llmClient, TestLLM.clientLayer],
|
||||||
|
[SessionRunnerModel.node, models],
|
||||||
|
[Permission.node, permission],
|
||||||
|
],
|
||||||
|
).pipe(Layer.provideMerge(TestLLM.layer({ fallback: [] }))),
|
||||||
|
)
|
||||||
|
|
||||||
|
const sessionModel = Model.Ref.make({ id: Model.ID.make("gpt-fake"), providerID: Provider.ID.make("fake") })
|
||||||
|
|
||||||
|
describe("hosted workspace session", () => {
|
||||||
|
it.live("runs a scripted model through the real runner on a hosted Location", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const workspaces = yield* Workspace.Service
|
||||||
|
const created = yield* workspaces.create({ provider: "fake" })
|
||||||
|
|
||||||
|
const sessions = yield* Session.Service
|
||||||
|
const session = yield* sessions.create({
|
||||||
|
title: "hosted session",
|
||||||
|
location: Location.Ref.make({
|
||||||
|
directory: AbsolutePath.make(created.root),
|
||||||
|
workspaceID: created.id,
|
||||||
|
}),
|
||||||
|
model: sessionModel,
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* TestLLM.push(
|
||||||
|
TestLLM.tool("call-patch", "patch", {
|
||||||
|
patchText: "*** Begin Patch\n*** Add File: from-patch.txt\n+patched\n*** End Patch",
|
||||||
|
}),
|
||||||
|
TestLLM.tool("call-shell", "shell", { command: "printf 'from-model' > from-model.txt" }),
|
||||||
|
TestLLM.tool("call-glob", "glob", { pattern: "*.txt" }),
|
||||||
|
TestLLM.tool("call-grep", "grep", { pattern: "from-model" }),
|
||||||
|
TestLLM.tool("call-read", "read", { path: "from-patch.txt" }),
|
||||||
|
TestLLM.text("done", "text-1"),
|
||||||
|
)
|
||||||
|
yield* sessions.prompt({ sessionID: session.id, text: "Write a file in the workspace", resume: false })
|
||||||
|
yield* sessions.resume(session.id)
|
||||||
|
|
||||||
|
// Both tools executed inside the workspace, not on a host path.
|
||||||
|
const written = yield* Effect.promise(() => readFile(path.join(created.root, "from-model.txt"), "utf8"))
|
||||||
|
expect(written).toBe("from-model")
|
||||||
|
const patched = yield* Effect.promise(() => readFile(path.join(created.root, "from-patch.txt"), "utf8"))
|
||||||
|
expect(patched).toBe("patched\n")
|
||||||
|
|
||||||
|
// The hosted catalog includes search tools now that they execute through
|
||||||
|
// the Workspace process seam, plus patch as the gpt-style editor.
|
||||||
|
const requests = (yield* TestLLM.Service).requests
|
||||||
|
const advertised = requests[0]?.tools.map((tool) => tool.name) ?? []
|
||||||
|
expect(advertised).toContain("shell")
|
||||||
|
expect(advertised).toContain("patch")
|
||||||
|
expect(advertised).toContain("glob")
|
||||||
|
expect(advertised).toContain("grep")
|
||||||
|
expect(advertised).toContain("read")
|
||||||
|
expect(advertised).not.toContain("edit")
|
||||||
|
expect(advertised).not.toContain("write")
|
||||||
|
|
||||||
|
// Projected history shows the completed tool call and the final text.
|
||||||
|
// Instruction updates (e.g. the Code Mode catalog settling after boot)
|
||||||
|
// may interleave, so assert on the user and assistant messages directly.
|
||||||
|
const context = yield* sessions.context(session.id)
|
||||||
|
expect(context.at(0)).toMatchObject({ type: "user", text: "Write a file in the workspace" })
|
||||||
|
const assistants = context.filter((message) => message.type === "assistant")
|
||||||
|
expect(assistants.at(0)).toMatchObject({
|
||||||
|
content: [{ type: "tool", id: "call-patch", state: { status: "completed" } }],
|
||||||
|
})
|
||||||
|
expect(assistants.at(1)).toMatchObject({
|
||||||
|
content: [{ type: "tool", id: "call-shell", state: { status: "completed" } }],
|
||||||
|
})
|
||||||
|
expect(assistants.at(2)).toMatchObject({
|
||||||
|
content: [{ type: "tool", id: "call-glob", state: { status: "completed" } }],
|
||||||
|
})
|
||||||
|
expect(assistants.at(3)).toMatchObject({
|
||||||
|
content: [{ type: "tool", id: "call-grep", state: { status: "completed" } }],
|
||||||
|
})
|
||||||
|
expect(assistants.at(4)).toMatchObject({
|
||||||
|
content: [{ type: "tool", id: "call-read", state: { status: "completed" } }],
|
||||||
|
})
|
||||||
|
expect(assistants.at(-1)).toMatchObject({ content: [{ type: "text", text: "done" }] })
|
||||||
|
|
||||||
|
expect(connects.count).toBe(1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -23,44 +23,11 @@ export type ProviderContext = {
|
|||||||
options: Record<string, any>
|
options: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkspaceInfo = {
|
|
||||||
id: string
|
|
||||||
type: string
|
|
||||||
name: string
|
|
||||||
branch: string | null
|
|
||||||
directory: string | null
|
|
||||||
extra: unknown | null
|
|
||||||
projectID: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WorkspaceTarget =
|
|
||||||
| {
|
|
||||||
type: "local"
|
|
||||||
directory: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "remote"
|
|
||||||
url: string | URL
|
|
||||||
headers?: HeadersInit
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WorkspaceAdapter = {
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
configure(config: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
|
|
||||||
create(config: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>
|
|
||||||
remove(config: WorkspaceInfo): Promise<void>
|
|
||||||
target(config: WorkspaceInfo): WorkspaceTarget | Promise<WorkspaceTarget>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PluginInput = {
|
export type PluginInput = {
|
||||||
client: ReturnType<typeof createOpencodeClient>
|
client: ReturnType<typeof createOpencodeClient>
|
||||||
project: Project
|
project: Project
|
||||||
directory: string
|
directory: string
|
||||||
worktree: string
|
worktree: string
|
||||||
experimental_workspace: {
|
|
||||||
register(type: string, adapter: WorkspaceAdapter): void
|
|
||||||
}
|
|
||||||
serverUrl: URL
|
serverUrl: URL
|
||||||
$: BunShell
|
$: BunShell
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export const LocationQuery = Schema.Struct({
|
|||||||
location: Schema.optional(
|
location: Schema.optional(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
directory: Schema.optional(Schema.String),
|
directory: Schema.optional(Schema.String),
|
||||||
workspace: Schema.optional(Schema.String),
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}).annotate({ identifier: "LocationQuery" })
|
}).annotate({ identifier: "LocationQuery" })
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
|
|||||||
"x-websocket": true,
|
"x-websocket": true,
|
||||||
parameters: [
|
parameters: [
|
||||||
...(operation.parameters ?? []),
|
...(operation.parameters ?? []),
|
||||||
...["location[directory]", "location[workspace]", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({
|
...["location[directory]", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({
|
||||||
in: "query",
|
in: "query",
|
||||||
name,
|
name,
|
||||||
schema: { type: "string" },
|
schema: { type: "string" },
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
|
|||||||
import { Project } from "@opencode-ai/schema/project"
|
import { Project } from "@opencode-ai/schema/project"
|
||||||
import { AbsolutePath, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
|
import { AbsolutePath, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
|
||||||
import { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
|
import { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
|
||||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||||
import {
|
import {
|
||||||
@@ -43,7 +42,6 @@ const ParentIDFilter = Schema.Union([
|
|||||||
})
|
})
|
||||||
|
|
||||||
const SessionsQueryFields = {
|
const SessionsQueryFields = {
|
||||||
workspace: Workspace.ID.pipe(Schema.optional),
|
|
||||||
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
|
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
|
||||||
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
|
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
|
||||||
}),
|
}),
|
||||||
@@ -156,6 +154,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
|||||||
location: Location.Ref.pipe(Schema.optional),
|
location: Location.Ref.pipe(Schema.optional),
|
||||||
}),
|
}),
|
||||||
success: Schema.Struct({ data: Session.Info }),
|
success: Schema.Struct({ data: Session.Info }),
|
||||||
|
error: InvalidRequestError,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "v2.session.create",
|
identifier: "v2.session.create",
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Session } from "@opencode-ai/schema/session"
|
|||||||
describe("SessionsCursor", () => {
|
describe("SessionsCursor", () => {
|
||||||
test("round trips without Node globals", async () => {
|
test("round trips without Node globals", async () => {
|
||||||
const input = {
|
const input = {
|
||||||
workspace: undefined,
|
|
||||||
search: "protocol",
|
search: "protocol",
|
||||||
order: "desc" as const,
|
order: "desc" as const,
|
||||||
anchor: { id: Session.ID.make("ses_test"), time: 1, direction: "next" as const },
|
anchor: { id: Session.ID.make("ses_test"), time: 1, direction: "next" as const },
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import { SessionEvent } from "./session-event.js"
|
|||||||
import { SessionStatusEvent } from "./session-status-event.js"
|
import { SessionStatusEvent } from "./session-status-event.js"
|
||||||
import { TuiEvent } from "./tui-event.js"
|
import { TuiEvent } from "./tui-event.js"
|
||||||
import { VcsEvent } from "./vcs-event.js"
|
import { VcsEvent } from "./vcs-event.js"
|
||||||
import { WorkspaceEvent } from "./workspace-event.js"
|
|
||||||
import { WorktreeEvent } from "./worktree-event.js"
|
import { WorktreeEvent } from "./worktree-event.js"
|
||||||
import { WebSearch } from "./websearch.js"
|
import { WebSearch } from "./websearch.js"
|
||||||
|
|
||||||
@@ -89,7 +88,6 @@ export const Definitions = Event.inventory(
|
|||||||
...SessionStatusEvent.Definitions,
|
...SessionStatusEvent.Definitions,
|
||||||
...SessionCompactionEvent.Definitions,
|
...SessionCompactionEvent.Definitions,
|
||||||
...VcsEvent.Definitions,
|
...VcsEvent.Definitions,
|
||||||
...WorkspaceEvent.Definitions,
|
|
||||||
...WorktreeEvent.Definitions,
|
...WorktreeEvent.Definitions,
|
||||||
...ServerEvent.Definitions,
|
...ServerEvent.Definitions,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
export * as WorkspaceEvent from "./workspace-event.js"
|
|
||||||
|
|
||||||
import { Schema } from "effect"
|
|
||||||
import { Event } from "./event.js"
|
|
||||||
import { WorkspaceID } from "./workspace-id.js"
|
|
||||||
|
|
||||||
export const ConnectionStatus = Schema.Struct({
|
|
||||||
workspaceID: WorkspaceID,
|
|
||||||
status: Schema.Literals(["connected", "connecting", "disconnected", "error"]),
|
|
||||||
}).annotate({ identifier: "WorkspaceEvent.ConnectionStatus" })
|
|
||||||
export interface ConnectionStatus extends Schema.Schema.Type<typeof ConnectionStatus> {}
|
|
||||||
|
|
||||||
export const Ready = Event.ephemeral({
|
|
||||||
type: "workspace.ready",
|
|
||||||
schema: {
|
|
||||||
name: Schema.String,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Failed = Event.ephemeral({
|
|
||||||
type: "workspace.failed",
|
|
||||||
schema: {
|
|
||||||
message: Schema.String,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Status = Event.ephemeral({
|
|
||||||
type: "workspace.status",
|
|
||||||
schema: ConnectionStatus.fields,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Definitions = Event.inventory(Ready, Failed, Status)
|
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
export * as Workspace from "./workspace.js"
|
export * as Workspace from "./workspace.js"
|
||||||
|
|
||||||
import { WorkspaceEvent } from "./workspace-event.js"
|
|
||||||
import { WorkspaceID } from "./workspace-id.js"
|
import { WorkspaceID } from "./workspace-id.js"
|
||||||
|
|
||||||
export const ID = WorkspaceID
|
export const ID = WorkspaceID
|
||||||
export type ID = WorkspaceID
|
export type ID = WorkspaceID
|
||||||
|
|
||||||
export const Event = WorkspaceEvent
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
Project,
|
Project,
|
||||||
Reference,
|
Reference,
|
||||||
Session,
|
Session,
|
||||||
Workspace,
|
|
||||||
} from "../src/index.js"
|
} from "../src/index.js"
|
||||||
import { EventManifest } from "../src/event-manifest.js"
|
import { EventManifest } from "../src/event-manifest.js"
|
||||||
import { FileSystemV1 } from "../src/filesystem-v1.js"
|
import { FileSystemV1 } from "../src/filesystem-v1.js"
|
||||||
@@ -19,7 +18,7 @@ import { Plugin } from "../src/plugin.js"
|
|||||||
import { SessionEvent } from "../src/session-event.js"
|
import { SessionEvent } from "../src/session-event.js"
|
||||||
import { SessionID } from "../src/session-id.js"
|
import { SessionID } from "../src/session-id.js"
|
||||||
import { SessionMessage } from "../src/session-message.js"
|
import { SessionMessage } from "../src/session-message.js"
|
||||||
import { WorkspaceEvent } from "../src/workspace-event.js"
|
|
||||||
|
|
||||||
describe("public event manifest", () => {
|
describe("public event manifest", () => {
|
||||||
test("owns the complete public event surface", () => {
|
test("owns the complete public event surface", () => {
|
||||||
@@ -48,8 +47,6 @@ describe("public event manifest", () => {
|
|||||||
test("uses canonical definitions for current public events", () => {
|
test("uses canonical definitions for current public events", () => {
|
||||||
expect(Session.Event).toBe(SessionEvent)
|
expect(Session.Event).toBe(SessionEvent)
|
||||||
expect(Session.Event.Definitions).toBe(SessionEvent.Definitions)
|
expect(Session.Event.Definitions).toBe(SessionEvent.Definitions)
|
||||||
expect(Workspace.Event).toBe(WorkspaceEvent)
|
|
||||||
expect(Workspace.Event.Definitions).toBe(WorkspaceEvent.Definitions)
|
|
||||||
expect(EventManifest.Latest.get("session.step.ended")).toBe(SessionEvent.Step.Ended)
|
expect(EventManifest.Latest.get("session.step.ended")).toBe(SessionEvent.Step.Ended)
|
||||||
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
|
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
|
||||||
expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated)
|
expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated)
|
||||||
|
|||||||
@@ -20,9 +20,11 @@
|
|||||||
"@opencode-ai/simulation": "workspace:*",
|
"@opencode-ai/simulation": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:"
|
"effect": "catalog:",
|
||||||
|
"modal": "0.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@opencode-ai/ai": "workspace:*",
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:"
|
"@typescript/native-preview": "catalog:"
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (han
|
|||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
return new Location.Info({
|
return new Location.Info({
|
||||||
directory: location.directory,
|
directory: location.directory,
|
||||||
workspaceID: location.workspaceID,
|
|
||||||
project: location.project,
|
project: location.project,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { PtyEnvironment } from "../pty-environment"
|
|||||||
|
|
||||||
const ticketScope = Effect.gen(function* () {
|
const ticketScope = Effect.gen(function* () {
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
return { directory: location.directory as string, workspaceID: location.workspaceID }
|
return { directory: location.directory as string }
|
||||||
})
|
})
|
||||||
|
|
||||||
export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) =>
|
export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) =>
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||||||
: ctx.query
|
: ctx.query
|
||||||
const page = yield* session.list({
|
const page = yield* session.list({
|
||||||
...query,
|
...query,
|
||||||
workspaceID: query.workspace,
|
|
||||||
limit: ctx.query.limit ?? DefaultSessionsLimit,
|
limit: ctx.query.limit ?? DefaultSessionsLimit,
|
||||||
})
|
})
|
||||||
const sessions = page.data
|
const sessions = page.data
|
||||||
@@ -84,7 +83,15 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||||||
model: ctx.payload.model,
|
model: ctx.payload.model,
|
||||||
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
|
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
|
||||||
})
|
})
|
||||||
.pipe(Effect.orDie),
|
.pipe(
|
||||||
|
Effect.catchTags({
|
||||||
|
"Workspace.NotFoundError": (error) =>
|
||||||
|
Effect.fail(new InvalidRequestError({ message: `Workspace not found: ${error.id}` })),
|
||||||
|
"Session.WorkspaceDirectoryError": (error) =>
|
||||||
|
Effect.fail(new InvalidRequestError({ message: error.message })),
|
||||||
|
}),
|
||||||
|
Effect.orDie,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -265,7 +272,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||||||
.move({
|
.move({
|
||||||
sessionID: ctx.params.sessionID,
|
sessionID: ctx.params.sessionID,
|
||||||
directory: ctx.payload.directory,
|
directory: ctx.payload.directory,
|
||||||
workspaceID: ctx.payload.workspaceID,
|
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.catchTag("Session.NotFoundError", (error) =>
|
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||||
@@ -456,6 +462,9 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Effect.catchTag("Shell.InvalidCwdError", (error) =>
|
||||||
|
Effect.fail(new InvalidRequestError({ message: error.message })),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return HttpApiSchema.NoContent.make()
|
return HttpApiSchema.NoContent.make()
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Shell } from "@opencode-ai/core/shell"
|
|||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||||
import { ShellNotFoundError } from "@opencode-ai/protocol/errors"
|
import { InvalidRequestError, ShellNotFoundError } from "@opencode-ai/protocol/errors"
|
||||||
import { Api } from "../api"
|
import { Api } from "../api"
|
||||||
import { response } from "../location"
|
import { response } from "../location"
|
||||||
|
|
||||||
@@ -21,7 +21,11 @@ export const ShellHandler = HttpApiBuilder.group(Api, "server.shell", (handlers)
|
|||||||
Effect.fn(function* (ctx) {
|
Effect.fn(function* (ctx) {
|
||||||
const shell = yield* Shell.Service
|
const shell = yield* Shell.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
return yield* response(shell.create({ ...ctx.payload, cwd: ctx.payload.cwd || location.directory }))
|
return yield* response(
|
||||||
|
shell
|
||||||
|
.create({ ...ctx.payload, cwd: ctx.payload.cwd || location.directory })
|
||||||
|
.pipe(Effect.mapError((error) => new InvalidRequestError({ message: error.message }))),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.handle(
|
.handle(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { Workspace } from "@opencode-ai/core/workspace"
|
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { HttpServerRequest } from "effect/unstable/http"
|
import { HttpServerRequest } from "effect/unstable/http"
|
||||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||||
@@ -28,13 +27,11 @@ export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
|
|||||||
|
|
||||||
export function requestRef(request: HttpServerRequest.HttpServerRequest): Location.Ref {
|
export function requestRef(request: HttpServerRequest.HttpServerRequest): Location.Ref {
|
||||||
const query = new URL(request.url, "http://localhost").searchParams
|
const query = new URL(request.url, "http://localhost").searchParams
|
||||||
const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
|
|
||||||
const directory =
|
const directory =
|
||||||
query.get("location[directory]") ||
|
query.get("location[directory]") ||
|
||||||
(request.headers["x-opencode-directory"] ? decode(request.headers["x-opencode-directory"]) : process.cwd())
|
(request.headers["x-opencode-directory"] ? decode(request.headers["x-opencode-directory"]) : process.cwd())
|
||||||
return Location.Ref.make({
|
return Location.Ref.make({
|
||||||
directory: AbsolutePath.make(directory),
|
directory: AbsolutePath.make(directory),
|
||||||
workspaceID: workspaceID ? Workspace.ID.make(workspaceID) : undefined,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Location } from "@opencode-ai/core/location"
|
|||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { Session } from "@opencode-ai/core/session"
|
import { Session } from "@opencode-ai/core/session"
|
||||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { Workspace } from "@opencode-ai/core/workspace"
|
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { HttpRouter } from "effect/unstable/http"
|
import { HttpRouter } from "effect/unstable/http"
|
||||||
@@ -40,7 +39,7 @@ export const sessionLocationLayer = Layer.effect(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
const row = yield* db
|
const row = yield* db
|
||||||
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
|
.select({ directory: SessionTable.directory, workspace_id: SessionTable.workspace_id })
|
||||||
.from(SessionTable)
|
.from(SessionTable)
|
||||||
.where(eq(SessionTable.id, sessionID))
|
.where(eq(SessionTable.id, sessionID))
|
||||||
.get()
|
.get()
|
||||||
@@ -54,9 +53,11 @@ export const sessionLocationLayer = Layer.effect(
|
|||||||
return yield* effect.pipe(
|
return yield* effect.pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
locations.get(
|
locations.get(
|
||||||
|
// Hosted-ness is a server-side fact from the session row; clients
|
||||||
|
// never assert workspace identity per-request.
|
||||||
Location.Ref.make({
|
Location.Ref.make({
|
||||||
directory: AbsolutePath.make(row.directory),
|
directory: AbsolutePath.make(row.directory),
|
||||||
workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
|
workspaceID: row.workspace_id ?? undefined,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ import { PtyEnvironment } from "./pty-environment"
|
|||||||
import { layer } from "./location"
|
import { layer } from "./location"
|
||||||
import { formLocationLayer } from "./middleware/form-location"
|
import { formLocationLayer } from "./middleware/form-location"
|
||||||
import { sessionLocationLayer } from "./middleware/session-location"
|
import { sessionLocationLayer } from "./middleware/session-location"
|
||||||
|
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
|
||||||
|
import { ServerWorkspaceDrivers } from "./workspace/drivers"
|
||||||
import { ServerInfo } from "./server-info"
|
import { ServerInfo } from "./server-info"
|
||||||
import type { ServerOptions } from "./options"
|
import type { ServerOptions } from "./options"
|
||||||
|
|
||||||
@@ -86,6 +88,7 @@ function makeRoutes<AuthError, AuthServices>(
|
|||||||
const pluginRuntimeCell = PluginRuntime.makeCell()
|
const pluginRuntimeCell = PluginRuntime.makeCell()
|
||||||
const replacements: LayerNode.Replacements = [
|
const replacements: LayerNode.Replacements = [
|
||||||
[Database.node, Database.configured(options.database)],
|
[Database.node, Database.configured(options.database)],
|
||||||
|
[WorkspaceDriver.registryNode, ServerWorkspaceDrivers.node],
|
||||||
[Bus.node, Bus.configured({ persist: options.events?.persist })],
|
[Bus.node, Bus.configured({ persist: options.events?.persist })],
|
||||||
[App.node, App.configured(options.app)],
|
[App.node, App.configured(options.app)],
|
||||||
[ModelsDev.node, ModelsDev.configured(options.models)],
|
[ModelsDev.node, ModelsDev.configured(options.models)],
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export * as ServerWorkspaceDrivers from "./drivers"
|
||||||
|
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||||
|
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
|
||||||
|
import { ModalDriver } from "./modal"
|
||||||
|
|
||||||
|
/** Replaces core's empty default registry in Server composition. */
|
||||||
|
export const node = makeGlobalNode({
|
||||||
|
service: WorkspaceDriver.RegistryService,
|
||||||
|
layer: Layer.effect(
|
||||||
|
WorkspaceDriver.RegistryService,
|
||||||
|
Effect.map(ModalDriver.make, (modal) =>
|
||||||
|
WorkspaceDriver.RegistryService.of(WorkspaceDriver.registry({ modal })),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
deps: [],
|
||||||
|
})
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
export * as ModalDriver from "./modal"
|
||||||
|
|
||||||
|
import { Effect, Schema, Sink, Stream } from "effect"
|
||||||
|
import { systemError } from "effect/PlatformError"
|
||||||
|
import type { Command } from "effect/unstable/process/ChildProcess"
|
||||||
|
import { ExitCode, ProcessId, make as makeSpawner, makeHandle } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
|
import { ModalClient, type Sandbox, SandboxFilesystemNotFoundError, NotFoundError } from "modal"
|
||||||
|
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
|
||||||
|
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
|
||||||
|
|
||||||
|
const PROVIDER = "modal"
|
||||||
|
const APP = "opencode-workspaces"
|
||||||
|
const ROOT = "/workspace"
|
||||||
|
/** Maximum sandbox lifetime; stop/resume/TTL lifecycle is a deferred design. */
|
||||||
|
const SANDBOX_TIMEOUT_MS = 60 * 60 * 1000
|
||||||
|
/**
|
||||||
|
* Backstop for commands: Modal exposes no per-process termination, so kill is
|
||||||
|
* best-effort and this deadline is what actually reaps orphaned commands.
|
||||||
|
*/
|
||||||
|
const EXEC_TIMEOUT_MS = 30 * 60 * 1000
|
||||||
|
|
||||||
|
const Binding = Schema.Struct({ sandboxId: Schema.String })
|
||||||
|
|
||||||
|
const fail = (message: string) => (cause: unknown) => new WorkspaceDriver.Error({ provider: PROVIDER, message, cause })
|
||||||
|
|
||||||
|
// git, bash, and rg are provisioned in the image, never discovered.
|
||||||
|
const IMAGE_COMMANDS = ["RUN apt-get update && apt-get install -y --no-install-recommends git ripgrep ca-certificates"]
|
||||||
|
|
||||||
|
export const make = Effect.sync(() => {
|
||||||
|
// Credentials come from MODAL_TOKEN_ID/MODAL_TOKEN_SECRET or ~/.modal.toml.
|
||||||
|
const client = new ModalClient()
|
||||||
|
const decode = (binding: WorkspaceDriver.Binding) =>
|
||||||
|
Schema.decodeUnknownEffect(Binding)(binding).pipe(Effect.mapError(fail("invalid modal binding")))
|
||||||
|
|
||||||
|
return WorkspaceDriver.make({
|
||||||
|
create: ({ workspaceID }) =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: async () => {
|
||||||
|
const app = await client.apps.fromName(APP, { createIfMissing: true })
|
||||||
|
const image = client.images.fromRegistry("ubuntu:24.04").dockerfileCommands(IMAGE_COMMANDS)
|
||||||
|
const sandbox = await client.sandboxes.create(app, image, {
|
||||||
|
name: workspaceID,
|
||||||
|
timeoutMs: SANDBOX_TIMEOUT_MS,
|
||||||
|
})
|
||||||
|
await sandbox.filesystem.makeDirectory(ROOT)
|
||||||
|
return { binding: { sandboxId: sandbox.sandboxId }, root: ROOT }
|
||||||
|
},
|
||||||
|
catch: fail("create"),
|
||||||
|
}),
|
||||||
|
|
||||||
|
connect: (binding) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const decoded = yield* decode(binding)
|
||||||
|
const sandbox = yield* Effect.tryPromise({
|
||||||
|
try: () => client.sandboxes.fromId(decoded.sandboxId),
|
||||||
|
catch: fail("connect"),
|
||||||
|
})
|
||||||
|
const exit = yield* Effect.tryPromise({ try: () => sandbox.poll(), catch: fail("connect") })
|
||||||
|
if (exit !== null)
|
||||||
|
return yield* new WorkspaceDriver.Error({
|
||||||
|
provider: PROVIDER,
|
||||||
|
message: `sandbox ${decoded.sandboxId} has terminated (exit ${exit})`,
|
||||||
|
})
|
||||||
|
return environment(sandbox)
|
||||||
|
}),
|
||||||
|
|
||||||
|
destroy: (binding) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const decoded = yield* decode(binding)
|
||||||
|
yield* Effect.tryPromise({
|
||||||
|
try: async () => {
|
||||||
|
const sandbox = await client.sandboxes.fromId(decoded.sandboxId)
|
||||||
|
await sandbox.terminate({ wait: true })
|
||||||
|
},
|
||||||
|
catch: fail("destroy"),
|
||||||
|
}).pipe(
|
||||||
|
// Already gone is destroyed.
|
||||||
|
Effect.catchIf(
|
||||||
|
(error) => error.cause instanceof NotFoundError,
|
||||||
|
() => Effect.void,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const environment = (sandbox: Sandbox): WorkspaceEnvironment.Interface => ({
|
||||||
|
directory: ROOT,
|
||||||
|
files: files(sandbox),
|
||||||
|
process: makeSpawner(spawn(sandbox)),
|
||||||
|
shell: WorkspaceEnvironment.linuxShell,
|
||||||
|
})
|
||||||
|
|
||||||
|
const FILE_TYPE = { file: "File", directory: "Directory", symlink: "SymbolicLink" } as const
|
||||||
|
|
||||||
|
const files = (sandbox: Sandbox): WorkspaceEnvironment.Files => {
|
||||||
|
const wrap = <A>(operation: string, path: string, run: () => Promise<A>) =>
|
||||||
|
WorkspaceEnvironment.tryOperation({
|
||||||
|
operation,
|
||||||
|
path,
|
||||||
|
run,
|
||||||
|
isNotFound: (cause) => cause instanceof SandboxFilesystemNotFoundError,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
stat: (path) =>
|
||||||
|
wrap("stat", path, async () => {
|
||||||
|
const info = await sandbox.filesystem.stat(path)
|
||||||
|
return { type: FILE_TYPE[info.type] }
|
||||||
|
}),
|
||||||
|
realPath: (path) =>
|
||||||
|
// No canonicalization API; realpath(1) from coreutils. A non-zero exit
|
||||||
|
// is reported as NotFound, which matches the dominant failure mode.
|
||||||
|
wrap("realPath", path, async () => {
|
||||||
|
// Both streams piped: the SDK's "ignore" path needs ReadableStream.from,
|
||||||
|
// which Bun does not implement.
|
||||||
|
// -e: every component must exist, matching node fs.realpath semantics.
|
||||||
|
const process = await sandbox.exec(["realpath", "-e", "--", path], { stdout: "pipe", stderr: "pipe" })
|
||||||
|
const [output, code] = await Promise.all([process.stdout.readText(), process.wait()])
|
||||||
|
if (code !== 0) throw new SandboxFilesystemNotFoundError(`realpath exited ${code}`)
|
||||||
|
return output.trim()
|
||||||
|
}),
|
||||||
|
read: (path) => wrap("read", path, () => sandbox.filesystem.readBytes(path)),
|
||||||
|
list: (path) =>
|
||||||
|
wrap("list", path, async () => {
|
||||||
|
const entries = await sandbox.filesystem.listFiles(path)
|
||||||
|
return entries.map((entry) => ({
|
||||||
|
name: entry.name,
|
||||||
|
type:
|
||||||
|
entry.type === "file"
|
||||||
|
? ("file" as const)
|
||||||
|
: entry.type === "directory"
|
||||||
|
? ("directory" as const)
|
||||||
|
: ("symlink" as const),
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
// Parent directories are created by the SDK, matching the Files contract.
|
||||||
|
write: (path, content) =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: () => sandbox.filesystem.writeBytes(content, path),
|
||||||
|
catch: (cause) => new WorkspaceEnvironment.Error({ operation: "write", path, cause }),
|
||||||
|
}),
|
||||||
|
remove: (path) => wrap("remove", path, () => sandbox.filesystem.remove(path)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawnError = (method: string, options?: { description?: string; cause?: unknown }) =>
|
||||||
|
systemError({ _tag: "Unknown", module: "ModalDriver", method, ...options })
|
||||||
|
|
||||||
|
const spawn = (sandbox: Sandbox) => {
|
||||||
|
let pids = 0
|
||||||
|
return Effect.fnUntraced(function* (command: Command) {
|
||||||
|
if (command._tag === "PipedCommand")
|
||||||
|
return yield* Effect.fail(spawnError("spawn", { description: "piped commands unsupported" }))
|
||||||
|
|
||||||
|
const process = yield* Effect.tryPromise({
|
||||||
|
try: () =>
|
||||||
|
sandbox.exec([command.command, ...command.args], {
|
||||||
|
mode: "binary",
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
workdir: command.options.cwd,
|
||||||
|
env: compact(command.options.env),
|
||||||
|
timeoutMs: EXEC_TIMEOUT_MS,
|
||||||
|
}),
|
||||||
|
catch: (cause) => spawnError("spawn", { cause }),
|
||||||
|
})
|
||||||
|
|
||||||
|
let exited = false
|
||||||
|
let waited: Promise<number> | undefined
|
||||||
|
const wait = () =>
|
||||||
|
(waited ??= process.wait().then((code) => {
|
||||||
|
exited = true
|
||||||
|
return code
|
||||||
|
}))
|
||||||
|
const onError = (cause: unknown) => spawnError("process", { cause })
|
||||||
|
return makeHandle({
|
||||||
|
pid: ProcessId(++pids),
|
||||||
|
exitCode: Effect.tryPromise({ try: wait, catch: onError }).pipe(Effect.map(ExitCode)),
|
||||||
|
isRunning: Effect.sync(() => !exited),
|
||||||
|
// Modal has no per-process termination; EXEC_TIMEOUT_MS reaps orphans.
|
||||||
|
kill: () => Effect.logWarning("modal cannot kill sandbox commands; relying on exec timeout"),
|
||||||
|
stdin: Sink.forEach((chunk: Uint8Array) =>
|
||||||
|
Effect.tryPromise({ try: () => process.stdin.writeBytes(chunk), catch: onError }),
|
||||||
|
),
|
||||||
|
stdout: Stream.fromReadableStream({ evaluate: () => process.stdout, onError }),
|
||||||
|
stderr: Stream.fromReadableStream({ evaluate: () => process.stderr, onError }),
|
||||||
|
all: Stream.merge(
|
||||||
|
Stream.fromReadableStream({ evaluate: () => process.stdout, onError }),
|
||||||
|
Stream.fromReadableStream({ evaluate: () => process.stderr, onError }),
|
||||||
|
),
|
||||||
|
getInputFd: () => Sink.fail(spawnError("getInputFd", { description: "unsupported" })),
|
||||||
|
getOutputFd: () => Stream.fail(spawnError("getOutputFd", { description: "unsupported" })),
|
||||||
|
unref: Effect.succeed(Effect.void),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const compact = (env: Record<string, string | undefined> | undefined) => {
|
||||||
|
if (!env) return undefined
|
||||||
|
return Object.fromEntries(Object.entries(env).flatMap(([key, value]) => (value === undefined ? [] : [[key, value]])))
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { existsSync, mkdtempSync } from "fs"
|
||||||
|
import { homedir, tmpdir } from "os"
|
||||||
|
import path from "path"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { Bus } from "@opencode-ai/core/bus"
|
||||||
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
|
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||||
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||||
|
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||||
|
import { Project } from "@opencode-ai/core/project"
|
||||||
|
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||||
|
import { Shell } from "@opencode-ai/core/shell"
|
||||||
|
import { Workspace } from "@opencode-ai/core/workspace"
|
||||||
|
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
|
||||||
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
|
import { ServerWorkspaceDrivers } from "../src/workspace/drivers"
|
||||||
|
import { ModalDriver } from "../src/workspace/modal"
|
||||||
|
|
||||||
|
const hasCredentials = process.env.MODAL_TOKEN_ID !== undefined || existsSync(path.join(homedir(), ".modal.toml"))
|
||||||
|
|
||||||
|
const databaseFile = path.join(mkdtempSync(path.join(tmpdir(), "opencode-modal-graph-")), "graph.db")
|
||||||
|
|
||||||
|
const layer = AppNodeBuilder.build(
|
||||||
|
LayerNode.group([Database.node, Bus.node, Workspace.node, LocationServiceMap.node]),
|
||||||
|
[
|
||||||
|
[Database.node, Database.configured({ path: databaseFile })],
|
||||||
|
[WorkspaceDriver.registryNode, ServerWorkspaceDrivers.node],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
// The complete hosted stack against real Modal: workspace creation, the full
|
||||||
|
// Location graph, file mutation, real bash in the sandbox, eviction, reconnect.
|
||||||
|
describe.skipIf(!hasCredentials)("hosted location graph on modal (live)", () => {
|
||||||
|
test(
|
||||||
|
"runs the location graph against a modal sandbox",
|
||||||
|
async () => {
|
||||||
|
await Effect.gen(function* () {
|
||||||
|
const workspaces = yield* Workspace.Service
|
||||||
|
const created = yield* workspaces.create({ provider: "modal" })
|
||||||
|
const driver = yield* ModalDriver.make
|
||||||
|
const found = yield* workspaces.binding(created.id)
|
||||||
|
yield* Effect.addFinalizer(() => Effect.ignore(driver.destroy(found.binding)))
|
||||||
|
|
||||||
|
const locations = yield* LocationServiceMap.Service
|
||||||
|
const ref = Location.Ref.make({
|
||||||
|
directory: AbsolutePath.make(created.root),
|
||||||
|
workspaceID: created.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* Effect.gen(function* () {
|
||||||
|
const location = yield* Location.Service
|
||||||
|
expect(location.project.id).toBe(Project.ID.global)
|
||||||
|
|
||||||
|
const mutation = yield* LocationMutation.Service
|
||||||
|
const mutations = yield* FileMutation.Service
|
||||||
|
const target = yield* mutation.resolve({ path: "hello.txt" })
|
||||||
|
yield* mutations.write({ target, content: "hello from opencode\n" })
|
||||||
|
|
||||||
|
const shell = yield* Shell.Service
|
||||||
|
const command = yield* shell.create({
|
||||||
|
command: "cat hello.txt && printf 'bash-made' > bash.txt && ln -s /etc escaped",
|
||||||
|
timeout: 60_000,
|
||||||
|
})
|
||||||
|
const finished = yield* shell.wait(command.id)
|
||||||
|
expect(finished.status).toBe("exited")
|
||||||
|
const output = yield* shell.output(command.id)
|
||||||
|
expect(output.output).toContain("hello from opencode")
|
||||||
|
|
||||||
|
const filesystem = yield* FileSystem.Service
|
||||||
|
const fromBash = yield* filesystem.read({ path: RelativePath.make("bash.txt") })
|
||||||
|
expect(new TextDecoder().decode(fromBash.content)).toBe("bash-made")
|
||||||
|
const root = yield* mutation.resolve({ path: "." })
|
||||||
|
const entries = yield* filesystem.glob({ target: root, pattern: "*.txt", limit: 10 })
|
||||||
|
expect(entries.map((entry) => String(entry.path)).sort()).toEqual(["bash.txt", "hello.txt"])
|
||||||
|
const matches = yield* filesystem.grep({
|
||||||
|
target: yield* mutation.resolve({ path: "hello.txt" }),
|
||||||
|
pattern: "opencode",
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
expect(matches).toMatchObject([{ entry: { path: "hello.txt" }, line: 1 }])
|
||||||
|
const escaped = yield* mutation.resolve({ path: "escaped/passwd" }).pipe(Effect.flip)
|
||||||
|
expect(escaped).toMatchObject({ _tag: "LocationMutation.PathError", reason: "outside_workspace" })
|
||||||
|
}).pipe(Effect.provide(locations.get(ref)))
|
||||||
|
|
||||||
|
yield* locations.invalidate(ref)
|
||||||
|
|
||||||
|
yield* Effect.gen(function* () {
|
||||||
|
const filesystem = yield* FileSystem.Service
|
||||||
|
const hello = yield* filesystem.read({ path: RelativePath.make("hello.txt") })
|
||||||
|
expect(new TextDecoder().decode(hello.content)).toBe("hello from opencode\n")
|
||||||
|
}).pipe(Effect.provide(locations.get(ref)))
|
||||||
|
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||||
|
},
|
||||||
|
{ timeout: 600_000 },
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { existsSync, mkdtempSync } from "fs"
|
||||||
|
import { homedir, tmpdir } from "os"
|
||||||
|
import path from "path"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { LanguageModel } from "@opencode-ai/ai"
|
||||||
|
import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat"
|
||||||
|
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||||
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
||||||
|
import { Bus } from "@opencode-ai/core/bus"
|
||||||
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||||
|
import { Model } from "@opencode-ai/core/model"
|
||||||
|
import { Permission } from "@opencode-ai/core/permission"
|
||||||
|
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||||
|
import { Provider } from "@opencode-ai/core/provider"
|
||||||
|
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||||
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { Session } from "@opencode-ai/core/session"
|
||||||
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||||
|
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||||
|
import { Workspace } from "@opencode-ai/core/workspace"
|
||||||
|
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
|
||||||
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
|
import { ServerWorkspaceDrivers } from "../src/workspace/drivers"
|
||||||
|
import { ModalDriver } from "../src/workspace/modal"
|
||||||
|
|
||||||
|
const hasCredentials = process.env.MODAL_TOKEN_ID !== undefined || existsSync(path.join(homedir(), ".modal.toml"))
|
||||||
|
|
||||||
|
const databaseFile = path.join(mkdtempSync(path.join(tmpdir(), "opencode-modal-session-")), "session.db")
|
||||||
|
|
||||||
|
// A gpt-style id so the patch plugin's context hook advertises apply_patch as
|
||||||
|
// the only editor, matching production GPT-5 sessions.
|
||||||
|
const model = LanguageModel.make({ id: "gpt-fake", provider: "fake", route: OpenAIChat.route })
|
||||||
|
const models = Layer.mock(SessionRunnerModel.Service)({
|
||||||
|
resolve: () =>
|
||||||
|
Effect.succeed(
|
||||||
|
SessionRunnerModel.resolved(model, {
|
||||||
|
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||||
|
cost: [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const permission = Layer.mock(Permission.Service)({ assert: () => Effect.void })
|
||||||
|
|
||||||
|
const layer = AppNodeBuilder.build(
|
||||||
|
LayerNode.group([
|
||||||
|
Database.node,
|
||||||
|
Bus.node,
|
||||||
|
Workspace.node,
|
||||||
|
PluginRuntime.providerNode,
|
||||||
|
LocationServiceMap.node,
|
||||||
|
SessionExecution.node,
|
||||||
|
Session.node,
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
[Database.node, Database.configured({ path: databaseFile })],
|
||||||
|
[WorkspaceDriver.registryNode, ServerWorkspaceDrivers.node],
|
||||||
|
[LayerNodePlatform.llmClient, TestLLM.clientLayer],
|
||||||
|
[SessionRunnerModel.node, models],
|
||||||
|
[Permission.node, permission],
|
||||||
|
],
|
||||||
|
).pipe(Layer.provideMerge(TestLLM.layer({ fallback: [] })))
|
||||||
|
|
||||||
|
const sessionModel = Model.Ref.make({ id: Model.ID.make("gpt-fake"), providerID: Provider.ID.make("fake") })
|
||||||
|
|
||||||
|
// A scripted model through the real runner against a real Modal sandbox: the
|
||||||
|
// session-level counterpart of workspace-modal-graph.test.ts.
|
||||||
|
describe.skipIf(!hasCredentials)("hosted session on modal (live)", () => {
|
||||||
|
test(
|
||||||
|
"runs a scripted model through the real runner on a modal sandbox",
|
||||||
|
async () => {
|
||||||
|
await Effect.gen(function* () {
|
||||||
|
const workspaces = yield* Workspace.Service
|
||||||
|
const created = yield* workspaces.create({ provider: "modal" })
|
||||||
|
const driver = yield* ModalDriver.make
|
||||||
|
const found = yield* workspaces.binding(created.id)
|
||||||
|
yield* Effect.addFinalizer(() => Effect.ignore(driver.destroy(found.binding)))
|
||||||
|
|
||||||
|
const sessions = yield* Session.Service
|
||||||
|
const location = Location.Ref.make({
|
||||||
|
directory: AbsolutePath.make(created.root),
|
||||||
|
workspaceID: created.id,
|
||||||
|
})
|
||||||
|
const session = yield* sessions.create({
|
||||||
|
title: "hosted modal session",
|
||||||
|
location,
|
||||||
|
model: sessionModel,
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* TestLLM.push(
|
||||||
|
TestLLM.tool("call-patch", "patch", {
|
||||||
|
patchText: "*** Begin Patch\n*** Add File: from-patch.txt\n+patched\n*** End Patch",
|
||||||
|
}),
|
||||||
|
TestLLM.tool("call-shell", "shell", { command: "printf 'from-model' > from-model.txt" }),
|
||||||
|
TestLLM.tool("call-glob", "glob", { pattern: "*.txt" }),
|
||||||
|
TestLLM.tool("call-grep", "grep", { pattern: "from-model" }),
|
||||||
|
// Read must go through the sandbox filesystem: the host has no
|
||||||
|
// /workspace/from-patch.txt, so a host-backed reader would fail.
|
||||||
|
TestLLM.tool("call-read", "read", { path: "from-patch.txt" }),
|
||||||
|
TestLLM.text("done", "text-1"),
|
||||||
|
)
|
||||||
|
yield* sessions.prompt({ sessionID: session.id, text: "Write a file in the workspace", resume: false })
|
||||||
|
yield* sessions.resume(session.id)
|
||||||
|
|
||||||
|
const requests = (yield* TestLLM.Service).requests
|
||||||
|
const advertised = requests[0]?.tools.map((tool) => tool.name) ?? []
|
||||||
|
expect(advertised).toContain("shell")
|
||||||
|
expect(advertised).toContain("patch")
|
||||||
|
expect(advertised).toContain("glob")
|
||||||
|
expect(advertised).toContain("grep")
|
||||||
|
expect(advertised).toContain("read")
|
||||||
|
expect(advertised).not.toContain("edit")
|
||||||
|
expect(advertised).not.toContain("write")
|
||||||
|
|
||||||
|
const context = yield* sessions.context(session.id)
|
||||||
|
expect(context.at(0)).toMatchObject({ type: "user", text: "Write a file in the workspace" })
|
||||||
|
const assistants = context.filter((message) => message.type === "assistant")
|
||||||
|
expect(assistants.at(0)).toMatchObject({
|
||||||
|
content: [{ type: "tool", id: "call-patch", state: { status: "completed" } }],
|
||||||
|
})
|
||||||
|
expect(assistants.at(1)).toMatchObject({
|
||||||
|
content: [{ type: "tool", id: "call-shell", state: { status: "completed" } }],
|
||||||
|
})
|
||||||
|
expect(assistants.at(2)).toMatchObject({
|
||||||
|
content: [{ type: "tool", id: "call-glob", state: { status: "completed" } }],
|
||||||
|
})
|
||||||
|
expect(assistants.at(3)).toMatchObject({
|
||||||
|
content: [{ type: "tool", id: "call-grep", state: { status: "completed" } }],
|
||||||
|
})
|
||||||
|
expect(assistants.at(4)).toMatchObject({
|
||||||
|
content: [{ type: "tool", id: "call-read", state: { status: "completed" } }],
|
||||||
|
})
|
||||||
|
expect(assistants.at(-1)).toMatchObject({ content: [{ type: "text", text: "done" }] })
|
||||||
|
|
||||||
|
// Both tools executed inside the sandbox: read the files back through
|
||||||
|
// the hosted filesystem rather than any host path.
|
||||||
|
const locations = yield* LocationServiceMap.Service
|
||||||
|
yield* Effect.gen(function* () {
|
||||||
|
const filesystem = yield* FileSystem.Service
|
||||||
|
const written = yield* filesystem.read({ path: RelativePath.make("from-model.txt") })
|
||||||
|
expect(new TextDecoder().decode(written.content)).toBe("from-model")
|
||||||
|
const patched = yield* filesystem.read({ path: RelativePath.make("from-patch.txt") })
|
||||||
|
expect(new TextDecoder().decode(patched.content)).toBe("patched\n")
|
||||||
|
}).pipe(Effect.provide(locations.get(location)))
|
||||||
|
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||||
|
},
|
||||||
|
{ timeout: 600_000 },
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { existsSync } from "fs"
|
||||||
|
import { homedir } from "os"
|
||||||
|
import path from "path"
|
||||||
|
import { Effect, Stream } from "effect"
|
||||||
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
|
import { Workspace } from "@opencode-ai/core/workspace"
|
||||||
|
import { ModalDriver } from "../src/workspace/modal"
|
||||||
|
|
||||||
|
const hasCredentials = process.env.MODAL_TOKEN_ID !== undefined || existsSync(path.join(homedir(), ".modal.toml"))
|
||||||
|
|
||||||
|
// Live contract test against real Modal. First run may build the image
|
||||||
|
// (apt-get install), so the budget is generous.
|
||||||
|
describe.skipIf(!hasCredentials)("modal driver (live)", () => {
|
||||||
|
test(
|
||||||
|
"create, connect, files, exec, reconnect, destroy",
|
||||||
|
async () => {
|
||||||
|
await Effect.gen(function* () {
|
||||||
|
const driver = yield* ModalDriver.make
|
||||||
|
const workspaceID = Workspace.ID.create()
|
||||||
|
const created = yield* driver.create({ workspaceID })
|
||||||
|
expect(created.root).toBe("/workspace")
|
||||||
|
|
||||||
|
const cleanup = () => Effect.ignore(driver.destroy(created.binding))
|
||||||
|
yield* Effect.addFinalizer(cleanup)
|
||||||
|
|
||||||
|
const env = yield* driver.connect(created.binding)
|
||||||
|
expect(env.directory).toBe("/workspace")
|
||||||
|
|
||||||
|
// files: writes create parents and overwrite, read round-trips, stat sees a file
|
||||||
|
yield* env.files.write("/workspace/hello.txt", new TextEncoder().encode("hello modal\n"))
|
||||||
|
yield* env.files.write("/workspace/hello.txt", new TextEncoder().encode("hello again\n"))
|
||||||
|
yield* env.files.write("/workspace/nested/deep/hello.txt", new TextEncoder().encode("nested\n"))
|
||||||
|
expect(
|
||||||
|
yield* env.files
|
||||||
|
.read("/workspace/nested/deep/hello.txt")
|
||||||
|
.pipe(Effect.map((b) => new TextDecoder().decode(b))),
|
||||||
|
).toBe("nested\n")
|
||||||
|
const bytes = yield* env.files.read("/workspace/hello.txt")
|
||||||
|
expect(new TextDecoder().decode(bytes)).toBe("hello again\n")
|
||||||
|
expect((yield* env.files.stat("/workspace/hello.txt")).type).toBe("File")
|
||||||
|
expect(yield* env.files.realPath("/workspace")).toBe("/workspace")
|
||||||
|
const listed = yield* env.files.list("/workspace")
|
||||||
|
expect(listed.map((entry) => entry.name)).toContain("hello.txt")
|
||||||
|
|
||||||
|
// missing files are typed NotFound
|
||||||
|
const missing = yield* env.files.read("/workspace/nope.txt").pipe(Effect.flip)
|
||||||
|
expect(missing._tag).toBe("WorkspaceEnvironment.NotFoundError")
|
||||||
|
|
||||||
|
// exec: bash sees the file, git and rg are provisioned
|
||||||
|
const handle = yield* env.process.spawn(
|
||||||
|
ChildProcess.make(
|
||||||
|
env.shell.executable,
|
||||||
|
[...env.shell.args("cat hello.txt && git --version && rg --version | head -1")],
|
||||||
|
{
|
||||||
|
cwd: env.directory,
|
||||||
|
stdin: "ignore",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const [output, code] = yield* Effect.all([Stream.mkString(Stream.decodeText(handle.stdout)), handle.exitCode])
|
||||||
|
expect(Number(code)).toBe(0)
|
||||||
|
expect(output).toContain("hello again")
|
||||||
|
expect(output).toContain("git version")
|
||||||
|
expect(output).toContain("ripgrep")
|
||||||
|
|
||||||
|
// reconnect by binding: durable contents survive a fresh connection
|
||||||
|
const again = yield* driver.connect(created.binding)
|
||||||
|
const persisted = yield* again.files.read("/workspace/hello.txt")
|
||||||
|
expect(new TextDecoder().decode(persisted)).toBe("hello again\n")
|
||||||
|
|
||||||
|
// destroy, then connect must fail typed
|
||||||
|
yield* driver.destroy(created.binding)
|
||||||
|
const dead = yield* driver.connect(created.binding).pipe(Effect.flip)
|
||||||
|
expect(dead._tag).toBe("WorkspaceDriver.Error")
|
||||||
|
}).pipe(Effect.scoped, Effect.runPromise)
|
||||||
|
},
|
||||||
|
{ timeout: 600_000 },
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -584,7 +584,6 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||||||
order: "desc",
|
order: "desc",
|
||||||
parentID: null,
|
parentID: null,
|
||||||
directory: location.directory,
|
directory: location.directory,
|
||||||
workspace: location.workspaceID,
|
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
const match = response.data[0]?.id
|
const match = response.data[0]?.id
|
||||||
|
|||||||
@@ -713,7 +713,7 @@ async function disconnected(
|
|||||||
|
|
||||||
function location(data: ReturnType<typeof useData>) {
|
function location(data: ReturnType<typeof useData>) {
|
||||||
const current = data.location.default()
|
const current = data.location.default()
|
||||||
return { directory: current.directory, workspace: current.workspaceID }
|
return { directory: current.directory }
|
||||||
}
|
}
|
||||||
|
|
||||||
function message(cause: unknown) {
|
function message(cause: unknown) {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export function DialogMcp() {
|
|||||||
if (!server || server.status.status === "pending") return
|
if (!server || server.status.status === "pending") return
|
||||||
setLoading(name)
|
setLoading(name)
|
||||||
const current = data.location.default()
|
const current = data.location.default()
|
||||||
const input = { server: name, location: { directory: current.directory, workspace: current.workspaceID } }
|
const input = { server: name, location: { directory: current.directory } }
|
||||||
const call = server.status.status === "connected" ? client.api.mcp.disconnect(input) : client.api.mcp.connect(input)
|
const call = server.status.status === "connected" ? client.api.mcp.disconnect(input) : client.api.mcp.connect(input)
|
||||||
void call.catch(toast.error).finally(() => setLoading(null))
|
void call.catch(toast.error).finally(() => setLoading(null))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,7 +290,6 @@ export function Autocomplete(props: {
|
|||||||
limit: 20,
|
limit: 20,
|
||||||
location: {
|
location: {
|
||||||
directory: input.location?.directory,
|
directory: input.location?.directory,
|
||||||
workspace: input.location?.workspaceID ?? data.location.default().workspaceID,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(
|
.then(
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ function location(ref: LocationRef) {
|
|||||||
return {
|
return {
|
||||||
location: {
|
location: {
|
||||||
directory: ref.directory,
|
directory: ref.directory,
|
||||||
workspace: ref.workspaceID,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,7 +151,6 @@ function formRequestOptions(location: LocationRef | undefined) {
|
|||||||
return {
|
return {
|
||||||
headers: {
|
headers: {
|
||||||
"x-opencode-directory": encodeURIComponent(location.directory),
|
"x-opencode-directory": encodeURIComponent(location.directory),
|
||||||
...(location.workspaceID ? { "x-opencode-workspace": location.workspaceID } : {}),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,7 +235,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
.find({
|
.find({
|
||||||
query,
|
query,
|
||||||
type: "file",
|
type: "file",
|
||||||
location: { directory: state.location.directory, workspace: state.location.workspaceID },
|
location: { directory: state.location.directory },
|
||||||
})
|
})
|
||||||
.then((result) => result.data.map((file) => file.path))
|
.then((result) => result.data.map((file) => file.path))
|
||||||
.catch(() => []),
|
.catch(() => []),
|
||||||
@@ -651,7 +650,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
{
|
{
|
||||||
location: {
|
location: {
|
||||||
directory: state.location.directory,
|
directory: state.location.directory,
|
||||||
workspace: state.location.workspaceID,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ signal: attempt.signal },
|
{ signal: attempt.signal },
|
||||||
|
|||||||
@@ -421,7 +421,6 @@ async function resolveSelectedModel(
|
|||||||
? {
|
? {
|
||||||
location: {
|
location: {
|
||||||
directory: input.location.directory,
|
directory: input.location.directory,
|
||||||
workspace: input.location.workspaceID,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -819,7 +818,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
input.location
|
input.location
|
||||||
? client.form.request.list(
|
? client.form.request.list(
|
||||||
{
|
{
|
||||||
location: { directory: input.location.directory, workspace: input.location.workspaceID },
|
location: { directory: input.location.directory },
|
||||||
},
|
},
|
||||||
options,
|
options,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function ShellTab(props: { sessionID: string }) {
|
|||||||
if (!entry) return
|
if (!entry) return
|
||||||
void client.api.shell.remove({
|
void client.api.shell.remove({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
location: { directory: entry.location.directory, workspace: entry.location.workspaceID },
|
location: { directory: entry.location.directory },
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ function requestOptions(form: FormWithLocation) {
|
|||||||
return {
|
return {
|
||||||
headers: {
|
headers: {
|
||||||
"x-opencode-directory": encodeURIComponent(form.location.directory),
|
"x-opencode-directory": encodeURIComponent(form.location.directory),
|
||||||
...(form.location.workspaceID ? { "x-opencode-workspace": form.location.workspaceID } : {}),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2533,7 +2533,7 @@ function Shell(props: ToolProps) {
|
|||||||
id,
|
id,
|
||||||
cursor,
|
cursor,
|
||||||
limit: SHELL_DISPLAY_LIMIT,
|
limit: SHELL_DISPLAY_LIMIT,
|
||||||
location: location ? { directory: location.directory, workspace: location.workspaceID } : undefined,
|
location: location ? { directory: location.directory } : undefined,
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
if (!response) break
|
if (!response) break
|
||||||
|
|||||||
@@ -1916,7 +1916,7 @@ test("keeps shell state scoped to location", async () => {
|
|||||||
app.mockInput.pressKey("d", { ctrl: true })
|
app.mockInput.pressKey("d", { ctrl: true })
|
||||||
await wait(() => removed !== undefined)
|
await wait(() => removed !== undefined)
|
||||||
expect(removed?.searchParams.get("location[directory]")).toBe(other)
|
expect(removed?.searchParams.get("location[directory]")).toBe(other)
|
||||||
expect(removed?.searchParams.get("location[workspace]")).toBe(workspace)
|
expect(removed?.searchParams.get("location[workspace]")).toBeNull()
|
||||||
|
|
||||||
events.emit({
|
events.emit({
|
||||||
id: "evt_shell_created",
|
id: "evt_shell_created",
|
||||||
|
|||||||
@@ -240,7 +240,6 @@ describe("run interactive runtime", () => {
|
|||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
"x-opencode-directory": "%2Fremote%20work",
|
"x-opencode-directory": "%2Fremote%20work",
|
||||||
"x-opencode-workspace": "wrk_1",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -575,7 +574,7 @@ describe("run interactive runtime", () => {
|
|||||||
painted.resolve()
|
painted.resolve()
|
||||||
await task
|
await task
|
||||||
|
|
||||||
const query = { location: { directory: "/session", workspace: "work-1" } }
|
const query = { location: { directory: "/session" } }
|
||||||
expect(getDirectory?.()).toBe("/session")
|
expect(getDirectory?.()).toBe("/session")
|
||||||
expect(transportLocation).toMatchObject({ directory: "/session", workspaceID: "work-1" })
|
expect(transportLocation).toMatchObject({ directory: "/session", workspaceID: "work-1" })
|
||||||
expect(catalogs.provider).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
expect(catalogs.provider).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||||
|
|||||||
@@ -2219,7 +2219,7 @@ describe("V2 mini transport", () => {
|
|||||||
{ signal: undefined },
|
{ signal: undefined },
|
||||||
)
|
)
|
||||||
expect(defaultModel).toHaveBeenCalledWith(
|
expect(defaultModel).toHaveBeenCalledWith(
|
||||||
{ location: { directory: "/project", workspace: "wrk_1" } },
|
{ location: { directory: "/project" } },
|
||||||
{ signal: undefined },
|
{ signal: undefined },
|
||||||
)
|
)
|
||||||
await transport.close()
|
await transport.close()
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export function has(content: Uint8Array) {
|
|||||||
return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
|
return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fromBytes(content: Uint8Array) {
|
||||||
|
return split(decode(content))
|
||||||
|
}
|
||||||
|
|
||||||
export const readFile = Effect.fn("Bom.readFile")(function* (fs: FSUtil.Interface, filepath: string) {
|
export const readFile = Effect.fn("Bom.readFile")(function* (fs: FSUtil.Interface, filepath: string) {
|
||||||
return split(decode(yield* fs.readFile(filepath)))
|
return split(decode(yield* fs.readFile(filepath)))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Brand, Context, Layer } from "effect"
|
import { Brand, Context, Layer } from "effect"
|
||||||
|
|
||||||
type AnyNode = Node<unknown, unknown, any>
|
export type AnyNode = Node<unknown, unknown, any>
|
||||||
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
|
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
|
||||||
type NodeList<Item extends AnyNode = AnyNode> = readonly [] | readonly [Item, ...Item[]]
|
type NodeList<Item extends AnyNode = AnyNode> = readonly [] | readonly [Item, ...Item[]]
|
||||||
export type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown, any> ? A : never
|
export type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown, any> ? A : never
|
||||||
@@ -155,7 +155,7 @@ function nodeMakeIdentity(node: AnyNode): NodeIdentity {
|
|||||||
return { name: node.name }
|
return { name: node.name }
|
||||||
}
|
}
|
||||||
|
|
||||||
function isNode(input: Layer.Any | AnyNode): input is AnyNode {
|
export function isNode(input: Layer.Any | AnyNode): input is AnyNode {
|
||||||
return "kind" in input && "dependencies" in input
|
return "kind" in input && "dependencies" in input
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ type VisitContext<Result> = {
|
|||||||
readonly visit: (node: AnyNode) => Result
|
readonly visit: (node: AnyNode) => Result
|
||||||
}
|
}
|
||||||
|
|
||||||
function walk<Result>(
|
export function walk<Result>(
|
||||||
root: AnyNode,
|
root: AnyNode,
|
||||||
visit: Visit<Result>,
|
visit: Visit<Result>,
|
||||||
options: {
|
options: {
|
||||||
|
|||||||
@@ -269,8 +269,26 @@ export namespace FSUtil {
|
|||||||
return contains(a, b) || contains(b, a)
|
return contains(a, b) || contains(b, a)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Normalize path separators to forward slashes for display and resources. */
|
||||||
|
export function slash(value: string) {
|
||||||
|
return value.replaceAll("\\", "/")
|
||||||
|
}
|
||||||
|
|
||||||
export function contains(parent: string, child: string) {
|
export function contains(parent: string, child: string) {
|
||||||
const result = relative(parent, child)
|
return containsUsing({ relative, isAbsolute, sep }, parent, child)
|
||||||
return result === "" || (!isAbsolute(result) && result !== ".." && !result.startsWith(`..${sep}`))
|
}
|
||||||
|
|
||||||
|
/** `contains` with posix rules regardless of host platform, for provider paths. */
|
||||||
|
export function containsPosix(parent: string, child: string) {
|
||||||
|
return containsUsing(path.posix, parent, child)
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsUsing(
|
||||||
|
paths: Pick<typeof path.posix, "relative" | "isAbsolute" | "sep">,
|
||||||
|
parent: string,
|
||||||
|
child: string,
|
||||||
|
) {
|
||||||
|
const result = paths.relative(parent, child)
|
||||||
|
return result === "" || (!paths.isAbsolute(result) && result !== ".." && !result.startsWith(`..${paths.sep}`))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,579 @@
|
|||||||
|
# Hosted Workspace Execution
|
||||||
|
|
||||||
|
Status: superseded by `specs/v2/workspaces.md` for domain model and public API. Retained for execution-level research: provider feasibility gates, process laws, host-authority tripwires, and phase acceptance criteria. Where the two documents disagree (repository-at-creation, required Project identity, placement as a public concept, narrow process contract), `workspaces.md` wins.
|
||||||
|
|
||||||
|
Reader: the engineer implementing provider-neutral hosted Workspaces in OpenCode V2.
|
||||||
|
|
||||||
|
After reading this plan, that engineer can implement the smallest complete hosted coding slice in order, without moving the Session runner into a sandbox, leaking sandbox-driver details into Session APIs, or accidentally using the OpenCode server filesystem.
|
||||||
|
|
||||||
|
## The Runner Stays Central And Hosted Authority Enters Through Location
|
||||||
|
|
||||||
|
OpenCode will keep the Session runner in the central server. A Session with an explicit `Location.workspaceID` receives one hosted `WorkspaceEnvironment` when its Location graph is built.
|
||||||
|
|
||||||
|
```text
|
||||||
|
SessionExecution.resume(sessionID)
|
||||||
|
-> SessionStore.get(sessionID)
|
||||||
|
-> LocationServiceMap.get(session.location)
|
||||||
|
-> HostedWorkspaceStore.get(location.workspaceID)
|
||||||
|
-> SandboxDriver.acquire(persisted binding)
|
||||||
|
-> build hosted Location services
|
||||||
|
-> SessionRunner.run(sessionID)
|
||||||
|
```
|
||||||
|
|
||||||
|
The central server continues to own model calls, permissions, tool definitions, durable Session history, output limits, and tool settlement. Only Workspace file and foreground-process effects cross the sandbox-driver boundary.
|
||||||
|
|
||||||
|
An omitted `Location.workspaceID` continues through the current implicit-local graph. The first implementation does not rebuild local filesystem or process services behind the hosted interfaces.
|
||||||
|
|
||||||
|
The first milestone is an internal execution tracer, not a public Workspace product API. It connects to a pre-provisioned hosted resource, runs one real V2 Session, and reconnects after OpenCode rebuilds its Location graph. Public provisioning and lifecycle APIs follow only after the execution boundary works.
|
||||||
|
|
||||||
|
## One Concrete Scenario Defines The First Milestone
|
||||||
|
|
||||||
|
The milestone is complete when this scenario passes against a deterministic fake and the sandbox provider selected by Phase 0:
|
||||||
|
|
||||||
|
1. Register a pre-provisioned hosted resource containing a fixture Git repository.
|
||||||
|
2. Create a V2 Session whose Location references that hosted Workspace.
|
||||||
|
3. Confirm the provider directory does not exist on the OpenCode server host.
|
||||||
|
4. Prompt the Session to read and edit a file.
|
||||||
|
5. Run `git status` and one foreground shell command in the hosted checkout.
|
||||||
|
6. Interrupt a long command and confirm the provider command terminates.
|
||||||
|
7. Evict and rebuild the Location graph.
|
||||||
|
8. Reconnect to the same provider resource and verify the edit remains.
|
||||||
|
9. Run the existing implicit-local Session path without behavior changes.
|
||||||
|
|
||||||
|
The first milestone deliberately omits public create, deactivate, reactivate, and delete operations. The test fixture owns provider cleanup.
|
||||||
|
|
||||||
|
## Vocabulary Is Stable Across Core And Drivers
|
||||||
|
|
||||||
|
### Hosted Workspace
|
||||||
|
|
||||||
|
A Hosted Workspace is a stable writable checkout identified by `Workspace.ID`. A Session references it only through `Location.workspaceID`.
|
||||||
|
|
||||||
|
The Hosted Workspace record stores private placement and project metadata. It does not copy or synchronize files. The provider filesystem is authoritative.
|
||||||
|
|
||||||
|
### Workspace Root
|
||||||
|
|
||||||
|
The Workspace root is the checkout root. It is an absolute POSIX path in the provider filesystem, not a path on the OpenCode server.
|
||||||
|
|
||||||
|
The first milestone supports one checkout root per Hosted Workspace. Do not add a separate project root until a concrete multi-root layout requires one.
|
||||||
|
|
||||||
|
### Location Directory
|
||||||
|
|
||||||
|
The Location directory is the Session working directory inside the Workspace root. It may equal the root or name a nested directory. It uses the provider POSIX namespace.
|
||||||
|
|
||||||
|
`Location.Ref.directory` remains required. Core validates a hosted directory with POSIX path rules and never passes it to Node, Bun, native search, native watcher, or host Git APIs.
|
||||||
|
|
||||||
|
### Placement
|
||||||
|
|
||||||
|
Placement is the private durable fact that names the sandbox driver and provider resource for one Hosted Workspace.
|
||||||
|
|
||||||
|
Placement contains:
|
||||||
|
|
||||||
|
- `Workspace.ID`;
|
||||||
|
- the sandbox-driver key;
|
||||||
|
- a placement format version;
|
||||||
|
- a provider-validated JSON binding;
|
||||||
|
- the Workspace root;
|
||||||
|
- the logical Project ID;
|
||||||
|
- optional VCS metadata already discovered inside the provider resource.
|
||||||
|
|
||||||
|
Provider credentials, SDK clients, preview URLs, process handles, and model-provider credentials are never placement data.
|
||||||
|
|
||||||
|
### Binding
|
||||||
|
|
||||||
|
A binding is the smallest provider-specific JSON value required to reconnect to the same provider resource. Each sandbox driver owns an Effect Schema for its binding.
|
||||||
|
|
||||||
|
The first contract requires a stable binding. `acquire` cannot replace it or silently create another resource. Providers whose durable identity changes during activation require a later transactional binding-transition design.
|
||||||
|
|
||||||
|
### Sandbox Driver
|
||||||
|
|
||||||
|
A sandbox driver is an internal adapter for one hosted-environment provider. “Provider” without the “sandbox” qualifier remains reserved for model providers in user-facing APIs.
|
||||||
|
|
||||||
|
### WorkspaceEnvironment
|
||||||
|
|
||||||
|
`WorkspaceEnvironment` is the scoped live data-plane connection for one Hosted Workspace. Closing its scope releases local SDK and stream resources and cleans up scope-owned foreground commands. It never stops, snapshots, or deletes the durable provider resource.
|
||||||
|
|
||||||
|
## Placement Reconnects Without Acquiring Compute
|
||||||
|
|
||||||
|
V2 must read Hosted Workspace metadata without contacting a sandbox provider. This lets Session lists, routing, and Location validation remain cheap and available while provider compute is stopped or unavailable.
|
||||||
|
|
||||||
|
Add a V2-owned internal table rather than overloading the V1-owned `workspace.type` and `workspace.extra` columns:
|
||||||
|
|
||||||
|
```text
|
||||||
|
hosted_workspace
|
||||||
|
id primary key, Workspace.ID
|
||||||
|
driver sandbox-driver key
|
||||||
|
version placement encoding version
|
||||||
|
root provider POSIX Workspace root
|
||||||
|
project_id logical Project ID
|
||||||
|
vcs optional VCS kind
|
||||||
|
binding provider-validated JSON
|
||||||
|
time_created
|
||||||
|
time_updated
|
||||||
|
```
|
||||||
|
|
||||||
|
No V2 placement format has shipped, so the first migration has no compatibility decoder. The implementation does not add rows to the shared V1 `workspace` table and does not modify `packages/opencode`.
|
||||||
|
|
||||||
|
Hosted registration transactionally rejects an ID already present in the V1 `workspace` table. Session projection identifies hosted IDs through `hosted_workspace` and skips the legacy `WorkspaceTable.time_used` update for them. Hosted execution never inserts, updates, or deletes a V1 Workspace row.
|
||||||
|
|
||||||
|
The current Session schema requires `project_id` to reference `ProjectTable`. Registration therefore ensures a Project row exists. If no local checkout exists, its current `worktree` column temporarily contains the provider POSIX root. If the logical Project already has a local row, registration preserves its local `worktree` rather than overwriting it. V2 hosted code must never interpret a provider root through host filesystem services. This is an acknowledged shared-schema limitation, not a declaration that provider and host absolute paths share a namespace.
|
||||||
|
|
||||||
|
A later Project persistence redesign may separate logical Project identity from checkout locations. That redesign is not required to prove hosted execution.
|
||||||
|
|
||||||
|
## Hosted Session Admission Must Bypass Host Project Discovery
|
||||||
|
|
||||||
|
Current `SessionV2.create` always calls host-backed `Project.resolve(input.location.directory)` and computes the Session path with host `path.relative`. Hosted Session admission must branch before either operation.
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspaceID omitted
|
||||||
|
-> current Project.resolve and host path behavior
|
||||||
|
|
||||||
|
workspaceID present
|
||||||
|
-> HostedWorkspaceStore.get(workspaceID)
|
||||||
|
-> validate Location directory inside Workspace root with path.posix
|
||||||
|
-> ensure ProjectTable row from persisted logical project metadata
|
||||||
|
-> compute Session path with path.posix.relative
|
||||||
|
-> persist the existing Location.Ref and project ID
|
||||||
|
```
|
||||||
|
|
||||||
|
Hosted registration supplies project identity before Session creation. The first Vercel fixture discovers it by running Git inside the sandbox. Session admission does not wake compute merely to rediscover it.
|
||||||
|
|
||||||
|
Tests place tripwires on `Project.resolve`, host Git, and host filesystem services and assert that hosted Session creation calls none of them.
|
||||||
|
|
||||||
|
Path-bearing Session operations outside the Location graph need explicit guards too:
|
||||||
|
|
||||||
|
- `Session.move` rejects when either the current or requested Location has `workspaceID`, before path expansion, host filesystem access, or `Project.resolve`;
|
||||||
|
- hosted prompts accept inline `data:` attachments but reject `file:` attachments before prompt materialization;
|
||||||
|
- provider-file attachments and hosted Session movement require later `WorkspaceFiles` designs.
|
||||||
|
|
||||||
|
## The Hosted Graph Replaces Every Reachable Host Authority
|
||||||
|
|
||||||
|
The current Location graph statically includes services that ultimately use host filesystem, process, native search, watcher, PTY, snapshots, project copies, plugins, and configuration. Replacing only `FSUtil` or only `AppProcess` is unsafe:
|
||||||
|
|
||||||
|
- replacing all `FSUtil` would redirect host-owned config and managed outputs into the sandbox;
|
||||||
|
- retaining global `FSUtil` would read project files from the server host;
|
||||||
|
- retaining current plugin loading could import Workspace modules into the central process;
|
||||||
|
- retaining PTY, watcher, FFF, snapshot, or project-copy services would act on provider paths as if they were host paths.
|
||||||
|
|
||||||
|
The hosted branch therefore supplies dedicated implementations for the services it supports and explicit rejecting implementations for the rest.
|
||||||
|
|
||||||
|
### Host-Owned Services
|
||||||
|
|
||||||
|
These remain process-global and continue using host storage:
|
||||||
|
|
||||||
|
- database and durable event storage;
|
||||||
|
- global OpenCode configuration;
|
||||||
|
- credentials and auth stores;
|
||||||
|
- caches and downloaded assets;
|
||||||
|
- managed tool output storage;
|
||||||
|
- model and sandbox-driver credentials;
|
||||||
|
- built-in and global skills;
|
||||||
|
- remote URL instruction fetching.
|
||||||
|
|
||||||
|
### Workspace-Owned Services In The First Milestone
|
||||||
|
|
||||||
|
These use `WorkspaceEnvironment`:
|
||||||
|
|
||||||
|
- `Location` metadata;
|
||||||
|
- one hosted read path;
|
||||||
|
- one hosted exact-edit path;
|
||||||
|
- hosted `AGENTS.md` discovery needed by initial System Context;
|
||||||
|
- a hosted foreground Bash implementation;
|
||||||
|
- request-driven `git status` through Bash;
|
||||||
|
- hosted environment facts.
|
||||||
|
|
||||||
|
### Hosted Capability Table
|
||||||
|
|
||||||
|
| State | Capabilities |
|
||||||
|
| --- | --- |
|
||||||
|
| Advertised | read, exact edit, foreground Bash |
|
||||||
|
| Endpoint remains but returns `UnsupportedWorkspaceCapability` | Session move, `file:` prompt attachments, PTY, project copies, snapshots/revert |
|
||||||
|
| Not advertised and local producer is not activated | external file paths, apply-patch, glob, grep, project config, project skills, project plugins, application tools, FFF, watcher, LSP, full `Git.Service` |
|
||||||
|
|
||||||
|
The first milestone narrows the tool catalog for hosted Sessions. It must not advertise a tool whose execution would fall back to host authority.
|
||||||
|
|
||||||
|
Some current service interfaces and Protocol endpoints cannot represent “unsupported” as a typed result. The hosted graph work must add a sanitized `UnsupportedWorkspaceCapability` error where needed; carry placement and acquisition errors through `LocationServiceMap`, `SessionExecution.resume`, `SessionRunner.RunError`, Session error mapping, and HTTP middleware; and remove any `Layer.orDie` that would convert expected hosted availability errors into defects. Public `HttpApi` changes require client regeneration.
|
||||||
|
|
||||||
|
## WorkspaceEnvironment Is A Small Data-Plane Interface
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface WorkspaceEnvironment {
|
||||||
|
readonly platform: "linux"
|
||||||
|
readonly root: string
|
||||||
|
readonly files: WorkspaceFiles
|
||||||
|
readonly processes: WorkspaceProcesses
|
||||||
|
readonly shell: {
|
||||||
|
readonly executable: string
|
||||||
|
readonly args: (command: string) => readonly string[]
|
||||||
|
readonly environment: Readonly<Record<string, string>>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The environment does not expose driver keys, provider resource IDs, bindings, lifecycle methods, preview URLs, or an untyped `raw` escape hatch.
|
||||||
|
|
||||||
|
The first environment has no general capability map. Hosted graph construction knows the milestone's fixed supported set. Add capability negotiation only after two real drivers expose a difference a caller must handle dynamically.
|
||||||
|
|
||||||
|
## File Operations Preserve Current Cooperative Mutation Semantics
|
||||||
|
|
||||||
|
Provider file operations receive normalized Workspace-relative POSIX paths. Core never sends host absolute paths across this boundary.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface WorkspaceFiles {
|
||||||
|
readonly resolve: (
|
||||||
|
path: WorkspacePath,
|
||||||
|
) => Effect.Effect<ResolvedWorkspacePath, WorkspaceFileError>
|
||||||
|
|
||||||
|
readonly read: (
|
||||||
|
path: WorkspacePath,
|
||||||
|
) => Effect.Effect<Uint8Array, WorkspaceFileError>
|
||||||
|
|
||||||
|
readonly writeIfUnchanged: (
|
||||||
|
path: WorkspacePath,
|
||||||
|
expected: Uint8Array,
|
||||||
|
content: Uint8Array,
|
||||||
|
) => Effect.Effect<void, WorkspaceFileError | StaleContentError>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`WorkspacePath` is relative, slash-separated, and cannot escape with `..`. `ResolvedWorkspacePath` returns a canonical Workspace-relative identity for permission resources and OpenCode mutation locking.
|
||||||
|
|
||||||
|
Path normalization has one implementation:
|
||||||
|
|
||||||
|
1. Reject an absolute model-facing file path.
|
||||||
|
2. Resolve the relative input against the absolute provider Location directory with `path.posix`.
|
||||||
|
3. Verify lexical containment within the Workspace root.
|
||||||
|
4. Convert the result to a normalized Workspace-root-relative `WorkspacePath`.
|
||||||
|
5. Ask the driver to resolve symlinks and return the canonical Workspace-relative identity used for permissions and mutation locking.
|
||||||
|
|
||||||
|
The driver validates lexical containment and resolves symlinks at operation time. Under the assumed provider-isolation model, a path escape remains inside the provider environment rather than reaching the OpenCode host. The milestone does not validate the provider's isolation boundary or claim race-free containment against a concurrently malicious shell process inside the same sandbox.
|
||||||
|
|
||||||
|
`writeIfUnchanged` means OpenCode-coordinated conditional replacement:
|
||||||
|
|
||||||
|
- one driver call compares expected bytes and replaces the target;
|
||||||
|
- the fake serializes this operation with other `WorkspaceFiles` mutations;
|
||||||
|
- the Vercel implementation uses one provider-side helper invocation and same-filesystem atomic replacement where available;
|
||||||
|
- unrelated shell or external provider mutations may still race because provider filesystems expose no general CAS primitive.
|
||||||
|
|
||||||
|
This matches the first milestone's single mutating Session and no-background-command scope. Do not describe it as a global filesystem transaction.
|
||||||
|
|
||||||
|
The Vercel feasibility tracer decides whether direct SDK methods are sufficient or whether all supported file operations need a versioned helper installed in the sandbox image.
|
||||||
|
|
||||||
|
## Foreground Commands Use A Narrow Contract Instead Of ChildProcessSpawner
|
||||||
|
|
||||||
|
Effect's `ChildProcessSpawner` requires stdin, additional file descriptors, `unref`, PID semantics, combined streams, and other behavior the researched provider adapters do not prove. It also cannot see `AppProcess.RunOptions.timeout`, so adapting only the spawner cannot forward provider-native deadlines.
|
||||||
|
|
||||||
|
Define a hosted foreground-command contract at the level the first tools need:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface WorkspaceProcesses {
|
||||||
|
readonly start: (
|
||||||
|
input: ForegroundCommand,
|
||||||
|
) => Effect.Effect<WorkspaceProcess, WorkspaceProcessError, Scope.Scope>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ForegroundCommand {
|
||||||
|
readonly command: string
|
||||||
|
readonly args: ReadonlyArray<string>
|
||||||
|
readonly cwd: WorkspacePath
|
||||||
|
readonly env: Readonly<Record<string, string>>
|
||||||
|
readonly timeoutMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceProcess {
|
||||||
|
readonly stdout: Stream.Stream<Uint8Array, WorkspaceProcessError>
|
||||||
|
readonly stderr: Stream.Stream<Uint8Array, WorkspaceProcessError>
|
||||||
|
readonly exit: Effect.Effect<ProcessExit, WorkspaceProcessError>
|
||||||
|
readonly terminateAndConfirm: Effect.Effect<void, UnconfirmedProcessTermination>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Hosted Bash and hosted Git-status execution use this service directly. Register a distinct hosted Bash tool implementation in the hosted-safe tool bootstrap; do not reuse the current host-assuming Bash layer. Local Bash remains on the current `AppProcess` path.
|
||||||
|
|
||||||
|
Process laws:
|
||||||
|
|
||||||
|
- a pre-interrupted call creates no provider command;
|
||||||
|
- command and argv remain distinct from shell-string lowering;
|
||||||
|
- non-zero exit is a terminal process result;
|
||||||
|
- each output channel preserves provider order;
|
||||||
|
- `exit` settles once and supports multiple waiters;
|
||||||
|
- timeout is forwarded to the provider before a central deadline is used as a backstop;
|
||||||
|
- explicit cancellation runs `terminateAndConfirm` uninterruptibly with its own bounded confirmation deadline;
|
||||||
|
- the selected driver uses the kill-and-confirm sequence proven in Phase 0, with a fresh bounded confirmation signal rather than the caller's aborted signal;
|
||||||
|
- scope finalization performs best-effort cleanup and logs failures, because Effect finalizers cannot return a typed error;
|
||||||
|
- process handles are not reconnectable after an OpenCode restart.
|
||||||
|
|
||||||
|
The first contract has no stdin, background mode, PTY, provider PID exposure, or combined-output ordering guarantee. Hosted Bash renders stdout and stderr honestly rather than inventing interleaving.
|
||||||
|
|
||||||
|
Provider start has a bounded deadline. Use an interruptible checkpoint before one uninterruptible provider-start and finalizer-registration region. Interruption before the checkpoint makes zero provider calls. Once a handle is returned, cleanup is registered exactly once. A transport failure before the provider returns a handle is an indeterminate-start error and is never retried automatically.
|
||||||
|
|
||||||
|
The process scope and `WorkspaceEnvironment` scope remain open until streams and terminal settlement finish. Follow one explicit stream shape: `Stream.unwrap` acquires the environment and process in the stream execution scope, drains required channels there, and runs process cleanup before environment release.
|
||||||
|
|
||||||
|
## Effect Scopes Own Connections, Not Provider Lifecycle
|
||||||
|
|
||||||
|
Each cached hosted Location acquires its own `WorkspaceEnvironment`. Sharing one environment across simultaneous Location directories in the same Workspace is deferred, so the first milestone does not need a second keyed `WorkspaceEnvironmentMap`.
|
||||||
|
|
||||||
|
`LocationServiceMap` already owns a scoped, cached Location layer. The hosted Location layer acquires its `WorkspaceEnvironment` directly:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
Layer.effect(
|
||||||
|
WorkspaceEnvironment.Service,
|
||||||
|
driver.acquire(binding),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The driver implements `acquire` once with scoped acquisition and best-effort release. Do not wrap a scoped `acquire` in a second `Effect.acquireRelease`, and do not add `RcMap`, `RcRef`, manual connection counters, or a second idle TTL.
|
||||||
|
|
||||||
|
The existing Location TTL therefore also retains the provider handle. This is acceptable for the tracer and must be tested with `TestClock`. Releasing it may be a no-op for stateless SDK objects. It never stops or deletes provider compute.
|
||||||
|
|
||||||
|
Introduce keyed environment sharing only when simultaneous Locations need to share a provider handle.
|
||||||
|
|
||||||
|
## Errors Describe Distinct Recovery Actions
|
||||||
|
|
||||||
|
Start with this Core boundary:
|
||||||
|
|
||||||
|
- `WorkspaceNotFound`: registration or placement is absent;
|
||||||
|
- `WorkspaceUnavailable`: binding decode, credentials, provider outage, missing resource, or acquire failure, with a sanitized category;
|
||||||
|
- `WorkspaceFileError`: hosted file operation failed;
|
||||||
|
- `WorkspaceProcessError`: hosted foreground process failed;
|
||||||
|
- `UnsupportedWorkspaceCapability`: the hosted graph intentionally does not provide an operation;
|
||||||
|
- `UnconfirmedProcessTermination`: explicit cancellation could not prove the command stopped.
|
||||||
|
|
||||||
|
Use `Schema.TaggedErrorClass` for domain errors. Preserve existing tool-facing failure shapes where callers do not need a new distinction. Do not add `WorkspaceDeleted` without a durable tombstone.
|
||||||
|
|
||||||
|
## Vercel Is Provisional Until Three Feasibility Gates Pass
|
||||||
|
|
||||||
|
Vercel is the provisional first driver because its named persistent sandbox, detached command handle, output logs, timeout, `kill`, `wait`, stop, and delete APIs appear closest to the required slice.
|
||||||
|
|
||||||
|
Before production contracts land, a disposable provider-only tracer must prove:
|
||||||
|
|
||||||
|
1. **Rooted file behavior:** read and conditional replace reject lexical and symlink escape with the chosen direct-SDK or helper design.
|
||||||
|
2. **Stable retained identity:** exact-name get reconnects to the same persistent resource without `getOrCreate` silently replacing it.
|
||||||
|
3. **Confirmed termination:** kill followed by a fresh bounded wait proves the command stopped; a sentinel command cannot mutate after cancellation reports success.
|
||||||
|
|
||||||
|
The tracer must also establish the versioned sandbox image or bootstrap containing `git`, the selected shell, and any file helper. Required tools are provisioned, not discovered opportunistically.
|
||||||
|
|
||||||
|
For retained Vercel resources, explicitly evaluate persistent mode, non-expiring snapshot policy, retained-snapshot count, billing, and storage behavior. Persist the exact provider resource name in the binding. Do not infer durable retention from SDK defaults.
|
||||||
|
|
||||||
|
If Vercel fails a gate, compare Daytona on the same gates before choosing the first driver. Do not weaken the common contract to preserve the initial choice.
|
||||||
|
|
||||||
|
## Implementation Proceeds In Mergeable Phases
|
||||||
|
|
||||||
|
Each phase contains small commits and its own acceptance criteria. A phase may span packages; a commit should not combine storage, graph wiring, a provider adapter, and end-to-end behavior.
|
||||||
|
|
||||||
|
### Phase 0: Prove Provider Feasibility Outside Production Core
|
||||||
|
|
||||||
|
Create a disposable tracer, then remove it or retain only a focused test fixture.
|
||||||
|
|
||||||
|
Commits or experiments:
|
||||||
|
|
||||||
|
1. Prove exact-name create/get/reconnect and persistence configuration.
|
||||||
|
2. Prove the rooted read/conditional-replace design.
|
||||||
|
3. Prove detached stdout/stderr plus kill-and-confirm.
|
||||||
|
4. Record image/bootstrap requirements and SDK versions.
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- all three feasibility gates pass against one provider;
|
||||||
|
- no production Core contract depends on an unproven SDK behavior;
|
||||||
|
- observed behavior and provider configuration are recorded in this document.
|
||||||
|
|
||||||
|
### Phase 1: Persist Placement And Admit Hosted Sessions Without Host I/O
|
||||||
|
|
||||||
|
Commits:
|
||||||
|
|
||||||
|
1. Add the internal Hosted Workspace schema/table and store.
|
||||||
|
2. Add the sandbox-driver binding decoder registry without SDK implementations.
|
||||||
|
3. Add internal registration for a pre-provisioned fake resource.
|
||||||
|
4. Add the hosted branch to `SessionV2.create` using `path.posix` and persisted Project metadata.
|
||||||
|
5. Add hosted metadata resolution without constructing a runnable Location graph.
|
||||||
|
|
||||||
|
Hosted registration and admission remain internal test-only operations until Phase 2 installs the runnable hosted graph in production composition. No merged production server may admit a durable Session it cannot resume.
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- registration and Session creation make zero driver acquire calls;
|
||||||
|
- a hosted Session can be recorded when the provider directory is absent from the host;
|
||||||
|
- hosted Session creation calls no host `Project.resolve`, Git, or filesystem service;
|
||||||
|
- unknown Workspace, unknown driver, malformed binding, and out-of-root Location directory fail with typed errors;
|
||||||
|
- registration rejects a V1 Workspace ID collision;
|
||||||
|
- hosted Session creation performs no mutation against the V1 `workspace` table;
|
||||||
|
- the implicit-local Session creation tests remain unchanged;
|
||||||
|
- no row is added to the V1 `workspace` table.
|
||||||
|
|
||||||
|
### Phase 2: Build A Host-Safe Read-Only Location Graph
|
||||||
|
|
||||||
|
Commits:
|
||||||
|
|
||||||
|
1. Add `WorkspaceEnvironment`, `WorkspaceFiles.read`, and the minimal fake driver.
|
||||||
|
2. Make `buildLocationServiceMap` select local or hosted graph construction.
|
||||||
|
3. Add hosted `Location`, read, and environment-fact services.
|
||||||
|
4. Split instruction production: host-global discovery reads only host-global sources, while hosted upward `AGENTS.md` discovery reads through `WorkspaceFiles`; Session instruction composition combines both.
|
||||||
|
5. Add a hosted Config projection that retains only approved host-owned model, provider, agent, permission, and built-in/global-skill inputs. It emits no plugin-discovery directories and removes plugin, MCP, command, reference, formatter, LSP, watcher, shell, and snapshot configuration.
|
||||||
|
6. Add hosted plugin/tool bootstrap that does not call the standard local `PluginInternal.list` or `PluginSupervisor` scan path.
|
||||||
|
7. Add rejecting implementations and Protocol errors for every endpoint retained by the Hosted Capability Table.
|
||||||
|
8. Materialize an explicit allowlist containing only the central model setup and hosted read tool.
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- the real Session runner completes a read-only hosted Session against the fake;
|
||||||
|
- graph boot and read operations trigger host-access tripwires zero times;
|
||||||
|
- configured global plugins, MCP servers, commands, references, and local tools are neither imported nor advertised for a hosted Location, while remaining active for implicit-local Locations;
|
||||||
|
- hosted move and `file:` attachment requests fail before host path expansion and trigger host tripwires zero times;
|
||||||
|
- capabilities follow the Hosted Capability Table;
|
||||||
|
- expected placement/acquire failures do not become defects;
|
||||||
|
- Location eviction releases the fake environment but performs no lifecycle operation;
|
||||||
|
- `TestClock` verifies the current Location TTL behavior;
|
||||||
|
- implicit-local graph tests remain unchanged.
|
||||||
|
|
||||||
|
### Phase 3: Add One Cooperative Exact-Edit Path
|
||||||
|
|
||||||
|
Commits:
|
||||||
|
|
||||||
|
1. Add `WorkspaceFiles.resolve` and `writeIfUnchanged` to the fake.
|
||||||
|
2. Add hosted mutation path and permission-resource derivation.
|
||||||
|
3. Add hosted exact-edit materialization.
|
||||||
|
4. Add deterministic stale-content and symlink tests.
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- exact edit changes only fake provider storage;
|
||||||
|
- lexical escape and operation-time symlink escape fail;
|
||||||
|
- competing `WorkspaceFiles` conditional writes produce one winner and one stale failure;
|
||||||
|
- unrelated shell races remain explicitly outside the guarantee;
|
||||||
|
- external hosted file paths are unsupported;
|
||||||
|
- local edit behavior remains unchanged.
|
||||||
|
|
||||||
|
### Phase 4: Add Foreground Bash And Git Status
|
||||||
|
|
||||||
|
Commits:
|
||||||
|
|
||||||
|
1. Add the fake `WorkspaceProcesses` implementation with deterministic barriers.
|
||||||
|
2. Add hosted Bash over the narrow process contract.
|
||||||
|
3. Add hosted authority descriptions and environment allowlisting.
|
||||||
|
4. Add request-driven `git status` fixture coverage.
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- the driver receives the requested timeout;
|
||||||
|
- stdout and stderr preserve per-channel order;
|
||||||
|
- non-zero exit settles normally;
|
||||||
|
- interruption confirms provider termination before the tool settles;
|
||||||
|
- abort before start creates no provider command;
|
||||||
|
- kill refusal returns `UnconfirmedProcessTermination` and logs one orphan diagnostic;
|
||||||
|
- a later command succeeds after timeout or interruption;
|
||||||
|
- OpenCode forwards only the explicit environment allowlist and no model or sandbox-driver credentials; provider-injected variables are recorded separately;
|
||||||
|
- `git status` observes the hosted edit;
|
||||||
|
- local Bash behavior remains unchanged.
|
||||||
|
|
||||||
|
### Phase 5: Prove The Complete Fake Session
|
||||||
|
|
||||||
|
Commits:
|
||||||
|
|
||||||
|
1. Add one end-to-end Session fixture using the real runner, permissions, tools, and durable settlement.
|
||||||
|
2. Add Location eviction and reconstruction around the same fake provider resource.
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- the concrete milestone scenario passes through exact edit and Bash;
|
||||||
|
- each tool call in the fixture produces one terminal durable settlement;
|
||||||
|
- interrupted process cleanup completes before environment release;
|
||||||
|
- rebuilding the Location graph reconnects to the same fake resource;
|
||||||
|
- this phase claims cache/graph reconstruction, not a process restart, because fake state is in memory.
|
||||||
|
|
||||||
|
### Phase 6: Add The First Real Sandbox Driver
|
||||||
|
|
||||||
|
Commits:
|
||||||
|
|
||||||
|
1. Add a lazy driver-loader thunk keyed by the selected sandbox driver.
|
||||||
|
2. Add the provider binding schema and acquire-only adapter.
|
||||||
|
3. Add the rooted file implementation chosen in Phase 0.
|
||||||
|
4. Add foreground process streaming, timeout, and kill confirmation.
|
||||||
|
5. Provide the selected driver registry from Server composition without loading its SDK on the local-only path.
|
||||||
|
6. Register a pre-provisioned real-provider binding through the internal registration path.
|
||||||
|
7. Add credential-gated live contract tests.
|
||||||
|
8. Add a true second-process restart test that reconstructs placement from SQLite.
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- the complete milestone scenario passes against the real provider;
|
||||||
|
- a second OpenCode process reconnects to the same resource and reads the edit;
|
||||||
|
- reconnect returns the same recorded provider identity and `acquire` never invokes create or get-or-create;
|
||||||
|
- interruption is confirmed with a post-cancel sentinel test;
|
||||||
|
- OpenCode forwards no model credentials, sandbox-driver credentials, or ambient host environment variables; provider-injected variables are recorded separately;
|
||||||
|
- implicit-local startup does not evaluate the provider SDK module;
|
||||||
|
- required live tests run in hosted-feature release validation, not only developer machines.
|
||||||
|
|
||||||
|
Result: the first hosted execution milestone is complete.
|
||||||
|
|
||||||
|
## Lifecycle Requires A Separate Reviewed Plan
|
||||||
|
|
||||||
|
Lifecycle is not part of the execution tracer. Effect `LayerMap.invalidate` does not wait for active references and is not a lifecycle fence.
|
||||||
|
|
||||||
|
A follow-up plan must design one shared gate for Session creation, Location lease admission, deactivate, reactivate, and delete; durable transition and retry rules; and explicit behavior for historical Sessions after deletion. Do not expose lifecycle publicly until that gate, authorization, Protocol errors, Server handlers, and generated clients are reviewed together.
|
||||||
|
|
||||||
|
## Broader Tools And A Second Driver Follow The Tracer
|
||||||
|
|
||||||
|
After the first real hosted Session works, broaden the hosted graph in this order:
|
||||||
|
|
||||||
|
1. apply-patch over the established mutation contract;
|
||||||
|
2. provider-provisioned `rg` and `find`, then glob and grep;
|
||||||
|
3. data-only project configuration with an explicit field allowlist;
|
||||||
|
4. project skills;
|
||||||
|
5. stdin and the required subset of `Git.Service`;
|
||||||
|
6. a second driver selected to pressure-test the established contract.
|
||||||
|
|
||||||
|
Daytona is the likely second driver when Vercel passes Phase 0. It would pressure-test exact resource reconnect, stop/start retention, whole-second timeout rounding, command-session termination confirmation, and current SDK behavior. If Daytona becomes the first driver, select a different second driver with meaningfully different lifecycle or process semantics. Change the common contract only for a concrete difference OpenCode must expose.
|
||||||
|
|
||||||
|
## Package Ownership Preserves Dependency Direction
|
||||||
|
|
||||||
|
- `packages/schema`: existing provider-neutral IDs and any browser-safe public Workspace metadata.
|
||||||
|
- `packages/core`: Hosted Workspace domain, placement store, sandbox-driver interface and registry service tag, hosted Location policies, fake driver, and tests.
|
||||||
|
- `packages/server`: provider SDK adapters, provider credentials/configuration, lazy driver registration, and future lifecycle HTTP handlers.
|
||||||
|
- `packages/protocol`: sanitized public errors and Workspace endpoints only when a product API is added.
|
||||||
|
- `packages/client`: generated output only.
|
||||||
|
|
||||||
|
Core never imports a provider SDK or Server implementation. Server provides registered drivers to Core composition. Client runtime code does not depend on Core or Server.
|
||||||
|
|
||||||
|
## Verification Runs From Package Directories
|
||||||
|
|
||||||
|
Expected validation as implementation lands:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd packages/core
|
||||||
|
bun run test -- workspace
|
||||||
|
bun typecheck
|
||||||
|
|
||||||
|
cd ../server
|
||||||
|
bun run test -- workspace
|
||||||
|
bun typecheck
|
||||||
|
|
||||||
|
cd ../schema
|
||||||
|
bun typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
If public Protocol or Server `HttpApi` changes, run `bun run generate` from `packages/client` and inspect generated client diffs. Do not edit generated clients directly.
|
||||||
|
|
||||||
|
## Deferred Work Does Not Expand The First Milestone
|
||||||
|
|
||||||
|
- public Workspace provisioning and lifecycle APIs;
|
||||||
|
- replacement bindings and retired-resource reconciliation;
|
||||||
|
- force-delete and historical Session UX;
|
||||||
|
- shared environments across simultaneous Locations;
|
||||||
|
- concurrent mutating Sessions;
|
||||||
|
- background or reconnectable commands;
|
||||||
|
- stdin and full `ChildProcessSpawner` parity;
|
||||||
|
- PTY and LSP;
|
||||||
|
- filesystem watches;
|
||||||
|
- snapshots, revert, forks, and project copies;
|
||||||
|
- full Git mutation and worktree support;
|
||||||
|
- provider migration and file transfer;
|
||||||
|
- project plugins and unrestricted application tools;
|
||||||
|
- preview ports;
|
||||||
|
- clustered leases and Session execution;
|
||||||
|
- a public sandbox-driver plugin API;
|
||||||
|
- a general capability negotiation framework;
|
||||||
|
- a resident worker inside the sandbox.
|
||||||
|
|
||||||
|
## Research Basis
|
||||||
|
|
||||||
|
The plan is based on current source review of OpenCode V2, Effect v4, Flue `v2.0.3`, pi `v0.83.0`, `@sandbox-sdk/core`, OpenAI Agents SDK sandbox sessions, and Vercel Open Agents. Provider-specific claims remain Phase 0 feasibility gates rather than settled facts.
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
# Workspaces
|
||||||
|
|
||||||
|
Status: proposal
|
||||||
|
|
||||||
|
A **Workspace** is a durable place a Session executes: a filesystem root plus processes. Hosted execution (Modal, Vercel, ...) becomes a kind of Workspace. The Session model does not change.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- **Workspace is the noun; sandbox is a kind.** `Location.workspaceID` names a Workspace; omitted still means implicit local, unchanged.
|
||||||
|
- **A Workspace is an empty environment.** No repository, project, or name at creation. Cloning happens later, inside a Session. _A Workspace may contain a Project; a Workspace is not a Project._
|
||||||
|
- **Creation is eager.** `create` resolves when the environment is usable. No pending states, no lazy attachment, no detached Sessions.
|
||||||
|
- **Providers are pluggable drivers** behind a three-verb seam, selected by provider string.
|
||||||
|
|
||||||
|
## Public API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const workspace = await workspaces.create({ provider: "modal" })
|
||||||
|
|
||||||
|
const session = await sessions.create({
|
||||||
|
location: { workspaceID: workspace.id, directory: workspace.root },
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Consumers persist the Workspace ID as their durable handle and re-derive everything else:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const workspace = await workspaces.get(id) // { id, provider, root } — table read, never contacts a provider
|
||||||
|
```
|
||||||
|
|
||||||
|
- `provider` is required for now. A config default (`workspace.provider`) can be added later without breaking anything; skipping it keeps config untouched.
|
||||||
|
- `root` is an absolute POSIX path in the provider filesystem.
|
||||||
|
- `get` fails with typed `WorkspaceNotFound` — also the existence check. No `getOrCreate`: the consumer owns its "which Workspace is mine" mapping, and provider-level get-or-create can silently replace resources (Vercel tracer finding).
|
||||||
|
|
||||||
|
## Domain Model
|
||||||
|
|
||||||
|
| Concept | What it is | Visibility |
|
||||||
|
| -------------------- | -------------------------------------------------------------- | -------------------------------- |
|
||||||
|
| Workspace | Durable execution environment: `id` + `root` | Public |
|
||||||
|
| Sandbox | The hosted kind of Workspace | Vocabulary only, not an API noun |
|
||||||
|
| Binding | Smallest provider-owned JSON to reconnect to the same resource | Internal, stored opaquely |
|
||||||
|
| WorkspaceEnvironment | Scoped live connection: files + processes at the root | Internal seam |
|
||||||
|
| Project | Repository identity discovered _within_ a Location | Public, becomes optional |
|
||||||
|
|
||||||
|
Binding is all that earlier drafts called "placement" — a column, not a concept. Provider resource replacement (Modal snapshot → new sandbox) is the same Workspace with an updated binding.
|
||||||
|
|
||||||
|
## Driver Seam
|
||||||
|
|
||||||
|
```typescript title="packages/core/src/workspace/driver.ts"
|
||||||
|
export * as WorkspaceDriver from "./driver"
|
||||||
|
|
||||||
|
export const Binding = Schema.Record(Schema.String, Schema.Json)
|
||||||
|
export type Binding = typeof Binding.Type
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
// allocate; resolve only when ready to use
|
||||||
|
readonly create: (input: {
|
||||||
|
readonly workspaceID: Workspace.ID
|
||||||
|
}) => Effect.Effect<{ binding: Binding; root: string }, Error>
|
||||||
|
|
||||||
|
// binding -> live capabilities; the ONLY way to obtain an environment
|
||||||
|
readonly connect: (binding: Binding) => Effect.Effect<WorkspaceEnvironment.Interface, Error, Scope.Scope>
|
||||||
|
|
||||||
|
// permanently release provider resources
|
||||||
|
readonly destroy: (binding: Binding) => Effect.Effect<void, Error>
|
||||||
|
}
|
||||||
|
|
||||||
|
// one error shape for all three verbs; ProviderNotFoundError stays separate
|
||||||
|
export class Error extends Schema.TaggedErrorClass<Error>()("WorkspaceDriver.Error", {
|
||||||
|
provider: Schema.String,
|
||||||
|
message: Schema.optional(Schema.String),
|
||||||
|
cause: Schema.optional(Schema.Defect()),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
|
||||||
|
"WorkspaceDriver.ProviderNotFoundError",
|
||||||
|
{ provider: Schema.String },
|
||||||
|
) {}
|
||||||
|
|
||||||
|
export interface Registry {
|
||||||
|
readonly get: (provider: string) => Effect.Effect<Interface, ProviderNotFoundError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RegistryService extends Context.Service<RegistryService, Registry>()(
|
||||||
|
"@opencode/WorkspaceDriverRegistry",
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Fresh-create and restart-reconnect both flow through `connect` — where the prior tracers found their bugs.
|
||||||
|
- `connect` is scoped: environment lifetime = the acquiring scope (the existing cached Location graph). Scope closure drops the connection, never the provider resource. No `close` verb.
|
||||||
|
- Errors are `Schema.TaggedErrorClass` values.
|
||||||
|
|
||||||
|
## Defining A Driver
|
||||||
|
|
||||||
|
A driver is a plain value built in `packages/server`. Its binding schema is driver-private — opaque JSON becomes typed again at this boundary:
|
||||||
|
|
||||||
|
```typescript title="packages/server/src/workspace/modal.ts"
|
||||||
|
export * as ModalDriver from "./modal"
|
||||||
|
|
||||||
|
const ModalBinding = Schema.Struct({ sandboxId: Schema.String })
|
||||||
|
|
||||||
|
const ROOT = "/workspace"
|
||||||
|
|
||||||
|
export const make = Effect.gen(function* () {
|
||||||
|
const app = yield* Effect.promise(() => App.lookup("opencode-workspaces", { createIfMissing: true }))
|
||||||
|
// git, bash, rg provisioned in the image — never discovered opportunistically
|
||||||
|
const image = Image.fromRegistry("ghcr.io/anomalyco/opencode-workspace:1")
|
||||||
|
|
||||||
|
const decode = (binding: WorkspaceDriver.Binding) =>
|
||||||
|
Schema.decodeUnknownEffect(ModalBinding)(binding).pipe(
|
||||||
|
Effect.mapError((cause) => new WorkspaceDriver.Error({ provider: "modal", cause })),
|
||||||
|
)
|
||||||
|
|
||||||
|
return WorkspaceDriver.make({
|
||||||
|
create: ({ workspaceID }) =>
|
||||||
|
Effect.promise(() => Sandbox.create(app, { image, name: workspaceID })).pipe(
|
||||||
|
Effect.map((sandbox) => ({ binding: { sandboxId: sandbox.sandboxId }, root: ROOT })),
|
||||||
|
),
|
||||||
|
|
||||||
|
connect: Effect.fnUntraced(function* (binding) {
|
||||||
|
const decoded = yield* decode(binding)
|
||||||
|
const sandbox = yield* Effect.promise(() => Sandbox.fromId(decoded.sandboxId))
|
||||||
|
return WorkspaceEnvironment.make({
|
||||||
|
platform: "linux",
|
||||||
|
directory: ROOT,
|
||||||
|
files: modalFiles(sandbox), // Files over the sandbox filesystem API
|
||||||
|
process: modalSpawner(sandbox), // ChildProcessSpawner over sandbox.exec
|
||||||
|
shell: WorkspaceEnvironment.linuxShell,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
destroy: (binding) =>
|
||||||
|
decode(binding).pipe(
|
||||||
|
Effect.flatMap((decoded) =>
|
||||||
|
Effect.promise(async () => {
|
||||||
|
const sandbox = await Sandbox.fromId(decoded.sandboxId)
|
||||||
|
await sandbox.terminate()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Registering Drivers
|
||||||
|
|
||||||
|
Ordinary Server composition. The registry is an immutable map fixed at boot:
|
||||||
|
|
||||||
|
```typescript title="packages/server/src/workspace/drivers.ts"
|
||||||
|
export * as ServerWorkspaceDrivers from "./drivers"
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
WorkspaceDriver.RegistryService,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const drivers = {
|
||||||
|
modal: yield* ModalDriver.make,
|
||||||
|
vercel: yield* VercelDriver.make,
|
||||||
|
}
|
||||||
|
return WorkspaceDriver.RegistryService.of({
|
||||||
|
get: (provider) =>
|
||||||
|
drivers[provider]
|
||||||
|
? Effect.succeed(drivers[provider])
|
||||||
|
: Effect.fail(new WorkspaceDriver.ProviderNotFoundError({ provider })),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const node = makeGlobalNode({ service: WorkspaceDriver.RegistryService, layer, deps: [] })
|
||||||
|
```
|
||||||
|
|
||||||
|
Core consumes it blindly:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// workspaces.create
|
||||||
|
const driver = yield * registry.get(input.provider)
|
||||||
|
const created = yield * driver.create({ workspaceID: id })
|
||||||
|
yield * store.insert({ id, provider: input.provider, binding: created.binding, root: created.root })
|
||||||
|
|
||||||
|
// hosted Location graph construction (inside the existing scoped cache)
|
||||||
|
const workspace = yield * store.get(location.workspaceID)
|
||||||
|
const driver = yield * registry.get(workspace.provider)
|
||||||
|
const env = yield * driver.connect(workspace.binding)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Core defines the key and consumes; Server defines drivers and provides the layer; core never sees a provider SDK.
|
||||||
|
- Drivers ship in-tree for now. Plugin-contributed drivers later change only how the map is built; `Registry.get` and consumers are untouched.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
Reuses the seam proven on `origin/remote-workspaces-plan` (`fd92aeac66`) — a local implementation exists and the Location graph composes over it:
|
||||||
|
|
||||||
|
```typescript title="packages/core/src/workspace/environment.ts"
|
||||||
|
export * as WorkspaceEnvironment from "./environment"
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly directory: string // the Workspace root, absolute in the provider filesystem
|
||||||
|
readonly files: Files // stat / realPath / read / list / write / remove — one provider round trip each
|
||||||
|
readonly process: ChildProcessSpawner["Service"]
|
||||||
|
readonly shell: Shell // executable + args lowering for the bash tool; linuxShell default
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `files` is separate from `process`: providers expose direct filesystem APIs far faster than shelling out `cat`, and read/write/edit are the hottest ops.
|
||||||
|
- `Files` grows only when a domain service needs the verb. Type mismatches (read a directory, list a file) fail from the op itself — no stat pre-checks on the happy path.
|
||||||
|
- Core builds tools (bash, read, edit, glob, grep) on top. Drivers never know what a tool is.
|
||||||
|
- The branch's `ripgrep` field is dropped — tool implementation detail leaking into the seam. The image contract mandates `rg`; the hosted graph provides the existing `RipgrepBinary.Service` as `Effect.succeed("rg")`.
|
||||||
|
|
||||||
|
## Target Architecture
|
||||||
|
|
||||||
|
**Hosting is decided when the Location graph compiles, never when a tool executes.** A tool cannot name `WorkspaceEnvironment`, `FSUtil`, or any host path API; if a tool can observe where it runs, the layering is broken. Everything model-facing sits on four Location-scoped domain services, and only their implementations know about machines:
|
||||||
|
|
||||||
|
```
|
||||||
|
TOOLS (hosting-blind policy + orchestration)
|
||||||
|
edit write patch shell-tool read-tool
|
||||||
|
│ │ │ │ │
|
||||||
|
▼ ▼ ▼ ▼ ▼
|
||||||
|
┌───────────────────────────────────────────────────────────────┐
|
||||||
|
│ DOMAIN SERVICES — one implementation each │
|
||||||
|
│ │
|
||||||
|
│ LocationMutation.resolve path → Target (lexical path, │
|
||||||
|
│ canonical referent, │
|
||||||
|
│ permission resources, │
|
||||||
|
│ external-directory approvals) │
|
||||||
|
│ FileMutation read / write / remove — locks, │
|
||||||
|
│ BOM, formatting, typed errors │
|
||||||
|
│ FileSystem read-model queries │
|
||||||
|
│ Shell spawn + lifecycle (owns cwd │
|
||||||
|
│ validation per backend) │
|
||||||
|
└───────────────────────────┬───────────────────────────────────┘
|
||||||
|
│ consumes only
|
||||||
|
▼
|
||||||
|
┌───────────────────────────────────────────────────────────────┐
|
||||||
|
│ WorkspaceEnvironment — THE seam (one per graph) │
|
||||||
|
│ directory ∙ files ∙ process ∙ shell │
|
||||||
|
└──────────────┬──────────────────────────────┬─────────────────┘
|
||||||
|
│ local graph │ hosted graph
|
||||||
|
▼ ▼
|
||||||
|
host adapter driver.connect(binding)
|
||||||
|
(the only home of (Modal, fake, …)
|
||||||
|
FSUtil / host spawn)
|
||||||
|
```
|
||||||
|
|
||||||
|
The four domain services own all cross-cutting policy exactly once:
|
||||||
|
|
||||||
|
- **`LocationMutation.resolve(path, kind?)`** — path → `Target { absolute, canonical, resource, externalDirectory? }`. `absolute` is the normalized lexical path the model named; permissions and entry operations use it. `canonical` is the resolved referent used for reads, writes, and locking. Thus reads/writes follow a symlink while remove unlinks the symlink itself, and permission resources always describe the named path. Path semantics (posix in providers, win32 locally), containment, and permission-resource derivation live here; tools pass targets around without re-deriving anything.
|
||||||
|
- **`FileMutation`** — the read–transform–write seam:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
read(target) → string // BOM stripped inside the seam
|
||||||
|
writeTextPreservingBom({ target, content })
|
||||||
|
→ { existed, content } // lock ∙ BOM rejoin ∙ format ∙ final text
|
||||||
|
write({ target, content }) → { existed } // raw bytes, no formatting
|
||||||
|
remove(target) → void
|
||||||
|
// errors: FileMutation.NotFoundError | FileMutation.NotAFileError | filesystem error
|
||||||
|
```
|
||||||
|
|
||||||
|
BOM handling, per-target locking, formatter execution, and error classification are implementation details here. Tools never import `Bom`, never call `Formatter`, never see a backend error vocabulary.
|
||||||
|
|
||||||
|
- **`FileSystem`** — read-model queries for the read tool and file browsing.
|
||||||
|
- **`Shell`** — command execution; each backend validates its own cwd and surfaces one error shape.
|
||||||
|
|
||||||
|
Every model-facing tool then reads as pure policy over these:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// EditTool.execute — identical local and hosted
|
||||||
|
const target = yield * mutation.resolve({ path: input.path, kind: "file" })
|
||||||
|
const original = yield * files.read(target) // NotFoundError → "File not found"
|
||||||
|
const replaced = replace(original, input.oldString, input.newString) // pure
|
||||||
|
yield * permission.assert({ action: "edit", resources: [target.resource], diff })
|
||||||
|
const result = yield * files.writeTextPreservingBom({ target, content: replaced })
|
||||||
|
return { files: [fileDiff(target.resource, original, result.content)] }
|
||||||
|
|
||||||
|
// PatchTool.execute — identical local and hosted
|
||||||
|
const hunks = Patch.parse(input.patchText) // pure
|
||||||
|
for (const hunk of hunks) {
|
||||||
|
const target = yield * mutation.resolve({ path: hunk.path })
|
||||||
|
const original = yield * files.read(target) // updates and deletes
|
||||||
|
prepared.push(derive(hunk, target, original)) // pure
|
||||||
|
}
|
||||||
|
yield * permission.assert({ action: "edit", resources, diffs })
|
||||||
|
for (const change of prepared) {
|
||||||
|
// add/update → files.writeTextPreservingBom, delete/move → files.remove
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Local vs hosted is then a compilation difference, not a code difference:
|
||||||
|
|
||||||
|
| Layer | Local graph | Hosted graph |
|
||||||
|
| -------------------- | -------------------------------- | ---------------------------------------------------------------- |
|
||||||
|
| Tools | identical bytes | identical bytes |
|
||||||
|
| Domain services | identical bytes | identical bytes |
|
||||||
|
| WorkspaceEnvironment | host adapter (fs + spawn) | `driver.connect(binding)` |
|
||||||
|
| Formatting | runs (host binaries, host files) | inert until an environment formatter runtime exists |
|
||||||
|
| Catalog | full | minus tools without environment-backed execution yet (glob/grep) |
|
||||||
|
|
||||||
|
### Staging
|
||||||
|
|
||||||
|
1. **Now:** tools move onto the domain services; each service carries a local and a hosted node. Plugins become provably hosting-blind — the plugin context stops carrying any environment or host filesystem capability.
|
||||||
|
2. **Follow-up ("local is an environment"):** bind `WorkspaceEnvironment` in every graph — local binds a host adapter — and collapse each domain service's hosted/local node pair into one implementation over `env.files`/`env.process`. `FSUtil` remains only below the seam and in genuinely host-domain services (global config, credentials, npm). The hosted branch of graph construction shrinks to swapping `Location`, `Config`/`InstructionDiscovery`, and `WorkspaceEnvironment`.
|
||||||
|
|
||||||
|
### Prior Art Convergence
|
||||||
|
|
||||||
|
Surveyed grok-build (xAI), pi-mono, and flue (2026-08): all three converged on this exact seam — one byte-level filesystem-plus-exec capability that tools never bypass (grok's `AsyncFileSystem` resource with an ACP-remote adapter, pi-mono's `ExecutionEnv` with typed backend-independent `FileError` codes, flue's `Sandbox` where local is just another adapter). Flue ships stage 2 outright: zero mode branching in tools, local as an adapter file. None found a smaller shape; the simpler-looking tool layers achieve it by descoping policy we deliberately keep (flue and grok-build skip BOM handling and share the resulting byte-0 match bug; flue propagates raw `ENOENT` to the model; none run formatters or in-tool permission prompts). pi-mono's per-path mutation queue and flue's `withFileMutationLock` are this design's `FileMutation` locking, independently reinvented.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
One V2-owned table; no interaction with the V1 `workspace` table. Metadata reads never contact a provider.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const table = sqliteTable("workspace", {
|
||||||
|
id: text().primaryKey(), // Workspace.ID
|
||||||
|
provider: text().notNull(), // driver registry key
|
||||||
|
binding: text({ mode: "json" }).notNull(), // opaque driver-owned JSON
|
||||||
|
root: text().notNull(), // absolute POSIX root in provider filesystem
|
||||||
|
time_created: integer().notNull(),
|
||||||
|
time_updated: integer().notNull(),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Changes
|
||||||
|
|
||||||
|
1. **Session admission** — `workspaceID` present skips host `Project.resolve`; directory validated with `path.posix` containment in the root. `session.project_id` becomes optional: an empty Workspace has no honest Project.
|
||||||
|
2. **Location graph** — `LocationServiceMap` selects local or hosted construction; hosted acquires via `driver.connect(binding)` inside the existing scoped cache.
|
||||||
|
3. **Tool catalog** — hosted Locations advertise only environment-backed tools. Nothing may fall back to host authority.
|
||||||
|
|
||||||
|
| In an empty Workspace | |
|
||||||
|
| --------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Available | read/write/edit, bash, glob/grep, global config/agents/instructions, models, integrations |
|
||||||
|
| Needs a Project | git status/diffs, snapshots/revert, project instructions/config/skills/plugins, repo-scoped permissions |
|
||||||
|
|
||||||
|
### Project Identity For Hosted Sessions
|
||||||
|
|
||||||
|
- Admission consults **stored facts only** — never live discovery, never a driver. A stopped sandbox must not block `sessions.create`.
|
||||||
|
- An empty Workspace has no repository, so hosted Sessions reuse `Project.ID.global` — the same degenerate Project local non-VCS directories already get. This is "not discovered yet," not a new concept.
|
||||||
|
- Project identity is machine-independent by design (`Hash.fast("git-remote:" + origin)`, root-commit fallback). Once the rediscover slice runs discovery _inside_ the Workspace, a hosted clone of a repo joins the same Project as local checkouts automatically.
|
||||||
|
- The rediscover slice adds nullable `workspace.project_id`, stamped when discovery finds a repo; admission then reads `workspace.project_id ?? global`. Not added now — nothing writes it in this slice.
|
||||||
|
|
||||||
|
## First Milestone
|
||||||
|
|
||||||
|
1. **Fake driver, real runner.** `create()` → Session at root → write file → run command → evict + rebuild Location graph → reconnect → file still there. Local paths byte-identical throughout.
|
||||||
|
2. **First real driver.** Vercel provisional, Modal fallback — decided by the feasibility gates in `remote-workspace-execution.md`. Credential-gated live tests; second-process restart test reconstructing the binding from SQLite.
|
||||||
|
|
||||||
|
Next slice (not this one): clone-a-repo-during-a-Session, which needs an explicit "rediscover Location context" operation (Project detection, config rebuild, instruction-epoch refresh).
|
||||||
|
|
||||||
|
Deferred: lazy attachment / detached Sessions, stop/resume + TTL policy, multi-Session Workspaces, PTY / LSP / watchers / snapshots, plugin driver API, preview ports.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Does `ChildProcessSpawner`'s full surface (stdin, extra fds, `unref`, PIDs) map honestly to provider process APIs? The superseded plan proposed a narrower foreground contract; resolve against the first real driver.
|
||||||
|
- `session.project_id` nullability migration and Project-requiring read models.
|
||||||
|
- Where `workspaces.create` surfaces first: SDK/HTTP only; TUI/web later.
|
||||||
|
|
||||||
|
## Prior Art
|
||||||
|
|
||||||
|
`origin/remote-workspaces-plan`: `09903e120f` (plan + live Vercel tracer), `fd92aeac66` (environment seam + local impl), `d1b9b6c9ce` (live Modal tracer: reconnect, snapshot, restore), `650d5a5e92` (lifecycle). Both tracers already worked repository-free; only that branch's outer Workspace API carried Project assumptions — dropped here.
|
||||||
|
|
||||||
|
`specs/v2/remote-workspace-execution.md` is superseded for domain model and API, retained for execution research: feasibility gates, process laws, host-authority tripwires, phase acceptance criteria.
|
||||||
Reference in New Issue
Block a user