diff --git a/bun.lock b/bun.lock index 338f7f02342e..fd7bd2a28917 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,6 @@ { "lockfileVersion": 1, - "configVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "opencode", @@ -319,6 +319,7 @@ "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@ff-labs/fff-bun": "0.9.4", + "@libsql/client": "^0.17.0", "@lydell/node-pty": "catalog:", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", @@ -787,6 +788,7 @@ "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", + "@opencode-ai/temporal": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:", }, @@ -937,6 +939,22 @@ "vite": "catalog:", }, }, + "packages/temporal": { + "name": "@opencode-ai/temporal", + "version": "0.0.1", + "dependencies": { + "@opencode-ai/core": "workspace:*", + "@opencode-ai/llm": "workspace:*", + "@temporalio/activity": "^1.21.0", + "@temporalio/client": "^1.21.0", + "@temporalio/worker": "^1.21.0", + "@temporalio/workflow": "^1.21.0", + "effect": "catalog:", + }, + "devDependencies": { + "@temporalio/testing": "1.21.1", + }, + }, "packages/tui": { "name": "@opencode-ai/tui", "version": "1.18.31", @@ -1662,6 +1680,10 @@ "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], + "@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="], + + "@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.12.0", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.12.0" } }, "sha512-BUE55Rew3oMwBzwCwmUnV+Oxk51V3xolM39Ts6kGiBXNELjujiESwk9qc99JRr6cVrj3OTd9MJ5zQ2fpn+jy6g=="], "@hey-api/codegen-core": ["@hey-api/codegen-core@0.5.5", "", { "dependencies": { "@hey-api/types": "0.1.2", "ansi-colors": "4.1.3", "c12": "3.3.3", "color-support": "1.1.3" }, "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-f2ZHucnA2wBGAY8ipB4wn/mrEYW+WUxU2huJmUvfDO6AE2vfILSHeF3wCO39Pz4wUYPoAWZByaauftLrOfC12Q=="], @@ -1748,10 +1770,40 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], + "@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="], "@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="], + "@jsonjoy.com/base64": ["@jsonjoy.com/base64@1.1.2", "", { "peerDependencies": { "tslib": "2" } }, "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA=="], + + "@jsonjoy.com/buffers": ["@jsonjoy.com/buffers@17.67.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw=="], + + "@jsonjoy.com/codegen": ["@jsonjoy.com/codegen@1.0.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g=="], + + "@jsonjoy.com/fs-core": ["@jsonjoy.com/fs-core@4.67.0", "", { "dependencies": { "@jsonjoy.com/fs-node-builtins": "4.67.0", "@jsonjoy.com/fs-node-utils": "4.67.0", "thingies": "^2.5.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-+QOYAGujzm86pKcX4N0JQ1YcLEjypr/I+wmQRxwI8W7K0QXKSi8vQVC2oKQGjcfbHq02JvCQijypfvyhcTz7uw=="], + + "@jsonjoy.com/fs-fsa": ["@jsonjoy.com/fs-fsa@4.67.0", "", { "dependencies": { "@jsonjoy.com/fs-core": "4.67.0", "@jsonjoy.com/fs-node-builtins": "4.67.0", "@jsonjoy.com/fs-node-utils": "4.67.0", "thingies": "^2.5.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-2jCnH5ofKXb+6vcl8dQArO1Gb4FT7vLbMGVnNim0ekXkY78DPVXZvJ8DQp9WLbqP/G/gxiPVz/DOMoOCD4BqqQ=="], + + "@jsonjoy.com/fs-node": ["@jsonjoy.com/fs-node@4.67.0", "", { "dependencies": { "@jsonjoy.com/fs-core": "4.67.0", "@jsonjoy.com/fs-node-builtins": "4.67.0", "@jsonjoy.com/fs-node-utils": "4.67.0", "@jsonjoy.com/fs-print": "4.67.0", "@jsonjoy.com/fs-snapshot": "4.67.0", "glob-to-regex.js": "^1.0.0", "thingies": "^2.5.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-EZ/mSrxRYphDbyll1VuDW0mvj/USoe2M5sxT2nYqyYyvdxsIsijhJOiygBHAaj86Eqd/Kb9ukkwXjBisRTk1tg=="], + + "@jsonjoy.com/fs-node-builtins": ["@jsonjoy.com/fs-node-builtins@4.67.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-os7Cft1EudH0xZs5Kh5/qHI72jk8DMQ1561elyHkHd9c9xaa4wOfK1iMh4JB9y+kXtpXjEnT4cjHqqc7A3X3lA=="], + + "@jsonjoy.com/fs-node-to-fsa": ["@jsonjoy.com/fs-node-to-fsa@4.67.0", "", { "dependencies": { "@jsonjoy.com/fs-fsa": "4.67.0", "@jsonjoy.com/fs-node-builtins": "4.67.0", "@jsonjoy.com/fs-node-utils": "4.67.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-5e6WTnLhw0Q5mPEACOsA7h2BA++N1FCSmhXRX5gone8Le4fsqcpgpukqW5hWneLfzIr5AOEliu0PyokdYx3Bwg=="], + + "@jsonjoy.com/fs-node-utils": ["@jsonjoy.com/fs-node-utils@4.67.0", "", { "dependencies": { "@jsonjoy.com/fs-node-builtins": "4.67.0", "glob-to-regex.js": "^1.0.1" }, "peerDependencies": { "tslib": "2" } }, "sha512-ZcCPh4jvUqYxAgu4lLe+6eQbijxEkZIrCq0Jhh669o3v3zrCn8N4YAFod3zllZtSHhwb+YbER29LUW/SdNrP7Q=="], + + "@jsonjoy.com/fs-print": ["@jsonjoy.com/fs-print@4.67.0", "", { "dependencies": { "@jsonjoy.com/fs-node-utils": "4.67.0", "tree-dump": "^1.1.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-xBhay3ayVlFeScafZy+7jyH0+I6MLomaL+2nn/KWirgjwh58w8+u44j7oSvE8EQ2NLF63B1eVc65awmbs/39Iw=="], + + "@jsonjoy.com/fs-snapshot": ["@jsonjoy.com/fs-snapshot@4.67.0", "", { "dependencies": { "@jsonjoy.com/buffers": "^17.65.0", "@jsonjoy.com/fs-node-utils": "4.67.0", "@jsonjoy.com/json-pack": "^17.65.0", "@jsonjoy.com/util": "^17.65.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-wn5c6Qx0iVX1dV74l5WOCIPH9lz3xU6A0QG45n0c53athmmr7Z+XTg0YOndME88g/5LjcqwiSSYD9aSr9+goYg=="], + + "@jsonjoy.com/json-pack": ["@jsonjoy.com/json-pack@1.21.0", "", { "dependencies": { "@jsonjoy.com/base64": "^1.1.2", "@jsonjoy.com/buffers": "^1.2.0", "@jsonjoy.com/codegen": "^1.0.0", "@jsonjoy.com/json-pointer": "^1.0.2", "@jsonjoy.com/util": "^1.9.0", "hyperdyperid": "^1.2.0", "thingies": "^2.5.0", "tree-dump": "^1.1.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg=="], + + "@jsonjoy.com/json-pointer": ["@jsonjoy.com/json-pointer@1.0.2", "", { "dependencies": { "@jsonjoy.com/codegen": "^1.0.0", "@jsonjoy.com/util": "^1.9.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg=="], + + "@jsonjoy.com/util": ["@jsonjoy.com/util@1.9.0", "", { "dependencies": { "@jsonjoy.com/buffers": "^1.0.0", "@jsonjoy.com/codegen": "^1.0.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ=="], + "@jsx-email/all": ["@jsx-email/all@2.2.3", "", { "dependencies": { "@jsx-email/body": "1.0.2", "@jsx-email/button": "1.0.4", "@jsx-email/column": "1.0.3", "@jsx-email/container": "1.0.2", "@jsx-email/font": "1.0.3", "@jsx-email/head": "1.0.2", "@jsx-email/heading": "1.0.2", "@jsx-email/hr": "1.0.2", "@jsx-email/html": "1.0.2", "@jsx-email/img": "1.0.2", "@jsx-email/link": "1.0.2", "@jsx-email/markdown": "2.0.4", "@jsx-email/preview": "1.0.2", "@jsx-email/render": "1.1.1", "@jsx-email/row": "1.0.2", "@jsx-email/section": "1.0.2", "@jsx-email/tailwind": "2.4.4", "@jsx-email/text": "1.0.2" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-OBvLe/hVSQc0LlMSTJnkjFoqs3bmxcC4zpy/5pT5agPCSKMvAKQjzmsc2xJ2wO73jSpRV1K/g38GmvdCfrhSoQ=="], "@jsx-email/body": ["@jsx-email/body@1.0.2", "", { "peerDependencies": { "react": "^18.2.0" } }, "sha512-NjR2tgLH4XGfGkm+O8kcVwi9MBqZsXZCLlmk3HlMux3/n/+a5zB+yhJqXWZBJl2i+6cSF+E2O6hK11ekyK9WWQ=="], @@ -1802,6 +1854,32 @@ "@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="], + "@libsql/client": ["@libsql/client@0.17.4", "", { "dependencies": { "@libsql/core": "^0.17.4", "@libsql/hrana-client": "^0.10.0", "js-base64": "^3.7.5", "libsql": "^0.5.28", "promise-limit": "^2.7.0" } }, "sha512-lYayFWasDV78A+TjlEhr6ubb3odBV6OHjb+wdp8VQcyWWAEIjuwbCHaraEUS4m4yWoo0BvZo96It4VdzZRmRWw=="], + + "@libsql/core": ["@libsql/core@0.17.4", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-LqF9gIvnJ38nmAH1y/ChizHqDO/MO1wLgA96XrraulEEbqXxLjleSH92YWTolbuJKgPUmGu4aJk9W3UnAcxLOQ=="], + + "@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.5.29", "", { "os": "darwin", "cpu": "arm64" }, "sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A=="], + + "@libsql/darwin-x64": ["@libsql/darwin-x64@0.5.29", "", { "os": "darwin", "cpu": "x64" }, "sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ=="], + + "@libsql/hrana-client": ["@libsql/hrana-client@0.10.0", "", { "dependencies": { "@libsql/isomorphic-ws": "^0.1.5", "js-base64": "^3.7.5" } }, "sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw=="], + + "@libsql/isomorphic-ws": ["@libsql/isomorphic-ws@0.1.5", "", { "dependencies": { "@types/ws": "^8.5.4", "ws": "^8.13.0" } }, "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg=="], + + "@libsql/linux-arm-gnueabihf": ["@libsql/linux-arm-gnueabihf@0.5.29", "", { "os": "linux", "cpu": "arm" }, "sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ=="], + + "@libsql/linux-arm-musleabihf": ["@libsql/linux-arm-musleabihf@0.5.29", "", { "os": "linux", "cpu": "arm" }, "sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg=="], + + "@libsql/linux-arm64-gnu": ["@libsql/linux-arm64-gnu@0.5.29", "", { "os": "linux", "cpu": "arm64" }, "sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w=="], + + "@libsql/linux-arm64-musl": ["@libsql/linux-arm64-musl@0.5.29", "", { "os": "linux", "cpu": "arm64" }, "sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg=="], + + "@libsql/linux-x64-gnu": ["@libsql/linux-x64-gnu@0.5.29", "", { "os": "linux", "cpu": "x64" }, "sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg=="], + + "@libsql/linux-x64-musl": ["@libsql/linux-x64-musl@0.5.29", "", { "os": "linux", "cpu": "x64" }, "sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w=="], + + "@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.5.29", "", { "os": "win32", "cpu": "x64" }, "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg=="], + "@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="], "@lydell/node-pty": ["@lydell/node-pty@1.2.0-beta.12", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", "@lydell/node-pty-linux-arm64": "1.2.0-beta.12", "@lydell/node-pty-linux-x64": "1.2.0-beta.12", "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", "@lydell/node-pty-win32-x64": "1.2.0-beta.12" } }, "sha512-qIK890UwPupoj07osVvgOIa++1mxeHbcGry4PKRHhNVNs81V2SCG34eJr46GybiOmBtc8Sj5PB1/GGM5PL549g=="], @@ -1856,6 +1934,8 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="], + "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], "@nodable/entities": ["@nodable/entities@2.1.1", "", {}, "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg=="], @@ -2006,6 +2086,8 @@ "@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"], + "@opencode-ai/temporal": ["@opencode-ai/temporal@workspace:packages/temporal"], + "@opencode-ai/tui": ["@opencode-ai/tui@workspace:packages/tui"], "@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"], @@ -2706,7 +2788,7 @@ "@solidjs/router": ["@solidjs/router@0.15.4", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ=="], - "@solidjs/start": ["@solidjs/start@https://pkg.pr.new/@solidjs/start@dfb2020", { "dependencies": { "@babel/core": "^7.28.3", "@babel/traverse": "^7.28.3", "@babel/types": "^7.28.5", "@solidjs/meta": "^0.29.4", "@tanstack/server-functions-plugin": "1.134.5", "@types/babel__traverse": "^7.28.0", "@types/micromatch": "^4.0.9", "cookie-es": "^2.0.0", "defu": "^6.1.4", "error-stack-parser": "^2.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.25.3", "fast-glob": "^3.3.3", "h3": "npm:h3@2.0.1-rc.4", "html-to-image": "^1.11.13", "micromatch": "^4.0.8", "path-to-regexp": "^8.2.0", "pathe": "^2.0.3", "radix3": "^1.1.2", "seroval": "^1.3.2", "seroval-plugins": "^1.2.1", "shiki": "^1.26.1", "solid-js": "^1.9.9", "source-map-js": "^1.2.1", "srvx": "^0.9.1", "terracotta": "^1.0.6", "vite": "7.1.10", "vite-plugin-solid": "^2.11.9", "vitest": "^4.0.10" } }, "sha512-7JjjA49VGNOsMRI8QRUhVudZmv0CnJ18SliSgK1ojszs/c3ijftgVkzvXdkSLN4miDTzbkXewf65D6ZBo6W+GQ=="], + "@solidjs/start": ["@solidjs/start@https://pkg.pr.new/@solidjs/start@dfb2020", { "dependencies": { "@babel/core": "^7.28.3", "@babel/traverse": "^7.28.3", "@babel/types": "^7.28.5", "@solidjs/meta": "^0.29.4", "@tanstack/server-functions-plugin": "1.134.5", "@types/babel__traverse": "^7.28.0", "@types/micromatch": "^4.0.9", "cookie-es": "^2.0.0", "defu": "^6.1.4", "error-stack-parser": "^2.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.25.3", "fast-glob": "^3.3.3", "h3": "npm:h3@2.0.1-rc.4", "html-to-image": "^1.11.13", "micromatch": "^4.0.8", "path-to-regexp": "^8.2.0", "pathe": "^2.0.3", "radix3": "^1.1.2", "seroval": "^1.3.2", "seroval-plugins": "^1.2.1", "shiki": "^1.26.1", "solid-js": "^1.9.9", "source-map-js": "^1.2.1", "srvx": "^0.9.1", "terracotta": "^1.0.6", "vite": "7.1.10", "vite-plugin-solid": "^2.11.9", "vitest": "^4.0.10" } }], "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], @@ -2738,8 +2820,38 @@ "@stripe/stripe-js": ["@stripe/stripe-js@8.6.1", "", {}, "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA=="], + "@swc/core": ["@swc/core@1.15.47", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.27" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.47", "@swc/core-darwin-x64": "1.15.47", "@swc/core-linux-arm-gnueabihf": "1.15.47", "@swc/core-linux-arm64-gnu": "1.15.47", "@swc/core-linux-arm64-musl": "1.15.47", "@swc/core-linux-ppc64-gnu": "1.15.47", "@swc/core-linux-s390x-gnu": "1.15.47", "@swc/core-linux-x64-gnu": "1.15.47", "@swc/core-linux-x64-musl": "1.15.47", "@swc/core-win32-arm64-msvc": "1.15.47", "@swc/core-win32-ia32-msvc": "1.15.47", "@swc/core-win32-x64-msvc": "1.15.47" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg=="], + + "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.47", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA=="], + + "@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.47", "", { "os": "darwin", "cpu": "x64" }, "sha512-leTi7Rx3KF4zcC637iqWgk9SoV8VXAD8ppQYXsep63px5A/UftOcxLN1pmr8Z1si/YvX90ompP/rHgpYkgwXWg=="], + + "@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.47", "", { "os": "linux", "cpu": "arm" }, "sha512-hBqHuoWKKIsKmDBn9qVeWqj5GWZhtlcczVaqQmNRXsDfq+voR5CxKRfamA367QjJXtceYuliLFfEL8QsskRM2g=="], + + "@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.47", "", { "os": "linux", "cpu": "arm64" }, "sha512-TBxvRz+B4K205TWHHZxWVxkC2RFNP/Mz3PNcECBos5PsKwxjg3QSJzdoebr0VCf0Bfh8HOPldKxAP/8XkFe9gA=="], + + "@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.47", "", { "os": "linux", "cpu": "arm64" }, "sha512-3Yu3Uq/VgytqsPjTMbkPU1ExADytbdWbruJYhA584E9jrpE2Ki+R6VVPoZCeAVk1Cb7QxcRTgblw6bSa6a/R+w=="], + + "@swc/core-linux-ppc64-gnu": ["@swc/core-linux-ppc64-gnu@1.15.47", "", { "os": "linux", "cpu": "ppc64" }, "sha512-wfdMi5IaOaNtmh2/6geRoxIdNfqylUZFdtzTKS655y1axWfIWyx7As74vv0wVdjeCIZ3WmCI9odDd4rUttXOSQ=="], + + "@swc/core-linux-s390x-gnu": ["@swc/core-linux-s390x-gnu@1.15.47", "", { "os": "linux", "cpu": "s390x" }, "sha512-3hHYBY0yx8Ez7GMRrkhXHQzMdR5IZA6Wq5Ee4svlgwvSECLpnAJ9+0AimEGUFDvuLwE7nV/2+PYe8+Nm4rvNcQ=="], + + "@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.47", "", { "os": "linux", "cpu": "x64" }, "sha512-TjfhjgP/jGCfFHYC3JQPhJA1HwErbIJ9JfREDc1KNkvY6P0LodCgKVIlQ5deeTbkG7ih3bF5PHJLuLpaZjdRyQ=="], + + "@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.47", "", { "os": "linux", "cpu": "x64" }, "sha512-CQpS8Ge/avfjZd0UEwG/sds83Uu32deQXcV1Jo3jD0mmvQQqtYAjpsDZXugmheeAwmt+YIuoVtVHro8LMYHqsQ=="], + + "@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.47", "", { "os": "win32", "cpu": "arm64" }, "sha512-0W8IKHsUTYiT7G2RqtOoVWk+89yzZikIiDUb/sCK6BmQDBhN91hQSfyUtW12jhEWLzYgcfmisfsZrmZE+84U1A=="], + + "@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.47", "", { "os": "win32", "cpu": "ia32" }, "sha512-ZIp49d2Z4/ka2jO9otOg4hDvTdPmp86kVOgS2M5FCPI7eKKZ1W0boxWn+8XeZrfERtFGW0AlMRm4JhlJa7l3NA=="], + + "@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.47", "", { "os": "win32", "cpu": "x64" }, "sha512-2h8Iek95vnixkBRCo+H8p09+Q5ll2NgSMFrWTy0iKt7+/t+8/T5mBpiT6c0ZxSS7wcWjwZ9sGZkK70tTSYHdDw=="], + + "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], + "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], + "@swc/types": ["@swc/types@0.1.28", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw=="], + "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], "@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="], @@ -2786,6 +2898,24 @@ "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="], + "@temporalio/activity": ["@temporalio/activity@1.21.1", "", { "dependencies": { "@temporalio/client": "1.21.1", "@temporalio/common": "1.21.1" } }, "sha512-UjA1d4ugL3pRXElwnggtVAMe3lppUPzYpBPmpDLTXfOtxEXPTK1x+8OC2jrZY7qmvpHkOdoo86S+UYFzJkMk/A=="], + + "@temporalio/client": ["@temporalio/client@1.21.1", "", { "dependencies": { "@grpc/grpc-js": "^1.12.4", "@temporalio/common": "1.21.1", "@temporalio/proto": "1.21.1", "abort-controller": "^3.0.0", "long": "^5.2.3", "nexus-rpc": "^0.0.2", "uuid": "^11.1.0" } }, "sha512-rdZAh20wzI5i/SyS46Nv9mE6t2KkS9DTrB57HEMLsq6eqm58Nc6la0WQKJGsUNkmN98KKirByidZ5D/ik3kiQw=="], + + "@temporalio/common": ["@temporalio/common@1.21.1", "", { "dependencies": { "@temporalio/proto": "1.21.1", "long": "^5.2.3", "ms": "3.0.0-canary.1", "nexus-rpc": "^0.0.2", "proto3-json-serializer": "^2.0.0" } }, "sha512-8Pis59xYLrGu6GfkkWvrYWkSPEvo8lBBILsR6gBHGbymNlEc0ynylRXqPmRjwuLfYS7jHzxqjjO7cvXJQ/z2Fg=="], + + "@temporalio/core-bridge": ["@temporalio/core-bridge@1.21.1", "", { "dependencies": { "@grpc/grpc-js": "^1.12.4", "@temporalio/common": "1.21.1" } }, "sha512-gCy/6TFhcFAjFPRN1DeHSwAnXU380Jn/y6yG2dkSV+rYK0JRl66SE2NvdcAjzhoPO/twHEaHklbppojH0cUpUw=="], + + "@temporalio/nexus": ["@temporalio/nexus@1.21.1", "", { "dependencies": { "@temporalio/client": "1.21.1", "@temporalio/common": "1.21.1", "@temporalio/proto": "1.21.1", "long": "^5.2.3", "nexus-rpc": "^0.0.2" } }, "sha512-CIAoTt/WpSE0bn1mE9q5O6hU76q97W349e0FSrUuwQAYDXBy7jzFM6ZzYmm7S5Uk2tC1xrRCAjUGg1lMnHQppw=="], + + "@temporalio/proto": ["@temporalio/proto@1.21.1", "", { "dependencies": { "long": "^5.2.3", "protobufjs": "^7.6.4" } }, "sha512-eSHGrZ6CxbtjrAzxiMgKrWeDiBlWk6/JkIqsB1hrkPB6TQXC67Az8v0BL0Fj3ur8ktQZbaacON/xCDjTsvJyGw=="], + + "@temporalio/testing": ["@temporalio/testing@1.21.1", "", { "dependencies": { "@temporalio/activity": "1.21.1", "@temporalio/client": "1.21.1", "@temporalio/common": "1.21.1", "@temporalio/core-bridge": "1.21.1", "@temporalio/proto": "1.21.1", "@temporalio/worker": "1.21.1", "@temporalio/workflow": "1.21.1" } }, "sha512-vKYQZsa8EFE4Rvlh0v1Ry2B0+vnnpgfHarSadue+3ZUWf3cx28HGCseZEgHcaG299ChsjbADntKO6YjwlMv1Jw=="], + + "@temporalio/worker": ["@temporalio/worker@1.21.1", "", { "dependencies": { "@grpc/grpc-js": "^1.12.4", "@swc/core": "^1.3.102", "@temporalio/activity": "1.21.1", "@temporalio/client": "1.21.1", "@temporalio/common": "1.21.1", "@temporalio/core-bridge": "1.21.1", "@temporalio/nexus": "1.21.1", "@temporalio/proto": "1.21.1", "@temporalio/workflow": "1.21.1", "heap-js": "^2.6.0", "memfs": "^4.6.0", "nexus-rpc": "^0.0.2", "protobufjs": "^7.6.4", "rxjs": "^7.8.1", "source-map": "^0.7.4", "source-map-loader": "^5.0.0", "supports-color": "^8.1.1", "swc-loader": "^0.2.3", "unionfs": "^4.5.1", "webpack": "^5.108.4" } }, "sha512-ccXus6+w317tL+NsJXEYWpHFxcy0VDPfstmBzej0yZrkzWNvAkaWVGQbguKoLToDM8+DMaqklx1dX4gJnknR1g=="], + + "@temporalio/workflow": ["@temporalio/workflow@1.21.1", "", { "dependencies": { "@temporalio/common": "1.21.1", "@temporalio/proto": "1.21.1", "nexus-rpc": "^0.0.2" } }, "sha512-Tsoe9RnB0mL75DGVo3wJJrgTl+QnHYUysPjqRkzQdzgeKravN8RxE0HCfS3cYRZej3eEVwoDftgbo7/KR0NXWQ=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], @@ -3026,12 +3156,46 @@ "@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="], + "@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="], + + "@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="], + + "@webassemblyjs/helper-api-error": ["@webassemblyjs/helper-api-error@1.13.2", "", {}, "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ=="], + + "@webassemblyjs/helper-buffer": ["@webassemblyjs/helper-buffer@1.14.1", "", {}, "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA=="], + + "@webassemblyjs/helper-numbers": ["@webassemblyjs/helper-numbers@1.13.2", "", { "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA=="], + + "@webassemblyjs/helper-wasm-bytecode": ["@webassemblyjs/helper-wasm-bytecode@1.13.2", "", {}, "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA=="], + + "@webassemblyjs/helper-wasm-section": ["@webassemblyjs/helper-wasm-section@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/wasm-gen": "1.14.1" } }, "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw=="], + + "@webassemblyjs/ieee754": ["@webassemblyjs/ieee754@1.13.2", "", { "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw=="], + + "@webassemblyjs/leb128": ["@webassemblyjs/leb128@1.13.2", "", { "dependencies": { "@xtuc/long": "4.2.2" } }, "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw=="], + + "@webassemblyjs/utf8": ["@webassemblyjs/utf8@1.13.2", "", {}, "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ=="], + + "@webassemblyjs/wasm-edit": ["@webassemblyjs/wasm-edit@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/helper-wasm-section": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-opt": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1", "@webassemblyjs/wast-printer": "1.14.1" } }, "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ=="], + + "@webassemblyjs/wasm-gen": ["@webassemblyjs/wasm-gen@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg=="], + + "@webassemblyjs/wasm-opt": ["@webassemblyjs/wasm-opt@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1" } }, "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw=="], + + "@webassemblyjs/wasm-parser": ["@webassemblyjs/wasm-parser@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ=="], + + "@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="], + "@webcontainer/env": ["@webcontainer/env@1.1.1", "", {}, "sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng=="], "@webgpu/types": ["@webgpu/types@0.1.54", "", {}, "sha512-81oaalC8LFrXjhsczomEQ0u3jG+TqE6V9QHLA8GNZq/Rnot0KDugu3LhSYSlie8tSdooAN1Hov05asrUUp9qgg=="], "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], + "@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="], + + "@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="], + "@zip.js/zip.js": ["@zip.js/zip.js@2.7.62", "", {}, "sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA=="], "abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], @@ -3062,7 +3226,7 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], + "ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], @@ -3312,6 +3476,8 @@ "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="], + "chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="], "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], @@ -3658,10 +3824,16 @@ "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="], + "estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="], "estree-util-build-jsx": ["estree-util-build-jsx@3.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-walker": "^3.0.0" } }, "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ=="], @@ -3800,6 +3972,8 @@ "fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], + "fs-monkey": ["fs-monkey@1.1.0", "", {}, "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw=="], + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -3852,6 +4026,8 @@ "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "glob-to-regex.js": ["glob-to-regex.js@1.2.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ=="], + "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], @@ -3880,7 +4056,7 @@ "h3": ["h3@2.0.1-rc.4", "", { "dependencies": { "rou3": "^0.7.8", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-vZq8pEUp6THsXKXrUXX44eOqfChic2wVQ1GlSzQCBr7DeFBkfIZAo2WyNND4GSv54TAa0E4LYIK73WSPdgKUgw=="], - "happy-dom": ["happy-dom@20.12.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-7uMYJu2SEwwL8vVcKp0C0lnt6d2LSGGe+T+oY79PiCJNNSgFpbxW8n5KuzpDQvrU4mt+fYiK1+Jy7Z2v39YR6g=="], + "happy-dom": ["happy-dom@20.14.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-4bRh1KzRvKDnFNTlLhzT1RZTpkKhQbQDl9j+7GXszWsvuspYdo29k6OHRf4PwiM6oLb8r/pMWeYiJjkfod5AvQ=="], "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], @@ -3940,6 +4116,8 @@ "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + "heap-js": ["heap-js@2.7.1", "", {}, "sha512-EQfezRg0NCZGNlhlDR3Evrw1FVL2G3LhU7EgPoxufQKruNBSYA8MiRPHeWbU+36o+Fhel0wMwM+sLEiBAlNLJA=="], + "heap-snapshot-toolkit": ["heap-snapshot-toolkit@1.1.3", "", {}, "sha512-joThu2rEsDu8/l4arupRDI1qP4CZXNG+J6Wr348vnbLGSiBkwRdqZ6aOHl5BzEiC+Dc8OTbMlmWjD0lbXD5K2Q=="], "hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="], @@ -3982,6 +4160,8 @@ "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + "hyperdyperid": ["hyperdyperid@1.2.0", "", {}, "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A=="], + "i18n-iso-countries": ["i18n-iso-countries@7.14.0", "", { "dependencies": { "diacritics": "1.3.0" } }, "sha512-nXHJZYtNrfsi1UQbyRqm3Gou431elgLjKl//CYlnBGt5aTWdRPH1PiS2T/p/n8Q8LnqYqzQJik3Q7mkwvLokeg=="], "i18next": ["i18next@23.16.8", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg=="], @@ -4140,6 +4320,8 @@ "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], + "jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="], @@ -4222,6 +4404,8 @@ "leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="], + "libsql": ["libsql@0.5.29", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.29", "@libsql/darwin-x64": "0.5.29", "@libsql/linux-arm-gnueabihf": "0.5.29", "@libsql/linux-arm-musleabihf": "0.5.29", "@libsql/linux-arm64-gnu": "0.5.29", "@libsql/linux-arm64-musl": "0.5.29", "@libsql/linux-x64-gnu": "0.5.29", "@libsql/linux-x64-musl": "0.5.29", "@libsql/win32-x64-msvc": "0.5.29" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg=="], + "light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="], "lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], @@ -4254,6 +4438,8 @@ "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], + "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="], "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], @@ -4358,6 +4544,8 @@ "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], + "memfs": ["memfs@4.67.0", "", { "dependencies": { "@jsonjoy.com/fs-core": "4.67.0", "@jsonjoy.com/fs-fsa": "4.67.0", "@jsonjoy.com/fs-node": "4.67.0", "@jsonjoy.com/fs-node-builtins": "4.67.0", "@jsonjoy.com/fs-node-to-fsa": "4.67.0", "@jsonjoy.com/fs-node-utils": "4.67.0", "@jsonjoy.com/fs-print": "4.67.0", "@jsonjoy.com/fs-snapshot": "4.67.0", "@jsonjoy.com/json-pack": "^1.11.0", "@jsonjoy.com/util": "^1.9.0", "glob-to-regex.js": "^1.0.1", "thingies": "^2.5.0", "tree-dump": "^1.0.3", "tslib": "^2.0.0" } }, "sha512-yuwPWDAs2kfwpQFuFNQI2OkiJ4ZqkGvSFq2jbgC9pFPfgh1N1lPxJaBj5rHp9R1wfJlSzUQkFnSw6WYGPwBbRg=="], + "merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="], "merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="], @@ -4462,6 +4650,8 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "minimizer-webpack-plugin": ["minimizer-webpack-plugin@5.6.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "@minify-html/node": "*", "@swc/core": "*", "@swc/css": "*", "@swc/html": "*", "clean-css": "*", "cssnano": "*", "csso": "*", "esbuild": "*", "html-minifier-terser": "*", "lightningcss": "*", "postcss": "*", "uglify-js": "*", "webpack": "^5.1.0" }, "optionalPeers": ["@minify-html/node", "@swc/core", "@swc/css", "@swc/html", "clean-css", "cssnano", "csso", "esbuild", "html-minifier-terser", "lightningcss", "postcss", "uglify-js"] }, "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], "minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="], @@ -4514,8 +4704,12 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="], + "nexus-rpc": ["nexus-rpc@0.0.2", "", {}, "sha512-IWjIExdVYlmwXuzHdY/Q3lXCv1gbqoAXPazQhy2w4Xgtgha3H0OOujEESVPQcFUFMWm+pAk2gKnb57g8S41JZg=="], + "nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="], "nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="], @@ -4804,6 +4998,8 @@ "promise-call-limit": ["promise-call-limit@3.0.2", "", {}, "sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw=="], + "promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="], + "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], "promise.allsettled": ["promise.allsettled@1.0.7", "", { "dependencies": { "array.prototype.map": "^1.0.5", "call-bind": "^1.0.2", "define-properties": "^1.2.0", "es-abstract": "^1.22.1", "get-intrinsic": "^1.2.1", "iterate-value": "^1.0.2" } }, "sha512-hezvKvQQmsFkOdrZfYxUxkyxl8mgFQeT259Ajj9PXdbg9VzBCWrItOev72JyWxkCD5VSSqAeHmlN3tWx4DlmsA=="], @@ -4816,7 +5012,9 @@ "proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="], - "protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="], + "proto3-json-serializer": ["proto3-json-serializer@2.0.2", "", { "dependencies": { "protobufjs": "^7.2.5" } }, "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ=="], + + "protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], @@ -5002,6 +5200,8 @@ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + "s-js": ["s-js@0.4.9", "", {}, "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ=="], "safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="], @@ -5024,6 +5224,8 @@ "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + "schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], + "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], @@ -5132,10 +5334,12 @@ "sort-keys-length": ["sort-keys-length@1.0.1", "", { "dependencies": { "sort-keys": "^1.0.0" } }, "sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw=="], - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-loader": ["source-map-loader@5.0.0", "", { "dependencies": { "iconv-lite": "^0.6.3", "source-map-js": "^1.0.2" }, "peerDependencies": { "webpack": "^5.72.1" } }, "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA=="], + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], @@ -5240,12 +5444,14 @@ "superstruct": ["superstruct@1.0.4", "", {}, "sha512-7JpaAoX2NGyoFlI9NBh66BQXGONc+uE+MRS5i2iOBKuS4e+ccgMDjATgZldkah+33DakBxDHiss9kvUcGAO8UQ=="], - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "sury": ["sury@11.0.0-alpha.4", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="], + "swc-loader": ["swc-loader@0.2.7", "", { "dependencies": { "@swc/counter": "^0.1.3" }, "peerDependencies": { "@swc/core": "^1.2.147", "webpack": ">=2" } }, "sha512-nwYWw3Fh9ame3Rtm7StS9SBLpHRRnYcK7bnpF3UKZmesAK0gw2/ADvlURFAINmPvKtDLzp+GBiP9yLoEjg6S9w=="], + "system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="], "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], @@ -5274,6 +5480,8 @@ "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "thingies": ["thingies@2.6.1", "", { "peerDependencies": { "tslib": "^2" } }, "sha512-cV/CMGTK3M4MlnJ/0At6ismOw/A0EEniDNScajjz/Br3c1sqE72YD01rGpPTKwd27wAxI5Pr+6+0w8yofzFRYw=="], + "thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="], "thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="], @@ -5316,6 +5524,8 @@ "traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="], + "tree-dump": ["tree-dump@1.1.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA=="], + "tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="], "tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="], @@ -5340,6 +5550,8 @@ "tsscmp": ["tsscmp@1.0.6", "", {}, "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA=="], + "tsx": ["tsx@4.23.6", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-D/YYGUDqKlLvXhM5fBBbiENaGICxLfU4viHnZEkgmgplnDFa+Kczy34VV7AmLJgdzisv0I/J3zitfC26JH3GXg=="], + "tuf-js": ["tuf-js@4.1.0", "", { "dependencies": { "@tufjs/models": "4.1.0", "debug": "^4.4.3", "make-fetch-happen": "^15.0.1" } }, "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ=="], "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], @@ -5396,6 +5608,8 @@ "unifont": ["unifont@0.5.2", "", { "dependencies": { "css-tree": "^3.0.0", "ofetch": "^1.4.1", "ohash": "^2.0.0" } }, "sha512-LzR4WUqzH9ILFvjLAUU7dK3Lnou/qd5kD+IakBtBK4S15/+x2y9VX+DcWQv6s551R6W+vzwgVS6tFg3XggGBgg=="], + "unionfs": ["unionfs@4.6.0", "", { "dependencies": { "fs-monkey": "^1.0.0" } }, "sha512-fJAy3gTHjFi5S3TP5EGdjs/OUMFFvI/ady3T8qVuZfkv8Qi8prV/Q8BuFEgODJslhZTT2z2qdD2lGdee9qjEnA=="], + "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], @@ -5516,6 +5730,8 @@ "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], + "watchpack": ["watchpack@2.5.2", "", { "dependencies": { "graceful-fs": "^4.1.2" } }, "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg=="], + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], @@ -5526,6 +5742,8 @@ "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "webpack": ["webpack@5.109.2", "", { "dependencies": { "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.16.0", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.24.4", "es-module-lexer": "^2.1.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "graceful-fs": "^4.2.11", "mime-db": "^1.54.0", "minimizer-webpack-plugin": "^5.6.1", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "watchpack": "^2.5.2", "webpack-sources": "^3.5.1" }, "peerDependencies": { "webpack-cli": "*" }, "optionalPeers": ["webpack-cli"], "bin": { "webpack": "bin/webpack.js" } }, "sha512-U9/cvLzxObKNEZ9+TtdqrHM5/9z3lgl2c+c4BzbqGxFQvQvBAq87yql5A8pQ+rrMbS496MZJeF5enVBndIy2hw=="], + "webpack-sources": ["webpack-sources@3.5.0", "", {}, "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.5.0", "", {}, "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw=="], @@ -5720,7 +5938,7 @@ "@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], + "@ai-sdk/provider-utils/undici": ["undici@6.28.1", "", {}, "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA=="], "@ai-sdk/togetherai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], @@ -5750,8 +5968,6 @@ "@astrojs/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - "@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], - "@astrojs/sitemap/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@astrojs/solid-js/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], @@ -5912,6 +6128,8 @@ "@develar/schema-utils/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "@develar/schema-utils/ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], + "@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], @@ -5940,6 +6158,8 @@ "@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.4.0", "", {}, "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ=="], + "@grpc/proto-loader/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + "@hey-api/json-schema-ref-parser/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], @@ -5948,6 +6168,14 @@ "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@jsonjoy.com/fs-snapshot/@jsonjoy.com/json-pack": ["@jsonjoy.com/json-pack@17.67.0", "", { "dependencies": { "@jsonjoy.com/base64": "17.67.0", "@jsonjoy.com/buffers": "17.67.0", "@jsonjoy.com/codegen": "17.67.0", "@jsonjoy.com/json-pointer": "17.67.0", "@jsonjoy.com/util": "17.67.0", "hyperdyperid": "^1.2.0", "thingies": "^2.5.0", "tree-dump": "^1.1.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w=="], + + "@jsonjoy.com/fs-snapshot/@jsonjoy.com/util": ["@jsonjoy.com/util@17.67.0", "", { "dependencies": { "@jsonjoy.com/buffers": "17.67.0", "@jsonjoy.com/codegen": "17.67.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew=="], + + "@jsonjoy.com/json-pack/@jsonjoy.com/buffers": ["@jsonjoy.com/buffers@1.2.1", "", { "peerDependencies": { "tslib": "2" } }, "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA=="], + + "@jsonjoy.com/util/@jsonjoy.com/buffers": ["@jsonjoy.com/buffers@1.2.1", "", { "peerDependencies": { "tslib": "2" } }, "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA=="], + "@jsx-email/cli/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@jsx-email/cli/esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="], @@ -5964,8 +6192,6 @@ "@mdx-js/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - "@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], - "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "@modelcontextprotocol/sdk/hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="], @@ -6042,7 +6268,7 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], - "@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="], + "@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13-v2.tgz", {}], "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.88", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-6M4+bxK/UMijDYK2ia1hCKYma1iajFYxHDlhbinFUdhH2WbFsjoZRMpSjEMlTOBptqmh2J1JclKnprGulF8WwQ=="], @@ -6062,7 +6288,7 @@ "@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="], + "@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13-v2.tgz", {}], "@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], @@ -6070,6 +6296,8 @@ "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], + "@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="], + "@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], "@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], @@ -6164,6 +6392,10 @@ "@tanstack/server-functions-plugin/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + "@temporalio/client/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="], + + "@temporalio/common/ms": ["ms@3.0.0-canary.1", "", {}, "sha512-kh8ARjh8rMN7Du2igDRO9QJnqCb2xYTJxyQYK7vJJS4TvLLmsbyhiKpSW+t+y26gyOyMd0riphX0GeWKU3ky5g=="], + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], @@ -6212,8 +6444,6 @@ "ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.10.0", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-FMsAEjLUt5pWuRE2LDC/LCvVrFjLlrEzUITH5+5SZtfq7KZ2wrOHjQVxzz92sju8S9ltpzW87CLW8/b0oBXVCw=="], - "ajv-keywords/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], - "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -6272,6 +6502,8 @@ "c12/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], "compress-commons/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], @@ -6336,7 +6568,7 @@ "esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], - "estree-util-to-js/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], "execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="], @@ -6378,6 +6610,8 @@ "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], + "istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "istanbul-reports/html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], "js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], @@ -6388,6 +6622,8 @@ "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], + "light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="], "lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -6486,12 +6722,16 @@ "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "schema-utils/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], + "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], @@ -6512,6 +6752,10 @@ "sort-keys/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="], + "source-map-loader/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "sst/aws4fetch": ["aws4fetch@1.0.18", "", {}, "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ=="], "sst/jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="], @@ -6538,6 +6782,8 @@ "tree-sitter-bash/node-addon-api": ["node-addon-api@8.8.0", "", {}, "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA=="], + "tsx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + "tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="], @@ -6580,6 +6826,14 @@ "vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + "webpack/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "webpack/enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="], + + "webpack/es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], + + "webpack/webpack-sources": ["webpack-sources@3.5.1", "", {}, "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw=="], + "wrangler/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -6610,11 +6864,11 @@ "@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], + "@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/undici": ["undici@6.28.1", "", {}, "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA=="], "@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@ai-sdk/anthropic/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], + "@ai-sdk/anthropic/@ai-sdk/provider-utils/undici": ["undici@6.28.1", "", {}, "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA=="], "@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -6744,8 +6998,12 @@ "@develar/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "@dot/log/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], + "@electron/fuses/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "@electron/fuses/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], "@electron/notarize/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], @@ -6768,8 +7026,22 @@ "@expressive-code/plugin-shiki/shiki/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + "@grpc/proto-loader/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "@grpc/proto-loader/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "@hey-api/json-schema-ref-parser/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@jsonjoy.com/fs-snapshot/@jsonjoy.com/json-pack/@jsonjoy.com/base64": ["@jsonjoy.com/base64@17.67.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw=="], + + "@jsonjoy.com/fs-snapshot/@jsonjoy.com/json-pack/@jsonjoy.com/codegen": ["@jsonjoy.com/codegen@17.67.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q=="], + + "@jsonjoy.com/fs-snapshot/@jsonjoy.com/json-pack/@jsonjoy.com/json-pointer": ["@jsonjoy.com/json-pointer@17.67.0", "", { "dependencies": { "@jsonjoy.com/util": "17.67.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA=="], + + "@jsonjoy.com/fs-snapshot/@jsonjoy.com/util/@jsonjoy.com/codegen": ["@jsonjoy.com/codegen@17.67.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q=="], + + "@jsx-email/cli/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "@jsx-email/cli/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="], "@jsx-email/cli/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="], @@ -7064,8 +7336,6 @@ "ai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -7118,6 +7388,8 @@ "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "builder-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], @@ -7166,14 +7438,20 @@ "electron-builder-squirrel-windows/builder-util/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "electron-builder/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "electron-builder/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "electron-builder/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "electron-publish/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "electron-updater/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + "esbuild-plugin-copy/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -7238,6 +7516,58 @@ "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + "tw-to-css/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "tw-to-css/tailwindcss/glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -7394,6 +7724,14 @@ "@electron/universal/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@grpc/proto-loader/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@grpc/proto-loader/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "@grpc/proto-loader/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "@grpc/proto-loader/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@jsx-email/cli/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "@jsx-email/cli/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], @@ -7558,6 +7896,8 @@ "electron-builder-squirrel-windows/app-builder-lib/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "electron-builder-squirrel-windows/builder-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "electron-builder-squirrel-windows/builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "electron-builder/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -7636,6 +7976,10 @@ "@aws-sdk/token-providers/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], + "@grpc/proto-loader/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "@grpc/proto-loader/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -7654,6 +7998,8 @@ "electron-builder-squirrel-windows/app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + "electron-builder-squirrel-windows/app-builder-lib/electron-publish/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "electron-builder/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "electron-builder/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/packages/cli/bin/lildax.cjs b/packages/cli/bin/lildax.cjs old mode 100644 new mode 100755 diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index f42d8b07b0d3..fca703678b76 100755 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -66,7 +66,12 @@ for (const item of targets) { entrypoints: ["./src/index.ts"], tsconfig: "./tsconfig.json", plugins: [plugin], - external: ["node-gyp"], + // lightningcss (via ui -> tailwind) has a conditional native-binding require the bundler + // cannot resolve; the TUI never runs that path, so it stays external. @temporalio/worker + // drags webpack and swc (it bundles the workflow from source at startup), which cannot ride + // a compiled binary; a packaged serve runs OPENCODE_TEMPORAL_ROLE=client next to standalone + // workers instead. + external: ["node-gyp", "lightningcss", "@temporalio/worker"], format: "esm", minify: true, sourcemap: sourcemapsFlag ? "linked" : "none", diff --git a/packages/cli/src/tui.ts b/packages/cli/src/tui.ts index 370cd0f5bea4..b93a68366764 100644 --- a/packages/cli/src/tui.ts +++ b/packages/cli/src/tui.ts @@ -361,9 +361,7 @@ async function v1Providers(origin: string, directory: string | null, headers?: H .filter((method: any) => method.type === "env") .flatMap((method: any) => method.names ?? []) ?? [], options: {}, - models: Object.fromEntries( - models.filter((m) => m.providerID === provider.id).map((m) => [m.id, v1Model(m)]), - ), + models: Object.fromEntries(models.filter((m) => m.providerID === provider.id).map((m) => [m.id, v1Model(m)])), })) const connected = new Set(integrations.filter((item) => item.connections?.length).map((item) => item.id)) return { @@ -460,8 +458,32 @@ async function globalEventStream(origin: string, directory: string | null, heade // its session.next.* stream into them: admissions and text deltas map directly for low latency, // and every other session.next.* event schedules a debounced re-read of the session so the store // converges on what the daemon persisted (tools, reasoning, tokens, titles). -function createSessionProjector(origin: string, headers: HeadersInit | undefined, emit: (dir: string, payload: unknown) => void) { +// +// Cross-process turns stream nothing here: with OPENCODE_TEMPORAL_ROLE=client the turn runs in a +// separate worker process, and its session.next.* events live on that process's bus. The only +// locally observable moment is this daemon's own admission, so a followed session keeps re-reading +// until its latest assistant message settles; without that, a reply would only render when the +// NEXT local event happened to trigger a re-read. +function createSessionProjector( + origin: string, + headers: HeadersInit | undefined, + emit: (dir: string, payload: unknown) => void, +) { const timers = new Map>() + const FOLLOW_INTERVAL = 1200 + // A turn longer than this stops refreshing a cross-process TUI until the next admission. + const FOLLOW_DEADLINE = 15 * 60 * 1000 + // deadline plus the signature of the last settled read: the read model can show the completed + // flag before the settled row's rewritten content, so a follow only stops after two identical + // settled reads. + const follows = new Map() + // Part ids emitted per message, so a settled rewrite that coalesces parts under new ids also + // removes the stale ones from the store instead of leaving duplicated text. + const emittedParts = new Map>>() + // Question ids surfaced per session. A question raised inside a cross-process activity publishes + // its asked event on the worker's bus, never here, so the dialog would never open. The durable + // row is readable from this daemon, so poll it and project the asked/answered lifecycle. + const questionSeen = new Map>() // The SDK validates every frame against the event schema; a nonconforming frame kills the // stream, so synthetic events carry the required id and full property sets. let counter = 0 @@ -470,27 +492,91 @@ function createSessionProjector(origin: string, headers: HeadersInit | undefined type, properties, }) - const resync = (sessionID: string, dir: string) => { + const resync = (sessionID: string, dir: string, delay = 250) => { clearTimeout(timers.get(sessionID)) timers.set( sessionID, setTimeout(async () => { timers.delete(sessionID) + let settled = false + let signature = "" + let pendingQuestions = 0 try { const [info, items] = await Promise.all([ v2(origin, `/api/session/${sessionID}`, null, headers), v2(origin, `/api/session/${sessionID}/message`, null, headers), ]) emit(dir, legacyEvent("session.updated", { sessionID, info: v1Session(info) })) - for (const message of v1Messages(sessionID, items as any[])) { + const messages = v1Messages(sessionID, items as any[]) + const seen = emittedParts.get(sessionID) ?? new Map>() + emittedParts.set(sessionID, seen) + for (const message of messages) { emit(dir, legacyEvent("message.updated", { sessionID, info: message.info })) - for (const part of message.parts) + const ids = new Set() + for (const part of message.parts) { + ids.add(part.id) emit(dir, legacyEvent("message.part.updated", { sessionID, part, time: Date.now() })) + } + for (const stale of seen.get(message.info.id) ?? []) { + if (!ids.has(stale)) + emit(dir, legacyEvent("message.part.removed", { sessionID, messageID: message.info.id, partID: stale })) + } + seen.set(message.info.id, ids) } + const latest = messages.at(-1)?.info + settled = latest?.role === "assistant" && Boolean(latest.time?.completed) + signature = messages + .map( + (m) => + `${m.info.id}:${m.parts.map((part) => `${part.id}=${part.text?.length ?? part.state?.status ?? ""}`).join(",")}`, + ) + .join(";") } catch {} - }, 250), + try { + const pending = (await v2(origin, `/api/session/${sessionID}/question`, null, headers)) as any[] + const seen = questionSeen.get(sessionID) ?? new Set() + const current = new Set() + for (const question of pending) { + current.add(question.id) + emit( + dir, + legacyEvent("question.v2.asked", { + id: question.id, + sessionID, + questions: question.questions, + tool: question.tool, + }), + ) + } + // A question that left the list was answered or rejected elsewhere; clear it from the store. + for (const stale of seen) { + if (!current.has(stale)) + emit(dir, legacyEvent("question.v2.replied", { sessionID, requestID: stale, answers: [] })) + } + questionSeen.set(sessionID, current) + pendingQuestions = current.size + } catch {} + const follow = follows.get(sessionID) + if (follow === undefined) return + if (Date.now() > follow.deadline) { + follows.delete(sessionID) + return + } + // A pending question keeps the follow alive even once the assistant message looks settled: + // the turn is blocked on the answer, so the next change only lands after the user replies. + if (settled && follow.confirmed === signature && pendingQuestions === 0) { + follows.delete(sessionID) + return + } + follow.confirmed = settled ? signature : null + resync(sessionID, dir, FOLLOW_INTERVAL) + }, delay), ) } + const follow = (sessionID: string, dir: string) => { + follows.set(sessionID, { deadline: Date.now() + FOLLOW_DEADLINE, confirmed: null }) + resync(sessionID, dir) + } const handle = (event: any) => { const type = event?.type if (typeof type !== "string") return @@ -522,6 +608,7 @@ function createSessionProjector(origin: string, headers: HeadersInit | undefined time: Date.now(), }), ) + follow(sessionID, dir) return } if (type === "session.next.step.started" && data.assistantMessageID) { @@ -562,12 +649,14 @@ function createSessionProjector(origin: string, headers: HeadersInit | undefined ) return } - resync(sessionID, dir) + follow(sessionID, dir) } return Object.assign(handle, { dispose() { for (const timer of timers.values()) clearTimeout(timer) timers.clear() + follows.clear() + emittedParts.clear() }, }) } @@ -609,8 +698,7 @@ async function v2(origin: string, path: string, directory: string | null, header const gracefulFetch = Object.assign( async (input: RequestInfo | URL, init?: RequestInit) => { // The passthrough fetch consumes a Request's body; keep a clone in case a write needs bridging. - const preserved = - input instanceof Request && input.method !== "GET" && !init?.body ? input.clone() : undefined + const preserved = input instanceof Request && input.method !== "GET" && !init?.body ? input.clone() : undefined const response = await fetch(input, init) const url = new URL(input instanceof Request ? input.url : input) if (response.status !== 404) return response diff --git a/packages/core/package.json b/packages/core/package.json index 2b465e5852fd..6991253466a2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -87,6 +87,7 @@ "@effect/sql-sqlite-bun": "catalog:", "@lydell/node-pty": "catalog:", "@ff-labs/fff-bun": "0.9.4", + "@libsql/client": "^0.17.0", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", "@opencode-ai/effect-drizzle-sqlite": "workspace:*", diff --git a/packages/core/schema.json b/packages/core/schema.json index d0eeeebd5c41..df1c4c21a05d 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,8 +1,10 @@ { "version": "7", "dialect": "sqlite", - "id": "f14a9b18-8207-487e-a3d3-227e629ba9ad", - "prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"], + "id": "c1e14245-625d-4a2c-b3dd-aa10a72f5c0c", + "prevIds": [ + "c609b4b3-8061-4cef-a339-c44d54121462" + ], "ddl": [ { "name": "workspace", @@ -36,6 +38,10 @@ "name": "event", "entityType": "tables" }, + { + "name": "permission_request", + "entityType": "tables" + }, { "name": "permission", "entityType": "tables" @@ -48,6 +54,10 @@ "name": "project", "entityType": "tables" }, + { + "name": "question_request", + "entityType": "tables" + }, { "name": "message", "entityType": "tables" @@ -80,6 +90,10 @@ "name": "session_share", "entityType": "tables" }, + { + "name": "snapshot_pack", + "entityType": "tables" + }, { "type": "text", "notNull": false, @@ -540,6 +554,86 @@ "entityType": "columns", "table": "event" }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "permission_request" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "permission_request" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "permission_request" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "payload", + "entityType": "columns", + "table": "permission_request" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'pending'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "permission_request" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message", + "entityType": "columns", + "table": "permission_request" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission_request" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission_request" + }, { "type": "text", "notNull": false, @@ -770,6 +864,76 @@ "entityType": "columns", "table": "project" }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "question_request" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "question_request" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "payload", + "entityType": "columns", + "table": "question_request" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'pending'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "question_request" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "answers", + "entityType": "columns", + "table": "question_request" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "question_request" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "question_request" + }, { "type": "text", "notNull": false, @@ -1481,9 +1645,93 @@ "table": "session_share" }, { - "columns": ["project_id"], + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "snapshot_pack" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "snapshot_pack" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "snapshot_pack" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tree", + "entityType": "columns", + "table": "snapshot_pack" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "base", + "entityType": "columns", + "table": "snapshot_pack" + }, + { + "type": "blob", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "pack", + "entityType": "columns", + "table": "snapshot_pack" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "snapshot_pack" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "snapshot_pack" + }, + { + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1492,9 +1740,13 @@ "table": "workspace" }, { - "columns": ["active_account_id"], + "columns": [ + "active_account_id" + ], "tableTo": "account", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1503,9 +1755,13 @@ "table": "account_state" }, { - "columns": ["aggregate_id"], + "columns": [ + "aggregate_id" + ], "tableTo": "event_sequence", - "columnsTo": ["aggregate_id"], + "columnsTo": [ + "aggregate_id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1514,9 +1770,13 @@ "table": "event" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1525,9 +1785,13 @@ "table": "permission" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1536,9 +1800,13 @@ "table": "project_directory" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1547,9 +1815,13 @@ "table": "message" }, { - "columns": ["message_id"], + "columns": [ + "message_id" + ], "tableTo": "message", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1558,9 +1830,13 @@ "table": "part" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1569,9 +1845,13 @@ "table": "session_context_epoch" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1580,9 +1860,13 @@ "table": "session_input" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1591,9 +1875,13 @@ "table": "session_message" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1602,9 +1890,13 @@ "table": "session" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1613,9 +1905,13 @@ "table": "todo" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1624,138 +1920,206 @@ "table": "session_share" }, { - "columns": ["email", "url"], + "columns": [ + "email", + "url" + ], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": ["project_id", "directory"], + "columns": [ + "project_id", + "directory" + ], "nameExplicit": false, "name": "project_directory_pk", "entityType": "pks", "table": "project_directory" }, { - "columns": ["session_id", "position"], + "columns": [ + "session_id", + "position" + ], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": ["name"], + "columns": [ + "name" + ], "nameExplicit": false, "name": "data_migration_pk", "table": "data_migration", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "credential_pk", "table": "credential", "entityType": "pks" }, { - "columns": ["aggregate_id"], + "columns": [ + "aggregate_id" + ], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "event_pk", "table": "event", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "permission_request_pk", + "table": "permission_request", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "question_request_pk", + "table": "question_request", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "nameExplicit": false, "name": "session_context_epoch_pk", "table": "session_context_epoch", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "session_input_pk", "table": "session_input", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", "entityType": "pks" }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "snapshot_pack_pk", + "table": "snapshot_pack", + "entityType": "pks" + }, { "columns": [ { @@ -1796,6 +2160,38 @@ "entityType": "indexes", "table": "event" }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "status", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "permission_request_session_status_idx", + "entityType": "indexes", + "table": "permission_request" + }, + { + "columns": [ + { + "value": "status", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "permission_request_status_idx", + "entityType": "indexes", + "table": "permission_request" + }, { "columns": [ { @@ -1818,6 +2214,38 @@ "entityType": "indexes", "table": "permission" }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "status", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "question_request_session_status_idx", + "entityType": "indexes", + "table": "question_request" + }, + { + "columns": [ + { + "value": "status", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "question_request_status_idx", + "entityType": "indexes", + "table": "question_request" + }, { "columns": [ { @@ -2065,7 +2493,43 @@ "name": "todo_session_idx", "entityType": "indexes", "table": "todo" + }, + { + "columns": [ + { + "value": "directory", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "snapshot_pack_directory_idx", + "entityType": "indexes", + "table": "snapshot_pack" + }, + { + "columns": [ + { + "value": "worktree", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "snapshot_pack_worktree_idx", + "entityType": "indexes", + "table": "snapshot_pack" } ], "renames": [] -} +} \ No newline at end of file diff --git a/packages/core/script/migrate-once.ts b/packages/core/script/migrate-once.ts new file mode 100644 index 000000000000..7d1895f9491f --- /dev/null +++ b/packages/core/script/migrate-once.ts @@ -0,0 +1,17 @@ +// Build the Database layer against the file in argv[2], which runs migrations, then exit. Used by +// the cross-process migration test to race several independent processes against one shared file. +import { Effect, Layer } from "effect" +import { Database } from "../src/database/database" + +const file = process.argv[2] +if (!file) { + console.error("usage: migrate-once.ts ") + process.exit(2) +} + +Effect.runPromise(Effect.scoped(Layer.build(Database.layerFromPath(file)))) + .then(() => process.exit(0)) + .catch((error) => { + console.error(error) + process.exit(1) + }) diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index d61adf047eac..ec9ec91dcec1 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -19,25 +19,52 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/storage/Database") {} -const layer = Layer.effect( - Service, - Effect.gen(function* () { - const db = yield* makeDatabase - - yield* db.run("PRAGMA journal_mode = WAL") - yield* db.run("PRAGMA synchronous = NORMAL") - yield* db.run("PRAGMA busy_timeout = 5000") - yield* db.run("PRAGMA cache_size = -64000") - yield* db.run("PRAGMA foreign_keys = ON") - yield* db.run("PRAGMA wal_checkpoint(PASSIVE)") - yield* DatabaseMigration.apply(db) - - return { db } - }).pipe(Effect.orDie), -) +function makeServiceLayer(localPragmas: boolean) { + return Layer.effect( + Service, + Effect.gen(function* () { + const db = yield* makeDatabase + + // Local-file tuning. A shared/networked store (libSQL) manages journaling itself and may + // reject these, so they are skipped there; the schema and migrations are identical either way. + if (localPragmas) { + yield* db.run("PRAGMA journal_mode = WAL") + yield* db.run("PRAGMA synchronous = NORMAL") + yield* db.run("PRAGMA busy_timeout = 5000") + yield* db.run("PRAGMA cache_size = -64000") + yield* db.run("PRAGMA foreign_keys = ON") + yield* db.run("PRAGMA wal_checkpoint(PASSIVE)") + } else { + // Best effort on the shared backend: an embedded file honors it, a remote server applies + // it per stream at most. Deletes on a remote store rely on the application-side cascades. + yield* db.run("PRAGMA foreign_keys = ON").pipe(Effect.exit) + } + yield* DatabaseMigration.apply(db) + + return { db } + }).pipe(Effect.orDie), + ) +} export function layerFromPath(filename: string) { - return layer.pipe(Layer.provide(sqliteLayer({ filename }))) + return makeServiceLayer(true).pipe(Layer.provide(sqliteLayer({ filename }))) +} + +// A shared/durable event store: point every worker at one libSQL URL (a self-hosted sqld or Turso) +// so any worker can resume any session from the same log. Same SQLite dialect, so nothing in the +// schema/migrations/queries changes. +export function layerFromLibsql(url: string, authToken?: string) { + // Imported lazily: @libsql/client loads a platform native binding at import time, which a + // compiled binary cannot carry. The local-file backend must boot without it. + return makeServiceLayer(false).pipe( + Layer.provide( + Layer.unwrap( + Effect.promise(() => import("./sqlite.libsql")).pipe( + Effect.map((backend) => backend.layer({ url, authToken })), + ), + ), + ), + ) } export function path() { @@ -54,4 +81,10 @@ export function path() { return join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`) } -export const node = makeGlobalNode({ service: Service, layer: layerFromPath(path()), deps: [] }) +export const node = makeGlobalNode({ + service: Service, + layer: Flag.OPENCODE_DB_URL + ? layerFromLibsql(Flag.OPENCODE_DB_URL, Flag.OPENCODE_DB_AUTH_TOKEN) + : layerFromPath(path()), + deps: [], +}) diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index e6ea4eaa1477..1b65fb89e49d 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -40,5 +40,8 @@ export const migrations = ( import("./migration/20260622142730_simplify_session_context_epoch"), import("./migration/20260622170816_reset_v2_session_state"), import("./migration/20260622202450_simplify_session_input"), + import("./migration/20260810092511_permission_request"), + import("./migration/20260812070622_snapshot_pack"), + import("./migration/20260817200644_question_request"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration.ts b/packages/core/src/database/migration.ts index 644b22ab7a26..a0d87635b6b1 100644 --- a/packages/core/src/database/migration.ts +++ b/packages/core/src/database/migration.ts @@ -16,27 +16,70 @@ export type Migration = { } export function apply(db: Database) { + // Serialize across processes, not just within one. N cold workers pointing at the same shared + // store would otherwise race: both create the schema, or both insert the same migration id (the + // table check was a read outside any lock, a TOCTOU). The process-local semaphore covers + // same-process concurrency; the single BEGIN IMMEDIATE transaction makes check-and-apply atomic + // and write-locked, so a concurrent start on another process waits and then observes the + // migrations already applied and does nothing. The incremental branch mirrors `applyOnly` on the + // shared transaction (keep the two in sync). return lock.withPermit( - Effect.gen(function* () { - const tables = yield* db.all<{ name: string }>( - sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`, - ) - if (tables.some((table) => table.name === "session")) return yield* applyOnly(db, migrations) - if (tables.length > 0) return yield* Effect.die("Database is not empty and has no session table") - yield* db.transaction((tx) => + db.transaction( + (tx) => Effect.gen(function* () { - yield* schema.up(tx) + const tables = yield* tx.all<{ name: string }>( + sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`, + ) + const hasSession = tables.some((table) => table.name === "session") + if (!hasSession && tables.length > 0) + return yield* Effect.die("Database is not empty and has no session table") + if (!hasSession) { + yield* schema.up(tx) + yield* tx.run( + sql`CREATE TABLE ${sql.identifier("migration")} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`, + ) + yield* Effect.forEach(migrations, (migration) => + tx.run( + sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`, + ), + ) + return + } yield* tx.run( - sql`CREATE TABLE ${sql.identifier("migration")} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`, + sql`CREATE TABLE IF NOT EXISTS ${sql.identifier("migration")} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`, ) - yield* Effect.forEach(migrations, (migration) => - tx.run( - sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`, - ), + let completed = new Set( + (yield* tx.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id), ) + if (completed.size === 0) { + if ( + yield* tx.get( + sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${"__drizzle_migrations"}`, + ) + ) { + yield* tx.run(sql` + INSERT OR IGNORE INTO ${sql.identifier("migration")} (id, time_completed) + SELECT name, ${Date.now()} + FROM ${sql.identifier("__drizzle_migrations")} + WHERE name IS NOT NULL + `) + completed = new Set( + (yield* tx.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map( + (row) => row.id, + ), + ) + } + } + for (const migration of migrations) { + if (completed.has(migration.id)) continue + yield* migration.up(tx) + yield* tx.run( + sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`, + ) + } }), - ) - }), + { behavior: "immediate" }, + ), ) } diff --git a/packages/core/src/database/migration/20260810092511_permission_request.ts b/packages/core/src/database/migration/20260810092511_permission_request.ts new file mode 100644 index 000000000000..f4c5f9fb709e --- /dev/null +++ b/packages/core/src/database/migration/20260810092511_permission_request.ts @@ -0,0 +1,26 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260810092511_permission_request", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`permission_request\` ( + \`id\` text PRIMARY KEY, + \`session_id\` text NOT NULL, + \`agent\` text, + \`payload\` text NOT NULL, + \`status\` text DEFAULT 'pending' NOT NULL, + \`message\` text, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL + ); + `) + yield* tx.run( + `CREATE INDEX \`permission_request_session_status_idx\` ON \`permission_request\` (\`session_id\`,\`status\`);`, + ) + yield* tx.run(`CREATE INDEX \`permission_request_status_idx\` ON \`permission_request\` (\`status\`);`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260812070622_snapshot_pack.ts b/packages/core/src/database/migration/20260812070622_snapshot_pack.ts new file mode 100644 index 000000000000..e04ccfe38e6a --- /dev/null +++ b/packages/core/src/database/migration/20260812070622_snapshot_pack.ts @@ -0,0 +1,26 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260812070622_snapshot_pack", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`snapshot_pack\` ( + \`id\` text PRIMARY KEY, + \`directory\` text NOT NULL, + \`worktree\` text NOT NULL, + \`tree\` text NOT NULL, + \`base\` text, + \`pack\` blob NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL + ); + `) + yield* tx.run( + `CREATE INDEX \`snapshot_pack_directory_idx\` ON \`snapshot_pack\` (\`directory\`,\`time_created\`);`, + ) + yield* tx.run(`CREATE INDEX \`snapshot_pack_worktree_idx\` ON \`snapshot_pack\` (\`worktree\`,\`time_created\`);`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260817200644_question_request.ts b/packages/core/src/database/migration/20260817200644_question_request.ts new file mode 100644 index 000000000000..35732d24f8c8 --- /dev/null +++ b/packages/core/src/database/migration/20260817200644_question_request.ts @@ -0,0 +1,25 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260817200644_question_request", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`question_request\` ( + \`id\` text PRIMARY KEY, + \`session_id\` text NOT NULL, + \`payload\` text NOT NULL, + \`status\` text DEFAULT 'pending' NOT NULL, + \`answers\` text, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL + ); + `) + yield* tx.run( + `CREATE INDEX \`question_request_session_status_idx\` ON \`question_request\` (\`session_id\`,\`status\`);`, + ) + yield* tx.run(`CREATE INDEX \`question_request_status_idx\` ON \`question_request\` (\`status\`);`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index ed60fde6c55f..9a9027a4c149 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -86,6 +86,18 @@ export default { CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE ); `) + yield* tx.run(` + CREATE TABLE \`permission_request\` ( + \`id\` text PRIMARY KEY, + \`session_id\` text NOT NULL, + \`agent\` text, + \`payload\` text NOT NULL, + \`status\` text DEFAULT 'pending' NOT NULL, + \`message\` text, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL + ); + `) yield* tx.run(` CREATE TABLE \`permission\` ( \`id\` text PRIMARY KEY, @@ -124,6 +136,17 @@ export default { \`commands\` text ); `) + yield* tx.run(` + CREATE TABLE \`question_request\` ( + \`id\` text PRIMARY KEY, + \`session_id\` text NOT NULL, + \`payload\` text NOT NULL, + \`status\` text DEFAULT 'pending' NOT NULL, + \`answers\` text, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL + ); + `) yield* tx.run(` CREATE TABLE \`message\` ( \`id\` text PRIMARY KEY, @@ -236,11 +259,31 @@ export default { CONSTRAINT \`fk_session_share_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE ); `) + yield* tx.run(` + CREATE TABLE \`snapshot_pack\` ( + \`id\` text PRIMARY KEY, + \`directory\` text NOT NULL, + \`worktree\` text NOT NULL, + \`tree\` text NOT NULL, + \`base\` text, + \`pack\` blob NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL + ); + `) yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`) yield* tx.run(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`) + yield* tx.run( + `CREATE INDEX \`permission_request_session_status_idx\` ON \`permission_request\` (\`session_id\`,\`status\`);`, + ) + yield* tx.run(`CREATE INDEX \`permission_request_status_idx\` ON \`permission_request\` (\`status\`);`) yield* tx.run( `CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`, ) + yield* tx.run( + `CREATE INDEX \`question_request_session_status_idx\` ON \`question_request\` (\`session_id\`,\`status\`);`, + ) + yield* tx.run(`CREATE INDEX \`question_request_status_idx\` ON \`question_request\` (\`status\`);`) yield* tx.run( `CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`, ) @@ -269,6 +312,10 @@ export default { yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`) yield* tx.run(`CREATE INDEX \`session_parent_idx\` ON \`session\` (\`parent_id\`);`) yield* tx.run(`CREATE INDEX \`todo_session_idx\` ON \`todo\` (\`session_id\`);`) + yield* tx.run( + `CREATE INDEX \`snapshot_pack_directory_idx\` ON \`snapshot_pack\` (\`directory\`,\`time_created\`);`, + ) + yield* tx.run(`CREATE INDEX \`snapshot_pack_worktree_idx\` ON \`snapshot_pack\` (\`worktree\`,\`time_created\`);`) }) }, } satisfies Omit diff --git a/packages/core/src/database/sqlite.bun.ts b/packages/core/src/database/sqlite.bun.ts index e15f4c117e46..41d659400c46 100644 --- a/packages/core/src/database/sqlite.bun.ts +++ b/packages/core/src/database/sqlite.bun.ts @@ -161,7 +161,22 @@ const nativeLayer = (config: Config) => create: config.create ?? true, }) yield* Effect.addFinalizer(() => Effect.sync(() => native.close())) - if (config.disableWAL !== true) native.run("PRAGMA journal_mode = WAL;") + // Set busy_timeout first so later writers wait on a held lock instead of erroring. + native.run("PRAGMA busy_timeout = 5000;") + // A journal_mode change does NOT honor busy_timeout, so N cold workers racing the first WAL + // switch on a fresh file can get SQLITE_BUSY. Retry until one wins; the rest then find the + // file already in WAL and the pragma returns at once. + if (config.disableWAL !== true) { + for (let attempt = 0; ; attempt++) { + try { + native.run("PRAGMA journal_mode = WAL;") + break + } catch (cause) { + if (attempt >= 50 || !/SQLITE_BUSY|database is locked/i.test(String(cause))) throw cause + Bun.sleepSync(20) + } + } + } return native }), ) diff --git a/packages/core/src/database/sqlite.libsql.ts b/packages/core/src/database/sqlite.libsql.ts new file mode 100644 index 000000000000..2c2c0bb6a31d --- /dev/null +++ b/packages/core/src/database/sqlite.libsql.ts @@ -0,0 +1,228 @@ +// A libSQL-backed SqlClient, so the event store can live in a shared/networked SQLite (Turso or a +// self-hosted sqld) that every worker points at -- the basis for any-worker resume across hosts. +// It speaks the same SQLite dialect as the bun/node drivers, so the schema and all migrations are +// unchanged; only the transport differs. `makeDatabase` needs only the generic `SqlClient`, so this +// layer provides just that (no Sqlite.Native / Sqlite.Drizzle). + +import { createClient, type ResultSet, type Transaction } from "@libsql/client" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Fiber from "effect/Fiber" +import { identity } from "effect/Function" +import * as Layer from "effect/Layer" +import * as Scope from "effect/Scope" +import * as Semaphore from "effect/Semaphore" +import * as Stream from "effect/Stream" +import * as Reactivity from "effect/unstable/reactivity/Reactivity" +import * as Client from "effect/unstable/sql/SqlClient" +import type { Connection } from "effect/unstable/sql/SqlConnection" +import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError" +import * as Statement from "effect/unstable/sql/Statement" + +export interface LibsqlConfig { + readonly url: string + readonly authToken?: string + readonly transformResultNames?: (str: string) => string + readonly transformQueryNames?: (str: string) => string +} + +// The transaction-control statements the effect-drizzle layer emits onto the reserved connection. +// A remote libSQL client runs each execute() as an independent auto-commit request, so forwarding +// `begin`/`commit` as plain statements would NOT bind a transaction across the writes in between. +// We intercept them and drive a real interactive libSQL transaction instead (one pinned stream), +// which is atomic all-or-nothing. `rollback to savepoint` is a savepoint op, not a rollback, so it +// must fall through to the transaction. +const BEGIN = /^\s*begin\b/i +const COMMIT = /^\s*(commit|end)\b/i +const ROLLBACK = /^\s*rollback\s*(transaction)?\s*;?\s*$/i +const EMPTY = { rows: [], columns: [] } as unknown as ResultSet + +const make = (options: LibsqlConfig) => + Effect.gen(function* () { + // intMode "number": our columns are text ids + epoch-millis / sequence integers, all well under + // 2^53, so a JS number is exact and we avoid bigint round-tripping through the SqlClient. + const native = yield* Effect.acquireRelease( + Effect.sync(() => createClient({ url: options.url, authToken: options.authToken, intMode: "number" })), + (client) => Effect.sync(() => client.close()), + ) + + const compiler = Statement.makeCompilerSqlite(options.transformQueryNames) + const transformRows = options.transformResultNames + ? Statement.defaultTransforms(options.transformResultNames).array + : undefined + + const fail = (cause: unknown) => + new SqlError({ + reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }), + }) + + // sqld surfaces a cross-process write-lock conflict as a busy error instead of queueing, and + // the remote path has no `busy_timeout` pragma. Without a bounded retry, a routine collision + // (serve writing while a worker holds a turn transaction) dies the caller. + const isBusy = (cause: unknown) => /SQLITE_BUSY|database is locked|database table is locked/i.test(String(cause)) + const withBusyRetry = async (attempt: () => Promise): Promise => { + for (let tries = 0; ; tries++) { + try { + return await attempt() + } catch (cause) { + if (tries >= 5 || !isBusy(cause)) throw cause + const delay = Math.min(50 * 2 ** tries, 500) * (0.5 + Math.random()) + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + } + + const toRows = (r: ResultSet) => r.rows as unknown as Array> + const toValues = (r: ResultSet) => + r.rows.map((row) => r.columns.map((c) => (row as Record)[c])) as Array + + // Auto-commit connection: each statement is its own request. Used outside transactions. + const run = (query: string, params: ReadonlyArray = []) => + Effect.tryPromise({ + try: () => withBusyRetry(() => native.execute({ sql: query, args: params as never[] })).then(toRows), + catch: fail, + }) + const runValues = (query: string, params: ReadonlyArray = []) => + Effect.tryPromise({ + try: () => withBusyRetry(() => native.execute({ sql: query, args: params as never[] })).then(toValues), + catch: fail, + }) + + const connection = identity({ + execute(query, params, transformRows) { + return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params) + }, + executeRaw(query, params) { + return run(query, params) + }, + executeValues(query, params) { + return runValues(query, params) + }, + executeUnprepared(query, params, transformRows) { + return this.execute(query, params, transformRows) + }, + executeStream() { + return Stream.die("executeStream not implemented") + }, + }) + + // A per-transaction connection bound to one interactive libSQL transaction. All statements in + // the drizzle transaction scope run on the same pinned stream, so the writes commit atomically. + // We always open in "write" mode: the client gives no read-only hint, and a write transaction is + // correct for both reads and writes (refining read-only spans to "read" mode is a follow-up). + const makeTxConnection = () => { + let tx: Transaction | null = null + const exec = async (query: string, params: ReadonlyArray): Promise => { + if (BEGIN.test(query)) { + // Opening the write transaction is where the cross-process lock conflict lands. + tx = await withBusyRetry(() => native.transaction("write")) + return EMPTY + } + if (COMMIT.test(query)) { + if (tx) { + await tx.commit() + tx = null + } + return EMPTY + } + if (ROLLBACK.test(query)) { + if (tx) { + await tx.rollback() + tx = null + } + return EMPTY + } + // Data statements and savepoint ops (savepoint / release / rollback to savepoint) run inside + // the transaction. This connection is only handed out inside a transaction scope, where the + // drizzle layer always emits `begin` first, so a statement with no open transaction would + // mean auto-committing it alone (the non-atomic behavior this backend exists to avoid). + if (!tx) throw new Error("libSQL: statement on a transaction connection before begin") + return tx.execute({ sql: query, args: params as never[] }) + } + const close = async () => { + // Safety net: if the scope unwinds with the transaction still open (no commit/rollback was + // emitted), roll it back so the stream is released and nothing partial lingers. + if (tx) { + try { + await tx.rollback() + } catch {} + try { + tx.close() + } catch {} + tx = null + } + } + const conn = identity({ + execute(query, params, transformRows) { + const e = Effect.tryPromise({ try: () => exec(query, params).then(toRows), catch: fail }) + return transformRows ? Effect.map(e, transformRows) : e + }, + executeRaw(query, params) { + return Effect.tryPromise({ try: () => exec(query, params).then(toRows), catch: fail }) + }, + executeValues(query, params) { + return Effect.tryPromise({ try: () => exec(query, params).then(toValues), catch: fail }) + }, + executeUnprepared(query, params, transformRows) { + return this.execute(query, params, transformRows) + }, + executeStream() { + return Stream.die("executeStream not implemented") + }, + }) + return { conn, close } + } + + const semaphore = yield* Semaphore.make(1) + // The permit must cover the STATEMENT, not just the handing-out of the connection: an + // auto-commit write racing an open pinned transaction would get SQLITE_BUSY from the server + // (the remote path has no busy_timeout) and die the caller. Statements inside a transaction + // scope route to the transaction's own connection, so this never self-deadlocks. + // Reads never take the write lock (WAL), so they skip the permit: a permission poll or an + // `active` read must not queue behind an open turn transaction. Busy retry is the backstop if + // a server still reports a conflict. + const isRead = (query: string) => /^\s*(select|pragma)\b/i.test(query) + const guard = (query: string, effect: Effect.Effect) => + isRead(query) ? effect : semaphore.withPermits(1)(effect) + const guarded = identity({ + execute: (query, params, transformRows) => guard(query, connection.execute(query, params, transformRows)), + executeRaw: (query, params) => guard(query, connection.executeRaw(query, params)), + executeValues: (query, params) => guard(query, connection.executeValues(query, params)), + executeUnprepared: (query, params, transformRows) => + guard(query, connection.executeUnprepared(query, params, transformRows)), + executeStream() { + return Stream.die("executeStream not implemented") + }, + }) + const acquirer = Effect.succeed(guarded) + const transactionAcquirer = Effect.uninterruptibleMask((restore) => { + const fiber = Fiber.getCurrent()! + const scope = Context.getUnsafe(fiber.context, Scope.Scope) + const { conn, close } = makeTxConnection() + return Effect.as( + Effect.tap(restore(semaphore.take(1)), () => + // Close the transaction (rollback if still open) before releasing the permit, so the next + // transaction never starts against a half-finished one. + Scope.addFinalizer( + scope, + Effect.gen(function* () { + yield* Effect.promise(() => close()) + yield* semaphore.release(1) + }), + ), + ), + conn, + ) + }) + + return yield* Client.make({ + acquirer, + compiler, + transactionAcquirer, + spanAttributes: [["db.system.name", "sqlite"]], + transformRows, + }) + }) + +export const layer = (config: LibsqlConfig) => + Layer.effect(Client.SqlClient, make(config)).pipe(Layer.provide(Reactivity.layer)) diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 3a64217f6009..b544531e51b5 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -116,6 +116,15 @@ export class SubscriberOverflowError extends Schema.TaggedErrorClass("@opencode/Event/Owner", { + defaultValue: () => undefined, +}) + export interface PublishOptions { readonly id?: ID readonly metadata?: Record @@ -256,6 +265,7 @@ export const layerWith = (options?: LayerOptions) => ) } const list = projectors.get(event.type) ?? [] + const owner = yield* EventOwner return yield* Effect.uninterruptible( Effect.gen(function* () { const committed = yield* db @@ -281,6 +291,18 @@ export const layerWith = (options?: LayerOptions) => }), ) } + // Fence a live append behind the current owner. A superseded attempt + // still holding the loop would otherwise keep writing events under a + // session a newer attempt has claimed. `input` is the replay path, which + // owns its check above. + if (!input && owner !== undefined && row?.ownerID != null && row.ownerID !== owner) { + yield* Effect.die( + new InvalidDurableEventError({ + type: event.type, + message: `Owner fence for aggregate ${aggregateID}: held by ${row.ownerID}, publisher ${owner}`, + }), + ) + } if (input && input.seq <= latest) { const stored = yield* db .select() @@ -345,12 +367,14 @@ export const layerWith = (options?: LayerOptions) => if (commit) yield* commit(seq) yield* db .insert(EventSequenceTable) - .values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }]) + .values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID ?? owner }]) .onConflictDoUpdate({ target: EventSequenceTable.aggregate_id, set: { seq, - ...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}), + ...((input?.ownerID ?? owner) && row?.ownerID == null + ? { owner_id: input?.ownerID ?? owner } + : {}), }, }) .run() diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index b8a5533e7a0e..7b586bcb7db8 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -45,6 +45,10 @@ export const Flag = { OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"], OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"], OPENCODE_DB: process.env["OPENCODE_DB"], + // A libSQL URL (self-hosted sqld or Turso) for a shared, durable event store so any worker can + // resume any session. Takes precedence over OPENCODE_DB (the local file) when set. + OPENCODE_DB_URL: process.env["OPENCODE_DB_URL"], + OPENCODE_DB_AUTH_TOKEN: process.env["OPENCODE_DB_AUTH_TOKEN"], OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"), diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 7da67673c319..c3b43b7ddde2 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -30,6 +30,7 @@ import { SessionTodo } from "./session/todo" import { SkillV2 } from "./skill" import { SkillGuidance } from "./skill/guidance" import { Snapshot } from "./snapshot" +import { SnapshotSync } from "./snapshot-sync" import { SystemContextBuiltIns } from "./system-context/builtins" import { SystemContextRegistry } from "./system-context/registry" import { BuiltInTools } from "./tool/builtins" @@ -75,6 +76,7 @@ export const locationServices = LayerNode.group([ BuiltInTools.node, SessionRunnerModel.node, Snapshot.node, + SnapshotSync.node, SessionRunnerLLM.node, ]) diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index 3f28632a034d..f92b32a51930 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -1,8 +1,11 @@ export * as PermissionV2 from "./permission" +import { createHash } from "node:crypto" +import { and, eq } from "drizzle-orm" import { makeLocationNode } from "./effect/app-node" import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect" import { Permission } from "@opencode-ai/schema/permission" +import { Database } from "./database/database" import { EventV2 } from "./event" import { Location } from "./location" import { AgentV2 } from "./agent" @@ -10,10 +13,15 @@ import { SessionV2 } from "./session" import { SessionStore } from "./session/store" import { Wildcard } from "./util/wildcard" import { PermissionSaved } from "./permission/saved" +import { PermissionRequestTable } from "./permission/sql" export { Effect, Rule, Ruleset } from "@opencode-ai/schema/permission" const missingAgentPermissions: Permission.Ruleset = [{ action: "*", resource: "*", effect: "deny" }] +// A pending ask older than this is treated as abandoned (the turn that raised it was interrupted or +// crashed, so nothing will answer it). Long enough that a human deliberating never trips it. +const PENDING_TTL_MS = 24 * 60 * 60 * 1000 + export const ID = Permission.ID export type ID = typeof ID.Type @@ -114,12 +122,81 @@ const layer = Layer.effect( const agents = yield* AgentV2.Service const sessions = yield* SessionStore.Service const saved = yield* PermissionSaved.Service + const db = (yield* Database.Service).db const pending = new Map() + // Pending asks are durable rows in the shared store, so an ask raised in one process (a + // standalone worker's activity) is visible and replyable from another (the HTTP server), and a + // reply lands even after the asking process restarted. The in-memory deferred stays as the + // same-process fast path; a cross-process reply is observed by polling the row. + const readRow = (id: string) => + db + .select() + .from(PermissionRequestTable) + .where(eq(PermissionRequestTable.id, id)) + .all() + .pipe( + EffectRuntime.orDie, + EffectRuntime.map((rows) => rows[0]), + ) + // An interrupted turn leaves its asks pending with nothing to answer them; without a bound they + // linger forever in list()/forSession() and the decline cascade. A row untouched past the TTL + // is treated as abandoned. Reads sweep them lazily (opportunistic prune), so no scheduler and + // no cross-process coordination. + const pendingRows = (sessionID?: SessionV2.ID) => + EffectRuntime.gen(function* () { + const cutoff = Date.now() - PENDING_TTL_MS + const rows = yield* db + .select() + .from(PermissionRequestTable) + .where( + sessionID + ? and(eq(PermissionRequestTable.session_id, sessionID), eq(PermissionRequestTable.status, "pending")) + : eq(PermissionRequestTable.status, "pending"), + ) + .all() + .pipe(EffectRuntime.orDie) + const fresh: typeof rows = [] + for (const row of rows) { + if (row.time_updated < cutoff) yield* transitionRow(row.id, "pending", "expired") + else fresh.push(row) + } + return fresh + }) + // Status moves are compare-and-set on the stated `from`, so a reply that lost a race, or a + // shutdown racing an approval, cannot overwrite a landed outcome. + const transitionRow = (id: string, from: string, to: string, message?: string) => + db + .update(PermissionRequestTable) + .set({ status: to, message: message ?? null }) + .where(and(eq(PermissionRequestTable.id, id), eq(PermissionRequestTable.status, from))) + .run() + .pipe(EffectRuntime.orDie) + const decodeRow = (row: { payload: string }) => + Schema.decodeUnknownSync(Request)(JSON.parse(row.payload)) as Request + yield* EffectRuntime.addFinalizer(() => - EffectRuntime.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new DeclinedError()), { - discard: true, - }).pipe( + EffectRuntime.forEach( + pending.values(), + (item) => + // Graceful shutdown: a reply may have landed on the row before the local poll saw it. + // Honor it, or the shutdown records a decline the user never made. Only a row that is + // still pending gets retired. + EffectRuntime.gen(function* () { + const row = yield* readRow(item.request.id) + if (row?.status === "approved") { + yield* Deferred.succeed(item.deferred, undefined) + return + } + if (row?.status === "corrected") { + yield* Deferred.fail(item.deferred, new CorrectedError({ feedback: row.message ?? "" })) + return + } + yield* transitionRow(item.request.id, "pending", "expired") + yield* Deferred.fail(item.deferred, new DeclinedError()) + }).pipe(EffectRuntime.catch(() => EffectRuntime.void)), + { discard: true }, + ).pipe( EffectRuntime.ensuring( EffectRuntime.sync(() => { pending.clear() @@ -161,9 +238,21 @@ const layer = Layer.effect( return { effect, rules: all } }) + // A tool-originated ask gets a DETERMINISTIC id (session + callID + action + resources), so a + // re-driven activity resolves to the same durable row instead of filing a duplicate: a reply + // that landed while the asker was dead is honored on the retry, and a still-pending row is + // adopted rather than re-asked. Asks without a tool source keep random ids. + function deterministicID(input: AssertInput) { + if (!input.source?.callID) return undefined + const digest = createHash("sha256") + .update([input.sessionID, input.source.callID, input.action, ...[...input.resources].sort()].join("\u0000")) + .digest("hex") + return ID.create(`per_${digest.slice(0, 26)}`) + } + function request(input: AssertInput): Request { return { - id: input.id ?? ID.create(), + id: input.id ?? deterministicID(input) ?? ID.create(), sessionID: input.sessionID, action: input.action, resources: input.resources, @@ -176,10 +265,35 @@ const layer = Layer.effect( const create = (request: Request, agent?: AgentV2.ID) => EffectRuntime.uninterruptible( EffectRuntime.gen(function* () { + // A retry can land in this process while the prior attempt's waiter is still parked + // (deterministic ids make them the same ask). Share the waiter instead of dying. + const parked = pending.get(request.id) + if (parked) return parked const deferred = yield* Deferred.make() const item = { request, agent, deferred } - if (pending.has(request.id)) return yield* EffectRuntime.die(`Duplicate pending permission ID: ${request.id}`) pending.set(request.id, item) + yield* db + .insert(PermissionRequestTable) + .values({ + id: request.id, + session_id: request.sessionID, + agent: agent ?? null, + payload: JSON.stringify(Schema.encodeSync(Request)(request)), + }) + .onConflictDoNothing() + .run() + .pipe( + EffectRuntime.orDie, + EffectRuntime.onError(() => EffectRuntime.sync(() => pending.delete(request.id))), + ) + // A deterministic id can collide with its own expired row (a prior attempt shut down + // gracefully); revive it so the reply path and pollers see one pending ask again. + yield* db + .update(PermissionRequestTable) + .set({ status: "pending", message: null }) + .where(and(eq(PermissionRequestTable.id, request.id), eq(PermissionRequestTable.status, "expired"))) + .run() + .pipe(EffectRuntime.orDie) yield* events .publish(Event.Asked, request) .pipe(EffectRuntime.onError(() => EffectRuntime.sync(() => pending.delete(request.id)))) @@ -187,6 +301,20 @@ const layer = Layer.effect( }), ) + // Observe a cross-process reply: the replying process updates the row, not our deferred. + const awaitRow = (id: ID): EffectRuntime.Effect => + EffectRuntime.gen(function* () { + for (;;) { + const row = yield* readRow(id) + if (row && row.status !== "pending") { + if (row.status === "approved") return + if (row.status === "corrected") return yield* new CorrectedError({ feedback: row.message ?? "" }) + return yield* new DeclinedError() + } + yield* EffectRuntime.sleep(500) + } + }) + const ask = EffectRuntime.fn("PermissionV2.ask")(function* (input: AssertInput) { const result = yield* evaluateInput(input) const value = request(input) @@ -204,8 +332,19 @@ const layer = Layer.effect( }) } if (result.effect === "allow") return - const item = yield* create(request(input), input.agent) - return yield* restore(Deferred.await(item.deferred)).pipe( + const value = request(input) + // A deterministic id may already have a settled or in-flight row from a prior attempt of + // the same call: honor a reply that landed while the asker was dead, and adopt a pending + // row instead of duplicating the ask. + const existing = yield* readRow(value.id) + if (existing) { + if (existing.status === "approved") return + if (existing.status === "corrected") return yield* new CorrectedError({ feedback: existing.message ?? "" }) + if (existing.status === "declined") return yield* EffectRuntime.die(new DeclinedError()) + // pending or expired: fall through; create adopts (insert no-ops) or revives the row. + } + const item = yield* create(value, input.agent) + return yield* restore(EffectRuntime.raceFirst(Deferred.await(item.deferred), awaitRow(item.request.id))).pipe( EffectRuntime.catchTag("PermissionV2.DeclinedError", (error) => EffectRuntime.die(error)), EffectRuntime.ensuring( EffectRuntime.sync(() => { @@ -217,84 +356,101 @@ const layer = Layer.effect( ), ) + // Complete the local waiter if the ask was raised in this process; a cross-process waiter + // observes the row update through its poll. + const settleLocal = (id: ID, complete: (deferred: Pending["deferred"]) => EffectRuntime.Effect) => + EffectRuntime.suspend(() => { + const item = pending.get(id) + if (!item) return EffectRuntime.void + pending.delete(id) + return EffectRuntime.asVoid(complete(item.deferred)) + }) + + // The durable row is the source of truth, so a reply works from any process (the HTTP server + // replying to an ask raised inside a standalone worker's activity), not just the asking one. const reply = EffectRuntime.fn("PermissionV2.reply")((input: ReplyInput) => EffectRuntime.uninterruptible( EffectRuntime.gen(function* () { - const existing = pending.get(input.requestID) - if (!existing) return yield* new NotFoundError({ requestID: input.requestID }) + const row = yield* readRow(input.requestID) + if (!row || row.status !== "pending") return yield* new NotFoundError({ requestID: input.requestID }) + const existing = decodeRow(row) yield* events.publish(Event.Replied, { - sessionID: existing.request.sessionID, - requestID: existing.request.id, + sessionID: existing.sessionID, + requestID: existing.id, reply: input.reply, }) if (input.reply === "reject") { - yield* Deferred.fail( - existing.deferred, - input.message ? new CorrectedError({ feedback: input.message }) : new DeclinedError(), + yield* transitionRow(existing.id, "pending", input.message ? "corrected" : "declined", input.message) + yield* settleLocal(existing.id, (deferred) => + Deferred.fail( + deferred, + input.message ? new CorrectedError({ feedback: input.message }) : new DeclinedError(), + ), ) - pending.delete(input.requestID) - for (const [id, item] of pending) { - if (item.request.sessionID !== existing.request.sessionID) continue + // A decline cascades to every other pending ask in the session, wherever it was raised. + for (const other of yield* pendingRows(existing.sessionID)) { + if (other.id === existing.id) continue yield* events.publish(Event.Replied, { - sessionID: item.request.sessionID, - requestID: item.request.id, + sessionID: existing.sessionID, + requestID: ID.make(other.id), reply: "reject", }) - yield* Deferred.fail(item.deferred, new DeclinedError()) - pending.delete(id) + yield* transitionRow(other.id, "pending", "declined") + yield* settleLocal(ID.make(other.id), (deferred) => Deferred.fail(deferred, new DeclinedError())) } return } - if (input.reply === "always" && existing.request.save?.length) { + if (input.reply === "always" && existing.save?.length) { yield* saved.add({ projectID: location.project.id, - action: existing.request.action, - resources: existing.request.save, + action: existing.action, + resources: existing.save, }) } - yield* Deferred.succeed(existing.deferred, undefined) - pending.delete(input.requestID) - if (input.reply !== "always" || !existing.request.save?.length) return + yield* transitionRow(existing.id, "pending", "approved") + yield* settleLocal(existing.id, (deferred) => Deferred.succeed(deferred, undefined)) + if (input.reply !== "always" || !existing.save?.length) return + // An always-rule can retro-approve other pending asks it now covers. const rememberedRules = yield* savedRules() - for (const [id, item] of pending) { - const input = { ...item.request } - const rules = yield* configured(item.request.sessionID, item.agent).pipe( + for (const otherRow of yield* pendingRows()) { + if (otherRow.id === existing.id) continue + const other = decodeRow(otherRow) + const agent = otherRow.agent === null ? undefined : AgentV2.ID.make(otherRow.agent) + const rules = yield* configured(other.sessionID, agent).pipe( EffectRuntime.catchTag("Session.NotFoundError", () => EffectRuntime.succeed(undefined)), ) if (!rules) continue - if (denied(input, rules)) continue + if (denied(other, rules)) continue const effective = [...rules, ...rememberedRules] - if ( - !item.request.resources.every( - (resource) => evaluate(item.request.action, resource, effective).effect === "allow", - ) - ) + if (!other.resources.every((resource) => evaluate(other.action, resource, effective).effect === "allow")) continue yield* events.publish(Event.Replied, { - sessionID: item.request.sessionID, - requestID: item.request.id, + sessionID: other.sessionID, + requestID: other.id, reply: "always", }) - yield* Deferred.succeed(item.deferred, undefined) - pending.delete(id) + yield* transitionRow(other.id, "pending", "approved") + yield* settleLocal(other.id, (deferred) => Deferred.succeed(deferred, undefined)) } }), ), ) + // Reads come from the durable rows, so serve can list and inspect asks raised by any worker. const list = EffectRuntime.fn("PermissionV2.list")(function* () { - return Array.from(pending.values(), (item) => item.request) + return (yield* pendingRows()).map(decodeRow) }) const get = EffectRuntime.fn("PermissionV2.get")(function* (id: ID) { - return pending.get(id)?.request + const row = yield* readRow(id) + return row && row.status === "pending" ? decodeRow(row) : undefined }) const forSession = EffectRuntime.fn("PermissionV2.forSession")(function* (sessionID: SessionV2.ID) { - return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID) + return (yield* pendingRows(sessionID)).map(decodeRow) }) return Service.of({ ask, assert, reply, get, forSession, list }) @@ -306,5 +462,5 @@ export const locationLayer = layer.pipe(Layer.provideMerge(AgentV2.locationLayer export const node = makeLocationNode({ service: Service, layer, - deps: [EventV2.node, Location.node, AgentV2.node, SessionStore.node, PermissionSaved.node], + deps: [EventV2.node, Location.node, AgentV2.node, SessionStore.node, PermissionSaved.node, Database.node], }) diff --git a/packages/core/src/permission/sql.ts b/packages/core/src/permission/sql.ts index c395555d7950..d29c737583a9 100644 --- a/packages/core/src/permission/sql.ts +++ b/packages/core/src/permission/sql.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core" +import { index, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core" import { Timestamps } from "../database/schema.sql" import { ProjectV2 } from "../project" import { ProjectTable } from "../project/sql" @@ -18,3 +18,25 @@ export const PermissionTable = sqliteTable( }, (table) => [uniqueIndex("permission_project_action_resource_idx").on(table.project_id, table.action, table.resource)], ) + +// A durable pending-approval record, so an ask raised by one process (a standalone worker) can be +// listed and replied to from another (the HTTP server) via the shared store, and survives the asking +// process. `payload` is the JSON-encoded PermissionV2.Request; status transitions +// pending -> approved | declined | corrected | expired. +export const PermissionRequestTable = sqliteTable( + "permission_request", + { + id: text().primaryKey(), + session_id: text().notNull(), + agent: text(), + payload: text().notNull(), + status: text().notNull().default("pending"), + message: text(), + ...Timestamps, + }, + (table) => [ + index("permission_request_session_status_idx").on(table.session_id, table.status), + // The no-session list() and the TTL sweep both filter by status alone. + index("permission_request_status_idx").on(table.status), + ], +) diff --git a/packages/core/src/question.ts b/packages/core/src/question.ts index 79e0ea5e0384..73abb6aa07a4 100644 --- a/packages/core/src/question.ts +++ b/packages/core/src/question.ts @@ -1,9 +1,13 @@ export * as QuestionV2 from "./question" +import { createHash } from "node:crypto" +import { and, eq } from "drizzle-orm" import { makeLocationNode } from "./effect/app-node" import { Context, Deferred, Effect, Layer, Schema } from "effect" import { Question } from "@opencode-ai/schema/question" +import { Database } from "./database/database" import { EventV2 } from "./event" +import { QuestionRequestTable } from "./question/sql" import { SessionSchema } from "./session/schema" export const ID = Question.ID @@ -32,6 +36,13 @@ export type Reply = typeof Reply.Type export const Event = Question.Event +// A pending question older than this is treated as abandoned (the turn that raised it was +// interrupted or crashed, so nothing is waiting for the answer). Long enough that a human +// deliberating never trips it. +const PENDING_TTL_MS = 24 * 60 * 60 * 1000 + +const Answers = Schema.Array(Question.Answer) + export class RejectedError extends Schema.TaggedErrorClass()("QuestionV2.RejectedError", {}) { override get message() { return "The user dismissed this question" @@ -67,21 +78,89 @@ interface Pending { readonly deferred: Deferred.Deferred, RejectedError> } -/** - * Location-owned pending prompts. The Location layer map must materialize this - * layer once per embedded Location so replies cannot settle another Location's - * deferred request. - */ +// A tool-originated ask gets a DETERMINISTIC id (session + callID + question texts), so a re-driven +// activity resolves to the same durable row instead of filing a duplicate: answers that landed +// while the asker was dead are honored on the retry, and a still-pending row is adopted rather +// than re-asked. Asks without a tool source keep random ids. +function deterministicID(input: AskInput) { + if (!input.tool?.callID) return undefined + const digest = createHash("sha256") + .update([input.sessionID, input.tool.callID, ...input.questions.map((item) => item.question)].join("\u0000")) + .digest("hex") + return ID.ascending(`que_${digest.slice(0, 26)}`) +} + const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service + const db = (yield* Database.Service).db const pending = new Map() + // Pending questions are durable rows in the shared store, so an ask raised in one process (a + // standalone worker's activity) is visible and answerable from another (the HTTP server), and + // an answer lands even after the asking process restarted. The in-memory deferred stays as the + // same-process fast path; a cross-process answer is observed by polling the row. + const readRow = (id: string) => + db + .select() + .from(QuestionRequestTable) + .where(eq(QuestionRequestTable.id, id)) + .all() + .pipe( + Effect.orDie, + Effect.map((rows) => rows[0]), + ) + // Status moves are compare-and-set on the stated `from`, so a reply that lost a race, or a + // shutdown racing an answer, cannot overwrite a landed outcome. + const transitionRow = (id: string, from: string, to: string, answers?: string) => + db + .update(QuestionRequestTable) + .set({ status: to, answers: answers ?? null }) + .where(and(eq(QuestionRequestTable.id, id), eq(QuestionRequestTable.status, from))) + .run() + .pipe(Effect.orDie) + // An interrupted turn leaves its questions pending with nothing waiting for the answers; a row + // untouched past the TTL is treated as abandoned. Reads sweep them lazily. + const pendingRows = () => + Effect.gen(function* () { + const cutoff = Date.now() - PENDING_TTL_MS + const rows = yield* db + .select() + .from(QuestionRequestTable) + .where(eq(QuestionRequestTable.status, "pending")) + .all() + .pipe(Effect.orDie) + const fresh: typeof rows = [] + for (const row of rows) { + if (row.time_updated < cutoff) yield* transitionRow(row.id, "pending", "expired") + else fresh.push(row) + } + return fresh + }) + const decodeRow = (row: { payload: string }) => + Schema.decodeUnknownSync(Request)(JSON.parse(row.payload)) as Request + const decodeAnswers = (raw: string | null): ReadonlyArray => + raw ? (Schema.decodeUnknownSync(Answers)(JSON.parse(raw)) as ReadonlyArray) : [] + yield* Effect.addFinalizer(() => - Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), { - discard: true, - }).pipe( + Effect.forEach( + pending.values(), + (item) => + // Graceful shutdown: an answer may have landed on the row before the local poll saw it. + // Honor it, or the shutdown rejects a question the user already answered. Only a row that + // is still pending gets retired. + Effect.gen(function* () { + const row = yield* readRow(item.request.id) + if (row?.status === "answered") { + yield* Deferred.succeed(item.deferred, decodeAnswers(row.answers)) + return + } + yield* transitionRow(item.request.id, "pending", "expired") + yield* Deferred.fail(item.deferred, new RejectedError()) + }).pipe(Effect.catch(() => Effect.void)), + { discard: true }, + ).pipe( Effect.ensuring( Effect.sync(() => { pending.clear() @@ -90,18 +169,75 @@ const layer = Layer.effect( ), ) + const create = (request: Request) => + Effect.uninterruptible( + Effect.gen(function* () { + // A retry can land in this process while the prior attempt's waiter is still parked + // (deterministic ids make them the same ask). Share the waiter instead of dying. + const parked = pending.get(request.id) + if (parked) return parked + const deferred = yield* Deferred.make, RejectedError>() + const item: Pending = { request, deferred } + pending.set(request.id, item) + yield* db + .insert(QuestionRequestTable) + .values({ + id: request.id, + session_id: request.sessionID, + payload: JSON.stringify(Schema.encodeSync(Request)(request)), + }) + .onConflictDoNothing() + .run() + .pipe( + Effect.orDie, + Effect.onError(() => Effect.sync(() => pending.delete(request.id))), + ) + // A deterministic id can collide with its own expired row (a prior attempt shut down + // gracefully); revive it so the reply path and pollers see one pending ask again. + yield* db + .update(QuestionRequestTable) + .set({ status: "pending", answers: null }) + .where(and(eq(QuestionRequestTable.id, request.id), eq(QuestionRequestTable.status, "expired"))) + .run() + .pipe(Effect.orDie) + yield* events + .publish(Event.Asked, request) + .pipe(Effect.onError(() => Effect.sync(() => pending.delete(request.id)))) + return item + }), + ) + + // Observe a cross-process answer: the replying process updates the row, not our deferred. + const awaitRow = (id: ID): Effect.Effect, RejectedError> => + Effect.gen(function* () { + for (;;) { + const row = yield* readRow(id) + if (row && row.status !== "pending") { + if (row.status === "answered") return decodeAnswers(row.answers) + return yield* new RejectedError() + } + yield* Effect.sleep(500) + } + }) + const ask = Effect.fn("QuestionV2.ask")((input: AskInput) => Effect.uninterruptibleMask((restore) => Effect.gen(function* () { - const id = ID.ascending() - const deferred = yield* Deferred.make, RejectedError>() - const request: Request = { id, ...input } - pending.set(id, { request, deferred }) - return yield* events.publish(Event.Asked, request).pipe( - Effect.andThen(restore(Deferred.await(deferred))), + const request: Request = { id: deterministicID(input) ?? ID.ascending(), ...input } + // A deterministic id may already have a settled or in-flight row from a prior attempt of + // the same call: honor answers that landed while the asker was dead, and adopt a pending + // row instead of duplicating the ask. + const existing = yield* readRow(request.id) + if (existing) { + if (existing.status === "answered") return decodeAnswers(existing.answers) + if (existing.status === "rejected") return yield* new RejectedError() + // pending or expired: fall through; create adopts (insert no-ops) or revives the row. + } + const item = yield* create(request) + return yield* restore(Effect.raceFirst(Deferred.await(item.deferred), awaitRow(item.request.id))).pipe( Effect.ensuring( Effect.sync(() => { - pending.delete(id) + pending.delete(item.request.id) }), ), ) @@ -109,18 +245,36 @@ const layer = Layer.effect( ), ) + // Complete the local waiter if the ask was raised in this process; a cross-process waiter + // observes the row update through its poll. + const settleLocal = (id: ID, complete: (deferred: Pending["deferred"]) => Effect.Effect) => + Effect.suspend(() => { + const item = pending.get(id) + if (!item) return Effect.void + pending.delete(id) + return Effect.asVoid(complete(item.deferred)) + }) + + // The durable row is the source of truth, so a reply works from any process (the HTTP server + // answering a question raised inside a standalone worker's activity), not just the asking one. const reply = Effect.fn("QuestionV2.reply")((input: ReplyInput) => Effect.uninterruptible( Effect.gen(function* () { - const existing = pending.get(input.requestID) - if (!existing) return yield* new NotFoundError({ requestID: input.requestID }) + const row = yield* readRow(input.requestID) + if (!row || row.status !== "pending") return yield* new NotFoundError({ requestID: input.requestID }) + const existing = decodeRow(row) yield* events.publish(Event.Replied, { - sessionID: existing.request.sessionID, - requestID: existing.request.id, + sessionID: existing.sessionID, + requestID: existing.id, answers: input.answers.map((answer) => [...answer]), }) - yield* Deferred.succeed(existing.deferred, input.answers) - pending.delete(input.requestID) + yield* transitionRow( + existing.id, + "pending", + "answered", + JSON.stringify(Schema.encodeSync(Answers)(input.answers)), + ) + yield* settleLocal(existing.id, (deferred) => Deferred.succeed(deferred, input.answers)) }), ), ) @@ -128,20 +282,22 @@ const layer = Layer.effect( const reject = Effect.fn("QuestionV2.reject")((requestID: ID) => Effect.uninterruptible( Effect.gen(function* () { - const existing = pending.get(requestID) - if (!existing) return yield* new NotFoundError({ requestID }) + const row = yield* readRow(requestID) + if (!row || row.status !== "pending") return yield* new NotFoundError({ requestID }) + const existing = decodeRow(row) yield* events.publish(Event.Rejected, { - sessionID: existing.request.sessionID, - requestID: existing.request.id, + sessionID: existing.sessionID, + requestID: existing.id, }) - yield* Deferred.fail(existing.deferred, new RejectedError()) - pending.delete(requestID) + yield* transitionRow(existing.id, "pending", "rejected") + yield* settleLocal(existing.id, (deferred) => Deferred.fail(deferred, new RejectedError())) }), ), ) + // Reads come from the durable rows, so serve can list questions raised by any worker. const list = Effect.fn("QuestionV2.list")(function* () { - return Array.from(pending.values(), (item) => item.request) + return (yield* pendingRows()).map(decodeRow) }) return Service.of({ ask, reply, reject, list }) @@ -150,4 +306,4 @@ const layer = Layer.effect( export const locationLayer = layer -export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] }) +export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Database.node] }) diff --git a/packages/core/src/question/sql.ts b/packages/core/src/question/sql.ts new file mode 100644 index 000000000000..6dc08a6e0890 --- /dev/null +++ b/packages/core/src/question/sql.ts @@ -0,0 +1,23 @@ +import { index, sqliteTable, text } from "drizzle-orm/sqlite-core" +import { Timestamps } from "../database/schema.sql" + +// A durable pending-question record, so an ask raised by one process (a standalone worker's +// activity) can be listed and answered from another (the HTTP server) via the shared store, and +// survives the asking process. `payload` is the JSON-encoded QuestionV2.Request; `answers` holds +// the JSON-encoded reply once one lands; status transitions pending -> answered | rejected | expired. +export const QuestionRequestTable = sqliteTable( + "question_request", + { + id: text().primaryKey(), + session_id: text().notNull(), + payload: text().notNull(), + status: text().notNull().default("pending"), + answers: text(), + ...Timestamps, + }, + (table) => [ + index("question_request_session_status_idx").on(table.session_id, table.status), + // The no-session list() and the TTL sweep both filter by status alone. + index("question_request_status_idx").on(table.status), + ], +) diff --git a/packages/core/src/session/error.ts b/packages/core/src/session/error.ts index 158e46dd073e..096a0c9049c2 100644 --- a/packages/core/src/session/error.ts +++ b/packages/core/src/session/error.ts @@ -22,3 +22,16 @@ export class ContextSnapshotDecodeError extends Schema.TaggedErrorClass()( + "Session.SessionRunDeclinedError", + { + sessionID: SessionSchema.ID, + }, +) { + override get message() { + return `Session run halted by the user: ${this.sessionID}` + } +} diff --git a/packages/core/src/session/execution/worktree.ts b/packages/core/src/session/execution/worktree.ts new file mode 100644 index 000000000000..8fbc5259b330 --- /dev/null +++ b/packages/core/src/session/execution/worktree.ts @@ -0,0 +1,144 @@ +export * as WorktreeMaterializer from "./worktree" + +// Rebuilds a missing project worktree from the shared store before a drain runs. This closes the +// host-local gap in cross-host resume: file tools need the tree, and a fresh worker does not have +// it. The capture side (snapshot-sync.ts) ships each snapshot as an incremental git pack; this +// side indexes every pack for the worktree into a fresh repo and checks out the newest tree. +// Ignored files and dependencies are not captured, so a bootstrap step (install, build) stays the +// project's own concern. + +import { rm, writeFile } from "node:fs/promises" +import path from "path" +import { Cause, Context, Effect, Layer } from "effect" +import { ChildProcess } from "effect/unstable/process" +import { asc, desc, eq } from "drizzle-orm" +import { Database } from "../../database/database" +import { makeGlobalNode } from "../../effect/app-node" +import { KeyedMutex } from "../../effect/keyed-mutex" +import { FSUtil } from "../../fs-util" +import { Git } from "../../git" +import { AppProcess } from "../../process" +import { AbsolutePath } from "../../schema" +import { SnapshotPackTable } from "../../snapshot/sql" + +export interface Interface { + /** + * Make sure the session's directory exists, rebuilding its worktree from stored snapshot packs + * when it does not. A directory with no stored packs, or one whose worktree root already + * exists, is left alone. Never fails the caller. + */ + readonly ensure: (directory: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/WorktreeMaterializer") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const git = yield* Git.Service + const proc = yield* AppProcess.Service + const { db } = yield* Database.Service + const locks = KeyedMutex.makeUnsafe() + + const materialize = Effect.fnUntraced(function* (tip: typeof SnapshotPackTable.$inferSelect) { + const worktree = AbsolutePath.make(tip.worktree) + const repository = yield* git.repo + .create({ worktree, gitDirectory: AbsolutePath.make(path.join(worktree, ".git")) }) + .pipe(Effect.orDie) + // Index every pack shipped for this worktree; objects accumulate, the newest tree wins. + const rows = yield* db + .select() + .from(SnapshotPackTable) + .where(eq(SnapshotPackTable.worktree, tip.worktree)) + .orderBy(asc(SnapshotPackTable.time_created)) + .all() + .pipe(Effect.orDie) + const packDirectory = path.join(repository.gitDirectory, "objects", "pack") + yield* fs.ensureDir(packDirectory).pipe(Effect.orDie) + for (const row of rows) { + const packFile = path.join(packDirectory, `pack-${row.id}.pack`) + yield* Effect.promise(() => writeFile(packFile, row.pack)) + const indexed = yield* proc + .run( + ChildProcess.make("git", ["--git-dir", repository.gitDirectory, "index-pack", packFile], { + cwd: worktree, + extendEnv: true, + }), + ) + .pipe(Effect.orDie) + if (indexed.exitCode !== 0) + return yield* Effect.die(new Error(`index-pack: ${indexed.stderr.toString("utf8")}`)) + } + yield* git.tree.checkout({ repository, tree: Git.TreeID.make(tip.tree) }).pipe(Effect.orDie) + // Point HEAD at the sync commit so the rebuilt repo reads as a clean checkout, not an + // unborn branch over a full untracked tree. Cosmetic; the files above are what matter. + yield* proc + .run( + ChildProcess.make( + "git", + ["--git-dir", repository.gitDirectory, "update-ref", "refs/heads/opencode-restore", tip.id], + { cwd: worktree, extendEnv: true }, + ), + ) + .pipe(Effect.ignore) + yield* proc + .run( + ChildProcess.make( + "git", + ["--git-dir", repository.gitDirectory, "symbolic-ref", "HEAD", "refs/heads/opencode-restore"], + { cwd: worktree, extendEnv: true }, + ), + ) + .pipe(Effect.ignore) + yield* Effect.logInfo("materialized worktree from snapshot packs", { + worktree: tip.worktree, + packs: rows.length, + tree: tip.tree, + }) + }) + + const ensure = Effect.fn("WorktreeMaterializer.ensure")(function* (directory: string) { + if (yield* fs.existsSafe(directory)) return + // The newest capture whose session ran in this directory decides which worktree to rebuild. + const tip = yield* db + .select() + .from(SnapshotPackTable) + .where(eq(SnapshotPackTable.directory, directory)) + .orderBy(desc(SnapshotPackTable.time_created)) + .limit(1) + .get() + .pipe(Effect.orDie) + if (!tip) return + yield* locks.withLock(tip.worktree)( + Effect.gen(function* () { + // Re-check inside the lock (a concurrent drain may have rebuilt it), and never touch a + // worktree root that already exists: something else owns that tree. + if (yield* fs.existsSafe(tip.worktree)) return + yield* materialize(tip).pipe( + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => + Effect.gen(function* () { + // A half-built tree would pass the exists check forever; remove what we created. + yield* Effect.promise(() => rm(tip.worktree, { recursive: true, force: true })) + yield* Effect.logWarning("failed to materialize worktree", { + worktree: tip.worktree, + cause, + }) + }), + ), + ) + }), + ) + }) + + return Service.of({ ensure }) + }), +) + +export const node = makeGlobalNode({ + service: Service, + layer, + deps: [Database.node, FSUtil.node, Git.node, AppProcess.node], +}) diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index 634075dd91b2..1a58cd1865d0 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -3,7 +3,8 @@ export * as SessionRunner from "./index" import type { LLMError } from "@opencode-ai/llm" import { Context, Effect } from "effect" import { SessionSchema } from "../schema" -import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error" +import type { ContextSnapshotDecodeError, MessageDecodeError, SessionRunDeclinedError } from "../error" +import type { SessionInput } from "../input" import { SessionRunnerModel } from "./model" import type { SystemContext } from "../../system-context/index" import type { ToolOutputStore } from "../../tool-output-store" @@ -13,9 +14,26 @@ export type RunError = | SessionRunnerModel.Error | MessageDecodeError | ContextSnapshotDecodeError + | SessionRunDeclinedError | SystemContext.InitializationBlocked | ToolOutputStore.Error +/** Input for one step (one provider attempt + its tools) of a turn. */ +export interface StepInput { + readonly sessionID: SessionSchema.ID + readonly step: number + readonly promotion: SessionInput.Delivery | undefined + readonly first: boolean + readonly force: boolean +} + +/** Result of one step: whether to continue, and the next loop state. */ +export interface StepResult { + readonly continue: boolean + readonly step: number + readonly promotion: SessionInput.Delivery | undefined +} + /** Runs one local continuation from already-recorded Session history. */ export interface Interface { /** Drains eligible durable work. Explicit runs perform one provider attempt even when no work is eligible. */ @@ -23,6 +41,9 @@ export interface Interface { readonly sessionID: SessionSchema.ID readonly force: boolean }) => Effect.Effect + /** Run exactly one step and report the next loop state, so a caller (e.g. a Temporal workflow) + * can drive the turn one step at a time. Mirrors one iteration of `run`'s loop. */ + readonly runStep: (input: StepInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/SessionRunner") {} diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 874086a06bdb..a9165f57f762 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -29,14 +29,17 @@ import { SessionCompaction } from "../compaction" import { SessionEvent } from "../event" import { SessionHistory } from "../history" import { SessionInput } from "../input" +import { SessionMessage } from "../message" import { SessionSchema } from "../schema" import { SessionStore } from "../store" -import { type RunError, Service } from "./index" +import { type RunError, Service, type StepInput } from "./index" import { SessionRunnerModel } from "./model" -import { createLLMEventPublisher } from "./publish-llm-event" +import { createLLMEventPublisher, emitToolResult } from "./publish-llm-event" import { toLLMMessages } from "./to-llm-message" import { MAX_STEPS_PROMPT } from "./max-steps" +import { DEFAULT_MAX_STEPS, REPEAT_LIMIT, REPEATED_CALLS_PROMPT, trailingIdenticalToolSteps } from "./loop-guard" import { Snapshot } from "../../snapshot" +import { SnapshotSync } from "../../snapshot-sync" import { makeLocationNode } from "../../effect/app-node" import { llmClient } from "../../effect/app-node-platform" @@ -52,7 +55,7 @@ import { llmClient } from "../../effect/app-node-platform" * - [ ] Mark busy, retrying, idle, interrupted, or terminal-failure status durably. * - [ ] Honor interruption and reject stale work after runtime attachment replacement. * - [x] Honor optional agent step limits. - * - [ ] Bound provider retries and repeated identical tool calls. + * - [x] Bound provider retries and repeated identical tool calls. * * - Runtime context assembly * - Track V1 runtime-context parity canonically in `specs/v2/session.md`. @@ -105,6 +108,7 @@ const layer = Layer.effect( const referenceGuidance = yield* ReferenceGuidance.Service const config = yield* Config.Service const snapshots = yield* Snapshot.Service + const snapshotSync = yield* SnapshotSync.Service const db = (yield* Database.Service).db const compaction = SessionCompaction.make({ events, llm, config: yield* config.entries() }) const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) { @@ -116,10 +120,13 @@ const layer = Layer.effect( const getContext = Effect.fn("SessionRunner.getContext")(function* (sessionID: SessionSchema.ID) { return yield* store.context(sessionID) }) + // `preloaded` lets one projected-history read serve the step-entry checks; callers that just + // mutated the projection must not pass it, or they act on a stale view. const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* ( sessionID: SessionSchema.ID, + preloaded?: ReadonlyArray, ) { - for (const message of yield* getContext(sessionID)) { + for (const message of preloaded ?? (yield* getContext(sessionID))) { if (message.type !== "assistant") continue for (const tool of message.content) { if (tool.type !== "tool" || (tool.state.status !== "pending" && tool.state.status !== "running")) continue @@ -141,6 +148,25 @@ const layer = Layer.effect( const awaitToolFibers = (fibers: FiberSet.FiberSet) => Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers)) + // A crashed drain leaves durable evidence that the input tables no longer show: promotion + // consumed the pending row inside the turn's own transaction, so a re-driven activity that + // checks only hasPending would drop the crashed turn as a no-op. Recoverable work = the latest + // conversational message is a user prompt with no assistant reply, or the latest assistant is + // still in flight. An interrupted turn stays excluded because its cleanup completes the + // assistant via Step.Failed. + const hasRecoverableWork = Effect.fnUntraced(function* ( + sessionID: SessionSchema.ID, + preloaded?: ReadonlyArray, + ) { + const context = preloaded ?? (yield* getContext(sessionID)) + for (let index = context.length - 1; index >= 0; index--) { + const message = context[index] + if (message?.type === "assistant") return !message.time.completed + if (message?.type === "user") return true + } + return false + }) + // Match V1: declining a user prompt halts the loop instead of becoming model-facing tool output. const isUserDeclined = (cause: Cause.Cause) => cause.reasons.some( @@ -199,7 +225,12 @@ const layer = Layer.effect( const model = yield* models.resolve(session) const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq) const context = entries.map((entry) => entry.message) - const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps + // Two loop bounds, both ending in one final text-only step: a ceiling on provider attempts + // (the agent's configured limit, else a default so no run is unbounded), and the stuck loop + // where the model repeats the exact same tool calls step after step. Detection reads only the + // durable history, so it holds across re-drives too. + const stuck = trailingIdenticalToolSteps(context) >= REPEAT_LIMIT + const isLastStep = stuck || currentStep >= (agent.info?.steps ?? DEFAULT_MAX_STEPS) const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions) const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id const request = LLM.request({ @@ -215,13 +246,18 @@ const layer = Layer.effect( system: [agent.info?.system, system.baseline] .filter((part): part is string => part !== undefined && part.length > 0) .map(SystemPart.make), - messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])], + messages: [ + ...toLLMMessages(context, model), + ...(isLastStep ? [Message.assistant(stuck ? REPEATED_CALLS_PROMPT : MAX_STEPS_PROMPT)] : []), + ], tools: toolMaterialization?.definitions ?? [], toolChoice: isLastStep ? "none" : undefined, }) if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request })) return yield* Effect.die(continueAfterCompaction(currentStep)) const startSnapshot = yield* snapshots.capture() + // Ship the pre-step tree so another host can rebuild the worktree; best-effort inside push. + if (startSnapshot) yield* snapshotSync.push(startSnapshot) const publisher = createLLMEventPublisher(events, { sessionID: session.id, agent: agent.id, @@ -323,6 +359,8 @@ const layer = Layer.effect( const stepSettlement = publisher.stepSettlement() if (stepSettlement && !publisher.hasProviderError()) { const endSnapshot = yield* snapshots.capture() + // Ship the post-step tree: this is the state a resumed step on another host needs. + if (endSnapshot) yield* snapshotSync.push(endSnapshot) const files = startSnapshot && endSnapshot ? yield* snapshots @@ -393,10 +431,14 @@ const layer = Layer.effect( }) { const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer") const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue") - if (!input.force && !hasSteer && !hasQueue) return - yield* failInterruptedTools(input.sessionID) + const entryContext = yield* getContext(input.sessionID) + const recover = + !input.force && !hasSteer && !hasQueue && + (yield* hasRecoverableWork(input.sessionID, entryContext)) + if (!input.force && !hasSteer && !hasQueue && !recover) return + yield* failInterruptedTools(input.sessionID, entryContext) let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined - let shouldRun = input.force || hasSteer || hasQueue + let shouldRun = input.force || hasSteer || hasQueue || recover while (shouldRun) { let needsContinuation = true let step = 1 @@ -412,8 +454,141 @@ const layer = Layer.effect( } }) + // Where the turn goes once a step has closed. Steering outranks the queue because it belongs to + // the turn that is running; a queued prompt starts a turn of its own, so its step count restarts. + const stepContinuation = Effect.fn("SessionRunner.stepContinuation")(function* ( + sessionID: SessionSchema.ID, + needsContinuation: boolean, + step: number, + ) { + const steer = needsContinuation ? true : yield* SessionInput.hasPending(db, sessionID, "steer") + if (steer) return { continue: true, step: step + 1, promotion: "steer" as SessionInput.Delivery } + const queue = yield* SessionInput.hasPending(db, sessionID, "queue") + if (queue) return { continue: true, step: 1, promotion: "queue" as SessionInput.Delivery } + return { continue: false, step: step + 1, promotion: undefined } + }) + + // Tool.Called is recorded before the tool runs, so once a crashed step shows a dispatched tool, + // re-streaming it could run that side effect twice. The step is closed from the log instead, + // with no model call: completed results are kept, and only tools declared idempotent are run + // again. Usage is recorded as zero, since the provider's numbers died with the attempt. + const resumeCrashedStep = Effect.fn("SessionRunner.resumeCrashedStep")(function* ( + input: { + readonly sessionID: SessionSchema.ID + readonly step: number + }, + preloaded?: ReadonlyArray, + ) { + const context = preloaded ?? (yield* getContext(input.sessionID)) + // At most one assistant is in flight (the projector supersedes older ones); it only exists at + // step entry on a re-drive, never on a fresh step. + const inFlight = context.findLast( + (message): message is SessionMessage.Assistant => message.type === "assistant" && !message.time.completed, + ) + if (!inFlight) return undefined + const toolParts = inFlight.content.filter( + (part): part is SessionMessage.AssistantTool => part.type === "tool", + ) + const dispatched = toolParts.some( + (part) => part.state.status === "running" || part.state.status === "completed", + ) + if (!dispatched) return undefined + // A tool declared idempotent (a pure read) has no external side effect, so it is safe to + // re-run: re-settle it for a real result instead of failing it. Everything else still open is + // failed below -- we cannot know whether a side-effecting tool already ran. Completed tools + // keep their recorded results either way. + const session = yield* getSession(input.sessionID) + const agent = yield* agents.select(session.agent) + const materialization = yield* tools.materialize(agent.info?.permissions) + for (const part of toolParts) { + if (part.state.status !== "running") continue + if (!materialization.idempotent(part.name)) continue + const settlement = yield* materialization.settle({ + sessionID: session.id, + agent: agent.id, + assistantMessageID: inFlight.id, + call: LLMEvent.toolCall({ id: part.id, name: part.name, input: part.state.input }), + }) + yield* emitToolResult(events, { + sessionID: session.id, + assistantMessageID: inFlight.id, + callID: part.id, + result: settlement.result, + output: settlement.output, + outputPaths: settlement.outputPaths, + provider: { + executed: part.provider?.executed ?? false, + ...(part.provider?.metadata === undefined ? {} : { metadata: part.provider.metadata }), + }, + }) + } + // Fail whatever is still open (non-idempotent or never dispatched); completed and re-settled + // tools are terminal now and are skipped. + yield* failInterruptedTools(input.sessionID) + const startSnapshot = inFlight.snapshot?.start + const endSnapshot = yield* snapshots.capture() + if (endSnapshot) yield* snapshotSync.push(endSnapshot) + const files = + startSnapshot && endSnapshot + ? yield* snapshots + .files({ from: Snapshot.ID.make(startSnapshot), to: endSnapshot }) + .pipe(Effect.catch(() => Effect.succeed(undefined))) + : undefined + const localTools = toolParts.some((part) => part.provider?.executed !== true) + yield* events.publish(SessionEvent.Step.Ended, { + sessionID: input.sessionID, + timestamp: yield* DateTime.now, + assistantMessageID: inFlight.id, + // "tool-calls" only when a local tool actually needs a follow-up turn; a step whose tools + // were all provider-executed finalizes as a plain stop. + finish: localTools ? "tool-calls" : "stop", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + snapshot: endSnapshot, + files, + }) + // A step with local tool calls continues so the model sees the reused or failed results. + return yield* stepContinuation(input.sessionID, localTools, input.step) + }) + + // One iteration of `run`'s loop, exposed so a Temporal workflow can drive the turn one step at + // a time (each step = one runTurn = one provider attempt + its tools). Semantics match `run`. + const runStep = Effect.fn("SessionRunner.runStep")(function* (input: StepInput) { + // One projected-history load serves all the entry checks; nothing mutates the projection + // between them. The turn itself reloads after the first mutation. + const entryContext = yield* getContext(input.sessionID) + // Re-drive of a crashed step: finalize it from the log rather than re-calling the model and + // re-running its already-dispatched tools. + const resumed = yield* resumeCrashedStep(input, entryContext) + if (resumed) return resumed + let promotion = input.promotion + if (input.first) { + const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer") + const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue") + // Same recovery gate as `run`: a first-step retry whose prompt was already promoted (and + // whose crash predates any tool dispatch, so resumeCrashedStep had nothing to finalize) + // must re-stream, not no-op. + if ( + !input.force && + !hasSteer && + !hasQueue && + !(yield* hasRecoverableWork(input.sessionID, entryContext)) + ) + return { continue: false, step: input.step, promotion: undefined } + promotion = hasSteer ? "steer" : hasQueue ? "queue" : undefined + } + // Close tools left pending/running by an interrupted attempt before every turn, not just the + // first. A mid-turn re-drive (first=false, from a Temporal step retry) would otherwise + // re-stream a request with a dangling tool_use and no tool_result, which the provider rejects + // -- a retry poison loop. This is a no-op on a healthy step (the prior step settled its tools). + yield* failInterruptedTools(input.sessionID, entryContext) + const result = yield* runTurn(input.sessionID, promotion, input.step) + return yield* stepContinuation(input.sessionID, result.needsContinuation, result.step) + }) + return Service.of({ run, + runStep, }) }), ) @@ -434,6 +609,7 @@ export const node = makeLocationNode({ ReferenceGuidance.node, Config.node, Snapshot.node, + SnapshotSync.node, Database.node, ], }) diff --git a/packages/core/src/session/runner/loop-guard.ts b/packages/core/src/session/runner/loop-guard.ts new file mode 100644 index 000000000000..56c777c1be27 --- /dev/null +++ b/packages/core/src/session/runner/loop-guard.ts @@ -0,0 +1,61 @@ +// Turn-level loop bounds. HTTP-level provider retries are bounded in the LLM RequestExecutor; these +// bound the RUNNER's step loop: a ceiling on provider attempts per run for agents with no configured +// step limit, and detection of the degenerate stuck loop (the model repeating the exact same tool +// calls step after step). Both funnel into the existing last-step machinery, so the model gets one +// final text-only step instead of looping forever. + +import type { SessionMessage } from "../message" + +/** Ceiling on provider attempts per run when the agent config sets no step limit. */ +export const DEFAULT_MAX_STEPS = 200 + +/** Consecutive identical tool-call steps that count as a stuck loop. */ +export const REPEAT_LIMIT = 3 + +export const REPEATED_CALLS_PROMPT = `CRITICAL - REPEATED IDENTICAL TOOL CALLS + +The last several steps made exactly the same tool calls with exactly the same inputs. Repeating them +again will not produce a different result. Tools are disabled until next user input. Respond with +text only. + +Response must include: +- A summary of what has been accomplished so far +- What the repeated tool calls were trying to achieve and why that appears blocked +- Recommendations for what should be done next + +Do NOT make any tool calls. Respond with text ONLY.` + +// One step's identity: the full set of its tool-call signatures (name + exact input). The entire +// set must repeat for a step to count -- a step that reads the same file but also makes a different +// edit is iterating, not stuck. +const stepSignature = (message: SessionMessage.Assistant) => { + const signatures: string[] = [] + for (const part of message.content) { + if (part.type !== "tool") continue + signatures.push(`${part.name}\u0000${JSON.stringify(part.state.input)}`) + } + if (signatures.length === 0) return undefined + return signatures.sort().join("\u0001") +} + +/** + * How many consecutive trailing assistant steps made exactly the same non-empty set of tool calls. + * Any intervening non-assistant message (a user steer, compaction) or a differing/text-only step + * breaks the run, so legitimate iteration never counts. Please note that repeated crash-finalized + * steps whose re-issued calls are identical also count; after three, the run wraps up with text, + * which is an acceptable outcome for a session crash-looping on the same work. + */ +export const trailingIdenticalToolSteps = (context: ReadonlyArray) => { + let reference: string | undefined + let count = 0 + for (let index = context.length - 1; index >= 0; index--) { + const message = context[index] + if (message?.type !== "assistant") break + const signature = stepSignature(message) + if (signature === undefined) break + if (reference === undefined) reference = signature + else if (signature !== reference) break + count++ + } + return count +} diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 74e78120c20e..cb9c8a97aa97 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -184,9 +184,18 @@ export const locationLayer = Layer.effect( Effect.gen(function* () { const catalog = yield* Catalog.Service const integrations = yield* Integration.Service + // The auth wait below runs once per process: the integration state lag is a boot phenomenon, + // and a provider that genuinely has no connection must not pay the wait on every step. + let authSettled = false return Service.of({ resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) { - // Location plugins populate and filter the catalog asynchronously during layer startup. + // Location plugins populate and filter the catalog asynchronously during layer startup, so + // a resolve that lands right after boot can observe an empty catalog and fail a healthy + // model as unavailable; that failure is non-retryable by design. Wait, bounded, while the + // catalog has no models at all. A populated catalog resolves with no delay. + for (let attempt = 0; attempt < 40 && (yield* catalog.model.all()).length === 0; attempt++) { + yield* Effect.sleep(250) + } const defaultModel = session.model ? undefined : yield* catalog.model.default() const selected = session.model ? (yield* catalog.model.available()).find( @@ -202,9 +211,19 @@ export const locationLayer = Layer.effect( }) if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id }) const provider = yield* catalog.provider.get(selected.providerID) - const connection = yield* integrations.connection.active( - provider?.integrationID ?? Integration.ID.make(selected.providerID), - ) + const integrationID = provider?.integrationID ?? Integration.ID.make(selected.providerID) + let connection = yield* integrations.connection.active(integrationID) + // The integration state (auth methods) can lag the catalog at boot: the model resolves, + // but the request would go out unauthenticated and fail the turn. When the model carries + // no key of its own and no connection is active yet, give the loaders the same bounded + // window, once. + if (!connection && !authSettled && typeof selected.request.body.apiKey !== "string") { + for (let attempt = 0; attempt < 20 && !connection; attempt++) { + yield* Effect.sleep(250) + connection = yield* integrations.connection.active(integrationID) + } + } + authSettled = true return yield* resolve( session, selected, diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 33652a618c93..93dd3e86bb9c 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -50,6 +50,50 @@ const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): return { structured: record(settled.structured), content: settled.content } } +/** + * Publish the durable Tool.Success / Tool.Failed for one settled tool call. Shared by the streaming + * publisher below and the crash-resume path (which re-settles idempotent tools without a live + * publisher instance), so both encode the outcome identically. + */ +export const emitToolResult = ( + events: EventV2.Interface, + params: { + readonly sessionID: SessionSchema.ID + readonly assistantMessageID: SessionMessage.ID + readonly callID: string + readonly result: ToolResultValue + readonly output?: ToolOutput + readonly outputPaths?: ReadonlyArray + readonly provider: { readonly executed: boolean; readonly metadata?: ProviderMetadata } + }, +) => + Effect.gen(function* () { + const timestamp = yield* DateTime.now + const settled = settledOutput(params.output, params.result) + if ("error" in settled) { + yield* events.publish(SessionEvent.Tool.Failed, { + sessionID: params.sessionID, + timestamp, + assistantMessageID: params.assistantMessageID, + callID: params.callID, + error: settled.error, + result: params.result, + provider: params.provider, + }) + return + } + yield* events.publish(SessionEvent.Tool.Success, { + sessionID: params.sessionID, + timestamp, + assistantMessageID: params.assistantMessageID, + callID: params.callID, + ...settled, + outputPaths: params.outputPaths ?? [], + ...(params.provider.executed ? { result: params.result } : {}), + provider: params.provider, + }) + }) + /** Persist one provider turn without executing tools or starting a continuation turn. */ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) => { const tools = new Map< @@ -344,32 +388,17 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) return yield* Effect.die(`Duplicate tool result: ${event.id}`) } tool.settled = true - const result = settledOutput(event.output, event.result) - const provider = { - executed: event.providerExecuted === true || tool.providerExecuted, - ...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }), - } - if ("error" in result) { - yield* events.publish(SessionEvent.Tool.Failed, { - sessionID: input.sessionID, - timestamp: yield* timestamp, - assistantMessageID: tool.assistantMessageID, - callID: event.id, - error: result.error, - result: event.result, - provider, - }) - return - } - yield* events.publish(SessionEvent.Tool.Success, { + yield* emitToolResult(events, { sessionID: input.sessionID, - timestamp: yield* timestamp, assistantMessageID: tool.assistantMessageID, callID: event.id, - ...result, + result: event.result, + output: event.output, outputPaths, - ...(provider.executed ? { result: event.result } : {}), - provider, + provider: { + executed: event.providerExecuted === true || tool.providerExecuted, + ...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }), + }, }) return } diff --git a/packages/core/src/snapshot-sync.ts b/packages/core/src/snapshot-sync.ts new file mode 100644 index 000000000000..99541265e763 --- /dev/null +++ b/packages/core/src/snapshot-sync.ts @@ -0,0 +1,127 @@ +export * as SnapshotSync from "./snapshot-sync" + +// Ships captured snapshot trees to the shared store as git packs, so a worker on another host can +// rebuild the project worktree before it drains a session (see session/execution/worktree.ts). +// Each push wraps the tree in a sync commit chained onto the previous push and packs only the +// delta. Best-effort by design: a failed push degrades portability, never the turn. + +import { readFile, rm } from "node:fs/promises" +import os from "node:os" +import path from "path" +import { Cause, Context, Effect, Layer } from "effect" +import { ChildProcess } from "effect/unstable/process" +import { desc, eq } from "drizzle-orm" +import { Database } from "./database/database" +import { makeLocationNode } from "./effect/app-node" +import { FSUtil } from "./fs-util" +import { Git } from "./git" +import { Global } from "./global" +import { Location } from "./location" +import { AppProcess } from "./process" +import { AbsolutePath } from "./schema" +import type { Snapshot } from "./snapshot" +import { SnapshotPackTable } from "./snapshot/sql" +import { Hash } from "./util/hash" + +export interface Interface { + /** Ship a captured tree to the shared store as an incremental pack. Never fails the caller. */ + readonly push: (tree: Snapshot.ID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/SnapshotSync") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const git = yield* Git.Service + const global = yield* Global.Service + const location = yield* Location.Service + const proc = yield* AppProcess.Service + const { db } = yield* Database.Service + const source = yield* git.repo.discover(location.project.directory) + const worktree = source + ? AbsolutePath.make(yield* fs.realPath(source.worktree).pipe(Effect.orDie)) + : location.project.directory + // The same side git-dir Snapshot.capture writes trees into. + const gitDirectory = path.join(global.data, "snapshot", location.project.id, Hash.fast(worktree)) + + const run = (args: string[], stdin?: string) => + proc.run( + ChildProcess.make("git", ["--git-dir", gitDirectory, "--work-tree", worktree, ...args], { + cwd: worktree, + env: { + GIT_AUTHOR_NAME: "opencode", + GIT_AUTHOR_EMAIL: "opencode@sync", + GIT_COMMITTER_NAME: "opencode", + GIT_COMMITTER_EMAIL: "opencode@sync", + }, + extendEnv: true, + }), + { stdin }, + ) + + const push = Effect.fn("SnapshotSync.push")(function* (tree: Snapshot.ID) { + yield* Effect.gen(function* () { + if (!source) return + const latest = yield* db + .select() + .from(SnapshotPackTable) + .where(eq(SnapshotPackTable.worktree, worktree)) + .orderBy(desc(SnapshotPackTable.time_created)) + .limit(1) + .get() + .pipe(Effect.orDie) + // The newest shipped state already is this tree: nothing to pack. + if (latest?.tree === tree) return + // Chain onto the previous sync commit only when this host has it; a base absent locally + // would produce a delta pack the pack builder cannot compute. + const base = + latest && (yield* run(["cat-file", "-e", `${latest.id}^{commit}`]).pipe(Effect.orDie)).exitCode === 0 + ? latest.id + : undefined + const committed = yield* run([ + "commit-tree", + tree, + ...(base ? ["-p", base] : []), + "-m", + "opencode snapshot sync", + ]).pipe(Effect.orDie) + if (committed.exitCode !== 0) + return yield* Effect.die(new Error(`commit-tree: ${committed.stderr.toString("utf8")}`)) + const commit = committed.stdout.toString("utf8").trim() + const prefix = path.join(os.tmpdir(), `opencode-snapshot-${commit.slice(0, 12)}`) + const packed = yield* run( + ["pack-objects", "--revs", "-q", prefix], + `${commit}\n${base ? `^${base}\n` : ""}`, + ).pipe(Effect.orDie) + if (packed.exitCode !== 0) + return yield* Effect.die(new Error(`pack-objects: ${packed.stderr.toString("utf8")}`)) + const packHash = packed.stdout.toString("utf8").trim() + const packFile = `${prefix}-${packHash}.pack` + const bytes = yield* Effect.promise(() => readFile(packFile)) + yield* Effect.promise(() => Promise.allSettled([rm(packFile), rm(`${prefix}-${packHash}.idx`)])) + yield* db + .insert(SnapshotPackTable) + .values([{ id: commit, directory: location.directory, worktree, tree, base: base ?? null, pack: bytes }]) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + }).pipe( + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => Effect.logWarning("failed to ship snapshot pack", { tree, cause }), + ), + Effect.asVoid, + ) + }) + + return Service.of({ push }) + }), +) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Database.node, FSUtil.node, Git.node, Global.node, Location.node, AppProcess.node], +}) diff --git a/packages/core/src/snapshot/sql.ts b/packages/core/src/snapshot/sql.ts new file mode 100644 index 000000000000..541423a578f7 --- /dev/null +++ b/packages/core/src/snapshot/sql.ts @@ -0,0 +1,24 @@ +import { blob, index, sqliteTable, text } from "drizzle-orm/sqlite-core" +import { Timestamps } from "../database/schema.sql" + +// A captured snapshot tree shipped as a git pack, so a worker on another host can rebuild the +// project worktree from the shared store before it drains a session. `id` is the sync commit that +// wraps the tree; `base` is the sync commit the pack was built against (null means a full pack). +// `directory` is the session location the capture ran in (the materializer's lookup key); +// `worktree` is the project root the tree checks out into. +export const SnapshotPackTable = sqliteTable( + "snapshot_pack", + { + id: text().primaryKey(), + directory: text().notNull(), + worktree: text().notNull(), + tree: text().notNull(), + base: text(), + pack: blob({ mode: "buffer" }).notNull(), + ...Timestamps, + }, + (table) => [ + index("snapshot_pack_directory_idx").on(table.directory, table.time_created), + index("snapshot_pack_worktree_idx").on(table.worktree, table.time_created), + ], +) diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index f8bd1869e11e..8762af84ec94 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -47,6 +47,7 @@ const layer = Layer.effectDiscard( [name]: Tool.make({ description: "Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.", + idempotent: true, input: Input, output: Output, toModelOutput: ({ output }) => [ diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index f455bd4c8a0a..c56f8df81d9f 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -63,6 +63,7 @@ const layer = Layer.effectDiscard( [name]: Tool.make({ description: "Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.", + idempotent: true, input: Input, output: Output, toModelOutput: ({ output }) => [ diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index e5ae0d7426a6..44b995f8fe34 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -56,6 +56,10 @@ const layer = Layer.effectDiscard( description, input: Input, output: Output, + // Re-running the ask is safe: the durable question row has a deterministic id, so a + // crash-resume adopts the pending row (or returns answers that already landed) instead + // of failing the user's open questions. + idempotent: true, toModelOutput: ({ input, output }) => [ { type: "text", text: toModelOutput(input.questions, output.answers) }, ], diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 6961a8609118..9d71dad35f37 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -40,6 +40,7 @@ const layer = Layer.effectDiscard( [name]: Tool.make({ description: "Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.", + idempotent: true, input: Input, output: Output, toModelOutput: ({ input, output }) => { diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 1c2dfe7ab459..de812e3c4eeb 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -9,7 +9,15 @@ import { SessionSchema } from "../session/schema" import { ToolOutputStore } from "../tool-output-store" import { Wildcard } from "../util/wildcard" import { ApplicationTools } from "./application-tools" -import { definition, permission, settle, validateName, type AnyTool, type RegistrationError } from "./tool" +import { + definition, + idempotent as toolIdempotent, + permission, + settle, + validateName, + type AnyTool, + type RegistrationError, +} from "./tool" import { Tools } from "./tools" import { makeLocationNode } from "../effect/app-node" @@ -29,6 +37,8 @@ export interface Interface { export interface Materialization { readonly definitions: ReadonlyArray readonly settle: (input: ExecuteInput) => Effect.Effect + /** Whether a tool declared itself side-effect-free (safe to re-run on a crash resume). */ + readonly idempotent: (name: string) => boolean } export interface Settlement { @@ -118,6 +128,10 @@ const registryLayer = Layer.effect( if (registration) return settleWith(input, registration.identity) return Effect.succeed({ result: { type: "error", value: `Unknown tool: ${input.call.name}` } }) }, + idempotent: (name) => { + const registration = registrations.get(name) + return registration ? toolIdempotent(registration.tool) : false + }, } }), }) diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index 1d9a82e9522d..803256c175a6 100644 --- a/packages/core/src/tool/tool.ts +++ b/packages/core/src/tool/tool.ts @@ -43,6 +43,10 @@ type Config< Structured extends SchemaType = Output, > = { readonly description: string + // Opt-in: this tool has no external side effect (a pure read), so it is safe to re-run. Used to + // re-settle a tool caught in flight at a crash instead of failing it. Defaults to false; anything + // that writes files, runs shell, or mutates a remote must leave it unset. + readonly idempotent?: boolean readonly input: Input readonly output: Output readonly structured?: Structured @@ -62,6 +66,7 @@ type Config< type Runtime = { readonly permission?: string + readonly idempotent: boolean readonly definition: (name: string) => ToolDefinition readonly settle: (call: ToolCall, context: Context) => Effect.Effect } @@ -76,6 +81,7 @@ export function make< const tool = Object.freeze({}) as Definition const definitions = new Map() runtimes.set(tool, { + idempotent: config.idempotent === true, definition: (name) => { const cached = definitions.get(name) if (cached) return cached @@ -146,6 +152,7 @@ export const withPermission = , Output extends Sch } export const permission = (tool: AnyTool, name: string) => runtimeOf(tool).permission ?? name +export const idempotent = (tool: AnyTool) => runtimeOf(tool).idempotent export const definition = (name: string, tool: AnyTool) => runtimeOf(tool).definition(name) export const settle = (tool: AnyTool, call: ToolCall, context: Context) => runtimeOf(tool).settle(call, context) diff --git a/packages/core/test/database-libsql-remote.test.ts b/packages/core/test/database-libsql-remote.test.ts new file mode 100644 index 000000000000..982c1f1509fd --- /dev/null +++ b/packages/core/test/database-libsql-remote.test.ts @@ -0,0 +1,77 @@ +// Networked-libSQL integration check: the same commit/rollback atomicity assertions as +// database-libsql-transaction.test.ts, but against a REAL libSQL server over the network (sqld / +// `turso dev`), where each statement is its own HTTP request and only the interactive-transaction +// routing makes BEGIN..COMMIT atomic. Needs a live server, so it runs only when +// OPENCODE_LIBSQL_TEST_URL is set (e.g. `turso dev --port 8888` then +// OPENCODE_LIBSQL_TEST_URL=http://127.0.0.1:8888 bun test database-libsql-remote`); otherwise the +// suite is skipped, not silently passed. +import { describe, expect, test } from "bun:test" +import { Effect, Exit, Layer } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { testEffect } from "./lib/effect" + +const url = process.env.OPENCODE_LIBSQL_TEST_URL + +const withRemoteDb = (body: (db: Database.Interface["db"]) => Effect.Effect) => + Effect.gen(function* () { + const { db } = yield* Database.Service + return yield* body(db) + }).pipe(Effect.provide(Database.layerFromLibsql(url!, process.env.OPENCODE_LIBSQL_TEST_AUTH_TOKEN))) + +const it = testEffect(Layer.empty) + +describe.skipIf(!url)("Database libSQL backend (remote server)", () => { + it.live("commits a multi-statement transaction atomically over the network", () => + withRemoteDb((db) => + Effect.gen(function* () { + yield* db.run("DROP TABLE IF EXISTS tx_probe_remote") + yield* db.run("CREATE TABLE tx_probe_remote (id TEXT PRIMARY KEY, n INTEGER NOT NULL)") + yield* db.transaction( + () => + Effect.gen(function* () { + yield* db.run("INSERT INTO tx_probe_remote (id, n) VALUES ('a', 1)") + yield* db.run("INSERT INTO tx_probe_remote (id, n) VALUES ('b', 2)") + }), + { behavior: "immediate" }, + ) + const row = yield* db.get<{ c: number }>("SELECT COUNT(*) AS c FROM tx_probe_remote") + expect(Number(row?.c ?? 0)).toBe(2) + }), + ), + ) + + it.live("rolls back every write when a statement fails over the network", () => + withRemoteDb((db) => + Effect.gen(function* () { + yield* db.run("DROP TABLE IF EXISTS tx_probe_remote") + yield* db.run("CREATE TABLE tx_probe_remote (id TEXT PRIMARY KEY, n INTEGER NOT NULL)") + yield* db.run("INSERT INTO tx_probe_remote (id, n) VALUES ('seed', 0)") + const exit = yield* db + .transaction( + () => + Effect.gen(function* () { + yield* db.run("INSERT INTO tx_probe_remote (id, n) VALUES ('a', 1)") + yield* Effect.fail(new Error("boom")) + }), + { behavior: "immediate" }, + ) + .pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + const row = yield* db.get<{ c: number }>("SELECT COUNT(*) AS c FROM tx_probe_remote") + expect(Number(row?.c ?? 0)).toBe(1) + }), + ), + ) + + it.live("runs the schema migrations against the remote store", () => + withRemoteDb((db) => + Effect.gen(function* () { + // layerFromLibsql already applied migrations on build; spot-check the core tables exist. + const row = yield* db.get<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'event'", + ) + expect(row?.name).toBe("event") + }), + ), + ) +}) diff --git a/packages/core/test/database-libsql-transaction.test.ts b/packages/core/test/database-libsql-transaction.test.ts new file mode 100644 index 000000000000..7a9eb403ed32 --- /dev/null +++ b/packages/core/test/database-libsql-transaction.test.ts @@ -0,0 +1,67 @@ +import { describe, expect } from "bun:test" +import path from "path" +import { Effect, Exit, Layer } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { testEffect } from "./lib/effect" +import { tmpdir } from "./fixture/tmpdir" + +// The libSQL backend runs each statement as its own request, so a transaction is only atomic if the +// backend pins one interactive libSQL transaction for the BEGIN..COMMIT span. This is the write +// shape the event store uses (event_sequence upsert + event insert must commit together). Exercised +// against an embedded `file:` store; the interactive-tx API is identical for a remote URL, whose +// crash-atomicity still needs a live sqld/Turso to test. +const withLibsqlDb = (body: (db: Database.Interface["db"]) => Effect.Effect) => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + const { db } = yield* Database.Service + return yield* body(db) + }).pipe(Effect.provide(Database.layerFromLibsql(`file:${path.join(tmp.path, "events.db")}`))), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + +const it = testEffect(Layer.empty) + +describe("Database libSQL backend", () => { + it.live("commits a multi-statement transaction atomically", () => + withLibsqlDb((db) => + Effect.gen(function* () { + yield* db.run("CREATE TABLE tx_probe (id TEXT PRIMARY KEY, n INTEGER NOT NULL)") + yield* db.transaction( + () => + Effect.gen(function* () { + yield* db.run("INSERT INTO tx_probe (id, n) VALUES ('a', 1)") + yield* db.run("INSERT INTO tx_probe (id, n) VALUES ('b', 2)") + }), + { behavior: "immediate" }, + ) + const row = yield* db.get<{ c: number }>("SELECT COUNT(*) AS c FROM tx_probe") + expect(Number(row?.c ?? 0)).toBe(2) + }), + ), + ) + + it.live("rolls back every write when a statement in the transaction fails", () => + withLibsqlDb((db) => + Effect.gen(function* () { + yield* db.run("CREATE TABLE tx_probe (id TEXT PRIMARY KEY, n INTEGER NOT NULL)") + yield* db.run("INSERT INTO tx_probe (id, n) VALUES ('seed', 0)") + const exit = yield* db + .transaction( + () => + Effect.gen(function* () { + yield* db.run("INSERT INTO tx_probe (id, n) VALUES ('a', 1)") + yield* Effect.fail(new Error("boom")) + }), + { behavior: "immediate" }, + ) + .pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + // The seed survives; the in-transaction insert was rolled back with the failure. + const row = yield* db.get<{ c: number }>("SELECT COUNT(*) AS c FROM tx_probe") + expect(Number(row?.c ?? 0)).toBe(1) + }), + ), + ) +}) diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 464ce2695a76..354b7049b42c 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -113,6 +113,24 @@ describe("DatabaseMigration", () => { ), ) }) + + test("serializes concurrent migration across processes on one path", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "cross-process.sqlite") + const script = fileURLToPath(new URL("../script/migrate-once.ts", import.meta.url)) + // Independent processes (not sharing the module-level semaphore) racing the same fresh file: + // the BEGIN IMMEDIATE transaction must serialize them so none errors on a duplicate CREATE or + // migration insert. + const runs = await Promise.all(Array.from({ length: 5 }, () => $`bun ${script} ${file}`.quiet().nothrow())) + for (const result of runs) expect(result.exitCode, result.stderr.toString()).toBe(0) + + await Effect.runPromise( + Effect.gen(function* () { + const { db } = yield* Database.Service + expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length }) + }).pipe(Effect.provide(Database.layerFromPath(file)), Effect.scoped), + ) + }, 60_000) if (process.platform === "linux") { test("declared schema has no ungenerated migrations", async () => { const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check` diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index 6877520fc48c..d7ee0f69f274 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -1097,6 +1097,40 @@ describe("EventV2", () => { }), ) + it.effect("fences a live publish from a superseded owner and admits the current one", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = Session.ID.create() + + yield* events + .publish(DurableMessage, durableData(aggregateID, "seed")) + .pipe(Effect.provideService(EventV2.EventOwner, "owner-a")) + // A newer attempt takes the log. + yield* events.claim(aggregateID, "owner-b") + + const fenced = yield* events + .publish(DurableMessage, durableData(aggregateID, "stale")) + .pipe(Effect.provideService(EventV2.EventOwner, "owner-a"), Effect.exit) + yield* events + .publish(DurableMessage, durableData(aggregateID, "fresh")) + .pipe(Effect.provideService(EventV2.EventOwner, "owner-b")) + const rows = yield* db + .select() + .from(EventTable) + .where(eq(EventTable.aggregate_id, aggregateID)) + .all() + .pipe(Effect.orDie) + + expect(String(fenced)).toContain("Owner fence") + expect(rows.map((row) => row.seq)).toEqual([0, 1]) + expect(rows.map((row) => (row.data as { messageID: string }).messageID)).toEqual([ + durableData(aggregateID, "seed").messageID, + durableData(aggregateID, "fresh").messageID, + ]) + }), + ) + it.live("tails another writer's events only when the layer asked for a tick", () => Effect.gen(function* () { // A second service over the SAME database with its OWN pubsub: exactly what a standalone @@ -1146,6 +1180,20 @@ describe("EventV2", () => { }), ) + it.effect("never fences a publish made outside a drain", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const aggregateID = Session.ID.create() + yield* events.publish(DurableMessage, durableData(aggregateID, "seed")) + yield* events.claim(aggregateID, "owner-b") + + // No owner in context (the default), so ownership is not enforced for ordinary callers. + const event = yield* events.publish(DurableMessage, durableData(aggregateID, "unfenced")) + + expect(event.durable?.seq).toBe(1) + }), + ) + it.effect("remove clears durable event sequence", () => Effect.gen(function* () { const events = yield* EventV2.Service diff --git a/packages/core/test/lib/execution-conformance.ts b/packages/core/test/lib/execution-conformance.ts new file mode 100644 index 000000000000..759db5a98f3f --- /dev/null +++ b/packages/core/test/lib/execution-conformance.ts @@ -0,0 +1,285 @@ +// The SessionExecution conformance suite: the executable definition of what an executor must do. +// Any driver behind the SessionExecution seam registers the same scenarios through runContract; the +// built-in local executor runs it in core's tests, and the Temporal executor runs it against real +// workflows. The contract: wake drives a turn to settlement then the idle executor retires, resume +// forces a healthy turn to completion, resume surfaces the exact tagged RunError (through the same +// encode/decode path a process boundary uses), and interrupt cancels an in-flight turn and the +// session eventually leaves the active set. +import { LLMClient, type LLMClientShape } from "@opencode-ai/llm/route" +import { LLMEvent } from "@opencode-ai/llm" +import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" +import { PermissionV2 } from "@opencode-ai/core/permission" +import { Config } from "@opencode-ai/core/config" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { Snapshot } from "@opencode-ai/core/snapshot" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local" +import { SessionRunnerModel, ModelNotSelectedError } from "@opencode-ai/core/session/runner/model" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { SessionStore } from "@opencode-ai/core/session/store" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { Prompt } from "@opencode-ai/core/session/prompt" +import { Location } from "@opencode-ai/core/location" +import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry" +import { SystemContext } from "@opencode-ai/core/system-context" +import { SkillGuidance } from "@opencode-ai/core/skill/guidance" +import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" +import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" +import { Auth } from "@opencode-ai/llm/route" +import { describe, expect } from "bun:test" +import { realpathSync } from "node:fs" +import { tmpdir } from "node:os" +import { Cause, Context, DateTime, Effect, Exit, Layer, Stream } from "effect" +import { testEffect } from "./effect" + +// The per-location service build resolves the session directory on disk, so it must exist. +const WORKSPACE = AbsolutePath.make(realpathSync(tmpdir())) + +// Inert fixture data: the suite replaces LLMClient.Service with an injected stream, so nothing +// dials this endpoint or sends this token. The runner only needs a well-formed model descriptor +// to select and record; the .invalid TLD (RFC 2606) makes the inertness visible. +const model = OpenAIChat.route + .with({ endpoint: { baseURL: "https://llm.fixture.invalid/v1" }, auth: Auth.bearer("fixture") }) + .model({ id: "gpt-4o-mini" }) +const okModels = SessionRunnerModel.layerWith(() => Effect.succeed(model)) +const systemContext = AppNodeBuilder.build(SystemContextRegistry.node) +const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) +const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) +const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) +const permission = Layer.mock(PermissionV2.Service, {}) + +const mockClient = (stream: LLMClientShape["stream"]) => + Layer.succeed( + LLMClient.Service, + LLMClient.Service.of({ + prepare: () => Effect.die("unused"), + generate: () => Effect.die("unused"), + stream, + }), + ) + +const countingModel = () => { + const requests: number[] = [] + const stream: LLMClientShape["stream"] = () => { + requests.push(1) + return Stream.fromIterable([LLMEvent.stepStart({ index: 0 }), LLMEvent.stepFinish({ index: 0, reason: "stop" })]) + } + return { requests, stream } +} + +// The executor under test, built as its own graph over the shared database file (the same way the +// serve process builds it), with the model/LLM mocked. Any SessionExecution node with the standard +// dependency set (the local coordinator, the Temporal driver) plugs in here. +export const makeExecutionFor = + (node: typeof SessionExecutionLocal.node) => + (stream: LLMClientShape["stream"], models = okModels) => + AppNodeBuilder.build(node, [ + [LayerNodePlatform.llmClient, mockClient(stream)], + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [SessionRunnerModel.node, models], + [SystemContextRegistry.node, systemContext], + [Location.node, Location.boundNode({ directory: WORKSPACE })], + [SkillGuidance.node, skillGuidance], + [ReferenceGuidance.node, referenceGuidance], + [Config.node, config], + [Snapshot.node, Snapshot.noopLayer], + ]) + +// Reads and seeds go through a separate graph sharing the same database file. +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node])), +) + +const seedSession = (sessionID: SessionV2.ID) => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: WORKSPACE, sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "t", + directory: WORKSPACE, + title: "t", + version: "t", + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + }) + +const seedPrompt = (sessionID: SessionV2.ID) => + Effect.gen(function* () { + const events = yield* EventV2.Service + yield* events.publish(SessionEvent.Prompted, { + sessionID, + timestamp: yield* DateTime.now, + messageID: SessionMessage.ID.create(), + prompt: Prompt.make({ text: "do the thing" }), + delivery: "queue", + }) + }) + +// Generous defaults: the Temporal driver adds worker startup, activity scheduling, and +// eventually-consistent visibility (about a second) on top of the turn itself. +const until = (read: Effect.Effect, predicate: (value: A) => boolean, timeoutMs = 20000) => + Effect.gen(function* () { + const deadline = Date.now() + timeoutMs + for (;;) { + const value = yield* read + if (predicate(value)) return value + if (Date.now() > deadline) throw new Error("condition not reached in time") + yield* Effect.sleep(50) + } + }) + +// The idle override is read at the executor's layer build (the Temporal client forwards it as a +// workflow argument), so it must be set before makeExec's layer is built and restored afterwards so +// later layer builds in this process get the real default. +const withIdleOverride = (body: Effect.Effect) => + Effect.gen(function* () { + const previous = process.env.OPENCODE_SESSION_IDLE_TIMEOUT + process.env.OPENCODE_SESSION_IDLE_TIMEOUT = "2 seconds" + try { + return yield* body + } finally { + if (previous === undefined) delete process.env.OPENCODE_SESSION_IDLE_TIMEOUT + else process.env.OPENCODE_SESSION_IDLE_TIMEOUT = previous + } + }) + +// The SessionExecution contract, parameterized over the driver factory. `makeExec` builds a +// SessionExecution graph the same way serve does, with the model/LLM mocked. Running the identical +// suite against a second factory is how the two modes are held to one behavior now that they no +// longer share a single coordination loop -- only the drain. +export const runContract = (label: string, makeExec: ReturnType) => { + // The nonce keeps workflow ids unique across runs: the Temporal driver derives durable workflow + // ids from session ids, and on a shared dev server USE_EXISTING would otherwise route this run's + // updates to a leftover workflow from an earlier one, parked on a task queue nobody polls. + const slug = `${label.replace(/[^a-z0-9]+/gi, "_")}_${crypto.randomUUID().slice(0, 8)}` + describe(`SessionExecution contract: ${label}`, () => { + { + const { requests, stream } = countingModel() + const sessionID = SessionV2.ID.make(`ses_${slug}_wake`) + it.live( + "wake drives a turn to settlement, then the idle executor retires", + () => + withIdleOverride( + Effect.gen(function* () { + yield* seedSession(sessionID) + yield* seedPrompt(sessionID) + const exec = Context.get(yield* Layer.build(makeExec(stream)), SessionExecution.Service) + yield* exec.wake(sessionID) + const store = yield* SessionStore.Service + yield* until(store.context(sessionID), (context) => { + const assistant = context.findLast((message) => message.type === "assistant") + return assistant?.type === "assistant" && Boolean(assistant.time.completed) + }) + expect(requests).toHaveLength(1) + // Idle self-termination: the executor retires without an interrupt. How long a settled + // session lingers in `active` is the executor's business (the local coordinator retires + // on settlement, the Temporal workflow serves until its idle timeout); the contract only + // demands it eventually leaves. + yield* until(exec.active, (active) => !active.has(sessionID)) + }), + ), + 60000, + ) + } + + { + const { requests, stream } = countingModel() + const sessionID = SessionV2.ID.make(`ses_${slug}_resume_ok`) + it.live( + "resume forces a healthy turn to completion and resolves", + () => + withIdleOverride( + Effect.gen(function* () { + yield* seedSession(sessionID) + yield* seedPrompt(sessionID) + const exec = Context.get(yield* Layer.build(makeExec(stream)), SessionExecution.Service) + // resume is request/response: it awaits the forced drain and resolves on success. + const exit = yield* exec.resume(sessionID).pipe(Effect.exit) + expect(Exit.isSuccess(exit)).toBe(true) + expect(requests).toHaveLength(1) + const store = yield* SessionStore.Service + const context = yield* store.context(sessionID) + const assistant = context.findLast((message) => message.type === "assistant") + expect(assistant?.type === "assistant" && Boolean(assistant.time.completed)).toBe(true) + // Wait out the retirement while this run's worker still exists. A durable executor's + // session would otherwise linger forever: the test's task queue dies with the process, + // so nothing ever processes the idle timer's task. + yield* until(exec.active, (active) => !active.has(sessionID)) + }), + ), + 60000, + ) + } + + { + const sessionID = SessionV2.ID.make(`ses_${slug}_error`) + const failingModels = SessionRunnerModel.layerWith(() => Effect.fail(new ModelNotSelectedError({ sessionID }))) + it.live( + "resume surfaces the exact tagged RunError through the shared codec", + () => + withIdleOverride( + Effect.gen(function* () { + yield* seedSession(sessionID) + const { stream } = countingModel() + const exec = Context.get(yield* Layer.build(makeExec(stream, failingModels)), SessionExecution.Service) + const exit = yield* exec.resume(sessionID).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined + // The same encode -> details -> decode path the Temporal boundary uses, so the caller gets + // the identical tagged instance in both modes. + expect(error).toBeInstanceOf(ModelNotSelectedError) + // Same reason as the healthy resume: retire before this run's task queue dies. + yield* until(exec.active, (active) => !active.has(sessionID)) + }), + ), + 60000, + ) + } + + { + const sessionID = SessionV2.ID.make(`ses_${slug}_interrupt`) + it.live( + "interrupt cancels an in-flight turn and the session leaves the active set", + () => + withIdleOverride( + Effect.gen(function* () { + yield* seedSession(sessionID) + yield* seedPrompt(sessionID) + // A model that never answers: the turn hangs until interrupted. + const exec = Context.get(yield* Layer.build(makeExec(() => Stream.never)), SessionExecution.Service) + yield* exec.wake(sessionID) + yield* Effect.sleep(200) + yield* until(exec.active, (active) => active.has(sessionID)) + yield* exec.interrupt(sessionID) + // The Temporal supervisor keeps serving after an interrupt (a racing wake/resume must + // not be lost); with nothing else queued it leaves the active set via idle retirement. + yield* until(exec.active, (active) => !active.has(sessionID)) + }), + ), + 60000, + ) + } + }) +} diff --git a/packages/core/test/lib/runner-harness.ts b/packages/core/test/lib/runner-harness.ts new file mode 100644 index 000000000000..5a262c693a26 --- /dev/null +++ b/packages/core/test/lib/runner-harness.ts @@ -0,0 +1,124 @@ +// The application graph the SessionRunner tests run in: a real database, event log, projector and +// store, with the provider client, the model catalog, permissions and snapshots replaced by +// fixtures. `stream` is what the provider answers. +import { LLMClient, type LLMClientShape } from "@opencode-ai/llm/route" +import { LLMEvent } from "@opencode-ai/llm" +import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" +import { PermissionV2 } from "@opencode-ai/core/permission" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Config } from "@opencode-ai/core/config" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import type { SessionV2 } from "@opencode-ai/core/session" +import { Snapshot } from "@opencode-ai/core/snapshot" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { SessionStore } from "@opencode-ai/core/session/store" +import { Location } from "@opencode-ai/core/location" +import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry" +import { SystemContext } from "@opencode-ai/core/system-context" +import { SkillGuidance } from "@opencode-ai/core/skill/guidance" +import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" +import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" +import { Auth } from "@opencode-ai/llm/route" +import { Effect, Layer, Stream } from "effect" +import { testEffect } from "./effect" + +const PROJECT = AbsolutePath.make("/project") + +// Inert: the client below replaces the real one, so nothing dials this endpoint. +const model = OpenAIChat.route + .with({ endpoint: { baseURL: "https://api.openai.com/v1" }, auth: Auth.bearer("fixture") }) + .model({ id: "gpt-4o-mini" }) +const models = SessionRunnerModel.layerWith(() => Effect.succeed(model)) +const systemContext = AppNodeBuilder.build(SystemContextRegistry.node) +const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) +const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { + load: () => Effect.succeed(SystemContext.empty), +}) +const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) +const permission = Layer.mock(PermissionV2.Service, {}) + +/** A provider client whose only live method is `stream`. */ +export const mockClient = (stream: LLMClientShape["stream"]) => + Layer.succeed( + LLMClient.Service, + LLMClient.Service.of({ + prepare: () => Effect.die("LLMClient.prepare should not be called"), + generate: () => Effect.die("LLMClient.generate should not be called"), + stream, + }), + ) + +/** One completed step with no content: the smallest answer a provider can give. */ +export const emptyStep: LLMClientShape["stream"] = () => + Stream.fromIterable([LLMEvent.stepStart({ index: 0 }), LLMEvent.stepFinish({ index: 0, reason: "stop" })]) + +export const runnerHarness = (stream: LLMClientShape["stream"]) => + testEffect( + AppNodeBuilder.build( + LayerNode.group([ + Database.node, + EventV2.node, + SessionProjector.node, + SessionStore.node, + AgentV2.node, + ToolRegistry.node, + SessionRunnerModel.node, + SystemContextRegistry.node, + SkillGuidance.node, + ReferenceGuidance.node, + Config.node, + Snapshot.node, + SessionRunnerLLM.node, + ApplicationTools.node, + ]), + [ + [LayerNodePlatform.llmClient, mockClient(stream)], + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [SessionRunnerModel.node, models], + [SystemContextRegistry.node, systemContext], + [Location.node, Location.boundNode({ directory: PROJECT })], + [SkillGuidance.node, skillGuidance], + [ReferenceGuidance.node, referenceGuidance], + [Config.node, config], + [Snapshot.node, Snapshot.noopLayer], + ], + ), + ) + +/** The project row and a session under it, which every runner call expects to find. */ +export const seedSession = (sessionID: SessionV2.ID) => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: PROJECT, sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "t", + directory: "/project", + title: "t", + version: "t", + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + }) diff --git a/packages/core/test/permission-durable.test.ts b/packages/core/test/permission-durable.test.ts new file mode 100644 index 000000000000..4970fe914fb4 --- /dev/null +++ b/packages/core/test/permission-durable.test.ts @@ -0,0 +1,291 @@ +// Durable permission asks: a pending approval is a row in the shared store, so an ask raised by one +// process (a standalone worker's activity) can be listed and replied to from another (the HTTP +// server), and the blocked assert observes the cross-process reply by polling the row. Two fully +// independent service stacks share one DB file to simulate the two processes. +import { describe, expect } from "bun:test" +import path from "path" +import { createClient } from "@libsql/client" +import { Cause, Context, Effect, Exit, Fiber, Layer, Scope } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Database } from "@opencode-ai/core/database/database" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Location } from "@opencode-ai/core/location" +import { PermissionV2 } from "@opencode-ai/core/permission" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { SessionV2 } from "@opencode-ai/core/session" +import { testEffect } from "./lib/effect" +import { tmpdir } from "./fixture/tmpdir" + +const sessionID = SessionV2.ID.make("ses_permission_durable") +const agent = AgentV2.ID.make("build") + +// Every action evaluates to "ask", so assert always parks on an approval. +const askAll = Layer.mock(AgentV2.Service, { + resolve: () => + Effect.succeed({ permissions: [{ action: "*", resource: "*", effect: "ask" }] } as unknown as AgentV2.Info), +}) + +const stack = (file: string) => + AppNodeBuilder.build(PermissionV2.node, [ + [Database.node, Database.layerFromPath(file)], + [AgentV2.node, askAll], + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + ]) + +const seed = (file: string) => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "t", + directory: "/project", + title: "t", + version: "t", + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + }).pipe(Effect.provide(Database.layerFromPath(file)), Effect.scoped) + +const it = testEffect(Layer.empty) + +const awaitAsk = (perm: PermissionV2.Interface) => + Effect.gen(function* () { + for (;;) { + const asks = yield* perm.list() + const ask = asks[0] + if (ask) return ask + yield* Effect.sleep(50) + } + }) + +describe("PermissionV2 durable asks", () => { + it.live("unblocks an assert via a reply from a second process sharing the store", () => + Effect.gen(function* () { + const tmp = yield* Effect.promise(() => tmpdir()) + const file = path.join(tmp.path, "shared.db") + yield* seed(file) + const A = yield* Layer.build(stack(file)) + const B = yield* Layer.build(stack(file)) + const permA = Context.get(A, PermissionV2.Service) + const permB = Context.get(B, PermissionV2.Service) + + // A: a tool blocks on approval. B: a different process sees the durable ask and approves it. + const blocked = yield* permA + .assert({ sessionID, action: "bash", resources: ["echo hi"], agent }) + .pipe(Effect.forkChild) + const ask = yield* awaitAsk(permB) + expect(ask.action).toBe("bash") + expect(ask.resources).toEqual(["echo hi"]) + yield* permB.reply({ requestID: ask.id, reply: "once" }) + const exit = yield* Fiber.await(blocked) + expect(Exit.isSuccess(exit)).toBe(true) + // The row is settled everywhere: no pending asks remain on either side. + expect(yield* permA.list()).toEqual([]) + expect(yield* permB.list()).toEqual([]) + yield* Effect.promise(() => tmp[Symbol.asyncDispose]()) + }), + ) + + it.live("delivers a cross-process correction as the typed CorrectedError", () => + Effect.gen(function* () { + const tmp = yield* Effect.promise(() => tmpdir()) + const file = path.join(tmp.path, "shared.db") + yield* seed(file) + const A = yield* Layer.build(stack(file)) + const B = yield* Layer.build(stack(file)) + const permA = Context.get(A, PermissionV2.Service) + const permB = Context.get(B, PermissionV2.Service) + + const blocked = yield* permA + .assert({ sessionID, action: "bash", resources: ["rm -rf /"], agent }) + .pipe(Effect.forkChild) + const ask = yield* awaitAsk(permB) + yield* permB.reply({ requestID: ask.id, reply: "reject", message: "Use a scoped path instead." }) + const exit = yield* Fiber.await(blocked) + expect(Exit.isFailure(exit)).toBe(true) + const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined + expect(error).toBeInstanceOf(PermissionV2.CorrectedError) + expect((error as PermissionV2.CorrectedError).feedback).toBe("Use a scoped path instead.") + yield* Effect.promise(() => tmp[Symbol.asyncDispose]()) + }), + ) + + it.live("a re-drive adopts the same pending ask instead of duplicating it", () => + Effect.gen(function* () { + const tmp = yield* Effect.promise(() => tmpdir()) + const file = path.join(tmp.path, "shared.db") + yield* seed(file) + const A = yield* Layer.build(stack(file)) + const B = yield* Layer.build(stack(file)) + const permA = Context.get(A, PermissionV2.Service) + const permB = Context.get(B, PermissionV2.Service) + const input = { + sessionID, + action: "bash", + resources: ["git push"], + agent, + source: { type: "tool" as const, messageID: "msg_1", callID: "call_redrive" }, + } + + // Attempt 1 blocks, then dies (a crashed activity): the row stays pending. + const first = yield* permA.assert(input).pipe(Effect.forkChild) + const ask = yield* awaitAsk(permB) + yield* Fiber.interrupt(first) + // Attempt 2 (the Temporal retry) files the same deterministic ask: one row, same id. + const second = yield* permA.assert(input).pipe(Effect.forkChild) + yield* Effect.sleep(100) + const asks = yield* permB.list() + expect(asks).toHaveLength(1) + expect(asks[0]?.id).toBe(ask.id) + yield* permB.reply({ requestID: ask.id, reply: "once" }) + const exit = yield* Fiber.await(second) + expect(Exit.isSuccess(exit)).toBe(true) + yield* Effect.promise(() => tmp[Symbol.asyncDispose]()) + }), + ) + + it.live("honors an approval that landed while the asker was dead", () => + Effect.gen(function* () { + const tmp = yield* Effect.promise(() => tmpdir()) + const file = path.join(tmp.path, "shared.db") + yield* seed(file) + const A = yield* Layer.build(stack(file)) + const B = yield* Layer.build(stack(file)) + const permA = Context.get(A, PermissionV2.Service) + const permB = Context.get(B, PermissionV2.Service) + const input = { + sessionID, + action: "bash", + resources: ["make deploy"], + agent, + source: { type: "tool" as const, messageID: "msg_2", callID: "call_dead_asker" }, + } + + const first = yield* permA.assert(input).pipe(Effect.forkChild) + const ask = yield* awaitAsk(permB) + yield* Fiber.interrupt(first) + // The human approves after the asker died; the retry short-circuits on the approved row. + yield* permB.reply({ requestID: ask.id, reply: "once" }) + yield* permA.assert(input) + expect(yield* permB.list()).toEqual([]) + yield* Effect.promise(() => tmp[Symbol.asyncDispose]()) + }), + ) + + it.live("shutdown honors an approval that landed before the poll saw it", () => + Effect.gen(function* () { + const tmp = yield* Effect.promise(() => tmpdir()) + const file = path.join(tmp.path, "shared.db") + yield* seed(file) + const B = yield* Layer.build(stack(file)) + const permB = Context.get(B, PermissionV2.Service) + // A lives in its own scope so the test can shut it down while the waiter is parked. + const scope = yield* Scope.make() + const A = yield* Layer.build(stack(file)).pipe(Effect.provideService(Scope.Scope, scope)) + const permA = Context.get(A, PermissionV2.Service) + const blocked = yield* permA + .assert({ sessionID, action: "bash", resources: ["echo hi"], agent }) + .pipe(Effect.forkChild) + const ask = yield* awaitAsk(permB) + yield* permB.reply({ requestID: ask.id, reply: "once" }) + // Shut A down inside the poll window: the finalizer must honor the approval on the row, + // not record a decline the user never made. + yield* Scope.close(scope, Exit.void) + const exit = yield* Fiber.await(blocked) + expect(Exit.isSuccess(exit)).toBe(true) + yield* Effect.promise(() => tmp[Symbol.asyncDispose]()) + }), + ) + + it.live("a same-process retry shares the parked waiter instead of dying", () => + Effect.gen(function* () { + const tmp = yield* Effect.promise(() => tmpdir()) + const file = path.join(tmp.path, "shared.db") + yield* seed(file) + const A = yield* Layer.build(stack(file)) + const B = yield* Layer.build(stack(file)) + const permA = Context.get(A, PermissionV2.Service) + const permB = Context.get(B, PermissionV2.Service) + const input = { + sessionID, + action: "bash", + resources: ["make it"], + agent, + source: { type: "tool" as const, messageID: "msg_3", callID: "call_shared" }, + } + const first = yield* permA.assert(input).pipe(Effect.forkChild) + yield* Effect.sleep(100) + const second = yield* permA.assert(input).pipe(Effect.forkChild) + const ask = yield* awaitAsk(permB) + expect(yield* permB.list()).toHaveLength(1) + yield* permB.reply({ requestID: ask.id, reply: "once" }) + const exits = [yield* Fiber.await(first), yield* Fiber.await(second)] + expect(exits.every(Exit.isSuccess)).toBe(true) + yield* Effect.promise(() => tmp[Symbol.asyncDispose]()) + }), + ) + + it.live("sweeps an abandoned pending ask out of the list on read", () => + Effect.gen(function* () { + const tmp = yield* Effect.promise(() => tmpdir()) + const file = path.join(tmp.path, "shared.db") + yield* seed(file) + const A = yield* Layer.build(stack(file)) + const permA = Context.get(A, PermissionV2.Service) + const blocked = yield* permA + .assert({ sessionID, action: "bash", resources: ["echo hi"], agent }) + .pipe(Effect.forkChild) + const ask = yield* awaitAsk(permA) + // Backdate the row past the TTL: the turn that raised it is gone. + yield* Effect.promise(async () => { + const raw = createClient({ url: `file:${file}` }) + await raw.execute({ + sql: "UPDATE permission_request SET time_updated = ? WHERE id = ?", + args: [Date.now() - 25 * 60 * 60 * 1000, ask.id], + }) + raw.close() + }) + expect(yield* permA.list()).toEqual([]) + yield* Fiber.interrupt(blocked) + yield* Effect.promise(() => tmp[Symbol.asyncDispose]()) + }), + ) + + it.live("expires locally-pending asks on shutdown so they do not linger as pending rows", () => + Effect.gen(function* () { + const tmp = yield* Effect.promise(() => tmpdir()) + const file = path.join(tmp.path, "shared.db") + yield* seed(file) + let askID: PermissionV2.ID | undefined + // A raises an ask, then its scope closes (a graceful shutdown) before anyone replies. + yield* Effect.scoped( + Effect.gen(function* () { + const A = yield* Layer.build(stack(file)) + const permA = Context.get(A, PermissionV2.Service) + const result = yield* permA.ask({ sessionID, action: "bash", resources: ["echo bye"], agent }) + askID = result.id + }), + ) + const B = yield* Layer.build(stack(file)) + const permB = Context.get(B, PermissionV2.Service) + expect(askID).toBeDefined() + // The waiter died with A, so the ask is retired, not stuck pending forever. + expect(yield* permB.get(askID!)).toBeUndefined() + expect(yield* permB.list()).toEqual([]) + yield* Effect.promise(() => tmp[Symbol.asyncDispose]()) + }), + ) +}) diff --git a/packages/core/test/question-durable.test.ts b/packages/core/test/question-durable.test.ts new file mode 100644 index 000000000000..cb5590a27f57 --- /dev/null +++ b/packages/core/test/question-durable.test.ts @@ -0,0 +1,206 @@ +// Durable question asks: a pending question is a row in the shared store, so an ask raised by one +// process (a standalone worker's activity) can be listed and answered from another (the HTTP +// server), and the blocked ask observes the cross-process answer by polling the row. Two fully +// independent service stacks share one DB file to simulate the two processes. +import { describe, expect } from "bun:test" +import path from "path" +import { createClient } from "@libsql/client" +import { Cause, Context, Effect, Exit, Fiber, Layer, Scope } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Database } from "@opencode-ai/core/database/database" +import { QuestionV2 } from "@opencode-ai/core/question" +import { SessionV2 } from "@opencode-ai/core/session" +import { testEffect } from "./lib/effect" +import { tmpdir } from "./fixture/tmpdir" + +const sessionID = SessionV2.ID.make("ses_question_durable") +const question: QuestionV2.Info = { + question: "Which option?", + header: "Option", + options: [{ label: "One", description: "First option" }], +} + +const stack = (file: string) => AppNodeBuilder.build(QuestionV2.node, [[Database.node, Database.layerFromPath(file)]]) + +const it = testEffect(Layer.empty) + +const awaitAsk = (service: QuestionV2.Interface) => + Effect.gen(function* () { + for (;;) { + const asks = yield* service.list() + const ask = asks[0] + if (ask) return ask + yield* Effect.sleep(50) + } + }) + +describe("QuestionV2 durable asks", () => { + it.live("unblocks an ask via an answer from a second process sharing the store", () => + Effect.gen(function* () { + const tmp = yield* Effect.promise(() => tmpdir()) + const file = path.join(tmp.path, "shared.db") + const A = yield* Layer.build(stack(file)) + const B = yield* Layer.build(stack(file)) + const questionA = Context.get(A, QuestionV2.Service) + const questionB = Context.get(B, QuestionV2.Service) + + // A: a tool blocks on the questions. B: a different process sees the durable ask and answers. + const blocked = yield* questionA.ask({ sessionID, questions: [question] }).pipe(Effect.forkChild) + const ask = yield* awaitAsk(questionB) + expect(ask.sessionID).toBe(sessionID) + expect(ask.questions).toEqual([question]) + yield* questionB.reply({ requestID: ask.id, answers: [["One"]] }) + const exit = yield* Fiber.await(blocked) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) expect(exit.value).toEqual([["One"]]) + // The row is settled everywhere: no pending asks remain on either side. + expect(yield* questionA.list()).toEqual([]) + expect(yield* questionB.list()).toEqual([]) + yield* Effect.promise(() => tmp[Symbol.asyncDispose]()) + }), + ) + + it.live("delivers a cross-process rejection as the typed RejectedError", () => + Effect.gen(function* () { + const tmp = yield* Effect.promise(() => tmpdir()) + const file = path.join(tmp.path, "shared.db") + const A = yield* Layer.build(stack(file)) + const B = yield* Layer.build(stack(file)) + const questionA = Context.get(A, QuestionV2.Service) + const questionB = Context.get(B, QuestionV2.Service) + + const blocked = yield* questionA.ask({ sessionID, questions: [question] }).pipe(Effect.forkChild) + const ask = yield* awaitAsk(questionB) + yield* questionB.reject(ask.id) + const exit = yield* Fiber.await(blocked) + expect(Exit.isFailure(exit)).toBe(true) + const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined + expect(error).toBeInstanceOf(QuestionV2.RejectedError) + yield* Effect.promise(() => tmp[Symbol.asyncDispose]()) + }), + ) + + it.live("a re-drive adopts the same pending ask instead of duplicating it", () => + Effect.gen(function* () { + const tmp = yield* Effect.promise(() => tmpdir()) + const file = path.join(tmp.path, "shared.db") + const A = yield* Layer.build(stack(file)) + const B = yield* Layer.build(stack(file)) + const questionA = Context.get(A, QuestionV2.Service) + const questionB = Context.get(B, QuestionV2.Service) + const input: QuestionV2.AskInput = { + sessionID, + questions: [question], + tool: { messageID: "msg_1", callID: "call_redrive" }, + } + + // Attempt 1 blocks, then dies (a crashed activity): the row stays pending. + const first = yield* questionA.ask(input).pipe(Effect.forkChild) + const ask = yield* awaitAsk(questionB) + yield* Fiber.interrupt(first) + // Attempt 2 (the Temporal retry) files the same deterministic ask: one row, same id. + const second = yield* questionA.ask(input).pipe(Effect.forkChild) + yield* Effect.sleep(100) + const asks = yield* questionB.list() + expect(asks).toHaveLength(1) + expect(asks[0]?.id).toBe(ask.id) + yield* questionB.reply({ requestID: ask.id, answers: [["One"]] }) + const exit = yield* Fiber.await(second) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) expect(exit.value).toEqual([["One"]]) + yield* Effect.promise(() => tmp[Symbol.asyncDispose]()) + }), + ) + + it.live("honors answers that landed while the asker was dead", () => + Effect.gen(function* () { + const tmp = yield* Effect.promise(() => tmpdir()) + const file = path.join(tmp.path, "shared.db") + const A = yield* Layer.build(stack(file)) + const B = yield* Layer.build(stack(file)) + const questionA = Context.get(A, QuestionV2.Service) + const questionB = Context.get(B, QuestionV2.Service) + const input: QuestionV2.AskInput = { + sessionID, + questions: [question], + tool: { messageID: "msg_2", callID: "call_dead_asker" }, + } + + const first = yield* questionA.ask(input).pipe(Effect.forkChild) + const ask = yield* awaitAsk(questionB) + yield* Fiber.interrupt(first) + // The human answers after the asker died; the retry short-circuits on the answered row. + yield* questionB.reply({ requestID: ask.id, answers: [["One"]] }) + expect(yield* questionA.ask(input)).toEqual([["One"]]) + expect(yield* questionB.list()).toEqual([]) + yield* Effect.promise(() => tmp[Symbol.asyncDispose]()) + }), + ) + + it.live("shutdown honors an answer that landed before the poll saw it", () => + Effect.gen(function* () { + const tmp = yield* Effect.promise(() => tmpdir()) + const file = path.join(tmp.path, "shared.db") + const B = yield* Layer.build(stack(file)) + const questionB = Context.get(B, QuestionV2.Service) + // A lives in its own scope so the test can shut it down while the waiter is parked. + const scope = yield* Scope.make() + const A = yield* Layer.build(stack(file)).pipe(Effect.provideService(Scope.Scope, scope)) + const questionA = Context.get(A, QuestionV2.Service) + const blocked = yield* questionA.ask({ sessionID, questions: [question] }).pipe(Effect.forkChild) + const ask = yield* awaitAsk(questionB) + yield* questionB.reply({ requestID: ask.id, answers: [["One"]] }) + // Shut A down inside the poll window: the finalizer must honor the answer on the row, not + // reject a question the user already answered. + yield* Scope.close(scope, Exit.void) + const exit = yield* Fiber.await(blocked) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) expect(exit.value).toEqual([["One"]]) + yield* Effect.promise(() => tmp[Symbol.asyncDispose]()) + }), + ) + + it.live("sweeps an abandoned pending ask out of the list on read", () => + Effect.gen(function* () { + const tmp = yield* Effect.promise(() => tmpdir()) + const file = path.join(tmp.path, "shared.db") + const A = yield* Layer.build(stack(file)) + const questionA = Context.get(A, QuestionV2.Service) + const blocked = yield* questionA.ask({ sessionID, questions: [question] }).pipe(Effect.forkChild) + const ask = yield* awaitAsk(questionA) + // Backdate the row past the TTL: the turn that raised it is gone. + yield* Effect.promise(async () => { + const raw = createClient({ url: `file:${file}` }) + await raw.execute({ + sql: "UPDATE question_request SET time_updated = ? WHERE id = ?", + args: [Date.now() - 25 * 60 * 60 * 1000, ask.id], + }) + raw.close() + }) + expect(yield* questionA.list()).toEqual([]) + yield* Fiber.interrupt(blocked) + yield* Effect.promise(() => tmp[Symbol.asyncDispose]()) + }), + ) + + it.live("expires locally-pending asks on shutdown so they do not linger as pending rows", () => + Effect.gen(function* () { + const tmp = yield* Effect.promise(() => tmpdir()) + const file = path.join(tmp.path, "shared.db") + // A raises an ask, then its scope closes (a graceful shutdown) before anyone answers. + const scope = yield* Scope.make() + const A = yield* Layer.build(stack(file)).pipe(Effect.provideService(Scope.Scope, scope)) + const questionA = Context.get(A, QuestionV2.Service) + const blocked = yield* questionA.ask({ sessionID, questions: [question] }).pipe(Effect.forkChild) + yield* awaitAsk(questionA) + yield* Scope.close(scope, Exit.void) + const exit = yield* Fiber.await(blocked) + expect(Exit.isFailure(exit)).toBe(true) + const B = yield* Layer.build(stack(file)) + const questionB = Context.get(B, QuestionV2.Service) + // The waiter died with A, so the ask is retired, not stuck pending forever. + expect(yield* questionB.list()).toEqual([]) + yield* Effect.promise(() => tmp[Symbol.asyncDispose]()) + }), + ) +}) diff --git a/packages/core/test/question.test.ts b/packages/core/test/question.test.ts index 03d61a956507..64d87be80297 100644 --- a/packages/core/test/question.test.ts +++ b/packages/core/test/question.test.ts @@ -89,26 +89,20 @@ describe("QuestionV2", () => { }), ) - it.effect("isolates pending requests by location-layer instance and rejects them on finalization", () => + // Cross-instance visibility now goes through the durable rows (question-durable.test.ts); what + // finalization still owes a parked asker is a rejection, not silence. + it.effect("rejects pending asks when the owning layer finalizes", () => Effect.gen(function* () { - const firstScope = yield* Scope.make() - const secondScope = yield* Scope.make() - const first = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), firstScope), QuestionV2.Service) - const second = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), secondScope), QuestionV2.Service) - const fiber = yield* first.ask({ sessionID, questions: [question] }).pipe(Effect.forkScoped) - yield* Effect.yieldNow - const request = (yield* first.list())[0]! + const scope = yield* Scope.make() + const service = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), scope), QuestionV2.Service) + const fiber = yield* service.ask({ sessionID, questions: [question] }).pipe(Effect.forkScoped) + for (let i = 0; i < 100 && (yield* service.list()).length === 0; i++) yield* Effect.yieldNow + expect(yield* service.list()).toHaveLength(1) - expect(yield* second.list()).toEqual([]) - expect(yield* second.reply({ requestID: request.id, answers: [["One"]] }).pipe(Effect.flip)).toEqual( - new QuestionV2.NotFoundError({ requestID: request.id }), - ) - - yield* Scope.close(firstScope, Exit.void) + yield* Scope.close(scope, Exit.void) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(exit.cause.toString()).toContain("QuestionV2.RejectedError") - yield* Scope.close(secondScope, Exit.void) }), ) }) diff --git a/packages/core/test/session-execution-local.test.ts b/packages/core/test/session-execution-local.test.ts new file mode 100644 index 000000000000..fd8bcd4b5c5f --- /dev/null +++ b/packages/core/test/session-execution-local.test.ts @@ -0,0 +1,8 @@ +// The conformance suite run against the built-in local executor: SessionRunCoordinator behind the +// SessionExecution seam, whole turns through SessionRunner.run, no server. The same scenarios run +// against the Temporal executor in packages/temporal; the shared suite is what holds any executor +// to one behavior. +import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local" +import { makeExecutionFor, runContract } from "./lib/execution-conformance" + +runContract("local executor", makeExecutionFor(SessionExecutionLocal.node)) diff --git a/packages/core/test/session-runner-loop-guard.test.ts b/packages/core/test/session-runner-loop-guard.test.ts new file mode 100644 index 000000000000..acb34bb1f232 --- /dev/null +++ b/packages/core/test/session-runner-loop-guard.test.ts @@ -0,0 +1,107 @@ +// Turn-level loop bounds: a run whose model repeats the exact same tool call step after step must +// terminate on its own -- after REPEAT_LIMIT identical steps the next attempt runs as a last step +// (tools disabled, text-only wrap-up) instead of looping forever. Driven end to end through +// SessionRunner.run with a mock LLM that always answers with the same tool call until tools are +// disabled. Also unit-covers the trailing-signature detection. +import type { LLMClientShape } from "@opencode-ai/llm/route" +import { LLMEvent } from "@opencode-ai/llm" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionRunner } from "@opencode-ai/core/session/runner" +import { REPEAT_LIMIT, trailingIdenticalToolSteps } from "@opencode-ai/core/session/runner/loop-guard" +import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" +import { Tool } from "@opencode-ai/core/tool/tool" +import { SessionStore } from "@opencode-ai/core/session/store" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { describe, expect, test } from "bun:test" +import { DateTime, Effect, Schema, Stream } from "effect" +import { emptyStep, runnerHarness, seedSession } from "./lib/runner-harness" + +// Always answers with the same tool call until tools are disabled, then a bare text-less final step. +// Records each request's toolChoice so the test can assert the guard fired. +const stuckModel = () => { + // LLM.request normalizes toolChoice into a ToolChoice class; record its `type`. + const requests: Array<{ readonly tools: number; readonly toolChoice: string | undefined }> = [] + let attempt = 0 + const stream: LLMClientShape["stream"] = (request) => { + const toolChoice = (request.toolChoice as { type?: string } | undefined)?.type + requests.push({ tools: request.tools.length, toolChoice }) + attempt++ + if (toolChoice === "none" || request.tools.length === 0) return emptyStep(request) + return Stream.fromIterable([ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: `call_${attempt}`, name: "probe_stuck", input: { target: "same" } }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + ]) + } + return { requests, stream } +} + +const sessionID = SessionV2.ID.make("ses_loop_guard") + +describe("SessionRunner loop guard", () => { + const { requests, stream } = stuckModel() + runnerHarness(stream).effect("ends a run whose model repeats the same tool call every step", () => + Effect.gen(function* () { + yield* seedSession(sessionID) + yield* (yield* ApplicationTools.Service).register({ + probe_stuck: Tool.make({ + description: "always same result", + input: Schema.Struct({ target: Schema.String }), + output: Schema.String, + toModelOutput: ({ output }) => [{ type: "text", text: output }], + execute: () => Effect.succeed("unchanged"), + }), + }) + const runner = yield* SessionRunner.Service + // Terminates on its own: REPEAT_LIMIT identical tool steps, then one text-only wrap-up step. + yield* runner.run({ sessionID, force: true }) + const context = yield* (yield* SessionStore.Service).context(sessionID) + const assistants = context.filter((message) => message.type === "assistant") + expect(assistants).toHaveLength(REPEAT_LIMIT + 1) + const last = assistants.at(-1) + expect(last?.type === "assistant" ? last.content.filter((part) => part.type === "tool") : undefined).toEqual([]) + // The guard, not the model, ended the run: the final request had tools disabled. + expect(requests.at(-1)?.tools).toBe(0) + expect(requests.at(-1)?.toolChoice).toBe("none") + expect(requests).toHaveLength(REPEAT_LIMIT + 1) + }), + ) + + test("trailing-signature detection counts only consecutive identical non-empty tool steps", () => { + const created = DateTime.makeUnsafe(0) + const assistant = (value: string, tools: Array<{ name: string; input: Record }>) => + SessionMessage.Assistant.make({ + id: SessionMessage.ID.make(`msg_${value}`), + type: "assistant", + agent: "build", + model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, + content: tools.map((tool) => + SessionMessage.AssistantTool.make({ + type: "tool", + id: `call_${value}_${tool.name}`, + name: tool.name, + time: { created }, + state: SessionMessage.ToolStateRunning.make({ + status: "running", + input: tool.input, + structured: {}, + content: [], + }), + }), + ), + time: { created, completed: created }, + }) + const same = { name: "read", input: { path: "a.txt" } } + const other = { name: "edit", input: { path: "a.txt", change: 1 } } + + expect(trailingIdenticalToolSteps([assistant("1", [same]), assistant("2", [same]), assistant("3", [same])])).toBe(3) + // A step that also does different work breaks the run (iterating, not stuck). + expect( + trailingIdenticalToolSteps([assistant("1", [same]), assistant("2", [same, other]), assistant("3", [same])]), + ).toBe(1) + // A text-only step breaks the run. + expect(trailingIdenticalToolSteps([assistant("1", [same]), assistant("2", []), assistant("3", [same])])).toBe(1) + }) +}) diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 49bbce95a381..d47a4b296f5a 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -1,8 +1,9 @@ import { describe, expect } from "bun:test" import { LLM } from "@opencode-ai/llm" import { LLMClient } from "@opencode-ai/llm/route" -import { DateTime, Effect } from "effect" +import { DateTime, Effect, Layer } from "effect" import { Headers } from "effect/unstable/http" +import { Catalog } from "@opencode-ai/core/catalog" import { Credential } from "@opencode-ai/core/credential" import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" @@ -344,4 +345,108 @@ describe("SessionRunnerModel", () => { expect(SessionRunnerModel.supported(model({ type: "native", settings: {} }))).toBe(false) }), ) + + it.live("waits out the catalog's async population instead of failing the model", () => + Effect.gen(function* () { + const catalogModel = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }) + // Starts empty, like the catalog right after boot; plugins fill it a moment later. + const models: ModelV2.Info[] = [] + const catalogMock = Layer.mock(Catalog.Service, { + model: { + all: () => Effect.sync(() => [...models]), + available: () => Effect.sync(() => [...models]), + default: () => Effect.succeed(undefined), + }, + provider: { + get: () => Effect.succeed(undefined), + }, + } as unknown as Catalog.Interface) + const integrationMock = Layer.mock(Integration.Service, { + connection: { active: () => Effect.succeed(undefined) }, + } as unknown as Integration.Interface) + const session = SessionV2.Info.make({ + id: SessionV2.ID.make("ses_model_boot_race"), + projectID: ProjectV2.ID.global, + title: "test", + model: { id: catalogModel.id, providerID: catalogModel.providerID }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location: { directory: AbsolutePath.make("/project") }, + }) + + const resolved = yield* Effect.gen(function* () { + const svc = yield* SessionRunnerModel.Service + yield* Effect.forkScoped(Effect.sleep(400).pipe(Effect.andThen(Effect.sync(() => models.push(catalogModel))))) + return yield* svc.resolve(session) + }).pipe( + Effect.provide( + SessionRunnerModel.locationLayer.pipe(Layer.provide(catalogMock), Layer.provide(integrationMock)), + ), + ) + + expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" }) + }), + ) + + it.live("waits out the integration state's async population before sending without auth", () => + Effect.gen(function* () { + // A keyless model: auth can only come from a connection, so a resolve that runs before the + // integration state settles would send an unauthenticated request. + const catalogModel = ModelV2.Info.make({ + ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), + request: { headers: {}, body: {} }, + }) + const catalogMock = Layer.mock(Catalog.Service, { + model: { + all: () => Effect.succeed([catalogModel]), + available: () => Effect.succeed([catalogModel]), + default: () => Effect.succeed(undefined), + }, + provider: { + get: () => Effect.succeed(undefined), + }, + } as unknown as Catalog.Interface) + // The connection lands a moment after the catalog, like plugins at boot. + let connection: { type: "env"; name: string } | undefined + const integrationMock = Layer.mock(Integration.Service, { + connection: { + active: () => Effect.sync(() => connection), + resolve: () => Effect.succeed(Credential.Key.make({ type: "key", key: "boot-race-key" })), + }, + } as unknown as Integration.Interface) + const session = SessionV2.Info.make({ + id: SessionV2.ID.make("ses_model_auth_race"), + projectID: ProjectV2.ID.global, + title: "test", + model: { id: catalogModel.id, providerID: catalogModel.providerID }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location: { directory: AbsolutePath.make("/project") }, + }) + + const resolved = yield* Effect.gen(function* () { + const svc = yield* SessionRunnerModel.Service + yield* Effect.forkScoped( + Effect.sleep(400).pipe(Effect.andThen(Effect.sync(() => (connection = { type: "env", name: "TEST_KEY" })))), + ) + return yield* svc.resolve(session) + }).pipe( + Effect.provide( + SessionRunnerModel.locationLayer.pipe(Layer.provide(catalogMock), Layer.provide(integrationMock)), + ), + ) + + const request = LLM.request({ model: resolved, prompt: "Hello" }) + const headers = yield* resolved.route.auth.apply({ + request, + method: "POST", + url: "https://openai.example/v1/responses", + body: "{}", + headers: Headers.empty, + }) + expect(headers.authorization).toBe("Bearer boot-race-key") + }), + ) }) diff --git a/packages/core/test/session-runner-recovery.test.ts b/packages/core/test/session-runner-recovery.test.ts new file mode 100644 index 000000000000..da84e1319785 --- /dev/null +++ b/packages/core/test/session-runner-recovery.test.ts @@ -0,0 +1,105 @@ +// Re-drive eligibility: a crashed drain leaves no pending input rows (promotion consumed them +// inside the turn), so eligibility must come from the log. A promoted-but-unanswered prompt or an +// in-flight assistant makes a force=false re-drive run the turn; a settled history stays a no-op, +// so retries of a completed drain never spin the model. +import type { LLMClientShape } from "@opencode-ai/llm/route" +import { LLMEvent } from "@opencode-ai/llm" +import { EventV2 } from "@opencode-ai/core/event" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionRunner } from "@opencode-ai/core/session/runner" +import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event" +import { SessionStore } from "@opencode-ai/core/session/store" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { Prompt } from "@opencode-ai/core/session/prompt" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { describe, expect } from "bun:test" +import { DateTime, Effect } from "effect" +import { emptyStep, runnerHarness, seedSession } from "./lib/runner-harness" + +// Counts provider calls and answers each with an empty completed step. +const countingModel = () => { + const requests: number[] = [] + const stream: LLMClientShape["stream"] = (request) => { + requests.push(1) + return emptyStep(request) + } + return { requests, stream } +} + +// A prompt whose input row was already consumed: only the projected user message remains, exactly +// what a crash after promotion leaves behind. +const seedPromotedPrompt = (sessionID: SessionV2.ID) => + Effect.gen(function* () { + const events = yield* EventV2.Service + yield* events.publish(SessionEvent.Prompted, { + sessionID, + timestamp: yield* DateTime.now, + messageID: SessionMessage.ID.create(), + prompt: Prompt.make({ text: "do the thing" }), + delivery: "queue", + }) + }) + +describe("SessionRunner crash-recovery eligibility", () => { + { + const { requests, stream } = countingModel() + const sessionID = SessionV2.ID.make("ses_recovery_prompted") + runnerHarness(stream).effect("re-drives a promoted-but-unanswered prompt, then settles", () => + Effect.gen(function* () { + yield* seedSession(sessionID) + yield* seedPromotedPrompt(sessionID) + const runner = yield* SessionRunner.Service + yield* runner.run({ sessionID, force: false }) + expect(requests).toHaveLength(1) + const context = yield* (yield* SessionStore.Service).context(sessionID) + const assistant = context.findLast((message) => message.type === "assistant") + expect(assistant?.type === "assistant" ? Boolean(assistant.time.completed) : false).toBe(true) + // Settled history: a retried completed drain stays a no-op instead of re-calling the model. + yield* runner.run({ sessionID, force: false }) + expect(requests).toHaveLength(1) + }), + ) + } + + { + const { requests, stream } = countingModel() + const sessionID = SessionV2.ID.make("ses_recovery_inflight") + runnerHarness(stream).effect("re-drives an in-flight assistant and closes its dangling tool", () => + Effect.gen(function* () { + yield* seedSession(sessionID) + const events = yield* EventV2.Service + const publisher = createLLMEventPublisher(events, { + sessionID, + agent: "build", + model: { id: ModelV2.ID.make("gpt-4o-mini"), providerID: ProviderV2.ID.make("openai") }, + }) + yield* publisher.publish(LLMEvent.toolInputStart({ id: "call_dangling", name: "read" })) + const runner = yield* SessionRunner.Service + yield* runner.run({ sessionID, force: false }) + expect(requests).toHaveLength(1) + const context = yield* (yield* SessionStore.Service).context(sessionID) + for (const message of context) { + if (message.type !== "assistant") continue + for (const part of message.content) + if (part.type === "tool" && part.id === "call_dangling") expect(part.state.status).toBe("error") + } + }), + ) + } + + { + const { requests, stream } = countingModel() + const sessionID = SessionV2.ID.make("ses_recovery_step") + runnerHarness(stream).effect("runStep(first) recovers the same window instead of no-opping", () => + Effect.gen(function* () { + yield* seedSession(sessionID) + yield* seedPromotedPrompt(sessionID) + const runner = yield* SessionRunner.Service + yield* runner.runStep({ sessionID, step: 1, promotion: undefined, first: true, force: false }) + expect(requests).toHaveLength(1) + }), + ) + } +}) diff --git a/packages/core/test/session-runner-resume.test.ts b/packages/core/test/session-runner-resume.test.ts new file mode 100644 index 000000000000..f4d468c4714a --- /dev/null +++ b/packages/core/test/session-runner-resume.test.ts @@ -0,0 +1,144 @@ +// Resumability of a per-step turn (temporal mode): a Temporal step retry re-invokes runStep on the +// same durable log. These tests seed a crashed in-flight step (Step.Started + tool events, no +// Step.Ended) and drive runStep to check the two recovery behaviors: +// - Slice 1: a dangling tool left by an interrupted attempt is closed on every step entry, not +// just the first, so a re-drive never re-streams a request with a tool_use and no tool_result. +// - Slice 2: a step whose tools already ran is finalized from the log without re-calling the model +// (a dying mock LLM proves the model is never re-streamed) or re-running a completed tool. +import type { LLMClientShape } from "@opencode-ai/llm/route" +import { LLMEvent } from "@opencode-ai/llm" +import { EventV2 } from "@opencode-ai/core/event" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionRunner } from "@opencode-ai/core/session/runner" +import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event" +import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" +import { Tool } from "@opencode-ai/core/tool/tool" +import { SessionStore } from "@opencode-ai/core/session/store" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { describe, expect } from "bun:test" +import { Effect, Schema, Stream } from "effect" +import { emptyStep, runnerHarness, seedSession } from "./lib/runner-harness" + +// The model call is what a resume must NOT repeat, so tests drive it explicitly: `dying` fails if +// the runner streams at all (proves resume skips the model); `emptyStep` is a benign one-step response. +const dying: LLMClientShape["stream"] = () => Stream.die("LLMClient.stream should not be called on resume") + +const sessionID = SessionV2.ID.make("ses_runner_resume") + +// Each seed records a step that started but never published Step.Ended -- what a worker crash mid +// turn leaves in the log. +const seededPublisher = Effect.gen(function* () { + const events = yield* EventV2.Service + return createLLMEventPublisher(events, { + sessionID, + agent: "build", + model: { id: ModelV2.ID.make("gpt-4o-mini"), providerID: ProviderV2.ID.make("openai") }, + }) +}) +// Only a tool INPUT started -- the call was never dispatched, so no side effect ran and re-streaming +// is safe. This exercises slice 1 (close the dangling tool so the re-drive is not a poison loop). +const seedPendingStep = Effect.gen(function* () { + const publisher = yield* seededPublisher + yield* publisher.publish(LLMEvent.toolInputStart({ id: "call_pending", name: "read" })) +}) +// Tools were dispatched: `call_done` recorded its result (completed), `call_running` did not (its +// side effect may have run). This exercises slice 2 (finalize from the log, never re-stream). +const seedDispatchedStep = Effect.gen(function* () { + const publisher = yield* seededPublisher + yield* publisher.publish(LLMEvent.toolCall({ id: "call_done", name: "read", input: { path: "a.txt" } })) + yield* publisher.publish( + LLMEvent.toolResult({ + id: "call_done", + name: "read", + result: { type: "content", value: [{ type: "text", text: "done" }] }, + output: { structured: {}, content: [{ type: "text", text: "done" }] }, + }), + ) + yield* publisher.publish(LLMEvent.toolCall({ id: "call_running", name: "read", input: { path: "b.txt" } })) +}) + +const toolPart = (messages: ReadonlyArray, callID: string) => { + for (const message of messages) { + if (message.type !== "assistant") continue + for (const part of message.content) if (part.type === "tool" && part.id === callID) return part + } + return undefined +} + +describe("SessionRunner resume", () => { + runnerHarness(emptyStep).effect("closes a dangling tool on a mid-turn (first=false) re-drive", () => + Effect.gen(function* () { + yield* seedSession(sessionID) + yield* seedPendingStep + const runner = yield* SessionRunner.Service + const store = yield* SessionStore.Service + yield* runner.runStep({ sessionID, step: 2, promotion: "steer", first: false, force: false }) + const part = toolPart(yield* store.context(sessionID), "call_pending") + expect(part?.type === "tool" ? part.state.status : undefined).toBe("error") + }), + ) + + runnerHarness(dying).effect("finalizes a dispatched step from the log without re-calling the model", () => + Effect.gen(function* () { + yield* seedSession(sessionID) + yield* seedDispatchedStep + const runner = yield* SessionRunner.Service + const store = yield* SessionStore.Service + // A dying stream would fail this call if the runner re-streamed; it resolving proves resume. + const result = yield* runner.runStep({ sessionID, step: 3, promotion: "steer", first: false, force: false }) + expect(result.continue).toBe(true) + const context = yield* store.context(sessionID) + const done = toolPart(context, "call_done") + const running = toolPart(context, "call_running") + // The completed tool keeps its recorded result (not re-run); the unsettled one is failed. + expect(done?.type === "tool" ? done.state.status : undefined).toBe("completed") + expect(running?.type === "tool" ? running.state.status : undefined).toBe("error") + // The step is finalized in place (no duplicate assistant message). + const assistants = context.filter((message) => message.type === "assistant") + expect(assistants).toHaveLength(1) + expect(assistants[0]?.type === "assistant" ? Boolean(assistants[0].time.completed) : false).toBe(true) + }), + ) + + runnerHarness(dying).effect("re-settles an idempotent tool on resume but fails a side-effecting one", () => + Effect.gen(function* () { + yield* seedSession(sessionID) + // A pure-read tool is safe to re-run; a side-effecting one is not. + yield* (yield* ApplicationTools.Service).register({ + probe_read: Tool.make({ + description: "read probe", + idempotent: true, + input: Schema.Struct({}), + output: Schema.String, + toModelOutput: ({ output }) => [{ type: "text", text: output }], + execute: () => Effect.succeed("resettled"), + }), + probe_write: Tool.make({ + description: "write probe", + input: Schema.Struct({}), + output: Schema.String, + execute: () => Effect.succeed("wrote"), + }), + }) + const publisher = yield* seededPublisher + yield* publisher.publish(LLMEvent.toolCall({ id: "call_ro", name: "probe_read", input: {} })) + yield* publisher.publish(LLMEvent.toolCall({ id: "call_rw", name: "probe_write", input: {} })) + const runner = yield* SessionRunner.Service + const store = yield* SessionStore.Service + // Dying stream: resume must not re-call the model; the idempotent tool is re-run via the registry. + yield* runner.runStep({ sessionID, step: 4, promotion: "steer", first: false, force: false }) + const context = yield* store.context(sessionID) + const readPart = toolPart(context, "call_ro") + const writePart = toolPart(context, "call_rw") + expect(readPart?.type === "tool" ? readPart.state.status : undefined).toBe("completed") + expect( + readPart?.type === "tool" && readPart.state.status === "completed" + ? readPart.state.content.some((item) => item.type === "text" && item.text === "resettled") + : false, + ).toBe(true) + expect(writePart?.type === "tool" ? writePart.state.status : undefined).toBe("error") + }), + ) +}) diff --git a/packages/core/test/worktree-materialize.test.ts b/packages/core/test/worktree-materialize.test.ts new file mode 100644 index 000000000000..70d18a155344 --- /dev/null +++ b/packages/core/test/worktree-materialize.test.ts @@ -0,0 +1,128 @@ +// Cross-host worktree materialization: the capture side ships snapshot trees as git packs into +// the shared store; a worker on a host without the project tree rebuilds it before draining. One +// DB file, two independent stacks: "host A" captures and pushes, the worktree is deleted to +// simulate a fresh host, "host B" materializes it back from the store alone. +import { describe, expect } from "bun:test" +import { $ } from "bun" +import { realpathSync } from "node:fs" +import { mkdir, readFile, rm, writeFile } from "node:fs/promises" +import path from "path" +import { asc } from "drizzle-orm" +import { Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Database } from "@opencode-ai/core/database/database" +import { Global } from "@opencode-ai/core/global" +import { Location } from "@opencode-ai/core/location" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Snapshot } from "@opencode-ai/core/snapshot" +import { SnapshotSync } from "@opencode-ai/core/snapshot-sync" +import { SnapshotPackTable } from "@opencode-ai/core/snapshot/sql" +import { WorktreeMaterializer } from "@opencode-ai/core/session/execution/worktree" +import { testEffect } from "./lib/effect" +import { tmpdir } from "./fixture/tmpdir" + +const it = testEffect(Layer.empty) + +// The capturing host: real Location resolution against the git worktree, its own data dir for the +// side snapshot repo, the shared DB file. +const captureStack = (file: string, worktree: string, data: string) => + AppNodeBuilder.build(LayerNode.group([Snapshot.node, SnapshotSync.node]), [ + [Database.node, Database.layerFromPath(file)], + [Location.node, Location.boundNode({ directory: AbsolutePath.make(worktree) })], + [Global.node, Layer.succeed(Global.Service, Global.make({ data }))], + ]) + +// The resuming host: only the shared store, no location, no snapshot repo, no worktree. +const materializeStack = (file: string) => + AppNodeBuilder.build(WorktreeMaterializer.node, [[Database.node, Database.layerFromPath(file)]]) + +describe("WorktreeMaterializer", () => { + it.live("rebuilds a deleted worktree from shared-store packs, incremental chain included", () => + Effect.gen(function* () { + const tmp = yield* Effect.promise(() => tmpdir()) + const root = realpathSync(tmp.path) + const worktree = path.join(root, "project") + const file = path.join(root, "shared.db") + yield* Effect.promise(async () => { + await mkdir(worktree, { recursive: true }) + await $`git init -q ${worktree}`.quiet() + await $`git -C ${worktree} config user.email t@t`.quiet() + await $`git -C ${worktree} config user.name t`.quiet() + await writeFile(path.join(worktree, "tracked.txt"), "v1\n") + await $`git -C ${worktree} add .`.quiet() + await $`git -C ${worktree} commit -qm seed`.quiet() + // The state to port: an uncommitted edit and a file git never saw. + await writeFile(path.join(worktree, "tracked.txt"), "v2\n") + await writeFile(path.join(worktree, "untracked.txt"), "notes\n") + }) + + const A = yield* Layer.build(captureStack(file, worktree, path.join(root, "host-a-data"))) + const first = yield* Snapshot.Service.use((s) => s.capture()).pipe(Effect.provide(A)) + if (!first) throw new Error("expected the first capture to produce a tree") + yield* SnapshotSync.Service.use((s) => s.push(first)).pipe(Effect.provide(A)) + + // A second increment on top, so materialization has to index a base pack plus a delta pack. + yield* Effect.sleep(10) + yield* Effect.promise(async () => { + await writeFile(path.join(worktree, "tracked.txt"), "v3\n") + await writeFile(path.join(worktree, "extra.txt"), "more\n") + }) + const second = yield* Snapshot.Service.use((s) => s.capture()).pipe(Effect.provide(A)) + if (!second) throw new Error("expected the second capture to produce a tree") + yield* SnapshotSync.Service.use((s) => s.push(second)).pipe(Effect.provide(A)) + + const rows = yield* Database.Service.use(({ db }) => + db.select().from(SnapshotPackTable).orderBy(asc(SnapshotPackTable.time_created)).all(), + ).pipe(Effect.orDie, Effect.provide(Database.layerFromPath(file)), Effect.scoped) + expect(rows).toHaveLength(2) + expect(rows[1]?.base).toBe(rows[0]!.id) + + // The fresh host: the tree is gone, only the shared store remains. + yield* Effect.promise(() => rm(worktree, { recursive: true, force: true })) + const B = yield* Layer.build(materializeStack(file)) + yield* WorktreeMaterializer.Service.use((w) => w.ensure(worktree)).pipe(Effect.provide(B)) + + const [tracked, untracked, extra] = yield* Effect.promise(() => + Promise.all([ + readFile(path.join(worktree, "tracked.txt"), "utf8"), + readFile(path.join(worktree, "untracked.txt"), "utf8"), + readFile(path.join(worktree, "extra.txt"), "utf8"), + ]), + ) + expect(tracked).toBe("v3\n") + expect(untracked).toBe("notes\n") + expect(extra).toBe("more\n") + + // A second ensure on an existing tree is a no-op, not a rebuild. + yield* WorktreeMaterializer.Service.use((w) => w.ensure(worktree)).pipe(Effect.provide(B)) + expect(yield* Effect.promise(() => readFile(path.join(worktree, "tracked.txt"), "utf8"))).toBe("v3\n") + + yield* Effect.promise(() => tmp[Symbol.asyncDispose]()) + }), + ) + + // The shared-store deployment uses the libsql backend, so the pack blob has to survive that + // driver's parameter path too, not only bun's. + it.live("round-trips a pack blob through the libsql backend", () => + Effect.gen(function* () { + const tmp = yield* Effect.promise(() => tmpdir()) + const file = path.join(realpathSync(tmp.path), "libsql.db") + const bytes = Buffer.from([0x50, 0x41, 0x43, 0x4b, 0x00, 0x01, 0xff, 0xfe, 0x00, 0x7f]) + const layer = Database.layerFromLibsql(`file:${file}`) + yield* Database.Service.use(({ db }) => + db + .insert(SnapshotPackTable) + .values([{ id: "c".repeat(40), directory: "/w", worktree: "/w", tree: "t".repeat(40), pack: bytes }]) + .run(), + ).pipe(Effect.orDie, Effect.provide(layer), Effect.scoped) + const row = yield* Database.Service.use(({ db }) => db.select().from(SnapshotPackTable).get()).pipe( + Effect.orDie, + Effect.provide(layer), + Effect.scoped, + ) + expect(Buffer.from(row!.pack).equals(bytes)).toBeTrue() + yield* Effect.promise(() => tmp[Symbol.asyncDispose]()) + }), + ) +}) diff --git a/packages/server/package.json b/packages/server/package.json index c99bd4ac87d8..156b1cc733c2 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@opencode-ai/core": "workspace:*", + "@opencode-ai/temporal": "workspace:*", "@opencode-ai/protocol": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:" diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index cc1b1ae6a55d..96e558065e2a 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -10,6 +10,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { SessionExecution } from "@opencode-ai/core/session/execution" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local" +import { SessionExecutionTemporal } from "@opencode-ai/temporal/executor" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" @@ -48,8 +49,40 @@ export function createEmbeddedRoutes() { return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() })) } +// The application-service context (no HTTP surface), with the execution engine selected by env. +// Shared by the HTTP routes and the standalone worker entrypoint (src/worker.ts) so both build the +// exact same context. +export function createServiceLayer() { + // The factory: two modes. "temporal" runs each session as a per-step Temporal workflow + // (@opencode-ai/temporal, wired here as one dependency); anything else runs it in-process on the + // proven SessionRunCoordinator + // (execution/local.ts) -- no server, no ports. Both drive SessionRunner over the same durable + // event log; the local coordinator owns the wake/resume/interrupt lifecycle and is shared with + // the v1 server path, so it is the well-exercised default. + const temporal = process.env.OPENCODE_SESSION_EXECUTION === "temporal" + const executionNode = temporal ? SessionExecutionTemporal.node : SessionExecutionLocal.node + return AppNodeBuilder.build(applicationServices, [ + [SessionExecution.node, executionNode], + // Only the durable executor can put a turn in another process, and only then does a live tail + // need to re-read on its own: the wake it otherwise runs on is published in-process. Local mode + // keeps the wake alone, so the default deployment pays nothing for a case it does not have. + [EventV2.node, temporal ? EventV2.pollingNode : EventV2.node], + ]) +} + +// The context for a standalone worker (src/worker.ts). Same services as serve, but with +// SessionExecution as a built member so constructing the layer eagerly starts the Temporal worker +// (with OPENCODE_TEMPORAL_ROLE=worker there is no HTTP handler to pull it in lazily). +export function createWorkerLayer() { + // A standalone worker only makes sense in temporal mode. + return AppNodeBuilder.build(LayerNode.group([applicationServices, SessionExecution.node]), [ + [SessionExecution.node, SessionExecutionTemporal.node], + [EventV2.node, EventV2.pollingNode], + ]) +} + function makeRoutes(auth: Layer.Layer) { - const serviceLayer = AppNodeBuilder.build(applicationServices, [[SessionExecution.node, SessionExecutionLocal.node]]) + const serviceLayer = createServiceLayer() return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( Layer.provide(handlers), diff --git a/packages/server/src/worker.ts b/packages/server/src/worker.ts new file mode 100644 index 000000000000..fd61b200cfe2 --- /dev/null +++ b/packages/server/src/worker.ts @@ -0,0 +1,27 @@ +// A standalone Temporal activity worker for the v2 durable execution, decoupled from the HTTP server. +// It builds the same application context serve uses (createServiceLayer) but hosts no HTTP surface: +// forcing SessionExecution constructs the Temporal worker, which then polls the task queue and runs +// session continuations. Run one or many of these next to (or instead of) an embedded worker. +// +// OPENCODE_TEMPORAL_ROLE=worker OPENCODE_SESSION_EXECUTION=temporal \ +// TEMPORAL_ADDRESS=127.0.0.1:7237 OPENCODE_DB_URL=... \ +// bun run packages/server/src/worker.ts +// +// Note: file-touching tools run against the local working tree, so a worker must have the session's +// worktree present (co-locate by worktree, share the filesystem, or reconstruct from a snapshot). +import { Effect } from "effect" +import { createWorkerLayer } from "./routes" + +const program = Effect.gen(function* () { + // createWorkerLayer builds SessionExecution as a member, so providing it here starts the Temporal + // worker (OPENCODE_TEMPORAL_ROLE must be worker or both). + yield* Effect.logInfo("opencode v2 Temporal worker running").pipe( + Effect.annotateLogs({ role: process.env.OPENCODE_TEMPORAL_ROLE ?? "both" }), + ) + yield* Effect.never +}).pipe(Effect.provide(createWorkerLayer()), Effect.scoped) + +Effect.runPromise(program).catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/packages/temporal/README.md b/packages/temporal/README.md new file mode 100644 index 000000000000..ccf24e2063ee --- /dev/null +++ b/packages/temporal/README.md @@ -0,0 +1,183 @@ +# @opencode-ai/temporal + +A Temporal executor for opencode's `SessionExecution` seam. With it, a coding session is a durable +Temporal workflow: it survives the loss of the worker running it, can run detached from any client, +and resumes on any worker that shares its store. opencode's loop, tools, model, storage and HTTP API +are untouched, and nothing Temporal lives in core. + +## How it fits together + +Durability is a choice of executor behind the substitutable `SessionExecution` service. Both +executors drive the same `SessionRunner` over the same durable event log; the difference is who +schedules the steps. + +- The default executor runs turns in-process on `SessionRunCoordinator` (`core/src/session/execution/local.ts`). +- `OPENCODE_SESSION_EXECUTION=temporal` runs each session as a per-session workflow with one + activity per step. `executor.ts` wires the client and the worker, `supervisor.ts` is the loop, + `workflow.ts` adapts it to the Temporal sandbox, and `drain.ts` is the step body. + +What an executor must do is defined executably: the conformance suite in +`packages/core/test/lib/execution-conformance.ts` runs the same wake, resume and interrupt scenarios +against the local executor in core's tests and against this package through real workflows. + +Taking at-least-once execution seriously forces six things, all of which live in core because the +local executor benefits from them too: + +1. **State is shareable.** Any worker can resume any session only if the event log is not + host-local: a libSQL backend, atomic remote writes, a busy retry, serialized migrations. +2. **Re-drives are safe.** A retried step reuses completed tool results, re-runs only tools declared + `idempotent`, and fails the rest for the model to redo. The step loop is bounded by + `loop-guard.ts`: a step ceiling and a repeated-identical-call detector. +3. **Two writers are fenced.** Each drain claims the event log with an attempt token, and a + superseded attempt's appends die. +4. **The worktree travels.** Snapshot trees ship as incremental git packs, and a worker without the + project tree rebuilds it before the run. +5. **Human-in-the-loop is durable.** A pending permission or question is a row in the shared store, + answerable from any process, adopted by re-drives and expired when abandoned. +6. **Errors survive the boundary.** `resume` rejects with the exact tagged `RunError` rebuilt from + the failure details, and a user decline is non-retryable. + +## Run it + +```bash +temporal server start-dev --port 7237 +OPENAI_API_KEY=... OPENCODE_SESSION_EXECUTION=temporal TEMPORAL_ADDRESS=127.0.0.1:7237 \ + bun run --cwd packages/cli src/index.ts serve --port 4601 +``` + +Create a session with `POST /api/session` and prompt it with `POST /api/session/:id/prompt`. Each +session runs as the workflow `session-exec-`. + +## Configuration + +| Variable | Default | Meaning | +|---|---|---| +| `OPENCODE_SESSION_EXECUTION` | local | `temporal` selects this executor. | +| `TEMPORAL_ADDRESS` | `127.0.0.1:7237` | The Temporal frontend. | +| `TEMPORAL_NAMESPACE` | `default` | | +| `OPENCODE_TEMPORAL_TASK_QUEUE` | `opencode-session-exec` | | +| `OPENCODE_TEMPORAL_ROLE` | `both` | `both` hosts the worker and the client in one process. `client` drives workflows without a worker. `worker` runs activities with no HTTP surface. | +| `OPENCODE_SESSION_IDLE_TIMEOUT` | | How long an idle session's workflow stays open. Local mode honors the same variable. | +| `OPENCODE_EVENT_POLL_MS` | | How often a live subscriber re-reads the log for events another process appended. `0` turns the tick off. | +| `OPENCODE_DB` | | One absolute path shared by every process on a host. | +| `OPENCODE_DB_URL`, `OPENCODE_DB_AUTH_TOKEN` | | A libSQL URL for a store shared across hosts. Takes precedence over `OPENCODE_DB`. | + +Settings are read when the executor's layer is built, never at module load, so an embedder or a +test can provide `TemporalConfig.Service` instead. + +## One step per activity + +The supervisor loops a `runTurnStep` activity. Each step is one provider attempt plus the tools it +asked for, with its own retry, timeout and visibility. `SessionRunner.runStep` is one iteration of +the same loop `run` uses, so the two executors do not diverge. + +A re-drive resumes from the durable log rather than repeating work. `Tool.Called` is recorded before +a tool runs, so once a crashed step shows a dispatched tool, re-streaming the model could run that +side effect twice. The step is closed from the log instead: completed tool results are kept, tools +declared `idempotent` (`read`, `glob`, `grep`) are run again for a real result, and anything else +still open is failed for the model to redo, since the runner cannot know whether it already ran. A +step that crashed before dispatching anything is safe to stream again, and a dangling tool call left +by an interrupted attempt is closed on every step entry so no request ever carries a `tool_use` +without its `tool_result`. + +Activity bounds: the heartbeat is the liveness bound, so a dead worker is re-driven within seconds. +`startToCloseTimeout` is only a backstop for a drain that hangs while its process stays alive, and +it is long on purpose, because a bound that kills a live turn opens a two-writer window. That +window is fenced regardless: a drain claims the event log with an attempt token, and a live append +under a superseded token dies. + +`resume` awaits the forced run through an update-with-start and surfaces its failure as the exact +tagged `RunError`, encoded through a `Schema.Union` of every member in `run-error-codec.ts`. +`active` is the set of open per-session workflows, so it survives a restart of the serve process. +A user decline inside an activity is a non-retryable failure, so the workflow does not re-drive a +turn the user stopped. + +## Running workers separately + +By default the serve process hosts both the worker and the client. To scale workers apart from the +HTTP server, run standalone workers and point serve at the client role: + +```bash +# serve drives workflows, hosts no worker +OPENCODE_TEMPORAL_ROLE=client OPENCODE_SESSION_EXECUTION=temporal ... serve --port 4601 + +# one or more standalone activity workers (no HTTP surface) +OPENCODE_TEMPORAL_ROLE=worker OPENCODE_SESSION_EXECUTION=temporal \ + TEMPORAL_ADDRESS=127.0.0.1:7237 OPENCODE_DB_URL=... \ + bun run packages/server/src/worker.ts +``` + +`packages/server/src/worker.ts` builds the same application context serve uses, without the HTTP +API, so a worker resumes a session purely from the shared store. A compiled binary cannot carry the +worker's bundler, so a packaged serve runs as `client` next to standalone workers. + +## Durable asks + +A pending permission or question is a row in the shared store (`permission_request`, +`question_request`), not only a deferred in the asking process's memory. The blocked tool races its +local deferred against a poll of the row, so a reply from any process sharing the store unblocks +it, and the list endpoints read the rows, so serve can show asks raised inside a standalone worker. + +Tool-originated asks have deterministic ids (session, call and the ask's own fields), so a re-driven +activity adopts the same pending row instead of filing a duplicate, and an approval that landed while +the asker was dead short-circuits the retry. Graceful shutdown retires the process's pending asks as +`expired`, and a revived attempt flips them back. A row untouched for a day is treated as abandoned +and swept on read. + +Replies keep their meaning: `once` and `always` approve, an `always` rule retro-approves other +pending asks it covers, and `reject` declines and cascades to the session's other pending asks. + +## A shared store + +By default the event store is a local SQLite file, so a session can only resume on the host that +holds it. Point every process at one store and any worker resumes any session: + +- **Same host**: set `OPENCODE_DB` to one absolute path on all processes. WAL and `busy_timeout` + allow several processes. +- **Across hosts**: set `OPENCODE_DB_URL` to a libSQL URL (self-hosted `sqld` or Turso), with + `OPENCODE_DB_AUTH_TOKEN` if needed. `sqlite.libsql.ts` speaks the same SQLite dialect, so the + schema and the migrations are unchanged. + +Caveats of the shared backend: + +- Cold-start migrations serialize across processes inside one `BEGIN IMMEDIATE` transaction, so + concurrent starts wait and then no-op. Migrating once as a deploy step is still the better shape + for a fleet, and on a remote store it keeps cold starts from contending for the write lock. +- Foreign keys are enforced on the local backend and best-effort on the shared one. Deletes on the + shared store rely on the application-side cascades. +- The local-file PRAGMAs are skipped for the shared backend, which manages journaling itself. +- Multi-statement writes commit atomically on both backends. The libSQL client runs each statement + as its own request, so a transaction is routed through one interactive libSQL transaction rather + than emitted as `BEGIN` and `COMMIT` statements. +- Streaming text and reasoning deltas are live-only and write no row, so a streaming turn does not + pay a transaction per token. The durable cost of a step is its handful of boundary and tool + events, recorded as each settles. Coalescing them into one commit at step end would lose the + completed-tool records the resume path reuses, so per-event durability is kept. + +### Live events across processes + +A commit publishes its wake in-process, so a subscriber on the serve process cannot see events a +standalone worker appended: a live tail would show the prompt admitted and then silence. A durable +tail can also re-read on a tick, which catches what the wake cannot see while in-process commits +still wake it instantly. The tick is on only in the durable executor's composition root +(`EventV2.pollingNode`), because a deployment that runs in one process wakes its own subscribers +and would pay a query per second per subscribed session for events that cannot exist. Token deltas +are live-only and never cross a process boundary either way; what does is block-level: +`step.started`, `tool.called`, `tool.success`, `step.ended`. + +## What resumes cross-host, and what does not + +The runner rebuilds a session's model context purely from the store, so the conversation resumes +on any worker: messages, tool results, prompt attachments and credentials all ride the shared store. + +The project working tree rides it too. After each step capture the runner ships the snapshot tree +as an incremental git pack (`snapshot-sync.ts`, the `snapshot_pack` table), and a worker missing +the session's directory rebuilds it from those packs before a drain runs +(`session/execution/worktree.ts`), uncommitted edits and untracked files included, at the same +absolute path it was captured at. Ignored files and dependencies are not captured, so a rebuilt +tree may need an install step before `bash` behaves identically. + +Host-local state that does not ride the store: the snapshot object store and the retained full +tool-output files under the data directory. The runner never reads these to rebuild context; they +only affect the diff, restore and full-output features. Point the data directory at shared storage +to make them portable. diff --git a/packages/temporal/package.json b/packages/temporal/package.json new file mode 100644 index 000000000000..46846511dc53 --- /dev/null +++ b/packages/temporal/package.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/package", + "name": "@opencode-ai/temporal", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + "./*": "./src/*.ts" + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@opencode-ai/core": "workspace:*", + "@opencode-ai/llm": "workspace:*", + "@temporalio/activity": "^1.21.0", + "@temporalio/client": "^1.21.0", + "@temporalio/worker": "^1.21.0", + "@temporalio/workflow": "^1.21.0", + "effect": "catalog:" + }, + "devDependencies": { + "@temporalio/testing": "1.21.1" + } +} diff --git a/packages/temporal/src/activities.ts b/packages/temporal/src/activities.ts new file mode 100644 index 000000000000..68a37c8d8b03 --- /dev/null +++ b/packages/temporal/src/activities.ts @@ -0,0 +1,56 @@ +// The runTurnStep activity: one step of a turn (one provider attempt + its tools), run through the +// `stepDrain` closure the layer captured over the app's Effect context. It heartbeats so a worker +// crash is detected quickly, and forwards Temporal cancellation as an AbortSignal so an interrupt +// turns into Effect fiber interruption inside the runner. + +import { heartbeat, Context } from "@temporalio/activity" + +// The event-log owner token for one activity execution: run id + activity id + attempt. A Temporal +// retry of the SAME step gets a fresh attempt, so once the retry claims the log the previous attempt +// (if still running) is fenced out. The activity id is essential: activity attempt numbers restart +// at 1 for every step, so a run-id+attempt token alone would repeat across steps (step 1 attempt 1 +// and step 2 attempt 1 both mint `run#1`), letting a zombie attempt from an earlier step re-match +// the current owner and append stale events. Including the per-execution activity id keeps every +// step's tokens disjoint. +export function ownerTokenFrom(run: string, activityId: string, attempt: number): string { + return `${run}:${activityId}:${attempt}` +} + +function ownerToken(): string { + const info = Context.current().info + // runId/workflowType are typed optional on ActivityInfo; activityId is always present and already + // makes the token unique per execution, so an empty run prefix in the (degenerate) missing case is + // harmless. + const run = info.workflowExecution?.runId ?? info.workflowType ?? "" + return ownerTokenFrom(run, info.activityId, info.attempt) +} + +// The step contract lives with the drain in core; re-exported here so the workflow and its tests +// keep one import site inside this package. +import type { StepDrainInput, StepDrainResult } from "./drain" +export type { StepDrainInput, StepDrainResult } + +export type StepActivities = { + runTurnStep(input: StepDrainInput): Promise +} + +export function makeStepActivities( + stepDrain: (input: StepDrainInput, signal: AbortSignal) => Promise, +): StepActivities { + return { + async runTurnStep(input) { + const beat = setInterval(() => { + try { + heartbeat() + } catch { + // heartbeat outside an activity context is a no-op for our purposes + } + }, 3000) + try { + return await stepDrain({ ...input, owner: ownerToken() }, Context.current().cancellationSignal) + } finally { + clearInterval(beat) + } + }, + } +} diff --git a/packages/temporal/src/config.ts b/packages/temporal/src/config.ts new file mode 100644 index 000000000000..6d4c7c70de3e --- /dev/null +++ b/packages/temporal/src/config.ts @@ -0,0 +1,31 @@ +export * as TemporalConfig from "./config" + +// Connection and behavior settings for the Temporal executor. The executor reads them at layer +// build: an embedder or a test provides the service to override, and absent that the values come +// from env. Nothing reads env at module load, so import order carries no configuration. +import { Context } from "effect" +import { DEFAULTS } from "./protocol" + +// `both` (default) hosts the activity worker AND the workflow client in one process (the serve +// process). `client` drives workflows without hosting a worker (a packaged binary cannot carry the +// worker's bundler); `worker` runs a standalone activity worker with no HTTP surface. +export type Role = "both" | "client" | "worker" + +export interface Interface { + readonly address: string + readonly namespace: string + readonly taskQueue: string + readonly role: Role + /** Override for the supervisor's idle self-termination; local mode honors the same variable. */ + readonly idleTimeout?: string +} + +export class Service extends Context.Service()("@opencode/temporal/Config") {} + +export const fromEnv = (): Interface => ({ + address: process.env.TEMPORAL_ADDRESS ?? DEFAULTS.address, + namespace: process.env.TEMPORAL_NAMESPACE ?? DEFAULTS.namespace, + taskQueue: process.env.OPENCODE_TEMPORAL_TASK_QUEUE ?? DEFAULTS.taskQueue, + role: (process.env.OPENCODE_TEMPORAL_ROLE as Role | undefined) ?? "both", + idleTimeout: process.env.OPENCODE_SESSION_IDLE_TIMEOUT, +}) diff --git a/packages/temporal/src/drain.ts b/packages/temporal/src/drain.ts new file mode 100644 index 000000000000..fa692c74e54d --- /dev/null +++ b/packages/temporal/src/drain.ts @@ -0,0 +1,106 @@ +// The per-step drain body for the Temporal layer: it runs inside the runTurnStep activity. It wraps +// one SessionRunner.runStep, claims the event log for the attempt, ensures the worktree, and encodes +// the error for the activity boundary. Local mode does not use this: it runs whole turns through +// SessionRunner.run on the SessionRunCoordinator (execution/local.ts). Both modes go through the +// same SessionRunner and the same durable event log. + +import { Cause, Context, Effect, Exit, type LayerMap } from "effect" +import { ApplicationFailure } from "@temporalio/activity" +import type { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import type { Location } from "@opencode-ai/core/location" +import type { LocationError, LocationServices } from "@opencode-ai/core/location-services" +import { EventV2 } from "@opencode-ai/core/event" +import { WorktreeMaterializer } from "@opencode-ai/core/session/execution/worktree" +import { SessionRunner } from "@opencode-ai/core/session/runner" +import { SessionSchema } from "@opencode-ai/core/session/schema" +import { SessionStore } from "@opencode-ai/core/session/store" +import { SessionRunDeclinedError } from "@opencode-ai/core/session/error" +import type { SessionInput } from "@opencode-ai/core/session/input" +import { encodeRunError } from "./run-error-codec" +// One step of a turn, as any executor drives it. `promotion` is null (not undefined) so it +// serializes cleanly across an executor's process boundary. +export interface StepDrainInput { + sessionID: string + step: number + promotion: string | null + first: boolean + force: boolean + /** The attempt that owns the event log while this drain runs; set by the executor. */ + owner?: string +} + +export interface StepDrainResult { + continue: boolean + step: number + promotion: string | null +} + +export interface DrainDeps { + readonly store: SessionStore.Interface + readonly locations: LayerMap.LayerMap + /** The app context the drain runs in; providing it plus the per-location layer supplies + * SessionRunner and all of its dependencies. */ + readonly ctx: Context.Context + /** Used to claim the event log for the running attempt so a superseded one is fenced. */ + readonly events: EventV2.Interface + /** Rebuilds a missing project worktree from stored snapshot packs before the run. */ + readonly worktrees: WorktreeMaterializer.Interface +} + +export const makeDrains = ({ store, locations, ctx, events, worktrees }: DrainDeps) => { + // Run exactly one step of the turn (the supervisor loops it); returns the next loop state. + const stepDrain = async (input: StepDrainInput, signal: AbortSignal): Promise => { + const exit = await Effect.runPromiseExit( + Effect.gen(function* () { + const session = yield* store.get(SessionSchema.ID.make(input.sessionID)) + if (!session) return { continue: false, step: input.step, promotion: null } + // Take the event log before running so a superseded attempt's later appends are fenced. + if (input.owner) yield* events.claim(session.id, input.owner) + // A worker resuming on a host without the project tree rebuilds it from snapshot packs. + yield* worktrees.ensure(session.location.directory) + const r = yield* SessionRunner.Service.use((runner) => + runner.runStep({ + sessionID: session.id, + step: input.step, + promotion: (input.promotion ?? undefined) as SessionInput.Delivery | undefined, + first: input.first, + force: input.force, + }), + ).pipe(Effect.provide(locations.get(session.location))) + return { continue: r.continue, step: r.step, promotion: r.promotion ?? null } + }).pipe(Effect.provideService(EventV2.EventOwner, input.owner), Effect.provide(ctx), Effect.scoped), + { signal }, + ) + if (Exit.isSuccess(exit)) return exit.value + const cause = exit.cause + if (Cause.hasInterruptsOnly(cause)) { + // Two interrupt sources: driver cancellation (the AbortSignal fired; rethrow its reason so + // the attempt records Cancelled, not Failed) and an internal halt like a user declining a + // permission (the signal did NOT fire). The latter must be non-retryable, or the supervisor + // re-drives a turn the user explicitly stopped. + if (signal.aborted) throw signal.reason instanceof Error ? signal.reason : new Error("session run interrupted") + const declined = encodeRunError( + new SessionRunDeclinedError({ sessionID: SessionSchema.ID.make(input.sessionID) }), + ) + throw ApplicationFailure.create({ + message: "session run halted (user declined)", + type: "SessionRunDeclined", + nonRetryable: true, + details: declined === undefined ? undefined : [declined], + }) + } + // A genuine run error is thrown non-retryable so Temporal surfaces it (to resume) rather than + // retrying; only crashes / task timeouts (never thrown here) go through the retry policy. The + // error is encoded faithfully in `details` so the caller can reconstruct the exact RunError. + const squashed = Cause.squash(cause) as { _tag?: string; message?: string } + const encoded = encodeRunError(squashed) + throw ApplicationFailure.create({ + message: squashed?.message ?? Cause.pretty(cause), + type: squashed?._tag ?? "SessionRunError", + nonRetryable: true, + details: encoded === undefined ? undefined : [encoded], + }) + } + + return { stepDrain } +} diff --git a/packages/temporal/src/executor.ts b/packages/temporal/src/executor.ts new file mode 100644 index 000000000000..69acc86ad57d --- /dev/null +++ b/packages/temporal/src/executor.ts @@ -0,0 +1,226 @@ +export * as SessionExecutionTemporal from "./executor" + +import { fileURLToPath } from "node:url" +import { Effect, Layer, Option } from "effect" +import { Client, Connection, WithStartWorkflowOperation } from "@temporalio/client" +// Imported lazily inside the worker branch: the worker package drags webpack and swc (it bundles +// the workflow from source at startup), which a compiled binary can neither bundle nor run. A +// packaged serve runs OPENCODE_TEMPORAL_ROLE=client next to standalone workers instead. + +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import { EventV2 } from "@opencode-ai/core/event" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" +import { SessionSchema } from "@opencode-ai/core/session/schema" +import { SessionStore } from "@opencode-ai/core/session/store" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { makeStepActivities } from "./activities" +import { makeDrains } from "./drain" +import { WorktreeMaterializer } from "@opencode-ai/core/session/execution/worktree" +import { toRunError } from "./run-error-codec" +import * as WF from "./workflow" +import { TemporalConfig } from "./config" +import { WORKFLOW_TYPE, WORKFLOW_ID_PREFIX, workflowId } from "./protocol" + +// Classify an interrupt-signal delivery error. "already completed"/"not found" means an idle +// session's workflow has already closed -- nothing to interrupt, a no-op. Anything else is a genuine +// control-plane failure: the user's stop was not delivered, and it must NOT be reported as success. +export function classifyInterruptError(e: unknown): "ignore" | "fail" { + const message = String((e as { message?: unknown })?.message ?? e) + return /already completed|not found/i.test(message) ? "ignore" : "fail" +} + +/** + * A Temporal-backed SessionExecution. It makes each session a durable workflow: + * - wake -> signalWithStart(wake) (start while idle, or coalesce into the run) + * - resume -> executeUpdateWithStart(resume) (force one drain and await its result) + * - interrupt -> signal(interrupt) (cancels the current turn's drain scope; the + * workflow keeps serving later wakes/resumes) + * - active -> the running per-session workflows (visibility-backed) + * + * The drain runs one step (SessionRunner.runStep) inside a Temporal activity, looped by the + * workflow. Because turn state lives in the durable event log, a worker crash is recovered by + * re-running the activity: it re-reads recorded history and continues. (Local mode instead drives + * whole turns with SessionRunner.run on the SessionRunCoordinator; both share SessionRunner.) + */ +const layer = Layer.effect( + SessionExecution.Service, + Effect.gen(function* () { + const store = yield* SessionStore.Service + const locations = yield* LocationServiceMap.Service + // The app context the local drain runs in: providing it, then the per-location layer, supplies + // SessionRunner and all of its dependencies. + const ctx = yield* Effect.context() + // Provided by an embedder or a test, env otherwise; nothing is read at module load. + const config = Option.getOrElse(yield* Effect.serviceOption(TemporalConfig.Service), TemporalConfig.fromEnv) + const { address: ADDRESS, namespace: NAMESPACE, taskQueue: TASK_QUEUE } = config + const HOST_WORKER = config.role !== "client" + const HOST_CLIENT = config.role !== "worker" + // Same knob local mode honors; the workflow sandbox cannot read env, so the client forwards the + // override as a workflow argument. + const IDLE_TIMEOUT = config.idleTimeout + const events = yield* EventV2.Service + const worktrees = yield* WorktreeMaterializer.Service + + // The per-step drain (drain.ts) wraps SessionRunner.runStep for the activity boundary. Local + // mode runs whole turns through SessionRunner.run on the coordinator; both share SessionRunner. + const { stepDrain } = makeDrains({ store, locations, ctx, events, worktrees }) + + // Worker connection (native) hosts the runTurnStep activity + the workflow. Skipped in + // client-only role so serve can run without an embedded worker. + if (HOST_WORKER) { + const { NativeConnection, Worker } = yield* Effect.tryPromise(() => import("@temporalio/worker")).pipe( + Effect.catch(() => + Effect.die( + "The embedded Temporal worker is unavailable in this build. Run standalone workers " + + "(packages/server/src/worker.ts) and set OPENCODE_TEMPORAL_ROLE=client.", + ), + ), + ) + const nativeConn = yield* Effect.acquireRelease( + Effect.promise(() => NativeConnection.connect({ address: ADDRESS })), + (conn) => Effect.promise(() => conn.close().catch(() => {})), + ) + const worker = yield* Effect.promise(() => + Worker.create({ + connection: nativeConn, + namespace: NAMESPACE, + taskQueue: TASK_QUEUE, + workflowsPath: fileURLToPath(new URL("./workflow.ts", import.meta.url)), + activities: makeStepActivities(stepDrain), + }), + ) + const runHandle = worker.run() + runHandle.catch(() => {}) + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + worker.shutdown() + await runHandle.catch(() => {}) + }), + ) + } + + // Worker-only process: it hosts activities but drives no workflows, so the client methods are + // unused. Return a service whose driving methods fail loudly if something unexpectedly calls them. + if (!HOST_CLIENT) { + yield* Effect.logInfo("SessionExecutionTemporal worker ready").pipe( + Effect.annotateLogs({ address: ADDRESS, taskQueue: TASK_QUEUE, workflow: WORKFLOW_TYPE, role: config.role }), + ) + const clientOnly = Effect.die("SessionExecution client is not hosted when OPENCODE_TEMPORAL_ROLE=worker") + return SessionExecution.Service.of({ + active: Effect.succeed(new Set()), + wake: () => clientOnly, + resume: () => clientOnly, + interrupt: () => clientOnly, + }) + } + + // Client connection drives the per-session workflows. + const clientConn = yield* Effect.acquireRelease( + Effect.promise(() => Connection.connect({ address: ADDRESS })), + (conn) => Effect.promise(() => conn.close().catch(() => {})), + ) + const client = new Client({ connection: clientConn, namespace: NAMESPACE }) + + const drive = (id: SessionSchema.ID) => + Effect.promise(() => + client.workflow.signalWithStart(WORKFLOW_TYPE, { + taskQueue: TASK_QUEUE, + workflowId: workflowId(id), + args: [id, { startWithWake: true, idleTimeout: IDLE_TIMEOUT } satisfies WF.SessionTurnOptions], + signal: WF.wake, + signalArgs: [], + }), + ) + + yield* Effect.logInfo("SessionExecutionTemporal ready").pipe( + Effect.annotateLogs({ address: ADDRESS, taskQueue: TASK_QUEUE, workflow: WORKFLOW_TYPE }), + ) + + return SessionExecution.Service.of({ + // Durable and restart-surviving: the open per-session workflows in Temporal ARE the active + // set (unlike a process-local Set, which is empty after a restart). Both workflow types are + // queried so sessions survive a mode switch, and the result is intersected with this store's + // sessions because visibility is namespace-wide (other deployments sharing the namespace must + // not appear as ghosts). Visibility is eventually consistent: a just-woken session can lag + // here by about a second. + active: Effect.gen(function* () { + const found = yield* Effect.promise(async () => { + const ids: SessionSchema.ID[] = [] + for await (const wf of client.workflow.list({ + query: `WorkflowType = 'sessionTurn' AND ExecutionStatus = 'Running'`, + })) { + if (wf.workflowId.startsWith(WORKFLOW_ID_PREFIX)) { + ids.push(SessionSchema.ID.make(wf.workflowId.slice(WORKFLOW_ID_PREFIX.length))) + } + } + return ids + }) + // Point reads, but concurrent: they bypass the store permit, and the running-workflow set + // is small. A single IN query is the upgrade if this ever grows to hundreds. + const known = yield* Effect.forEach( + found, + (id) => Effect.map(store.get(id), (session) => (session ? id : undefined)), + { concurrency: 8 }, + ) + return new Set(known.filter((id): id is SessionSchema.ID => id !== undefined)) + }), + wake: (id) => drive(id).pipe(Effect.asVoid), + // resume = coordinator.run: drive a forced run via an Update-with-Start and AWAIT its result, + // so a run error is surfaced to the caller (as a RunError) instead of being swallowed. + resume: (id) => + Effect.tryPromise({ + try: async () => { + const attempt = () => { + const startOp = new WithStartWorkflowOperation(WORKFLOW_TYPE, { + taskQueue: TASK_QUEUE, + workflowId: workflowId(id), + // startWithWake=false: a fresh resume-with-start must not manufacture a wake drain; + // its forced drain comes from the resume update. Ignored when USE_EXISTING joins a + // running workflow (which keeps its own state). + args: [id, { startWithWake: false, idleTimeout: IDLE_TIMEOUT } satisfies WF.SessionTurnOptions], + workflowIdConflictPolicy: "USE_EXISTING", + }) + return client.workflow.executeUpdateWithStart(WF.resume, { + startWorkflowOperation: startOp, + args: [], + }) + } + try { + await attempt() + } catch (e) { + // The long-lived workflow self-completes after its idle timeout; an update admitted + // in that instant fails against the just-completed run instead of starting a fresh + // one. Retry once so the caller gets a real run, not the race. + const message = String((e as { message?: unknown })?.message ?? "") + if (!/already completed|not found/i.test(message)) throw e + await attempt() + } + }, + catch: (e) => toRunError(id, e), + }), + interrupt: (id) => + Effect.tryPromise({ + try: () => client.workflow.getHandle(workflowId(id)).signal(WF.interrupt), + catch: (e) => e, + }).pipe( + Effect.catch((e) => { + // An idle session's workflow has already completed; nothing to interrupt is fine. + if (classifyInterruptError(e) === "ignore") return Effect.void + // A genuine delivery failure must not read as success: the stop did not happen. The + // interface has no error channel, so surface it as a defect rather than a false success. + const message = String((e as { message?: unknown })?.message ?? e) + return Effect.logError("session interrupt signal failed").pipe( + Effect.annotateLogs({ sessionID: id, error: message }), + Effect.andThen(Effect.die(`session interrupt delivery failed for ${id}: ${message}`)), + ) + }), + ), + }) + }), +) + +export const node = makeGlobalNode({ + service: SessionExecution.Service, + layer, + deps: [SessionStore.node, LocationServiceMap.node, EventV2.node, WorktreeMaterializer.node], +}) diff --git a/packages/temporal/src/protocol.ts b/packages/temporal/src/protocol.ts new file mode 100644 index 000000000000..3f7807fef94f --- /dev/null +++ b/packages/temporal/src/protocol.ts @@ -0,0 +1,18 @@ +export * as TemporalProtocol from "./protocol" + +// One source of truth for the names the client, the worker, and the tests must agree on. Workflows +// start by the string type, never the function: a minified (packaged) client would otherwise +// register the mangled function name as the type and no worker would match it. +export const WORKFLOW_TYPE = "sessionTurn" + +export const SIGNALS = { wake: "wake", interrupt: "interrupt" } as const +export const RESUME_UPDATE = "resume" + +export const WORKFLOW_ID_PREFIX = "session-exec-" +export const workflowId = (sessionID: string) => `${WORKFLOW_ID_PREFIX}${sessionID}` + +export const DEFAULTS = { + address: "127.0.0.1:7237", + namespace: "default", + taskQueue: "opencode-session-exec", +} as const diff --git a/packages/temporal/src/run-error-codec.ts b/packages/temporal/src/run-error-codec.ts new file mode 100644 index 000000000000..c7534a25a8b6 --- /dev/null +++ b/packages/temporal/src/run-error-codec.ts @@ -0,0 +1,78 @@ +// Faithful round-trip of a SessionRunner.RunError across the Temporal boundary. Every member of the +// union is a Schema.TaggedErrorClass, so we can encode the error to JSON in the activity and decode +// it back into the exact tagged instance in the layer, instead of flattening it to a carrier. + +import { Schema } from "effect" +import { LLMError } from "@opencode-ai/llm" +import { Integration } from "@opencode-ai/core/integration" +import { SystemContext } from "@opencode-ai/core/system-context" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import type { SessionSchema } from "@opencode-ai/core/session/schema" +import { + ContextSnapshotDecodeError, + MessageDecodeError, + SessionRunDeclinedError, +} from "@opencode-ai/core/session/error" +import { + ModelNotSelectedError, + ModelUnavailableError, + UnsupportedApiError, + VariantUnavailableError, +} from "@opencode-ai/core/session/runner/model" +import type { SessionRunner } from "@opencode-ai/core/session/runner" + +const RunErrorSchema = Schema.Union([ + LLMError, + ModelNotSelectedError, + ModelUnavailableError, + VariantUnavailableError, + UnsupportedApiError, + Integration.AuthorizationError, + MessageDecodeError, + ContextSnapshotDecodeError, + SessionRunDeclinedError, + SystemContext.InitializationBlocked, + ToolOutputStore.StorageError, +]) + +const encode = Schema.encodeSync(RunErrorSchema) +const decode = Schema.decodeUnknownSync(RunErrorSchema) + +// Returns the JSON encoding of a RunError, or undefined if the value is not a known member (e.g. a +// defect); the caller then falls back to a plain message. +export function encodeRunError(error: unknown): unknown | undefined { + try { + return (encode as (e: unknown) => unknown)(error) + } catch { + return undefined + } +} + +// Reconstructs the exact tagged RunError from its JSON encoding, or undefined if it does not decode. +function decodeRunError(payload: unknown): SessionRunner.RunError | undefined { + try { + return decode(payload) as SessionRunner.RunError + } catch { + return undefined + } +} + +// Walk a failure chain (WorkflowUpdateFailedError -> ActivityFailure -> ApplicationFailure, or a +// bare ApplicationFailure from the in-process driver) to the encoded run error the drain attached, +// and reconstruct the exact tagged error. Falls back to a ContextSnapshotDecodeError carrying the +// text, since the RunError union has no generic member. +export function toRunError(sessionID: SessionSchema.ID, e: unknown): SessionRunner.RunError { + let node = e as { details?: unknown; cause?: unknown; message?: string } | undefined + for (let depth = 0; node && depth < 6; depth++) { + if (Array.isArray(node.details) && node.details.length > 0) { + const decoded = decodeRunError(node.details[0]) + if (decoded) return decoded + return new ContextSnapshotDecodeError({ sessionID, details: `session run failed: ${node.message}` }) + } + node = node.cause as typeof node + } + return new ContextSnapshotDecodeError({ + sessionID, + details: `session run failed: ${(e as { message?: string })?.message ?? String(e)}`, + }) +} diff --git a/packages/temporal/src/supervisor.ts b/packages/temporal/src/supervisor.ts new file mode 100644 index 000000000000..9f2a17bd72b8 --- /dev/null +++ b/packages/temporal/src/supervisor.ts @@ -0,0 +1,206 @@ +// The Temporal driver's per-session supervisor, expressed over a runtime interface so the SDK's +// condition/signals/updates/activities/cancellation plug in (workflow.ts) and it stays +// unit-testable off a live cluster (a fake runtime). Local mode does NOT run this loop: it uses the +// proven SessionRunCoordinator directly (execution/local.ts). The two modes share SessionRunner and +// the durable event log, not this supervisor. +// +// MUST stay pure: the Temporal driver bundles this into the workflow sandbox, so no `effect`, no +// `@opencode-ai/core` runtime imports, no Node builtins. Type-only imports are erased and safe. +// +// Semantics mirror SessionRunCoordinator (run-coordinator.ts): at most one drain runs at a time; a +// `wake` drives a drain and tolerates errors; `resume` JOINS the active drain (or forces one when +// idle) and surfaces its result; `interrupt` stops the CURRENT turn (not the session) and the +// long-lived supervisor keeps serving later wakes/resumes, terminating only after an idle period. + +import type { StepDrainInput, StepDrainResult } from "./activities" + +/** What a driver must provide; everything else is supervisor logic. */ +export interface SupervisorRuntime { + /** Wait until the predicate is true. With a timeout, resolve false when it expires first. */ + readonly condition: (predicate: () => boolean, timeout?: string) => Promise + readonly setSignalHandler: (name: "wake" | "interrupt", handler: () => void) => void + readonly setUpdateHandler: (name: "resume", handler: () => Promise) => void + /** One step of a turn (SessionRunner.runStep). The Temporal driver runs it as an activity. */ + readonly runTurnStep: (input: StepDrainInput) => Promise + /** + * Run one whole turn's drain inside a fresh cancellable scope. `cancelCurrentScope()` cancels the + * scope of the drain currently running, which aborts its in-flight step; the supervisor stays + * alive. Scoping the cancellation to the turn (not the workflow) is what lets an interrupt stop + * the current turn without killing the session. + */ + readonly runInDrainScope: (fn: () => Promise) => Promise + /** Cancel the drain scope currently running (interrupt the active turn). No-op when idle. */ + readonly cancelCurrentScope: () => void + /** Whether an error is the driver's cancellation (a normal stop, not a failure). */ + readonly isCancellation: (error: unknown) => boolean + /** Whether the WHOLE run (root scope) is cancelled -- a real workflow cancellation, as opposed to + * a per-turn interrupt (which cancels only the current drain's child scope). A root cancellation + * must stop the supervisor; it must never keep serving or continue-as-new. Reliable because the + * timed wait no longer cancels any scope (so it cannot contaminate the root). */ + readonly isRootCancelled: () => boolean + /** Whether every signal/update handler has fully finished (Temporal's own accounting). Gates + * completion and continue-as-new so an in-flight update's result is never abandoned. Optional: + * drivers without a handler protocol return true. */ + readonly allHandlersFinished?: () => boolean + /** Restart the run with fresh history, carrying whether work is still pending. History-keeping + * drivers only (Temporal). */ + readonly continueAsNew?: (sessionID: string, startWithWake: boolean) => Promise + /** Whether the driver says this run's history is large enough to roll over. A drain count cannot + * answer this: one drain is a whole turn, and a stepped turn of 200 steps is thousands of events, + * so a handful of drains can cross the server's limit long before the count does. Optional: + * drivers without a history return false. */ + readonly historyWantsRollover?: () => boolean + /** Where a turn says it stopped because it ran out of steps rather than because it finished. */ + readonly warn?: (message: string, attributes: Record) => void +} + +export interface WorkflowOptions { + /** How long to stay alive with no work before self-terminating. */ + readonly idleTimeout?: string + /** Drains per run before continue-as-new, when the driver supports it. */ + readonly maxDrainsPerRun?: number + /** Steps one turn may take before the supervisor stops driving it. */ + readonly maxStepsPerTurn?: number +} + +export const makeSupervisor = (rt: SupervisorRuntime, options?: WorkflowOptions) => { + const IDLE_TIMEOUT = options?.idleTimeout ?? "5 minutes" + // A continuously busy session never hits the idle return, so without a bound its history grows + // until Temporal terminates the workflow. continue-as-new carries the pending-wake state, so no + // queued work is lost across the boundary. + const MAX_DRAINS_PER_RUN = options?.maxDrainsPerRun ?? 30 + // A turn that never stops stepping is a bug in the loop above this one: a model asking for the + // same tool forever, or a step that keeps handing itself back because its host keeps dying. Only + // the supervisor can see it, because each step is its own activity and each one succeeds. High + // enough that real work never reaches it. + const MAX_STEPS_PER_TURN = options?.maxStepsPerTurn ?? 200 + + // Each step (one provider attempt + its tools) is its own activity; the step loop is supervisor + // control flow (step / promotion / first mirror SessionRunner.run's loop). `startWithWake` is the + // explicit start intent: a wake-with-start begins with pending work, a resume-with-start does not + // (its forced drain comes from the resume update), so resume must not manufacture a spurious wake. + async function sessionTurn(sessionID: string, startWithWake: boolean = true): Promise { + let pendingWake = startWithWake + let drains = 0 + let rolloverPending = false + let resumers = 0 // in-flight resume handlers awaiting a drain + // The single in-flight drain, or null when idle. New callers JOIN it rather than starting a + // second, mirroring SessionRunCoordinator.run: one execution per session at a time, and a resume + // attaches to the running one instead of queueing a redundant forced drain. + let inFlight: Promise | null = null + + const drive = (force: boolean): Promise => { + if (inFlight) return inFlight + const running = rt + .runInDrainScope(async () => { + drains++ + if (drains >= MAX_DRAINS_PER_RUN) rolloverPending = true + if (rt.historyWantsRollover?.()) rolloverPending = true + let step = 1 + let promotion: string | null = null + let first = true + for (let taken = 0; ; taken++) { + if (taken >= MAX_STEPS_PER_TURN) { + rt.warn?.("turn hit the step ceiling and was left where it stopped", { + sessionID, + steps: taken, + }) + break + } + const r: StepDrainResult = await rt.runTurnStep({ sessionID, step, promotion, first, force }) + // Inside the loop as well, because one drain is a whole turn: a long one outgrows the + // history without ever reaching the next drain's check. + if (rt.historyWantsRollover?.()) rolloverPending = true + if (!r.continue) break + // A queued prompt continues this same drain as a fresh turn, so a session fed without a + // gap never goes quiet and the rollover it is waiting for never happens. Stop at that + // boundary instead and let the new run pick the queue up: the work is not lost, it is + // one turn later. A steer is not a boundary, so it still rides this drain through. + if (rolloverPending && r.promotion === "queue") { + pendingWake = true + break + } + step = r.step + promotion = r.promotion + first = false + } + }) + .finally(() => { + inFlight = null + }) + inFlight = running + return running + } + + rt.setSignalHandler("wake", () => { + pendingWake = true + }) + // interrupt stops the CURRENT turn, not the session: cancel the active drain's child scope. The + // supervisor keeps serving, so a later wake/resume drives a fresh turn on this same long-lived + // workflow and a prompt that races the interrupt is never stranded. A per-turn interrupt leaves + // the root scope untouched, so isRootCancelled() distinguishes it from a real cancellation. + rt.setSignalHandler("interrupt", () => { + rt.cancelCurrentScope() + }) + // resume = coordinator.run: join the active drain (or force one when idle) and surface its + // result. A run error, or an interruption of the joined drain, rejects the update so the caller + // observes it. + rt.setUpdateHandler("resume", async () => { + resumers++ + try { + await drive(true) + } finally { + resumers-- + } + }) + + // "Idle" for completion/continue-as-new means: no drain in flight, no resume handler in our + // accounting, AND Temporal reports every handler finished (so an update's result is never + // abandoned by completing/continuing before the protocol records it). + const quiescent = () => !inFlight && resumers === 0 && (rt.allHandlersFinished?.() ?? true) + + try { + for (;;) { + // Wake on a real wake, or on an ACTIONABLE rollover (bound crossed and quiescent). Gating + // the rollover on quiescence keeps the loop from spinning while a resume drain is still in + // flight, and -- crucially -- keeps a rollover from being mis-handled as a wake below. + const woke = await rt.condition(() => pendingWake || (rolloverPending && quiescent()), IDLE_TIMEOUT) + // A real root cancellation must stop the supervisor -- never keep serving or continue-as-new. + // (Checked here too so the rollover short-circuit path can't continue-as-new a cancelled run.) + if (rt.isRootCancelled()) return + // continue-as-new: only from the main method, only when quiescent. Carry the pending wake so + // queued work survives the boundary (a pure rollover carries false -> no spurious drain). + if (rt.continueAsNew && rolloverPending && quiescent()) { + await rt.continueAsNew(sessionID, pendingWake) + } + if (pendingWake) { + pendingWake = false + // If a resume already started the drain we take, we only JOIN it -- and it may be past the + // point where it could pick up the work this wake signals. Re-arm pendingWake for one + // follow-up drain; a fresh drain we start ourselves already covers the wake. + const joined = inFlight !== null + try { + await drive(false) + } catch (e) { + // A real root cancellation wins: propagate it to end the workflow. A per-turn interrupt + // or a run error (already recorded in the log) is tolerated and the supervisor keeps + // serving. + if (rt.isRootCancelled()) throw e + } + if (joined) pendingWake = true + continue + } + // No real wake. Retire only on a genuine idle timeout with nothing in flight; a rollover + // wakeup was either handled above (continue-as-new) or is waiting for the drain to finish. + if (!woke && quiescent()) return + } + } catch (e) { + // A real root cancellation (workflow cancelled) or a defect reaches here. End the workflow: a + // cancellation completes the run cleanly, a defect fails it. + if (rt.isCancellation(e)) return + throw e + } + } + + return { sessionTurn } +} diff --git a/packages/temporal/src/workflow.ts b/packages/temporal/src/workflow.ts new file mode 100644 index 000000000000..f18ac6f93868 --- /dev/null +++ b/packages/temporal/src/workflow.ts @@ -0,0 +1,124 @@ +// The Temporal driver for the session supervisor. The supervisor loop lives in supervisor.ts; +// this file adapts the real SDK's primitives (condition, signal/update handlers, activity proxies, +// cancellation) to the SupervisorRuntime interface and exports the workflow function the worker +// registers. Local mode does not use this loop -- it runs the proven SessionRunCoordinator directly +// (execution/local.ts); the two modes share SessionRunner and the durable event log. +// +// MUST stay sandbox-safe: Temporal bundles this in an isolated context, so no `effect`, no +// `@opencode-ai/core` runtime imports, no Node builtins. + +import { + proxyActivities, + defineSignal, + defineUpdate, + setHandler, + condition, + sleep, + continueAsNew, + CancellationScope, + isCancellation, + allHandlersFinished, + log, + workflowInfo, +} from "@temporalio/workflow" +import type { StepActivities } from "./activities" +import { SIGNALS, RESUME_UPDATE } from "./protocol" +import { makeSupervisor, type SupervisorRuntime } from "./supervisor" + +const activityOptions = { + // The heartbeat is the liveness bound (it stops within seconds of a worker death and Temporal + // re-drives). startToClose is only the backstop for a drain that hangs while its process stays + // alive, so it must comfortably exceed any legitimate turn: long tool runs, many steps, or a + // human taking their time over a permission ask. A bound that kills a live turn also opens a + // two-writer window until the old attempt notices its heartbeat was rejected. + startToCloseTimeout: "12 hours", + heartbeatTimeout: "10 seconds", + retry: { maximumAttempts: 100 }, +} as const + +const { runTurnStep } = proxyActivities(activityOptions) + +export const wake = defineSignal(SIGNALS.wake) +export const interrupt = defineSignal(SIGNALS.interrupt) +export const resume = defineUpdate(RESUME_UPDATE) +const signals = { wake, interrupt } as const + +const runtime: SupervisorRuntime = { + // A timed wait that does not use the SDK's condition(fn, timeout). On @temporalio/workflow 1.21 + // that variant cancels its internal timer scope on resolve, and the cancellation leaks into the + // parent scope, so the next drain's child scope is born cancelled and the turn never runs. An + // already-true predicate is answered without a timer for the same reason. For a real wait, a + // no-timeout condition races a bare timer and the loser is abandoned: nothing here cancels a + // scope, and a pending timer is cleaned up when the workflow closes. + condition: async (predicate, timeout) => { + if (predicate()) return true + if (timeout === undefined) { + await condition(predicate) + return true + } + let timedOut = false + const timer = sleep(timeout as never).then(() => { + timedOut = true + }) + timer.catch(() => {}) + await Promise.race([condition(() => predicate() || timedOut), timer]) + return predicate() + }, + setSignalHandler: (name, handler) => setHandler(signals[name], handler), + setUpdateHandler: (_name, handler) => setHandler(resume, handler), + runTurnStep, + // Run the turn's drain inside its own cancellable scope, tracked as the active one. An interrupt + // cancels this scope (aborting the in-flight activity) without touching the workflow root, so the + // supervisor survives to serve later turns. + runInDrainScope: (fn) => + CancellationScope.cancellable(async () => { + activeDrainScope = CancellationScope.current() + try { + return await fn() + } finally { + activeDrainScope = undefined + } + }), + cancelCurrentScope: () => activeDrainScope?.cancel(), + isCancellation, + // A per-turn interrupt cancels only the child drain scope; a real workflow cancellation cancels + // the root. Reliable now that the timed wait no longer cancels any scope (nothing contaminates the + // root's consideredCancelled). + isRootCancelled: () => rootScope?.consideredCancelled ?? false, + allHandlersFinished, + continueAsNew: (sessionID, startWithWake) => continueAsNew(sessionID, { startWithWake }), + // The server's own read of whether this run has grown enough to roll over. The drain count alone + // misses it: a stepped turn is thousands of events, so a handful of drains can cross the limit. + historyWantsRollover: () => workflowInfo().continueAsNewSuggested, + warn: (message, attributes) => log.warn(message, attributes), +} + +// The scope of the drain currently running, so an interrupt signal can cancel exactly that turn. +let activeDrainScope: CancellationScope | undefined +// The workflow's root scope, captured at entry, to detect a whole-run cancellation. +let rootScope: CancellationScope | undefined + +const workflows = makeSupervisor(runtime) + +// One evolvable input record instead of positional arguments: new settings ride along without a +// signature change, and a continue-as-new run carries the record forward. The sandbox cannot read +// env, so the idle override arrives here from the client (which reads the same variable local mode +// honors). +export interface SessionTurnOptions { + readonly startWithWake?: boolean + readonly idleTimeout?: string +} + +export async function sessionTurn(sessionID: string, options?: SessionTurnOptions): Promise { + rootScope = CancellationScope.current() + const startWithWake = options?.startWithWake ?? true + const idleTimeout = options?.idleTimeout + if (!idleTimeout) return workflows.sessionTurn(sessionID, startWithWake) + return makeSupervisor( + { + ...runtime, + continueAsNew: (id, wake) => continueAsNew(id, { startWithWake: wake, idleTimeout }), + }, + { idleTimeout }, + ).sessionTurn(sessionID, startWithWake) +} diff --git a/packages/temporal/test/lib/workflow-harness.ts b/packages/temporal/test/lib/workflow-harness.ts new file mode 100644 index 000000000000..da61ce48588b --- /dev/null +++ b/packages/temporal/test/lib/workflow-harness.ts @@ -0,0 +1,57 @@ +// The real sessionTurn workflow (workflow.ts and supervisor.ts) against @temporalio/testing's +// in-process server, with the step activity mocked: no dev server, no provider. Each test file +// gets one environment, because two native Temporal servers in one bun process crash the runtime. +import { fileURLToPath } from "node:url" +import type { WorkflowHandle } from "@temporalio/client" +import { TestWorkflowEnvironment } from "@temporalio/testing" +import { Worker } from "@temporalio/worker" +import type { StepDrainResult } from "../../src/drain" + +const WORKFLOW = fileURLToPath(new URL("../../src/workflow.ts", import.meta.url)) + +export interface HarnessOptions { + /** Names the task queue, the workflow and the session, so two files never share a queue. */ + readonly name: string + /** Time-skipping fast-forwards the idle timer; a real clock is needed when an activity blocks. */ + readonly timeSkipping?: boolean + readonly runTurnStep: () => Promise +} + +/** Start the workflow with one wake and run `body` against its handle while a worker serves it. */ +export async function withSessionWorkflow( + options: HarnessOptions, + body: (handle: WorkflowHandle) => Promise, +): Promise { + const env = options.timeSkipping + ? await TestWorkflowEnvironment.createTimeSkipping() + : await TestWorkflowEnvironment.createLocal() + try { + const worker = await Worker.create({ + connection: env.nativeConnection, + namespace: env.namespace, + taskQueue: options.name, + workflowsPath: WORKFLOW, + activities: { runTurnStep: options.runTurnStep }, + }) + await worker.runUntil(async () => { + const handle = await env.client.workflow.signalWithStart("sessionTurn", { + taskQueue: options.name, + workflowId: `wf-${options.name}`, + args: [`ses_${options.name}`], + signal: "wake", + signalArgs: [], + }) + await body(handle) + }) + } finally { + await env.teardown() + } +} + +export async function poll(fn: () => boolean, ms = 20_000): Promise { + const deadline = Date.now() + ms + while (!fn()) { + if (Date.now() > deadline) throw new Error("condition not reached") + await new Promise((r) => setTimeout(r, 50)) + } +} diff --git a/packages/temporal/test/session-execution-temporal-contract.test.ts b/packages/temporal/test/session-execution-temporal-contract.test.ts new file mode 100644 index 000000000000..f87d5fa50236 --- /dev/null +++ b/packages/temporal/test/session-execution-temporal-contract.test.ts @@ -0,0 +1,21 @@ +// The driver-contract suite run against the Temporal driver: the same scenarios the local +// coordinator passes, driven through real workflows on a Temporal server. It is what makes the +// parity between the two executors a test rather than a claim. +// +// It needs a dev server and generous timeouts (worker startup bundles the workflow), so it is +// opt-in: +// +// temporal server start-dev --port 7237 --headless & +// OPENCODE_CONTRACT_TEMPORAL=1 bun test --timeout 120000 test/session-execution-temporal-contract.test.ts +// +// Without the opt-in the file registers nothing, so a plain `bun test` stays server-free. +import { makeExecutionFor, runContract } from "../../core/test/lib/execution-conformance" + +if (process.env.OPENCODE_CONTRACT_TEMPORAL === "1") { + // One task queue per run: a stale worker from an earlier run against the same dev server would + // otherwise steal activities and answer with its own (differently mocked) graph. + process.env.OPENCODE_TEMPORAL_TASK_QUEUE ??= `contract-${crypto.randomUUID()}` + // Imported inside the opt-in so a plain `bun test` never loads the Temporal SDK. + const { SessionExecutionTemporal } = await import("@opencode-ai/temporal/executor") + runContract("temporal driver", makeExecutionFor(SessionExecutionTemporal.node)) +} diff --git a/packages/temporal/test/session-supervisor-ceiling.test.ts b/packages/temporal/test/session-supervisor-ceiling.test.ts new file mode 100644 index 000000000000..6a31cb260ca7 --- /dev/null +++ b/packages/temporal/test/session-supervisor-ceiling.test.ts @@ -0,0 +1,60 @@ +// A turn that never stops stepping is a bug in the loop above this one: a model asking for the same +// tool forever, or a step that keeps handing itself back because its host keeps dying. Every one of +// those steps is a model call somebody pays for, and each of them succeeds, so nothing below the +// supervisor can see it. Driven by a fake runtime whose steps always ask for another. +import { it, expect } from "bun:test" +import { makeSupervisor, type SupervisorRuntime } from "../src/supervisor" +import type { StepDrainResult } from "../src/activities" + +const MORE: StepDrainResult = { continue: true, step: 1, promotion: null } +const DONE: StepDrainResult = { continue: false, step: 1, promotion: null } + +class LoopingRuntime implements SupervisorRuntime { + steps = 0 + warnings: string[] = [] + // A wake for the first turn and an idle timeout after it, which is how the supervisor gets to run + // one turn and then return rather than waiting for a signal this test never sends. + private woken = false + condition = async (predicate: () => boolean) => { + if (!this.woken) { + this.woken = true + this.wake?.() + return predicate() + } + return false + } + private wake: (() => void) | undefined + setSignalHandler = (name: string, handler: () => void) => { + if (name === "wake") this.wake = handler + } + setUpdateHandler = () => {} + // Always another step, up to a bound of its own: without one a ceiling that failed to hold would + // hang this test rather than fail it. + finishAfter = 50 + runTurnStep = async () => (++this.steps < this.finishAfter ? MORE : DONE) + runInDrainScope = (fn: () => Promise) => fn() + cancelCurrentScope = () => {} + isCancellation = () => false + isRootCancelled = () => false + warn = (message: string) => { + this.warnings.push(message) + } +} + +it("stops driving a turn that keeps asking for another step", async () => { + const rt = new LoopingRuntime() + const supervisor = makeSupervisor(rt, { maxStepsPerTurn: 5, idleTimeout: "1 millisecond" }) + await supervisor.sessionTurn("ses_ceiling") + + expect(rt.steps).toBe(5) + expect(rt.warnings).toEqual(["turn hit the step ceiling and was left where it stopped"]) +}) + +it("leaves a turn that finishes on its own alone", async () => { + const rt = new LoopingRuntime() + rt.finishAfter = 3 + await makeSupervisor(rt, { idleTimeout: "1 millisecond" }).sessionTurn("ses_finishes") + + expect(rt.steps).toBe(3) + expect(rt.warnings).toEqual([]) +}) diff --git a/packages/temporal/test/session-supervisor-rollover.test.ts b/packages/temporal/test/session-supervisor-rollover.test.ts new file mode 100644 index 000000000000..ca674189050d --- /dev/null +++ b/packages/temporal/test/session-supervisor-rollover.test.ts @@ -0,0 +1,76 @@ +// #4 regression: continue-as-new must count EVERY drain, including resume-driven ones (counting only +// wake-loop drains let a resume-heavy workflow grow history without rolling over). Plus the fix for +// the rollover-manufactures-a-wake bug: a rollover triggered by a resume drain must carry +// startWithWake=false into the successor, not re-arm a spurious wake. Driven by a fake runtime. +import { describe, it, expect } from "bun:test" +import { makeSupervisor, type SupervisorRuntime } from "../src/supervisor" +import type { StepDrainResult } from "../src/activities" + +class ContinuedAsNew extends Error {} +const DONE: StepDrainResult = { continue: false, step: 1, promotion: null } +const settle = () => new Promise((r) => setTimeout(r, 0)) + +class FakeRuntime implements SupervisorRuntime { + steps = 0 + continued = 0 + continuedWith: boolean | undefined + private waiters: { predicate: () => boolean; resolve: (b: boolean) => void }[] = [] + private updates = new Map Promise>() + + condition = (predicate: () => boolean, _timeout?: string) => + new Promise((resolve) => { + this.waiters.push({ predicate, resolve }) + this.flush() + }) + setSignalHandler = () => {} + setUpdateHandler = (_name: "resume", handler: () => Promise) => this.updates.set("resume", handler) + runTurnStep = async () => { + this.steps++ + return DONE + } + runInDrainScope = (fn: () => Promise) => fn() + cancelCurrentScope = () => {} + isCancellation = () => false // no interrupts in this test; continueAsNew propagates out + isRootCancelled = () => false + continueAsNew = async (_sessionID: string, startWithWake: boolean): Promise => { + this.continued++ + this.continuedWith = startWithWake + throw new ContinuedAsNew() + } + + private flush() { + for (const w of [...this.waiters]) { + if (!w.predicate()) continue + this.waiters = this.waiters.filter((x) => x !== w) + w.resolve(true) + } + } + async resume(): Promise { + const p = this.updates.get("resume")!() + this.flush() + await p.catch(() => {}) + this.flush() + } +} + +describe("supervisor continue-as-new counting", () => { + it("counts a resume-driven drain toward the bound and rolls over carrying no spurious wake", async () => { + const rt = new FakeRuntime() + // Bound of 2: the initial wake drain is #1; a single resume drain is #2 and must roll over. + const supervisor = makeSupervisor(rt, { maxDrainsPerRun: 2 }).sessionTurn("ses_rollover") + let ended = false + supervisor.then( + () => (ended = true), + () => (ended = true), + ) + await settle() // initial wake drain (#1) + expect(rt.steps).toBe(1) + expect(rt.continued).toBe(0) + await rt.resume() // resume drain (#2) crosses the bound + await settle() + expect(rt.steps).toBe(2) + expect(rt.continued).toBe(1) // continue-as-new fired because the resume drain was counted + expect(rt.continuedWith).toBe(false) // no wake was pending, so none is carried (no spurious drain) + expect(ended).toBe(true) + }) +}) diff --git a/packages/temporal/test/session-supervisor.test.ts b/packages/temporal/test/session-supervisor.test.ts new file mode 100644 index 000000000000..e820c542c2fd --- /dev/null +++ b/packages/temporal/test/session-supervisor.test.ts @@ -0,0 +1,169 @@ +// Deterministic unit tests for the Temporal supervisor (execution/supervisor.ts) driven by a fake +// SupervisorRuntime -- no Temporal, no DB. Covers the coordination redesign: resume joins the single +// in-flight drain (never a second forced drain), a wake that only joins a resume drain still gets a +// follow-up, an interrupt stops the current turn but the supervisor keeps serving, and a fresh +// resume-with-start (startWithWake=false) does exactly one drain (no spurious wake drain). +import { describe, it, expect } from "bun:test" +import { makeSupervisor, type SupervisorRuntime } from "../src/supervisor" +import type { StepDrainResult } from "../src/activities" + +class FakeCancel extends Error {} +const DONE: StepDrainResult = { continue: false, step: 1, promotion: null } +const settle = () => new Promise((r) => setTimeout(r, 0)) + +// runTurnStep either resolves immediately (ungated) or parks until released (gated), so a turn can +// be held in flight while resumes/wakes/interrupts are delivered. cancelCurrentScope rejects the +// in-flight step with FakeCancel, modelling an interrupt aborting the active drain's scope. +class FakeRuntime implements SupervisorRuntime { + steps = 0 + gated = false + private waiters: { predicate: () => boolean; resolve: (b: boolean) => void; isTimeout: boolean }[] = [] + private signals = new Map void>() + private updates = new Map Promise>() + private pending: { resolve: (r: StepDrainResult) => void; reject: (e: unknown) => void }[] = [] + + condition = (predicate: () => boolean, timeout?: string) => + new Promise((resolve) => { + this.waiters.push({ predicate, resolve, isTimeout: timeout !== undefined }) + this.flush() + }) + setSignalHandler = (name: "wake" | "interrupt", handler: () => void) => { + this.signals.set(name, handler) + } + setUpdateHandler = (_name: "resume", handler: () => Promise) => this.updates.set("resume", handler) + runTurnStep = () => { + this.steps++ + if (!this.gated) return Promise.resolve(DONE) + return new Promise((resolve, reject) => this.pending.push({ resolve, reject })) + } + runInDrainScope = (fn: () => Promise) => fn() + cancelCurrentScope = () => { + for (const p of this.pending.splice(0)) p.reject(new FakeCancel()) + } + isCancellation = (e: unknown) => e instanceof FakeCancel + rootCancelled = false + isRootCancelled = () => this.rootCancelled + + private flush() { + for (const w of [...this.waiters]) { + if (!w.predicate()) continue + this.waiters = this.waiters.filter((x) => x !== w) + w.resolve(true) + } + } + deliver(name: "wake" | "interrupt") { + this.signals.get(name)?.() + this.flush() + } + resume(): Promise { + const p = this.updates.get("resume")!() + this.flush() + return p + } + fireIdle() { + const expiring = [...this.waiters] + this.waiters = [] + for (const w of expiring) w.resolve(false) + } + releaseStep(result: StepDrainResult = DONE) { + this.pending.shift()?.resolve(result) + } + get pendingSteps() { + return this.pending.length + } +} + +const start = (rt: FakeRuntime, startWithWake = true) => { + let ended = false + makeSupervisor(rt) + .sessionTurn("ses_test", startWithWake) + .then( + () => (ended = true), + () => (ended = true), + ) + return { isDone: () => ended } +} + +describe("Temporal supervisor", () => { + it("concurrent resumes join a single drain (no duplicate forced turns)", async () => { + const rt = new FakeRuntime() + rt.gated = true + start(rt, false) // fresh resume-with-start: no initial wake drain + const r1 = rt.resume() + const r2 = rt.resume() + await settle() + expect(rt.steps).toBe(1) // joined + rt.releaseStep(DONE) + await Promise.all([r1, r2]) + expect(rt.steps).toBe(1) + }) + + it("a fresh resume-with-start does exactly one drain (no spurious wake drain)", async () => { + const rt = new FakeRuntime() + rt.gated = true + start(rt, false) + const resumed = rt.resume() + await settle() + expect(rt.steps).toBe(1) + rt.releaseStep(DONE) + await resumed + await settle() + expect(rt.steps).toBe(1) // no extra drain manufactured by an initial pendingWake + }) + + it("interrupt stops the current turn but the supervisor keeps serving", async () => { + const rt = new FakeRuntime() + rt.gated = true + const sup = start(rt, true) // initial wake drain + await settle() + expect(rt.steps).toBe(1) + rt.deliver("interrupt") // cancels the in-flight drain + await settle() + expect(sup.isDone()).toBe(false) // supervisor did NOT exit + rt.deliver("wake") // a new prompt after the interrupt + await settle() + expect(rt.steps).toBe(2) // drives a fresh turn on the same supervisor + rt.releaseStep(DONE) + await settle() + rt.fireIdle() + await settle() + expect(sup.isDone()).toBe(true) // ends only when idle + }) + + it("a root cancellation (not a per-turn interrupt) stops the supervisor", async () => { + const rt = new FakeRuntime() + rt.gated = true + const sup = start(rt, true) + await settle() + expect(rt.steps).toBe(1) + // A real workflow (root) cancellation, not a per-turn interrupt: the root scope is cancelled and + // it propagates into the drain. The supervisor must end -- not keep serving (the per-turn- + // interrupt behavior tested above). + rt.rootCancelled = true + rt.cancelCurrentScope() + await settle() + expect(sup.isDone()).toBe(true) + rt.deliver("wake") // a wake after root cancellation must NOT drive a new turn + await settle() + expect(rt.steps).toBe(1) + }) + + it("a wake that only joins a resume drain still gets its own follow-up", async () => { + const rt = new FakeRuntime() + rt.gated = true + start(rt, false) + const resumed = rt.resume() + await settle() + expect(rt.steps).toBe(1) + rt.deliver("wake") // arrives while the resume drain is in flight -> can only join + await settle() + expect(rt.steps).toBe(1) + rt.releaseStep(DONE) // resume drain ends -> the joined wake triggers a follow-up + await resumed + await settle() + expect(rt.steps).toBe(2) + rt.releaseStep(DONE) + await settle() + expect(rt.steps).toBe(2) + }) +}) diff --git a/packages/temporal/test/temporal-harness-interrupt.test.ts b/packages/temporal/test/temporal-harness-interrupt.test.ts new file mode 100644 index 000000000000..a21cdc9783c8 --- /dev/null +++ b/packages/temporal/test/temporal-harness-interrupt.test.ts @@ -0,0 +1,52 @@ +// An interrupt cancels the current turn and the supervisor keeps serving. A real clock, because a +// blocking, heartbeating activity under time-skipping would jump past its heartbeat timeout and be +// retried for the wrong reason. +import { describe, it, expect } from "bun:test" +import { Context, heartbeat, CancelledFailure } from "@temporalio/activity" +import { poll, withSessionWorkflow } from "./lib/workflow-harness" + +describe("temporal workflow harness: interrupt", () => { + it("interrupt cancels the current turn but the workflow keeps serving the next", async () => { + let steps = 0 + let firstStarted = false + await withSessionWorkflow( + { + name: "harness-interrupt", + runTurnStep: async () => { + steps++ + if (steps === 1) { + // Turn 1 blocks until the interrupt cancels it, heartbeating so it is not failed for + // liveness while it waits. + firstStarted = true + const beat = setInterval(() => { + try { + heartbeat() + } catch { + // no-op outside an activity context + } + }, 200) + try { + await new Promise((_resolve, reject) => { + Context.current().cancellationSignal.addEventListener("abort", () => + reject(new CancelledFailure("interrupted")), + ) + }) + } finally { + clearInterval(beat) + } + } + return { continue: false, step: 1, promotion: null } + }, + }, + async (handle) => { + await poll(() => firstStarted) + await handle.signal("interrupt") + // A workflow the interrupt had ended would drop this wake, and the second turn would + // never run. + await handle.signal("wake") + await poll(() => steps === 2) + }, + ) + expect(steps).toBe(2) + }, 120_000) +}) diff --git a/packages/temporal/test/temporal-harness-multiturn.test.ts b/packages/temporal/test/temporal-harness-multiturn.test.ts new file mode 100644 index 000000000000..d41523c3e14e --- /dev/null +++ b/packages/temporal/test/temporal-harness-multiturn.test.ts @@ -0,0 +1,35 @@ +// A session serves more than one turn: turn 1 completes, the supervisor parks in its idle wait, +// and a later wake drives turn 2 on the same workflow. A real clock, so the idle timer does not +// retire the workflow before the second wake arrives. +import { describe, it, expect } from "bun:test" +import { poll, withSessionWorkflow } from "./lib/workflow-harness" + +describe("temporal workflow harness: multi-turn", () => { + it("serves a second turn after parking in the idle wait", async () => { + let steps = 0 + await withSessionWorkflow( + { + name: "harness-multiturn", + runTurnStep: async () => { + steps++ + return { continue: false, step: 1, promotion: null } + }, + }, + async (handle) => { + await poll(() => steps === 1) + // Wake only once the supervisor has parked, which is when its sleep timer is in history. + // A wake that lands during turn 1 would test a different path. + const deadline = Date.now() + 20_000 + for (;;) { + const history = await handle.fetchHistory() + if ((history.events ?? []).some((e) => e.timerStartedEventAttributes)) break + if (Date.now() > deadline) throw new Error("supervisor never parked in the idle timed wait") + await new Promise((r) => setTimeout(r, 100)) + } + await handle.signal("wake") + await poll(() => steps === 2) + }, + ) + expect(steps).toBe(2) + }, 120_000) +}) diff --git a/packages/temporal/test/temporal-harness-smoke.test.ts b/packages/temporal/test/temporal-harness-smoke.test.ts new file mode 100644 index 000000000000..ec3413fa7afa --- /dev/null +++ b/packages/temporal/test/temporal-harness-smoke.test.ts @@ -0,0 +1,23 @@ +// One wake drives one drain, and the supervisor then idles out. Under time-skipping the idle +// timer fast-forwards, so the workflow completes on its own. A drain count of zero here means the +// workflow's timed wait cancelled the scope the drain would have run in. +import { describe, it, expect } from "bun:test" +import { withSessionWorkflow } from "./lib/workflow-harness" + +describe("temporal workflow harness", () => { + it("drives the real sessionTurn workflow through a wake-driven drain, then idles out", async () => { + let steps = 0 + await withSessionWorkflow( + { + name: "harness-smoke", + timeSkipping: true, + runTurnStep: async () => { + steps++ + return { continue: false, step: 1, promotion: null } + }, + }, + (handle) => handle.result(), + ) + expect(steps).toBe(1) + }, 120_000) +}) diff --git a/packages/temporal/test/temporal-interrupt-classify.test.ts b/packages/temporal/test/temporal-interrupt-classify.test.ts new file mode 100644 index 000000000000..281935ae32d3 --- /dev/null +++ b/packages/temporal/test/temporal-interrupt-classify.test.ts @@ -0,0 +1,19 @@ +// The interrupt path must not report a genuine signal-delivery failure as success. Only an already +// closed workflow ("already completed"/"not found") is a benign no-op; everything else must fail. +import { describe, it, expect } from "bun:test" +import { SessionExecutionTemporal } from "../src/executor" + +const classify = SessionExecutionTemporal.classifyInterruptError + +describe("temporal interrupt error classification", () => { + it("ignores an already-closed workflow", () => { + expect(classify(new Error("workflow execution already completed"))).toBe("ignore") + expect(classify(new Error("workflow not found"))).toBe("ignore") + }) + + it("fails a genuine delivery error", () => { + expect(classify(new Error("14 UNAVAILABLE: tcp connect error"))).toBe("fail") + expect(classify(new Error("DEADLINE_EXCEEDED"))).toBe("fail") + expect(classify("some non-error value")).toBe("fail") + }) +}) diff --git a/packages/temporal/test/temporal-owner-token.test.ts b/packages/temporal/test/temporal-owner-token.test.ts new file mode 100644 index 000000000000..a2b6e1b8a570 --- /dev/null +++ b/packages/temporal/test/temporal-owner-token.test.ts @@ -0,0 +1,21 @@ +// Activity attempt numbers restart at 1 on every step, so a token built from the run id and the +// attempt alone repeats across steps. The token has to be unique per activity execution, or a +// zombie attempt from an earlier step matches the current owner. +import { describe, it, expect } from "bun:test" +import { ownerTokenFrom } from "../src/activities" + +describe("temporal event-log owner token", () => { + const run = "run-1" + + it("distinguishes a retry of the same step from its prior attempt (retry fences prior)", () => { + expect(ownerTokenFrom(run, "act-1", 1)).not.toBe(ownerTokenFrom(run, "act-1", 2)) + }) + + it("distinguishes different steps that share an attempt number (no cross-step collision)", () => { + expect(ownerTokenFrom(run, "act-1", 1)).not.toBe(ownerTokenFrom(run, "act-2", 1)) + }) + + it("is stable for a given execution", () => { + expect(ownerTokenFrom(run, "act-7", 3)).toBe(ownerTokenFrom(run, "act-7", 3)) + }) +}) diff --git a/packages/temporal/tsconfig.json b/packages/temporal/tsconfig.json new file mode 100644 index 000000000000..00ef12546856 --- /dev/null +++ b/packages/temporal/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 71e050d11e68..1b1c0c7a22ea 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -224,8 +224,11 @@ export const { break } + // The v2 engine publishes the versioned names; the unversioned ones are the v1 vocabulary. case "question.replied": - case "question.rejected": { + case "question.rejected": + case "question.v2.replied": + case "question.v2.rejected": { const requests = store.question[event.properties.sessionID] if (!requests) break const match = search(requests, event.properties.requestID, (r) => r.id) @@ -240,7 +243,8 @@ export const { break } - case "question.asked": { + case "question.asked": + case "question.v2.asked": { const request = event.properties const requests = store.question[request.sessionID] if (!requests) { diff --git a/packages/tui/src/routes/session/question.tsx b/packages/tui/src/routes/session/question.tsx index 5ab467a351d3..d0919146b834 100644 --- a/packages/tui/src/routes/session/question.tsx +++ b/packages/tui/src/routes/session/question.tsx @@ -47,17 +47,18 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st function submit() { const answers = questions().map((_, i) => store.answers[i] ?? []) - void sdk.client.question.reply({ + // The v2 daemon only serves the session-scoped question routes. + void sdk.client.v2.session.question.reply({ + sessionID: props.request.sessionID, requestID: props.request.id, - directory: props.directory, - answers, + questionV2Reply: { answers }, }) } function reject() { - void sdk.client.question.reject({ + void sdk.client.v2.session.question.reject({ + sessionID: props.request.sessionID, requestID: props.request.id, - directory: props.directory, }) } @@ -71,10 +72,10 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st setStore("custom", inputs) } if (single()) { - void sdk.client.question.reply({ + void sdk.client.v2.session.question.reply({ + sessionID: props.request.sessionID, requestID: props.request.id, - directory: props.directory, - answers: [[answer]], + questionV2Reply: { answers: [[answer]] }, }) return }