diff --git a/bun.lock b/bun.lock index 18923e1..2736fcb 100644 --- a/bun.lock +++ b/bun.lock @@ -21,13 +21,17 @@ "name": "@humanlayer/example-alchemy-cloudflare", "version": "0.0.0", "dependencies": { + "@cloudflare/computer": "catalog:", "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", + "@humanlayer/fold-agent": "workspace:*", "@humanlayer/fold-core": "workspace:*", + "@platformatic/vfs": "catalog:", "alchemy": "catalog:", "effect": "catalog:", }, "devDependencies": { + "@cloudflare/workers-types": "catalog:", "@effect/vitest": "catalog:", "@humanlayer/fold-vitest-config": "workspace:*", "typescript": "catalog:", @@ -261,6 +265,7 @@ }, }, "catalog": { + "@cloudflare/computer": "https://github.com/K-Mistele/computer/releases/download/fold-91611593a1d7/cloudflare-computer-0.3.1.tgz", "@cloudflare/workers-types": "5.20260929.1", "@effect/platform-bun": "4.0.0-rc.112", "@effect/platform-node": "4.0.0-rc.112", @@ -269,6 +274,7 @@ "@opentui/core": "0.4.3", "@opentui/keymap": "0.4.3", "@opentui/solid": "0.4.3", + "@platformatic/vfs": "0.4.0", "@silvia-odwyer/photon-node": "0.3.4", "@types/bun": "1.3.14", "@types/node": "26.1.0", @@ -389,6 +395,8 @@ "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], + "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@10.5.0", "", { "dependencies": { "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw=="], "@chevrotain/gast": ["@chevrotain/gast@10.5.0", "", { "dependencies": { "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A=="], @@ -401,6 +409,8 @@ "@clack/prompts": ["@clack/prompts@1.8.1", "", { "dependencies": { "@clack/core": "1.5.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ=="], + "@cloudflare/computer": ["@cloudflare/computer@https://github.com/K-Mistele/computer/releases/download/fold-91611593a1d7/cloudflare-computer-0.3.1.tgz", { "dependencies": { "acorn": "^8.17.0", "capnweb": "^0.10.0", "just-bash": "^3.0.1" }, "peerDependencies": { "@platformatic/vfs": "*", "ai": "^6.0.196 || ^7.0.0", "zod": "^4.4.3" }, "optionalPeers": ["@platformatic/vfs", "ai", "zod"] }, "sha512-TC9Pqi3UtVbaA/jSW9ZOVUQcfnQaSJdJkTK1sc8oL0skyopVFtjxX6N3vrbqzavN50zKXpxTDPL6xRh6hyP2VQ=="], + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.2", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ=="], "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260704.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-XO+vvdhhTNZSsIWCkZ+JaE/JrFUmQAB0H0y/sVkAf32xa2TYBRvFUMwjSqf6WxtlxcKPQvmbLfpO/UQ0l/q4eQ=="], @@ -557,6 +567,16 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.5", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="], + "@jitl/quickjs-ffi-types": ["@jitl/quickjs-ffi-types@0.32.0", "", {}, "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg=="], + + "@jitl/quickjs-wasmfile-debug-asyncify": ["@jitl/quickjs-wasmfile-debug-asyncify@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw=="], + + "@jitl/quickjs-wasmfile-debug-sync": ["@jitl/quickjs-wasmfile-debug-sync@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q=="], + + "@jitl/quickjs-wasmfile-release-asyncify": ["@jitl/quickjs-wasmfile-release-asyncify@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q=="], + + "@jitl/quickjs-wasmfile-release-sync": ["@jitl/quickjs-wasmfile-release-sync@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], @@ -603,6 +623,8 @@ "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], + "@mongodb-js/zstd": ["@mongodb-js/zstd@7.0.0", "", { "dependencies": { "node-addon-api": "^8.5.0", "prebuild-install": "^7.1.3" } }, "sha512-mQ2s0pYYiav+tzCDR05Zptem8Ey2v8s11lri5RKGhTtL4COVCvVCk5vtyRYNT+9L8qSfyOqqefF9UtnW8mC5jA=="], + "@mrleebo/prisma-ast": ["@mrleebo/prisma-ast@0.13.1", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw=="], "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], @@ -779,6 +801,8 @@ "@paralleldrive/cuid2": ["@paralleldrive/cuid2@3.3.0", "", { "dependencies": { "@noble/hashes": "^2.0.1", "bignumber.js": "^9.3.1", "error-causes": "^3.0.2" }, "bin": { "cuid2": "bin/cuid2.js" } }, "sha512-OqiFvSOF0dBSesELYY2CAMa4YINvlLpvKOz/rv6NeZEqiyttlHgv98Juwv4Ch+GrEV7IZ8jfI2VcEoYUjXXCjw=="], + "@platformatic/vfs": ["@platformatic/vfs@0.4.0", "", {}, "sha512-JwRxSIG63e/VaDSkYXkSBajySt5MJzxYIKuknYda28fhhmJMqyUGf5rbKrGW+vO9L83nIjQH3+YI12fp7EKICQ=="], + "@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="], "@prisma/dev": ["@prisma/dev@0.20.0", "", { "dependencies": { "@electric-sql/pglite": "0.3.15", "@electric-sql/pglite-socket": "0.0.20", "@electric-sql/pglite-tools": "0.2.20", "@hono/node-server": "1.19.9", "@mrleebo/prisma-ast": "0.13.1", "@prisma/get-platform": "7.2.0", "@prisma/query-plan-executor": "7.2.0", "foreground-child": "3.3.1", "get-port-please": "3.2.0", "hono": "4.11.4", "http-status-codes": "2.3.0", "pathe": "2.0.3", "proper-lockfile": "4.1.2", "remeda": "2.33.4", "std-env": "3.10.0", "valibot": "1.2.0", "zeptomatch": "2.1.0" } }, "sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ=="], @@ -861,6 +885,10 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@types/aws-lambda": ["@types/aws-lambda@8.10.164", "", {}, "sha512-XOnrazWcOd6yWPnR7DxqCBPPYciDjHq+NN8LPwwqBCJNXLyPJgKo47fZpJ113+oyAn9Zti0Cf6H4k6yPcE3WFg=="], @@ -937,6 +965,8 @@ "@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + "alchemy": ["alchemy@2.0.0-beta.76", "", { "dependencies": { "@alchemy.run/cloudflare-runtime": "2.0.0-beta.76", "@alchemy.run/floci": "2.0.0-beta.76", "@alchemy.run/node-utils": "2.0.0-beta.76", "@aws-sdk/credential-providers": "^3.0.0", "@clack/prompts": "^1.7.0", "@distilled.cloud/aws": "1.0.0-rc.8", "@distilled.cloud/axiom": "1.0.0-rc.8", "@distilled.cloud/cloudflare": "1.0.0-rc.8", "@distilled.cloud/core": "1.0.0-rc.8", "@distilled.cloud/fly-io": "1.0.0-rc.8", "@distilled.cloud/hetzner": "1.0.0-rc.8", "@distilled.cloud/neon": "1.0.0-rc.8", "@distilled.cloud/planetscale": "1.0.0-rc.8", "@distilled.cloud/railway": "1.0.0-rc.8", "@effect/sql-d1": ">=4.0.0-rc.112 || >=4.0.0", "@effect/sql-sqlite-do": ">=4.0.0-rc.112 || >=4.0.0", "@effect/vitest": ">=4.0.0-rc.112 || >=4.0.0", "@libsql/client": "^0.17.0", "@octokit/rest": "^22.0.1", "@octokit/webhooks": "^14.2.0", "@prisma/dev": "^0.20.0", "@smithy/node-config-provider": "^4.0.0", "@smithy/shared-ini-file-loader": "^4.3.4", "@smithy/types": "^4.8.1", "@types/aws-lambda": "^8.10.152", "aws4fetch": "^1.0.20", "capnweb": "^0.6.1", "fast-glob": "^3.3.2", "fast-xml-parser": "^5.3.4", "ink": "^6.3.1", "jszip": "^3.10.1", "libsodium-wrappers": "^0.8.3", "pathe": "^2.0.3", "picomatch": "^4.0.4", "react": "^19.2.0", "rolldown": "1.2.5", "undici": "^7.16.0", "yaml": "^2.0.0" }, "peerDependencies": { "@alchemy.run/frontend-frameworks": "2.0.0-beta.76", "@aws/durable-execution-sdk-js": "^2.1.0", "@effect/platform-bun": ">=4.0.0-rc.112 || >=4.0.0", "@effect/platform-node": ">=4.0.0-rc.112 || >=4.0.0", "@effect/sql-mysql2": ">=4.0.0-rc.112 || >=4.0.0", "@effect/sql-pg": ">=4.0.0-rc.112 || >=4.0.0", "@vercel/nft": "^1.10.2", "drizzle-kit": "1.0.0-rc.5-ab785fc", "drizzle-orm": "1.0.0-rc.5-ab785fc", "effect": ">=4.0.0-rc.112 || >=4.0.0", "mongodb": "^6.10.0", "mysql2": "^3.24.2", "pg": "^8.22.0", "vite": "^8.0.7", "ws": "^8.20.0" }, "optionalPeers": ["@alchemy.run/frontend-frameworks", "@aws/durable-execution-sdk-js", "@effect/platform-bun", "@effect/platform-node", "@effect/sql-mysql2", "@effect/sql-pg", "@vercel/nft", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "pg", "vite", "ws"], "bin": { "alchemy": "./bin/cli.js" } }, "sha512-XpcsXWk38YV9nO0v7s5PPjlMVtF8OQPeP8YqLu1iMzrq33bwViEOqkwQn3cgFiD7/QsnNcCju+hCXyeBwXrdtg=="], "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], @@ -961,7 +991,9 @@ "babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.43", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ=="], @@ -969,14 +1001,18 @@ "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], - "brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="], + "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="], + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], @@ -985,7 +1021,7 @@ "capnp-es": ["capnp-es@0.0.14", "", { "peerDependencies": { "typescript": "^5.7.3" }, "optionalPeers": ["typescript"], "bin": { "capnp-es": "dist/compiler/capnpc-js.mjs", "capnpc-js": "dist/compiler/capnpc-js.mjs", "capnpc-ts": "dist/compiler/capnpc-ts.mjs", "capnpc-dts": "dist/compiler/capnpc-dts.mjs" } }, "sha512-8lWj4GJISiqRSlAJGkWpI4Azib7QY5UDIkqxeHcI7aAnXVk9SFuWxl1Fme+2HhzNANV0WTJqwUZvvXvloc3sBA=="], - "capnweb": ["capnweb@0.6.1", "", {}, "sha512-fmhV26QPd1ewf5R74h55oVZnGwIcSaRMzbfLQUy8+zOBjuTmT3KXoT8wxHvnp1m9Ht9BoUUS5ZwNLoVLfQTyBg=="], + "capnweb": ["capnweb@0.10.0", "", {}, "sha512-lQ2nlzaifkqBeNfs8RLErn7NZ8FNf3vn1W7J3nSH73edWG+0B5nHZGR51S2S9HMZEuZcEypOO469dZRY/BluFA=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], @@ -993,6 +1029,8 @@ "chevrotain": ["chevrotain@10.5.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "10.5.0", "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "@chevrotain/utils": "10.5.0", "lodash": "4.17.21", "regexp-to-ast": "0.5.0" } }, "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A=="], + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], @@ -1005,6 +1043,8 @@ "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], + "commander": ["commander@6.2.1", "", {}, "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA=="], + "content-type": ["content-type@3.1.1", "", {}, "sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], @@ -1019,6 +1059,10 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], @@ -1029,6 +1073,8 @@ "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], @@ -1047,6 +1093,8 @@ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], @@ -1067,6 +1115,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "file-type": ["file-type@21.3.4", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="], @@ -1075,6 +1125,8 @@ "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -1089,6 +1141,8 @@ "get-port-please": ["get-port-please@3.2.0", "", {}, "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A=="], + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + "glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -1111,12 +1165,16 @@ "http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], + "ink": ["ink@6.8.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="], "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], @@ -1155,6 +1213,8 @@ "jszip": ["jszip@3.10.2", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ=="], + "just-bash": ["just-bash@3.4.2", "", { "dependencies": { "diff": "^8.0.4", "fast-xml-parser": "^5.10.1", "file-type": "^21.3.4", "ini": "^6.0.0", "minimatch": "^10.2.6", "modern-tar": "^0.7.7", "papaparse": "^5.6.0", "quickjs-emscripten": "^0.32.0", "re2js": "^1.3.3", "seek-bzip": "^2.0.0", "smol-toml": "^1.8.0", "sprintf-js": "^1.1.3", "sql.js": "^1.14.1", "turndown": "^7.2.4", "undici": "^7.29.0", "yaml": "^2.9.0" }, "optionalDependencies": { "@mongodb-js/zstd": "^7.0.0", "node-liblzma": "^2.2.0" }, "bin": { "just-bash": "dist/bin/just-bash.js", "just-bash-shell": "dist/bin/shell/shell.js" } }, "sha512-T0Vpy7YRgCjxJdqG3tkxn0ZnIDLJvVwb8hH4L+6NVdp+Te27jQxjxnszW9ODjEKbWxWujj83rP5S0GQxCSufgg=="], + "libsodium": ["libsodium@0.8.4", "", {}, "sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw=="], "libsodium-wrappers": ["libsodium-wrappers@0.8.4", "", { "dependencies": { "libsodium": "^0.8.0" } }, "sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw=="], @@ -1211,11 +1271,17 @@ "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - "minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + + "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], - "modern-tar": ["modern-tar@0.8.5", "", {}, "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA=="], + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + + "modern-tar": ["modern-tar@0.7.7", "", {}, "sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -1225,12 +1291,24 @@ "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + + "node-abi": ["node-abi@3.96.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg=="], + + "node-addon-api": ["node-addon-api@8.9.2", "", {}, "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg=="], + + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + "node-liblzma": ["node-liblzma@2.2.0", "", { "dependencies": { "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" }, "bin": { "nxz": "lib/cli/nxz.js" } }, "sha512-s0KzNOWwOJJgPG6wxg6cKohnAl9Wk/oW1KrQaVzJBjQwVcUGPQCzpR46Ximygjqj/3KhOrtJXnYMp/xYAXp75g=="], + "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], "oxfmt": ["oxfmt@0.62.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.62.0", "@oxfmt/binding-android-arm64": "0.62.0", "@oxfmt/binding-darwin-arm64": "0.62.0", "@oxfmt/binding-darwin-x64": "0.62.0", "@oxfmt/binding-freebsd-x64": "0.62.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.62.0", "@oxfmt/binding-linux-arm-musleabihf": "0.62.0", "@oxfmt/binding-linux-arm64-gnu": "0.62.0", "@oxfmt/binding-linux-arm64-musl": "0.62.0", "@oxfmt/binding-linux-ppc64-gnu": "0.62.0", "@oxfmt/binding-linux-riscv64-gnu": "0.62.0", "@oxfmt/binding-linux-riscv64-musl": "0.62.0", "@oxfmt/binding-linux-s390x-gnu": "0.62.0", "@oxfmt/binding-linux-x64-gnu": "0.62.0", "@oxfmt/binding-linux-x64-musl": "0.62.0", "@oxfmt/binding-openharmony-arm64": "0.62.0", "@oxfmt/binding-win32-arm64-msvc": "0.62.0", "@oxfmt/binding-win32-ia32-msvc": "0.62.0", "@oxfmt/binding-win32-x64-msvc": "0.62.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-vxgGHTmnDU9j4CX7dDBLzxgmHxfda/yPcgJkGCMUSCwRmz+euo/V08xXLNgXTeqAB9Fhf3Pe2nO1RNKLCVgphQ=="], @@ -1247,6 +1325,8 @@ "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "papaparse": ["papaparse@5.7.0", "", {}, "sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], @@ -1273,16 +1353,28 @@ "postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="], + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], "promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="], "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "quickjs-emscripten": ["quickjs-emscripten@0.32.0", "", { "dependencies": { "@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0", "@jitl/quickjs-wasmfile-debug-sync": "0.32.0", "@jitl/quickjs-wasmfile-release-asyncify": "0.32.0", "@jitl/quickjs-wasmfile-release-sync": "0.32.0", "quickjs-emscripten-core": "0.32.0" } }, "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA=="], + + "quickjs-emscripten-core": ["quickjs-emscripten-core@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg=="], + + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + + "re2js": ["re2js@1.4.0", "", {}, "sha512-KTOIcZTSOpOxbu3i0+T6mFQ6tkxXKlTxfcMFs1trQbsMnG84qNq+DjXr8Afu+FEFjvF1NNlldpC7roPyazFI8g=="], + "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="], @@ -1317,6 +1409,8 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "seek-bzip": ["seek-bzip@2.0.0", "", { "dependencies": { "commander": "^6.0.0" }, "bin": { "seek-bunzip": "bin/seek-bunzip", "seek-table": "bin/seek-bzip-table" } }, "sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg=="], + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "seroval": ["seroval@1.5.5", "", {}, "sha512-bSjOuPcwPKLSJNhr9+bZxA20nQxVle5J5MNsYRVE6cIg7KpRLXGupymePavu0jrxlPiPsr4xGZSB8yUY2sH2sw=="], @@ -1337,14 +1431,24 @@ "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], + "smol-toml": ["smol-toml@1.9.0", "", {}, "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ=="], + "solid-js": ["solid-js@1.9.12", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + + "sql.js": ["sql.js@1.14.2", "", {}, "sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw=="], + "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], @@ -1357,14 +1461,22 @@ "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "strnum": ["strnum@2.4.2", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw=="], + "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + "tar-fs": ["tar-fs@2.1.5", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + "terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], @@ -1379,14 +1491,20 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + "turndown": ["turndown@7.2.4", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ=="], "type-fest": ["type-fest@5.10.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA=="], "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + "uint8array-extras": ["uint8array-extras@1.6.0", "", {}, "sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q=="], + "undici": ["undici@8.10.1", "", {}, "sha512-YQ3WlbqjYMmNpdvDH64jAgLjxuAR9+649calDWhbshYaeQGO2bR4nI94ORJmwI3J9YhoKQnpyGOK+0zlWS5N5Q=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], @@ -1417,6 +1535,8 @@ "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], @@ -1447,32 +1567,50 @@ "@prisma/dev/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "@puppeteer/browsers/modern-tar": ["modern-tar@0.8.5", "", {}, "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA=="], + + "alchemy/capnweb": ["capnweb@0.6.1", "", {}, "sha512-fmhV26QPd1ewf5R74h55oVZnGwIcSaRMzbfLQUy8+zOBjuTmT3KXoT8wxHvnp1m9Ht9BoUUS5ZwNLoVLfQTyBg=="], + "alchemy/undici": ["undici@7.30.0", "", {}, "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ=="], "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], + "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "cli-truncate/string-width": ["string-width@8.3.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ=="], "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], + "ink/string-width": ["string-width@8.3.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ=="], + "just-bash/diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + + "just-bash/undici": ["undici@7.30.0", "", {}, "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ=="], + "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], "make-dir/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "node-abi/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "react-devtools-core/ws": ["ws@7.5.12", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-1xGnbYN3zbog9CwuNDQULNRrTCLIn46/WmpR1f0w6PsCYQHkylZr5vkd6kfMZYV6pRnQkcPNRyiA8LsrNKyhpg=="], "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "vite/rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="], "widest-line/string-width": ["string-width@8.3.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ=="], @@ -1483,6 +1621,8 @@ "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + "glob/minimatch/brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="], + "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], @@ -1512,5 +1652,7 @@ "vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="], "vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="], + + "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/examples/alchemy-cloudflare/README.md b/examples/alchemy-cloudflare/README.md index 076c8b3..5c23453 100644 --- a/examples/alchemy-cloudflare/README.md +++ b/examples/alchemy-cloudflare/README.md @@ -1,14 +1,19 @@ # alchemy-cloudflare -A minimal fold chat host on Cloudflare, deployed with [Alchemy v2](https://alchemy.run/cloudflare/). Inference runs on OpenAI (`gpt-5.6-terra`, medium reasoning) through fold's `openaiModel`. Each fold session is one Durable Object, named by its `SessionId`. +A minimal fold chat host on Cloudflare, deployed with [Alchemy v2](https://alchemy.run/cloudflare/). Inference runs on OpenAI (`gpt-5.6-terra`, medium reasoning) through fold's `openaiModel`. Each fold session is one Durable Object, named by its `SessionId`, with a workspace of cloned repos in a second one of the same name. -- `src/ChatSession.ts` — the Durable Object. It opens the fold log in its SQLite on activation; the first message starts the session, later activations resume it. +- `src/ChatSession.ts` — the Durable Object. It opens the fold log in its SQLite on activation; the first message clones its repos and starts the session, later activations resume it. - `src/DurableObjectEventLog.ts` — the service that opens fold's `EventLogService` over the object's SQLite. - `src/Keepalive.ts` — a 30s alarm heartbeat that keeps the object alive while a turn runs. +- `src/SessionExpiry.ts` — deletes an idle session and its workspace; see below. - `src/ChatSessions.ts` — the service the routes require; its `layer` reaches each session's Durable Object. +- `src/Workspace.ts` — the service ChatSession clones repos and reaches files through; its `layer` reaches the session's Computer. +- `src/BashTool.ts` — the agent's `bash` tool, running commands in the workspace's shell. +- `src/WorkspaceFileSystem.ts` — Effect's `FileSystem` over the Computer's file methods, so fold-agent's file tools and skill loader run on the workspace unchanged. +- `src/computer/` — the Computer Worker: a plain Durable Object holding a [`@cloudflare/computer`](https://github.com/cloudflare/computer) workspace (a filesystem in its SQLite, with git and a shell) per session. The shell is just-bash in a Worker that a Worker Loader starts, reading and writing the same filesystem. A separate Worker, because `@cloudflare/computer` needs the plain `cloudflare:workers` class, and an Effect Worker's bundle keeps only its own exports. - `src/Api.ts` — the `HttpRouter` routes, encoding responses with fold's log schemas. - `src/Worker.ts` — serves the routes, with `ChatSessions` as RPC to the Durable Objects. -- `alchemy.run.ts` — the stack. +- `alchemy.run.ts` — the stack: the Computer Worker, then the chat Worker, which binds it by script name. ## Run @@ -20,16 +25,33 @@ bun run deploy # prints the Worker url ``` ```sh -ID=sess_... # any new SessionId; the first message starts that session -curl -X POST "$URL/sessions/$ID/messages" -d '{"text":"hi"}' # the agent-finished entry +ID=sess_... # any new SessionId; the first message clones its repos and starts that session +curl -X POST "$URL/sessions/$ID/messages" -d '{"text":"hi","repos":[{"url":"https://github.com/humanlayer/fold"}]}' # the agent-finished entry +curl -X POST "$URL/sessions/$ID/messages" -d '{"text":"hi"}' # later messages' repos are ignored curl -X POST "$URL/sessions/$ID/messages" -d '{"text":"stop","whenRunning":"interrupt"}' # queue (default) | steer | interrupt -curl "$URL/sessions/$ID" # all log entries +curl "$URL/sessions/$ID/log" # the fold event log ``` +A session with no messages for 14 days (`IDLE_TIME` in `src/SessionExpiry.ts`) deletes its workspace and its log; its id then starts a new session. The workspace also has its own deadline a day later, so it gets deleted even if its session never does it. + +`bun alchemy tail` streams both Workers' logs. Each clone logs `workspace.prepare`, each file call from the agent's tools logs `workspace.file` with its method, path and result, and each shell command logs `workspace.exec` with its exit code. + +Each repo is `{ "url": "https://...", "name"?: string, "ref"?: string }`: it clones into `/workspace/` (`name` defaults to the URL's last path segment) at `ref` (the default branch when absent), shallow. A repo that fails to clone makes the first message a 422 and starts nothing; the next message clones again from scratch. The log's `session_started` entry records each repo's commit. + +The agent gets fold-agent's file tools on the workspace: `read`, plus `write` and `edit` for Claude models or `apply_patch` for GPT models (fold picks by model). Relative paths resolve against `/workspace`. It also gets `bash`, which runs in a lightweight shell: the common text commands (`ls`, `cat`, `grep`, `find`, `sed`, `awk`, `jq`, ...), pipes and redirects, and git. The shell and the file tools see the same files. Skills load from each repo's `.claude/skills` and `.agents/skills` when the session opens. The system prompt lists the repos. + `bun run test` checks the routes against an in-memory `ChatSessions` built on fold's in-memory log. ## Limits - A turn cut off by a crash or deploy continues when the object next wakes, through a synthetic "continue" user message (at most 3 per cut-off turn). A subagent cut off with it is left for the root model to resume by id. +- The shell is not a full Linux machine: no package managers, no node or python, no compilers, and no network. It can't install, build, or run tests. + +- The file tools read and write the workspace, not the repos' remotes: changes stay in the session's workspace. `read` can't process images in a Worker (its image library needs Node), so an image comes back as a short note, not the picture. + +- The Computer Worker's script name is fixed (`src/computer/Contract.ts`), so an account holds one deploy of this stack. + +- Clones go over HTTPS, public repos only. They run in the Durable Object through `isomorphic-git`, which fetches every file at the tip, so large repos clone slowly. + - Replies arrive whole. `FoldSession.events` can stream deltas once a route forwards them. diff --git a/examples/alchemy-cloudflare/alchemy.run.ts b/examples/alchemy-cloudflare/alchemy.run.ts index 0a7f514..94c1ded 100644 --- a/examples/alchemy-cloudflare/alchemy.run.ts +++ b/examples/alchemy-cloudflare/alchemy.run.ts @@ -2,12 +2,15 @@ import * as Alchemy from 'alchemy' import * as Cloudflare from 'alchemy/Cloudflare' import { Effect } from 'effect' +import { ComputerWorker } from './src/computer/ComputerWorker' import Worker from './src/Worker' export default Alchemy.Stack( 'FoldAlchemyCloudflare', { providers: Cloudflare.providers(), state: Cloudflare.state() }, Effect.gen(function* () { + // First: ChatWorker binds the Computer Durable Object by this Worker's script name. + yield* ComputerWorker const worker = yield* Worker return { url: worker.url } }), diff --git a/examples/alchemy-cloudflare/package.json b/examples/alchemy-cloudflare/package.json index f6677ca..a9b7528 100644 --- a/examples/alchemy-cloudflare/package.json +++ b/examples/alchemy-cloudflare/package.json @@ -13,13 +13,17 @@ "test:watch": "bun vitest" }, "dependencies": { + "@cloudflare/computer": "catalog:", + "@humanlayer/fold-agent": "workspace:*", "@humanlayer/fold-core": "workspace:*", + "@platformatic/vfs": "catalog:", "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "alchemy": "catalog:", "effect": "catalog:" }, "devDependencies": { + "@cloudflare/workers-types": "catalog:", "@effect/vitest": "catalog:", "@humanlayer/fold-vitest-config": "workspace:*", "typescript": "catalog:", diff --git a/examples/alchemy-cloudflare/src/Api.ts b/examples/alchemy-cloudflare/src/Api.ts index cbe489f..9f8862c 100644 --- a/examples/alchemy-cloudflare/src/Api.ts +++ b/examples/alchemy-cloudflare/src/Api.ts @@ -2,21 +2,19 @@ * The chat HTTP routes over {@link ChatSessions}, encoding responses with fold's log schemas. * * - `POST /sessions/:sessionId/messages` with `{ "text": string, "whenRunning"?: "queue" | "steer" | - * "interrupt" }` delivers one user message, starting the session if the id is new, and returns the - * `agent-finished` entry of the run that consumed it. `whenRunning` defaults to `queue`. - * - `GET /sessions/:sessionId` returns the session's log entries. + * "interrupt", "repos"?: [{ "url": string, "name"?: string, "ref"?: string }] }` delivers one user + * message and returns the `agent-finished` entry of the run that consumed it. `whenRunning` defaults to + * `queue`. A message to a new id clones its `repos` into `/workspace/` and starts the session; + * if a repo fails to clone the response is a 422 and nothing starts. + * - `GET /sessions/:sessionId/log` returns the session's fold event log. */ import { AgentFinishedLogEntry, LogEntry, SessionId } from '@humanlayer/fold-core' import { Effect, Schema } from 'effect' import { HttpRouter, HttpServerRequest, HttpServerResponse } from 'effect/unstable/http' -import { ChatSessions, WhenRunning } from './ChatSessions' +import { ChatSessions, Message } from './ChatSessions' const SessionParams = Schema.Struct({ sessionId: SessionId }) -const MessageBody = Schema.Struct({ - text: Schema.String, - whenRunning: WhenRunning.pipe(Schema.withDecodingDefaultKey(Effect.succeed('queue'))), -}) const entriesJson = HttpServerResponse.schemaJson(Schema.Array(LogEntry)) const finishedJson = HttpServerResponse.schemaJson(AgentFinishedLogEntry) @@ -28,7 +26,7 @@ export const ChatRoutes = HttpRouter.use((router) => yield* router.add( 'GET', - '/sessions/:sessionId', + '/sessions/:sessionId/log', Effect.gen(function* () { const { sessionId } = yield* HttpRouter.schemaPathParams(SessionParams) return yield* entriesJson(yield* sessions.entries(sessionId)) @@ -40,9 +38,14 @@ export const ChatRoutes = HttpRouter.use((router) => '/sessions/:sessionId/messages', Effect.gen(function* () { const { sessionId } = yield* HttpRouter.schemaPathParams(SessionParams) - const { text, whenRunning } = yield* HttpServerRequest.schemaBodyJson(MessageBody) - return yield* finishedJson(yield* sessions.send(sessionId, text, whenRunning)) - }).pipe(Effect.catchTag('SchemaError', badRequest)), + const message = yield* HttpServerRequest.schemaBodyJson(Message) + return yield* finishedJson(yield* sessions.send(sessionId, message)) + }).pipe( + Effect.catchTags({ + SchemaError: badRequest, + RepoCloneError: ({ message }) => Effect.succeed(HttpServerResponse.text(message, { status: 422 })), + }), + ), ) }), ) diff --git a/examples/alchemy-cloudflare/src/BashTool.ts b/examples/alchemy-cloudflare/src/BashTool.ts new file mode 100644 index 0000000..d119735 --- /dev/null +++ b/examples/alchemy-cloudflare/src/BashTool.ts @@ -0,0 +1,114 @@ +/** + * The agent's `bash` tool, running commands on the session's workspace in one of two places: the shell + * (just-bash, fast, with the common text commands and git) or the container (Linux, with package managers + * and language runtimes, slower to start). fold-agent's bash tool starts real processes, which a Worker + * cannot; this one keeps its parameters and its output handling - stdout and stderr trimmed to the last + * 2000 lines or 50KB, and a failure carrying the output when the command fails - and adds `backend`. + */ +import { + defaultMaxBytes, + defaultMaxLines, + defineTool, + formatSize, + ToolResultFailure, + ToolResultText, + truncateTail, + type FoldTool, +} from '@humanlayer/fold-core' +import { Effect, Schema } from 'effect' + +import { type CommandInput, type CommandOutput, WORKSPACE_ROOT } from './computer/Contract' +import type { ShellError } from './Workspace' + +const DEFAULT_TIMEOUT_MS = 120_000 +const MAX_TIMEOUT_MS = 600_000 + +const BashParameters = Schema.Struct({ + command: Schema.String.annotate({ description: 'Bash command to execute' }), + backend: Schema.optionalKey(Schema.Literals(['shell', 'container'])).annotate({ + description: + 'Where to run the command: "shell" (default; fast, text commands and git only) or "container" ' + + '(full Linux: installs, builds, tests)', + }), + timeout_ms: Schema.optionalKey(Schema.Number).annotate({ + description: `Timeout in milliseconds (default ${DEFAULT_TIMEOUT_MS}, maximum ${MAX_TIMEOUT_MS})`, + }), + workdir: Schema.optionalKey(Schema.String).annotate({ + description: `Working directory for the command, absolute or relative to ${WORKSPACE_ROOT}. Use this instead of cd.`, + }), + description: Schema.optionalKey(Schema.String).annotate({ + description: 'Short (5-10 word) description of what this command does', + }), +}) + +const DESCRIPTION = + `Run a bash command in the session's workspace, where the repo(s) are cloned under ${WORKSPACE_ROOT}. ` + + 'Returns stdout then stderr, keeping the last ' + + `${defaultMaxLines} lines or ${formatSize(defaultMaxBytes)}. Commands start in ${WORKSPACE_ROOT} unless ` + + 'you pass workdir.\n\n' + + 'Commands run in one of two places, which see the same files:\n' + + '- backend "shell" (the default): a lightweight shell (just-bash), not a full Linux machine. It has the ' + + 'common text commands - ls, cat, head, tail, grep, find, sed, awk, sort, uniq, wc, diff, cut, tr, xargs, ' + + 'jq and more - pipes, redirects, and git (clone, status, diff, log, add, commit, branch, checkout). It has ' + + 'no package managers, no node or python, no compilers, and no network access. It answers at once. Use it ' + + 'to explore and search the repos: list directories, grep for code, and inspect git history.\n' + + '- backend "container": a Debian Linux container where commands run as root in a bash login shell, with ' + + 'internet access, git, node 22 with npm, bun, ' + + 'python3, ripgrep, fd and tmux; install anything else with apt-get or a package manager. Use it to install ' + + 'dependencies, build, and run tests or scripts. It can take up to 30 seconds to start if it was stopped. ' + + 'Programs and files outside the workspace last only until it stops; files under the workspace are kept, ' + + "except those a repo's .gitignore lists (node_modules, build output, caches): they stay in the container " + + 'only, so the shell and the file tools cannot see them, and they are gone once the container stops, so ' + + 'reinstall or rebuild after a restart. Look at them with container commands. A gitignored file you made ' + + 'with the file tools, such as .env, does not receive changes made in the container; change it with the ' + + 'file tools.\n\n' + + 'Try the shell first, and switch to the container when a command is not found or needs a real machine.' + +/** The command's output as the model sees it: stdout, then stderr, trimmed from the front. */ +const formatOutput = ({ stdout, stderr }: CommandOutput) => { + const text = stdout.length > 0 && stderr.length > 0 ? `${stdout.replace(/\n?$/, '\n')}${stderr}` : stdout + stderr + const truncation = truncateTail(text) + if (!truncation.truncated) return text + return `[Showing the last ${truncation.outputLines} of ${truncation.totalLines} lines]\n${truncation.content}` +} + +/** Build the bash tool over one session's shell. */ +export const bashTool = (run: (input: CommandInput) => Effect.Effect): FoldTool => + defineTool({ + name: 'bash', + description: DESCRIPTION, + parameters: BashParameters, + success: ToolResultText, + failure: ToolResultFailure, + handler: (params) => + Effect.gen(function* () { + const timeoutMs = params.timeout_ms ?? DEFAULT_TIMEOUT_MS + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMEOUT_MS) { + return yield* Effect.fail( + `Invalid timeout_ms: must be between 1 and ${MAX_TIMEOUT_MS} milliseconds`, + ) + } + const cwd = + params.workdir === undefined + ? WORKSPACE_ROOT + : params.workdir.startsWith('/') + ? params.workdir + : `${WORKSPACE_ROOT}/${params.workdir}` + + const output = yield* run({ + backend: params.backend ?? 'shell', + command: params.command, + cwd, + timeoutMs, + }).pipe(Effect.mapError((error) => `The shell could not run the command: ${error.message}`)) + const text = formatOutput(output).replace(/\n+$/, '') + + if (output.status === 'cancelled') { + return yield* Effect.fail(`${text}\n\nCommand timed out after ${timeoutMs} milliseconds`) + } + if (output.exitCode !== 0) { + return yield* Effect.fail(`${text}\n\nCommand exited with code ${output.exitCode}`) + } + return ToolResultText.make({ text: text.length === 0 ? '(no output)' : text }) + }).pipe(Effect.mapError((text) => ToolResultFailure.make({ text }))), + }) diff --git a/examples/alchemy-cloudflare/src/ChatSession.ts b/examples/alchemy-cloudflare/src/ChatSession.ts index 9360f49..bb93ebc 100644 --- a/examples/alchemy-cloudflare/src/ChatSession.ts +++ b/examples/alchemy-cloudflare/src/ChatSession.ts @@ -1,14 +1,23 @@ /** * One fold session per Durable Object, named by its SessionId. The object's SQLite log is the session: - * addressing a new id creates the object with an empty log, and its first `send` starts the session - * under that id; a written log resumes on activation. Callers never create a session explicitly. + * addressing a new id creates the object with an empty log, and its first `send` clones the message's repos + * into the session's {@link Workspace} and starts the session under that id; a written log resumes on + * activation. Callers never create a session explicitly. + * + * The agent reads and changes the repos' files through fold's file tools, runs commands in the workspace's + * shell through {@link bashTool}, and loads skills from each repo's `.claude/skills` and `.agents/skills`. * * Every turn runs under a {@link Keepalive} lease so the object is not evicted mid-run. A turn can still * be cut off - a crash, a deploy - leaving the root's user message with no finished run after it. When a * written log activates in that state, the object nudges the root to continue. * Fold fills any tool call the cut left without a result, and the model can resume a cut-off subagent * by id itself. A turn that keeps getting cut off stops being nudged after {@link MAX_RESTART_NUDGES}. + * + * A session idle for 14 days deletes its workspace and itself; see + * {@link SessionExpiry}. Its id then starts a new session. */ +import { skillsFromDisk } from '@humanlayer/fold-agent/skills' +import { fileTools } from '@humanlayer/fold-agent/tools/files' import { SessionId, type AgentId, @@ -19,21 +28,69 @@ import { eventLogSource, openaiModel, resumeSession, + skillTool, startSession, } from '@humanlayer/fold-core' import * as Cloudflare from 'alchemy/Cloudflare' -import { Config, Effect, Match, Option, Predicate, Result, Schema, Scope, Stream } from 'effect' +import { + Config, + Effect, + FileSystem, + Match, + Option, + Path, + Predicate, + Result, + Schema, + Scope, + Stream, + SynchronizedRef, +} from 'effect' -import type { WhenRunning } from './ChatSessions' +import { bashTool } from './BashTool' +import type { Message, WhenRunning } from './ChatSessions' +import { WORKSPACE_ROOT } from './computer/Contract' import { DurableObjectEventLog } from './DurableObjectEventLog' import { Keepalive } from './Keepalive' +import { SessionExpiry } from './SessionExpiry' +import { type Repo, Workspace } from './Workspace' const MODEL = 'gpt-5.6-terra' +/** The agent's home directory. Nothing is there unless the agent puts it there. */ +const HOME = '/root' + +const systemPrompt = (repoNames: ReadonlyArray) => + [ + 'You are a helpful, concise coding assistant.', + repoNames.length === 0 + ? 'No repos are cloned for this session.' + : `This session's repos are cloned under ${WORKSPACE_ROOT}:\n${repoNames.map((name) => `- ${WORKSPACE_ROOT}/${name}`).join('\n')}`, + 'Use bash to list, search and inspect them, and the file tools to read and change files. Run bash in ' + + "the container to install packages, build, and run tests. Files a repo's .gitignore lists, such as " + + 'node_modules and build output, exist only in the container.', + ].join('\n\n') + +/** Skills from each repo's `.claude/skills` and `.agents/skills`, scanned once when the session opens. */ +const repoSkills = (repoNames: ReadonlyArray) => + skillTool( + skillsFromDisk({ + cwd: WORKSPACE_ROOT, + home: HOME, + extraPaths: repoNames.flatMap((name) => [ + `${WORKSPACE_ROOT}/${name}/.claude/skills`, + `${WORKSPACE_ROOT}/${name}/.agents/skills`, + ]), + }), + ) + const RESTART_NUDGE = 'A restart cut you off before you finished. Continue where you left off.' const MAX_RESTART_NUDGES = 3 +/** The workspace's backup deadline trails the session's by this much, so the session normally deletes both. */ +const WORKSPACE_GRACE_MILLIS = 24 * 60 * 60 * 1000 + /** The root's user messages since its last finished run: any means a restart cut that run off. */ const openRootMessages = (entries: ReadonlyArray, rootAgentId: AgentId) => { const lastFinishedSeq = @@ -92,6 +149,8 @@ export default class ChatSession extends Cloudflare.DurableObject() const apiKey = yield* Config.redacted('OPENAI_API_KEY').pipe(Effect.orDie) const eventLogs = yield* DurableObjectEventLog const keepalive = yield* Keepalive + const expiry = yield* SessionExpiry + const workspace = yield* Workspace return Effect.gen(function* () { const sessionId = yield* Schema.decodeUnknownEffect(SessionId)(state.id.name) @@ -99,42 +158,102 @@ export default class ChatSession extends Cloudflare.DurableObject() const log = eventLogSource(Effect.succeed(eventLog)) const isEmpty = Option.isNone(yield* Stream.runHead(eventLog.entries())) - const agent = defineAgent({ - name: 'alchemy-cloudflare-chat', - systemPrompt: 'You are a helpful, concise assistant.', - model: openaiModel({ apiKey, model: MODEL, reasoning: 'medium' }), - }) + const fileSystem = workspace.fileSystem(sessionId) + const model = openaiModel({ apiKey, model: MODEL, reasoning: 'medium' }) + const agentFor = (repoNames: ReadonlyArray) => + defineAgent({ + name: 'alchemy-cloudflare-chat', + systemPrompt: systemPrompt(repoNames.toSorted()), + model, + tools: [ + ...fileTools({ cwd: WORKSPACE_ROOT }), + bashTool((input) => workspace.exec(sessionId, input)), + repoSkills(repoNames), + ], + }) // Never closed: the session lives as long as the object stays in memory, past every call's own // scope. Opened on the first send, so reading an unknown id writes nothing. const scope = yield* Scope.make() - const session = yield* Effect.cached( - (isEmpty ? startSession({ agent, log, sessionId }) : resumeSession({ agent, log })).pipe( - Scope.provide(scope), - ), - ) + + // A new session clones its repos, starts its container in the background, then starts; a cut-off + // start leaves the log empty, so the next send clones them again from scratch. + const start = (repos: ReadonlyArray) => + Effect.gen(function* () { + const cloned = yield* workspace.prepare(sessionId, repos) + yield* Effect.forkIn(workspace.startContainer(sessionId), scope) + return yield* startSession({ + agent: agentFor(cloned.map((repo) => repo.name)), + log, + sessionId, + cwd: WORKSPACE_ROOT, + meta: { repos: cloned }, + }) + }) + + // A written log's repos are the workspace's top-level directories. + const resume = Effect.gen(function* () { + const entries = yield* fileSystem.readDirectory(WORKSPACE_ROOT).pipe(Effect.orDie) + const repoNames = yield* Effect.filter(entries, (name) => + fileSystem.stat(`${WORKSPACE_ROOT}/${name}`).pipe( + Effect.map((info) => info.type === 'Directory'), + Effect.orDie, + ), + ) + return yield* resumeSession({ agent: agentFor(repoNames), log }) + }) + + // Opened once, by whichever send or restart nudge comes first; `repos` only matter to a new session. + const opened = yield* SynchronizedRef.make(Option.none()) + const open = (repos: ReadonlyArray) => + SynchronizedRef.modifyEffect(opened, (current) => + Option.match(current, { + onSome: (session) => Effect.succeed([session, current] as const), + onNone: () => + (isEmpty ? start(repos) : resume).pipe( + // fold hands these to the file tools and the skill loader. + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provide(Path.layer), + Scope.provide(scope), + Effect.map((session) => [session, Option.some(session)] as const), + ), + }), + ) // A written log may hold a turn a restart cut off: nudge the root to continue it, in the background // under a lease so the heartbeat keeps the object alive until it lands - even when that heartbeat's // alarm is the only thing that woke the object. - if (!isEmpty) { + // An expired session skips this and waits for its alarm: resuming would reach into the workspace + // while it is being deleted, and could bring it back empty after. + if (!isEmpty && !(yield* expiry.expired)) { + yield* expiry.ensureScheduled yield* Effect.forkIn( Effect.gen(function* () { - const opened = yield* session - const open = openRootMessages(yield* opened.entries, opened.rootAgentId) - if (open.length === 0 || open.filter(isRestartNudge).length >= MAX_RESTART_NUDGES) return - yield* keepalive.whileRunning(deliver(opened, RESTART_NUDGE, 'queue')) + const session = yield* open([]) + const cutOff = openRootMessages(yield* session.entries, session.rootAgentId) + if (cutOff.length === 0 || cutOff.filter(isRestartNudge).length >= MAX_RESTART_NUDGES) return + yield* keepalive.whileRunning(deliver(session, RESTART_NUDGE, 'queue')) }), scope, ) } return { - send: (text: string, whenRunning: WhenRunning) => - keepalive.whileRunning(Effect.flatMap(session, (opened) => deliver(opened, text, whenRunning))), + send: ({ text, whenRunning, repos }: Message) => + keepalive.whileRunning( + Effect.gen(function* () { + const deadline = yield* expiry.touch + yield* workspace.expireAt(sessionId, deadline + WORKSPACE_GRACE_MILLIS) + return yield* deliver(yield* open(repos ?? []), text, whenRunning) + }), + ), entries: () => Stream.runCollect(eventLog.entries()).pipe(Effect.orDie), - alarm: () => keepalive.alarm, + // The heartbeat while a turn runs; after that, the idle deadline. + alarm: () => + Effect.flatMap(keepalive.alarm, (running) => + running ? Effect.void : expiry.alarm(workspace.destroy(sessionId)), + ), } }).pipe(Effect.orDie) - }).pipe(Effect.provide([DurableObjectEventLog.layer, Keepalive.layer])), + }).pipe(Effect.provide([DurableObjectEventLog.layer, Keepalive.layer, SessionExpiry.layer, Workspace.layer])), ) {} diff --git a/examples/alchemy-cloudflare/src/ChatSessions.ts b/examples/alchemy-cloudflare/src/ChatSessions.ts index 2d4b8a4..02dada6 100644 --- a/examples/alchemy-cloudflare/src/ChatSessions.ts +++ b/examples/alchemy-cloudflare/src/ChatSessions.ts @@ -1,12 +1,13 @@ /** * The fold chat sessions this host serves, each addressed by its SessionId. There is no create step: - * the first `send` to an id starts that session. The Worker implements this as RPC to the ChatSession - * Durable Object holding each session. + * the first `send` to an id clones its repos and starts that session. The Worker implements this as RPC to + * the ChatSession Durable Object holding each session. */ import type { AgentFinishedLogEntry, LogEntry, SessionId } from '@humanlayer/fold-core' import { Context, Effect, Layer, Schema } from 'effect' import ChatSession from './ChatSession' +import { RepoCloneError, Repos } from './Workspace' /** * What `send` does when the session's root agent is already running. When it is idle, every mode just @@ -19,6 +20,17 @@ import ChatSession from './ChatSession' export const WhenRunning = Schema.Literals(['queue', 'steer', 'interrupt']) export type WhenRunning = typeof WhenRunning.Type +/** + * One user message. `repos` clone into the session's workspace when the message starts the session; later + * messages' `repos` are ignored. + */ +export const Message = Schema.Struct({ + text: Schema.String, + whenRunning: WhenRunning.pipe(Schema.withDecodingDefaultKey(Effect.succeed('queue'))), + repos: Schema.optionalKey(Repos), +}) +export type Message = typeof Message.Type + export class ChatSessions extends Context.Service< ChatSessions, { @@ -26,13 +38,10 @@ export class ChatSessions extends Context.Service< readonly entries: (sessionId: SessionId) => Effect.Effect> /** * Deliver one user message to the session's root agent, starting the session if it is new, and - * resolve with the `agent-finished` entry of the run that consumed it. + * resolve with the `agent-finished` entry of the run that consumed it. Fails, starting nothing, when a + * new session's repo does not clone. */ - readonly send: ( - sessionId: SessionId, - text: string, - whenRunning: WhenRunning, - ) => Effect.Effect + readonly send: (sessionId: SessionId, message: Message) => Effect.Effect } >()('alchemy-cloudflare/ChatSessions') { /** Each session is the ChatSession Durable Object named by its id. Build it in a Worker's construction. */ @@ -43,7 +52,19 @@ export class ChatSessions extends Context.Service< return ChatSessions.of({ entries: (sessionId) => sessions.getByName(sessionId).entries(), - send: (sessionId, text, whenRunning) => sessions.getByName(sessionId).send(text, whenRunning), + // The object's failures arrive encoded: decode the clone failure, and die on anything else. + send: (sessionId, message) => + sessions + .getByName(sessionId) + .send(message) + .pipe( + Effect.catch((error) => + Schema.decodeUnknownEffect(RepoCloneError)(error).pipe( + Effect.catch(() => Effect.die(error)), + Effect.flatMap(Effect.fail), + ), + ), + ), }) }), ) diff --git a/examples/alchemy-cloudflare/src/Keepalive.ts b/examples/alchemy-cloudflare/src/Keepalive.ts index 3014179..9ff09a6 100644 --- a/examples/alchemy-cloudflare/src/Keepalive.ts +++ b/examples/alchemy-cloudflare/src/Keepalive.ts @@ -14,8 +14,11 @@ export class Keepalive extends Context.Service< { /** Run `effect` under a lease: the first lease arms the heartbeat. */ readonly whileRunning: (effect: Effect.Effect) => Effect.Effect - /** The object's alarm handler: re-arm while any lease is held; once none are, let the alarm lapse. */ - readonly alarm: Effect.Effect + /** + * The object's alarm handler: re-arm while any lease is held. Returns whether it did; once no lease is + * held, the alarm is the caller's. + */ + readonly alarm: Effect.Effect } >()('alchemy-cloudflare/Keepalive') { static readonly layer = Layer.effect( @@ -37,7 +40,9 @@ export class Keepalive extends Context.Service< () => effect, () => Ref.update(leases, (held) => held - 1), ), - alarm: Effect.flatMap(Ref.get(leases), (held) => (held > 0 ? armHeartbeat : Effect.void)), + alarm: Effect.flatMap(Ref.get(leases), (held) => + held > 0 ? Effect.as(armHeartbeat, true) : Effect.succeed(false), + ), }) }), ) diff --git a/examples/alchemy-cloudflare/src/SessionExpiry.ts b/examples/alchemy-cloudflare/src/SessionExpiry.ts new file mode 100644 index 0000000..4824b33 --- /dev/null +++ b/examples/alchemy-cloudflare/src/SessionExpiry.ts @@ -0,0 +1,116 @@ +/** + * Deletes an idle session. Every message pushes the session's deadline to {@link IDLE_TIME} from now. Once no turn is running, the object's alarm is set for the deadline; when it fires past + * the deadline, the session's workspace is deleted, then the object's own storage, and the object restarts + * empty, so its id is new again. + * + * The alarm is shared with {@link Keepalive}: while a turn runs, Keepalive's heartbeat holds it, and the + * heartbeat that finds no turn running hands it back here. + */ +import * as Cloudflare from 'alchemy/Cloudflare' +import { Clock, Context, Duration, Effect, Layer } from 'effect' + +/** How long a session lasts after its last message. */ +export const IDLE_TIME = Duration.days(14) + +const DEADLINE_KEY = 'session_deadline' + +/** How long after deleting the session the object restarts. */ +const RESTART_DELAY_MILLIS = 1_000 + +/** What SessionExpiry needs from the Durable Object: its key-value storage, alarm, and a restart. */ +export type ExpiryStorage = { + readonly get: (key: string) => Effect.Effect + readonly put: (key: string, value: number) => Effect.Effect + readonly getAlarm: Effect.Effect + readonly setAlarm: (at: number) => Effect.Effect + /** Delete the alarm and every stored value. */ + readonly deleteAll: Effect.Effect + /** Throw away the object's in-memory state, so the next call starts from its (empty) storage. */ + readonly restart: Effect.Effect +} + +export class SessionExpiry extends Context.Service< + SessionExpiry, + { + /** Push the deadline back to the idle time from now. Returns the new deadline, in epoch milliseconds. */ + readonly touch: Effect.Effect + /** Whether the deadline has passed: the session is waiting for its alarm to delete it. */ + readonly expired: Effect.Effect + /** Give a written session an alarm if it has none, such as one written before expiry existed. */ + readonly ensureScheduled: Effect.Effect + /** + * The object's alarm when no turn holds it: past the deadline, run `deleteWorkspace` and delete the + * session; before it, set the alarm for the deadline. A session with no deadline was already deleted + * (or never written), so there is nothing to do. + */ + readonly alarm: (deleteWorkspace: Effect.Effect) => Effect.Effect + } +>()('alchemy-cloudflare/SessionExpiry') { + static readonly make = (storage: ExpiryStorage, idleTime: Duration.Duration) => { + const touch = Effect.gen(function* () { + const deadline = (yield* Clock.currentTimeMillis) + Duration.toMillis(idleTime) + yield* storage.put(DEADLINE_KEY, deadline) + return deadline + }) + + return SessionExpiry.of({ + touch, + expired: Effect.gen(function* () { + const deadline = yield* storage.get(DEADLINE_KEY) + return deadline !== undefined && (yield* Clock.currentTimeMillis) >= deadline + }), + ensureScheduled: Effect.gen(function* () { + if ((yield* storage.getAlarm) !== null) return + const deadline = (yield* storage.get(DEADLINE_KEY)) ?? (yield* touch) + yield* storage.setAlarm(deadline) + }), + alarm: (deleteWorkspace) => + Effect.gen(function* () { + const deadline = yield* storage.get(DEADLINE_KEY) + if (deadline === undefined) return + if ((yield* Clock.currentTimeMillis) < deadline) return yield* storage.setAlarm(deadline) + + yield* deleteWorkspace + yield* storage.deleteAll + yield* Effect.logInfo('session.expired') + yield* storage.restart + }), + }) + } + + /** Over the Durable Object's storage, with {@link IDLE_TIME}. */ + static readonly layer = Layer.effect( + SessionExpiry, + Effect.gen(function* () { + const { raw } = yield* Cloudflare.DurableObjectState + + return SessionExpiry.make( + { + get: (key) => + Effect.promise(() => raw.storage.get(key)).pipe( + Effect.map((value) => (typeof value === 'number' ? value : undefined)), + ), + put: (key, value) => Effect.promise(() => raw.storage.put(key, value)), + getAlarm: Effect.promise(() => raw.storage.getAlarm()), + setAlarm: (at) => Effect.promise(() => raw.storage.setAlarm(at)), + deleteAll: Effect.promise(async () => { + await raw.storage.deleteAlarm() + await raw.storage.deleteAll() + }), + // Once the alarm has finished: Cloudflare holds its completion until the delete is saved, + // and an abort before then fails the alarm, which Cloudflare retries. + restart: Effect.sync(() => { + setTimeout(() => { + try { + raw.abort('session expired') + } catch { + // abort throws to unwind; the object resets either way. + } + }, RESTART_DELAY_MILLIS) + }), + }, + IDLE_TIME, + ) + }), + ) +} diff --git a/examples/alchemy-cloudflare/src/Workspace.ts b/examples/alchemy-cloudflare/src/Workspace.ts new file mode 100644 index 0000000..cfe6a08 --- /dev/null +++ b/examples/alchemy-cloudflare/src/Workspace.ts @@ -0,0 +1,176 @@ +/** + * Each session's workspace: the Computer Durable Object (see `computer/`) named by the session's id, + * reached across scripts from ChatSession. Repos clone into `/workspace/` when the session starts. + */ +import type { SessionId } from '@humanlayer/fold-core' +import type { RpcCallError } from 'alchemy' +import * as Cloudflare from 'alchemy/Cloudflare' +import { Clock, Context, Data, Effect, type FileSystem, Layer, Schema } from 'effect' + +import { + COMPUTER_WORKER_NAME, + type ClonedRepo, + type CommandInput, + type CommandOutput, + type ComputerResult, + type RepoSpec, +} from './computer/Contract' +import { type ComputerFiles, workspaceFileSystem } from './WorkspaceFileSystem' + +const REPO_NAME = /^(?!\.{1,2}$)[\w.-]+$/ + +/** + * One repo to clone: an https URL, a `ref` (the remote's default branch when absent), and the directory + * `name` under `/workspace` (the URL's last path segment when absent). + */ +export const Repo = Schema.Struct({ + url: Schema.String.check(Schema.isStartsWith('https://')), + name: Schema.optionalKey(Schema.String), + ref: Schema.optionalKey(Schema.String), +}) +export type Repo = typeof Repo.Type + +/** The directory a repo clones into, under `/workspace`. */ +export const repoName = (repo: Repo) => + repo.name ?? (repo.url.replace(/\/+$/, '').split('/').at(-1) ?? '').replace(/\.git$/, '') + +/** A session's repos: every directory name valid and distinct. */ +export const Repos = Schema.Array(Repo).check( + Schema.makeFilter((repos) => { + const names = repos.map(repoName) + const invalid = names.find((name) => !REPO_NAME.test(name)) + if (invalid !== undefined) return `repo directory name ${JSON.stringify(invalid)} is invalid; pass a "name"` + return new Set(names).size === names.length || 'repo directory names must be distinct' + }), +) + +/** A repo failed to clone, so the session did not start. Crosses the ChatSession RPC boundary encoded. */ +export class RepoCloneError extends Schema.TaggedError()('RepoCloneError', { + message: Schema.String, +}) {} + +/** The shell could not run a command at all. A command that runs and fails is a {@link CommandOutput}. */ +export class ShellError extends Data.TaggedError('ShellError')<{ readonly message: string }> {} + +/** The Computer Durable Object, as ChatSession sees it. Its class lives in the Computer Worker. */ +class Computer extends Cloudflare.DurableObject< + Computer, + ComputerFiles & { + readonly prepare: (repos: ReadonlyArray) => Effect.Effect, RpcCallError> + readonly exec: (input: CommandInput) => Effect.Effect, RpcCallError> + readonly startContainer: () => Effect.Effect, RpcCallError> + readonly expireAt: (deleteAt: number) => Effect.Effect + readonly destroy: () => Effect.Effect + } +>()('Computer') {} + +export class Workspace extends Context.Service< + Workspace, + { + /** Clone the session's repos fresh, replacing any clone a cut-off attempt left behind. */ + readonly prepare: ( + sessionId: SessionId, + repos: ReadonlyArray, + ) => Effect.Effect, RepoCloneError> + /** The session's workspace as a `FileSystem`, for fold's file tools and skill loader. */ + readonly fileSystem: (sessionId: SessionId) => FileSystem.FileSystem + /** Run one command in the session's shell or container. */ + readonly exec: (sessionId: SessionId, input: CommandInput) => Effect.Effect + /** + * Start the session's container, so its first command doesn't wait for it. A failure is logged, not + * raised: the first container command starts it again. + */ + readonly startContainer: (sessionId: SessionId) => Effect.Effect + /** + * Have the workspace delete itself at `deleteAt` (epoch milliseconds) unless moved again: the backup + * for a session that never deletes it. A failure is logged, not raised. + */ + readonly expireAt: (sessionId: SessionId, deleteAt: number) => Effect.Effect + /** Delete the workspace now. A failure is logged, not raised: the workspace's own deadline still holds. */ + readonly destroy: (sessionId: SessionId) => Effect.Effect + } +>()('alchemy-cloudflare/Workspace') { + /** Binds the Computer Durable Object; build it in a Worker's or Durable Object's construction. */ + static readonly layer = Layer.effect( + Workspace, + Effect.gen(function* () { + const computers = yield* Computer.from(COMPUTER_WORKER_NAME) + + return Workspace.of({ + prepare: Effect.fn('alchemy_cloudflare.workspace.prepare')((sessionId, repos) => + computers + .getByName(sessionId) + .prepare(repos.map((repo) => ({ name: repoName(repo), url: repo.url, ref: repo.ref ?? null }))) + .pipe( + Effect.tap((cloned) => + Effect.logInfo('workspace.prepare', JSON.stringify({ sessionId, cloned })), + ), + Effect.mapError((error) => new RepoCloneError({ message: error.message })), + ), + ), + fileSystem: (sessionId) => workspaceFileSystem(computers.getByName(sessionId)), + expireAt: (sessionId, deleteAt) => + computers + .getByName(sessionId) + .expireAt(deleteAt) + .pipe( + Effect.catch((error) => + Effect.logWarning( + 'workspace.expireAt failed', + JSON.stringify({ sessionId, error: error.message }), + ), + ), + ), + destroy: (sessionId) => + computers + .getByName(sessionId) + .destroy() + .pipe( + Effect.tap(() => Effect.logInfo('workspace.destroyed', JSON.stringify({ sessionId }))), + Effect.catch((error) => + Effect.logWarning( + 'workspace.destroy failed', + JSON.stringify({ sessionId, error: error.message }), + ), + ), + ), + startContainer: Effect.fn('alchemy_cloudflare.workspace.startContainer')(function* (sessionId) { + const started = yield* Clock.currentTimeMillis + const result = yield* computers + .getByName(sessionId) + .startContainer() + .pipe( + Effect.catch((error) => + Effect.succeed({ ok: false as const, code: 'RPC', message: error.message }), + ), + ) + const details = { sessionId, millis: (yield* Clock.currentTimeMillis) - started } + yield* result.ok + ? Effect.logInfo('workspace.container.started', JSON.stringify(details)) + : Effect.logWarning( + 'workspace.container.start failed', + JSON.stringify({ ...details, error: result.message }), + ) + }), + exec: Effect.fn('alchemy_cloudflare.workspace.exec')(function* (sessionId, input) { + const result = yield* computers + .getByName(sessionId) + .exec(input) + .pipe(Effect.mapError((error) => new ShellError({ message: error.message }))) + yield* Effect.logInfo( + 'workspace.exec', + JSON.stringify({ + backend: input.backend, + command: input.command, + cwd: input.cwd, + outcome: result.ok + ? { status: result.value.status, exitCode: result.value.exitCode } + : result, + }), + ) + return result.ok ? result.value : yield* new ShellError({ message: result.message }) + }), + }) + }), + ) +} diff --git a/examples/alchemy-cloudflare/src/WorkspaceFileSystem.ts b/examples/alchemy-cloudflare/src/WorkspaceFileSystem.ts new file mode 100644 index 0000000..718c240 --- /dev/null +++ b/examples/alchemy-cloudflare/src/WorkspaceFileSystem.ts @@ -0,0 +1,136 @@ +/** + * Effect's `FileSystem` over a session's Computer, so fold's file tools and skill loader work on the + * workspace unchanged. It covers what they call: reading, writing, making directories, removing, + * `stat`, `access`, `exists`, `realPath` and listing a directory. Every other method is Effect's no-op. + * + * The workspace has no permissions, so `access` only checks the path exists. It has symlinks, but + * `realPath` returns the path unchanged: fold only uses it to key its per-file write lock. + */ +import type { RpcCallError } from 'alchemy' +import { Effect, FileSystem, Option, PlatformError } from 'effect' + +import type { FileInfo, ComputerResult } from './computer/Contract' + +/** The Computer's file methods, as its RPC stub returns them. */ +export type ComputerFiles = { + readonly readFile: (path: string) => Effect.Effect, RpcCallError> + readonly writeFile: (path: string, content: string) => Effect.Effect, RpcCallError> + readonly mkdir: (path: string, recursive: boolean) => Effect.Effect, RpcCallError> + readonly rm: (path: string, recursive: boolean, force: boolean) => Effect.Effect, RpcCallError> + readonly stat: (path: string) => Effect.Effect, RpcCallError> + readonly readdir: (path: string) => Effect.Effect>, RpcCallError> +} + +const SYSTEM_ERROR_TAGS = new Map([ + ['ENOENT', 'NotFound'], + ['EEXIST', 'AlreadyExists'], + ['EACCES', 'PermissionDenied'], + ['EPERM', 'PermissionDenied'], + ['EISDIR', 'BadResource'], + ['ENOTDIR', 'BadResource'], + ['EINVAL', 'InvalidData'], +]) + +/** + * One Computer call as an Effect. A failed operation keeps its code in `cause.code`, where fold's tools + * read it; a failed RPC is `Unknown`, so the tool call fails and the session goes on. + */ +const run = ( + method: string, + path: string, + call: Effect.Effect, RpcCallError>, +): Effect.Effect => + call.pipe( + Effect.mapError((cause) => + PlatformError.systemError({ + _tag: 'Unknown', + module: 'FileSystem', + method, + pathOrDescriptor: path, + description: cause.message, + cause, + }), + ), + Effect.flatMap((result) => + result.ok + ? Effect.succeed(result.value) + : Effect.fail( + PlatformError.systemError({ + _tag: SYSTEM_ERROR_TAGS.get(result.code) ?? 'Unknown', + module: 'FileSystem', + method, + pathOrDescriptor: path, + description: result.message, + cause: { code: result.code }, + }), + ), + ), + Effect.tap(() => Effect.logInfo('workspace.file', JSON.stringify({ method, path, outcome: 'ok' }))), + Effect.tapError((error) => + Effect.logInfo( + 'workspace.file', + JSON.stringify({ method, path, outcome: error.reason._tag, error: error.message }), + ), + ), + ) + +const toInfo = (info: FileInfo): FileSystem.File.Info => ({ + type: info.type, + mtime: Option.some(new Date(info.mtime)), + atime: Option.none(), + birthtime: Option.none(), + dev: 0, + ino: Option.some(info.inode), + mode: info.mode, + nlink: Option.none(), + uid: Option.none(), + gid: Option.none(), + rdev: Option.none(), + size: FileSystem.Size(info.size), + blksize: Option.none(), + blocks: Option.none(), +}) + +export const workspaceFileSystem = (computer: ComputerFiles): FileSystem.FileSystem => { + const stat = (path: string) => run('stat', path, computer.stat(path)).pipe(Effect.map(toInfo)) + const readFile = (path: string) => run('readFile', path, computer.readFile(path)) + + return FileSystem.makeNoop({ + readFile, + readFileString: (path, encoding) => + Effect.flatMap(readFile(path), (bytes) => + Effect.try({ + try: () => new TextDecoder(encoding).decode(bytes), + catch: (cause) => + PlatformError.badArgument({ + module: 'FileSystem', + method: 'readFileString', + description: 'invalid encoding', + cause, + }), + }), + ), + writeFileString: (path, data) => run('writeFileString', path, computer.writeFile(path, data)), + makeDirectory: (path, options) => run('makeDirectory', path, computer.mkdir(path, options?.recursive ?? false)), + remove: (path, options) => + run('remove', path, computer.rm(path, options?.recursive ?? false, options?.force ?? false)), + stat, + access: (path) => Effect.asVoid(stat(path)), + exists: (path) => + stat(path).pipe( + Effect.as(true), + Effect.catchReasons('PlatformError', { NotFound: () => Effect.succeed(false) }), + ), + realPath: (path) => Effect.as(stat(path), path), + readDirectory: (path, options) => + options?.recursive === true + ? Effect.fail( + PlatformError.badArgument({ + module: 'FileSystem', + method: 'readDirectory', + description: 'recursive listing is not supported', + }), + ) + : run('readDirectory', path, computer.readdir(path)).pipe(Effect.map((names) => [...names])), + }) +} diff --git a/examples/alchemy-cloudflare/src/computer/Computer.ts b/examples/alchemy-cloudflare/src/computer/Computer.ts new file mode 100644 index 0000000..f678a48 --- /dev/null +++ b/examples/alchemy-cloudflare/src/computer/Computer.ts @@ -0,0 +1,255 @@ +/// +/** + * The Computer Durable Object: one `@cloudflare/computer` Workspace per fold session, named by its + * SessionId. The Workspace is a virtual filesystem in the object's SQLite, with git, and two places to run + * commands on it: + * + * - `shell`: just-bash in a Worker the Worker Loader starts, which reads and writes this same filesystem. + * - `container`: this object's Linux container (see `Dockerfile`), running `computerd`, which mounts a copy + * of the filesystem at `/workspace`. Each command first sends the container the files changed since the + * last one, then copies back what the command changed. + * + * A plain Durable Object rather than an Effect one, because `withWorkspace` must wrap the + * `cloudflare:workers` class. + */ +import { type DurableObjectStorageLike, getWorkspace, withWorkspace } from '@cloudflare/computer' +import { + CloudflareContainerBackend, + type CloudflareContainerBackendOptions, + withWorkspaceContainer, +} from '@cloudflare/computer/backends/container' +import { WorkerShellBackend } from '@cloudflare/computer/backends/worker-shell' +import { createGitClient, type GitClient, type GitCloneOptions } from '@cloudflare/computer/git' +import jq from '@cloudflare/computer/shell/jq' +import { DurableObject } from 'cloudflare:workers' + +import { + type Backend, + type ClonedRepo, + type CommandInput, + type CommandOutput, + COMPUTER_BINDING, + type ComputerResult, + type FileInfo, + type RepoSpec, + WORKSPACE_ROOT, +} from './Contract' + +// Entrypoints the backends reach this object through, via `ctx.exports`: the shell calls back into the +// workspace through WorkspaceServiceProxy, and the container dials its connection in through WorkspaceProxy. +export { WorkspaceProxy, WorkspaceServiceProxy } from '@cloudflare/computer' + +type Env = { + /** Starts the shell's Worker. */ + readonly LOADER: WorkerLoader +} + +/** How long `startContainer` waits for the no-op command, past the container's own 30s start budget. */ +const CONTAINER_START_TIMEOUT_MILLIS = 10_000 + +/** How long `destroy` waits after answering before the object restarts. */ +const RESTART_DELAY_MILLIS = 1_000 + +/** + * The object's storage as `@cloudflare/computer` types it. Its `exec` promises whatever row type the caller + * names, which a Durable Object's SQL cannot; the rows are the same at runtime. + */ +const workspaceStorage = (storage: DurableObjectStorage): DurableObjectStorageLike => { + function exec(query: string, ...bindings: Array): { toArray(): Array } + function exec(query: string, ...bindings: Array): { toArray(): Array } { + return storage.sql.exec(query, ...bindings) + } + return { + sql: { exec }, + transaction: (closure) => storage.transaction(async () => closure()), + transactionSync: (closure) => storage.transactionSync(closure), + } +} + +/** What `withWorkspaceContainer` adds to the object. */ +type ContainerOwner = { getWorkspaceContainer(): unknown } + +/** + * The object as the container backend's host. `@cloudflare/computer` types the container API against its + * own copy of the Workers types, whose `Fetcher` has more methods than ours; the object is the same. + */ +function containerHost(self: ContainerOwner): Awaited> +function containerHost(self: ContainerOwner): ContainerOwner { + return self +} + +/** Run one file operation, returning a thrown workspace error as its code and message. */ +const attempt = async (operation: () => Promise): Promise> => { + try { + return { ok: true, value: await operation() } + } catch (cause) { + const code = + cause instanceof Error && 'code' in cause && typeof cause.code === 'string' ? cause.code : 'UNKNOWN' + return { ok: false, code, message: String(cause) } + } +} + +class ComputerBase extends withWorkspaceContainer(class extends DurableObject {}) { + // For withWorkspace's options, which see the instance but not `ctx` or `env`: DurableObject keeps them + // protected. + readonly storage = workspaceStorage(this.ctx.storage) + readonly shell = new WorkerShellBackend({ + id: 'shell' satisfies Backend, + loader: this.env.LOADER, + workspace: { binding: COMPUTER_BINDING, id: this.ctx.id.toString() }, + ctx: this.ctx, + commands: [jq], + }) + readonly container = new CloudflareContainerBackend({ + id: 'container' satisfies Backend, + container: () => containerHost(this), + workspace: { binding: COMPUTER_BINDING, id: this.ctx.id.toString() }, + // Commands may install packages. + egress: { mode: 'direct' }, + }) +} + +export class Computer extends withWorkspace(ComputerBase, (self) => ({ + storage: self.storage, + git: createGitClient(), + backends: [self.shell, self.container], +})) { + /** The container's connection, which `computerd` dials in through WorkspaceProxy. */ + override async fetch(request: Request): Promise { + return await this.container.handleFetch(request) + } + + /** + * Start the container and connect to it, so the first container command doesn't wait for it. Runs a + * no-op command: the workspace connects a backend on its first command. + */ + async startContainer(): Promise> { + return await this.exec({ + backend: 'container', + command: 'true', + cwd: '/', + timeoutMs: CONTAINER_START_TIMEOUT_MILLIS, + }).then((result) => (result.ok ? { ok: true, value: null } : result)) + } + + /** + * Clone each repo into `/workspace/`, one at a time, replacing whatever a cut-off earlier attempt + * left there. Rejects with the first repo that fails. + */ + async prepare(repos: ReadonlyArray): Promise> { + using workspace = await getWorkspace(this) + const git: GitClient = workspace.git + await workspace.fs.mkdir(WORKSPACE_ROOT, { recursive: true }) + + const cloned: Array = [] + for (const repo of repos) { + const dir = `${WORKSPACE_ROOT}/${repo.name}` + try { + await workspace.fs.rm(dir, { recursive: true, force: true }) + const options: GitCloneOptions = { url: repo.url, dir } + if (repo.ref !== null) options.ref = repo.ref + await git.clone(options) + cloned.push({ ...repo, dir, commit: await git.revParse({ dir, ref: 'HEAD' }) }) + } catch (cause) { + throw new Error(`Cloning ${repo.name} from ${repo.url} failed: ${String(cause)}`, { cause }) + } + } + return cloned + } + + // The file methods fold's read, write, edit and apply_patch tools and its skill loader need. Paths are + // absolute. + + async readFile(path: string): Promise> { + using workspace = await getWorkspace(this) + return await attempt(async () => new Response(await workspace.fs.readFile(path)).bytes()) + } + + async writeFile(path: string, content: string): Promise> { + using workspace = await getWorkspace(this) + return await attempt(async () => { + await workspace.fs.writeFile(path, content) + return null + }) + } + + async mkdir(path: string, recursive: boolean): Promise> { + using workspace = await getWorkspace(this) + return await attempt(async () => { + await workspace.fs.mkdir(path, { recursive }) + return null + }) + } + + async rm(path: string, recursive: boolean, force: boolean): Promise> { + using workspace = await getWorkspace(this) + return await attempt(async () => { + await workspace.fs.rm(path, { recursive, force }) + return null + }) + } + + /** Follows symlinks. */ + async stat(path: string): Promise> { + using workspace = await getWorkspace(this) + return await attempt(async () => { + const stat = await workspace.fs.stat(path) + const type = stat.isDirectory ? 'Directory' : stat.isSymbolicLink ? 'SymbolicLink' : 'File' + return { type, size: stat.size, mtime: stat.mtime, mode: stat.mode, inode: stat.inode } + }) + } + + /** + * Delete this workspace at `deleteAt` (epoch milliseconds) unless moved again before then. Its session + * normally deletes it first; this is the backup for one that never does, such as a failed first clone. + */ + async expireAt(deleteAt: number): Promise { + await this.ctx.storage.setAlarm(deleteAt) + } + + /** Delete the workspace: the container, files, git data, and the backup alarm. The object then restarts empty. */ + async destroy(): Promise { + if (this.ctx.container?.running === true) await this.ctx.container.destroy('workspace deleted') + await this.ctx.storage.deleteAlarm() + await this.ctx.storage.deleteAll() + // The in-memory workspace must go: its tables are gone. Wait until this call has answered: Cloudflare + // holds the answer until the delete is saved, and an abort before then fails the call. + setTimeout(() => { + try { + this.ctx.abort('workspace deleted') + } catch { + // abort throws to unwind; the object resets either way. + } + }, RESTART_DELAY_MILLIS) + } + + /** Only `expireAt` sets the alarm, and each call replaces it, so it fires at the latest deadline. */ + override async alarm(): Promise { + await this.destroy() + } + + /** Run one command. A command that fails still succeeds here, with its exit code. */ + async exec(input: CommandInput): Promise> { + using workspace = await getWorkspace(this) + return await attempt(async () => { + using handle = await workspace.runtime.exec(input.command, { + backend: input.backend, + cwd: input.cwd, + encoding: 'utf8', + timeoutMs: input.timeoutMs, + }) + const { status, exitCode, stdout, stderr } = await handle.result() + return { status, exitCode, stdout, stderr } + }) + } + + /** The directory's entry names. */ + async readdir(path: string): Promise>> { + using workspace = await getWorkspace(this) + return await attempt(async () => (await workspace.fs.readdir(path)).map((entry) => entry.name)) + } +} + +export default { + fetch: () => new Response('Not found', { status: 404 }), +} satisfies ExportedHandler diff --git a/examples/alchemy-cloudflare/src/computer/ComputerWorker.ts b/examples/alchemy-cloudflare/src/computer/ComputerWorker.ts new file mode 100644 index 0000000..9a92ef2 --- /dev/null +++ b/examples/alchemy-cloudflare/src/computer/ComputerWorker.ts @@ -0,0 +1,24 @@ +/** + * The Worker hosting the Computer Durable Object. An async Worker: an Effect Worker's bundle drops every + * export but its own, and `@cloudflare/computer` needs its classes exported from the Worker module. + */ +import * as Cloudflare from 'alchemy/Cloudflare' + +import type { Computer } from './Computer' +import { COMPUTER_WORKER_NAME } from './Contract' + +export const ComputerWorker = Cloudflare.Worker('ComputerWorker', { + name: COMPUTER_WORKER_NAME, + main: './src/computer/Computer.ts', + compatibility: { flags: ['nodejs_compat'], date: '2026-08-31' }, + env: { + // The Computer Durable Object with its container. Named COMPUTER_BINDING: the shell and the container + // call back into the Computer through this binding. + Computer: Cloudflare.Container('Computer', { + context: './src/computer/container', + + instanceType: 'standard-4', // standard-2 = 1 vCPU, standard-1 for 1/2 and standard-4 = 4 + }), + LOADER: Cloudflare.WorkerLoader(), + }, +}) diff --git a/examples/alchemy-cloudflare/src/computer/Contract.ts b/examples/alchemy-cloudflare/src/computer/Contract.ts new file mode 100644 index 0000000..6f329ab --- /dev/null +++ b/examples/alchemy-cloudflare/src/computer/Contract.ts @@ -0,0 +1,68 @@ +/** + * What both sides of the Computer boundary share: the Computer Worker's script name, where repos live, and + * the values its RPC methods take and return. Plain values only, so the Computer bundle stays free of Effect. + */ + +/** + * The Computer Worker's script name. Fixed, so ChatWorker can bind the Computer Durable Object across + * scripts; it also means one deploy of this stack per Cloudflare account. + */ +export const COMPUTER_WORKER_NAME = 'fold-alchemy-cloudflare-computer' + +/** Every repo clones into `${WORKSPACE_ROOT}/`. */ +export const WORKSPACE_ROOT = '/workspace' + +/** The Computer's binding name on its own Worker, which the shell uses to call back into it. */ +export const COMPUTER_BINDING = 'Computer' + +export type RepoSpec = { + readonly name: string + readonly url: string + /** Branch, tag, or commit; the remote's default branch when null. */ + readonly ref: string | null +} + +export type ClonedRepo = RepoSpec & { + readonly dir: string + /** The commit checked out. */ + readonly commit: string +} + +/** + * A file or command method's outcome. Failures come back as values, not thrown: an error thrown across RPC + * keeps its message but loses its `code` (`ENOENT`, `EISDIR`, ...), which the file tools need. + */ +export type ComputerResult = + | { readonly ok: true; readonly value: A } + | { readonly ok: false; readonly code: string; readonly message: string } + +export type FileInfo = { + readonly type: 'File' | 'Directory' | 'SymbolicLink' + readonly size: number + /** Milliseconds since the epoch. */ + readonly mtime: number + readonly mode: number + readonly inode: number +} + +/** + * Where a command runs: `shell` is just-bash in a Worker, fast but with only text commands and git; + * `container` is a Linux container with node, bun, python and internet access, slow to start. + */ +export type Backend = 'shell' | 'container' + +/** One command, run on `backend` in `cwd` and stopped after `timeoutMs`. */ +export type CommandInput = { + readonly backend: Backend + readonly command: string + readonly cwd: string + readonly timeoutMs: number +} + +export type CommandOutput = { + /** `cancelled` when the command hit its timeout. */ + readonly status: 'completed' | 'failed' | 'cancelled' + readonly exitCode: number + readonly stdout: string + readonly stderr: string +} diff --git a/examples/alchemy-cloudflare/src/computer/container/Dockerfile b/examples/alchemy-cloudflare/src/computer/container/Dockerfile new file mode 100644 index 0000000..97efe38 --- /dev/null +++ b/examples/alchemy-cloudflare/src/computer/container/Dockerfile @@ -0,0 +1,66 @@ +# The Computer's Linux container: computerd, which mounts the session's workspace at /workspace and runs +# the agent's container commands, plus git, node, bun and python for installs, builds and tests. +# +# Based on @cloudflare/computer's examples/container. computerd comes from a fork of cloudflare/computer +# that keeps gitignored files in the container (SYNC_GITIGNORE below), so installs and builds never reach +# the Computer's storage. See https://github.com/cloudflare/computer/issues/179. To change it, push to +# the fork and update COMPUTER_COMMIT. + +FROM node:22-bookworm AS computerd +ARG COMPUTER_REPO=https://github.com/K-Mistele/computer +ARG COMPUTER_COMMIT=b8aa33f8d770dc3ab0c3f1156390a15c9f2d45da +RUN git clone --filter=blob:none "$COMPUTER_REPO" /opt/computer \ + && cd /opt/computer \ + && git checkout "$COMPUTER_COMMIT" \ + && npm ci \ + && npm run build --workspace @cloudflare/dofs --workspace @cloudflare/computer-rpc --workspace @cloudflare/computerd + +FROM oven/bun:1.3.14-debian AS bun + +FROM debian:stable-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + fuse3 libfuse2t64 ca-certificates curl gnupg git jq ripgrep python3 tmux fd-find \ + && mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun +RUN ln -s /usr/local/bin/bun /usr/local/bin/bunx \ + && ln -s /usr/bin/fdfind /usr/local/bin/fd + +COPY --from=computerd /opt/computer /opt/computer + +# computerd serves HTTP on PORT and mounts the workspace at MOUNT_POINT. FUSE_MOUNT=auto uses the kernel's +# FUSE, which Cloudflare Containers provide, and falls back to a userspace copy where it is missing. +ENV PORT=8080 +ENV MOUNT_POINT=/workspace +ENV FUSE_MOUNT=auto +# Run the agent's commands as root in a bash login shell, like a terminal: computerd calls +# `$EXEC_SHELL -c `, and the wrapper adds -l so /etc/profile and ~/.bashrc load. +ENV HOME=/root +ENV SHELL=/bin/bash +ENV EXEC_SHELL=/usr/local/bin/agent-bash +RUN printf '#!/bin/bash\nexec /bin/bash -l "$@"\n' > /usr/local/bin/agent-bash \ + && chmod +x /usr/local/bin/agent-bash +# Keep computerd's copy of the workspace on disk, not in memory, so a large install can't fill the +# container's memory. Skip SQLite's disk syncs: the disk goes away with the container, so a crash can't +# leave a damaged store behind, and syncing was most of the cost of saving each file. +ENV COMPUTERD_DB=/var/lib/computerd/state.db +ENV COMPUTERD_DB_SYNCHRONOUS=off +# Let the kernel remember "file not found" for a second. Module resolution looks for many files that +# don't exist, and without this every miss goes to computerd. computerd waits out the second after the +# Computer sends files, so a command never misses one. +ENV COMPUTERD_FUSE_NEGATIVE_TIMEOUT=1 +# Never send gitignored files back to the Computer. +ENV SYNC_GITIGNORE=1 +RUN mkdir -p /var/lib/computerd +EXPOSE 8080 + +ENTRYPOINT ["node", "/opt/computer/packages/computerd/dist/cli/computerd.cjs"] diff --git a/examples/alchemy-cloudflare/test/Api.test.ts b/examples/alchemy-cloudflare/test/Api.test.ts index ae3d585..785de59 100644 --- a/examples/alchemy-cloudflare/test/Api.test.ts +++ b/examples/alchemy-cloudflare/test/Api.test.ts @@ -1,8 +1,8 @@ /** * The chat routes against an in-memory ChatSessions: each session is fold's in-memory EventLog, so the * routes encode and decode real log entries without a Durable Object or a model. The fake mirrors the - * real contract's shape - the first send to an id starts its session - and echoes the delivery mode so - * tests can see what the routes passed. + * real contract's shape - the first send to an id clones its repos and starts its session - and echoes the + * delivery mode so tests can see what the routes passed. A repo whose URL ends in `/missing` fails to clone. */ import { it } from '@effect/vitest' import { @@ -12,6 +12,7 @@ import { SessionId, layerInMemoryEventLog, type EventLogService, + type LogEntryInput, } from '@humanlayer/fold-core' import { Context, Effect, Layer, Predicate, Ref, Schema, Scope, Stream } from 'effect' import { HttpRouter } from 'effect/unstable/http' @@ -19,6 +20,7 @@ import { expect } from 'vitest' import { ChatRoutes } from '../src/Api' import { ChatSessions } from '../src/ChatSessions' +import { RepoCloneError, repoName } from '../src/Workspace' const rootAgentId = AgentId.create() @@ -41,25 +43,30 @@ const layerInMemory = Layer.effect( }) const entriesOf = (log: EventLogService) => Stream.runCollect(log.entries()).pipe(Effect.orDie) + const appendOrDie = (log: EventLogService, input: LogEntryInput) => Effect.orDie(log.append(input)) return ChatSessions.of({ entries: (sessionId) => Effect.flatMap(logFor(sessionId), entriesOf), - send: (sessionId, text, whenRunning) => + send: (sessionId, { text, whenRunning, repos = [] }) => Effect.gen(function* () { const log = yield* logFor(sessionId) if ((yield* entriesOf(log)).length === 0) { - yield* log.append({ + const missing = repos.find((repo) => repo.url.endsWith('/missing')) + if (missing !== undefined) { + return yield* new RepoCloneError({ message: `Cloning ${repoName(missing)} failed` }) + } + yield* appendOrDie(log, { _tag: 'session_started', agentId: null, parentAgentId: null, toolCallId: null, - cwd: null, + cwd: '/workspace', sessionId, rootAgentId, - meta: {}, + meta: { repos: repos.map(repoName) }, }) } - const finished = yield* log.append({ + const finished = yield* appendOrDie(log, { _tag: 'agent-finished', agentId: rootAgentId, parentAgentId: null, @@ -70,7 +77,7 @@ const layerInMemory = Layer.effect( }) if (Predicate.isTagged(finished, 'agent-finished')) return finished return yield* Effect.die(`appended ${finished._tag} while appending agent-finished`) - }).pipe(Effect.orDie), + }), }) }), ) @@ -93,12 +100,12 @@ const serve = Effect.acquireRelease( const decodeEntries = (response: Response) => Effect.promise(() => response.json()).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(LogEntry)))) -it.effect('the first message to a new id starts its session; GET replays it', () => +it.effect('the first message to a new id starts its session; GET /log replays it', () => Effect.gen(function* () { const request = yield* serve const sessionId = SessionId.create() - expect(yield* request('GET', `/sessions/${sessionId}`).pipe(Effect.flatMap(decodeEntries))).toEqual([]) + expect(yield* request('GET', `/sessions/${sessionId}/log`).pipe(Effect.flatMap(decodeEntries))).toEqual([]) const sent = yield* request('POST', `/sessions/${sessionId}/messages`, { text: 'hi' }) expect(sent.status).toBe(200) @@ -107,7 +114,7 @@ it.effect('the first message to a new id starts its session; GET replays it', () resultText: 'queue: hi', }) - const entries = yield* request('GET', `/sessions/${sessionId}`).pipe(Effect.flatMap(decodeEntries)) + const entries = yield* request('GET', `/sessions/${sessionId}/log`).pipe(Effect.flatMap(decodeEntries)) expect(entries.map((entry) => entry._tag)).toEqual(['session_started', 'agent-finished']) }).pipe(Effect.scoped), ) @@ -129,9 +136,63 @@ it.effect('rejects bad ids, bodies, and modes', () => const request = yield* serve const messages = `/sessions/${SessionId.create()}/messages` - expect((yield* request('GET', '/sessions/not-a-session')).status).toBe(400) + expect((yield* request('GET', '/sessions/not-a-session/log')).status).toBe(400) expect((yield* request('POST', messages, { message: 'hi' })).status).toBe(400) expect((yield* request('POST', messages, { text: 'hi', whenRunning: 'later' })).status).toBe(400) expect((yield* request('GET', '/nope')).status).toBe(404) + expect((yield* request('GET', `/sessions/${SessionId.create()}`)).status).toBe(404) + }).pipe(Effect.scoped), +) + +it.effect('a new session clones the repos its first message names', () => + Effect.gen(function* () { + const request = yield* serve + const sessionId = SessionId.create() + + const sent = yield* request('POST', `/sessions/${sessionId}/messages`, { + text: 'hi', + repos: [ + { url: 'https://github.com/humanlayer/fold.git' }, + { url: 'https://github.com/x/y', name: 'other' }, + ], + }) + expect(sent.status).toBe(200) + + const [started] = yield* request('GET', `/sessions/${sessionId}/log`).pipe(Effect.flatMap(decodeEntries)) + expect(started).toMatchObject({ + _tag: 'session_started', + cwd: '/workspace', + meta: { repos: ['fold', 'other'] }, + }) + }).pipe(Effect.scoped), +) + +it.effect('a repo that fails to clone is a 422 and starts nothing', () => + Effect.gen(function* () { + const request = yield* serve + const sessionId = SessionId.create() + + const sent = yield* request('POST', `/sessions/${sessionId}/messages`, { + text: 'hi', + repos: [{ url: 'https://github.com/x/missing' }], + }) + expect(sent.status).toBe(422) + expect(yield* Effect.promise(() => sent.text())).toBe('Cloning missing failed') + expect(yield* request('GET', `/sessions/${sessionId}/log`).pipe(Effect.flatMap(decodeEntries))).toEqual([]) + }).pipe(Effect.scoped), +) + +it.effect('rejects repos that are not https or whose directory names are invalid or repeated', () => + Effect.gen(function* () { + const request = yield* serve + const send = (repos: ReadonlyArray) => + request('POST', `/sessions/${SessionId.create()}/messages`, { text: 'hi', repos }).pipe( + Effect.map((response) => response.status), + ) + + expect(yield* send([{ url: 'git@github.com:x/y.git' }])).toBe(400) + expect(yield* send([{ url: 'https://github.com/x/y', name: 'a b' }])).toBe(400) + expect(yield* send([{ url: 'https://github.com/x/y', name: '..' }])).toBe(400) + expect(yield* send([{ url: 'https://github.com/a/y' }, { url: 'https://github.com/b/y.git' }])).toBe(400) }).pipe(Effect.scoped), ) diff --git a/examples/alchemy-cloudflare/test/BashTool.test.ts b/examples/alchemy-cloudflare/test/BashTool.test.ts new file mode 100644 index 0000000..e3ea94f --- /dev/null +++ b/examples/alchemy-cloudflare/test/BashTool.test.ts @@ -0,0 +1,125 @@ +/** + * The bash tool against a fake shell that records each command and answers with a scripted output. + */ +import { it } from '@effect/vitest' +import { Effect, FileSystem, Ref } from 'effect' +import { expect } from 'vitest' + +import { bashTool } from '../src/BashTool' +import type { CommandInput, CommandOutput } from '../src/computer/Contract' +import { ShellError } from '../src/Workspace' +import { callTool } from './ToolCalls' + +const output = (fields: Partial): CommandOutput => ({ + status: 'completed', + exitCode: 0, + stdout: '', + stderr: '', + ...fields, +}) + +/** A bash tool whose shell answers every command with `answer`, recording the inputs. */ +const fakeBash = (answer: Effect.Effect) => + Effect.gen(function* () { + const inputs = yield* Ref.make>([]) + const tool = bashTool((input) => + Effect.andThen( + Ref.update(inputs, (all) => [...all, input]), + answer, + ), + ) + // fold's tool setup may ask for a FileSystem; bash never does. + const bash = (params: unknown) => + callTool([tool], 'bash', params).pipe(Effect.provide(FileSystem.layerNoop({}))) + return { bash, inputs: Ref.get(inputs) } + }) + +it.effect('returns stdout then stderr, running in /workspace by default', () => + Effect.gen(function* () { + const { bash, inputs } = yield* fakeBash(Effect.succeed(output({ stdout: 'README\n', stderr: 'warning' }))) + + expect(yield* bash({ command: 'ls' })).toEqual({ _tag: 'text', text: 'README\nwarning' }) + expect(yield* inputs).toEqual([{ backend: 'shell', command: 'ls', cwd: '/workspace', timeoutMs: 120_000 }]) + }), +) + +it.effect('runs in the container when asked', () => + Effect.gen(function* () { + const { bash, inputs } = yield* fakeBash(Effect.succeed(output({ stdout: 'v22.0.0\n' }))) + + expect(yield* bash({ command: 'node --version', backend: 'container' })).toEqual({ + _tag: 'text', + text: 'v22.0.0', + }) + expect((yield* inputs).map(({ backend }) => backend)).toEqual(['container']) + }), +) + +it.effect('resolves workdir against /workspace and passes the timeout', () => + Effect.gen(function* () { + const { bash, inputs } = yield* fakeBash(Effect.succeed(output({}))) + + expect(yield* bash({ command: 'true', workdir: 'app/src', timeout_ms: 5_000 })).toEqual({ + _tag: 'text', + text: '(no output)', + }) + yield* bash({ command: 'true', workdir: '/tmp' }) + expect((yield* inputs).map(({ cwd, timeoutMs }) => ({ cwd, timeoutMs }))).toEqual([ + { cwd: '/workspace/app/src', timeoutMs: 5_000 }, + { cwd: '/tmp', timeoutMs: 120_000 }, + ]) + }), +) + +it.effect('fails with the output and exit code when the command fails', () => + Effect.gen(function* () { + const { bash } = yield* fakeBash(Effect.succeed(output({ exitCode: 2, stderr: 'grep: nope: No such file\n' }))) + + expect(yield* Effect.flip(bash({ command: 'grep x nope' }))).toEqual({ + _tag: 'failure', + text: 'grep: nope: No such file\n\nCommand exited with code 2', + }) + }), +) + +it.effect('fails when the command times out', () => + Effect.gen(function* () { + const { bash } = yield* fakeBash(Effect.succeed(output({ status: 'cancelled', exitCode: 130 }))) + + expect(yield* Effect.flip(bash({ command: 'sleep 1000', timeout_ms: 10 }))).toMatchObject({ + text: expect.stringContaining('Command timed out after 10 milliseconds'), + }) + }), +) + +it.effect('rejects a bad timeout without running anything', () => + Effect.gen(function* () { + const { bash, inputs } = yield* fakeBash(Effect.succeed(output({}))) + + expect(yield* Effect.flip(bash({ command: 'ls', timeout_ms: 0 }))).toMatchObject({ _tag: 'failure' }) + expect(yield* inputs).toEqual([]) + }), +) + +it.effect('keeps the end of long output', () => + Effect.gen(function* () { + const lines = Array.from({ length: 2_500 }, (_, index) => `line ${index + 1}`).join('\n') + const { bash } = yield* fakeBash(Effect.succeed(output({ stdout: lines }))) + + const result = yield* bash({ command: 'seq 2500' }) + expect(result).toMatchObject({ text: expect.stringMatching(/^\[Showing the last 2000 of 2500 lines\]\n/) }) + expect(result).toMatchObject({ text: expect.stringMatching(/line 2500$/) }) + expect(result).not.toMatchObject({ text: expect.stringContaining('line 500\n') }) + }), +) + +it.effect('fails the tool call when the shell cannot run the command', () => + Effect.gen(function* () { + const { bash } = yield* fakeBash(Effect.fail(new ShellError({ message: 'shell unavailable' }))) + + expect(yield* Effect.flip(bash({ command: 'ls' }))).toEqual({ + _tag: 'failure', + text: 'The shell could not run the command: shell unavailable', + }) + }), +) diff --git a/examples/alchemy-cloudflare/test/SessionExpiry.test.ts b/examples/alchemy-cloudflare/test/SessionExpiry.test.ts new file mode 100644 index 0000000..39912ba --- /dev/null +++ b/examples/alchemy-cloudflare/test/SessionExpiry.test.ts @@ -0,0 +1,119 @@ +/** + * SessionExpiry against an in-memory Durable Object store, with Effect's test clock standing in for time. + */ +import { it } from '@effect/vitest' +import { Duration, Effect, Ref } from 'effect' +import { TestClock } from 'effect/testing' +import { expect } from 'vitest' + +import { type ExpiryStorage, SessionExpiry } from '../src/SessionExpiry' + +const DAY = Duration.toMillis(Duration.days(1)) + +/** A fake object: its stored values, its alarm, and whether it was deleted or restarted. */ +const fakeObject = Effect.gen(function* () { + const values = yield* Ref.make>(new Map()) + const alarm = yield* Ref.make(null) + const events = yield* Ref.make>([]) + const record = (event: string) => Ref.update(events, (all) => [...all, event]) + + const storage: ExpiryStorage = { + get: (key) => Effect.map(Ref.get(values), (all) => all.get(key)), + put: (key, value) => Ref.update(values, (all) => new Map(all).set(key, value)), + getAlarm: Ref.get(alarm), + setAlarm: (at) => Ref.set(alarm, at), + deleteAll: Effect.andThen(Ref.set(values, new Map()), Ref.set(alarm, null)).pipe( + Effect.andThen(record('deleteAll')), + ), + restart: record('restart'), + } + const expiry = SessionExpiry.make(storage, Duration.days(14)) + return { expiry, alarm: Ref.get(alarm), events: Ref.get(events), deleteWorkspace: record('deleteWorkspace') } +}) + +it.effect('each message pushes the deadline back', () => + Effect.gen(function* () { + const { expiry } = yield* fakeObject + + expect(yield* expiry.touch).toBe(14 * DAY) + yield* TestClock.adjust(Duration.days(3)) + expect(yield* expiry.touch).toBe(17 * DAY) + }), +) + +it.effect('before the deadline, the alarm waits for it', () => + Effect.gen(function* () { + const { expiry, alarm, events, deleteWorkspace } = yield* fakeObject + yield* expiry.touch + yield* TestClock.adjust(Duration.days(1)) + + yield* expiry.alarm(deleteWorkspace) + expect(yield* alarm).toBe(14 * DAY) + expect(yield* events).toEqual([]) + }), +) + +it.effect('past the deadline, the alarm deletes the workspace, then the session, then restarts', () => + Effect.gen(function* () { + const { expiry, alarm, events, deleteWorkspace } = yield* fakeObject + yield* expiry.touch + yield* TestClock.adjust(Duration.days(14)) + + yield* expiry.alarm(deleteWorkspace) + expect(yield* events).toEqual(['deleteWorkspace', 'deleteAll', 'restart']) + expect(yield* alarm).toBeNull() + }), +) + +it.effect('a message after an earlier alarm keeps the session alive past the first deadline', () => + Effect.gen(function* () { + const { expiry, alarm, events, deleteWorkspace } = yield* fakeObject + yield* expiry.touch + yield* TestClock.adjust(Duration.days(10)) + yield* expiry.touch + yield* TestClock.adjust(Duration.days(4)) + + yield* expiry.alarm(deleteWorkspace) + expect(yield* alarm).toBe(24 * DAY) + expect(yield* events).toEqual([]) + }), +) + +it.effect('an alarm with no deadline stored does nothing: the session is already gone', () => + Effect.gen(function* () { + const { expiry, alarm, events, deleteWorkspace } = yield* fakeObject + + yield* expiry.alarm(deleteWorkspace) + expect(yield* alarm).toBeNull() + expect(yield* events).toEqual([]) + }), +) + +it.effect('a written session without an alarm gets one; an existing alarm is left alone', () => + Effect.gen(function* () { + const { expiry, alarm } = yield* fakeObject + + yield* expiry.ensureScheduled + expect(yield* alarm).toBe(14 * DAY) + + yield* TestClock.adjust(Duration.days(1)) + yield* expiry.ensureScheduled + expect(yield* alarm).toBe(14 * DAY) + }), +) + +it.effect('the session counts as expired from its deadline until it is deleted', () => + Effect.gen(function* () { + const { expiry, deleteWorkspace } = yield* fakeObject + expect(yield* expiry.expired).toBe(false) + + yield* expiry.touch + yield* TestClock.adjust(Duration.days(13)) + expect(yield* expiry.expired).toBe(false) + yield* TestClock.adjust(Duration.days(1)) + expect(yield* expiry.expired).toBe(true) + + yield* expiry.alarm(deleteWorkspace) + expect(yield* expiry.expired).toBe(false) + }), +) diff --git a/examples/alchemy-cloudflare/test/ToolCalls.ts b/examples/alchemy-cloudflare/test/ToolCalls.ts new file mode 100644 index 0000000..73c8f11 --- /dev/null +++ b/examples/alchemy-cloudflare/test/ToolCalls.ts @@ -0,0 +1,42 @@ +/** + * Call a fold tool's handler outside a session. fold provides these services around every tool call; here + * they are stubs. Platform services (FileSystem, Path) are the caller's to provide. + */ +import { + AgentId, + CurrentAgent, + CurrentToolCall, + InterruptNote, + StopController, + Subagents, + ToolCallId, + ToolEvents, + ToolState, + type FoldTool, +} from '@humanlayer/fold-core' +import { Effect, Layer } from 'effect' + +const toolCallServices = Layer.mergeAll( + Layer.succeed(ToolState, { get: () => Effect.succeed(null), set: () => Effect.void }), + Layer.succeed(ToolEvents, { emit: () => Effect.void }), + Layer.succeed(StopController, { requestStop: () => Effect.void, isStopRequested: Effect.succeed(false) }), + Layer.succeed(CurrentAgent, { agentId: AgentId.create(), parentAgentId: null }), + Layer.succeed(CurrentToolCall, { toolCallId: ToolCallId.create() }), + Layer.succeed(InterruptNote, { set: () => Effect.void }), + Layer.succeed(Subagents, { + dispatch: () => Effect.die('no subagents'), + fork: () => Effect.die('no subagents'), + resume: () => Effect.die('no subagents'), + continueSubagent: () => Effect.die('no subagents'), + }), +) + +/** Run the tool named `name` from `tools` with `params`, as the model would call it. */ +export const callTool = (tools: ReadonlyArray, name: string, params: unknown) => { + const tool = tools.find((candidate) => candidate.name === name) + if (tool === undefined) return Effect.die(`no ${name} tool`) + return tool.init.pipe( + Effect.flatMap((contribution) => contribution.handler(params)), + Effect.provide(toolCallServices), + ) +} diff --git a/examples/alchemy-cloudflare/test/WorkspaceFileSystem.test.ts b/examples/alchemy-cloudflare/test/WorkspaceFileSystem.test.ts new file mode 100644 index 0000000..f5687ae --- /dev/null +++ b/examples/alchemy-cloudflare/test/WorkspaceFileSystem.test.ts @@ -0,0 +1,154 @@ +/** + * The workspace FileSystem against an in-memory Computer that fails the way the real one does: with a + * code such as `ENOENT` in a result value. fold's disk skill loader then runs over it unchanged. + */ +import { it } from '@effect/vitest' +import { makeDiskSkillSource } from '@humanlayer/fold-agent/skills' +import { fileTools } from '@humanlayer/fold-agent/tools/files' +import type { FoldTool } from '@humanlayer/fold-core' +import { Effect, FileSystem, Path, type PlatformError } from 'effect' +import { expect } from 'vitest' + +import type { FileInfo, ComputerResult } from '../src/computer/Contract' +import { type ComputerFiles, workspaceFileSystem } from '../src/WorkspaceFileSystem' +import { callTool } from './ToolCalls' + +type Node = { readonly type: 'Directory' } | { readonly type: 'File'; readonly content: Uint8Array } + +const parentOf = (path: string) => path.slice(0, path.lastIndexOf('/')) || '/' + +const fakeComputer = (): ComputerFiles => { + const nodes = new Map([['/', { type: 'Directory' }]]) + const ok = (value: A): Effect.Effect> => Effect.succeed({ ok: true, value }) + const fail = (code: string, path: string): Effect.Effect> => + Effect.succeed({ ok: false, code, message: `${code}: ${path}` }) + const children = (path: string) => + [...nodes.keys()] + .filter((child) => child !== '/' && parentOf(child) === path) + .map((child) => child.slice(path.length).replace(/^\//, '')) + + return { + readFile: (path) => { + const node = nodes.get(path) + if (node === undefined) return fail('ENOENT', path) + return node.type === 'File' ? ok(node.content) : fail('EISDIR', path) + }, + writeFile: (path, content) => { + if (nodes.get(parentOf(path))?.type !== 'Directory') return fail('ENOENT', path) + nodes.set(path, { type: 'File', content: new TextEncoder().encode(content) }) + return ok(null) + }, + mkdir: (path, recursive) => { + if (nodes.has(path)) return recursive ? ok(null) : fail('EEXIST', path) + if (!nodes.has(parentOf(path)) && !recursive) return fail('ENOENT', path) + for (let dir = path; !nodes.has(dir); dir = parentOf(dir)) nodes.set(dir, { type: 'Directory' }) + return ok(null) + }, + rm: (path, recursive, force) => { + if (!nodes.has(path)) return force ? ok(null) : fail('ENOENT', path) + if (children(path).length > 0 && !recursive) return fail('ENOTEMPTY', path) + for (const key of nodes.keys()) if (key === path || key.startsWith(`${path}/`)) nodes.delete(key) + return ok(null) + }, + stat: (path) => { + const node = nodes.get(path) + if (node === undefined) return fail('ENOENT', path) + const info: FileInfo = { + type: node.type, + size: node.type === 'File' ? node.content.length : 0, + mtime: 0, + mode: 0o644, + inode: 1, + } + return ok(info) + }, + readdir: (path) => { + const node = nodes.get(path) + if (node === undefined) return fail('ENOENT', path) + return node.type === 'Directory' ? ok(children(path).sort()) : fail('ENOTDIR', path) + }, + } +} + +/** Call one of fold's tools with the workspace as its FileSystem. */ +const call = (fs: FileSystem.FileSystem, tools: ReadonlyArray, name: string, params: unknown) => + callTool(tools, name, params).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provide(Path.layer)) + +const reasonTag = (error: PlatformError.PlatformError) => error.reason._tag + +it.effect('writes, reads, lists, and removes files', () => + Effect.gen(function* () { + const fs = workspaceFileSystem(fakeComputer()) + + yield* fs.makeDirectory('/workspace/app/src', { recursive: true }) + yield* fs.writeFileString('/workspace/app/src/index.ts', 'export {}\n') + + expect(yield* fs.readFileString('/workspace/app/src/index.ts')).toBe('export {}\n') + expect(yield* fs.readDirectory('/workspace/app')).toEqual(['src']) + expect((yield* fs.stat('/workspace/app/src')).type).toBe('Directory') + expect(yield* fs.exists('/workspace/app/src/index.ts')).toBe(true) + expect(yield* fs.realPath('/workspace/app/src/index.ts')).toBe('/workspace/app/src/index.ts') + + yield* fs.remove('/workspace/app', { recursive: true }) + expect(yield* fs.exists('/workspace/app')).toBe(false) + }), +) + +it.effect("maps the Computer's error codes to platform errors that keep the code", () => + Effect.gen(function* () { + const fs = workspaceFileSystem(fakeComputer()) + yield* fs.makeDirectory('/workspace') + + const missing = yield* Effect.flip(fs.readFile('/workspace/nope.txt')) + expect(reasonTag(missing)).toBe('NotFound') + expect(missing.reason.cause).toEqual({ code: 'ENOENT' }) + + expect(reasonTag(yield* Effect.flip(fs.readFile('/workspace')))).toBe('BadResource') + expect(reasonTag(yield* Effect.flip(fs.access('/workspace/nope.txt')))).toBe('NotFound') + expect(reasonTag(yield* Effect.flip(fs.writeFileString('/nowhere/file.txt', '')))).toBe('NotFound') + expect(reasonTag(yield* Effect.flip(fs.makeDirectory('/workspace')))).toBe('AlreadyExists') + }), +) + +it.effect("fold's skill loader finds a repo's skills on the workspace", () => + Effect.gen(function* () { + const fs = workspaceFileSystem(fakeComputer()) + yield* fs.makeDirectory('/workspace/app/.claude/skills/deploy', { recursive: true }) + yield* fs.writeFileString( + '/workspace/app/.claude/skills/deploy/SKILL.md', + '---\nname: deploy\ndescription: Ship the app.\n---\nRun the deploy.\n', + ) + + const skills = yield* makeDiskSkillSource({ + cwd: '/workspace', + home: '/root', + extraPaths: ['/workspace/app/.claude/skills', '/workspace/app/.agents/skills'], + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) + + expect(yield* skills.list).toEqual([{ name: 'deploy', description: 'Ship the app.' }]) + expect((yield* skills.load('deploy')).content).toBe('Run the deploy.') + }), +) + +it.effect("fold's file tools write, edit, patch and read files on the workspace", () => + Effect.gen(function* () { + const fs = workspaceFileSystem(fakeComputer()) + const tools = fileTools({ cwd: '/workspace' }) + yield* fs.makeDirectory('/workspace/app', { recursive: true }) + + yield* call(fs, tools, 'write', { path: 'app/notes.md', content: 'one\ntwo\n' }) + yield* call(fs, tools, 'edit', { path: 'app/notes.md', edits: [{ oldText: 'two', newText: 'three' }] }) + yield* call(fs, tools, 'apply_patch', { + patch_text: '*** Begin Patch\n*** Add File: app/new/added.md\n+added\n*** End Patch', + }) + + expect(yield* fs.readFileString('/workspace/app/notes.md')).toBe('one\nthree\n') + expect(yield* fs.readFileString('/workspace/app/new/added.md')).toBe('added\n') + expect(yield* call(fs, tools, 'read', { path: 'app/notes.md' })).toMatchObject({ + text: expect.stringContaining('three'), + }) + expect(yield* Effect.flip(call(fs, tools, 'read', { path: 'app/missing.md' }))).toMatchObject({ + text: expect.stringContaining('file not found'), + }) + }), +) diff --git a/package.json b/package.json index 383a80c..1f6ded9 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "catalog": { "@kitlangton/terminal-control": "0.3.1", "alchemy": "2.0.0-beta.76", + "@cloudflare/computer": "https://github.com/K-Mistele/computer/releases/download/fold-91611593a1d7/cloudflare-computer-0.3.1.tgz", + "@platformatic/vfs": "0.4.0", "@cloudflare/workers-types": "5.20260929.1", "@opentui/core": "0.4.3", "@opentui/keymap": "0.4.3", diff --git a/packages/fold-agent/package.json b/packages/fold-agent/package.json index 04b5f77..644fa2e 100644 --- a/packages/fold-agent/package.json +++ b/packages/fold-agent/package.json @@ -7,7 +7,9 @@ "module": "src/index.ts", "types": "src/index.ts", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./skills": "./src/Skills/DiskSkills.ts", + "./tools/files": "./src/Tools/FileTools.ts" }, "scripts": { "typecheck": "tsc --noEmit", diff --git a/packages/fold-agent/src/Tools/CodingTools.ts b/packages/fold-agent/src/Tools/CodingTools.ts index 20d295c..881635a 100644 --- a/packages/fold-agent/src/Tools/CodingTools.ts +++ b/packages/fold-agent/src/Tools/CodingTools.ts @@ -6,12 +6,9 @@ */ import type { FoldTool } from '@humanlayer/fold-core' -import { applyPatchTool } from './ApplyPatchTool' import { bashTool, type BashToolOptions } from './BashTool' -import { editTool } from './EditTool' -import { readTool } from './ReadTool' +import { fileTools } from './FileTools' import { webTools, type WebToolsOptions } from './WebTools' -import { writeTool } from './WriteTool' /** Options for {@link codingTools}: the shared cwd plus bash output-spill configuration. */ export type CodingToolsOptions = Pick & @@ -22,10 +19,7 @@ export type CodingToolsOptions = Pick => [ - readTool(options), - writeTool(options), - editTool(options), - applyPatchTool(options), + ...fileTools(options), bashTool(options), ...webTools(options), ] diff --git a/packages/fold-agent/src/Tools/FileTools.ts b/packages/fold-agent/src/Tools/FileTools.ts new file mode 100644 index 0000000..27111f4 --- /dev/null +++ b/packages/fold-agent/src/Tools/FileTools.ts @@ -0,0 +1,24 @@ +/** + * This file bundles the file tools - read, write, edit, and apply_patch - over the ambient FileSystem. + * It is also the `@humanlayer/fold-agent/tools/files` entry, for hosts such as Cloudflare Workers that + * want these tools without the rest of fold-agent (bash, providers, config). + */ +import type { FoldTool } from '@humanlayer/fold-core' + +import { applyPatchTool } from './ApplyPatchTool' +import { editTool } from './EditTool' +import { readTool } from './ReadTool' +import { writeTool } from './WriteTool' + +export { applyPatchTool } from './ApplyPatchTool' +export { editTool } from './EditTool' +export { readTool } from './ReadTool' +export { writeTool } from './WriteTool' + +/** read, write, edit, and apply_patch, resolving relative paths against `cwd`. */ +export const fileTools = (options?: { readonly cwd?: string }): ReadonlyArray => [ + readTool(options), + writeTool(options), + editTool(options), + applyPatchTool(options), +] diff --git a/packages/fold-agent/src/Tools/ReadTool.ts b/packages/fold-agent/src/Tools/ReadTool.ts index 1d90f07..c16d08a 100644 --- a/packages/fold-agent/src/Tools/ReadTool.ts +++ b/packages/fold-agent/src/Tools/ReadTool.ts @@ -82,7 +82,7 @@ const decodeTextFile = (path: string, bytes: Uint8Array): Effect.Effect new TextDecoder('utf-8', { fatal: true }).decode(bytes), + try: () => new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(bytes), catch: () => ToolResultFailure.make({ text: `Cannot read file because it is not valid UTF-8: ${path}` }), }) } diff --git a/packages/fold-agent/src/index.ts b/packages/fold-agent/src/index.ts index fa20eef..01e35e1 100644 --- a/packages/fold-agent/src/index.ts +++ b/packages/fold-agent/src/index.ts @@ -41,6 +41,7 @@ export * from './Tools/ApplyPatchTool' export * from './Tools/BashTool' export * from './Tools/CodingTools' export * from './Tools/EditTool' +export * from './Tools/FileTools' export * from './Tools/Image/Mime' export * from './Tools/Image/Process' export * from './Tools/ReadTool' diff --git a/packages/fold-core/src/Api/StartSession.ts b/packages/fold-core/src/Api/StartSession.ts index 17b9132..0ff0958 100644 --- a/packages/fold-core/src/Api/StartSession.ts +++ b/packages/fold-core/src/Api/StartSession.ts @@ -301,6 +301,20 @@ const providedPlatformServices: Effect.Effect> ) /** Lower the event log descriptor to its EventLog layer. */ +/** + * Run the tool's handler with the session's platform services over whatever is in scope where the turn + * runs. Effect AI merges the turn's services over the toolkit's, so a host whose request handling carries + * its own FileSystem (Alchemy's Worker runtime provides Node's) would otherwise shadow the session's. + */ +const withPlatformServices = ( + contribution: SessionToolContribution, + platformServices: Context.Context, +): SessionToolContribution => ({ + ...contribution, + handler: (params) => + contribution.handler(params).pipe(Effect.updateContext((context) => Context.merge(context, platformServices))), +}) + const eventLogLayerFor = ( log: FoldEventLog, platformServices: Context.Context, @@ -408,7 +422,9 @@ const assembleSessionGraph = (options: { (tool) => tool.init.pipe( Effect.provideContext(platformServices), - Effect.map((contribution) => toolContributions.set(tool, contribution)), + Effect.map((contribution) => + toolContributions.set(tool, withPlatformServices(contribution, platformServices)), + ), ), { discard: true }, ).pipe(Effect.asVoid) diff --git a/packages/fold-core/test/Api/StartSession.vi.test.ts b/packages/fold-core/test/Api/StartSession.vi.test.ts index 52bfbd6..75a7f05 100644 --- a/packages/fold-core/test/Api/StartSession.vi.test.ts +++ b/packages/fold-core/test/Api/StartSession.vi.test.ts @@ -388,3 +388,31 @@ it.effect('tool handlers that declare platform services read the FileSystem the ), ), ) + +it.effect('tool handlers keep the FileSystem the session started with when a turn runs under another', () => + Effect.gen(function* () { + const { model } = yield* scriptedModel(gptActiveModel, [ + toolCallTurn([{ id: 'provider-call-1', name: 'read_note', params: {} }]), + textTurn('done'), + ]) + const readNote = defineTool({ + name: 'read_note', + description: 'Reads the note file.', + success: Schema.String, + dependencies: platformToolDependencies, + handler: () => + Effect.flatMap(FileSystem.FileSystem, (fs) => fs.readFileString('/note.txt')).pipe(Effect.orDie), + }) + const noteFileSystem = (note: string) => FileSystem.makeNoop({ readFileString: () => Effect.succeed(note) }) + + const session = yield* startSession({ agent: defineAgent({ model, tools: [readNote] }) }).pipe( + Effect.provideService(FileSystem.FileSystem, noteFileSystem('from the session')), + ) + // A host whose request handling carries its own FileSystem, as Alchemy's Worker runtime does. + yield* session + .send('read the note') + .pipe(Effect.provideService(FileSystem.FileSystem, noteFileSystem('from the turn'))) + + expect(firstToolResultPart(yield* session.entries).result).toBe('from the session') + }).pipe(Effect.scoped), +)