diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 00000000..d0b61bec
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,24 @@
+// For format details, see https://aka.ms/devcontainer.json. For config options, see the
+// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
+{
+ "name": "Node.js & TypeScript",
+ // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
+ "image": "mcr.microsoft.com/devcontainers/typescript-node:5-24-trixie",
+
+ "postCreateCommand": "bun install"
+
+ // Features to add to the dev container. More info: https://containers.dev/features.
+ // "features": {},
+
+ // Use 'forwardPorts' to make a list of ports inside the container available locally.
+ // "forwardPorts": [],
+
+ // Use 'postCreateCommand' to run commands after the container is created.
+ // "postCreateCommand": "yarn install",
+
+ // Configure tool-specific properties.
+ // "customizations": {},
+
+ // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
+ // "remoteUser": "root"
+}
diff --git a/bun.lock b/bun.lock
index 1e3a6425..47da2b1f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1442,8 +1442,6 @@
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
- "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
-
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.12.0", "", {}, "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA=="],
"@bufbuild/protoplugin": ["@bufbuild/protoplugin@2.12.0", "", { "dependencies": { "@bufbuild/protobuf": "2.12.0", "@typescript/vfs": "^1.6.2", "typescript": "5.4.5" } }, "sha512-ORlDITp8AFUXzIhLRoMCG+ud+D3MPKWb5HQXBoskMMnjeyEjE1H1qLonVNPyOr8lkx3xSfYUo8a0dvOZJVAzow=="],
@@ -3002,8 +3000,6 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
- "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.8", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.8", "vitest": "4.1.8" }, "optionalPeers": ["@vitest/browser"] }, "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw=="],
-
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@vitest/mocker": ["@vitest/mocker@4.1.7", "", { "dependencies": { "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA=="],
@@ -3124,8 +3120,6 @@
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
- "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg=="],
-
"astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="],
"astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="],
@@ -3610,8 +3604,6 @@
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
- "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="],
-
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"engine.io-client": ["engine.io-client@6.6.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.20.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg=="],
@@ -4132,12 +4124,6 @@
"isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="],
- "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
-
- "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
-
- "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
-
"iterate-iterator": ["iterate-iterator@1.0.2", "", {}, "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw=="],
"iterate-value": ["iterate-value@1.0.2", "", { "dependencies": { "es-get-iterator": "^1.0.2", "iterate-iterator": "^1.0.1" } }, "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ=="],
@@ -4306,8 +4292,6 @@
"magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="],
- "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
-
"make-fetch-happen": ["make-fetch-happen@15.0.6", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "@npmcli/redact": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", "ssri": "^13.0.0" } }, "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw=="],
"markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="],
@@ -6122,10 +6106,6 @@
"@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
- "@standard-community/standard-json/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
-
- "@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
-
"@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -6154,10 +6134,6 @@
"@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
- "@vitest/coverage-v8/@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="],
-
- "@vitest/coverage-v8/magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="],
-
"@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
@@ -6208,8 +6184,6 @@
"archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
- "ast-v8-to-istanbul/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
-
"astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
"astro/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@@ -6300,8 +6274,6 @@
"electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="],
- "encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
-
"engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
"esast-util-from-js/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@@ -6352,8 +6324,6 @@
"iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
- "istanbul-reports/html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
-
"js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="],
@@ -6960,18 +6930,12 @@
"@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
- "@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
- "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
"@storybook/csf-plugin/unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
- "@vitest/coverage-v8/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="],
-
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
diff --git a/packages/core/effect-drizzle-sqlite/src/effect-sqlite/driver.ts.gcov.html b/packages/core/effect-drizzle-sqlite/src/effect-sqlite/driver.ts.gcov.html
new file mode 100644
index 00000000..235eb688
--- /dev/null
+++ b/packages/core/effect-drizzle-sqlite/src/effect-sqlite/driver.ts.gcov.html
@@ -0,0 +1,153 @@
+
+
+
+
+
+
+ LCOV - lcov.info - ../effect-drizzle-sqlite/src/effect-sqlite/driver.ts
+
+
+
+
+
+
+ | LCOV - code coverage report |
+  |
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+  |
+
+ |
+
+
+  |
+
+
+
+
+
|
+
+
+
+ Line data Source code
+
+ 1 : /* oxlint-disable */
+ 2 42 : import * as Effect from "effect/Effect"
+ 3 76 : import * as Layer from "effect/Layer"
+ 4 116 : import { SqlClient } from "effect/unstable/sql/SqlClient"
+ 5 132 : import { EffectCache } from "drizzle-orm/cache/core/cache-effect"
+ 6 110 : import { EffectLogger } from "drizzle-orm/effect-core"
+ 7 96 : import { entityKind } from "drizzle-orm/entity"
+ 8 : import type { AnyRelations, EmptyRelations } from "drizzle-orm/relations"
+ 9 138 : import { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
+ 10 128 : import { SQLiteEffectDatabase } from "../sqlite-core/effect/db"
+ 11 : import type { DrizzleConfig } from "drizzle-orm/utils"
+ 12 118 : import { jitCompatCheck } from "../internal/drizzle-utils"
+ 13 96 : import { type EffectSQLiteQueryEffectHKT, type EffectSQLiteRunResult, EffectSQLiteSession } from "./session"
+ 14 :
+ 15 124 : export class EffectSQLiteDatabase<TRelations extends AnyRelations = EmptyRelations> extends SQLiteEffectDatabase<
+ 16 : EffectSQLiteQueryEffectHKT,
+ 17 : EffectSQLiteRunResult,
+ 18 : TRelations
+ 19 22 : > {
+ 20 76 : static override readonly [entityKind]: string = "EffectSQLiteDatabase"
+ 21 2 : }
+ 22 :
+ 23 : export type EffectDrizzleSQLiteConfig<TRelations extends AnyRelations = EmptyRelations> = Omit<
+ 24 : DrizzleConfig<Record<string, never>, TRelations>,
+ 25 : "cache" | "logger" | "schema"
+ 26 : >
+ 27 :
+ 28 172 : export const DefaultServices = Layer.merge(EffectCache.Default, EffectLogger.Default)
+ 29 :
+ 30 : /**
+ 31 : * Creates an EffectSQLiteDatabase instance.
+ 32 : *
+ 33 : * Requires a generic Effect `SqlClient`, `EffectLogger`, and `EffectCache` services to be provided.
+ 34 : * Drizzle only depends on the generic `SqlClient`; install and provide a compatible SQLite provider such as
+ 35 : * `@effect/sql-sqlite-node`, `@effect/sql-sqlite-bun`, or another package that exposes `SqlClient`.
+ 36 : *
+ 37 : * @example
+ 38 : * ```ts
+ 39 : * import { SqliteClient } from '@effect/sql-sqlite-node';
+ 40 : * import * as SQLiteDrizzle from 'drizzle-orm/effect-sqlite';
+ 41 : * import * as Effect from 'effect/Effect';
+ 42 : *
+ 43 : * const db = yield* SQLiteDrizzle.make({ relations }).pipe(
+ 44 : * Effect.provide(SQLiteDrizzle.DefaultServices),
+ 45 : * Effect.provide(SqliteClient.layer({ filename: 'sqlite.db' })),
+ 46 : * );
+ 47 : * ```
+ 48 : */
+ 49 113 : export const make = Effect.fn("SQLiteDrizzle.make")(function* <TRelations extends AnyRelations = EmptyRelations>(
+ 50 25 : config: EffectDrizzleSQLiteConfig<TRelations> = {},
+ 51 6 : ) {
+ 52 68 : const client = yield* SqlClient
+ 53 70 : const cache = yield* EffectCache
+ 54 74 : const logger = yield* EffectLogger
+ 55 :
+ 56 82 : const dialect = new SQLiteAsyncDialect()
+ 57 86 : const relations = config.relations ?? ({} as TRelations)
+ 58 146 : const session = new EffectSQLiteSession(client, dialect, relations, {
+ 59 22 : logger,
+ 60 20 : cache,
+ 61 86 : useJitMappers: jitCompatCheck(config.jit),
+ 62 10 : })
+ 63 134 : const db = new EffectSQLiteDatabase(dialect, session, relations) as EffectSQLiteDatabase<TRelations> & {
+ 64 : $client: SqlClient
+ 65 : }
+ 66 44 : db.$client = client
+ 67 80 : db.$cache.invalidate = cache.onMutate
+ 68 :
+ 69 20 : return db
+ 70 6 : })
+ 71 :
+ 72 : /**
+ 73 : * Convenience function that creates an EffectSQLiteDatabase with `DefaultServices` already provided.
+ 74 : */
+ 75 63 : export const makeWithDefaults = <TRelations extends AnyRelations = EmptyRelations>(
+ 76 32 : config: EffectDrizzleSQLiteConfig<TRelations> = {},
+ 77 102 : ) => make(config).pipe(Effect.provide(DefaultServices))
+
+ |
+
+
+
+
+
+
+
+
+
diff --git a/packages/llm/http-recorder/src/cassette.ts.gcov.html b/packages/llm/http-recorder/src/cassette.ts.gcov.html
new file mode 100644
index 00000000..ca785732
--- /dev/null
+++ b/packages/llm/http-recorder/src/cassette.ts.gcov.html
@@ -0,0 +1,255 @@
+
+
+
+
+
+
+ LCOV - lcov.info - ../http-recorder/src/cassette.ts
+
+
+
+
+
+
+ | LCOV - code coverage report |
+  |
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+  |
+
+ |
+
+
+  |
+
+
+
+
+
|
+
+
+
+ Line data Source code
+
+ 1 41 : import { Context, Effect, FileSystem, Layer, Schema, Semaphore } from "effect"
+ 2 30 : import * as fs from "node:fs"
+ 3 34 : import * as path from "node:path"
+ 4 69 : import { secretFindings, SecretFindingSchema, type SecretFinding } from "./redaction.js"
+ 5 61 : import { CassetteSchema, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema.js"
+ 6 :
+ 7 93 : const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
+ 8 :
+ 9 96 : export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFoundError>()("CassetteNotFoundError", {
+ 10 28 : cassetteName: Schema.String,
+ 11 0 : }) {
+ 12 0 : override get message() {
+ 13 1 : return `Cassette "${this.cassetteName}" not found`
+ 14 : }
+ 15 1 : }
+ 16 :
+ 17 92 : export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
+ 18 30 : cassetteName: Schema.String,
+ 19 44 : findings: Schema.Array(SecretFindingSchema),
+ 20 0 : }) {
+ 21 0 : override get message() {
+ 22 0 : return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
+ 23 0 : .map((finding) => `${finding.path} (${finding.reason})`)
+ 24 1 : .join(", ")}`
+ 25 : }
+ 26 1 : }
+ 27 :
+ 28 : export interface Interface {
+ 29 : readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
+ 30 : readonly append: (
+ 31 : name: string,
+ 32 : interaction: Interaction,
+ 33 : metadata?: CassetteMetadata,
+ 34 : ) => Effect.Effect<void, UnsafeCassetteError>
+ 35 : readonly exists: (name: string) => Effect.Effect<boolean>
+ 36 : readonly list: () => Effect.Effect<ReadonlyArray<string>>
+ 37 : }
+ 38 :
+ 39 88 : export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
+ 40 :
+ 41 43 : const cassettePath = (directory: string, name: string) => {
+ 42 107 : if (!name || path.isAbsolute(name) || path.win32.isAbsolute(name) || name.split(/[\\/]/).includes(".."))
+ 43 2 : throw new Error(`Invalid cassette name "${name}"`)
+ 44 39 : const root = path.resolve(directory)
+ 45 52 : const target = path.resolve(root, `${name}.json`)
+ 46 47 : const relative = path.relative(root, target)
+ 47 75 : if (!relative || relative.startsWith("..") || path.isAbsolute(relative))
+ 48 2 : throw new Error(`Invalid cassette name "${name}"`)
+ 49 15 : return target
+ 50 : }
+ 51 :
+ 52 53 : export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) =>
+ 53 79 : fs.existsSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name))
+ 54 :
+ 55 0 : const buildCassette = (
+ 56 0 : name: string,
+ 57 0 : interactions: ReadonlyArray<Interaction>,
+ 58 0 : metadata: CassetteMetadata | undefined,
+ 59 0 : ): Cassette => ({
+ 60 0 : version: 1,
+ 61 0 : metadata: { name, recordedAt: new Date().toISOString(), ...metadata },
+ 62 : interactions,
+ 63 2 : })
+ 64 :
+ 65 23 : const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
+ 66 :
+ 67 86 : const parseCassette = Schema.decodeUnknownSync(Schema.fromJsonString(CassetteSchema))
+ 68 :
+ 69 0 : const failIfUnsafe = (name: string, findings: ReadonlyArray<SecretFinding>) =>
+ 70 2 : findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings }))
+ 71 :
+ 72 25 : export const fileSystem = (
+ 73 17 : options: { readonly directory?: string } = {},
+ 74 : ): Layer.Layer<Service, never, FileSystem.FileSystem> =>
+ 75 13 : Layer.effect(
+ 76 9 : Service,
+ 77 15 : Effect.gen(function* () {
+ 78 42 : const fs = yield* FileSystem.FileSystem
+ 79 64 : const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
+ 80 27 : const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
+ 81 46 : const appendLock = yield* Semaphore.make(1)
+ 82 :
+ 83 56 : const pathFor = (name: string) => cassettePath(directory, name)
+ 84 :
+ 85 0 : const walk = (current: string): Effect.Effect<ReadonlyArray<string>> =>
+ 86 0 : Effect.gen(function* () {
+ 87 0 : const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[])))
+ 88 0 : const nested = yield* Effect.forEach(entries, (entry) => {
+ 89 0 : const full = path.join(current, entry)
+ 90 0 : return fs.stat(full).pipe(
+ 91 0 : Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
+ 92 0 : Effect.catch(() => Effect.succeed([] as string[])),
+ 93 0 : )
+ 94 0 : })
+ 95 : return nested.flat()
+ 96 3 : })
+ 97 :
+ 98 23 : return Service.of({
+ 99 15 : read: (name) =>
+ 100 38 : fs.readFileString(pathFor(name)).pipe(
+ 101 52 : Effect.map((raw) => parseCassette(raw).interactions),
+ 102 13 : Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))),
+ 103 5 : ),
+ 104 0 : append: (name, interaction, metadata) =>
+ 105 0 : appendLock.withPermit(
+ 106 0 : Effect.gen(function* () {
+ 107 0 : const entry = recorded.get(name) ?? { interactions: [], findings: [] }
+ 108 0 : const interactions = [...entry.interactions, interaction]
+ 109 0 : const interactionFindings = [...entry.findings, ...secretFindings(interaction)]
+ 110 0 : const cassette = buildCassette(name, interactions, metadata)
+ 111 0 : const findings = [...interactionFindings, ...secretFindings(cassette.metadata ?? {})]
+ 112 0 : yield* failIfUnsafe(name, findings)
+ 113 0 : const target = pathFor(name)
+ 114 0 : yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(Effect.orDie)
+ 115 0 : const temporary = `${target}.${crypto.randomUUID()}.tmp`
+ 116 0 : yield* fs.writeFileString(temporary, formatCassette(cassette)).pipe(
+ 117 0 : Effect.flatMap(() => fs.rename(temporary, target)),
+ 118 0 : Effect.ensuring(fs.remove(temporary, { force: true }).pipe(Effect.catch(() => Effect.void))),
+ 119 0 : Effect.orDie,
+ 120 0 : )
+ 121 0 : recorded.set(name, { interactions, findings: interactionFindings })
+ 122 : }),
+ 123 5 : ),
+ 124 0 : exists: (name) =>
+ 125 0 : fs.access(pathFor(name)).pipe(
+ 126 0 : Effect.as(true),
+ 127 : Effect.catch(() => Effect.succeed(false)),
+ 128 5 : ),
+ 129 0 : list: () =>
+ 130 0 : walk(directory).pipe(
+ 131 0 : Effect.map((files) =>
+ 132 0 : files
+ 133 0 : .filter((file) => file.endsWith(".json"))
+ 134 0 : .map((file) =>
+ 135 0 : path
+ 136 0 : .relative(directory, file)
+ 137 0 : .replace(/\\/g, "/")
+ 138 0 : .replace(/\.json$/, ""),
+ 139 0 : )
+ 140 0 : .toSorted((a, b) => a.localeCompare(b)),
+ 141 : ),
+ 142 2 : ),
+ 143 2 : })
+ 144 1 : }),
+ 145 2 : )
+ 146 :
+ 147 0 : export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {}): Layer.Layer<Service> =>
+ 148 0 : Layer.sync(Service, () => {
+ 149 0 : const stored = new Map<string, Interaction[]>(
+ 150 0 : Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]),
+ 151 0 : )
+ 152 0 : const accumulatedFindings = new Map<string, SecretFinding[]>()
+ 153 0 : const appendLock = Semaphore.makeUnsafe(1)
+ 154 0 :
+ 155 0 : return Service.of({
+ 156 0 : read: (name) =>
+ 157 0 : stored.has(name)
+ 158 0 : ? Effect.succeed(stored.get(name) ?? [])
+ 159 0 : : Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
+ 160 0 : append: (name, interaction, metadata) =>
+ 161 0 : appendLock.withPermit(
+ 162 0 : Effect.suspend(() => {
+ 163 0 : const interactions = [...(stored.get(name) ?? []), interaction]
+ 164 0 : const findings = [...(accumulatedFindings.get(name) ?? []), ...secretFindings(interaction)]
+ 165 0 : const allFindings = metadata ? [...findings, ...secretFindings({ name, ...metadata })] : findings
+ 166 0 : return failIfUnsafe(name, allFindings).pipe(
+ 167 0 : Effect.tap(() =>
+ 168 0 : Effect.sync(() => {
+ 169 0 : stored.set(name, interactions)
+ 170 0 : accumulatedFindings.set(name, findings)
+ 171 0 : }),
+ 172 0 : ),
+ 173 0 : )
+ 174 0 : }),
+ 175 0 : ),
+ 176 0 : exists: (name) => Effect.sync(() => stored.has(name)),
+ 177 0 : list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
+ 178 : })
+ 179 1 : })
+
+ |
+
+
+
+
+
+
+
+
+
diff --git a/packages/llm/http-recorder/src/index-sort-f.html b/packages/llm/http-recorder/src/index-sort-f.html
new file mode 100644
index 00000000..f28b7279
--- /dev/null
+++ b/packages/llm/http-recorder/src/index-sort-f.html
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+ LCOV - lcov.info - ../http-recorder/src
+
+
+
+
+
+
+ | LCOV - code coverage report |
+  |
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+  |
+
+ |
+
+
+  |
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/llm/http-recorder/src/index-sort-l.html b/packages/llm/http-recorder/src/index-sort-l.html
new file mode 100644
index 00000000..da1f6d4d
--- /dev/null
+++ b/packages/llm/http-recorder/src/index-sort-l.html
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+ LCOV - lcov.info - ../http-recorder/src
+
+
+
+
+
+
+ | LCOV - code coverage report |
+  |
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+  |
+
+ |
+
+
+  |
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/llm/http-recorder/src/index.html b/packages/llm/http-recorder/src/index.html
new file mode 100644
index 00000000..f926469e
--- /dev/null
+++ b/packages/llm/http-recorder/src/index.html
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+ LCOV - lcov.info - ../http-recorder/src
+
+
+
+
+
+
+ | LCOV - code coverage report |
+  |
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+  |
+
+ |
+
+
+  |
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/llm/http-recorder/src/internal-effect.ts.gcov.html b/packages/llm/http-recorder/src/internal-effect.ts.gcov.html
new file mode 100644
index 00000000..2bee3a43
--- /dev/null
+++ b/packages/llm/http-recorder/src/internal-effect.ts.gcov.html
@@ -0,0 +1,265 @@
+
+
+
+
+
+
+ LCOV - lcov.info - ../http-recorder/src/internal-effect.ts
+
+
+
+
+
+
+ | LCOV - code coverage report |
+  |
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+  |
+
+ |
+
+
+  |
+
+
+
+
+
|
+
+
+
+ Line data Source code
+
+ 1 55 : import { NodeFileSystem } from "@effect/platform-node"
+ 2 62 : import { Deferred, Effect, Layer, Option, Ref } from "effect"
+ 3 160 : import {
+ 4 : FetchHttpClient,
+ 5 : Headers,
+ 6 : HttpBody,
+ 7 : HttpClient,
+ 8 : HttpClientError,
+ 9 : HttpClientRequest,
+ 10 : HttpClientResponse,
+ 11 : UrlParams,
+ 12 : } from "effect/unstable/http"
+ 13 49 : import * as CassetteService from "./cassette.js"
+ 14 65 : import { defaultMatcher, selectSequential } from "./matching.js"
+ 15 65 : import { makeReplayState, resolveAutoMode } from "./recorder.js"
+ 16 37 : import { make, type Redactor } from "./redactor.js"
+ 17 43 : import { redactUrl } from "./redaction.js"
+ 18 47 : import { httpInteractions } from "./schema.js"
+ 19 : import type { CassetteMetadata, HttpInteraction, RequestMatcher, ResponseSnapshot } from "./types.js"
+ 20 :
+ 21 26 : export { defaultMatcher }
+ 22 :
+ 23 : export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough"
+ 24 :
+ 25 : export interface RecordReplayOptions {
+ 26 : readonly mode?: RecordReplayMode
+ 27 : readonly directory?: string
+ 28 : readonly metadata?: CassetteMetadata
+ 29 : readonly redactor?: Redactor
+ 30 : readonly match?: RequestMatcher
+ 31 : }
+ 32 :
+ 33 37 : const TEXT_CONTENT_TYPES = new Set([
+ 34 24 : "application/graphql",
+ 35 27 : "application/javascript",
+ 36 21 : "application/json",
+ 37 20 : "application/sql",
+ 38 38 : "application/x-www-form-urlencoded",
+ 39 20 : "application/xml",
+ 40 21 : "application/yaml",
+ 41 16 : "image/svg+xml",
+ 42 3 : ])
+ 43 :
+ 44 0 : const isTextContentType = (contentType: string | undefined) => {
+ 45 0 : const mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase()
+ 46 0 : if (!mediaType) return false
+ 47 0 : return (
+ 48 0 : mediaType.startsWith("text/") ||
+ 49 0 : mediaType.endsWith("+json") ||
+ 50 0 : mediaType.endsWith("+xml") ||
+ 51 2 : TEXT_CONTENT_TYPES.has(mediaType)
+ 52 : )
+ 53 : }
+ 54 :
+ 55 0 : const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) =>
+ 56 0 : response.arrayBuffer.pipe(
+ 57 0 : Effect.map((bytes) =>
+ 58 0 : isTextContentType(contentType)
+ 59 0 : ? { body: new TextDecoder().decode(bytes) }
+ 60 0 : : { body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" as const },
+ 61 : ),
+ 62 2 : )
+ 63 :
+ 64 39 : const decodeResponseBody = (snapshot: ResponseSnapshot) =>
+ 65 89 : snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body
+ 66 :
+ 67 50 : const responseFromSnapshot = (request: HttpClientRequest.HttpClientRequest, snapshot: ResponseSnapshot) =>
+ 68 27 : HttpClientResponse.fromWeb(
+ 69 9 : request,
+ 70 13 : new Response(
+ 71 108 : request.method === "HEAD" || snapshot.status === 204 || snapshot.status === 205 || snapshot.status === 304
+ 72 2 : ? null
+ 73 29 : : decodeResponseBody(snapshot),
+ 74 8 : snapshot,
+ 75 1 : ),
+ 76 2 : )
+ 77 :
+ 78 0 : export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) =>
+ 79 0 : HttpClientRequest.makeWith(
+ 80 0 : request.method,
+ 81 0 : redactUrl(request.url),
+ 82 0 : UrlParams.empty,
+ 83 0 : Option.none(),
+ 84 0 : Headers.empty,
+ 85 : HttpBody.empty,
+ 86 2 : )
+ 87 :
+ 88 0 : const transportError = (request: HttpClientRequest.HttpClientRequest, description: string) =>
+ 89 0 : new HttpClientError.HttpClientError({
+ 90 : reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }),
+ 91 2 : })
+ 92 :
+ 93 29 : export const recordingLayer = (
+ 94 6 : name: string,
+ 95 17 : options: Omit<RecordReplayOptions, "directory"> = {},
+ 96 : ): Layer.Layer<HttpClient.HttpClient, never, HttpClient.HttpClient | CassetteService.Service> =>
+ 97 13 : Layer.effect(
+ 98 23 : HttpClient.HttpClient,
+ 99 15 : Effect.gen(function* () {
+ 100 48 : const upstream = yield* HttpClient.HttpClient
+ 101 57 : const cassetteService = yield* CassetteService.Service
+ 102 46 : const redactor = options.redactor ?? make()
+ 103 48 : const match = options.match ?? defaultMatcher
+ 104 43 : const requested = options.mode ?? "auto"
+ 105 48 : const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested
+ 106 :
+ 107 36 : const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
+ 108 17 : Effect.gen(function* () {
+ 109 75 : const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
+ 110 31 : return redactor.request({
+ 111 25 : method: web.method,
+ 112 19 : url: web.url,
+ 113 57 : headers: Object.fromEntries(web.headers.entries()),
+ 114 47 : body: yield* Effect.promise(() => web.text()),
+ 115 3 : })
+ 116 3 : })
+ 117 :
+ 118 32 : if (mode === "passthrough") return upstream
+ 119 :
+ 120 22 : if (mode === "record") {
+ 121 0 : const initial = yield* Deferred.make<void>()
+ 122 0 : yield* Deferred.succeed(initial, undefined)
+ 123 0 : const tail = yield* Ref.make(initial)
+ 124 0 : return HttpClient.make((request) =>
+ 125 0 : Effect.gen(function* () {
+ 126 0 : const completed = yield* Deferred.make<void>()
+ 127 0 : const previous = yield* Ref.modify(tail, (current) => [current, completed])
+ 128 0 : return yield* Effect.gen(function* () {
+ 129 0 : const incoming = yield* snapshotRequest(request)
+ 130 0 : const response = yield* upstream.execute(request)
+ 131 0 : const captured = yield* captureResponseBody(response, response.headers["content-type"])
+ 132 0 : const responseSnapshot: ResponseSnapshot = {
+ 133 0 : status: response.status,
+ 134 0 : headers: response.headers as Record<string, string>,
+ 135 0 : ...captured,
+ 136 0 : }
+ 137 0 : const interaction: HttpInteraction = {
+ 138 0 : transport: "http",
+ 139 0 : request: incoming,
+ 140 0 : response: redactor.response(responseSnapshot),
+ 141 0 : }
+ 142 0 : yield* Deferred.await(previous)
+ 143 0 : yield* cassetteService
+ 144 0 : .append(name, interaction, options.metadata)
+ 145 0 : .pipe(
+ 146 0 : Effect.catchTag("UnsafeCassetteError", (error) =>
+ 147 0 : Effect.fail(transportError(request, error.message)),
+ 148 0 : ),
+ 149 0 : )
+ 150 0 : return responseFromSnapshot(request, responseSnapshot)
+ 151 : }).pipe(Effect.ensuring(Deferred.succeed(completed, undefined)))
+ 152 : }),
+ 153 0 : )
+ 154 2 : }
+ 155 :
+ 156 81 : const replay = yield* makeReplayState(cassetteService, name, httpInteractions)
+ 157 35 : return HttpClient.make((request) =>
+ 158 17 : Effect.gen(function* () {
+ 159 53 : const incoming = yield* snapshotRequest(request)
+ 160 30 : const claimed = yield* replay
+ 161 50 : .claim((interaction, index, interactions) => {
+ 162 76 : const result = selectSequential(interactions, incoming, match, index)
+ 163 48 : if (result.interaction) return Effect.void
+ 164 0 : return Effect.fail(
+ 165 0 : transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`),
+ 166 0 : )
+ 167 2 : })
+ 168 5 : .pipe(
+ 169 0 : Effect.mapError((error) =>
+ 170 0 : error._tag === "CassetteNotFoundError"
+ 171 0 : ? transportError(
+ 172 0 : request,
+ 173 0 : `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`,
+ 174 0 : )
+ 175 : : error,
+ 176 1 : ),
+ 177 6 : )
+ 178 67 : return responseFromSnapshot(request, claimed.interaction.response)
+ 179 : }),
+ 180 1 : )
+ 181 1 : }),
+ 182 2 : )
+ 183 :
+ 184 0 : export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
+ 185 0 : recordingLayer(name, options).pipe(
+ 186 0 : Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
+ 187 0 : Layer.provide(FetchHttpClient.layer),
+ 188 : Layer.provide(NodeFileSystem.layer),
+ 189 1 : )
+
+ |
+
+
+
+
+
+
+
+
+
diff --git a/packages/llm/http-recorder/src/internal.ts.gcov.html b/packages/llm/http-recorder/src/internal.ts.gcov.html
new file mode 100644
index 00000000..716e80c4
--- /dev/null
+++ b/packages/llm/http-recorder/src/internal.ts.gcov.html
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+ LCOV - lcov.info - ../http-recorder/src/internal.ts
+
+
+
+
+
+
+ | LCOV - code coverage report |
+  |
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+  |
+
+ |
+
+
+  |
+
+
+
+
+
|
+
+
+
+ Line data Source code
+
+ 1 92 : export { CassetteNotFoundError, hasCassetteSync, UnsafeCassetteError } from "./cassette.js"
+ 2 69 : export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./internal-effect.js"
+ 3 74 : export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction.js"
+ 4 42 : export { socketLayer } from "./socket.js"
+ 5 55 : export {
+ 6 : makeWebSocketExecutor,
+ 7 : type WebSocketConnection,
+ 8 : type WebSocketExecutor,
+ 9 : type WebSocketRecordReplayOptions,
+ 10 : type WebSocketRequest,
+ 11 : } from "./websocket.js"
+ 12 42 : export * as Cassette from "./cassette.js"
+ 13 42 : export * as Redactor from "./redactor.js"
+ 14 :
+ 15 53 : export * as HttpRecorderInternal from "./internal.js"
+
+ |
+
+
+
+
+
+
+
+
+
diff --git a/packages/llm/http-recorder/src/matching.ts.gcov.html b/packages/llm/http-recorder/src/matching.ts.gcov.html
new file mode 100644
index 00000000..e211982f
--- /dev/null
+++ b/packages/llm/http-recorder/src/matching.ts.gcov.html
@@ -0,0 +1,182 @@
+
+
+
+
+
+
+ LCOV - lcov.info - ../http-recorder/src/matching.ts
+
+
+
+
+
+
+ | LCOV - code coverage report |
+  |
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+  |
+
+ |
+
+
+  |
+
+
+
+
+
|
+
+
+
+ Line data Source code
+
+ 1 40 : import { Option, Schema } from "effect"
+ 2 58 : import { REDACTED, secretFindings } from "./redaction.js"
+ 3 : import type { HttpInteraction, RequestMatcher, RequestSnapshot } from "./types.js"
+ 4 :
+ 5 56 : const JsonValue = Schema.fromJsonString(Schema.Unknown)
+ 6 64 : export const decodeJson = Schema.decodeUnknownOption(JsonValue)
+ 7 :
+ 8 26 : const isRecord = (value: unknown): value is Record<string, unknown> =>
+ 9 69 : value !== null && typeof value === "object" && !Array.isArray(value)
+ 10 :
+ 11 44 : export const canonicalizeJson = (value: unknown): unknown => {
+ 12 64 : if (Array.isArray(value)) return value.map(canonicalizeJson)
+ 13 25 : if (isRecord(value)) {
+ 14 26 : return Object.fromEntries(
+ 15 19 : Object.keys(value)
+ 16 11 : .toSorted()
+ 17 47 : .map((key) => [key, canonicalizeJson(value[key])]),
+ 18 1 : )
+ 19 2 : }
+ 20 14 : return value
+ 21 : }
+ 22 :
+ 23 : export type { RequestMatcher } from "./types.js"
+ 24 :
+ 25 45 : export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
+ 26 18 : JSON.stringify({
+ 27 26 : method: snapshot.method,
+ 28 20 : url: snapshot.url,
+ 29 46 : headers: canonicalizeJson(snapshot.headers),
+ 30 51 : body: Option.match(decodeJson(snapshot.body), {
+ 31 12 : onNone: () => snapshot.body,
+ 32 26 : onSome: canonicalizeJson,
+ 33 3 : }),
+ 34 2 : })
+ 35 :
+ 36 52 : export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
+ 37 60 : canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
+ 38 :
+ 39 0 : export const safeText = (value: unknown) => {
+ 40 0 : if (value === undefined) return "undefined"
+ 41 0 : if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
+ 42 0 : const text = JSON.stringify(value)
+ 43 0 : if (!text) return typeof value
+ 44 2 : return text.length > 300 ? `${text.slice(0, 300)}...` : text
+ 45 : }
+ 46 :
+ 47 17 : const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body))
+ 48 :
+ 49 0 : const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray<string> => {
+ 50 0 : if (Object.is(expected, received)) return []
+ 51 0 : if (isRecord(expected) && isRecord(received)) {
+ 52 0 : return [...new Set([...Object.keys(expected), ...Object.keys(received)])]
+ 53 0 : .toSorted()
+ 54 0 : .flatMap((key) => valueDiffs(expected[key], received[key], `${base}.${key}`, limit))
+ 55 0 : .slice(0, limit)
+ 56 0 : }
+ 57 0 : if (Array.isArray(expected) && Array.isArray(received)) {
+ 58 0 : return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index)
+ 59 0 : .flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit))
+ 60 0 : .slice(0, limit)
+ 61 0 : }
+ 62 2 : return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`]
+ 63 : }
+ 64 :
+ 65 0 : const headerDiffs = (expected: Record<string, string>, received: Record<string, string>) =>
+ 66 0 : [...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => {
+ 67 0 : if (expected[key] === received[key]) return []
+ 68 0 : if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`]
+ 69 0 : if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`]
+ 70 : return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`]
+ 71 2 : })
+ 72 :
+ 73 0 : export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot): ReadonlyArray<string> => {
+ 74 0 : const lines: string[] = []
+ 75 0 : if (expected.method !== received.method) {
+ 76 0 : lines.push("method:", ` expected ${expected.method}, received ${received.method}`)
+ 77 0 : }
+ 78 0 : if (expected.url !== received.url) {
+ 79 0 : lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`)
+ 80 0 : }
+ 81 0 : const headers = headerDiffs(expected.headers, received.headers)
+ 82 0 : if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8))
+ 83 0 : const expectedBody = jsonBody(expected.body)
+ 84 0 : const receivedBody = jsonBody(received.body)
+ 85 0 : const body =
+ 86 0 : expectedBody !== undefined && receivedBody !== undefined
+ 87 0 : ? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`)
+ 88 0 : : expected.body === received.body
+ 89 0 : ? []
+ 90 0 : : [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`]
+ 91 0 : if (body.length > 0) lines.push("body:", ...body)
+ 92 2 : return lines
+ 93 : }
+ 94 :
+ 95 31 : export const selectSequential = (
+ 96 14 : interactions: ReadonlyArray<HttpInteraction>,
+ 97 10 : incoming: RequestSnapshot,
+ 98 7 : match: RequestMatcher,
+ 99 10 : index: number,
+ 100 3 : ): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => {
+ 101 42 : const interaction = interactions[index]
+ 102 22 : if (!interaction) return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` }
+ 103 45 : if (!match(incoming, interaction.request))
+ 104 2 : return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") }
+ 105 35 : return { interaction, detail: "" }
+ 106 : }
+
+ |
+
+
+
+
+
+
+
+
+
diff --git a/packages/llm/http-recorder/src/recorder.ts.gcov.html b/packages/llm/http-recorder/src/recorder.ts.gcov.html
new file mode 100644
index 00000000..1c7fe9c3
--- /dev/null
+++ b/packages/llm/http-recorder/src/recorder.ts.gcov.html
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+ LCOV - lcov.info - ../http-recorder/src/recorder.ts
+
+
+
+
+
+
+ | LCOV - code coverage report |
+  |
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+  |
+
+ |
+
+
+  |
+
+
+
+
+
|
+
+
+
+ Line data Source code
+
+ 1 49 : import { Effect, Scope, SynchronizedRef } from "effect"
+ 2 : import type * as CassetteService from "./cassette.js"
+ 3 : import type { CassetteNotFoundError } from "./cassette.js"
+ 4 : import type { Interaction } from "./schema.js"
+ 5 :
+ 6 0 : const isCI = () => {
+ 7 0 : const value = process.env.CI
+ 8 2 : return value !== undefined && value !== "" && value !== "false" && value !== "0"
+ 9 : }
+ 10 :
+ 11 0 : export const resolveAutoMode = (
+ 12 0 : cassette: CassetteService.Interface,
+ 13 0 : name: string,
+ 14 0 : ): Effect.Effect<"record" | "replay" | "passthrough"> =>
+ 15 0 : Effect.gen(function* () {
+ 16 0 : if (isCI()) return "replay"
+ 17 : return (yield* cassette.exists(name)) ? "replay" : "record"
+ 18 2 : })
+ 19 :
+ 20 : export interface ReplayState<T> {
+ 21 : readonly claim: <E>(
+ 22 : validate: (interaction: T | undefined, index: number, interactions: ReadonlyArray<T>) => Effect.Effect<void, E>,
+ 23 : ) => Effect.Effect<{ readonly interaction: T; readonly index: number }, CassetteNotFoundError | E>
+ 24 : }
+ 25 :
+ 26 30 : export const makeReplayState = <T>(
+ 27 10 : cassette: CassetteService.Interface,
+ 28 6 : name: string,
+ 29 12 : project: (interactions: ReadonlyArray<Interaction>) => ReadonlyArray<T>,
+ 30 : ): Effect.Effect<ReplayState<T>, never, Scope.Scope> =>
+ 31 15 : Effect.gen(function* () {
+ 32 83 : const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project)))
+ 33 50 : const position = yield* SynchronizedRef.make(0)
+ 34 :
+ 35 32 : yield* Effect.addFinalizer(() =>
+ 36 17 : Effect.gen(function* () {
+ 37 54 : const used = yield* SynchronizedRef.get(position)
+ 38 49 : if (used === 0) return yield* Effect.void
+ 39 56 : const interactions = yield* load.pipe(Effect.orDie)
+ 40 36 : if (used < interactions.length)
+ 41 0 : return yield* Effect.die(
+ 42 0 : new Error(`Unused recorded interactions in ${name}: used ${used} of ${interactions.length}`),
+ 43 4 : )
+ 44 26 : return yield* Effect.void
+ 45 : }),
+ 46 4 : )
+ 47 :
+ 48 12 : return {
+ 49 20 : claim: (validate) =>
+ 50 38 : Effect.flatMap(load, (interactions) =>
+ 51 49 : SynchronizedRef.modifyEffect(position, (index) =>
+ 52 19 : Effect.gen(function* () {
+ 53 46 : const interaction = interactions[index]
+ 54 56 : yield* validate(interaction, index, interactions)
+ 55 37 : if (interaction === undefined)
+ 56 6 : return yield* Effect.die("Replay validation accepted a missing interaction")
+ 57 43 : return [{ interaction, index }, index + 1] as const
+ 58 : }),
+ 59 : ),
+ 60 2 : ),
+ 61 1 : }
+ 62 1 : })
+
+ |
+
+
+
+
+
+
+
+
+
diff --git a/packages/llm/http-recorder/src/redaction.ts.gcov.html b/packages/llm/http-recorder/src/redaction.ts.gcov.html
new file mode 100644
index 00000000..164e3056
--- /dev/null
+++ b/packages/llm/http-recorder/src/redaction.ts.gcov.html
@@ -0,0 +1,193 @@
+
+
+
+
+
+
+ LCOV - lcov.info - ../http-recorder/src/redaction.ts
+
+
+
+
+
+
+ | LCOV - code coverage report |
+  |
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+  |
+
+ |
+
+
+  |
+
+
+
+
+
|
+
+
+
+ Line data Source code
+
+ 1 32 : import { Schema } from "effect"
+ 2 :
+ 3 37 : export const REDACTED = "[REDACTED]"
+ 4 :
+ 5 33 : const DEFAULT_REDACT_HEADERS = [
+ 6 18 : "authorization",
+ 7 11 : "cookie",
+ 8 24 : "proxy-authorization",
+ 9 15 : "set-cookie",
+ 10 14 : "x-api-key",
+ 11 25 : "x-amz-security-token",
+ 12 17 : "x-goog-api-key",
+ 13 2 : ]
+ 14 :
+ 15 31 : const DEFAULT_REDACT_QUERY = [
+ 16 17 : "access_token",
+ 17 12 : "api-key",
+ 18 12 : "api_key",
+ 19 11 : "apikey",
+ 20 9 : "code",
+ 21 8 : "key",
+ 22 14 : "signature",
+ 23 8 : "sig",
+ 24 10 : "token",
+ 25 21 : "x-amz-credential",
+ 26 25 : "x-amz-security-token",
+ 27 18 : "x-amz-signature",
+ 28 2 : ]
+ 29 :
+ 30 26 : const SECRET_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp }> = [
+ 31 78 : { label: "bearer token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i },
+ 32 72 : { label: "API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b/ },
+ 33 75 : { label: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
+ 34 69 : { label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ },
+ 35 72 : { label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
+ 36 72 : { label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ },
+ 37 72 : { label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
+ 38 2 : ]
+ 39 :
+ 40 85 : const ENV_SECRET_NAMES = /(?:API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN)/i
+ 41 65 : const SAFE_ENV_VALUES = new Set(["fixture", "test", "test-key"])
+ 42 :
+ 43 0 : const envSecrets = () =>
+ 44 0 : Object.entries(process.env).flatMap(([name, value]) => {
+ 45 0 : if (!value) return []
+ 46 0 : if (!ENV_SECRET_NAMES.test(name)) return []
+ 47 0 : if (value.length < 12) return []
+ 48 0 : if (SAFE_ENV_VALUES.has(value.toLowerCase())) return []
+ 49 : return [{ name, value }]
+ 50 2 : })
+ 51 :
+ 52 16 : const pathFor = (base: string, key: string) => (base ? `${base}.${key}` : key)
+ 53 :
+ 54 0 : const stringEntries = (value: unknown, base = ""): ReadonlyArray<{ readonly path: string; readonly value: string }> => {
+ 55 0 : if (typeof value === "string") return [{ path: base, value }]
+ 56 0 : if (Array.isArray(value)) return value.flatMap((item, index) => stringEntries(item, `${base}[${index}]`))
+ 57 0 : if (value && typeof value === "object") {
+ 58 0 : return Object.entries(value).flatMap(([key, child]) => stringEntries(child, pathFor(base, key)))
+ 59 0 : }
+ 60 2 : return []
+ 61 : }
+ 62 :
+ 63 41 : const redactionSet = (values: ReadonlyArray<string> | undefined, defaults: ReadonlyArray<string>) =>
+ 64 74 : new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase()))
+ 65 :
+ 66 : export type UrlRedactor = (url: string) => string
+ 67 :
+ 68 24 : export const redactUrl = (
+ 69 5 : raw: string,
+ 70 30 : query: ReadonlyArray<string> = DEFAULT_REDACT_QUERY,
+ 71 16 : urlRedactor?: UrlRedactor,
+ 72 3 : ) => {
+ 73 28 : if (!URL.canParse(raw)) return urlRedactor?.(raw) ?? raw
+ 74 27 : const url = new URL(raw)
+ 75 22 : if (url.username) url.username = REDACTED
+ 76 22 : if (url.password) url.password = REDACTED
+ 77 61 : const redacted = redactionSet(query, DEFAULT_REDACT_QUERY)
+ 78 47 : for (const key of url.searchParams.keys()) {
+ 79 43 : if (redacted.has(key.toLowerCase())) url.searchParams.set(key, REDACTED)
+ 80 2 : }
+ 81 56 : return urlRedactor?.(url.toString()) ?? url.toString()
+ 82 : }
+ 83 :
+ 84 28 : export const redactHeaders = (
+ 85 9 : headers: Record<string, string>,
+ 86 7 : allow: ReadonlyArray<string>,
+ 87 36 : redact: ReadonlyArray<string> = DEFAULT_REDACT_HEADERS,
+ 88 3 : ) => {
+ 89 65 : const allowed = new Set(allow.map((name) => name.toLowerCase()))
+ 90 64 : const redacted = redactionSet(redact, DEFAULT_REDACT_HEADERS)
+ 91 26 : return Object.fromEntries(
+ 92 24 : Object.entries(headers)
+ 93 50 : .map(([name, value]) => [name.toLowerCase(), value] as const)
+ 94 36 : .filter(([name]) => allowed.has(name))
+ 95 58 : .map(([name, value]) => [name, redacted.has(name) ? REDACTED : value] as const)
+ 96 40 : .toSorted(([a], [b]) => a.localeCompare(b)),
+ 97 3 : )
+ 98 : }
+ 99 :
+ 100 51 : export const SecretFindingSchema = Schema.Struct({
+ 101 22 : path: Schema.String,
+ 102 22 : reason: Schema.String,
+ 103 3 : })
+ 104 : export type SecretFinding = Schema.Schema.Type<typeof SecretFindingSchema>
+ 105 :
+ 106 0 : export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> => {
+ 107 0 : const environment = envSecrets()
+ 108 0 : return stringEntries(value).flatMap((entry) => [
+ 109 0 : ...SECRET_PATTERNS.filter((item) => item.pattern.test(entry.value)).map((item) => ({
+ 110 0 : path: entry.path,
+ 111 0 : reason: item.label,
+ 112 0 : })),
+ 113 0 : ...environment
+ 114 0 : .filter((item) => entry.value.includes(item.value))
+ 115 0 : .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
+ 116 1 : ])
+ 117 : }
+
+ |
+
+
+
+
+
+
+
+
+
diff --git a/packages/llm/http-recorder/src/redactor.ts.gcov.html b/packages/llm/http-recorder/src/redactor.ts.gcov.html
new file mode 100644
index 00000000..5e328411
--- /dev/null
+++ b/packages/llm/http-recorder/src/redactor.ts.gcov.html
@@ -0,0 +1,211 @@
+
+
+
+
+
+
+ LCOV - lcov.info - ../http-recorder/src/redactor.ts
+
+
+
+
+
+
+ | LCOV - code coverage report |
+  |
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+  |
+
+ |
+
+
+  |
+
+
+
+
+
|
+
+
+
+ Line data Source code
+
+ 1 32 : import { Option } from "effect"
+ 2 43 : import { decodeJson } from "./matching.js"
+ 3 68 : import { REDACTED, redactHeaders, redactUrl } from "./redaction.js"
+ 4 : import type { RedactOptions, RequestSnapshot, ResponseSnapshot } from "./types.js"
+ 5 :
+ 6 : export type { RedactOptions } from "./types.js"
+ 7 :
+ 8 81 : export const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
+ 9 57 : export const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
+ 10 :
+ 11 17 : const identity = <T>(value: T) => value
+ 12 :
+ 13 : export interface Redactor {
+ 14 : readonly request: (snapshot: RequestSnapshot) => RequestSnapshot
+ 15 : readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot
+ 16 : }
+ 17 :
+ 18 42 : export const compose = (...redactors: ReadonlyArray<Partial<Redactor>>): Redactor => {
+ 19 80 : const requests = redactors.map((r) => r.request).filter((fn): fn is Redactor["request"] => fn !== undefined)
+ 20 82 : const responses = redactors.map((r) => r.response).filter((fn): fn is Redactor["response"] => fn !== undefined)
+ 21 12 : return {
+ 22 95 : request: requests.length === 0 ? identity : (snapshot) => requests.reduce((acc, fn) => fn(acc), snapshot),
+ 23 36 : response: responses.length === 0 ? identity : (snapshot) => responses.reduce((acc, fn) => fn(acc), snapshot),
+ 24 3 : }
+ 25 : }
+ 26 :
+ 27 : export interface HeaderOptions {
+ 28 : readonly allow?: ReadonlyArray<string>
+ 29 : readonly redact?: ReadonlyArray<string>
+ 30 : }
+ 31 :
+ 32 50 : export const requestHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
+ 33 31 : request: (snapshot) => ({
+ 34 13 : ...snapshot,
+ 35 100 : headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact),
+ 36 2 : }),
+ 37 2 : })
+ 38 :
+ 39 51 : export const responseHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
+ 40 0 : response: (snapshot) => ({
+ 41 0 : ...snapshot,
+ 42 0 : headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact),
+ 43 1 : }),
+ 44 2 : })
+ 45 :
+ 46 : export interface UrlOptions {
+ 47 : readonly query?: ReadonlyArray<string>
+ 48 : readonly transform?: (url: string) => string
+ 49 : }
+ 50 :
+ 51 39 : export const url = (options: UrlOptions = {}): Partial<Redactor> => ({
+ 52 103 : request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }),
+ 53 2 : })
+ 54 :
+ 55 0 : export const body = (transform: (parsed: unknown) => unknown): Partial<Redactor> => ({
+ 56 0 : request: (snapshot) => ({
+ 57 0 : ...snapshot,
+ 58 0 : body: Option.match(decodeJson(snapshot.body), {
+ 59 0 : onNone: () => snapshot.body,
+ 60 0 : onSome: (parsed) => JSON.stringify(transform(parsed)),
+ 61 0 : }),
+ 62 : }),
+ 63 2 : })
+ 64 :
+ 65 : export interface DefaultRedactorOverrides {
+ 66 : readonly requestHeaders?: HeaderOptions
+ 67 : readonly responseHeaders?: HeaderOptions
+ 68 : readonly url?: UrlOptions
+ 69 : readonly body?: (parsed: unknown) => unknown
+ 70 : }
+ 71 :
+ 72 37 : const DEFAULT_REDACT_JSON_FIELDS = [
+ 73 17 : "access_token",
+ 74 12 : "api_key",
+ 75 11 : "apikey",
+ 76 18 : "client_secret",
+ 77 13 : "password",
+ 78 18 : "refresh_token",
+ 79 11 : "secret",
+ 80 8 : "token",
+ 81 2 : ]
+ 82 :
+ 83 79 : const normalizeField = (field: string) => field.replace(/[^a-z0-9]/gi, "").toLowerCase()
+ 84 :
+ 85 45 : const redactJsonFields = (value: unknown, fields: ReadonlySet<string>): unknown => {
+ 86 86 : if (Array.isArray(value)) return value.map((item) => redactJsonFields(item, fields))
+ 87 57 : if (!value || typeof value !== "object") return value
+ 88 26 : return Object.fromEntries(
+ 89 48 : Object.entries(value).map(([key, child]) => [
+ 90 8 : key,
+ 91 67 : fields.has(normalizeField(key)) ? REDACTED : redactJsonFields(child, fields),
+ 92 1 : ]),
+ 93 3 : )
+ 94 : }
+ 95 :
+ 96 50 : const redactBody = (value: string, fields: ReadonlySet<string>, transform: ((body: string) => string) | undefined) => {
+ 97 54 : const redacted = Option.match(decodeJson(value), {
+ 98 12 : onNone: () => value,
+ 99 68 : onSome: (parsed) => JSON.stringify(redactJsonFields(parsed, fields)),
+ 100 5 : })
+ 101 42 : return transform?.(redacted) ?? redacted
+ 102 : }
+ 103 :
+ 104 39 : export const make = (options: RedactOptions = {}): Redactor => {
+ 105 107 : const fields = new Set([...DEFAULT_REDACT_JSON_FIELDS, ...(options.jsonFields ?? [])].map(normalizeField))
+ 106 15 : return compose(
+ 107 20 : requestHeaders({
+ 108 104 : allow: [...DEFAULT_REQUEST_HEADERS, ...(options.allowRequestHeaders ?? []), ...(options.headers ?? [])],
+ 109 25 : redact: options.headers,
+ 110 4 : }),
+ 111 21 : responseHeaders({
+ 112 106 : allow: [...DEFAULT_RESPONSE_HEADERS, ...(options.allowResponseHeaders ?? []), ...(options.headers ?? [])],
+ 113 25 : redact: options.headers,
+ 114 4 : }),
+ 115 65 : url({ query: options.queryParameters, transform: options.url }),
+ 116 5 : {
+ 117 33 : request: (snapshot) => ({
+ 118 15 : ...snapshot,
+ 119 57 : body: redactBody(snapshot.body, fields, options.body),
+ 120 6 : }),
+ 121 0 : response: (snapshot) => ({
+ 122 0 : ...snapshot,
+ 123 0 : body: redactBody(snapshot.body, fields, options.body),
+ 124 2 : }),
+ 125 1 : },
+ 126 3 : )
+ 127 : }
+ 128 :
+ 129 0 : export const defaults = (overrides: DefaultRedactorOverrides = {}): Redactor =>
+ 130 0 : compose(
+ 131 0 : requestHeaders(overrides.requestHeaders),
+ 132 0 : responseHeaders(overrides.responseHeaders),
+ 133 0 : url(overrides.url),
+ 134 : ...(overrides.body ? [body(overrides.body)] : []),
+ 135 1 : )
+
+ |
+
+
+
+
+
+
+
+
+
diff --git a/packages/llm/http-recorder/src/schema.ts.gcov.html b/packages/llm/http-recorder/src/schema.ts.gcov.html
new file mode 100644
index 00000000..7970775e
--- /dev/null
+++ b/packages/llm/http-recorder/src/schema.ts.gcov.html
@@ -0,0 +1,163 @@
+
+
+
+
+
+
+ LCOV - lcov.info - ../http-recorder/src/schema.ts
+
+
+
+
+
+
+ | LCOV - code coverage report |
+  |
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+  |
+
+ |
+
+
+  |
+
+
+
+
+
|
+
+
+
+ Line data Source code
+
+ 1 32 : import { Schema } from "effect"
+ 2 : import type {
+ 3 : CassetteMetadata,
+ 4 : HttpInteraction,
+ 5 : RequestSnapshot,
+ 6 : ResponseSnapshot,
+ 7 : WebSocketEvent,
+ 8 : WebSocketInteraction,
+ 9 : } from "./types.js"
+ 10 :
+ 11 : export type {
+ 12 : CassetteMetadata,
+ 13 : HttpInteraction,
+ 14 : RequestSnapshot,
+ 15 : ResponseSnapshot,
+ 16 : WebSocketEvent,
+ 17 : WebSocketInteraction,
+ 18 : } from "./types.js"
+ 19 :
+ 20 53 : export const RequestSnapshotSchema = Schema.Struct({
+ 21 24 : method: Schema.String,
+ 22 21 : url: Schema.String,
+ 23 55 : headers: Schema.Record(Schema.String, Schema.String),
+ 24 20 : body: Schema.String,
+ 25 3 : })
+ 26 :
+ 27 54 : export const ResponseSnapshotSchema = Schema.Struct({
+ 28 24 : status: Schema.Number,
+ 29 55 : headers: Schema.Record(Schema.String, Schema.String),
+ 30 22 : body: Schema.String,
+ 31 67 : bodyEncoding: Schema.optional(Schema.Literals(["text", "base64"])),
+ 32 3 : })
+ 33 :
+ 34 83 : export const CassetteMetadataSchema = Schema.Record(Schema.String, Schema.Unknown)
+ 35 :
+ 36 53 : export const HttpInteractionSchema = Schema.Struct({
+ 37 32 : transport: Schema.tag("http"),
+ 38 33 : request: RequestSnapshotSchema,
+ 39 33 : response: ResponseSnapshotSchema,
+ 40 3 : })
+ 41 :
+ 42 51 : export const WebSocketEventSchema = Schema.Union([
+ 43 19 : Schema.Struct({
+ 44 53 : direction: Schema.Literals(["client", "server"]),
+ 45 29 : kind: Schema.tag("text"),
+ 46 21 : body: Schema.String,
+ 47 5 : }),
+ 48 19 : Schema.Struct({
+ 49 53 : direction: Schema.Literals(["client", "server"]),
+ 50 31 : kind: Schema.tag("binary"),
+ 51 24 : body: Schema.String,
+ 52 40 : bodyEncoding: Schema.Literal("base64"),
+ 53 3 : }),
+ 54 3 : ])
+ 55 :
+ 56 58 : export const WebSocketInteractionSchema = Schema.Struct({
+ 57 37 : transport: Schema.tag("websocket"),
+ 58 25 : open: Schema.Struct({
+ 59 23 : url: Schema.String,
+ 60 54 : headers: Schema.Record(Schema.String, Schema.String),
+ 61 5 : }),
+ 62 43 : events: Schema.Array(WebSocketEventSchema),
+ 63 3 : })
+ 64 :
+ 65 103 : export const InteractionSchema = Schema.Union([HttpInteractionSchema, WebSocketInteractionSchema]).pipe(
+ 66 33 : Schema.toTaggedUnion("transport"),
+ 67 3 : )
+ 68 : export type Interaction = Schema.Schema.Type<typeof InteractionSchema>
+ 69 :
+ 70 63 : export const isHttpInteraction = InteractionSchema.guards.http
+ 71 :
+ 72 73 : export const isWebSocketInteraction = InteractionSchema.guards.websocket
+ 73 :
+ 74 87 : export const httpInteractions = (interactions: ReadonlyArray<Interaction>) => interactions.filter(isHttpInteraction)
+ 75 :
+ 76 0 : export const webSocketInteractions = (interactions: ReadonlyArray<Interaction>) =>
+ 77 2 : interactions.filter(isWebSocketInteraction)
+ 78 :
+ 79 46 : export const CassetteSchema = Schema.Struct({
+ 80 29 : version: Schema.Literal(1),
+ 81 52 : metadata: Schema.optional(CassetteMetadataSchema),
+ 82 46 : interactions: Schema.Array(InteractionSchema),
+ 83 3 : })
+ 84 : export type Cassette = Schema.Schema.Type<typeof CassetteSchema>
+ 85 :
+ 86 71 : export const decodeCassette = Schema.decodeUnknownSync(CassetteSchema)
+ 87 63 : export const encodeCassette = Schema.encodeSync(CassetteSchema)
+
+ |
+
+
+
+
+
+
+
+
+
diff --git a/packages/llm/http-recorder/src/socket.ts.gcov.html b/packages/llm/http-recorder/src/socket.ts.gcov.html
new file mode 100644
index 00000000..4fb6661c
--- /dev/null
+++ b/packages/llm/http-recorder/src/socket.ts.gcov.html
@@ -0,0 +1,402 @@
+
+
+
+
+
+
+ LCOV - lcov.info - ../http-recorder/src/socket.ts
+
+
+
+
+
+
+ | LCOV - code coverage report |
+  |
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+  |
+
+ |
+
+
+  |
+
+
+
+
+
|
+
+
+
+ Line data Source code
+
+ 1 55 : import { NodeFileSystem } from "@effect/platform-node"
+ 2 81 : import { Deferred, Effect, Exit, FiberSet, Layer, Ref, Scope, Semaphore } from "effect"
+ 3 48 : import { Socket } from "effect/unstable/socket"
+ 4 49 : import * as CassetteService from "./cassette.js"
+ 5 71 : import { canonicalizeJson, decodeJson, safeText } from "./matching.js"
+ 6 65 : import { makeReplayState, resolveAutoMode } from "./recorder.js"
+ 7 37 : import { make, type Redactor } from "./redactor.js"
+ 8 52 : import { webSocketInteractions } from "./schema.js"
+ 9 : import type {
+ 10 : RecorderOptions,
+ 11 : WebSocketEvent,
+ 12 : WebSocketInteraction,
+ 13 : WebSocketRecorderOptions,
+ 14 : WebSocketRequest,
+ 15 : } from "./types.js"
+ 16 :
+ 17 : interface ActiveReplay {
+ 18 : readonly interaction: WebSocketInteraction
+ 19 : readonly progress: Ref.Ref<{ readonly position: number; readonly changed: Deferred.Deferred<void> }>
+ 20 : readonly writeLock: Semaphore.Semaphore
+ 21 : readonly closed: Ref.Ref<boolean>
+ 22 : }
+ 23 :
+ 24 : interface ActiveRecording {
+ 25 : readonly events: Array<WebSocketEvent>
+ 26 : readonly eventLock: Semaphore.Semaphore
+ 27 : readonly accepting: Ref.Ref<boolean>
+ 28 : opened: boolean
+ 29 : valid: boolean
+ 30 : }
+ 31 :
+ 32 : type Frame = string | Uint8Array
+ 33 :
+ 34 0 : const encodeEvent = (direction: "client" | "server", message: Frame): WebSocketEvent =>
+ 35 0 : typeof message === "string"
+ 36 0 : ? { direction, kind: "text", body: message }
+ 37 2 : : { direction, kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
+ 38 :
+ 39 0 : const decodeEvent = (event: WebSocketEvent): Frame =>
+ 40 2 : event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64"))
+ 41 :
+ 42 0 : const redactEvent = (event: WebSocketEvent, redactor: Redactor): WebSocketEvent => {
+ 43 0 : if (event.kind === "binary") return event
+ 44 0 : const body =
+ 45 0 : event.direction === "client"
+ 46 0 : ? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body
+ 47 0 : : redactor.response({ status: 101, headers: {}, body: event.body }).body
+ 48 2 : return { ...event, body }
+ 49 : }
+ 50 :
+ 51 0 : const comparable = (event: WebSocketEvent, asJson: boolean) => {
+ 52 0 : if (!asJson || event.kind === "binary") return JSON.stringify(canonicalizeJson(event))
+ 53 0 : const decoded = decodeJson(event.body)
+ 54 0 : return JSON.stringify(
+ 55 0 : canonicalizeJson({
+ 56 0 : ...event,
+ 57 0 : body: decoded._tag === "None" ? event.body : canonicalizeJson(decoded.value),
+ 58 0 : }),
+ 59 2 : )
+ 60 : }
+ 61 :
+ 62 0 : const assertEvent = (actual: WebSocketEvent, expected: WebSocketEvent | undefined, index: number, asJson: boolean) =>
+ 63 0 : Effect.sync(() => {
+ 64 0 : if (expected && comparable(actual, asJson) === comparable(expected, asJson)) return
+ 65 : throw new Error(`WebSocket event ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`)
+ 66 2 : })
+ 67 :
+ 68 0 : const runHandler = <A, E, R>(handler: (value: A) => Effect.Effect<unknown, E, R> | void, value: A) =>
+ 69 0 : Effect.suspend(() => {
+ 70 0 : const result = handler(value)
+ 71 : return Effect.isEffect(result) ? Effect.asVoid(result) : Effect.void
+ 72 2 : })
+ 73 :
+ 74 0 : const runReplay = <A, E, R>(
+ 75 0 : state: ActiveReplay,
+ 76 0 : handler: (value: A) => Effect.Effect<unknown, E, R> | void,
+ 77 0 : decode: (event: WebSocketEvent) => A,
+ 78 0 : onOpen: Effect.Effect<void> | undefined,
+ 79 0 : ) =>
+ 80 0 : Effect.scoped(
+ 81 0 : Effect.gen(function* () {
+ 82 0 : const handlers = yield* FiberSet.make<unknown, E>()
+ 83 0 : const run = yield* FiberSet.runtime(handlers)<R>()
+ 84 0 : if (onOpen) yield* onOpen
+ 85 0 :
+ 86 0 : const drive = Effect.gen(function* () {
+ 87 0 : while (true) {
+ 88 0 : const current = yield* Ref.get(state.progress)
+ 89 0 : const event = state.interaction.events[current.position]
+ 90 0 : if (!event) return
+ 91 0 : if (yield* Ref.get(state.closed))
+ 92 0 : return yield* Effect.die(
+ 93 0 : new Error(
+ 94 0 : `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`,
+ 95 0 : ),
+ 96 0 : )
+ 97 0 : if (event.direction === "server") {
+ 98 0 : yield* Ref.set(state.progress, {
+ 99 0 : position: current.position + 1,
+ 100 0 : changed: yield* Deferred.make<void>(),
+ 101 0 : })
+ 102 0 : run(runHandler(handler, decode(event)))
+ 103 0 : continue
+ 104 0 : }
+ 105 0 : yield* Deferred.await(current.changed)
+ 106 0 : }
+ 107 0 : })
+ 108 0 :
+ 109 0 : yield* drive.pipe(Effect.raceFirst(FiberSet.join(handlers)))
+ 110 0 : yield* FiberSet.awaitEmpty(handlers).pipe(Effect.raceFirst(FiberSet.join(handlers)))
+ 111 : }),
+ 112 2 : )
+ 113 :
+ 114 0 : const openSnapshot = (request: WebSocketRequest, redactor: Redactor) => {
+ 115 0 : const snapshot = redactor.request({ method: "GET", url: request.url, headers: request.headers ?? {}, body: "" })
+ 116 2 : return { url: snapshot.url, headers: snapshot.headers }
+ 117 : }
+ 118 :
+ 119 0 : const makeRecordingSocket = (
+ 120 0 : upstream: Socket.Socket,
+ 121 0 : cassette: CassetteService.Interface,
+ 122 0 : name: string,
+ 123 0 : request: WebSocketRequest,
+ 124 0 : options: WebSocketRecorderOptions,
+ 125 0 : redactor: Redactor,
+ 126 0 : ) =>
+ 127 0 : Effect.gen(function* () {
+ 128 0 : const active = yield* Ref.make<ActiveRecording | undefined>(undefined)
+ 129 0 : const writeLock = yield* Semaphore.make(1)
+ 130 0 :
+ 131 0 : return Socket.make({
+ 132 0 : runRaw: (handler, runOptions) =>
+ 133 0 : Effect.gen(function* () {
+ 134 0 : const state: ActiveRecording = {
+ 135 0 : events: [],
+ 136 0 : eventLock: yield* Semaphore.make(1),
+ 137 0 : accepting: yield* Ref.make(true),
+ 138 0 : opened: false,
+ 139 0 : valid: true,
+ 140 0 : }
+ 141 0 : const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state])
+ 142 0 : if (occupied) return yield* Effect.die("Concurrent runs of a recorded WebSocket are not supported")
+ 143 0 : yield* upstream
+ 144 0 : .runRaw(
+ 145 0 : (message) => {
+ 146 0 : if (!Ref.getUnsafe(state.accepting)) throw new Error("WebSocket received a frame after closing")
+ 147 0 : state.events.push(redactEvent(encodeEvent("server", message), redactor))
+ 148 0 : return handler(message)
+ 149 0 : },
+ 150 0 : {
+ 151 0 : ...runOptions,
+ 152 0 : onOpen: Effect.gen(function* () {
+ 153 0 : state.opened = true
+ 154 0 : if (runOptions?.onOpen) yield* runOptions.onOpen
+ 155 0 : }),
+ 156 0 : },
+ 157 0 : )
+ 158 0 : .pipe(
+ 159 0 : Effect.onExit((exit) =>
+ 160 0 : writeLock.withPermit(
+ 161 0 : state.eventLock.withPermit(
+ 162 0 : Effect.gen(function* () {
+ 163 0 : yield* Ref.set(state.accepting, false)
+ 164 0 : yield* Ref.set(active, undefined)
+ 165 0 : if (!Exit.isSuccess(exit) || !state.opened || !state.valid) return
+ 166 0 : yield* cassette
+ 167 0 : .append(
+ 168 0 : name,
+ 169 0 : {
+ 170 0 : transport: "websocket",
+ 171 0 : open: openSnapshot(request, redactor),
+ 172 0 : events: [...state.events],
+ 173 0 : },
+ 174 0 : options.metadata,
+ 175 0 : )
+ 176 0 : .pipe(Effect.orDie)
+ 177 0 : }),
+ 178 0 : ),
+ 179 0 : ),
+ 180 0 : ),
+ 181 0 : )
+ 182 0 : }),
+ 183 0 : writer: upstream.writer.pipe(
+ 184 0 : Effect.map(
+ 185 0 : (write) => (message) =>
+ 186 0 : writeLock.withPermit(
+ 187 0 : Effect.gen(function* () {
+ 188 0 : if (Socket.isCloseEvent(message)) return yield* write(message)
+ 189 0 : const state = yield* Ref.get(active)
+ 190 0 : if (!state || !(yield* Ref.get(state.accepting)))
+ 191 0 : return yield* Effect.die("WebSocket writer used without an active socket run")
+ 192 0 : const event = redactEvent(encodeEvent("client", message), redactor)
+ 193 0 : yield* state.eventLock.withPermit(Effect.sync(() => state.events.push(event)))
+ 194 0 : return yield* write(message).pipe(Effect.onError(() => Effect.sync(() => (state.valid = false))))
+ 195 0 : }),
+ 196 0 : ),
+ 197 0 : ),
+ 198 0 : ),
+ 199 : })
+ 200 2 : })
+ 201 :
+ 202 0 : const makeReplaySocket = (
+ 203 0 : cassette: CassetteService.Interface,
+ 204 0 : name: string,
+ 205 0 : request: WebSocketRequest,
+ 206 0 : options: WebSocketRecorderOptions,
+ 207 0 : redactor: Redactor,
+ 208 0 : ): Effect.Effect<Socket.Socket, never, Scope.Scope> =>
+ 209 0 : Effect.gen(function* () {
+ 210 0 : const replay = yield* makeReplayState(cassette, name, webSocketInteractions)
+ 211 0 : const active = yield* Ref.make<ActiveReplay | undefined>(undefined)
+ 212 0 :
+ 213 0 : return Socket.make({
+ 214 0 : runRaw: (handler, runOptions) =>
+ 215 0 : Effect.gen(function* () {
+ 216 0 : const claimed = yield* replay
+ 217 0 : .claim((interaction, index) =>
+ 218 0 : Effect.sync(() => {
+ 219 0 : const incoming = openSnapshot(request, redactor)
+ 220 0 : if (
+ 221 0 : interaction &&
+ 222 0 : JSON.stringify(canonicalizeJson(incoming)) === JSON.stringify(canonicalizeJson(interaction.open))
+ 223 0 : )
+ 224 0 : return
+ 225 0 : throw new Error(
+ 226 0 : `WebSocket open ${index + 1}: expected ${safeText(interaction?.open)}, received ${safeText(incoming)}`,
+ 227 0 : )
+ 228 0 : }),
+ 229 0 : )
+ 230 0 : .pipe(Effect.orDie)
+ 231 0 : const progress = yield* Ref.make({ position: 0, changed: yield* Deferred.make<void>() })
+ 232 0 : const writeLock = yield* Semaphore.make(1)
+ 233 0 : const state = {
+ 234 0 : interaction: claimed.interaction,
+ 235 0 : progress,
+ 236 0 : writeLock,
+ 237 0 : closed: yield* Ref.make(false),
+ 238 0 : }
+ 239 0 : const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state])
+ 240 0 : if (occupied) return yield* Effect.die("Concurrent runs of a replayed WebSocket are not supported")
+ 241 0 : yield* runReplay(state, handler, decodeEvent, runOptions?.onOpen).pipe(
+ 242 0 : Effect.ensuring(Ref.set(active, undefined)),
+ 243 0 : )
+ 244 0 : }),
+ 245 0 : writer: Effect.succeed((message) => {
+ 246 0 : return Ref.get(active).pipe(
+ 247 0 : Effect.flatMap((state) =>
+ 248 0 : state
+ 249 0 : ? state.writeLock.withPermit(
+ 250 0 : Effect.gen(function* () {
+ 251 0 : const current = yield* Ref.get(state.progress)
+ 252 0 : if (Socket.isCloseEvent(message)) {
+ 253 0 : yield* Ref.set(state.closed, true)
+ 254 0 : yield* Deferred.succeed(current.changed, undefined)
+ 255 0 : if (current.position === state.interaction.events.length) return
+ 256 0 : return yield* Effect.die(
+ 257 0 : new Error(
+ 258 0 : `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`,
+ 259 0 : ),
+ 260 0 : )
+ 261 0 : }
+ 262 0 : const actual = redactEvent(encodeEvent("client", message), redactor)
+ 263 0 : yield* assertEvent(
+ 264 0 : actual,
+ 265 0 : state.interaction.events[current.position],
+ 266 0 : current.position,
+ 267 0 : options.compareClientMessagesAsJson === true,
+ 268 0 : )
+ 269 0 : yield* Ref.set(state.progress, {
+ 270 0 : position: current.position + 1,
+ 271 0 : changed: yield* Deferred.make<void>(),
+ 272 0 : })
+ 273 0 : yield* Deferred.succeed(current.changed, undefined)
+ 274 0 : }),
+ 275 0 : )
+ 276 0 : : Effect.die("WebSocket writer used without an active socket run"),
+ 277 0 : ),
+ 278 0 : )
+ 279 0 : }),
+ 280 : })
+ 281 2 : })
+ 282 :
+ 283 0 : const recordingLayer = (
+ 284 0 : name: string,
+ 285 0 : request: WebSocketRequest,
+ 286 0 : options: WebSocketRecorderOptions,
+ 287 0 : forcedMode?: "record" | "replay",
+ 288 0 : ): Layer.Layer<Socket.Socket, never, Socket.Socket | CassetteService.Service> =>
+ 289 0 : Layer.effect(
+ 290 0 : Socket.Socket,
+ 291 0 : Effect.gen(function* () {
+ 292 0 : const upstream = yield* Socket.Socket
+ 293 0 : const cassette = yield* CassetteService.Service
+ 294 0 : const redactor = make(options.redact)
+ 295 0 : if ((forcedMode ?? (yield* resolveAutoMode(cassette, name))) === "record")
+ 296 0 : return yield* makeRecordingSocket(upstream, cassette, name, request, options, redactor)
+ 297 0 : return yield* makeReplaySocket(cassette, name, request, options, redactor)
+ 298 : }),
+ 299 2 : )
+ 300 :
+ 301 : /**
+ 302 : * Wraps a provided `Socket.Socket` with cassette recording and replay.
+ 303 : *
+ 304 : * Supply the ordinary URL-bound Effect socket layer beneath this decorator.
+ 305 : * The cassette name identifies the connection; recorder configuration does not
+ 306 : * duplicate the transport URL.
+ 307 : */
+ 308 0 : export const socket = (name: string, options: RecorderOptions = {}): Layer.Layer<Socket.Socket, never, Socket.Socket> =>
+ 309 2 : provideCassette(recordingLayer(name, { url: "" }, { ...options, compareClientMessagesAsJson: true }), options)
+ 310 :
+ 311 : /** @internal */
+ 312 0 : export const socketLayer = (
+ 313 0 : name: string,
+ 314 0 : request: WebSocketRequest,
+ 315 0 : options: WebSocketRecorderOptions & { readonly mode: "record" | "replay" },
+ 316 0 : ): Layer.Layer<Socket.Socket, never, Socket.Socket> =>
+ 317 2 : provideCassette(recordingLayer(name, request, options, options.mode), options)
+ 318 :
+ 319 0 : const provideCassette = (
+ 320 0 : layer: Layer.Layer<Socket.Socket, never, Socket.Socket | CassetteService.Service>,
+ 321 0 : options: WebSocketRecorderOptions,
+ 322 0 : ) =>
+ 323 0 : layer.pipe(
+ 324 0 : Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
+ 325 : Layer.provide(NodeFileSystem.layer),
+ 326 1 : )
+
+ |
+
+
+
+
+
+
+
+
+
diff --git a/packages/llm/http-recorder/src/websocket.ts.gcov.html b/packages/llm/http-recorder/src/websocket.ts.gcov.html
new file mode 100644
index 00000000..4e78fb92
--- /dev/null
+++ b/packages/llm/http-recorder/src/websocket.ts.gcov.html
@@ -0,0 +1,249 @@
+
+
+
+
+
+
+ LCOV - lcov.info - ../http-recorder/src/websocket.ts
+
+
+
+
+
+
+ | LCOV - code coverage report |
+  |
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+  |
+
+ |
+
+
+  |
+
+
+
+
+
|
+
+
+
+ Line data Source code
+
+ 1 81 : import { Effect, Option, Ref, Scope, Semaphore, Stream, SynchronizedRef } from "effect"
+ 2 : import type { Headers } from "effect/unstable/http"
+ 3 : import * as CassetteService from "./cassette.js"
+ 4 71 : import { canonicalizeJson, decodeJson, safeText } from "./matching.js"
+ 5 65 : import { makeReplayState, resolveAutoMode } from "./recorder.js"
+ 6 : import type { RecordReplayMode } from "./internal-effect.js"
+ 7 37 : import { make, type Redactor } from "./redactor.js"
+ 8 52 : import { webSocketInteractions, type CassetteMetadata, type WebSocketEvent } from "./schema.js"
+ 9 :
+ 10 : export interface WebSocketRequest {
+ 11 : readonly url: string
+ 12 : readonly headers: Headers.Headers
+ 13 : }
+ 14 :
+ 15 : export interface WebSocketConnection<E> {
+ 16 : readonly sendText: (message: string) => Effect.Effect<void, E>
+ 17 : readonly messages: Stream.Stream<string | Uint8Array, E>
+ 18 : readonly close: Effect.Effect<void>
+ 19 : }
+ 20 :
+ 21 : export interface WebSocketExecutor<E> {
+ 22 : readonly open: (request: WebSocketRequest) => Effect.Effect<WebSocketConnection<E>, E>
+ 23 : }
+ 24 :
+ 25 : export interface WebSocketRecordReplayOptions<E> {
+ 26 : readonly name: string
+ 27 : readonly mode?: RecordReplayMode
+ 28 : readonly metadata?: CassetteMetadata
+ 29 : readonly cassette: CassetteService.Interface
+ 30 : readonly live: WebSocketExecutor<E>
+ 31 : readonly redactor?: Redactor
+ 32 : readonly compareClientMessagesAsJson?: boolean
+ 33 : }
+ 34 :
+ 35 0 : const headersRecord = (headers: Headers.Headers): Record<string, string> =>
+ 36 0 : Object.fromEntries(
+ 37 0 : Object.entries(headers as Record<string, unknown>).filter(
+ 38 0 : (entry): entry is [string, string] => typeof entry[1] === "string",
+ 39 : ),
+ 40 2 : )
+ 41 :
+ 42 0 : const textEvent = (direction: "client" | "server", body: string): WebSocketEvent => ({
+ 43 0 : direction,
+ 44 0 : kind: "text",
+ 45 : body,
+ 46 2 : })
+ 47 :
+ 48 0 : const decodeEvent = (event: WebSocketEvent) =>
+ 49 2 : event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64"))
+ 50 :
+ 51 19 : const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson })
+ 52 :
+ 53 0 : const assertClientEvent = (actual: string, expected: WebSocketEvent | undefined, index: number, asJson: boolean) =>
+ 54 0 : Effect.sync(() => {
+ 55 0 : const matches =
+ 56 0 : expected?.direction === "client" &&
+ 57 0 : expected.kind === "text" &&
+ 58 0 : JSON.stringify(asJson ? jsonOrText(actual) : actual) ===
+ 59 0 : JSON.stringify(asJson ? jsonOrText(expected.body) : expected.body)
+ 60 0 : if (matches) return
+ 61 : throw new Error(`WebSocket client frame ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`)
+ 62 2 : })
+ 63 :
+ 64 36 : export const makeWebSocketExecutor = <E>(
+ 65 12 : options: WebSocketRecordReplayOptions<E>,
+ 66 : ): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
+ 67 15 : Effect.gen(function* () {
+ 68 88 : const mode = options.mode ?? (yield* resolveAutoMode(options.cassette, options.name))
+ 69 46 : const redactor = options.redactor ?? make()
+ 70 0 : const openSnapshot = (request: WebSocketRequest) => {
+ 71 0 : const snapshot = redactor.request({
+ 72 0 : method: "GET",
+ 73 0 : url: request.url,
+ 74 0 : headers: headersRecord(request.headers),
+ 75 0 : body: "",
+ 76 0 : })
+ 77 3 : return { url: snapshot.url, headers: snapshot.headers }
+ 78 : }
+ 79 0 : const redactEvent = (event: WebSocketEvent) => {
+ 80 0 : if (event.kind === "binary") return event
+ 81 0 : const body =
+ 82 0 : event.direction === "client"
+ 83 0 : ? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body
+ 84 0 : : redactor.response({ status: 101, headers: {}, body: event.body }).body
+ 85 3 : return { ...event, body }
+ 86 : }
+ 87 :
+ 88 32 : if (mode === "passthrough") return options.live
+ 89 :
+ 90 22 : if (mode === "record") {
+ 91 0 : return {
+ 92 0 : open: (request) =>
+ 93 0 : Effect.gen(function* () {
+ 94 0 : const events: WebSocketEvent[] = []
+ 95 0 : const connection = yield* options.live.open(request)
+ 96 0 : const closed = yield* Ref.make(false)
+ 97 0 : const closeLock = yield* Semaphore.make(1)
+ 98 0 : return {
+ 99 0 : sendText: (message) =>
+ 100 0 : Effect.sync(() => events.push(redactEvent(textEvent("client", message)))).pipe(
+ 101 0 : Effect.andThen(connection.sendText(message)),
+ 102 0 : ),
+ 103 0 : messages: connection.messages.pipe(
+ 104 0 : Stream.tap((message) =>
+ 105 0 : Effect.sync(() =>
+ 106 0 : events.push(
+ 107 0 : typeof message === "string"
+ 108 0 : ? redactEvent(textEvent("server", message))
+ 109 0 : : {
+ 110 0 : direction: "server",
+ 111 0 : kind: "binary",
+ 112 0 : body: Buffer.from(message).toString("base64"),
+ 113 0 : bodyEncoding: "base64",
+ 114 0 : },
+ 115 0 : ),
+ 116 0 : ),
+ 117 0 : ),
+ 118 0 : ),
+ 119 0 : close: closeLock.withPermit(
+ 120 0 : Effect.gen(function* () {
+ 121 0 : if (yield* Ref.get(closed)) return
+ 122 0 : yield* connection.close
+ 123 0 : yield* options.cassette
+ 124 0 : .append(
+ 125 0 : options.name,
+ 126 0 : { transport: "websocket", open: openSnapshot(request), events },
+ 127 0 : options.metadata,
+ 128 0 : )
+ 129 0 : .pipe(Effect.orDie)
+ 130 0 : yield* Ref.set(closed, true)
+ 131 0 : }),
+ 132 0 : ),
+ 133 : }
+ 134 0 : }),
+ 135 0 : }
+ 136 2 : }
+ 137 :
+ 138 95 : const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions)
+ 139 12 : return {
+ 140 0 : open: (request) =>
+ 141 0 : Effect.gen(function* () {
+ 142 0 : const claimed = yield* replay
+ 143 0 : .claim((interaction, index) =>
+ 144 0 : Effect.sync(() => {
+ 145 0 : const incoming = canonicalizeJson(openSnapshot(request))
+ 146 0 : if (interaction && JSON.stringify(incoming) === JSON.stringify(canonicalizeJson(interaction.open)))
+ 147 0 : return
+ 148 0 : throw new Error(`WebSocket open ${index + 1} does not match ${safeText(incoming)}`)
+ 149 0 : }),
+ 150 0 : )
+ 151 0 : .pipe(Effect.orDie)
+ 152 0 : const client = claimed.interaction.events.filter((event) => event.direction === "client")
+ 153 0 : const server = claimed.interaction.events.filter((event) => event.direction === "server")
+ 154 0 : const position = yield* SynchronizedRef.make(0)
+ 155 0 : return {
+ 156 0 : sendText: (message) =>
+ 157 0 : SynchronizedRef.updateEffect(position, (index) =>
+ 158 0 : assertClientEvent(message, client[index], index, options.compareClientMessagesAsJson === true).pipe(
+ 159 0 : Effect.as(index + 1),
+ 160 0 : ),
+ 161 0 : ),
+ 162 0 : messages: Stream.fromIterable(server).pipe(Stream.map(decodeEvent)),
+ 163 0 : close: Effect.gen(function* () {
+ 164 0 : const used = yield* SynchronizedRef.get(position)
+ 165 0 : if (used !== client.length)
+ 166 0 : return yield* Effect.die(
+ 167 0 : new Error(`WebSocket client frame count: expected ${client.length}, received ${used}`),
+ 168 0 : )
+ 169 0 : }),
+ 170 : }
+ 171 2 : }),
+ 172 1 : }
+ 173 1 : })
+
+ |
+
+
+
+
+
+
+
+
+
diff --git a/packages/llm/schema/src/index-sort-f.html b/packages/llm/schema/src/index-sort-f.html
new file mode 100644
index 00000000..87bc821a
--- /dev/null
+++ b/packages/llm/schema/src/index-sort-f.html
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+ LCOV - lcov.info - ../schema/src
+
+
+
+
+
+
+ | LCOV - code coverage report |
+  |
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+  |
+
+ |
+
+
+  |
+
+
+
+
+
+
+
|
+ |
+ |
+ |
+ |
+
+
+
+ File  |
+ Line Coverage  |
+
+
+ | Rate |
+ Total |
+ Hit |
+
+
+ | llm.ts |
+
+
+ |
+ 100.0 % |
+ 19 |
+ 19 |
+
+
+ | schema.ts |
+
+
+ |
+ 90.9 % |
+ 22 |
+ 20 |
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/llm/schema/src/index-sort-l.html b/packages/llm/schema/src/index-sort-l.html
new file mode 100644
index 00000000..70014036
--- /dev/null
+++ b/packages/llm/schema/src/index-sort-l.html
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+ LCOV - lcov.info - ../schema/src
+
+
+
+
+
+
+ | LCOV - code coverage report |
+  |
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+  |
+
+ |
+
+
+  |
+
+
+
+
+
+
+
|
+ |
+ |
+ |
+ |
+
+
+
+ File  |
+ Line Coverage  |
+
+
+ | Rate |
+ Total |
+ Hit |
+
+
+ | schema.ts |
+
+
+ |
+ 90.9 % |
+ 22 |
+ 20 |
+
+
+ | llm.ts |
+
+
+ |
+ 100.0 % |
+ 19 |
+ 19 |
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/llm/schema/src/index.html b/packages/llm/schema/src/index.html
new file mode 100644
index 00000000..89f97961
--- /dev/null
+++ b/packages/llm/schema/src/index.html
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+ LCOV - lcov.info - ../schema/src
+
+
+
+
+
+
+ | LCOV - code coverage report |
+  |
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+  |
+
+ |
+
+
+  |
+
+
+
+
+
+
+
|
+ |
+ |
+ |
+ |
+
+
+
+ File  |
+ Line Coverage  |
+
+
+ | Rate |
+ Total |
+ Hit |
+
+
+ | llm.ts |
+
+
+ |
+ 100.0 % |
+ 19 |
+ 19 |
+
+
+ | schema.ts |
+
+
+ |
+ 90.9 % |
+ 22 |
+ 20 |
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/llm/schema/src/llm.ts.gcov.html b/packages/llm/schema/src/llm.ts.gcov.html
new file mode 100644
index 00000000..1a7cd1b0
--- /dev/null
+++ b/packages/llm/schema/src/llm.ts.gcov.html
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+ LCOV - lcov.info - ../schema/src/llm.ts
+
+
+
+
+
+
+ | LCOV - code coverage report |
+  |
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+  |
+
+ |
+
+
+  |
+
+
+
+
+
|
+
+
+
+ Line data Source code
+
+ 1 29 : export * as LLM from "./llm"
+ 2 :
+ 3 32 : import { Schema } from "effect"
+ 4 36 : import { optional } from "./schema"
+ 5 :
+ 6 118 : export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)).annotate({
+ 7 35 : identifier: "LLM.ProviderMetadata",
+ 8 3 : })
+ 9 : export type ProviderMetadata = Schema.Schema.Type<typeof ProviderMetadata>
+ 10 :
+ 11 : export interface ToolTextContent extends Schema.Schema.Type<typeof ToolTextContent> {}
+ 12 47 : export const ToolTextContent = Schema.Struct({
+ 13 31 : type: Schema.Literal("text"),
+ 14 20 : text: Schema.String,
+ 15 48 : }).annotate({ identifier: "Tool.TextContent" })
+ 16 :
+ 17 : export interface ToolFileContent extends Schema.Schema.Type<typeof ToolFileContent> {}
+ 18 47 : export const ToolFileContent = Schema.Struct({
+ 19 31 : type: Schema.Literal("file"),
+ 20 21 : uri: Schema.String,
+ 21 22 : mime: Schema.String,
+ 22 30 : name: optional(Schema.String),
+ 23 48 : }).annotate({ identifier: "Tool.FileContent" })
+ 24 :
+ 25 75 : export const ToolContent = Schema.Union([ToolTextContent, ToolFileContent])
+ 26 35 : .pipe(Schema.toTaggedUnion("type"))
+ 27 44 : .annotate({ identifier: "LLM.ToolContent" })
+ 28 : export type ToolContent = Schema.Schema.Type<typeof ToolContent>
+
+ |
+
+
+
+
+
+
+
+
+
diff --git a/packages/llm/schema/src/schema.ts.gcov.html b/packages/llm/schema/src/schema.ts.gcov.html
new file mode 100644
index 00000000..68766767
--- /dev/null
+++ b/packages/llm/schema/src/schema.ts.gcov.html
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+ LCOV - lcov.info - ../schema/src/schema.ts
+
+
+
+
+
+
+ | LCOV - code coverage report |
+  |
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+  |
+
+ |
+
+
+  |
+
+
+
+
+
|
+
+
+
+ Line data Source code
+
+ 1 64 : import { DateTime, Option, Schema, SchemaGetter } from "effect"
+ 2 :
+ 3 69 : export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
+ 4 81 : export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
+ 5 :
+ 6 77 : export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath"))
+ 7 : export type RelativePath = typeof RelativePath.Type
+ 8 :
+ 9 77 : export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath"))
+ 10 : export type AbsolutePath = typeof AbsolutePath.Type
+ 11 :
+ 12 34 : export const optional = <S extends Schema.Top>(schema: S) =>
+ 13 32 : Schema.optionalKey(schema).pipe(
+ 14 59 : Schema.decodeTo(Schema.optional(Schema.toType(schema)), {
+ 15 54 : decode: SchemaGetter.passthrough({ strict: false }),
+ 16 55 : encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)),
+ 17 1 : }),
+ 18 2 : )
+ 19 :
+ 20 21 : export const statics =
+ 21 0 : <S extends object, M extends Record<string, unknown>>(methods: (schema: S) => M) =>
+ 22 0 : (schema: S): S & M =>
+ 23 2 : Object.assign(schema, methods(schema))
+ 24 :
+ 25 55 : export const DateTimeUtcFromMillis = Schema.Finite.pipe(
+ 26 39 : Schema.decodeTo(Schema.DateTimeUtc, {
+ 27 34 : decode: SchemaGetter.transform((value) => DateTime.makeUnsafe(value)),
+ 28 32 : encode: SchemaGetter.transform((value) => DateTime.toEpochMillis(value)),
+ 29 1 : }),
+ 30 2 : )
+
+ |
+
+
+
+
+
+
+
+
+
diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts
index 5b7d354b..5cea6644 100644
--- a/packages/server/src/handlers/session.ts
+++ b/packages/server/src/handlers/session.ts
@@ -16,6 +16,14 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
const DefaultSessionsLimit = 50
const DefaultSessionHistoryLimit = 50
+const mapSessionNotFound = (error: { sessionID: string }) =>
+ Effect.fail(
+ new SessionNotFoundError({
+ sessionID: error.sessionID,
+ message: `Session not found: ${error.sessionID}`,
+ }),
+ )
+
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
@@ -173,14 +181,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
"session.compact",
Effect.fn(function* (ctx) {
yield* session.compact({ sessionID: ctx.params.sessionID }).pipe(
- Effect.catchTag("Session.NotFoundError", (error) =>
- Effect.fail(
- new SessionNotFoundError({
- sessionID: error.sessionID,
- message: `Session not found: ${error.sessionID}`,
- }),
- ),
- ),
+ Effect.catchTag("Session.NotFoundError", mapSessionNotFound),
Effect.catchTag("Session.OperationUnavailableError", (error) =>
Effect.fail(
new ServiceUnavailableError({
@@ -197,14 +198,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
"session.wait",
Effect.fn(function* (ctx) {
yield* session.wait(ctx.params.sessionID).pipe(
- Effect.catchTag("Session.NotFoundError", (error) =>
- Effect.fail(
- new SessionNotFoundError({
- sessionID: error.sessionID,
- message: `Session not found: ${error.sessionID}`,
- }),
- ),
- ),
+ Effect.catchTag("Session.NotFoundError", mapSessionNotFound),
Effect.catchTag("Session.OperationUnavailableError", (error) =>
Effect.fail(
new ServiceUnavailableError({
@@ -382,4 +376,4 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}),
)
}),
-)
+)
\ No newline at end of file