diff --git a/bun.lock b/bun.lock index d2a4a7745d70..c22cc13d0e24 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,6 @@ { "lockfileVersion": 1, - "configVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "opencode", @@ -317,6 +317,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", @@ -785,6 +786,7 @@ "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", + "@opencode-ai/temporal": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:", }, @@ -942,6 +944,21 @@ "vite": "catalog:", }, }, + "packages/temporal": { + "name": "@opencode-ai/temporal", + "version": "0.0.1", + "dependencies": { + "@opencode-ai/core": "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.18", @@ -1663,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.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="], "@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=="], @@ -1749,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=="], @@ -1803,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=="], @@ -1857,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=="], @@ -2007,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"], @@ -2707,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=="], @@ -2739,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=="], @@ -2787,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=="], @@ -3035,12 +3164,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=="], @@ -3071,7 +3234,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=="], @@ -3319,6 +3482,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=="], @@ -3667,8 +3832,14 @@ "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=="], + "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=="], @@ -3807,6 +3978,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=="], @@ -3859,6 +4032,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=="], @@ -3947,6 +4122,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=="], @@ -3989,6 +4166,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=="], @@ -4147,6 +4326,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=="], @@ -4229,6 +4410,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=="], @@ -4261,6 +4444,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=="], @@ -4365,6 +4550,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=="], @@ -4469,6 +4656,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=="], @@ -4521,8 +4710,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=="], @@ -4809,6 +5002,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=="], @@ -4821,7 +5016,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=="], @@ -5007,6 +5204,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=="], @@ -5029,6 +5228,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=="], @@ -5137,10 +5338,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=="], @@ -5245,12 +5448,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=="], @@ -5279,6 +5484,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=="], @@ -5323,6 +5530,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=="], @@ -5347,6 +5556,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=="], @@ -5403,6 +5614,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=="], @@ -5523,6 +5736,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=="], @@ -5533,6 +5748,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=="], @@ -5737,8 +5954,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=="], @@ -5899,6 +6114,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=="], @@ -5927,6 +6144,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=="], @@ -5935,6 +6154,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=="], @@ -5951,8 +6178,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=="], @@ -6029,7 +6254,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.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], @@ -6047,7 +6272,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=="], @@ -6055,6 +6280,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=="], @@ -6149,6 +6376,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=="], @@ -6191,8 +6422,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=="], @@ -6251,6 +6480,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=="], @@ -6315,7 +6546,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=="], @@ -6357,6 +6588,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=="], @@ -6367,6 +6600,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=="], @@ -6465,12 +6700,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=="], @@ -6491,6 +6730,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=="], @@ -6519,6 +6762,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=="], @@ -6561,6 +6806,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=="], @@ -6717,8 +6970,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=="], @@ -6741,8 +6998,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=="], @@ -7033,8 +7304,6 @@ "ai-gateway-provider/@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7u5B/E2uZmU65SlJhhQGFHZwRCN0xOz4HHtFc4sEGV9PHbX3fGiEiZBpc/SABay1dGeJgK3VD60rvLGoWdWPXA=="], - "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=="], @@ -7087,6 +7356,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=="], @@ -7135,14 +7406,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=="], @@ -7203,6 +7480,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=="], @@ -7359,6 +7688,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=="], @@ -7525,6 +7862,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=="], @@ -7601,6 +7940,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=="], @@ -7619,6 +7962,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 cd9161398025..6a28e79b75c7 100644 --- a/packages/cli/src/tui.ts +++ b/packages/cli/src/tui.ts @@ -213,13 +213,15 @@ function v1AssistantInfo(sessionID: string, item: any) { } function v1UserInfo(sessionID: string, item: any) { + // No fabricated agent or model: the prompt bar syncs its selection from the last user message, + // so an invented value would clobber the user's choice (and an empty model renders a + // "Model / is not valid" toast). An empty agent short-circuits that sync. return { id: item.id, sessionID, role: "user", time: { created: epoch(item.time?.created) }, - agent: "build", - model: { providerID: "", modelID: "" }, + agent: "", } } @@ -455,8 +457,28 @@ 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). +// +// 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 @@ -465,27 +487,83 @@ 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 @@ -517,6 +595,7 @@ function createSessionProjector(origin: string, headers: HeadersInit | undefined time: Date.now(), }), ) + follow(sessionID, dir) return } if (type === "session.next.step.started" && data.assistantMessageID) { @@ -557,12 +636,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() }, }) } diff --git a/packages/core/package.json b/packages/core/package.json index ee24893c3ae5..ca8eac20a713 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -40,6 +40,15 @@ } }, "devDependencies": { + "@opencode-ai/http-recorder": "workspace:*", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@types/cross-spawn": "catalog:", @@ -49,15 +58,6 @@ "@types/semver": "catalog:", "@types/turndown": "5.0.5", "@types/which": "3.0.4", - "@parcel/watcher-darwin-arm64": "2.5.1", - "@parcel/watcher-darwin-x64": "2.5.1", - "@parcel/watcher-linux-arm64-glibc": "2.5.1", - "@parcel/watcher-linux-arm64-musl": "2.5.1", - "@parcel/watcher-linux-x64-glibc": "2.5.1", - "@parcel/watcher-linux-x64-musl": "2.5.1", - "@parcel/watcher-win32-arm64": "2.5.1", - "@parcel/watcher-win32-x64": "2.5.1", - "@opencode-ai/http-recorder": "workspace:*", "drizzle-kit": "catalog:" }, "dependencies": { @@ -85,22 +85,23 @@ "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", - "@lydell/node-pty": "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", "@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*", "@opencode-ai/llm": "workspace:*", - "@opencode-ai/schema": "workspace:*", "@opencode-ai/plugin": "workspace:*", + "@opencode-ai/schema": "workspace:*", + "@openrouter/ai-sdk-provider": "2.9.0", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "@parcel/watcher": "2.5.1", "@silvia-odwyer/photon-node": "0.3.4", - "@openrouter/ai-sdk-provider": "2.9.0", "ai-gateway-provider": "3.2.0", "bun-pty": "0.4.8", "cross-spawn": "catalog:", @@ -113,8 +114,8 @@ "google-auth-library": "10.5.0", "gray-matter": "4.0.3", "htmlparser2": "8.0.2", - "immer": "11.1.4", "ignore": "7.0.5", + "immer": "11.1.4", "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "minimatch": "10.2.5", 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..159c9c6660b2 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -40,5 +40,9 @@ 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/20260812000509_permission_request_status_idx"), + 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 90dee8acbf3b..9e255dc7751d 100644 --- a/packages/core/src/database/migration.ts +++ b/packages/core/src/database/migration.ts @@ -16,27 +16,66 @@ 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..58176e762030 --- /dev/null +++ b/packages/core/src/database/migration/20260810092511_permission_request.ts @@ -0,0 +1,25 @@ +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\`);`, + ) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260812000509_permission_request_status_idx.ts b/packages/core/src/database/migration/20260812000509_permission_request_status_idx.ts new file mode 100644 index 000000000000..9f1a921df73e --- /dev/null +++ b/packages/core/src/database/migration/20260812000509_permission_request_status_idx.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260812000509_permission_request_status_idx", + up(tx) { + return Effect.gen(function* () { + 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..38173de6ee2a --- /dev/null +++ b/packages/core/src/database/sqlite.libsql.ts @@ -0,0 +1,232 @@ +// 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 c92ac0ac2ce3..97a003bdf132 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -115,6 +115,15 @@ export class SubscriberOverflowError extends Schema.TaggedErrorClass("@opencode/Event/Owner", { + defaultValue: () => undefined, +}) + export interface PublishOptions { readonly id?: ID readonly metadata?: Record @@ -234,6 +243,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 @@ -259,6 +269,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() @@ -323,12 +345,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 a0eb78a13e2a..20364dccdfdb 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..068149e04608 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,22 @@ 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 +359,104 @@ 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 +468,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/conformance.ts b/packages/core/src/session/execution/conformance.ts new file mode 100644 index 000000000000..a9386a7ed724 --- /dev/null +++ b/packages/core/src/session/execution/conformance.ts @@ -0,0 +1,277 @@ +// 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 "../../testing/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/src/session/execution/run-error-codec.ts b/packages/core/src/session/execution/run-error-codec.ts new file mode 100644 index 000000000000..472a0eba90ba --- /dev/null +++ b/packages/core/src/session/execution/run-error-codec.ts @@ -0,0 +1,74 @@ +// 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 "../../integration" +import { SystemContext } from "../../system-context/index" +import { ToolOutputStore } from "../../tool-output-store" +import type { SessionSchema } from "../schema" +import { ContextSnapshotDecodeError, MessageDecodeError, SessionRunDeclinedError } from "../error" +import { + ModelNotSelectedError, + ModelUnavailableError, + UnsupportedApiError, + VariantUnavailableError, +} from "../runner/model" +import type { SessionRunner } from "../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. +export 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/core/src/session/execution/worktree.ts b/packages/core/src/session/execution/worktree.ts new file mode 100644 index 000000000000..060af00577c7 --- /dev/null +++ b/packages/core/src/session/execution/worktree.ts @@ -0,0 +1,146 @@ +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..e3ef80b67d07 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,27 @@ 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 it ran, whether to continue, and the next loop state. */ +export interface StepResult { + readonly ran: boolean + 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 +42,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 72c761e10d93..f49205e2b8ef 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 { 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({ @@ -208,13 +239,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, @@ -316,6 +352,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 @@ -386,10 +424,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 @@ -405,8 +447,152 @@ const layer = Layer.effect( } }) + // Resume a crashed step from the durable log instead of re-streaming it. A Temporal step retry + // re-invokes runStep on the same log; if the in-flight step already DISPATCHED tools (Tool.Called + // is recorded before the side effect runs, so a running/completed tool may have run), re-streaming + // would re-run that side effect and duplicate the assistant message. Instead we close the step + // from the log: keep completed tool results, fail the ones still unsettled (their result never + // committed -- we can't know if they ran, so the model redoes them), and publish a synthesized + // Step.Ended. The model is NOT re-called. Returns undefined when there is nothing to finalize (a + // fresh step, or a partial with no dispatched tools, which is safe to re-stream). Token/cost + // metering is 0 for the resumed step only; faithful metering would need a durable step-sealed + // marker carrying the provider usage. + 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, + }) + // Mirror runStep's continuation tail: a step with local tool calls continues so the model sees + // the (reused or failed) results. + let needsContinuation = localTools + if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer") + if (needsContinuation) + return { ran: true, continue: true, step: input.step + 1, promotion: "steer" as SessionInput.Delivery } + const moreQueue = yield* SessionInput.hasPending(db, input.sessionID, "queue") + if (moreQueue) return { ran: true, continue: true, step: 1, promotion: "queue" as SessionInput.Delivery } + return { ran: true, continue: false, step: input.step + 1, promotion: undefined } + }) + + // 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: { + readonly sessionID: SessionSchema.ID + readonly step: number + readonly promotion: SessionInput.Delivery | undefined + readonly first: boolean + readonly force: boolean + }) { + // 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 { ran: false, 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) + let needsContinuation = result.needsContinuation + if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer") + if (needsContinuation) + return { ran: true, continue: true, step: result.step + 1, promotion: "steer" as SessionInput.Delivery } + const moreQueue = yield* SessionInput.hasPending(db, input.sessionID, "queue") + if (moreQueue) return { ran: true, continue: true, step: 1, promotion: "queue" as SessionInput.Delivery } + return { ran: true, continue: false, step: result.step + 1, promotion: undefined } + }) + return Service.of({ run, + runStep, }) }), ) @@ -427,6 +613,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..edab2106d124 --- /dev/null +++ b/packages/core/src/snapshot-sync.ts @@ -0,0 +1,130 @@ +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/testing/effect.ts b/packages/core/src/testing/effect.ts new file mode 100644 index 000000000000..131ec5cc6bc2 --- /dev/null +++ b/packages/core/src/testing/effect.ts @@ -0,0 +1,53 @@ +import { test, type TestOptions } from "bun:test" +import { Cause, Effect, Exit, Layer } from "effect" +import type * as Scope from "effect/Scope" +import * as TestClock from "effect/testing/TestClock" +import * as TestConsole from "effect/testing/TestConsole" + +type Body = Effect.Effect | (() => Effect.Effect) + +const body = (value: Body) => Effect.suspend(() => (typeof value === "function" ? value() : value)) + +const run = (value: Body, layer: Layer.Layer) => + Effect.gen(function* () { + const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit) + if (Exit.isFailure(exit)) { + for (const err of Cause.prettyErrors(exit.cause)) { + yield* Effect.logError(err) + } + } + return yield* exit + }).pipe(Effect.runPromise) + +const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) => { + const effect = (name: string, value: Body, opts?: number | TestOptions) => + test(name, () => run(value, testLayer), opts) + + effect.only = (name: string, value: Body, opts?: number | TestOptions) => + test.only(name, () => run(value, testLayer), opts) + + effect.skip = (name: string, value: Body, opts?: number | TestOptions) => + test.skip(name, () => run(value, testLayer), opts) + + const live = (name: string, value: Body, opts?: number | TestOptions) => + test(name, () => run(value, liveLayer), opts) + + live.only = (name: string, value: Body, opts?: number | TestOptions) => + test.only(name, () => run(value, liveLayer), opts) + + live.skip = (name: string, value: Body, opts?: number | TestOptions) => + test.skip(name, () => run(value, liveLayer), opts) + + return { effect, live } +} + +// Test environment with TestClock and TestConsole +const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer()) + +// Live environment - uses real clock, but keeps TestConsole for output capture +const liveEnv = TestConsole.layer + +export const it = make(testEnv, liveEnv) + +export const testEffect = (layer: Layer.Layer) => + make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv)) 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 b381cc7418a3..3f4b84162b2b 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -50,6 +50,30 @@ 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 e4329a2dde98..e5e71c9a81ad 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -1097,6 +1097,54 @@ 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.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/effect.ts b/packages/core/test/lib/effect.ts index 131ec5cc6bc2..ad94c5fe615d 100644 --- a/packages/core/test/lib/effect.ts +++ b/packages/core/test/lib/effect.ts @@ -1,53 +1,3 @@ -import { test, type TestOptions } from "bun:test" -import { Cause, Effect, Exit, Layer } from "effect" -import type * as Scope from "effect/Scope" -import * as TestClock from "effect/testing/TestClock" -import * as TestConsole from "effect/testing/TestConsole" - -type Body = Effect.Effect | (() => Effect.Effect) - -const body = (value: Body) => Effect.suspend(() => (typeof value === "function" ? value() : value)) - -const run = (value: Body, layer: Layer.Layer) => - Effect.gen(function* () { - const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit) - if (Exit.isFailure(exit)) { - for (const err of Cause.prettyErrors(exit.cause)) { - yield* Effect.logError(err) - } - } - return yield* exit - }).pipe(Effect.runPromise) - -const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) => { - const effect = (name: string, value: Body, opts?: number | TestOptions) => - test(name, () => run(value, testLayer), opts) - - effect.only = (name: string, value: Body, opts?: number | TestOptions) => - test.only(name, () => run(value, testLayer), opts) - - effect.skip = (name: string, value: Body, opts?: number | TestOptions) => - test.skip(name, () => run(value, testLayer), opts) - - const live = (name: string, value: Body, opts?: number | TestOptions) => - test(name, () => run(value, liveLayer), opts) - - live.only = (name: string, value: Body, opts?: number | TestOptions) => - test.only(name, () => run(value, liveLayer), opts) - - live.skip = (name: string, value: Body, opts?: number | TestOptions) => - test.skip(name, () => run(value, liveLayer), opts) - - return { effect, live } -} - -// Test environment with TestClock and TestConsole -const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer()) - -// Live environment - uses real clock, but keeps TestConsole for output capture -const liveEnv = TestConsole.layer - -export const it = make(testEnv, liveEnv) - -export const testEffect = (layer: Layer.Layer) => - make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv)) +// Re-export so the test tree keeps its historical import path; the implementation lives in src so +// packages outside core (and the conformance suite) can use the same harness. +export * from "@opencode-ai/core/testing/effect" 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..cbfbb89537bf --- /dev/null +++ b/packages/core/test/question-durable.test.ts @@ -0,0 +1,207 @@ +// 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..1b5930fe2e5e --- /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 "@opencode-ai/core/session/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..ed46ebeb1407 --- /dev/null +++ b/packages/core/test/session-runner-loop-guard.test.ts @@ -0,0 +1,201 @@ +// 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 { 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 { 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 { SessionV2 } from "@opencode-ai/core/session" +import { Snapshot } from "@opencode-ai/core/snapshot" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionRunner } from "@opencode-ai/core/session/runner" +import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { REPEAT_LIMIT, trailingIdenticalToolSteps } from "@opencode-ai/core/session/runner/loop-guard" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" +import { Tool } from "@opencode-ai/core/tool/tool" +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 { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +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, test } from "bun:test" +import { DateTime, Effect, Layer, Schema, Stream } from "effect" +import { testEffect } from "./lib/effect" + +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, {}) + +// 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 Stream.fromIterable([LLMEvent.stepStart({ index: 0 }), LLMEvent.stepFinish({ index: 0, reason: "stop" })]) + 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 harness = (stream: LLMClientShape["stream"]) => + testEffect( + AppNodeBuilder.build( + LayerNode.group([ + Database.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, + Layer.succeed( + LLMClient.Service, + LLMClient.Service.of({ + prepare: () => Effect.die("unused"), + generate: () => Effect.die("unused"), + stream, + }), + ), + ], + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [SessionRunnerModel.node, models], + [SystemContextRegistry.node, systemContext], + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + [SkillGuidance.node, skillGuidance], + [ReferenceGuidance.node, referenceGuidance], + [Config.node, config], + [Snapshot.node, Snapshot.noopLayer], + ], + ), + ) + +const sessionID = SessionV2.ID.make("ses_loop_guard") + +const seedSession = 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) +}) + +describe("SessionRunner loop guard", () => { + const { requests, stream } = stuckModel() + harness(stream).effect("ends a run whose model repeats the same tool call every step", () => + Effect.gen(function* () { + yield* seedSession + 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..c5198eb1f40f 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,112 @@ 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..48d33a74629c --- /dev/null +++ b/packages/core/test/session-runner-recovery.test.ts @@ -0,0 +1,207 @@ +// 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 { 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 { 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 { SessionRunner } from "@opencode-ai/core/session/runner" +import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" +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 { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +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 { DateTime, Effect, Layer, Stream } from "effect" +import { testEffect } from "./lib/effect" + +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, {}) + +// Counts provider calls and answers each with an empty completed step. +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 } +} + +const harness = (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, + ]), + [ + [ + LayerNodePlatform.llmClient, + Layer.succeed( + LLMClient.Service, + LLMClient.Service.of({ + prepare: () => Effect.die("unused"), + generate: () => Effect.die("unused"), + stream, + }), + ), + ], + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [SessionRunnerModel.node, models], + [SystemContextRegistry.node, systemContext], + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + [SkillGuidance.node, skillGuidance], + [ReferenceGuidance.node, referenceGuidance], + [Config.node, config], + [Snapshot.node, Snapshot.noopLayer], + ], + ), + ) + +const seedSession = (sessionID: SessionV2.ID) => + 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) + }) + +// 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") + harness(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") + harness(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") + harness(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 + const result = yield* runner.runStep({ sessionID, step: 1, promotion: undefined, first: true, force: false }) + expect(result.ran).toBe(true) + 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..dfe258afdcf8 --- /dev/null +++ b/packages/core/test/session-runner-resume.test.ts @@ -0,0 +1,239 @@ +// 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 { 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 { SessionV2 } from "@opencode-ai/core/session" +import { Snapshot } from "@opencode-ai/core/snapshot" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionRunner } from "@opencode-ai/core/session/runner" +import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" +import { Tool } from "@opencode-ai/core/tool/tool" +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 { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +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 { Effect, Layer, Schema, Stream } from "effect" +import { testEffect } from "./lib/effect" + +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, {}) + +// 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); `empty` is a benign one-step response. +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, + }), + ) +const dying: LLMClientShape["stream"] = () => Stream.die("LLMClient.stream should not be called on resume") +const empty: LLMClientShape["stream"] = () => + Stream.fromIterable([LLMEvent.stepStart({ index: 0 }), LLMEvent.stepFinish({ index: 0, reason: "stop" })]) + +const harness = (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: AbsolutePath.make("/project") })], + [SkillGuidance.node, skillGuidance], + [ReferenceGuidance.node, referenceGuidance], + [Config.node, config], + [Snapshot.node, Snapshot.noopLayer], + ], + ), + ) + +const sessionID = SessionV2.ID.make("ses_runner_resume") + +const seedSession = 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) +}) + +// 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", () => { + harness(empty).effect("closes a dangling tool on a mid-turn (first=false) re-drive", () => + Effect.gen(function* () { + yield* seedSession + 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") + }), + ) + + harness(dying).effect("finalizes a dispatched step from the log without re-calling the model", () => + Effect.gen(function* () { + yield* seedSession + 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) + }), + ) + + harness(dying).effect("re-settles an idempotent tool on resume but fails a side-effecting one", () => + Effect.gen(function* () { + yield* seedSession + // 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..55274f129acd --- /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 83eca9036b2a..671cfc774c98 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..94117d000399 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,35 @@ 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 executionNode = + process.env.OPENCODE_SESSION_EXECUTION === "temporal" + ? SessionExecutionTemporal.node + : SessionExecutionLocal.node + return AppNodeBuilder.build(applicationServices, [[SessionExecution.node, executionNode]]) +} + +// 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], + ]) +} + 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..1de02f76b2b8 --- /dev/null +++ b/packages/temporal/README.md @@ -0,0 +1,289 @@ +# @opencode-ai/temporal + +The Temporal executor for opencode's `SessionExecution` seam, packaged as a plugin: core carries +the seam, the built-in local executor, and the executor-agnostic toolkit; this package is one +dependency that makes an opencode **session** a durable Temporal workflow. A coding session +survives worker loss, can run detached or in the background, and can be driven from anywhere by +signal. opencode's loop, tools, model, storage, and HTTP API are untouched, and nothing Temporal +exists in core. + +A lighter increment exists as its own change: the `2026/08/opencode-temporal-http` branch wraps +the **shipping** `opencode serve` over its HTTP API, for the agent-as-black-box case. It makes the +orchestration durable but cannot recover a partial turn. This change is the deeper one: durability +inside the engine, so a crashed turn resumes mid-step instead of being re-attached to. + +## How it fits together + +One design decision carries the change: durability is a choice of executor behind the substitutable +`SessionExecution` service, and both executors drive the same `SessionRunner` over the same durable +event log. Everything else here is a consequence of taking at-least-once execution seriously. + +One env var picks the executor. `temporal` runs each session as a per-session Temporal workflow +with one activity per step (this package: `executor.ts` wires the client and worker, `supervisor.ts` +is the loop, `workflow.ts` adapts it to the sandbox, `drain.ts` is the step body). The default runs +in-process on the proven `SessionRunCoordinator` (core's `execution/local.ts`), the same lifecycle +the v1 server uses, with no server and no ports (see [Two modes, one runner](#two-modes-one-runner)). +What an executor must do is defined executably: core's conformance suite +(`session/execution/conformance.ts`) runs the same wake/resume/interrupt scenarios against the local +executor in core's tests and against this package through real workflows. + +That forces six things: + +1. **State must be shareable.** Any worker resumes any session only if the event log is not + host-local: the libSQL backend, atomic remote writes, busy retry, serialized migrations + ([Shared, durable event store](#shared-durable-event-store-any-worker-resume)). +2. **Re-drives must be 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 + (`loop-guard.ts`: a step ceiling plus a repeated-identical-call detector), because a runaway + turn would otherwise be a durable runaway turn + ([Two modes, one runner](#two-modes-one-runner)). +3. **Two writers must be fenced.** A superseded attempt cannot keep appending to the log; each + drain claims the log with an attempt token ([Notes](#notes)). +4. **The worktree must travel.** Snapshot trees ship as incremental git packs, and a worker + without the project tree rebuilds it before the run + ([What resumes cross-host](#what-resumes-cross-host-and-what-does-not)). +5. **Human-in-the-loop must be durable.** A pending permission ask is a row in the shared store, + answerable from any process, adopted by re-drives, expired when abandoned + ([Durable permission asks](#durable-permission-asks)). +6. **Errors must survive the boundary.** `resume` rejects with the exact tagged `RunError` + reconstructed from the failure details, and a user decline is non-retryable ([Notes](#notes)). + +Operationally, workers scale separately from the HTTP server +([Running workers separately](#running-workers-separately)), and `active` is backed by Temporal +visibility, so it survives restarts. + +## A durable `SessionExecution` on the v2 engine + +opencode's v2 engine (`packages/core` + `packages/server`) is already event-sourced per session and +exposes a substitutable `SessionExecution` service (`active` / `resume` / `wake` / `interrupt`) +whose local impl comments "Future remote placement belongs here." This change provides a +Temporal-backed `SessionExecution` in `packages/core/src/session/execution/`: + +- `temporal-workflow.ts`: the pure per-session workflow (the Temporal equivalent of + `SessionRunCoordinator`: `wake`/`force` drive one drain, wakes coalesce, quiescent runs end). +- `temporal-activities.ts`: the `runTurnStep` activity (heartbeats; forwards cancellation; + injects the attempt's event-log owner token). +- `temporal.ts`: the `SessionExecution` layer + node: `wake` → `signalWithStart`, `resume` → + forced `signalWithStart`, `interrupt` → cancel signal; each drain runs one step of the local + coordinator's loop (`SessionRunner.runStep`) in an activity against the durable event log. The + Temporal client and an embedded worker are co-hosted in the server process (both run under bun). + +Wiring is one binding in `packages/server/src/routes.ts`, opt-in via +`OPENCODE_SESSION_EXECUTION=temporal`. Because turn state lives in the event log, the workflow stays +thin and recovery is engine-level: a run re-reads recorded history and continues, it does not +re-attach. + +### 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 and prompt it against `POST /api/session` and `POST /api/session/:id/prompt`; each +session runs as a Temporal workflow `session-exec-`. + +### Verified + +- With Temporal execution on, prompting a v2 session drove a full turn to completion (`step.ended`, + `TEMPORAL_V2_OK`), recorded as a completed per-session workflow. +- Engine-level crash recovery: killing the whole server (with its + embedded worker) mid-turn, then restarting, still completes the turn. Temporal re-drives + the in-flight step activity (attempt 2), the run continues from the event log, and the workflow + completes. + +### Two modes, one runner + +The factory has exactly two modes, both driving the same `SessionRunner` over the same durable event +log. `OPENCODE_SESSION_EXECUTION=temporal` runs each session as a per-session Temporal workflow: the +`sessionTurn` supervisor (`supervisor.ts`) loops a `runTurnStep` drain, so each step (one provider +attempt + its tools) is its own activity with its own retry/timeout/visibility, reusing +`SessionRunner.runStep` (one iteration of `run`'s loop). Anything else (the default) runs in-process +on the proven `SessionRunCoordinator` (`execution/local.ts`) -- no server, no worker, no ports -- which +drives whole turns with `SessionRunner.run` and owns the wake/resume/interrupt lifecycle. That +coordinator is the same one the v1 server uses and has direct lifecycle tests +(`session-run-coordinator.test.ts`), so the default path reuses well-exercised code rather than a +second hand-written loop. The local integration wiring is covered by +`session-execution-local.test.ts`, and Temporal crash recovery by the crash test (in the +stacked scripts PR). The same wake/resume/interrupt contract also runs against the Temporal driver +through real workflows via the opt-in suite (`session-execution-temporal-contract.test.ts`). +Verified: +a create-then-read-then-reply turn recorded three `runTurnStep` activities under a `sessionTurn` +workflow and completed. (Earlier whole-turn-per-activity, stock-coordinator, and shared-supervisor +local modes were folded away in favor of the coordinator for local.) + +A per-step re-drive resumes from the durable event log rather than re-running work. `runStep` closes +any tool left dangling by an interrupted attempt on every entry, not just the first. Without that, a +mid-turn retry (`first=false`) re-streamed a request carrying a `tool_use` with no `tool_result`, +which the provider rejects, a retry poison loop. And if the crashed step had already dispatched tools +it is finalized from the log: completed tool results are kept, still-unsettled tools are failed, and +a synthesized `Step.Ended` closes the step without re-calling the model. A tool caught in flight at +the crash is handled by declared idempotency: a side-effect-free tool (`read`/`glob`/`grep`, marked +`idempotent: true`) is re-run for a real result, while a side-effecting tool is marked interrupted +and left for the model to redo. The harness cannot know whether the side-effecting one already ran +and must not re-run `git push`, so the default is non-idempotent; the blanket case (idempotency keys +against an external system) is per-integration and out of scope. Finer granularity (the model call +and each tool as separate Temporal activities) would un-fuse the eager tool dispatch and is left for +later. Verified by `packages/core/test/session-runner-resume.test.ts`. + +### Notes + +- `resume` awaits the forced run (via Update-with-Start) and surfaces its failure as the **exact + tagged `RunError`**: the activity encodes the error through a `Schema.Union` of every RunError + member (`run-error-codec.ts`) into the failure details, and the layer reconstructs it, falling + back to a `ContextSnapshotDecodeError` carrying the text only if decoding fails. +- `active` queries Temporal for the open per-session workflows, so it reflects durable state and + survives a restart (not a process-local set). +- The per-session workflow is long-lived and self-terminates after an idle period. Layer + construction connects to Temporal at startup, so the server needs Temporal reachable when + `OPENCODE_SESSION_EXECUTION=temporal`. +- Activity bounds: the 10s heartbeat is the liveness bound (worker death re-drives within seconds); + `startToCloseTimeout` is a 12-hour backstop for a drain that hangs while its process stays alive. + When an attempt is retried while the previous one is still alive (a network partition, or the + backstop firing), the old attempt could briefly keep publishing until its heartbeat is rejected + and the AbortSignal interrupts it. That overlap is now fenced: each drain claims the event log + with an attempt token (`event_sequence.owner_id` via `claim()`), and a live durable append dies + if a newer attempt has since claimed the log (the check is in `event.ts`, gated by the + `EventOwner` context the drain provides). The owner is set activity-side from the run id and + attempt, so it stays out of the workflow's deterministic input; the local driver uses a + per-instance token. The projector's status guards still make any duplicate settlement a no-op. + +Resume is verified end to end: it resolves on a healthy session and rejects on a failing +one with the original tagged error (`LLM.Error`) reconstructed across the boundary. + +### Running workers separately + +By default the serve process hosts both the Temporal activity worker and the workflow client +(`OPENCODE_TEMPORAL_ROLE=both`). To scale workers independently of the HTTP server, run standalone +workers and point serve at client-only: + +```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 (`createWorkerLayer`) +without the HTTP API, so a worker resumes a session purely from the shared store. +Verified: a worker comes up with no serve process and registers +on the task queue. Caveat: file-touching tools run against the local working tree, so a worker must +have the session's worktree present (see "What resumes cross-host"). + +### Durable permission asks + +A tool waiting for user approval used to park on an in-memory deferred: invisible outside the asking +process (a standalone worker's ask could never be answered) and gone on restart. A pending ask is now +also a row in the shared store (`permission_request`). The blocked `assert` races its local deferred +against a poll of the row, so a reply from ANY process sharing the store (the HTTP server answering +for a detached worker) unblocks it; `list`/`get`/`forSession` read the rows, so serve can show asks +raised elsewhere. Replies keep their semantics: `once`/`always` approve (an `always` rule +retro-approves other pending asks it covers), `reject` declines and cascades to the session's other +pending asks, a rejection message arrives as the typed `CorrectedError`. A user decline inside a +Temporal activity is now a non-retryable failure, so the workflow does not re-drive a turn the user +stopped. Tool-originated asks have deterministic ids (session + callID + action + resources), 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 (a one-time approve is honored across +re-drives without a saved rule). Graceful shutdown retires the process's pending asks as `expired` +and a revived attempt flips them back to pending; after a hard crash the pending row feeds the +retry. A pending ask whose session is abandoned lingers in the list until a reply retires it. +Verified by `packages/core/test/permission-durable.test.ts` (two independent stacks over one store). +The `question` tool still uses an in-process deferred and needs the same treatment. + +## Shared, durable event store (any-worker resume) + +The v2 engine event-sources each session to a SQLite store. By default that is a local file, so a +session can only be resumed on the host holding the file. Point every worker at one **shared** store +and any worker resumes any session: Temporal load-balances `runTurnStep` across the fleet, +`active` is visibility-backed, and the resumed worker reads the session purely from the shared log. + +- **Same host**: set `OPENCODE_DB` to one absolute path on all workers (WAL + `busy_timeout` allow + multiple processes). No code change. +- **Across hosts**: set `OPENCODE_DB_URL` to a libSQL URL (self-hosted `sqld` or Turso), with + `OPENCODE_DB_AUTH_TOKEN` if needed. `packages/core/src/database/sqlite.libsql.ts` provides a + `SqlClient` over `@libsql/client`; it speaks the same SQLite dialect, so the schema and all + migrations are unchanged. Selection is one env check in `database.ts`. + +Verified: worker A handles turn 1 (a code word), A is killed, +and a fresh worker B (same queue, same store, never saw the session) handles turn 2 and recalls the +code word, which it can only do by loading turn 1 from the shared store. The two turns run on two +distinct worker identities. + +### Caveats + +- Cold-start migrations serialize across processes (one `BEGIN IMMEDIATE` transaction wraps + check-and-apply, so concurrent starts wait and then no-op). Running migrations once as a deploy + step is still good practice for large fleets, and on a remote store it keeps cold starts from + contending for the write lock. A fresh remote store also builds the whole schema inside one + write transaction; a slow link or a server-side transaction timeout can kill that, one more + reason to migrate before starting workers. +- Foreign keys are enforced on the local backend and best-effort on the shared one (SQLite + defaults them off; a remote server applies the pragma per stream at most). Deletes on the + shared store rely on the application-side cascades. +- The PRAGMAs (`journal_mode` / `synchronous` / `busy_timeout` / `cache_size` / `wal_checkpoint`) + are local-file semantics and are skipped for the shared/libSQL backend, which manages journaling + itself. +- Multi-statement writes commit atomically on both backends. The libSQL client runs each statement + as its own auto-commit request, so a `BEGIN`/`COMMIT` emitted as plain statements would not bind a + transaction over a remote URL. The shared backend instead routes a transaction through a real + interactive libSQL transaction (one pinned stream), which is all-or-nothing. Verified against an + embedded (`file:`) store (`packages/core/test/database-libsql-transaction.test.ts`) AND against a + live networked `sqld` over HTTP (`packages/core/test/database-libsql-remote.test.ts`: atomic + commit, full rollback on a mid-transaction failure, and the schema migrations applied remotely). + The remote suite needs a server, so it runs only when `OPENCODE_LIBSQL_TEST_URL` is set (e.g. + `turso dev --port 8899`, then `OPENCODE_LIBSQL_TEST_URL=http://127.0.0.1:8899`); it skips + otherwise. +- Every durable event is its own interactive transaction, but streaming text and reasoning deltas + are live-only: they broadcast in memory and write no row (verified by `session-runner.test.ts` + "broadcasts provider ... deltas without storing projection rewrites", which streams 32 chunks and + asserts zero delta rows while the context still rebuilds from the log). So a streaming turn does + NOT pay a transaction per token. The durable cost of one step is the handful of boundary and tool + events (`Step.Started`, a `Text.Ended` per block, the `Tool.*` pair per call, `Step.Ended`), each + recorded as it settles. Over a remote store that is a small, bounded number of round trips per + step, not one per token. Coalescing them into a single commit at step end would cut round trips + further, but a mid-step crash would then lose the completed-tool records the resume path reuses, + so per-event durability is kept on purpose. + +### What resumes cross-host, and what does not + +The runner rebuilds a session's LLM context purely from the shared DB (`SessionHistory.entriesForRunner` +then `toLLMMessages`); it never reads local disk to reconstruct context. So the **conversation** resumes +on any worker: messages, tool results (the bounded preview and structured output that the model sees), +prompt attachments (stored inline as `data:` URIs in the prompt), and credentials (`CredentialTable`) +all ride the shared store. + +**The project working tree** now rides the store too. After each step capture the runner ships +the snapshot tree as an incremental git pack (`snapshot-sync.ts`, `snapshot_pack` table). Before a +drain runs, a worker missing the session's directory rebuilds the worktree from those packs +(`session/execution/worktree.ts`): uncommitted edits and untracked files included, checked out at +the same absolute path it was captured at (a uniform fleet layout). Ignored files and dependencies +are not captured, so a rebuilt tree may need an install step before `bash` behaves identically. +Worker affinity or a shared volume skips the materialization latency on warm paths; the packs +are the portable baseline that works with neither. + +Host-local state that does NOT ride the DB, so it is not reconstructed on a different host: + +- **The snapshot store (`${data}/snapshot`) and the retained full tool-output files + (`${data}/tool-output`).** The runner never reads these to rebuild context: snapshot file-diffs are + best-effort (`Effect.catch` to `undefined`), and the model sees the bounded tool-output preview, not + the file. They only affect the diff/restore/revert features and full-output viewing. Point `${data}` + (the XDG data dir) at shared storage to make them portable. + +## Porting this pattern + +The shape transfers to any agent engine; Temporal is one executor behind a seam the engine owns. + +1. Find the engine's coordination seam and name it: here, four verbs (`active`, `wake`, `resume`, + `interrupt`) behind one substitutable service, with the in-process coordinator as the default. +2. Make the turn body an idempotent, fenced step function: claim the log with an owner token, + reuse recorded results on re-drive, encode errors so they survive a process boundary. +3. Write the executor as a thin workflow that loops the step as activities; keep the loop free of + engine imports so it stays deterministic and sandbox-safe. +4. Forward settings as one evolvable input record; read configuration at layer build, never at + module load. +5. Hold every executor to one conformance suite. Parity between the default and the durable path + is a test, not a promise. diff --git a/packages/temporal/docs/native-local-driver-branch.md b/packages/temporal/docs/native-local-driver-branch.md new file mode 100644 index 000000000000..67317539e10b --- /dev/null +++ b/packages/temporal/docs/native-local-driver-branch.md @@ -0,0 +1,103 @@ +# Branch: 2026/08/opencode-native-local-driver + +Base: `2026/08/opencode-temporal` (`8c140c0344`). This branch reworks how a v2 session's +`SessionExecution` runs, in two independent parts, backed by a new deterministic test harness. + +## Why + +The base branch runs BOTH execution modes on one hand-written per-session supervisor +(`execution/workflow-core.ts`, driven for local mode by `execution/local-driver.ts` and for Temporal +mode by `execution/temporal-workflow.ts`). Independent review (Codex, several rounds) found repeated +lifecycle races in that shared supervisor and in the local driver: concurrent successors during +interrupt cleanup, a completion barrier that did not cover resume-started drains, resume/wake lost at +the interrupt boundary, and duplicate provider turns on concurrent resume. Each fix uncovered +another. The pattern was clear: the coordination lifecycle is the dangerous part, and hand-rolling it +kept going wrong, while opencode already had a proven, tested implementation of exactly those +semantics (`SessionRunCoordinator`, `session/run-coordinator.ts`). + +## What changed + +### 1. Local mode runs on the proven SessionRunCoordinator + +`routes.ts` now selects `SessionExecutionLocal` (`execution/local.ts`) for the default mode. It maps +`active` / `wake` / `resume` / `interrupt` onto `SessionRunCoordinator` and drains whole turns with +`SessionRunner.run`, the same lifecycle the v1 server already uses. The hand-written +`local-driver.ts` supervisor path was removed. Local mode is now the well-exercised default and its +correctness comes from reused, tested code rather than a second coordination loop. + +That supervisor is therefore Temporal-only; it now lives at `packages/temporal/src/supervisor.ts`. + +### 2. A Temporal test harness, and a Temporal-mode correctness pass + +Standing up a real Temporal test harness (`@temporalio/testing` time-skipping / local server running +the actual `sessionTurn` workflow with a mock activity) immediately caught a blocker and enabled a +correctness pass over the Temporal supervisor. Fixed: + +- **Blocker: the workflow never drained a turn, and a session could serve only one turn.** The SDK's + `condition(fn, timeout)` on `@temporalio/workflow` 1.21 leaks its timer-scope cancellation into the + root scope on resolve, poisoning the next drain. The timed wait now races a no-timeout `condition` + against a bare `sleep` and abandons the loser, cancelling no scope. (This, not the HTTP `steer` + delivery, was the real cause of the "0 activities / turn never runs" symptom.) +- **Interrupt keeps serving.** `interrupt` now cancels only the current turn's child cancellation + scope; the long-lived workflow keeps serving, so a wake/resume racing the interrupt drives a fresh + turn on the same workflow instead of being lost to a doomed one. +- **resume joins.** All drains route through one in-flight promise; a resume joins it instead of + queueing a second forced drain (no duplicate provider turns / tool side effects). +- **Explicit start intent.** `sessionTurn(sessionID, startWithWake)`; resume-with-start passes + `[id, false]`, so a fresh resume does exactly one drain; the flag is carried across + `continueAsNew`. +- **continue-as-new** counts every drain, gates on `allHandlersFinished()`, and a resume-driven + rollover carries no spurious wake. +- **Real (root) workflow cancellation** is detected via the root scope and stops the supervisor, + never keeps serving or continue-as-news. +- **Event-log owner token** is now unique per activity execution (`runId:activityId:attempt`), so a + zombie attempt from an earlier step can no longer re-authorize past the fence. +- **Interrupt delivery failure** is surfaced as a defect instead of being swallowed as success. + +Full status and the remaining (non-correctness) follow-ups are in +[temporal-supervisor-followups.md](./temporal-supervisor-followups.md). + +## Commits + +1. Run local sessions on the proven SessionRunCoordinator. +2. Add a Temporal test harness and fix a condition-cancellation blocker. +3. Fix the event-log owner token to be unique per activity execution. +4. Surface genuine interrupt-delivery failures instead of swallowing them. +5. Count all drains toward continue-as-new, including resume-driven ones. +6. Document the remaining Temporal supervisor coordination follow-ups. +7. Redesign the Temporal supervisor: interrupt keeps serving, resume joins. +8. Test the supervisor redesign: fake-runtime + real-Temporal harness. +9. Update the Temporal supervisor follow-ups doc: deep items now fixed. + +## Verification + +- `bun typecheck` (core + server): clean. +- Fake-runtime + regression suites: green (supervisor join/interrupt/rollover/root-cancel, owner + token, interrupt classification, local coordinator integration, run-coordinator, and the + session-runner suites). +- Real Temporal harness (run each file on its own; two native Temporal servers in one bun process + segfault the runtime): + - `temporal-harness-smoke.test.ts`: a wake drives a turn, then the workflow idle-retires. + - `temporal-harness-interrupt.test.ts`: an interrupt cancels the current turn; a later wake runs a + second turn on the same workflow. + - `temporal-harness-multiturn.test.ts`: turn 1 completes, the supervisor parks in the idle wait + (asserted via a `TimerStarted` history event), then a wake drives turn 2. +- Live: with the blocker fixed, a real `gpt-5-mini` turn completes end-to-end against a dev server in + Temporal mode (previously it recorded zero activities). + +Each substantive change was reviewed with Codex (twice per round); the redesign went through several +review-and-fix rounds until both runs returned no remaining correctness must-fix for a fresh +deployment. + +## Known limitations + +- **Rolling deploys need Temporal versioning/patching or draining old executions**, because the + Temporal workflow's command sequence changed. Fresh deployments are unaffected. See the follow-ups + doc. +There is NO "steer prompt delivery" problem, contrary to an earlier mistaken note. `POST +/api/session/:id/prompt` without an explicit `delivery` defaults to `"steer"` (`session.ts:366`); +`steer` is a valid delivery that `SessionRunner.run` handles (`llm.ts:425`, it interjects into / leads +the current turn, versus `queue` which waits for the next turn). The "0 activities / turn never runs" +symptom seen end-to-end was entirely the `condition(fn, timeout)` leak described above, not the +delivery mode. With that fixed, a default (`steer`) prompt drives a turn to completion in Temporal +mode (verified live: assistant reply recorded, one `runTurnStep` activity). diff --git a/packages/temporal/docs/temporal-supervisor-followups.md b/packages/temporal/docs/temporal-supervisor-followups.md new file mode 100644 index 000000000000..ede4e77e4888 --- /dev/null +++ b/packages/temporal/docs/temporal-supervisor-followups.md @@ -0,0 +1,61 @@ +# Temporal supervisor: status and follow-ups + +Independent review (Codex, several rounds) enumerated correctness issues in the Temporal-mode session +supervisor (now `packages/temporal/src/supervisor.ts` + `workflow.ts` + `executor.ts`). +Local mode is unaffected: it runs the proven `SessionRunCoordinator` +(`execution/local.ts`). + +A deterministic test harness now exists for this code: fake-runtime unit tests of the supervisor loop +(`session-supervisor.test.ts`, `session-supervisor-rollover.test.ts`) and real-Temporal tests via +`@temporalio/testing` (`temporal-harness-{smoke,interrupt,multiturn}.test.ts`). Every item below is +reproducible/verifiable through one of those. + +## Fixed + +- **Blocker: the workflow never drained a turn (and a session could serve only one turn).** On + `@temporalio/workflow` 1.21, `condition(fn, timeout)` cancels its internal timer scope on resolve + and that cancellation LEAKS into the parent/root scope, so the next `condition()`/drain saw a + cancelled scope. The supervisor completed with zero activities, or (after the first turn) a second + turn's drain was born cancelled. Fixed by not using the SDK's timed `condition` at all: the adapter + short-circuits an already-true predicate and, for a real wait, races a no-timeout `condition` + against a bare `sleep` and abandons the loser -- cancelling no scope, so nothing leaks. (This, not + the HTTP `steer` delivery, was the real cause of the "0 activities" symptom.) +- **Resume/wake lost at the interrupt boundary.** `interrupt` used to end the workflow, so a + resume/wake admitted in the interrupt→completion window (USE_EXISTING) attached to a doomed run. + Now `interrupt` cancels only the current turn's child cancellation scope (`runInDrainScope`); the + long-lived workflow keeps serving, and a later wake/resume drives a fresh turn on the same + workflow. Verified: `temporal-harness-interrupt.test.ts`. +- **Concurrent resumes duplicated forced turns.** `drainTurn` serialized but did not JOIN. Now every + drain routes through one in-flight promise and resume joins it (no duplicate provider turns / tool + side effects), mirroring `SessionRunCoordinator.run`. +- **Fresh-resume spurious drain.** Explicit start intent: `sessionTurn(sessionID, startWithWake)`, + resume-with-start passes `[id, false]`, and `pendingWake` is carried across `continueAsNew`, so a + fresh resume does exactly one drain. +- **continue-as-new ignored resume drains / manufactured a wake.** Every drain now counts toward the + bound, the main loop rolls over on `rolloverPending`, a resume-driven rollover carries no spurious + wake, and completion/continue-as-new gate on `allHandlersFinished()` so an in-flight update result + is never abandoned. +- **Real workflow (root) cancellation.** Detected via the root scope's `consideredCancelled` + (reliable now that the timed wait cancels no scope): a root cancellation stops the supervisor and + never keeps serving or continue-as-news; a per-turn interrupt (child scope) does not trip it. +- **Owner-token collision (event-log fence).** Token was `runId#attempt`; activity attempts restart + per step, so tokens repeated across steps and a zombie attempt could re-authorize. Now + `runId:activityId:attempt` (`temporal-activities.ts`). +- **Interrupt delivery failure reported as success.** A genuine signal failure was swallowed to + `void`; now surfaced as a defect (`temporal.ts`, `classifyInterruptError`). + +## Remaining follow-ups (not correctness bugs on a fresh deployment) + +- **Replay / versioning for rolling deploys.** This revision changes command-producing behavior + (single-flight resume, the timed wait no longer cancels a timer, `continueAsNew` carries a second + arg). A workflow already running under the OLD code can replay nondeterministically under the new + code. Before a rolling deploy, use Temporal Worker Versioning / patching or drain old executions. A + fresh deployment is unaffected. +- **`active` reports idle-but-parked workflows.** The visibility query lists running `sessionTurn` + workflows, so a session that finished a turn but hasn't idled out yet still shows as active. A + workflow query or search attribute exposing `pendingWake || inFlight || resumers > 0` would be more + precise if the API needs it. +- **Broader real-harness coverage.** The harness proves draining, idle retirement, interrupt + keep-serving, and park-then-wake. Concurrent real updates → one activity, resume-with-start → one + drain, and a real root-cancel-during-drain are covered at the fake-runtime level; promoting them to + the real harness would add belt-and-suspenders confidence. diff --git a/packages/temporal/package.json b/packages/temporal/package.json new file mode 100644 index 000000000000..c660ce4c7eca --- /dev/null +++ b/packages/temporal/package.json @@ -0,0 +1,24 @@ +{ + "$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:*", + "@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..db10397030d0 --- /dev/null +++ b/packages/temporal/src/drain.ts @@ -0,0 +1,108 @@ +// 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 "@opencode-ai/core/session/execution/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 { + ran: boolean + 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 { ran: false, 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 { ran: r.ran, 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..1d3e7042a0e6 --- /dev/null +++ b/packages/temporal/src/executor.ts @@ -0,0 +1,229 @@ +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 "@opencode-ai/core/session/execution/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/supervisor.ts b/packages/temporal/src/supervisor.ts new file mode 100644 index 000000000000..d279dcfc5164 --- /dev/null +++ b/packages/temporal/src/supervisor.ts @@ -0,0 +1,173 @@ +// 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 +} + +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 +} + +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 + + // 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 + let step = 1 + let promotion: string | null = null + let first = true + for (;;) { + const r: StepDrainResult = await rt.runTurnStep({ sessionID, step, promotion, first, force }) + if (!r.continue) 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..d19ac7df8228 --- /dev/null +++ b/packages/temporal/src/workflow.ts @@ -0,0 +1,126 @@ +// 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, +} 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. 30 minutes proved far too tight -- it hard-killed + // legitimate turns and each kill opened a short two-writer window until the zombie attempt + // noticed its heartbeat rejection. + 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 = { + // Short-circuit when the predicate already holds. Besides saving a round trip, this avoids a real + // breakage: on @temporalio/workflow 1.21, calling condition(fn, timeout) when fn is already true + // leaves the current CancellationScope cancelled, so the NEXT condition() throws CancelledFailure + // -- which the supervisor reads as an interrupt and the workflow completes without ever draining a + // turn. Checking fn() first keeps the timeout timer (and its scope) out of the already-true path. + // 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 (root) scope, so the next drain's child scope is born cancelled and the turn never runs + // (a session could serve only one turn). Instead: short-circuit an already-true predicate; for a + // real wait, race a no-timeout condition against a bare timer and abandon the loser. Nothing here + // cancels a scope, so nothing leaks; an unfired timer / unresolved condition is harmless 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 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/session-execution-temporal-contract.test.ts b/packages/temporal/test/session-execution-temporal-contract.test.ts new file mode 100644 index 000000000000..bb9142008a62 --- /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. This is the second half +// of the parity story in packages/temporal/README.md "Two modes, one drain". +// +// 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 "@opencode-ai/core/session/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 dynamically because the driver reads its connection config at module load. + const { SessionExecutionTemporal } = await import("@opencode-ai/temporal/executor") + runContract("temporal driver", makeExecutionFor(SessionExecutionTemporal.node)) +} 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..29bf46d9d662 --- /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 = { ran: true, 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..0855bb97cd50 --- /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 = { ran: true, 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..729b9a746e7e --- /dev/null +++ b/packages/temporal/test/temporal-harness-interrupt.test.ts @@ -0,0 +1,82 @@ +// Real-Temporal validation that an interrupt cancels the CURRENT turn but the supervisor keeps +// serving (the redesigned interrupt semantics). Uses a real (non-time-skipping) local server so a +// blocking, heartbeating activity behaves in real time -- time-skipping would jump past its +// heartbeat timeout and spuriously retry it. Runs in its own file: two native Temporal servers in +// one bun process segfault the runtime. +import { describe, it, expect } from "bun:test" +import { fileURLToPath } from "node:url" +import { TestWorkflowEnvironment } from "@temporalio/testing" +import { Worker } from "@temporalio/worker" +import { Context, heartbeat, CancelledFailure } from "@temporalio/activity" + +const WORKFLOW = fileURLToPath(new URL("../src/workflow.ts", import.meta.url)) +const poll = async (fn: () => boolean, ms = 20_000) => { + const deadline = Date.now() + ms + while (!fn()) { + if (Date.now() > deadline) throw new Error("condition not reached") + await new Promise((r) => setTimeout(r, 50)) + } +} + +describe("temporal workflow harness: interrupt", () => { + it("interrupt cancels the current turn but the workflow keeps serving the next", async () => { + const env = await TestWorkflowEnvironment.createLocal() + let steps = 0 + let firstStarted = false + try { + const worker = await Worker.create({ + connection: env.nativeConnection, + namespace: env.namespace, + taskQueue: "harness-interrupt", + workflowsPath: WORKFLOW, + activities: { + runTurnStep: async () => { + steps++ + if (steps === 1) { + // Turn 1 blocks until the interrupt cancels it, heartbeating so it isn't 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 { ran: true, continue: false, step: 1, promotion: null } + }, + }, + }) + + await worker.runUntil(async () => { + const handle = await env.client.workflow.signalWithStart("sessionTurn", { + taskQueue: "harness-interrupt", + workflowId: "wf-interrupt", + args: ["ses_interrupt"], + signal: "wake", + signalArgs: [], + }) + await poll(() => firstStarted) // turn 1's activity is running + await handle.signal("interrupt") // cancel the current turn's scope + // The workflow must still be alive to accept a new prompt; if the interrupt had ended it, + // this wake would target a completed workflow and the second turn would never run. + await handle.signal("wake") + await poll(() => steps === 2) // a second turn ran on the SAME workflow + }) + + expect(steps).toBe(2) + } finally { + await env.teardown() + } + }, 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..2b8f7c93ccfd --- /dev/null +++ b/packages/temporal/test/temporal-harness-multiturn.test.ts @@ -0,0 +1,69 @@ +// Real-Temporal validation that a session serves MULTIPLE turns: turn 1 completes, the supervisor +// parks in its idle timed wait, and a later wake drives turn 2 on the SAME workflow. This is the +// exact path the timed-wait rewrite fixes -- the SDK's condition(fn, timeout) leaked its cancellation +// into the root scope when it resolved, so turn 2's drain scope was born cancelled and never ran (a +// session could serve only one turn). With the leaking condition this test fails (steps stays 1). +// +// Uses a real (non-time-skipping) local server so the idle timer doesn't fast-forward and retire the +// workflow before the second wake. Own file: two native Temporal servers in one bun process segfault. +import { describe, it, expect } from "bun:test" +import { fileURLToPath } from "node:url" +import { TestWorkflowEnvironment } from "@temporalio/testing" +import { Worker } from "@temporalio/worker" + +const WORKFLOW = fileURLToPath(new URL("../src/workflow.ts", import.meta.url)) +const poll = async (fn: () => boolean, ms = 20_000) => { + const deadline = Date.now() + ms + while (!fn()) { + if (Date.now() > deadline) throw new Error("condition not reached") + await new Promise((r) => setTimeout(r, 50)) + } +} + +describe("temporal workflow harness: multi-turn", () => { + it("serves a second turn after parking in the idle wait", async () => { + const env = await TestWorkflowEnvironment.createLocal() + let steps = 0 + try { + const worker = await Worker.create({ + connection: env.nativeConnection, + namespace: env.namespace, + taskQueue: "harness-multiturn", + workflowsPath: WORKFLOW, + activities: { + runTurnStep: async () => { + steps++ + return { ran: true, continue: false, step: 1, promotion: null } + }, + }, + }) + + await worker.runUntil(async () => { + const handle = await env.client.workflow.signalWithStart("sessionTurn", { + taskQueue: "harness-multiturn", + workflowId: "wf-multiturn", + args: ["ses_multiturn"], + signal: "wake", + signalArgs: [], + }) + await poll(() => steps === 1) // turn 1 drained + // Deterministically wait until the supervisor has actually PARKED in the idle timed wait -- + // i.e. its sleep timer is recorded in history -- before waking it, so we genuinely exercise + // the timed-wait/park-then-wake path (not a race where the wake lands during turn 1). + 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) // turn 2 drained on the SAME workflow (no leak poisoning it) + }) + + expect(steps).toBe(2) + } finally { + await env.teardown() + } + }, 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..59615eec6f2b --- /dev/null +++ b/packages/temporal/test/temporal-harness-smoke.test.ts @@ -0,0 +1,54 @@ +// A real Temporal test harness for the session workflow: it runs the ACTUAL sessionTurn workflow +// (workflow.ts + supervisor.ts) against @temporalio/testing's time-skipping server with +// a mock runTurnStep activity, entirely in-process -- no dev server, no provider, deterministic time. +// This is the harness for validating the Temporal supervisor's real behavior (draining, idle +// retirement, and -- as the suite grows -- interrupt/resume/join/continue-as-new). +// +// It also pins a regression: on @temporalio/workflow 1.21, condition(fn, timeout) called when fn is +// already true left the CancellationScope cancelled, so sessionTurn completed without ever draining +// a turn. The adapter now short-circuits an already-true predicate; this test fails without that fix +// (zero activities scheduled) and passes with it. +import { describe, it, expect } from "bun:test" +import { fileURLToPath } from "node:url" +import { TestWorkflowEnvironment } from "@temporalio/testing" +import { Worker } from "@temporalio/worker" + +const WORKFLOW = fileURLToPath(new URL("../src/workflow.ts", import.meta.url)) + +describe("temporal workflow harness", () => { + it("drives the real sessionTurn workflow through a wake-driven drain, then idles out", async () => { + const env = await TestWorkflowEnvironment.createTimeSkipping() + let steps = 0 + try { + const worker = await Worker.create({ + connection: env.nativeConnection, + namespace: env.namespace, + taskQueue: "harness-smoke", + workflowsPath: WORKFLOW, + activities: { + runTurnStep: async () => { + steps++ + return { ran: true, continue: false, step: 1, promotion: null } + }, + }, + }) + + await worker.runUntil(async () => { + const handle = await env.client.workflow.signalWithStart("sessionTurn", { + taskQueue: "harness-smoke", + workflowId: "wf-smoke", + args: ["ses_smoke"], + signal: "wake", + signalArgs: [], + }) + // The wake-driven drain runs the (mock) step; the supervisor then idles and, under + // time-skipping, the 5-minute idle timer fast-forwards and the workflow completes. + await handle.result() + }) + + expect(steps).toBe(1) + } finally { + await env.teardown() + } + }, 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..7a2015068cba --- /dev/null +++ b/packages/temporal/test/temporal-owner-token.test.ts @@ -0,0 +1,23 @@ +// Regression for the event-log owner-token collision: activity attempt numbers restart at 1 per +// step, so a run-id+attempt token repeats across steps and lets a zombie attempt from an earlier +// step re-match the current owner. The token must be unique per activity execution. +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)", () => { + // The bug: step 1 attempt 1 and step 2 attempt 1 both minted `run#1`. With the activity id in + // the token they are disjoint, so a zombie from step 1 cannot re-authorize against step 2. + 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 }