Compare commits

..

47 Commits

Author SHA1 Message Date
Kit Langton d7ecb3af37 refactor(core): promote slash helper to FSUtil 2026-08-06 17:11:31 -04:00
Kit Langton 13a415b68a refactor(core): own environment error wrapping in WorkspaceEnvironment 2026-08-06 17:09:46 -04:00
Kit Langton dee3f88737 refactor(core): dedupe shell cwd checks and search plumbing 2026-08-06 17:08:30 -04:00
Kit Langton 39dab7199f refactor(core): share FileSystem entry operations across backends 2026-08-06 17:03:56 -04:00
Kit Langton 4729a3f9ae refactor(core): share LocationMutation ancestor walk 2026-08-06 17:00:56 -04:00
Kit Langton 9f05331ac4 refactor(core): share FileMutation orchestration via backend seam 2026-08-06 16:58:56 -04:00
Kit Langton 0b3732aa7c refactor(core): unify FileMutation write and add move 2026-08-06 16:41:33 -04:00
Kit Langton 248bfb64d4 feat(core): enable hosted search tools 2026-08-06 16:20:47 -04:00
Kit Langton cbec0989e0 Merge branch 'v2' into workspace-plan 2026-08-06 15:12:18 -04:00
Kit Langton 9d284040de fix(core): tighten workspace mutation boundaries 2026-08-06 12:43:49 -04:00
Kit Langton 4e03ad95a7 chore: regenerate workspace lockfile 2026-08-06 12:05:39 -04:00
Kit Langton 8d70adab50 chore(app): drop unrelated branch changes 2026-08-06 12:04:29 -04:00
Kit Langton 8b8e2d97dc Merge remote-tracking branch 'origin/v2' into workspace-plan 2026-08-06 12:04:19 -04:00
Kit Langton 716258d97a fix(core): preserve mutation boundary semantics 2026-08-06 11:57:46 -04:00
Kit Langton d6ab618155 refactor(core): make tools hosting-blind 2026-08-06 11:45:50 -04:00
Kit Langton 1fa3d9dd12 docs: target architecture for hosting-blind tools 2026-08-06 11:23:32 -04:00
Kit Langton 92442bb7ff chore: merge origin/v2 2026-08-06 11:12:37 -04:00
Kit Langton 64c82bedec chore: format workspace files 2026-08-05 22:40:59 -04:00
Kit Langton 6cbc71a2eb feat(core): hosted apply_patch through the workspace environment 2026-08-05 22:38:35 -04:00
Kit Langton 756ac88039 test(core): scripted-model session e2e on hosted locations 2026-08-05 22:27:56 -04:00
Kit Langton a4eeaefd5b fix(core): stat shell workdir through the workspace environment 2026-08-05 22:27:55 -04:00
Kit Langton 6e0066247c fix(server): route hosted sessions through workspace location 2026-08-05 21:48:28 -04:00
Kit Langton 65ac161373 test(server): run hosted location graph live on modal 2026-08-05 21:43:07 -04:00
Kit Langton 9c0883054b feat(server): add modal workspace driver 2026-08-05 21:39:09 -04:00
Kit Langton 79eed3d3a0 refactor(core): apply simplify review to hosted workspace slice 2026-08-05 21:29:19 -04:00
Kit Langton be74fad688 test(core): prove hosted workspace end to end with directory-backed fake driver 2026-08-05 21:15:16 -04:00
Kit Langton 5d93a35a1d feat(core): exclude host-search tools from hosted locations 2026-08-05 21:08:26 -04:00
Kit Langton 395eb8a9c8 feat(core): run hosted shell commands through workspace environment 2026-08-05 21:06:48 -04:00
Kit Langton 14e7b547c5 feat(core): route hosted mutation resolution and writes through workspace environment 2026-08-05 21:03:24 -04:00
Kit Langton 97a5497664 feat(core): serve hosted filesystem reads through workspace environment 2026-08-05 20:58:39 -04:00
Kit Langton e544b8927b feat(core): branch hosted location graph construction 2026-08-05 20:42:29 -04:00
Kit Langton 44481e1ecc docs: record hosted project identity decisions 2026-08-05 20:16:26 -04:00
Kit Langton 5168652082 feat(core): admit hosted sessions without host project discovery 2026-08-05 19:33:27 -04:00
Kit Langton 977a35c222 refactor(core): use struct for workspace info 2026-08-05 19:28:57 -04:00
Kit Langton 5d64dc8bfd feat(core): add workspace domain service 2026-08-05 19:25:06 -04:00
Kit Langton 479821e621 feat(core): add workspace driver seam, environment contract, and table 2026-08-05 19:23:09 -04:00
Kit Langton 5ce6626a44 Merge remote-tracking branch 'origin/remove-old-workspaces' into workspace-plan 2026-08-05 19:06:22 -04:00
Kit Langton eef60bcd64 docs: add workspaces.get metadata read 2026-08-05 18:55:54 -04:00
Kit Langton f840b3e3a2 fix: complete legacy workspace removal 2026-08-05 18:52:44 -04:00
Kit Langton b1abfd4bd9 docs: require explicit workspace provider 2026-08-05 18:51:40 -04:00
Kit Langton 71251dd246 docs: tighten workspaces proposal to code-first form 2026-08-05 18:35:12 -04:00
Kit Langton 8461226ccd docs: add driver registration example and drop ripgrep seam field 2026-08-05 18:35:12 -04:00
Kit Langton e1b863dcb3 docs: name environment files interface and explain ripgrep seam 2026-08-05 18:35:12 -04:00
Kit Langton 56405af7d6 docs: propose workspace domain model and driver seam 2026-08-05 18:35:11 -04:00
Kit Langton 05c3866788 fix(cli): remove legacy workspace request fields 2026-08-05 18:33:39 -04:00
Kit Langton 3eca11a7da chore(client): regenerate workspace-free API 2026-08-05 18:32:05 -04:00
Kit Langton 794283057a refactor: remove legacy workspace control plane 2026-08-05 18:29:26 -04:00
131 changed files with 9713 additions and 4592 deletions
+40
View File
@@ -678,8 +678,10 @@
"@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"modal": "0.9.0",
},
"devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "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=="],
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA=="],
"@cbor-extract/cbor-extract-darwin-x64": ["@cbor-extract/cbor-extract-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q=="],
"@cbor-extract/cbor-extract-linux-arm": ["@cbor-extract/cbor-extract-linux-arm@2.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ=="],
"@cbor-extract/cbor-extract-linux-arm64": ["@cbor-extract/cbor-extract-linux-arm64@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg=="],
"@cbor-extract/cbor-extract-linux-x64": ["@cbor-extract/cbor-extract-linux-x64@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA=="],
"@cbor-extract/cbor-extract-win32-x64": ["@cbor-extract/cbor-extract-win32-x64@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA=="],
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
"@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="],
@@ -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=="],
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
"@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="],
@@ -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=="],
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
"@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
"@jsx-email/all": ["@jsx-email/all@2.2.3", "", { "dependencies": { "@jsx-email/body": "1.0.2", "@jsx-email/button": "1.0.4", "@jsx-email/column": "1.0.3", "@jsx-email/container": "1.0.2", "@jsx-email/font": "1.0.3", "@jsx-email/head": "1.0.2", "@jsx-email/heading": "1.0.2", "@jsx-email/hr": "1.0.2", "@jsx-email/html": "1.0.2", "@jsx-email/img": "1.0.2", "@jsx-email/link": "1.0.2", "@jsx-email/markdown": "2.0.4", "@jsx-email/preview": "1.0.2", "@jsx-email/render": "1.1.1", "@jsx-email/row": "1.0.2", "@jsx-email/section": "1.0.2", "@jsx-email/tailwind": "2.4.4", "@jsx-email/text": "1.0.2" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-OBvLe/hVSQc0LlMSTJnkjFoqs3bmxcC4zpy/5pT5agPCSKMvAKQjzmsc2xJ2wO73jSpRV1K/g38GmvdCfrhSoQ=="],
@@ -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-x": ["abort-controller-x@0.5.0", "", {}, "sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@@ -3537,6 +3559,10 @@
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
"cbor-extract": ["cbor-extract@2.2.2", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", "@cbor-extract/cbor-extract-linux-arm": "2.2.2", "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", "@cbor-extract/cbor-extract-linux-x64": "2.2.2", "@cbor-extract/cbor-extract-win32-x64": "2.2.2" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng=="],
"cbor-x": ["cbor-x@1.6.5", "", { "optionalDependencies": { "cbor-extract": "^2.2.2" } }, "sha512-yO64CxnSh6kp+pHNRK9IfwnMvCB+c8HvmUjQY/9l9YRF0/cAPka/tUHLwS64QqUpFCq3/OtbKziVJYXH2EaRig=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
@@ -4569,6 +4595,8 @@
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
@@ -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=="],
"modal": ["modal@0.9.0", "", { "dependencies": { "cbor-x": "^1.6.0", "long": "^5.3.1", "nice-grpc": "^2.1.12", "protobufjs": "^7.5.0", "smol-toml": "^1.3.3", "uuid": "^11.1.0" } }, "sha512-kCXcdJkhbJorf/q/6T9Wdlg6in9JmRnCNQnV6rVBMyeqNV/iXI6BYk4IzY4cvZ6dbauNeDMjk/Q08cbxvoIaXg=="],
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
@@ -4833,6 +4863,10 @@
"nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="],
"nice-grpc": ["nice-grpc@2.1.16", "", { "dependencies": { "@grpc/grpc-js": "^1.14.0", "abort-controller-x": "^0.5.0", "nice-grpc-common": "^2.0.3" } }, "sha512-Cl3Pn00212Hl8/U6bpgMxmhZj5lyv3nWoJov4cd3FjWarktrMHP4DNvSjCnDwkMWYx4W1tyscEia4JX6Y4GVCQ=="],
"nice-grpc-common": ["nice-grpc-common@2.0.3", "", { "dependencies": { "ts-error": "^1.0.6" } }, "sha512-MEhnD3JMah0mgyivpb9hpRDbOBuXBxI/TVO+OK1h6rC97WM42HsPMR+zzRNQ0C5BqYJTw1nyWiQRD0DucO+pjQ=="],
"nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="],
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
@@ -5631,6 +5665,8 @@
"ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="],
"ts-error": ["ts-error@1.0.6", "", {}, "sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
@@ -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=="],
"cbor-extract/node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-test": "build-test.js", "node-gyp-build-optional-packages-optional": "optional.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="],
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -6733,6 +6771,8 @@
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"modal/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
"motion/framer-motion": ["framer-motion@12.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=="],
+1 -1
View File
@@ -86,7 +86,7 @@ export async function runMini(input: MiniCommandInput) {
) =>
resolveSessionTarget({
client,
location: { directory: next.location.directory, workspace: next.location.workspaceID },
location: { directory: next.location.directory },
agent: next.agent,
model: next.model
? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
+1 -2
View File
@@ -703,7 +703,7 @@ export async function runNonInteractivePrompt(input: Input) {
? Promise.resolve(undefined)
: input.client.form.request
.list({
location: { directory: input.location.directory, workspace: input.location.workspaceID },
location: { directory: input.location.directory },
})
.catch(() => undefined),
])
@@ -757,7 +757,6 @@ function formRequestOptions(location: LocationRef | undefined): [] | [{ headers:
{
headers: {
"x-opencode-directory": encodeURIComponent(location.directory),
...(location.workspaceID ? { "x-opencode-workspace": location.workspaceID } : {}),
},
},
]
+1 -1
View File
@@ -96,7 +96,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
next.model ??
(options.variant
? await client.model
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.default({ location: { directory: next.location.directory } })
.then((result) => result.data)
: undefined)
const model = selected
-1
View File
@@ -136,7 +136,6 @@ async function latestSession(
const page = await client.session.list(
{
directory: location.directory,
workspace: location.workspaceID,
parentID: null,
limit: SESSION_PAGE_LIMIT,
order: "desc",
+1 -2
View File
@@ -426,14 +426,13 @@ describe("runNonInteractivePrompt", () => {
const globalOptions = {
headers: {
"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: "ses_1", formID: "frm_pending" })
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
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.reject).not.toHaveBeenCalled()
+59 -104
View File
@@ -3,13 +3,13 @@ import type { Effect, Stream } from "effect"
import type { Location } from "@opencode-ai/schema/location"
import type { Agent } from "@opencode-ai/schema/agent"
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 { AbsolutePath } from "@opencode-ai/schema/schema"
import type { Project } from "@opencode-ai/schema/project"
import type { RelativePath } from "@opencode-ai/schema/schema"
import type { Brand } from "effect"
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 { PromptInput } from "@opencode-ai/schema/prompt-input"
import type { AgentAttachment } from "@opencode-ai/schema/prompt"
@@ -59,9 +59,7 @@ export interface ServerApi<E = never> {
readonly get: ServerGetOperation<E>
}
export type Endpoint2_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint2_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint2_0Output = Location.Info
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>
}
export type Endpoint3_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint3_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
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 Endpoint3_1Input = {
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 AgentGetOperation<E = never> = (input: Endpoint3_1Input) => Effect.Effect<Endpoint3_1Output, E>
@@ -87,9 +83,7 @@ export interface AgentApi<E = never> {
readonly get: AgentGetOperation<E>
}
export type Endpoint4_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint4_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
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>
@@ -98,7 +92,6 @@ export interface PluginApi<E = never> {
}
export type Endpoint5_0Input = {
readonly workspace?: Workspace.ID | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
@@ -919,15 +912,11 @@ export interface MessageApi<E = never> {
readonly list: MessageListOperation<E>
}
export type Endpoint7_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint7_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
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 Endpoint7_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint7_1Input = { readonly location?: { readonly directory?: string | undefined } | 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>
@@ -937,7 +926,7 @@ export interface ModelApi<E = never> {
}
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 model?: Model.Ref | undefined
}
@@ -948,15 +937,13 @@ export interface GenerateApi<E = never> {
readonly text: GenerateTextOperation<E>
}
export type Endpoint9_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint9_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
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 Endpoint9_1Input = {
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 ProviderGetOperation<E = never> = (input: Endpoint9_1Input) => Effect.Effect<Endpoint9_1Output, E>
@@ -966,21 +953,19 @@ export interface ProviderApi<E = never> {
readonly get: ProviderGetOperation<E>
}
export type Endpoint10_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint10_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
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 Endpoint10_1Input = {
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 IntegrationGetOperation<E = never> = (input: Endpoint10_1Input) => Effect.Effect<Endpoint10_1Output, E>
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
}
export type Endpoint10_2Output = void
@@ -990,7 +975,7 @@ export type IntegrationWellknownAddOperation<E = never> = (
export type Endpoint10_3Input = {
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 label?: string | undefined
}
@@ -1001,7 +986,7 @@ export type IntegrationConnectKeyOperation<E = never> = (
export type Endpoint10_4Input = {
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 inputs: { readonly [x: string]: string }
readonly label?: string | undefined
@@ -1014,7 +999,7 @@ export type IntegrationOauthConnectOperation<E = never> = (
export type Endpoint10_5Input = {
readonly integrationID: Integration.ID
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 IntegrationOauthStatusOperation<E = never> = (
@@ -1024,7 +1009,7 @@ export type IntegrationOauthStatusOperation<E = never> = (
export type Endpoint10_6Input = {
readonly integrationID: Integration.ID
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
}
export type Endpoint10_6Output = void
@@ -1035,7 +1020,7 @@ export type IntegrationOauthCompleteOperation<E = never> = (
export type Endpoint10_7Input = {
readonly integrationID: Integration.ID
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 IntegrationOauthCancelOperation<E = never> = (
@@ -1044,7 +1029,7 @@ export type IntegrationOauthCancelOperation<E = never> = (
export type Endpoint10_8Input = {
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 label?: string | undefined
}
@@ -1056,7 +1041,7 @@ export type IntegrationCommandConnectOperation<E = never> = (
export type Endpoint10_9Input = {
readonly integrationID: Integration.ID
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 IntegrationCommandStatusOperation<E = never> = (
@@ -1066,7 +1051,7 @@ export type IntegrationCommandStatusOperation<E = never> = (
export type Endpoint10_10Input = {
readonly integrationID: Integration.ID
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 IntegrationCommandCancelOperation<E = never> = (
@@ -1091,15 +1076,13 @@ export interface IntegrationApi<E = never> {
}
}
export type Endpoint11_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint11_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
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 Endpoint11_1Input = {
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
}
export type Endpoint11_1Output = void
@@ -1107,28 +1090,26 @@ export type McpAddOperation<E = never> = (input: Endpoint11_1Input) => Effect.Ef
export type Endpoint11_2Input = {
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 McpRemoveOperation<E = never> = (input: Endpoint11_2Input) => Effect.Effect<Endpoint11_2Output, E>
export type Endpoint11_3Input = {
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 McpConnectOperation<E = never> = (input: Endpoint11_3Input) => Effect.Effect<Endpoint11_3Output, E>
export type Endpoint11_4Input = {
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 McpDisconnectOperation<E = never> = (input: Endpoint11_4Input) => Effect.Effect<Endpoint11_4Output, E>
export type Endpoint11_5Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint11_5Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
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>
@@ -1143,7 +1124,7 @@ export interface McpApi<E = never> {
export type Endpoint12_0Input = {
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
}
export type Endpoint12_0Output = void
@@ -1151,7 +1132,7 @@ export type CredentialUpdateOperation<E = never> = (input: Endpoint12_0Input) =>
export type Endpoint12_1Input = {
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 CredentialRemoveOperation<E = never> = (input: Endpoint12_1Input) => Effect.Effect<Endpoint12_1Output, E>
@@ -1164,15 +1145,13 @@ export interface CredentialApi<E = never> {
export type Endpoint13_0Output = ReadonlyArray<Project.Info>
export type ProjectListOperation<E = never> = () => Effect.Effect<Endpoint13_0Output, E>
export type Endpoint13_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint13_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint13_1Output = Project.Current
export type ProjectCurrentOperation<E = never> = (input?: Endpoint13_1Input) => Effect.Effect<Endpoint13_1Output, E>
export type Endpoint13_2Input = {
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 ProjectDirectoriesOperation<E = never> = (input: Endpoint13_2Input) => Effect.Effect<Endpoint13_2Output, E>
@@ -1183,9 +1162,7 @@ export interface ProjectApi<E = never> {
readonly directories: ProjectDirectoriesOperation<E>
}
export type Endpoint14_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint14_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
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>
@@ -1229,9 +1206,7 @@ export interface FormApi<E = never> {
readonly cancel: FormCancelOperation<E>
}
export type Endpoint15_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint15_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint15_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Permission.Request> }
export type PermissionRequestListOperation<E = never> = (
input?: Endpoint15_0Input,
@@ -1289,14 +1264,14 @@ export interface PermissionApi<E = never> {
}
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
}
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 Endpoint16_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined } | undefined
readonly query: string
readonly type?: "file" | "directory" | undefined
readonly limit?: number | undefined
@@ -1309,9 +1284,7 @@ export interface FileApi<E = never> {
readonly find: FileFindOperation<E>
}
export type Endpoint17_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint17_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
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>
@@ -1319,9 +1292,7 @@ export interface CommandApi<E = never> {
readonly list: CommandListOperation<E>
}
export type Endpoint18_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint18_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
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>
@@ -1336,14 +1307,12 @@ export interface EventApi<E = never> {
readonly subscribe: EventSubscribeOperation<E>
}
export type Endpoint20_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint20_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
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 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 args?: ReadonlyArray<string> | undefined
readonly cwd?: string | undefined
@@ -1355,14 +1324,14 @@ export type PtyCreateOperation<E = never> = (input?: Endpoint20_1Input) => Effec
export type Endpoint20_2Input = {
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 PtyGetOperation<E = never> = (input: Endpoint20_2Input) => Effect.Effect<Endpoint20_2Output, E>
export type Endpoint20_3Input = {
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 size?: { readonly rows: number; readonly cols: number } | undefined
}
@@ -1371,7 +1340,7 @@ export type PtyUpdateOperation<E = never> = (input: Endpoint20_3Input) => Effect
export type Endpoint20_4Input = {
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 PtyRemoveOperation<E = never> = (input: Endpoint20_4Input) => Effect.Effect<Endpoint20_4Output, E>
@@ -1384,14 +1353,12 @@ export interface PtyApi<E = never> {
readonly remove: PtyRemoveOperation<E>
}
export type Endpoint21_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint21_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
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 Endpoint21_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined } | undefined
readonly command: string
readonly cwd?: string | undefined
readonly timeout: number
@@ -1402,14 +1369,14 @@ export type ShellCreateOperation<E = never> = (input: Endpoint21_1Input) => Effe
export type Endpoint21_2Input = {
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 ShellGetOperation<E = never> = (input: Endpoint21_2Input) => Effect.Effect<Endpoint21_2Output, E>
export type Endpoint21_3Input = {
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
}
export type Endpoint21_3Output = { readonly location: Location.Info; readonly data: Shell.Info }
@@ -1417,7 +1384,7 @@ export type ShellTimeoutOperation<E = never> = (input: Endpoint21_3Input) => Eff
export type Endpoint21_4Input = {
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 limit?: number | undefined
}
@@ -1434,7 +1401,7 @@ export type ShellOutputOperation<E = never> = (input: Endpoint21_4Input) => Effe
export type Endpoint21_5Input = {
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 ShellRemoveOperation<E = never> = (input: Endpoint21_5Input) => Effect.Effect<Endpoint21_5Output, E>
@@ -1448,9 +1415,7 @@ export interface ShellApi<E = never> {
readonly remove: ShellRemoveOperation<E>
}
export type Endpoint22_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint22_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Question.Request> }
export type QuestionRequestListOperation<E = never> = (
input?: Endpoint22_0Input,
@@ -1479,9 +1444,7 @@ export interface QuestionApi<E = never> {
readonly reject: QuestionRejectOperation<E>
}
export type Endpoint23_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint23_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
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>
@@ -1491,7 +1454,7 @@ export interface ReferenceApi<E = never> {
export type Endpoint24_0Input = {
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 directory: AbsolutePath
readonly name?: string | undefined
@@ -1501,7 +1464,7 @@ export type ProjectCopyCreateOperation<E = never> = (input: Endpoint24_0Input) =
export type Endpoint24_1Input = {
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 force: boolean
}
@@ -1510,7 +1473,7 @@ export type ProjectCopyRemoveOperation<E = never> = (input: Endpoint24_1Input) =
export type Endpoint24_2Input = {
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 ProjectCopyRefreshOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
@@ -1521,20 +1484,16 @@ export interface ProjectCopyApi<E = never> {
readonly refresh: ProjectCopyRefreshOperation<E>
}
export type Endpoint25_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint25_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
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 Endpoint25_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint25_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
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 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 context?: number | undefined
}
@@ -1550,9 +1509,7 @@ export interface VcsApi<E = never> {
export type Endpoint26_0Output = ReadonlyArray<Location.Ref>
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
export type Endpoint26_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint26_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint26_1Output = void
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E>
@@ -1577,14 +1534,12 @@ export interface MigrationApi<E = never> {
readonly v1: { readonly status: MigrationV1StatusOperation<E> }
}
export type Endpoint28_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint28_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
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 Endpoint28_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined } | undefined
readonly query: string
readonly providerID?: WebSearch.ID | undefined
}
@@ -288,7 +288,6 @@ const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0In
preserveEffect<Endpoint5_0Output>()(
raw["session.list"]({
query: {
workspace: input?.["workspace"],
limit: input?.["limit"],
order: input?.["order"],
search: input?.["search"],
@@ -444,7 +444,6 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session`,
query: {
workspace: input?.["workspace"],
limit: input?.["limit"],
order: input?.["order"],
search: input?.["search"],
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -1021,13 +1021,12 @@ describe("OpenAPI.fromSpec", () => {
await Effect.runPromise(
location
.execute({ location: { directory: "/tmp", workspace: "workspace-1" } })
.execute({ location: { directory: "/tmp" } })
.pipe(Effect.provide(client.layer)),
)
const url = new URL(client.requests[0]!.url)
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 () => {
+70 -105
View File
@@ -1,15 +1,11 @@
{
"version": "7",
"dialect": "sqlite",
"id": "2d214a71-3b0a-48c1-a667-741952c4e188",
"id": "2415fa65-3def-4f9f-be85-eb35f4f94ddd",
"prevIds": [
"f14a9b18-8207-487e-a3d3-227e629ba9ad"
"4d283c23-533f-4b87-9aa4-218f7fd36bca"
],
"ddl": [
{
"name": "workspace",
"entityType": "tables"
},
{
"name": "account_state",
"entityType": "tables"
@@ -75,84 +71,8 @@
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
"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"
"name": "workspace",
"entityType": "tables"
},
{
"type": "integer",
@@ -1385,18 +1305,63 @@
"table": "session_v2"
},
{
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_workspace_project_id_project_id_fk",
"entityType": "fks",
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"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"
},
{
@@ -1564,15 +1529,6 @@
"entityType": "pks",
"table": "instruction_entry"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
"entityType": "pks"
},
{
"columns": [
"id"
@@ -1690,6 +1646,15 @@
"table": "session_v2",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
"entityType": "pks"
},
{
"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
View File
@@ -42,5 +42,7 @@ export const migrations = (
import("./migration/20260622202450_simplify_session_input"),
import("./migration/20260804233008_loose_psylocke"),
import("./migration/20260805200742_import_legacy_credentials"),
import("./migration/20260806150823_remove_workspace"),
import("./migration/20260806150824_add_workspace"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260806150823_remove_workspace",
up(tx) {
return Effect.gen(function* () {
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
+10 -13
View File
@@ -4,19 +4,6 @@ import type { DatabaseMigration } from "./migration"
export default {
up(tx) {
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(`
CREATE TABLE \`account_state\` (
\`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
);
`)
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 INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
yield* tx.run(
+208 -47
View File
@@ -1,23 +1,49 @@
export * as FileMutation from "./file-mutation"
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 { FSUtil } from "@opencode-ai/util/fs-util"
import { Bom } from "@opencode-ai/util/bom"
import { Formatter } from "./formatter"
import { WorkspaceEnvironment } from "./workspace/environment"
export interface Target {
readonly canonical: string
/** Lexical path for entry operations; remove unlinks the name, not the referent. */
readonly absolute: 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 {
readonly target: Target
readonly content: string | Uint8Array
readonly content: string
}
export interface TextWriteInput {
readonly target: Target
export interface MoveInput {
readonly from: Target
readonly to: Target
/** Text for the destination; may differ from the source content. */
readonly content: string
}
@@ -26,74 +52,209 @@ export interface WriteResult {
readonly target: string
readonly resource: string
readonly existed: boolean
/** Final text on disk after BOM handling and formatting. */
readonly content: string
}
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>
/** 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") {}
/** 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
* write under the same process-local lock so cooperating OpenCode mutations do
* 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(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const locks = KeyedMutex.makeUnsafe<string>()
const withTargetLock =
(target: Target) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
locks.withLock(target.canonical)(Effect.uninterruptible(effect))
const writeResult = (target: Target, existed: boolean): WriteResult => ({
operation: "write",
target: target.canonical,
resource: target.resource,
existed,
const formatter = yield* Formatter.Service
return make({
readOptional: (path) =>
fs.readFile(path).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))),
isDirectory: (path) =>
fs.stat(path).pipe(
Effect.map((info) => info.type === "Directory"),
Effect.catch(() => Effect.succeed(false)),
),
write: (path, content) => fs.writeWithDirs(path, content),
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
const mapFileSystemError = (method: string) => (error: WorkspaceEnvironment.Error) =>
new FSUtil.FileSystemError({ method, cause: error })
return make({
readOptional: (path) =>
env.files.read(path).pipe(
Effect.catchTag("WorkspaceEnvironment.NotFoundError", () => Effect.succeed(undefined)),
Effect.mapError(mapFileSystemError("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(mapFileSystemError("write"))),
remove: (path) =>
env.files
.remove(path)
.pipe(
Effect.mapError((error) =>
error._tag === "WorkspaceEnvironment.NotFoundError"
? new NotFoundError({ path })
: mapFileSystemError("remove")(error),
),
),
})
}),
)
export const hostedNode = makeLocationNode({
service: Service,
layer: hostedLayer,
deps: [WorkspaceEnvironment.node],
})
/**
* 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: Add snapshots / undo after snapshot design exists.
// TODO: Notify LSP and collect diagnostics after LSP runtime exists.
+240 -45
View File
@@ -7,7 +7,9 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location"
import { PositiveInt, RelativePath } from "./schema"
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 const ReadInput = Schema.Struct({
@@ -32,7 +34,24 @@ export type ListInput = typeof ListInput.Type
export { FindInput }
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")({
pattern: Schema.String,
@@ -47,71 +66,247 @@ export class GrepInput extends Schema.Class<GrepInput>("FileSystem.GrepInput")({
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 interface Interface {
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
readonly list: (input?: ListInput) => 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") {}
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((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1))
}),
}
}
const baseLayer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const search = yield* FileSystemSearch.Service
const ripgrep = yield* Ripgrep.Service
const searches = makeSearch(location, ripgrep, path, (target) =>
fs.stat(target.canonical).pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new SearchPathError({ path: target.absolute, reason: "not_found" })),
),
Effect.mapError(mapSearchError),
),
)
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
const absolute = path.resolve(location.directory, input ?? ".")
if (!FSUtil.contains(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the location"))
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
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 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({
service: Service,
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],
})
-1
View File
@@ -10,7 +10,6 @@ const prefixes = {
part: "prt",
pty: "pty",
tool: "tool",
workspace: "wrk",
} as const
export function ascending(prefix: keyof typeof prefixes, given?: string) {
+113 -37
View File
@@ -7,6 +7,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location"
import { Project } from "./project"
import { AbsolutePath } from "./schema"
import { WorkspaceEnvironment } from "./workspace/environment"
export const Kind = Schema.Literals(["file", "directory"])
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", {
path: Schema.String,
reason: Schema.Literal("non_directory_ancestor"),
reason: Schema.Literals(["non_directory_ancestor", "outside_workspace"]),
}) {}
export interface ExternalDirectoryAuthorization {
readonly action: "external_directory"
/** Canonical existing directory used as the external approval boundary. */
/** Lexical directory used as the external approval boundary. */
readonly directory: string
/** `external_directory` permission resource. */
readonly resource: string
@@ -46,7 +47,13 @@ export const externalDirectoryPermission = (input: ExternalDirectoryAuthorizatio
export interface Target {
/** Canonical existing path, or missing path below a canonical directory. */
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 externalDirectory?: ExternalDirectoryAuthorization
}
@@ -74,9 +81,56 @@ interface ResolvedPath {
| "Socket"
| "Unknown"
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(
Service,
@@ -88,34 +142,10 @@ const layer = Layer.effect(
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
}
const resolvePath = Effect.fnUntraced(function* (absolute: string) {
const existing = yield* notFound(fs.realPath(absolute))
if (existing !== undefined) {
const info = yield* fs.stat(existing)
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 resolvePath = resolvePathWith({
paths: path,
realPathOptional: (target) => notFound(fs.realPath(target)),
statOptional: (target) => notFound(fs.stat(target)),
})
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
@@ -126,19 +156,21 @@ const layer = Layer.effect(
const resolved = yield* resolvePath(absolute)
const external = !lexicallyInternal
const resource = external ? slash(resolved.canonical) : slash(path.relative(location.directory, absolute) || ".")
const externalDirectory =
input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory
const externalResource = slash(path.join(externalDirectory, "*"))
const resource = external
? FSUtil.slash(absolute)
: FSUtil.slash(path.relative(location.directory, absolute) || ".")
const externalDirectory = resolved.lexicalDirectory
const externalResource = FSUtil.slash(path.join(externalDirectory, "*"))
return {
canonical: resolved.canonical,
absolute,
resource,
externalDirectory: external
? {
action: "external_directory",
directory: externalDirectory,
resource: externalResource,
save: slash(
save: FSUtil.slash(
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
),
}
@@ -155,3 +187,47 @@ export const node = makeLocationNode({
layer: layer.pipe(Layer.orDie),
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],
})
+21 -1
View File
@@ -11,6 +11,7 @@ import { FileMutation } from "./file-mutation"
import { Formatter } from "./formatter"
import { FileSystem } from "./filesystem"
import { FileSystemSearch } from "./filesystem/search"
import { Ripgrep } from "./ripgrep"
import { Generate } from "./generate"
import { Form } from "./form"
import { Image } from "./image"
@@ -47,6 +48,7 @@ import { McpTool } from "./tool/mcp"
import { ReadToolFileSystem } from "./tool/read-filesystem"
import { Tool } from "./tool"
import { Vcs } from "./vcs"
import { WorkspaceEnvironment } from "./workspace/environment"
export { LocationServiceMap } from "./location-service-map"
@@ -118,7 +120,25 @@ export function buildLocationServiceMap(
LayerMap.make(
(ref: Location.Ref) => {
const startedAt = performance.now()
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
const workspaceID = ref.workspaceID
// 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.
const allReplacements = replacements.concat(
workspaceID
? [
[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],
[Shell.node, Shell.hostedNode],
]
: [[Location.node, Location.boundNode(ref)]],
)
// Apply replacements during hoist, not afterward: replacements can
// introduce new tagged dependencies (Location.boundNode depends on
// Project), and the hoist walk is the only pass that can still slice
+32
View File
@@ -1,6 +1,9 @@
import path from "path"
import { Context, Effect, Layer } from "effect"
import { Info, Ref, response } from "@opencode-ai/schema/location"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Project } from "./project"
import { WorkspaceEnvironment } from "./workspace/environment"
import { LayerNode } from "@opencode-ai/util/effect/layer-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") {}
/**
* 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)
const layer = (ref: Ref) =>
@@ -37,3 +46,26 @@ export const boundNode = (ref: Ref) =>
layer: layer(ref),
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],
})
-3
View File
@@ -32,7 +32,6 @@ import { Npm } from "@opencode-ai/util/npm"
import { Permission } from "../permission"
import { Reference } from "../reference"
import { WebSearch } from "../websearch"
import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions"
import { Shell } from "../shell"
import { Skill } from "../skill"
@@ -89,7 +88,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const read = yield* ReadToolFileSystem.Service
const reference = yield* Reference.Service
const websearch = yield* WebSearch.Service
const ripgrep = yield* Ripgrep.Service
const instructions = yield* SessionInstructions.Service
const shell = yield* Shell.Service
const skill = yield* Skill.Service
@@ -121,7 +119,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(ReadToolFileSystem.Service, read),
Context.make(Reference.Service, reference),
Context.make(WebSearch.Service, websearch),
Context.make(Ripgrep.Service, ripgrep),
Context.make(SessionInstructions.Service, instructions),
Context.make(Shell.Service, shell),
Context.make(Skill.Service, skill),
+3 -2
View File
@@ -282,7 +282,9 @@ const layer = Layer.effect(
})
const updates = Stream.merge(
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)),
),
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
@@ -338,7 +340,6 @@ export const node = makeLocationNode({
Form.node,
ReadToolFileSystem.node,
Reference.node,
Ripgrep.node,
SessionInstructions.node,
Shell.node,
Skill.node,
+12
View File
@@ -44,6 +44,18 @@ export interface Resolved {
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.
export const root = Effect.fn("Project.root")(function* (
fs: FSUtil.Interface,
+1 -5
View File
@@ -1,6 +1,5 @@
export * as PtyTicket from "./ticket"
import { Workspace } from "../workspace"
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
import { PtyID } from "./schema"
import { Cache, Context, Duration, Effect, Layer } from "effect"
@@ -14,7 +13,6 @@ export const ConnectToken = PtyTicket.ConnectToken
export type Scope = {
readonly ptyID: PtyID
readonly directory?: string
readonly workspaceID?: Workspace.ID
}
export interface Interface {
@@ -25,9 +23,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/PtyTicket") {}
function matches(record: Scope, input: Scope) {
return (
record.ptyID === input.ptyID && record.directory === input.directory && record.workspaceID === input.workspaceID
)
return record.ptyID === input.ptyID && record.directory === input.directory
}
// Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is
+246 -175
View File
@@ -2,11 +2,13 @@ export * as Ripgrep from "./ripgrep"
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import type { Command } from "effect/unstable/process/ChildProcess"
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 { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { RipgrepBinary } from "./ripgrep/binary"
import { WorkspaceEnvironment } from "./workspace/environment"
/**
* 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 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({
type: Schema.Literal("match"),
data: Schema.Struct({
@@ -90,184 +99,208 @@ const failure = (message: string, cause?: unknown) => new Error({ message, cause
const isInvalidPattern = (stderr: string) =>
stderr.includes("regex parse error") || stderr.includes("error parsing regex")
const normalizeRelativePath = (value: string) =>
value
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.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: {
readonly cwd: string
readonly args: string[]
readonly limit: number
readonly signal?: AbortSignal
readonly parse: (line: string) => Effect.Effect<A | undefined, Error>
readonly output: "lines" | "matches"
readonly pattern?: string
readonly onItem?: (item: A) => Effect.Effect<void>
}) => {
const program = Effect.scoped(
Effect.gen(function* () {
const handle = yield* backend.spawn(yield* backend.command(input))
const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe(
Effect.map((output) => output.buffer.toString("utf8")),
Effect.forkScoped,
)
let observed = 0
const rows = yield* Stream.decodeText(handle.stdout).pipe(
Stream.splitLines,
Stream.filter((line) => line.length > 0),
Stream.mapEffect(input.parse),
Stream.filter((row): row is A => row !== undefined),
Stream.tap((row) => {
if (!input.onItem || observed++ >= input.limit) return Effect.void
return input.onItem(row)
}),
Stream.take(input.limit + 1),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
const truncated = rows.length > input.limit
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 stderr = yield* Fiber.join(stderrFiber)
if (input.pattern && code === 2 && isInvalidPattern(stderr)) {
return yield* new InvalidPatternError({ pattern: input.pattern, message: stderr.trim() })
}
if (code !== 0 && code !== 1 && code !== 2) {
return yield* failure(stderr.trim() || `ripgrep failed with code ${code}`)
}
return { items: code === 1 ? [] : rows, truncated: false, partial: code === 2 }
}),
)
const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program
return abortable.pipe(
Effect.mapError((cause) =>
cause instanceof Error || cause instanceof InvalidPatternError
? cause
: failure("ripgrep execution failed", cause),
),
)
}
return Service.of({
glob: (input) =>
run<string>({
cwd: input.cwd,
limit: input.limit,
signal: input.signal,
output: "lines",
args: [
"--no-config",
"--files",
...(input.hidden ? ["--hidden"] : []),
...(input.follow ? ["--follow"] : []),
`--glob=${input.pattern}`,
"--glob=!**/.git/**",
".",
],
parse: (line) => Effect.succeed(normalizeRelativePath(line)),
}).pipe(
Effect.map((result) =>
result.items.map((relative) =>
Entry.make({
path: RelativePath.make(relative),
type: "file",
}),
),
),
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
),
find: (input) =>
run<Entry>({
cwd: input.cwd,
limit: input.limit,
signal: input.signal,
output: "lines",
args: [
"--no-config",
"--files",
...(input.hidden ? ["--hidden"] : []),
...(input.follow ? ["--follow"] : []),
...(input.pattern === "*" ? [] : [`--glob=${input.pattern}`]),
...(input.exclude ?? []).map((pattern) => `--glob=!${pattern}`),
"--glob=!**/.git/**",
".",
],
parse: (line) => {
return Effect.succeed(
Entry.make({
path: RelativePath.make(normalizeRelativePath(line)),
type: "file",
}),
)
},
onItem: input.onEntry,
}).pipe(
Effect.map((result) => result.items),
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
),
grep: (input) =>
run<RawMatchData>({
...input,
output: "matches",
args: [
"--no-config",
"--json",
"--hidden",
"--no-messages",
...(input.include ? [`--glob=${input.include}`] : []),
"--glob=!**/.git/**",
"--",
input.pattern,
input.file ?? ".",
],
parse: (line) =>
decodeJsonRecord(line).pipe(
Effect.mapError((cause) => failure("Invalid ripgrep JSON output", cause)),
Effect.flatMap((json) => {
if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match")
return Effect.succeed(undefined)
return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
Effect.map((match) => ({
...match.data,
submatches: match.data.submatches.slice(0, MAX_SUBMATCHES),
})),
Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
)
}),
),
}).pipe(
Effect.map((result) =>
result.items.map((match) => {
return Match.make({
entry: Entry.make({
path: RelativePath.make(normalizeRelativePath(match.path.text)),
type: "file",
}),
line: match.line_number,
offset: match.absolute_offset,
text: match.lines.text.length > 2_000 ? match.lines.text.slice(0, 2_000) + "..." : match.lines.text,
submatches: match.submatches.map((submatch) => ({
text: submatch.match.text,
start: submatch.start,
end: submatch.end,
})),
})
}),
),
),
})
}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const process = yield* AppProcess.Service
const binary = yield* RipgrepBinary.Service
const run = <A>(input: {
readonly cwd: string
readonly args: string[]
readonly limit: number
readonly signal?: AbortSignal
readonly parse: (line: string) => Effect.Effect<A | undefined, Error>
readonly pattern?: string
readonly onItem?: (item: A) => Effect.Effect<void>
}) => {
const program = Effect.scoped(
Effect.gen(function* () {
const handle = yield* process.spawn(
ChildProcess.make(yield* binary.filepath, input.args, { cwd: input.cwd, extendEnv: true, stdin: "ignore" }),
)
const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe(
Effect.map((output) => output.buffer.toString("utf8")),
Effect.forkScoped,
)
let observed = 0
const rows = yield* Stream.decodeText(handle.stdout).pipe(
Stream.splitLines,
Stream.filter((line) => line.length > 0),
Stream.mapEffect(input.parse),
Stream.filter((row): row is A => row !== undefined),
Stream.tap((row) => {
if (!input.onItem || observed++ >= input.limit) return Effect.void
return input.onItem(row)
}),
Stream.take(input.limit + 1),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
const truncated = rows.length > input.limit
if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false }
const code = yield* handle.exitCode
const stderr = yield* Fiber.join(stderrFiber)
if (input.pattern && code === 2 && isInvalidPattern(stderr)) {
return yield* new InvalidPatternError({ pattern: input.pattern, message: stderr.trim() })
}
if (code !== 0 && code !== 1 && code !== 2) {
return yield* failure(stderr.trim() || `ripgrep failed with code ${code}`)
}
return { items: code === 1 ? [] : rows, truncated: false, partial: code === 2 }
}),
)
const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program
return abortable.pipe(
Effect.mapError((cause) =>
cause instanceof Error || cause instanceof InvalidPatternError
? cause
: failure("ripgrep execution failed", cause),
),
)
}
return Service.of({
glob: (input) =>
run<string>({
cwd: input.cwd,
limit: input.limit,
signal: input.signal,
args: [
"--no-config",
"--files",
...(input.hidden ? ["--hidden"] : []),
...(input.follow ? ["--follow"] : []),
`--glob=${input.pattern}`,
"--glob=!**/.git/**",
".",
],
parse: (line) =>
Effect.succeed(
line
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/"),
),
}).pipe(
Effect.map((result) =>
result.items.map((relative) =>
Entry.make({
path: RelativePath.make(relative),
type: "file",
}),
),
),
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
),
find: (input) =>
run<Entry>({
cwd: input.cwd,
limit: input.limit,
signal: input.signal,
args: [
"--no-config",
"--files",
...(input.hidden ? ["--hidden"] : []),
...(input.follow ? ["--follow"] : []),
...(input.pattern === "*" ? [] : [`--glob=${input.pattern}`]),
...(input.exclude ?? []).map((pattern) => `--glob=!${pattern}`),
"--glob=!**/.git/**",
".",
],
parse: (line) => {
const relative = line
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/")
return Effect.succeed(
Entry.make({
path: RelativePath.make(relative),
type: "file",
}),
)
},
onItem: input.onEntry,
}).pipe(
Effect.map((result) => result.items),
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
),
grep: (input) =>
run<RawMatchData>({
...input,
args: [
"--no-config",
"--json",
"--hidden",
"--no-messages",
...(input.include ? [`--glob=${input.include}`] : []),
"--glob=!**/.git/**",
"--",
input.pattern,
input.file ?? ".",
],
parse: (line) =>
decodeJsonRecord(line).pipe(
Effect.mapError((cause) => failure("Invalid ripgrep JSON output", cause)),
Effect.flatMap((json) => {
if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match")
return Effect.succeed(undefined)
return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
Effect.map((match) => ({
...match.data,
path: { text: match.data.path.text.replace(/^\.[\\/]/, "") },
submatches: match.data.submatches.slice(0, MAX_SUBMATCHES),
})),
Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
)
}),
),
}).pipe(
Effect.map((result) =>
result.items.map((match) => {
const relative = match.path.text
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/")
return Match.make({
entry: Entry.make({
path: RelativePath.make(relative),
type: "file",
}),
line: match.line_number,
offset: match.absolute_offset,
text: match.lines.text.length > 2_000 ? match.lines.text.slice(0, 2_000) + "..." : match.lines.text,
submatches: match.submatches.map((submatch) => ({
text: submatch.match.text,
start: submatch.start,
end: submatch.end,
})),
})
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",
}),
),
),
@@ -275,4 +308,42 @@ const layer = Layer.effect(
}),
)
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],
})
+28 -14
View File
@@ -5,7 +5,6 @@ import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
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 { Project } from "./project"
import { Workspace } from "./workspace"
import { Model } from "./model"
import { Location } from "./location"
import { SessionMessage } from "./session/message"
@@ -27,7 +26,8 @@ import { fromRow } from "./session/info"
import { SessionRunner } from "./session/runner/index"
import { SessionStore } from "./session/store"
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 { LocationServiceMap } from "./location-service-map"
import { SessionEvent } from "./session/event"
@@ -58,12 +58,9 @@ import { fileURLToPath } from "url"
// - by project
// - by subpath
// - by workspace (home is special)
export { ListAnchor }
const ListInputBase = {
workspaceID: Workspace.ID.pipe(Schema.optional),
search: Schema.String.pipe(Schema.optional),
limit: PositiveInt.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 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: (
input: ForkInput,
) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError | ForkEmptyError>
@@ -199,7 +198,6 @@ export interface Interface {
readonly move: (input: {
sessionID: SessionSchema.ID
directory: AbsolutePath
workspaceID?: Location.Ref["workspaceID"]
}) => Effect.Effect<void, NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError>
readonly prompt: (input: {
id?: SessionMessage.ID
@@ -235,7 +233,7 @@ export interface Interface {
id?: Event.ID
sessionID: SessionSchema.ID
command: string
}) => Effect.Effect<void, NotFoundError>
}) => Effect.Effect<void, NotFoundError | Shell.InvalidCwdError>
readonly skill: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
@@ -280,6 +278,7 @@ const layer = Layer.effect(
const db = database.db
const bus = yield* Bus.Service
const projects = yield* Project.Service
const workspaces = yield* Workspace.Service
const global = yield* Global.Service
const execution = yield* SessionExecution.Service
const store = yield* SessionStore.Service
@@ -291,6 +290,17 @@ const layer = Layer.effect(
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
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 vcs = project.vcs?.type
return db
@@ -328,7 +338,9 @@ const layer = Layer.effect(
const location = parent?.location ?? input.location
if (location === undefined)
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)
const projected = yield* bus
.publish(
@@ -340,7 +352,9 @@ const layer = Layer.effect(
projectID: project.id,
parentID: input.parentID,
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,
agent: input.agent,
model: input.model
@@ -427,7 +441,6 @@ const layer = Layer.effect(
const sortColumn = SessionTable.time_updated
const conditions: SQL[] = []
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 && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
@@ -701,10 +714,10 @@ const layer = Layer.effect(
const expanded =
value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
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)))
if (!info) return yield* new DestinationNotFoundError({ 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)
yield* persistProject(project)
if ((yield* execution.active).has(input.sessionID)) {
@@ -715,9 +728,9 @@ const layer = Layer.effect(
SessionEvent.Moved,
{
sessionID: input.sessionID,
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
location: Location.Ref.make({ directory }),
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 },
)
@@ -1013,6 +1026,7 @@ export const node = makeGlobalNode({
Database.node,
Bus.node,
Project.node,
Workspace.node,
SessionExecution.node,
SessionStore.node,
LocationServiceMap.node,
+14
View File
@@ -2,6 +2,7 @@ export * as SessionErrors from "./error"
import { Schema } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { WorkspaceID } from "@opencode-ai/schema/workspace-id"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
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", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
+3 -10
View File
@@ -7,16 +7,16 @@ import { Bus } from "../bus"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Model } from "../model"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { SessionMessageUpdater } from "./message-updater"
import { SessionPending } from "./pending"
import { Workspace } from "../workspace"
import { InstructionState } from "./instruction-state"
import { SessionPendingTable, SessionMessageTable, SessionTable } from "./sql"
import { Slug } from "../util/slug"
import { Money } from "@opencode-ai/schema/money"
import type { SessionSchema } from "./schema"
import { WorkspaceTable } from "../control-plane/workspace.sql"
import { Workspace } from "../workspace"
type DatabaseService = Database.Interface["db"]
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
@@ -376,13 +376,6 @@ const layer = Layer.effectDiscard(
.get()
.pipe(Effect.orDie)
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) =>
@@ -393,7 +386,7 @@ const layer = Layer.effectDiscard(
directory: event.data.location.directory,
path: event.data.subpath,
...(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),
})
.where(eq(SessionTable.id, event.data.sessionID))
+351 -269
View File
@@ -6,12 +6,14 @@ import { ChildProcess } from "effect/unstable/process"
import { produce } from "immer"
import { Shell } from "@opencode-ai/schema/shell"
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 { Config } from "./config"
import { Bus } from "./bus"
import { Location } from "./location"
import { Global } from "@opencode-ai/util/global"
import { ShellSelect } from "./shell/select"
import { WorkspaceEnvironment } from "./workspace/environment"
import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"
import { PluginHooks } from "./plugin/hooks"
@@ -19,6 +21,18 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("She
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.
// Cap retention so abandoned commands do not accumulate unbounded state and output files.
const EXITED_LIMIT = 25
@@ -50,7 +64,7 @@ export interface Interface {
readonly create: <E = never, R = never>(
input: Shell.CreateInput,
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.
readonly list: () => Effect.Effect<Shell.Info[]>
readonly get: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
@@ -65,286 +79,354 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
export const layer = (options?: ShellSelect.Options) => Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const config = yield* Config.Service
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const hooks = yield* PluginHooks.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<string, Active>()
const exitOrder: string[] = []
/** 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 outputDir = path.join(global.data, "shell", location.project.id)
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs"))
yield* Effect.promise(() => mkdir(outputDir, { recursive: true }))
const layerWith = <E, R>(backend: Effect.Effect<Backend, E, R>) =>
Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const global = yield* Global.Service
const spawner = yield* backend
const hooks = yield* PluginHooks.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<string, Active>()
const exitOrder: string[] = []
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const session of sessions.values()) {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock waiters still pending at teardown; succeed is a no-op once already resolved.
yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) }))
const outputDir = path.join(global.data, "shell", location.project.id)
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs"))
yield* Effect.promise(() => mkdir(outputDir, { recursive: true }))
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const session of sessions.values()) {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock waiters still pending at teardown; succeed is a no-op once already resolved.
yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) }))
}
sessions.clear()
exitOrder.length = 0
}),
)
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ id })
return session
})
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock any wait still pending when the command is removed before it terminated.
yield* Deferred.fail(session.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
yield* bus.publish(Shell.Event.Deleted, { id })
})
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
yield* require(id)
yield* removeSession(id)
})
const list = Effect.fn("Shell.list")(function* () {
return Array.from(sessions.values())
.filter((session) => session.info.status === "running")
.map((session) => session.info)
})
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
return (yield* require(id)).info
})
const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) {
return yield* Deferred.await((yield* require(id)).done)
})
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
const session = yield* require(id)
if (session.info.status !== "running" || !session.timeout) return session.info
yield* session.timeout(duration)
return session.info
})
const name = () => spawner.shell.pipe(Effect.map(ShellSelect.name))
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
const start = Math.max(0, cursor)
const length = Math.min(limit, session.size - start)
const buffer = Buffer.alloc(length)
const bytesRead = yield* Effect.promise(
() =>
new Promise<number>((resolve) => {
const stream = createReadStream(session.file, { start, end: start + length - 1 })
let offset = 0
stream.on("data", (chunk: string | Buffer) => {
const bytes = Buffer.from(chunk)
bytes.copy(buffer, offset)
offset += bytes.length
})
stream.on("end", () => resolve(offset))
stream.on("error", () => resolve(0))
}),
)
return {
output: buffer.subarray(0, bytesRead).toString("utf8"),
cursor: start + bytesRead,
size: session.size,
truncated: false,
}
sessions.clear()
exitOrder.length = 0
}),
})
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) {
const invocation: ShellCreateBefore = {
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* spawner.shell,
env: {
...spawner.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
},
}
yield* hooks.trigger("shell", "create.before", invocation)
if (before) yield* before(invocation)
yield* spawner.validateCwd(invocation.cwd)
const id = Shell.ID.ascending()
const args = spawner.args(invocation.shell, invocation.command)
const file = path.join(outputDir, `${id}.out`)
const info: Info = {
id,
status: "running",
command: invocation.command,
cwd: invocation.cwd,
shell: invocation.shell,
file,
metadata: input.metadata ?? {},
time: { started: Date.now() },
}
// Spawn via AppProcess and stream combined output to the file. The handle is scope-bound, so
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
// end). `create` returns once `ready` resolves with the registered session.
const ready = Deferred.makeUnsafe<Active>()
runFork(
Effect.scoped(
Effect.gen(function* () {
const handle = yield* spawner.spawn(
ChildProcess.make(invocation.shell, args, {
cwd: invocation.cwd,
env: invocation.env,
stdin: "ignore",
detached: spawner.detached,
forceKillAfter: Duration.seconds(3),
}),
)
const session: Active = {
info: produce(info, (draft) => {
draft.pid = handle.pid
}),
file,
size: 0,
done: Deferred.makeUnsafe<Info, NotFoundError>(),
}
sessions.set(id, session)
const stream = createWriteStream(file)
const outputDone = Deferred.makeUnsafe<void>()
const pump = handle.all.pipe(
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
stream.write(chunk)
session.size += chunk.length
}),
),
)
runFork(
Effect.gen(function* () {
yield* pump.pipe(Effect.catch(() => Effect.void))
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.end(() => resolve())
}),
)
yield* Deferred.succeed(outputDone, undefined)
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
)
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.once("open", () => resolve())
stream.once("error", () => resolve())
}),
)
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
Effect.gen(function* () {
if (session.info.status !== "running") return
session.info = produce(session.info, (draft) => {
draft.status = status
if (exit !== undefined) draft.exit = exit
draft.time.completed = Date.now()
})
yield* beforeWait
yield* Deferred.await(outputDone)
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
// session still reports success rather than the removal NotFoundError. This runs before
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
yield* Deferred.succeed(session.done, session.info)
yield* bus.publish(Shell.Event.Exited, {
id,
...(exit !== undefined ? { exit } : {}),
status,
})
exitOrder.push(id)
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeSession(Shell.ID.make(oldest))
}
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
// aborting finish when finish itself runs on the timeout fiber.
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
})
session.timeout = (duration) =>
Effect.gen(function* () {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
session.timeoutFiber = undefined
if (duration === 0 || session.info.status !== "running") return
session.timeoutFiber = runFork(
Effect.sleep(Duration.millis(duration)).pipe(
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
),
Effect.catch(() => Effect.void),
),
)
})
yield* session.timeout(invocation.timeout)
runFork(
handle.exitCode.pipe(
Effect.flatMap((code) => finish("exited", code)),
Effect.catch(() => Effect.void),
),
)
yield* bus.publish(Shell.Event.Created, { info })
yield* Deferred.succeed(ready, session)
// Hold the handle's scope open until the command terminates; closing it earlier would
// release (kill) the process before its exit is observed.
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
}),
).pipe(Effect.catch(() => Effect.void)),
)
const session = yield* Deferred.await(ready)
return session.info
})
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" })),
),
)
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ id })
return session
})
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock any wait still pending when the command is removed before it terminated.
yield* Deferred.fail(session.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
yield* bus.publish(Shell.Event.Deleted, { id })
})
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
yield* require(id)
yield* removeSession(id)
})
const list = Effect.fn("Shell.list")(function* () {
return Array.from(sessions.values())
.filter((session) => session.info.status === "running")
.map((session) => session.info)
})
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
return (yield* require(id)).info
})
const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) {
return yield* Deferred.await((yield* require(id)).done)
})
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
const session = yield* require(id)
if (session.info.status !== "running" || !session.timeout) return session.info
yield* session.timeout(duration)
return session.info
})
const resolve = () =>
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 session = yield* require(id)
const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
const start = Math.max(0, cursor)
const length = Math.min(limit, session.size - start)
const buffer = Buffer.alloc(length)
const bytesRead = yield* Effect.promise(
() =>
new Promise<number>((resolve) => {
const stream = createReadStream(session.file, { start, end: start + length - 1 })
let offset = 0
stream.on("data", (chunk: string | Buffer) => {
const bytes = Buffer.from(chunk)
bytes.copy(buffer, offset)
offset += bytes.length
})
stream.on("end", () => resolve(offset))
stream.on("error", () => resolve(0))
}),
)
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 {
output: buffer.subarray(0, bytesRead).toString("utf8"),
cursor: start + bytesRead,
size: session.size,
truncated: false,
}
})
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) {
const invocation: ShellCreateBefore = {
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* resolve(),
env: {
...process.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
},
}
yield* hooks.trigger("shell", "create.before", invocation)
if (before) yield* before(invocation)
const id = Shell.ID.ascending()
const args = ShellSelect.args(invocation.shell, invocation.command)
const file = path.join(outputDir, `${id}.out`)
const info: Info = {
id,
status: "running",
command: invocation.command,
cwd: invocation.cwd,
shell: invocation.shell,
file,
metadata: input.metadata ?? {},
time: { started: Date.now() },
}
// Spawn via AppProcess and stream combined output to the file. The handle is scope-bound, so
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
// end). `create` returns once `ready` resolves with the registered session.
const ready = Deferred.makeUnsafe<Active>()
runFork(
Effect.scoped(
Effect.gen(function* () {
const handle = yield* appProcess.spawn(
ChildProcess.make(invocation.shell, args, {
cwd: invocation.cwd,
env: invocation.env,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
}),
)
const session: Active = {
info: produce(info, (draft) => {
draft.pid = handle.pid
}),
file,
size: 0,
done: Deferred.makeUnsafe<Info, NotFoundError>(),
}
sessions.set(id, session)
const stream = createWriteStream(file)
const outputDone = Deferred.makeUnsafe<void>()
const pump = handle.all.pipe(
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
stream.write(chunk)
session.size += chunk.length
}),
),
)
runFork(
Effect.gen(function* () {
yield* pump.pipe(Effect.catch(() => Effect.void))
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.end(() => resolve())
}),
)
yield* Deferred.succeed(outputDone, undefined)
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
)
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.once("open", () => resolve())
stream.once("error", () => resolve())
}),
)
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
Effect.gen(function* () {
if (session.info.status !== "running") return
session.info = produce(session.info, (draft) => {
draft.status = status
if (exit !== undefined) draft.exit = exit
draft.time.completed = Date.now()
})
yield* beforeWait
yield* Deferred.await(outputDone)
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
// session still reports success rather than the removal NotFoundError. This runs before
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
yield* Deferred.succeed(session.done, session.info)
yield* bus.publish(Shell.Event.Exited, {
id,
...(exit !== undefined ? { exit } : {}),
status,
})
exitOrder.push(id)
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeSession(Shell.ID.make(oldest))
}
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
// aborting finish when finish itself runs on the timeout fiber.
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
})
session.timeout = (duration) =>
Effect.gen(function* () {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
session.timeoutFiber = undefined
if (duration === 0 || session.info.status !== "running") return
session.timeoutFiber = runFork(
Effect.sleep(Duration.millis(duration)).pipe(
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
),
Effect.catch(() => Effect.void),
),
)
})
yield* session.timeout(invocation.timeout)
runFork(
handle.exitCode.pipe(
Effect.flatMap((code) => finish("exited", code)),
Effect.catch(() => Effect.void),
),
)
yield* bus.publish(Shell.Event.Created, { info })
yield* Deferred.succeed(ready, session)
// Hold the handle's scope open until the command terminates; closing it earlier would
// release (kill) the process before its exit is observed.
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
}),
).pipe(Effect.catch(() => Effect.void)),
)
const session = yield* Deferred.await(ready)
return session.info
})
return Service.of({ name, create, list, get, wait, timeout, output, remove })
}),
)
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) {
return makeLocationNode({
service: Service,
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()
// 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],
})
+100 -119
View File
@@ -12,8 +12,6 @@ import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Bom } from "@opencode-ai/util/bom"
import { Effect, Schema } from "effect"
import { FileMutation } from "../../file-mutation"
import { Formatter } from "../../formatter"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { fileDiff } from "./file-diff"
@@ -110,130 +108,113 @@ export const Plugin = {
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const fs = yield* FSUtil.Service
const permission = yield* Permission.Service
yield* ctx.tool
.transform((draft) =>
draft.add(
({
name,
options: { codemode: false, permission: "edit" },
description:
"Edit the contents of a file by finding and replacing exact text. When editing text from Read output, preserve the exact indentation (tabs or spaces) and omit the line-number prefix, such as `1: `. Never include the prefix in oldString or newString. The edit fails if oldString is not found. By default, oldString must identify a UNIQUE location. Multiple matches FAIL unless replaceAll is true. Add more surrounding context to disambiguate, or set replaceAll to true to replace every occurrence. Use replaceAll when the change should apply to every occurrence, such as renaming a variable.",
input: Input,
output: Output,
execute: (input, context) => {
return Effect.gen(function* () {
const permissionSource = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
if (input.oldString === input.newString) {
return yield* new ToolFailure({
message: "No changes to apply: oldString and newString are identical.",
})
}
if (input.oldString === "") {
return yield* new ToolFailure({
message: "oldString must not be empty. Use write to create or overwrite a file.",
})
}
draft.add({
name,
options: { codemode: false, permission: "edit" },
description:
"Edit the contents of a file by finding and replacing exact text. When editing text from Read output, preserve the exact indentation (tabs or spaces) and omit the line-number prefix, such as `1: `. Never include the prefix in oldString or newString. The edit fails if oldString is not found. By default, oldString must identify a UNIQUE location. Multiple matches FAIL unless replaceAll is true. Add more surrounding context to disambiguate, or set replaceAll to true to replace every occurrence. Use replaceAll when the change should apply to every occurrence, such as renaming a variable.",
input: Input,
output: Output,
execute: (input, context) => {
return Effect.gen(function* () {
const permissionSource = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
if (input.oldString === input.newString) {
return yield* new ToolFailure({
message: "No changes to apply: oldString and newString are identical.",
})
}
if (input.oldString === "") {
return yield* new ToolFailure({
message: "oldString must not be empty. Use write to create or overwrite a file.",
})
}
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
const external = target.externalDirectory
if (external) {
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
})
}
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
const external = target.externalDirectory
if (external) {
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
})
}
const info = yield* fs.stat(target.canonical).pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `File not found: ${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 oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
const exact = findOccurrences(source, oldString)
// These one-to-one mappings preserve offsets into the original source.
const unicode =
exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString))
const trailing =
exact.length > 0 || unicode.length > 0
? []
: findLineOccurrences(source, oldString)
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
const replacements = matches.length
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
.toReversed()
.reduce(
(content, match) =>
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
source,
)
const preview =
replacements > 0 && (replacements === 1 || input.replaceAll === true)
? fileDiff(target.resource, source, replaced)
: undefined
yield* permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
metadata: preview ? { files: [preview] } : undefined,
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
})
if (replacements === 0) {
return yield* new ToolFailure({
message: `Could not find oldString in ${input.path}. It must match exactly, including whitespace and indentation.`,
})
}
if (replacements > 1 && input.replaceAll !== true) {
return yield* new ToolFailure({
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: 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 {
files: [fileDiff(result.resource, source, formatted)],
replacements,
} satisfies Output
}).pipe(
Effect.map((output) => ({
output,
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
metadata: { files: output.files },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to edit ${input.path}`, error }),
),
const source = yield* files.read(target).pipe(
Effect.catchTag("FileMutation.NotFoundError", () =>
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}` })),
),
)
const ending = source.includes(crlf) ? crlf : "\n"
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
const exact = findOccurrences(source, oldString)
// These one-to-one mappings preserve offsets into the original source.
const unicode =
exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString))
const trailing = exact.length > 0 || unicode.length > 0 ? [] : findLineOccurrences(source, oldString)
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
const replacements = matches.length
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
.toReversed()
.reduce(
(content, match) => `${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
source,
)
},
}),
),
const content = FileMutation.normalizeText(replaced)
const preview =
replacements > 0 && (replacements === 1 || input.replaceAll === true)
? fileDiff(target.resource, source, content)
: undefined
yield* permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
metadata: preview ? { files: [preview] } : undefined,
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
})
if (replacements === 0) {
return yield* new ToolFailure({
message: `Could not find oldString in ${input.path}. It must match exactly, including whitespace and indentation.`,
})
}
if (replacements > 1 && input.replaceAll !== true) {
return yield* new ToolFailure({
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 result = yield* files.write({ target, content: replaced })
return {
files: [fileDiff(result.resource, source, result.content)],
replacements,
} satisfies Output
}).pipe(
Effect.map((output) => ({
output,
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
metadata: { files: output.files },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to edit ${input.path}`, error }),
),
)
},
}),
)
.pipe(Effect.orDie)
}),
+62 -85
View File
@@ -5,10 +5,8 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { Effect, Schema } from "effect"
import path from "path"
import { FileSystem } from "../../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../../location"
import { LocationMutation } from "../../location-mutation"
import { Ripgrep } from "../../ripgrep"
import { RelativePath } from "../../schema"
import { Permission } from "../../permission"
@@ -42,104 +40,83 @@ export const toModelContent = (entries: EncodedOutput, truncated = false) => {
export const Plugin = {
id: "opencode.tool.glob",
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service
const filesystem = yield* FileSystem.Service
const location = yield* Location.Service
const resolve = Location.paths(location).resolve
const mutation = yield* LocationMutation.Service
const permission = yield* Permission.Service
yield* ctx.tool
.transform((draft) =>
draft.add(
({
name,
options: { codemode: false },
description:
'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
draft.add({
name,
options: { codemode: false },
description: 'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: searchPath ?? ".",
path: searchPath,
limit: input.limit,
},
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const info = yield* fs
.stat(target.canonical)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
yield* permission.assert({
action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: searchPath ?? ".",
path: searchPath,
limit: input.limit,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
const entries = yield* filesystem
.glob({
target,
pattern: input.pattern,
limit: limit + 1,
})
.pipe(
FileSystem.searchTimeout((message) => new ToolFailure({ message })),
Effect.catchTag("FileSystem.SearchPathError", (error) =>
Effect.fail(
new ToolFailure({
message:
error.reason === "not_found"
? `Search path does not exist: ${searchPath ?? "."}`
: `Search path is not a directory: ${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 entries = yield* ripgrep
.glob({
cwd: target.canonical,
pattern: input.pattern,
limit: limit + 1,
})
.pipe(
Effect.timeoutOrElse({
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
orElse: () =>
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((entry) =>
FileSystem.Entry.make({
...entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(root, entry.path))),
}),
),
),
)
return { entries: entries.slice(0, limit), truncated: entries.length > limit }
}).pipe(
Effect.map((result) => ({
output: result.entries,
content: toModelContent(
result.entries.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
result.truncated,
),
metadata: { count: result.entries.length, truncated: result.truncated },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
)
return { entries: entries.slice(0, limit), truncated: entries.length > limit }
}).pipe(
Effect.map((result) => ({
output: result.entries,
content: toModelContent(
result.entries.map((entry) => ({ ...entry, path: resolve(location.directory, entry.path) })),
result.truncated,
),
metadata: { count: result.entries.length, truncated: result.truncated },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
),
}),
),
),
}),
)
.pipe(Effect.orDie)
}),
+67 -99
View File
@@ -5,21 +5,19 @@ import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import path from "path"
import { FileSystem } from "../../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../../location"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { Ripgrep } from "../../ripgrep"
import { RelativePath } from "../../schema"
export const name = "grep"
export const Input = Schema.Struct({
pattern: FileSystem.GrepInput.fields.pattern.check(
Schema.isMinLength(1, { message: "Pattern must not be empty" }),
).annotate({
description: "Regular expression to search for in file contents (ripgrep syntax)",
}),
pattern: FileSystem.GrepInput.fields.pattern
.check(Schema.isMinLength(1, { message: "Pattern must not be empty" }))
.annotate({
description: "Regular expression to search for in file contents (ripgrep syntax)",
}),
path: Schema.optionalKey(RelativePath).annotate({
description: "File or directory to search. Defaults to the current working directory.",
}),
@@ -58,112 +56,82 @@ export const toModelContent = (matches: EncodedOutput, truncated = false) => {
export const Plugin = {
id: "opencode.tool.grep",
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service
const filesystem = yield* FileSystem.Service
const location = yield* Location.Service
const resolve = Location.paths(location).resolve
const mutation = yield* LocationMutation.Service
const permission = yield* Permission.Service
yield* ctx.tool
.transform((draft) =>
draft.add(
({
name,
options: { codemode: false },
description:
"Search file contents using regular expressions. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.",
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const target = yield* mutation.resolve({ path: input.path ?? "." })
if (target.externalDirectory)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
sessionID: context.sessionID,
agent: context.agent,
source,
})
draft.add({
name,
options: { codemode: false },
description:
"Search file contents using regular expressions. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.",
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const target = yield* mutation.resolve({ path: input.path ?? "." })
if (target.externalDirectory)
yield* permission.assert({
action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: ".",
path: input.path,
include: input.include,
limit: input.limit,
},
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
sessionID: context.sessionID,
agent: context.agent,
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 matches = yield* ripgrep
.grep({
cwd,
pattern: input.pattern,
file: info?.type === "File" ? path.basename(root) : undefined,
include: input.include,
limit: limit + 1,
})
.pipe(
Effect.timeoutOrElse({
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
orElse: () =>
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 }
}).pipe(
Effect.map((result) => ({
output: result.matches,
content: toModelContent(
result.matches.map((match) => ({
...match,
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
})),
result.truncated,
yield* permission.assert({
action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: ".",
path: input.path,
include: input.include,
limit: input.limit,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
const matches = yield* filesystem
.grep({
target,
pattern: input.pattern,
include: input.include,
limit: limit + 1,
})
.pipe(
FileSystem.searchTimeout((message) => new ToolFailure({ message })),
Effect.catchTag("FileSystem.SearchPathError", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
),
metadata: { matches: result.matches.length, truncated: result.truncated },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: error instanceof Ripgrep.InvalidPatternError
? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
: new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
)
return { matches: matches.slice(0, limit), truncated: matches.length > limit }
}).pipe(
Effect.map((result) => ({
output: result.matches,
content: toModelContent(
result.matches.map((match) => ({
...match,
entry: { ...match.entry, path: resolve(location.directory, match.entry.path) },
})),
result.truncated,
),
metadata: { matches: result.matches.length, truncated: result.truncated },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: error instanceof FileSystem.InvalidPatternError
? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
: new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
),
}),
),
),
}),
)
.pipe(Effect.orDie)
}),
+212 -287
View File
@@ -6,11 +6,8 @@ import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Schema } from "effect"
import { PlatformError } from "effect/PlatformError"
import path from "path"
import { Bom } from "@opencode-ai/util/bom"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Formatter } from "../../formatter"
import { Location } from "../../location"
import { FileMutation } from "../../file-mutation"
import { LocationMutation } from "../../location-mutation"
import { Patch } from "@opencode-ai/util/patch"
import { Permission } from "../../permission"
import DESCRIPTION from "../patch.txt"
@@ -57,286 +54,232 @@ type Prepared =
readonly moveTarget?: Target
})
interface Target {
readonly canonical: string
readonly resource: string
readonly externalDirectory?: {
readonly directory: string
readonly resource: string
}
}
type Target = LocationMutation.Target
export const Plugin = {
id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
const fs = yield* FSUtil.Service
const formatter = yield* Formatter.Service
const location = yield* Location.Service
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const permission = yield* Permission.Service
yield* ctx.tool
.transform((draft) =>
draft.add(
({
name,
options: { codemode: false, permission: "edit" },
description: DESCRIPTION,
input: Input,
output: Output,
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
const fail = (operation: string, error: unknown) => {
const completed = applied.map((item) => item.resource).join(", ")
return new ToolFailure({
message: `${operation}: ${errorMessage(error)}${completed ? `. Completed before failure: ${completed}` : ""}`,
})
}
return Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
draft.add({
name,
options: { codemode: false, permission: "edit" },
description: DESCRIPTION,
input: Input,
output: Output,
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
const fail = (operation: string, error: unknown) => {
const completed = applied.map((item) => item.resource).join(", ")
return new ToolFailure({
message: `${operation}: ${errorMessage(error)}${completed ? `. Completed before failure: ${completed}` : ""}`,
})
}
return Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
Effect.mapError((error) => new ToolFailure({ message: `patch verification failed: ${error.message}` })),
)
if (hunks.length === 0) {
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
}
const prepared: Prepared[] = []
const targets: Target[] = []
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: ${error.message}` }),
(error) => new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
),
)
if (hunks.length === 0) {
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
for (const hunk of hunks) {
yield* Effect.gen(function* () {
const target = yield* resolveHunkTarget(hunk.path)
targets.push(target)
if (target.externalDirectory) {
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
const prepared: Prepared[] = []
const targets: Target[] = []
const updates = new Map<string, string>()
for (const hunk of hunks) {
yield* Effect.gen(function* () {
const target = resolveTarget(location, hunk.path)
targets.push(target)
if (target.externalDirectory) {
yield* permission.assert({
action: "external_directory",
resources: [target.externalDirectory.resource],
save: [target.externalDirectory.resource],
metadata: {
filepath: target.canonical,
parentDir: target.externalDirectory.directory,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
if (hunk.type === "add") {
prepared.push({
...hunk,
target,
before: "",
after: Bom.split(
hunk.contents.endsWith("\n") || hunk.contents === ""
? hunk.contents
: `${hunk.contents}\n`,
).text,
})
return
}
if (hunk.type === "delete") {
const content = yield* Bom.readFile(fs, target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to delete ${target.resource}: ${errorMessage(error)}`,
}),
),
)
prepared.push({ ...hunk, target, before: content.text, after: "" })
return
}
const previous = updates.get(target.canonical)
const original =
previous ??
(yield* Effect.gen(function* () {
const stats = yield* fs.stat(target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
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({
try: () => Patch.derive(hunk.path, hunk.chunks, original),
catch: (error) =>
new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
})
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
if (moveTarget) targets.push(moveTarget)
if (moveTarget?.externalDirectory) {
yield* permission.assert({
action: "external_directory",
resources: [moveTarget.externalDirectory.resource],
save: [moveTarget.externalDirectory.resource],
metadata: {
filepath: moveTarget.canonical,
parentDir: moveTarget.externalDirectory.directory,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
prepared.push({
...hunk,
target,
content: Patch.joinBom(update.content, update.bom),
before,
after: update.content,
moveTarget,
})
if (!moveTarget) updates.set(target.canonical, Patch.joinBom(update.content, update.bom))
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, error }),
if (hunk.type === "add") {
const contents =
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
prepared.push({
...hunk,
contents,
target,
before: "",
after: FileMutation.normalizeText(contents),
})
return
}
if (hunk.type === "delete") {
const content = yield* files.read(target).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to delete ${target.resource}: ${errorMessage(error)}`,
}),
),
)
prepared.push({ ...hunk, target, before: content, after: "" })
return
}
const patchFiles = prepared.map((change) => patchFile(change))
yield* permission.assert({
action: "edit",
resources: [...new Set(targets.map((target) => target.resource))],
save: ["*"],
metadata: {
filepath: targets.map((target) => target.resource).join(", "),
diff: patchFiles.map((file) => `${file.patch}\n`).join(""),
files: patchFiles,
},
sessionID: context.sessionID,
agent: context.agent,
source,
const previous = updates.get(target.canonical)
const original =
previous ??
(yield* files.read(target).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
}),
),
))
const update = yield* Effect.try({
try: () => Patch.derive(hunk.path, hunk.chunks, original),
catch: (error) => new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
})
yield* Effect.forEach(
prepared,
(change) =>
Effect.gen(function* () {
if (change.type === "add") {
yield* fs
.writeWithDirs(
change.target.canonical,
change.contents.endsWith("\n") || change.contents === ""
? change.contents
: `${change.contents}\n`,
)
.pipe(
Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)),
)
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.canonical,
})
return
}
if (change.type === "delete") {
yield* fs
.remove(change.target.canonical)
.pipe(
Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)),
)
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.canonical,
})
return
}
if (change.moveTarget) {
const moveTarget = change.moveTarget
yield* fs
.writeWithDirs(moveTarget.canonical, change.content)
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
yield* fs.remove(change.target.canonical).pipe(
Effect.mapError((error) =>
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
),
)
applied.push({
type: change.type,
resource: change.moveTarget.resource,
target: change.moveTarget.canonical,
})
return
}
yield* fs
.writeWithDirs(change.target.canonical, change.content)
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.canonical,
})
}),
{ discard: true },
)
const formatted = new Map<string, string>()
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))
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
return Effect.succeed(patchFile(change, formatted.get(target.canonical)))
const content = FileMutation.normalizeText(update.content)
const moveTarget = hunk.movePath ? yield* resolveHunkTarget(hunk.movePath) : undefined
if (moveTarget) targets.push(moveTarget)
if (moveTarget?.externalDirectory) {
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(moveTarget.externalDirectory),
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
prepared.push({
...hunk,
target,
content: Patch.joinBom(update.content, update.bom),
before: original,
after: content,
moveTarget,
})
return { applied, files }
if (!moveTarget) updates.set(target.canonical, content)
}).pipe(
Effect.map((output) => ({
output,
content: toModelOutput(output),
metadata: { files: output.files },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: "Unable to apply patch", error }),
: new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, error }),
),
)
},
}),
),
}
const patchFiles = prepared.map((change) => patchFile(change))
yield* permission.assert({
action: "edit",
resources: [...new Set(targets.map((target) => target.resource))],
save: ["*"],
metadata: {
filepath: targets.map((target) => target.resource).join(", "),
diff: patchFiles.map((file) => `${file.patch}\n`).join(""),
files: patchFiles,
},
sessionID: context.sessionID,
agent: context.agent,
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(
prepared,
(change) =>
Effect.gen(function* () {
if (change.type === "add") {
const result = yield* files
.write({ target: change.target, content: change.contents })
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
formatted.set(change.target.canonical, result.content)
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.canonical,
})
return
}
if (change.type === "delete") {
yield* files
.remove(change.target)
.pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)))
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.canonical,
})
return
}
if (change.moveTarget) {
const moveTarget = change.moveTarget
const result = yield* files
.move({ from: change.target, to: moveTarget, content: change.content })
.pipe(
Effect.mapError((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({
type: change.type,
resource: change.moveTarget.resource,
target: change.moveTarget.canonical,
})
return
}
const result = yield* files
.write({ target: change.target, content: change.content })
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
formatted.set(change.target.canonical, result.content)
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.canonical,
})
}),
{ discard: true },
)
const fileDiffs = yield* Effect.forEach(prepared, (change) => {
if (change.type === "delete") return Effect.succeed(patchFile(change))
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
return Effect.succeed(patchFile(change, formatted.get(target.canonical)))
})
return { applied, files: fileDiffs }
}).pipe(
Effect.map((output) => ({
output,
content: toModelOutput(output),
metadata: { files: output.files },
})),
Effect.mapError((error) =>
error instanceof ToolFailure ? error : new ToolFailure({ message: "Unable to apply patch", error }),
),
)
},
}),
)
.pipe(Effect.orDie)
@@ -354,8 +297,13 @@ export const Plugin = {
)
}),
}
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.reason._tag === "NotFound") return "file does not exist"
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 {
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
const patch = trimDiff(
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, after),
)
const patch = trimDiff(createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, after))
const counts =
change.type === "delete"
? { additions: 0, deletions: change.before.split("\n").length }
@@ -414,24 +360,3 @@ function trimDiff(diff: string) {
})
.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,
}
}
+7 -14
View File
@@ -4,8 +4,7 @@ import path from "path"
import { ToolFailure } from "@opencode-ai/ai"
import type { Content } from "@opencode-ai/schema/tool"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Deferred, Effect, Schema, Scope } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Array, Deferred, Effect, Schema, Scope } from "effect"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { PluginRuntime } from "../../plugin/runtime"
@@ -82,7 +81,6 @@ export const Plugin = {
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
const runtime = yield* PluginRuntime.Service
const scope = yield* Scope.Scope
const fsUtil = yield* FSUtil.Service
const mutation = yield* LocationMutation.Service
const shell = yield* Shell.Service
const permission = yield* Permission.Service
@@ -154,10 +152,12 @@ export const Plugin = {
)
invocation.cwd = target.canonical
finalTimeout = invocation.timeout
const external = [target, ...directories]
.map((item) => item.externalDirectory)
.filter((item) => item !== undefined)
.filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index)
const external = Array.dedupeWith(
[target, ...directories]
.map((item) => item.externalDirectory)
.filter((item) => item !== undefined),
(left, right) => left.resource === right.resource,
)
if (external.length > 0)
yield* permission.assert({
action: "external_directory",
@@ -176,13 +176,6 @@ export const Plugin = {
agent: context.agent,
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 })
+54 -58
View File
@@ -9,10 +9,7 @@ export * as WriteTool from "./write"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
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 { Formatter } from "../../formatter"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { fileDiff } from "./file-diff"
@@ -48,65 +45,64 @@ export const Plugin = {
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const fs = yield* FSUtil.Service
const permission = yield* Permission.Service
yield* ctx.tool
.transform((draft) =>
draft.add(
({
name,
options: { codemode: false, permission: "edit" },
description:
"Writes a file to the local filesystem, overwriting if one exists.\n\nMissing parent directories are created automatically.\n\nUse this tool to create new files or overwrite existing files. For partial changes, use the edit tool instead.",
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const current = yield* Bom.readFile(fs, target.canonical).pipe(
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
)
const next = Bom.split(input.content)
const preview = fileDiff(
target.resource,
current?.text ?? "",
next.text,
current ? "modified" : "added",
)
yield* permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
metadata: { files: [preview] },
sessionID: context.sessionID,
agent: context.agent,
source,
})
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
const bom = (yield* Bom.readFile(fs, target.canonical)).bom
if (yield* formatter.file(target.canonical)) yield* Bom.syncFile(fs, target.canonical, bom)
return result
}).pipe(
Effect.map((output) => ({ output, content: toModelOutput(output) })),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
),
}),
),
draft.add({
name,
options: { codemode: false, permission: "edit" },
description:
"Writes a file to the local filesystem, overwriting if one exists.\n\nMissing parent directories are created automatically.\n\nUse this tool to create new files or overwrite existing files. For partial changes, use the edit tool instead.",
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const current = yield* files
.read(target)
.pipe(Effect.catchTag("FileMutation.NotFoundError", () => Effect.succeed(undefined)))
const content = FileMutation.normalizeText(input.content)
const preview = fileDiff(
target.resource,
current ?? "",
content,
current === undefined ? "added" : "modified",
)
yield* permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
metadata: { files: [preview] },
sessionID: context.sessionID,
agent: context.agent,
source,
})
const result = yield* files.write({ target, content: input.content })
return {
operation: result.operation,
target: result.target,
resource: result.resource,
existed: result.existed,
}
}).pipe(
Effect.map((output) => ({ output, content: toModelOutput(output) })),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
),
}),
)
.pipe(Effect.orDie)
}),
+47 -64
View File
@@ -1,8 +1,5 @@
export * as ConfigMigrateV1 from "./migrate"
import { Info } from "@opencode-ai/schema/config"
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
import { Schema } from "effect"
import { ConfigV1 } from "./config"
import { ConfigAgentV1 } from "./agent"
import { ConfigCommandV1 } from "./command"
@@ -13,12 +10,6 @@ import { ConfigProviderOptionsV1 } from "./provider-options"
import { Provider } from "../../provider"
import { Model } from "../../model"
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownSync(Schema.fromJsonString(Info), decodeOptions)
const encodeInfo = Schema.encodeSync(Info)
const decodeAgent = Schema.decodeUnknownSync(Schema.fromJsonString(ConfigAgent.Info), decodeOptions)
const encodeAgent = Schema.encodeSync(ConfigAgent.Info)
const keys = new Set([
"logLevel",
"server",
@@ -57,46 +48,42 @@ export function isV1(input: unknown) {
}
export function migrate(info: typeof ConfigV1.Info.Type) {
return encodeInfo(
decodeInfo(
JSON.stringify({
$schema: info.$schema,
shell: info.shell,
model: modelSelection(info.model),
default_agent: info.default_agent,
autoupdate: info.autoupdate,
share: info.share ?? (info.autoshare ? "auto" : undefined),
enterprise: info.enterprise,
username: info.username,
permissions: permissions(info.permission, info.tools),
agents: agents(info),
snapshots: info.snapshot,
watcher: info.watcher,
formatter: info.formatter,
lsp: info.lsp,
media: info.attachment,
tool_output: info.tool_output,
mcp: mcp(info),
compaction: info.compaction && {
auto: info.compaction.auto,
prune: info.compaction.prune,
keep: {
tokens: info.compaction.preserve_recent_tokens,
},
buffer: info.compaction.reserved,
},
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
commands: commands(info.command),
instructions: info.instructions,
references: info.references ?? info.reference,
experimental: experimental(info),
plugins: info.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
),
providers: providers(info.provider),
}),
return {
$schema: info.$schema,
shell: info.shell,
model: modelSelection(info.model),
default_agent: info.default_agent,
autoupdate: info.autoupdate,
share: info.share ?? (info.autoshare ? "auto" : undefined),
enterprise: info.enterprise,
username: info.username,
permissions: permissions(info.permission, info.tools),
agents: agents(info),
snapshots: info.snapshot,
watcher: info.watcher,
formatter: info.formatter,
lsp: info.lsp,
media: info.attachment,
tool_output: info.tool_output,
mcp: mcp(info),
compaction: info.compaction && {
auto: info.compaction.auto,
prune: info.compaction.prune,
keep: {
tokens: info.compaction.preserve_recent_tokens,
},
buffer: info.compaction.reserved,
},
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
commands: commands(info.command),
instructions: info.instructions,
references: info.references ?? info.reference,
experimental: experimental(info),
plugins: info.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
),
)
providers: providers(info.provider),
}
}
function experimental(info: typeof ConfigV1.Info.Type) {
@@ -167,22 +154,18 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
...(info.temperature === undefined ? {} : { temperature: info.temperature }),
...(info.top_p === undefined ? {} : { top_p: info.top_p }),
}
return encodeAgent(
decodeAgent(
JSON.stringify({
model: modelSelection(info.model, info.variant),
request: Object.keys(body).length ? { body } : undefined,
system: info.prompt,
description: info.description,
mode: info.mode,
hidden: info.hidden,
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
steps: info.steps,
disabled: info.disable,
permissions: permissions(info.permission),
}),
),
)
return {
model: modelSelection(info.model, info.variant),
request: Object.keys(body).length ? { body } : undefined,
system: info.prompt,
description: info.description,
mode: info.mode,
hidden: info.hidden,
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
steps: info.steps,
disabled: info.disable,
permissions: permissions(info.permission),
}
}
function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
+74
View File
@@ -1,6 +1,80 @@
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 { 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 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] })
+70
View File
@@ -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: [],
})
+126
View File
@@ -0,0 +1,126 @@
export * as WorkspaceEnvironment from "./environment"
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
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,
}) {}
/**
* 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,
}
+12
View File
@@ -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,
})
+1 -1
View File
@@ -516,12 +516,12 @@ describe("Config", () => {
})
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
expect(migrated.providers?.["google-vertex"]).toMatchObject({
package: undefined,
settings: { project: "test-project", location: "us-central1" },
models: {
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
},
})
expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
}),
)
+30 -2
View File
@@ -46,6 +46,7 @@ describe("FileMutation", () => {
target: target.canonical,
resource: "hello.txt",
existed: true,
content: "after",
})
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("after")
}).pipe(provide(directory)),
@@ -65,6 +66,7 @@ describe("FileMutation", () => {
target: target.canonical,
resource: "src/nested/hello.txt",
existed: false,
content: "hello",
})
expect(yield* Effect.promise(() => fs.readFile(result.target, "utf8"))).toBe("hello")
}).pipe(provide(directory)),
@@ -80,8 +82,8 @@ describe("FileMutation", () => {
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
const files = yield* FileMutation.Service
yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
yield* files.writeTextPreservingBom({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
yield* files.write({ target: preserved, content: "\uFEFFafter" })
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(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", () =>
withTmp((directory) =>
withTmp((outside) =>
@@ -102,6 +129,7 @@ describe("FileMutation", () => {
target: target.canonical,
resource: target.resource,
existed: false,
content: "external",
})
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("external")
}).pipe(provide(directory)),
+161
View File
@@ -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", () =>
withTmp((directory) =>
withTmp((outside) =>
+4 -8
View File
@@ -3,7 +3,6 @@ import { Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { PtyID } from "@opencode-ai/core/pty/schema"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Workspace } from "@opencode-ai/core/workspace"
import { testEffect } from "../lib/effect"
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* () {
const tickets = yield* PtyTicket.Service
const ptyID = PtyID.ascending()
const workspaceID = Workspace.ID.ascending()
const issued = yield* tickets.issue({ ptyID, workspaceID })
const issued = yield* tickets.issue({ ptyID })
expect(yield* tickets.consume({ ptyID, workspaceID: Workspace.ID.ascending(), ticket: issued.ticket })).toBe(
false,
)
expect(yield* tickets.consume({ ptyID, workspaceID, ticket: issued.ticket })).toBe(true)
expect(yield* tickets.consume({ ptyID: PtyID.ascending(), ticket: issued.ticket })).toBe(false)
expect(yield* tickets.consume({ ptyID, ticket: issued.ticket })).toBe(true)
}),
)
})
+2 -4
View File
@@ -23,7 +23,6 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Workspace } from "@opencode-ai/core/workspace"
import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir"
@@ -119,7 +118,6 @@ describe("Session.create", () => {
it.effect("stores supplied immutable create attributes", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const workspaceID = Workspace.ID.make("wrk_test")
const model = Model.Ref.make({
id: Model.ID.make("sonnet"),
providerID: Provider.ID.anthropic,
@@ -128,11 +126,11 @@ describe("Session.create", () => {
expect(
yield* session.create({
location: Location.Ref.make({ directory: location.directory, workspaceID }),
location: Location.Ref.make({ directory: location.directory }),
agent: Agent.ID.make("build"),
model,
}),
).toMatchObject({ location: { directory: location.directory, workspaceID }, agent: "build", model })
).toMatchObject({ location: { directory: location.directory }, agent: "build", model })
}),
)
+25 -3
View File
@@ -13,6 +13,8 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { Tool } from "@opencode-ai/core/tool"
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 { tmpdir } from "./fixture/tmpdir"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -22,7 +24,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const patchToolNode = makeLocationNode({
name: "test/patch-tool-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")
@@ -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", () =>
withTempTool((directory, registry) =>
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.promise(() => tmpdir()),
(tmp) => {
@@ -847,7 +869,7 @@ describe("PatchTool", () => {
call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"),
),
).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")
}),
tmp.path,
+1
View File
@@ -155,6 +155,7 @@ const mutation = Layer.succeed(
const externalResource = path.join(directory, "*").replaceAll("\\", "/")
return Effect.succeed({
canonical,
absolute: canonical,
resource,
externalDirectory: external
? {
+30 -20
View File
@@ -6,11 +6,9 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { GlobTool } from "@opencode-ai/core/tool/plugin/glob"
@@ -24,19 +22,12 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
const globToolNode = makeLocationNode({
name: "test/glob-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.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 grepToolNode = makeLocationNode({
name: "test/grep-tool-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")
@@ -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", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "haystack\n")).pipe(
Effect.andThen(
withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" }))),
),
Effect.andThen(withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" })))),
Effect.tap((result) =>
Effect.sync(() => {
expect(result).toMatchObject({
@@ -297,9 +311,7 @@ describe("search tools", () => {
(tmp) =>
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "content\n")).pipe(
Effect.andThen(
withTools(tmp.path, (registry) =>
executeTool(registry, call("glob", { path: "file.txt", pattern: "*" })),
),
withTools(tmp.path, (registry) => executeTool(registry, call("glob", { path: "file.txt", pattern: "*" }))),
),
Effect.tap((result) =>
Effect.sync(() => {
@@ -331,9 +343,7 @@ describe("search tools", () => {
Effect.sync(() => {
expect(result.status).toBe("completed")
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "glob"])
expect(assertions[0]?.resources).toEqual([
path.join(outside.path, "*").replaceAll("\\", "/"),
])
expect(assertions[0]?.resources).toEqual([path.join(outside.path, "*").replaceAll("\\", "/")])
}),
),
)
+150
View File
@@ -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,67 @@
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 { 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]), [
[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")
}),
)
})
+175
View File
@@ -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,182 @@
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.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).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(-1)).toMatchObject({ content: [{ type: "text", text: "done" }] })
expect(connects.count).toBe(1)
}),
)
})
-33
View File
@@ -23,44 +23,11 @@ export type ProviderContext = {
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 = {
client: ReturnType<typeof createOpencodeClient>
project: Project
directory: string
worktree: string
experimental_workspace: {
register(type: string, adapter: WorkspaceAdapter): void
}
serverUrl: URL
$: BunShell
}
File diff suppressed because it is too large Load Diff
-1
View File
@@ -6,7 +6,6 @@ export const LocationQuery = Schema.Struct({
location: Schema.optional(
Schema.Struct({
directory: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
}),
),
}).annotate({ identifier: "LocationQuery" })
+1 -1
View File
@@ -130,7 +130,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
"x-websocket": true,
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",
name,
schema: { type: "string" },
-2
View File
@@ -6,7 +6,6 @@ import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
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 { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import {
@@ -42,7 +41,6 @@ const ParentIDFilter = Schema.Union([
})
const SessionsQueryFields = {
workspace: Workspace.ID.pipe(Schema.optional),
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
}),
@@ -6,7 +6,6 @@ import { Session } from "@opencode-ai/schema/session"
describe("SessionsCursor", () => {
test("round trips without Node globals", async () => {
const input = {
workspace: undefined,
search: "protocol",
order: "desc" as const,
anchor: { id: Session.ID.make("ses_test"), time: 1, direction: "next" as const },
+30 -30
View File
@@ -3,7 +3,7 @@ export * as Config from "./config.js"
import { Schema } from "effect"
import { ephemeral, inventory } from "./event.js"
import { Permission } from "./permission.js"
import { AbsolutePath, optional } from "./schema.js"
import { AbsolutePath } from "./schema.js"
import { ConfigAgent } from "./config/agent.js"
import { ConfigMedia } from "./config/media.js"
import { ConfigCompaction } from "./config/compaction.js"
@@ -22,94 +22,94 @@ import { ConfigWatcher } from "./config/watcher.js"
import { ConfigWarming } from "./config/warming.js"
export class Info extends Schema.Class<Info>("Config.Info")({
$schema: optional(Schema.String).annotate({
$schema: Schema.optional(Schema.String).annotate({
description: "JSON schema reference for configuration validation",
}),
shell: Schema.String.pipe(optional).annotate({
shell: Schema.String.pipe(Schema.optional).annotate({
description: "Default shell to use for terminal and shell tool execution",
}),
model: ConfigModel.Selection.pipe(optional).annotate({
model: ConfigModel.Selection.pipe(Schema.optional).annotate({
description: "Default model to use when no session or agent model is selected",
}),
default_agent: Schema.String.pipe(optional).annotate({
default_agent: Schema.String.pipe(Schema.optional).annotate({
description: "Default primary agent to use when no session agent is selected",
}),
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
.pipe(optional)
.pipe(Schema.optional)
.annotate({
description: "Automatically update or notify when a new version is available",
}),
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({
description: "Control whether sessions may be shared manually, automatically, or not at all",
}),
enterprise: Schema.Struct({
url: Schema.String.pipe(optional),
url: Schema.String.pipe(Schema.optional),
})
.pipe(optional)
.pipe(Schema.optional)
.annotate({
description: "Enterprise sharing service configuration",
}),
username: Schema.String.pipe(optional).annotate({
username: Schema.String.pipe(Schema.optional).annotate({
description: "Username displayed in conversations and used for telemetry identity",
}),
permissions: Permission.Ruleset.pipe(optional).annotate({
permissions: Permission.Ruleset.pipe(Schema.optional).annotate({
description: "Ordered tool permission rules applied to agent tool use",
}),
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(optional).annotate({
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
description: "Named built-in agent overrides and custom agent definitions",
}),
snapshots: Schema.Boolean.pipe(optional).annotate({
snapshots: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Enable snapshots used for undo and revert behavior",
}),
watcher: ConfigWatcher.Info.pipe(optional).annotate({
watcher: ConfigWatcher.Info.pipe(Schema.optional).annotate({
description: "Filesystem watcher configuration",
}),
formatter: ConfigFormatter.Info.pipe(optional).annotate({
formatter: ConfigFormatter.Info.pipe(Schema.optional).annotate({
description: "Enable built-in formatters or configure formatter overrides",
}),
lsp: ConfigLSP.Info.pipe(optional).annotate({
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
description: "Enable built-in language servers or configure server overrides",
}),
media: ConfigMedia.Info.pipe(optional).annotate({
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
description: "Media processing configuration",
}),
tool_output: ConfigToolOutput.Info.pipe(optional).annotate({
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
description: "Tool output truncation thresholds",
}),
mcp: ConfigMCP.Info.pipe(optional).annotate({
mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({
description: "MCP server configuration",
}),
compaction: ConfigCompaction.Info.pipe(optional).annotate({
compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({
description: "Conversation compaction behavior",
}),
skills: Schema.String.pipe(Schema.Array, optional).annotate({
skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
description: "Additional paths or URLs to discover skills from",
}),
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(optional).annotate({
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(Schema.optional).annotate({
description: "Named slash command definitions",
}),
instructions: Schema.String.pipe(Schema.Array, optional).annotate({
instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
description: "Additional paths or URLs supplying ambient instructions",
}),
references: ConfigReference.Info.pipe(optional).annotate({
references: ConfigReference.Info.pipe(Schema.optional).annotate({
description: "Named local directories or Git repositories available as external context",
}),
websearch: ConfigWebSearch.Info.pipe(optional).annotate({
websearch: ConfigWebSearch.Info.pipe(Schema.optional).annotate({
description: "Web search provider selection",
}),
plugins: ConfigPlugin.Plugins.pipe(optional).annotate({
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
description: "Ordered plugin enablement directives and external package declarations",
}),
warming: ConfigWarming.Warming.pipe(optional).annotate({
warming: ConfigWarming.Warming.pipe(Schema.optional).annotate({
description: "Keep recently active sessions warm with transient model requests (default: false)",
}),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(optional),
experimental: ConfigExperimental.Info.pipe(optional),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
experimental: ConfigExperimental.Info.pipe(Schema.optional),
}) {}
export class Document extends Schema.Class<Document>("Config.Document")({
type: Schema.Literal("document"),
path: Schema.String.pipe(optional),
path: Schema.String.pipe(Schema.optional),
info: Info,
}) {}
+11 -11
View File
@@ -2,21 +2,21 @@ export * as ConfigAgent from "./agent.js"
import { Schema } from "effect"
import { Permission } from "../permission.js"
import { optional, PositiveInt } from "../schema.js"
import { PositiveInt } from "../schema.js"
import { ConfigModel } from "./model.js"
import { ConfigProvider } from "./provider.js"
export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
export class Info extends Schema.Class<Info>("Config.Agent")({
model: ConfigModel.Selection.pipe(optional),
request: ConfigProvider.Request.pipe(optional),
system: Schema.String.pipe(optional),
description: Schema.String.pipe(optional),
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(optional),
hidden: Schema.Boolean.pipe(optional),
color: Color.pipe(optional),
steps: PositiveInt.pipe(optional),
disabled: Schema.Boolean.pipe(optional),
permissions: Permission.Ruleset.pipe(optional),
model: ConfigModel.Selection.pipe(Schema.optional),
request: ConfigProvider.Request.pipe(Schema.optional),
system: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
color: Color.pipe(Schema.optional),
steps: PositiveInt.pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
permissions: Permission.Ruleset.pipe(Schema.optional),
}) {}
+4 -5
View File
@@ -1,13 +1,12 @@
export * as ConfigCommand from "./command.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
import { ConfigModel } from "./model.js"
export class Info extends Schema.Class<Info>("Config.Command")({
template: Schema.String,
description: Schema.String.pipe(optional),
agent: Schema.String.pipe(optional),
model: ConfigModel.Selection.pipe(optional),
subtask: Schema.Boolean.pipe(optional),
description: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
model: ConfigModel.Selection.pipe(Schema.optional),
subtask: Schema.Boolean.pipe(Schema.optional),
}) {}
+5 -5
View File
@@ -1,14 +1,14 @@
export * as ConfigCompaction from "./compaction.js"
import { Schema } from "effect"
import { NonNegativeInt, optional } from "../schema.js"
import { NonNegativeInt } from "../schema.js"
export class Keep extends Schema.Class<Keep>("Config.Compaction.Keep")({
tokens: NonNegativeInt.pipe(optional),
tokens: NonNegativeInt.pipe(Schema.optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Compaction")({
auto: Schema.Boolean.pipe(optional),
keep: Keep.pipe(optional),
buffer: NonNegativeInt.pipe(optional),
auto: Schema.Boolean.pipe(Schema.optional),
keep: Keep.pipe(Schema.optional),
buffer: NonNegativeInt.pipe(Schema.optional),
}) {}
+3 -3
View File
@@ -1,14 +1,14 @@
export * as ConfigExperimental from "./experimental.js"
import { Schema } from "effect"
import { NonNegativeInt, optional } from "../schema.js"
import { NonNegativeInt } from "../schema.js"
import { ConfigPolicy } from "./policy.js"
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
subagent_depth: NonNegativeInt.pipe(optional).annotate({
subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({
description: "Maximum subagent nesting depth. Defaults to 1.",
}),
policies: ConfigPolicy.Info.pipe(Schema.Array, optional).annotate({
policies: ConfigPolicy.Info.pipe(Schema.Array, Schema.optional).annotate({
description: "Ordered policies controlling access to configured resources",
}),
}) {}
+4 -5
View File
@@ -1,13 +1,12 @@
export * as ConfigFormatter from "./formatter.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Entry extends Schema.Class<Entry>("Config.Formatter.Entry")({
disabled: Schema.Boolean.pipe(optional),
command: Schema.String.pipe(Schema.Array, optional),
environment: Schema.Record(Schema.String, Schema.String).pipe(optional),
extensions: Schema.String.pipe(Schema.Array, optional),
disabled: Schema.Boolean.pipe(Schema.optional),
command: Schema.String.pipe(Schema.Array, Schema.optional),
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
extensions: Schema.String.pipe(Schema.Array, Schema.optional),
}) {}
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
+4 -5
View File
@@ -1,7 +1,6 @@
export * as ConfigLSP from "./lsp.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export const Disabled = Schema.Struct({
disabled: Schema.Literal(true),
@@ -9,10 +8,10 @@ export const Disabled = Schema.Struct({
export class Server extends Schema.Class<Server>("Config.LSP.Server")({
command: Schema.String.pipe(Schema.Array),
extensions: Schema.String.pipe(Schema.Array, optional),
disabled: Schema.Boolean.pipe(optional),
env: Schema.Record(Schema.String, Schema.String).pipe(optional),
initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
extensions: Schema.String.pipe(Schema.Array, Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
env: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}) {}
export const Entry = Schema.Union([Disabled, Server])
+2 -3
View File
@@ -2,7 +2,6 @@ export * as ConfigMCP from "./mcp.js"
import { Schema } from "effect"
import { Mcp } from "../mcp.js"
import { optional } from "../schema.js"
export const Timeout = Mcp.TimeoutConfig
export type Timeout = Mcp.TimeoutConfig
@@ -15,6 +14,6 @@ export type Remote = Mcp.RemoteConfig
export const Server = Mcp.ServerConfig
export class Info extends Schema.Class<Info>("Config.MCP")({
timeout: Timeout.pipe(optional),
servers: Schema.Record(Schema.String, Server).pipe(optional),
timeout: Timeout.pipe(Schema.optional),
servers: Schema.Record(Schema.String, Server).pipe(Schema.optional),
}) {}
+6 -6
View File
@@ -1,15 +1,15 @@
export * as ConfigMedia from "./media.js"
import { Schema } from "effect"
import { optional, PositiveInt } from "../schema.js"
import { PositiveInt } from "../schema.js"
export class Image extends Schema.Class<Image>("Config.Media.Image")({
auto_resize: Schema.Boolean.pipe(optional),
max_width: PositiveInt.pipe(optional),
max_height: PositiveInt.pipe(optional),
max_base64_bytes: PositiveInt.pipe(optional),
auto_resize: Schema.Boolean.pipe(Schema.optional),
max_width: PositiveInt.pipe(Schema.optional),
max_height: PositiveInt.pipe(Schema.optional),
max_base64_bytes: PositiveInt.pipe(Schema.optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Media")({
image: Image.pipe(optional),
image: Image.pipe(Schema.optional),
}) {}
+1 -2
View File
@@ -3,7 +3,6 @@ export * as ConfigModel from "./model.js"
import { Schema, SchemaGetter } from "effect"
import { Model } from "../model.js"
import { Provider } from "../provider.js"
import { optional } from "../schema.js"
const ProviderID = Provider.ID.check(Schema.isPattern(/^[^/#]+$/))
const ModelID = Model.ID.check(Schema.isPattern(/^[^#]+$/))
@@ -12,7 +11,7 @@ const VariantID = Model.VariantID.check(Schema.isPattern(/^[^#]+$/))
const Explicit = Schema.Struct({
providerID: ProviderID,
model: ModelID,
variant: VariantID.pipe(optional),
variant: VariantID.pipe(Schema.optional),
})
const Short = Schema.String.check(Schema.isPattern(/^[^/#]+\/[^#]+(?:#[^#]+)?$/))
+1 -2
View File
@@ -1,11 +1,10 @@
export * as ConfigPlugin from "./plugin.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Entry extends Schema.Class<Entry>("Config.Plugin.Entry")({
package: Schema.String,
options: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
options: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}) {}
export const Plugin = Schema.Union([Schema.String, Entry])
+24 -25
View File
@@ -3,14 +3,13 @@ export * as ConfigProvider from "./provider.js"
import { Schema } from "effect"
import { Money } from "../money.js"
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
import { optional } from "../schema.js"
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
export const Overlays = {
settings: JsonRecord.pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
body: JsonRecord.pipe(optional),
settings: JsonRecord.pipe(Schema.optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
body: JsonRecord.pipe(Schema.optional),
}
export class Request extends Schema.Class<Request>("Config.Provider.Request")({
@@ -19,47 +18,47 @@ export class Request extends Schema.Class<Request>("Config.Provider.Request")({
}) {}
class Cache extends Schema.Class<Cache>("Config.Model.Cost.Cache")({
read: Money.USDPerMillionTokens.pipe(optional),
write: Money.USDPerMillionTokens.pipe(optional),
read: Money.USDPerMillionTokens.pipe(Schema.optional),
write: Money.USDPerMillionTokens.pipe(Schema.optional),
}) {}
class Cost extends Schema.Class<Cost>("Config.Model.Cost")({
tier: Schema.Struct({
type: Schema.Literal("context"),
size: Schema.Int,
}).pipe(optional),
}).pipe(Schema.optional),
input: Money.USDPerMillionTokens,
output: Money.USDPerMillionTokens,
cache: Cache.pipe(optional),
cache: Cache.pipe(Schema.optional),
}) {}
class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
context: Schema.Int.pipe(optional),
input: Schema.Int.pipe(optional),
output: Schema.Int.pipe(optional),
context: Schema.Int.pipe(Schema.optional),
input: Schema.Int.pipe(Schema.optional),
output: Schema.Int.pipe(Schema.optional),
}) {}
class Model extends Schema.Class<Model>("Config.Model")({
modelID: ID.pipe(optional),
family: Family.pipe(optional),
name: Schema.String.pipe(optional),
compatibility: Compatibility.pipe(optional),
package: Schema.String.pipe(optional),
modelID: ID.pipe(Schema.optional),
family: Family.pipe(Schema.optional),
name: Schema.String.pipe(Schema.optional),
compatibility: Compatibility.pipe(Schema.optional),
package: Schema.String.pipe(Schema.optional),
...Overlays,
capabilities: Capabilities.pipe(optional),
capabilities: Capabilities.pipe(Schema.optional),
variants: Schema.Struct({
id: VariantID,
...Overlays,
}).pipe(Schema.Array, optional),
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(optional),
disabled: Schema.Boolean.pipe(optional),
limit: Limit.pipe(optional),
}).pipe(Schema.Array, Schema.optional),
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
limit: Limit.pipe(Schema.optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Provider")({
name: Schema.String.pipe(optional),
env: Schema.String.pipe(Schema.Array, optional),
package: Schema.String.pipe(optional),
name: Schema.String.pipe(Schema.optional),
env: Schema.String.pipe(Schema.Array, Schema.optional),
package: Schema.String.pipe(Schema.optional),
...Overlays,
models: Schema.Record(Schema.String, Model).pipe(optional),
models: Schema.Record(Schema.String, Model).pipe(Schema.optional),
}) {}
+5 -6
View File
@@ -1,19 +1,18 @@
export * as ConfigReference from "./reference.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Git extends Schema.Class<Git>("Config.Reference.Git")({
repository: Schema.String,
branch: Schema.String.pipe(optional),
description: Schema.String.pipe(optional),
hidden: Schema.Boolean.pipe(optional),
branch: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
}) {}
export class Local extends Schema.Class<Local>("Config.Reference.Local")({
path: Schema.String,
description: Schema.String.pipe(optional),
hidden: Schema.Boolean.pipe(optional),
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
}) {}
export const Entry = Schema.Union([Schema.String, Git, Local])
+3 -3
View File
@@ -1,9 +1,9 @@
export * as ConfigToolOutput from "./tool-output.js"
import { Schema } from "effect"
import { optional, PositiveInt } from "../schema.js"
import { PositiveInt } from "../schema.js"
export class Info extends Schema.Class<Info>("Config.ToolOutput")({
max_lines: PositiveInt.pipe(optional),
max_bytes: PositiveInt.pipe(optional),
max_lines: PositiveInt.pipe(Schema.optional),
max_bytes: PositiveInt.pipe(Schema.optional),
}) {}
+3 -4
View File
@@ -1,16 +1,15 @@
export * as ConfigWarming from "./warming.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Info extends Schema.Class<Info>("Config.Warming")({
prompt: Schema.String.pipe(optional).annotate({
prompt: Schema.String.pipe(Schema.optional).annotate({
description: "Prompt sent for keep-alive requests",
}),
interval: Schema.DurationFromString.pipe(optional).annotate({
interval: Schema.DurationFromString.pipe(Schema.optional).annotate({
description: 'Idle time between keep-alive requests (default: "4 minutes")',
}),
duration: Schema.DurationFromString.pipe(optional).annotate({
duration: Schema.DurationFromString.pipe(Schema.optional).annotate({
description: 'Time after the last active request to keep a session warm (default: "30 minutes")',
}),
}) {}
+1 -2
View File
@@ -1,8 +1,7 @@
export * as ConfigWatcher from "./watcher.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Info extends Schema.Class<Info>("Config.Watcher")({
ignore: Schema.String.pipe(Schema.Array, optional),
ignore: Schema.String.pipe(Schema.Array, Schema.optional),
}) {}
-2
View File
@@ -31,7 +31,6 @@ import { SessionEvent } from "./session-event.js"
import { SessionStatusEvent } from "./session-status-event.js"
import { TuiEvent } from "./tui-event.js"
import { VcsEvent } from "./vcs-event.js"
import { WorkspaceEvent } from "./workspace-event.js"
import { WorktreeEvent } from "./worktree-event.js"
import { WebSearch } from "./websearch.js"
@@ -89,7 +88,6 @@ export const Definitions = Event.inventory(
...SessionStatusEvent.Definitions,
...SessionCompactionEvent.Definitions,
...VcsEvent.Definitions,
...WorkspaceEvent.Definitions,
...WorktreeEvent.Definitions,
...ServerEvent.Definitions,
)
+18 -18
View File
@@ -5,13 +5,13 @@ import { optional, PositiveInt } from "./schema.js"
import { IntegrationID } from "./integration-id.js"
export class TimeoutConfig extends Schema.Class<TimeoutConfig>("Mcp.TimeoutConfig")({
startup: PositiveInt.pipe(optional).annotate({
startup: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to establish and initialize the MCP server.",
}),
catalog: PositiveInt.pipe(optional).annotate({
catalog: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to wait for MCP discovery requests such as tools/list and prompts/list.",
}),
execution: PositiveInt.pipe(optional).annotate({
execution: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to wait for MCP tool and prompt execution.",
}),
}) {}
@@ -19,35 +19,35 @@ export class TimeoutConfig extends Schema.Class<TimeoutConfig>("Mcp.TimeoutConfi
export class LocalConfig extends Schema.Class<LocalConfig>("Mcp.LocalConfig")({
type: Schema.Literal("local"),
command: Schema.String.pipe(Schema.Array),
cwd: Schema.String.pipe(optional).annotate({
cwd: Schema.String.pipe(Schema.optional).annotate({
description: "Working directory for the MCP server process. Relative paths resolve from the workspace directory.",
}),
environment: Schema.Record(Schema.String, Schema.String).pipe(optional),
disabled: Schema.Boolean.pipe(optional),
codemode: Schema.Boolean.pipe(optional).annotate({
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Expose this server's tools through Code Mode. Defaults to true.",
}),
timeout: TimeoutConfig.pipe(optional),
timeout: TimeoutConfig.pipe(Schema.optional),
}) {}
export class OAuthConfig extends Schema.Class<OAuthConfig>("Mcp.OAuthConfig")({
client_id: Schema.String.pipe(optional),
client_secret: Schema.String.pipe(optional),
scope: Schema.String.pipe(optional),
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(optional),
redirect_uri: Schema.String.pipe(optional),
client_id: Schema.String.pipe(Schema.optional),
client_secret: Schema.String.pipe(Schema.optional),
scope: Schema.String.pipe(Schema.optional),
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(Schema.optional),
redirect_uri: Schema.String.pipe(Schema.optional),
}) {}
export class RemoteConfig extends Schema.Class<RemoteConfig>("Mcp.RemoteConfig")({
type: Schema.Literal("remote"),
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
oauth: Schema.Union([OAuthConfig, Schema.Literal(false)]).pipe(optional),
disabled: Schema.Boolean.pipe(optional),
codemode: Schema.Boolean.pipe(optional).annotate({
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
oauth: Schema.Union([OAuthConfig, Schema.Literal(false)]).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Expose this server's tools through Code Mode. Defaults to true.",
}),
timeout: TimeoutConfig.pipe(optional),
timeout: TimeoutConfig.pipe(Schema.optional),
}) {}
export const ServerConfig = Schema.Union([LocalConfig, RemoteConfig]).pipe(Schema.toTaggedUnion("type"))
-32
View File
@@ -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)
-3
View File
@@ -1,9 +1,6 @@
export * as Workspace from "./workspace.js"
import { WorkspaceEvent } from "./workspace-event.js"
import { WorkspaceID } from "./workspace-id.js"
export const ID = WorkspaceID
export type ID = WorkspaceID
export const Event = WorkspaceEvent
+1 -47
View File
@@ -1,10 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Config } from "../src/config.js"
import { ConfigAgent } from "../src/config/agent.js"
import { ConfigMCP } from "../src/config/mcp.js"
import { ConfigProvider } from "../src/config/provider.js"
import { Mcp } from "../src/mcp.js"
import { AbsolutePath } from "../src/schema.js"
describe("Config.Entry", () => {
@@ -33,14 +29,7 @@ describe("Config.Entry", () => {
expect(decoded).toEqual(entries)
expect(decoded[0]).toBeInstanceOf(Config.Document)
expect(decoded[1]).not.toHaveProperty("path")
expect(decoded.map((entry) => entry.type)).toEqual([
"document",
"document",
"directory",
"file",
"agents",
"claude",
])
expect(decoded.map((entry) => entry.type)).toEqual(["document", "document", "directory", "file", "agents", "claude"])
expect(decoded[0]?.type === "document" ? decoded[0].info.permissions : undefined).toEqual([
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
@@ -50,39 +39,4 @@ describe("Config.Entry", () => {
test("has a stable public identifier", () => {
expect(Config.Entry.ast.annotations?.identifier).toBe("Config.Entry")
})
test("omits undefined optional properties while encoding", () => {
const entry = new Config.Document({
type: "document",
path: undefined,
info: new Config.Info({
default_agent: undefined,
agents: { reviewer: new ConfigAgent.Info({ description: undefined }) },
mcp: new ConfigMCP.Info({
timeout: undefined,
servers: {
docs: new Mcp.RemoteConfig({
type: "remote",
url: "https://example.com/mcp",
headers: undefined,
oauth: new Mcp.OAuthConfig({ client_id: undefined }),
}),
},
}),
providers: { custom: new ConfigProvider.Info({ headers: undefined }) },
}),
})
const encoded = Schema.encodeSync(Config.Entry)(entry)
if (encoded.type !== "document") throw new Error("Expected a config document")
expect(encoded).not.toHaveProperty("path")
expect(encoded.info).not.toHaveProperty("default_agent")
expect(encoded.info.agents?.reviewer).not.toHaveProperty("description")
expect(encoded.info.mcp).not.toHaveProperty("timeout")
const docs = encoded.info.mcp?.servers?.docs
if (docs?.type !== "remote" || docs.oauth === false) throw new Error("Expected a remote MCP server")
expect(docs).not.toHaveProperty("headers")
expect(docs.oauth).not.toHaveProperty("client_id")
expect(encoded.info.providers?.custom).not.toHaveProperty("headers")
})
})
+1 -4
View File
@@ -9,7 +9,6 @@ import {
Project,
Reference,
Session,
Workspace,
} from "../src/index.js"
import { EventManifest } from "../src/event-manifest.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 { SessionID } from "../src/session-id.js"
import { SessionMessage } from "../src/session-message.js"
import { WorkspaceEvent } from "../src/workspace-event.js"
describe("public event manifest", () => {
test("owns the complete public event surface", () => {
@@ -48,8 +47,6 @@ describe("public event manifest", () => {
test("uses canonical definitions for current public events", () => {
expect(Session.Event).toBe(SessionEvent)
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("agent.updated")).toBe(Agent.Event.Updated)
expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated)
+3 -1
View File
@@ -20,9 +20,11 @@
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:"
"effect": "catalog:",
"modal": "0.9.0"
},
"devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
-1
View File
@@ -10,7 +10,6 @@ export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (han
const location = yield* Location.Service
return new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
})
}),
+1 -1
View File
@@ -19,7 +19,7 @@ import { PtyEnvironment } from "../pty-environment"
const ticketScope = Effect.gen(function* () {
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) =>
+3 -2
View File
@@ -37,7 +37,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
: ctx.query
const page = yield* session.list({
...query,
workspaceID: query.workspace,
limit: ctx.query.limit ?? DefaultSessionsLimit,
})
const sessions = page.data
@@ -213,7 +212,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.move({
sessionID: ctx.params.sessionID,
directory: ctx.payload.directory,
workspaceID: ctx.payload.workspaceID,
})
.pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
@@ -404,6 +402,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()
}),
+6 -2
View File
@@ -2,7 +2,7 @@ import { Shell } from "@opencode-ai/core/shell"
import { Location } from "@opencode-ai/core/location"
import { Effect } from "effect"
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 { response } from "../location"
@@ -21,7 +21,11 @@ export const ShellHandler = HttpApiBuilder.group(Api, "server.shell", (handlers)
Effect.fn(function* (ctx) {
const shell = yield* Shell.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(
-3
View File
@@ -1,7 +1,6 @@
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Workspace } from "@opencode-ai/core/workspace"
import { Effect, Layer } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
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 {
const query = new URL(request.url, "http://localhost").searchParams
const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
const directory =
query.get("location[directory]") ||
(request.headers["x-opencode-directory"] ? decode(request.headers["x-opencode-directory"]) : process.cwd())
return Location.Ref.make({
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 { Session } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Workspace } from "@opencode-ai/core/workspace"
import { eq } from "drizzle-orm"
import { Effect, Layer, Schema } from "effect"
import { HttpRouter } from "effect/unstable/http"
@@ -40,7 +39,7 @@ export const sessionLocationLayer = Layer.effect(
),
)
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.select({ directory: SessionTable.directory, workspace_id: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
@@ -54,9 +53,11 @@ export const sessionLocationLayer = Layer.effect(
return yield* effect.pipe(
Effect.provide(
locations.get(
// Hosted-ness is a server-side fact from the session row; clients
// never assert workspace identity per-request.
Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
workspaceID: row.workspace_id ?? undefined,
}),
),
),
+3
View File
@@ -40,6 +40,8 @@ import { PtyEnvironment } from "./pty-environment"
import { layer } from "./location"
import { formLocationLayer } from "./middleware/form-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 type { ServerOptions } from "./options"
@@ -84,6 +86,7 @@ function makeRoutes<AuthError, AuthServices>(
const pluginRuntimeCell = PluginRuntime.makeCell()
const replacements: LayerNode.Replacements = [
[Database.node, Database.configured(options.database)],
[WorkspaceDriver.registryNode, ServerWorkspaceDrivers.node],
[Bus.node, Bus.configured({ persist: options.events?.persist })],
[App.node, App.configured(options.app)],
[ModelsDev.node, ModelsDev.configured(options.models)],
+18
View File
@@ -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: [],
})
+201
View File
@@ -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]])))
}
+4 -20
View File
@@ -16,9 +16,7 @@ it.live("returns ordered config entries for the requested directory", () =>
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const config = path.join(project, "opencode.json")
yield* Effect.promise(() =>
Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]),
)
yield* Effect.promise(() => Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]))
yield* Effect.promise(() =>
fs.writeFile(
config,
@@ -27,7 +25,6 @@ it.live("returns ordered config entries for the requested directory", () =>
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
],
mcp: { servers: { docs: { type: "remote", url: "https://example.com/mcp" } } },
}),
),
)
@@ -45,8 +42,9 @@ it.live("returns ordered config entries for the requested directory", () =>
const response = yield* Effect.promise(() =>
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
)
const body: unknown = yield* Effect.promise(() => response.json())
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(body)
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(
yield* Effect.promise(() => response.json()),
)
expect(response.status).toBe(200)
expect(Array.isArray(entries)).toBe(true)
@@ -58,21 +56,7 @@ it.live("returns ordered config entries for the requested directory", () =>
{ action: "shell", resource: "git status", effect: "allow" },
])
expect(entries.some((entry) => entry.type === "file" && entry.path === config)).toBe(true)
if (!Array.isArray(body)) throw new Error("Expected a config entry array")
const raw = body.find((entry) => isRecord(entry) && entry["type"] === "document" && entry["path"] === config)
if (!isRecord(raw) || !isRecord(raw["info"])) throw new Error("Expected a config document")
expect(raw["info"]).not.toHaveProperty("default_agent")
expect(raw["info"]).not.toHaveProperty("model")
const mcp = raw["info"]["mcp"]
if (!isRecord(mcp) || !isRecord(mcp["servers"]) || !isRecord(mcp["servers"]["docs"]))
throw new Error("Expected an MCP server config")
expect(mcp["servers"]["docs"]).not.toHaveProperty("headers")
expect(mcp["servers"]["docs"]).not.toHaveProperty("oauth")
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(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,146 @@
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" }),
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).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(-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 },
)
})
-1
View File
@@ -584,7 +584,6 @@ function App(props: { pair?: DialogPairCredentials }) {
order: "desc",
parentID: null,
directory: location.directory,
workspace: location.workspaceID,
})
.then((response) => {
const match = response.data[0]?.id

Some files were not shown because too many files have changed in this diff Show More