diff --git a/.oxlintrc.json b/.oxlintrc.json deleted file mode 100644 index 0b117ad..0000000 --- a/.oxlintrc.json +++ /dev/null @@ -1,165 +0,0 @@ -{ - "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": ["typescript", "import", "oxc", "eslint", "unicorn", "node"], - "options": { - "reportUnusedDisableDirectives": "error", - "denyWarnings": true, - "typeAware": true, - "typeCheck": true - }, - "categories": { - "correctness": "error", - "suspicious": "error", - "perf": "error" - }, - "rules": { - // Custom Rules - "no-restricted-imports": [ - "error", - { - "paths": [ - { - "name": "node:crypto", - "message": "Use Effect Crypto service and provide a platform layer at the boundary." - }, - { - "name": "crypto", - "message": "Use Effect Crypto service and provide a platform layer at the boundary." - }, - { - "name": "node:fs", - "message": "Use Effect FileSystem service and provide a platform layer at the boundary." - }, - { - "name": "fs", - "message": "Use Effect FileSystem service and provide a platform layer at the boundary." - }, - { - "name": "node:path", - "message": "Use Effect Path service and provide a platform layer at the boundary." - }, - { - "name": "path", - "message": "Use Effect Path service and provide a platform layer at the boundary." - }, - { - "name": "bun", - "message": "Use Effect services and provide a platform layer at the boundary." - }, - { - "name": "@opencode-ai/sdk/v1", - "message": "Import from '@opencode-ai/sdk/v2' instead of v1 for better type safety and features." - }, - { - "name": "@opencode-ai/sdk", - "message": "Import from '@opencode-ai/sdk/v2' instead of the root SDK package for better type safety and features." - } - ] - } - ], - - // TypeScript Rules - "typescript/consistent-type-assertions": [ - "error", - { - "assertionStyle": "never" - } - ], - "typescript/consistent-type-imports": [ - "error", - { - "fixStyle": "inline-type-imports" - } - ], - "typescript/no-confusing-void-expression": "error", - "typescript/no-deprecated": "error", - "typescript/no-explicit-any": "error", - "typescript/no-import-type-side-effects": "error", - "typescript/no-misused-promises": "error", - "typescript/no-namespace": "error", - "typescript/no-non-null-assertion": "error", - "typescript/no-require-imports": "error", - "typescript/no-unnecessary-condition": "error", - "typescript/no-unnecessary-type-assertion": "error", - "typescript/no-unnecessary-type-constraint": "error", - "typescript/no-unsafe-argument": "error", - "typescript/no-unsafe-assignment": "error", - "typescript/no-unsafe-call": "error", - "typescript/no-unsafe-member-access": "error", - "typescript/no-unsafe-return": "error", - "typescript/no-unsafe-type-assertion": "error", - "typescript/no-unused-expressions": "error", - "typescript/no-unused-vars": [ - "error", - { - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_" - } - ], - "typescript/no-useless-empty-export": "error", - "typescript/no-var-requires": "error", - "typescript/only-throw-error": "error", - "typescript/prefer-nullish-coalescing": "error", - "typescript/prefer-promise-reject-errors": "error", - "typescript/promise-function-async": "error", - "typescript/require-await": "error", - "typescript/restrict-plus-operands": "error", - "typescript/return-await": "error", - "typescript/switch-exhaustiveness-check": [ - "error", - { - "considerDefaultExhaustiveForUnions": true - } - ], - "typescript/use-unknown-in-catch-callback-variable": "error", - - // Import Rules - "import/consistent-type-specifier-style": ["error", "prefer-top-level"], - "import/no-commonjs": "error", - "import/no-cycle": "error", - "import/no-duplicates": "error", - "import/no-empty-named-blocks": "error", - "import/no-self-import": "error", - - // ESLint Rules - "eslint/default-case-last": "error", - "eslint/eqeqeq": "error", - "eslint/no-console": "error", - "eslint/no-fallthrough": "error", - "eslint/no-param-reassign": "error", - "eslint/no-regex-spaces": "error", - "eslint/no-self-compare": "error", - "eslint/no-throw-literal": "error", - "eslint/no-unneeded-ternary": "error", - "eslint/no-useless-concat": "error", - "eslint/no-useless-constructor": "error", - "eslint/no-var": "error", - - // Oxlint Rules - "oxc/misrefactored-assign-op": "error", - - // Unicorn Rules - "unicorn/no-abusive-eslint-disable": "error", - "unicorn/no-accessor-recursion": "error", - "unicorn/no-console-spaces": "error", - "unicorn/no-instanceof-array": "error", - "unicorn/number-literal-case": "error", - "unicorn/prefer-array-flat-map": "error", - "unicorn/prefer-array-some": "error", - "unicorn/prefer-modern-math-apis": "error", - "unicorn/prefer-node-protocol": "error", - "unicorn/prefer-string-slice": "error", - "unicorn/throw-new-error": "error" - }, - "env": { - "builtin": true - }, - "overrides": [ - { - "files": ["alchemy.run.ts"], - "rules": { - "typescript/no-unsafe-assignment": "off" - } - } - ] -} diff --git a/README.md b/README.md index 70ffe53..3893289 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,12 @@ Local checks: pnpm run check ``` +`pnpm run lint` runs type-aware Oxlint with TypeScript and Effect diagnostics. +`pnpm install` patches Oxlint with `@effect/tsgo`; keep the exact +`@effect/tsgo`, `typescript`, `oxlint`, and `oxlint-tsgolint` versions aligned. +TypeScript remains an unpatched, typecheck-only path so Effect diagnostics are +not reported twice. + Infra deploy test: ```bash diff --git a/knip.json b/knip.json index 9bd84f6..9ba749f 100644 --- a/knip.json +++ b/knip.json @@ -1,5 +1,4 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["stacks/**/*.ts"], - "ignoreDependencies": ["@effect/language-service"] + "entry": ["stacks/**/*.ts"] } diff --git a/oxlint.config.ts b/oxlint.config.ts new file mode 100644 index 0000000..e3bac43 --- /dev/null +++ b/oxlint.config.ts @@ -0,0 +1,154 @@ +import { recommended, style } from "@effect/tsgo/oxlint-presets"; +import { defineConfig } from "oxlint"; + +export default defineConfig({ + extends: [recommended, style], + plugins: ["import", "vitest"], + options: { + reportUnusedDisableDirectives: "error", + denyWarnings: true, + typeAware: true, + typeCheck: true, + }, + categories: { + correctness: "error", + suspicious: "error", + perf: "error", + }, + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "node:crypto", + message: "Use Effect Crypto service and provide a platform layer at the boundary.", + }, + { + name: "crypto", + message: "Use Effect Crypto service and provide a platform layer at the boundary.", + }, + { + name: "node:fs", + message: "Use Effect FileSystem service and provide a platform layer at the boundary.", + }, + { + name: "fs", + message: "Use Effect FileSystem service and provide a platform layer at the boundary.", + }, + { + name: "node:path", + message: "Use Effect Path service and provide a platform layer at the boundary.", + }, + { + name: "path", + message: "Use Effect Path service and provide a platform layer at the boundary.", + }, + { + name: "bun", + message: "Use Effect services and provide a platform layer at the boundary.", + }, + { + name: "@opencode-ai/sdk/v1", + message: + "Import from '@opencode-ai/sdk/v2' instead of v1 for better type safety and features.", + }, + { + name: "@opencode-ai/sdk", + message: + "Import from '@opencode-ai/sdk/v2' instead of the root SDK package for better type safety and features.", + }, + ], + }, + ], + + "eslint/default-case-last": "error", + "eslint/eqeqeq": "error", + "eslint/max-params": ["error", 3], + "eslint/no-console": "error", + "eslint/no-else-return": "error", + "eslint/no-param-reassign": "error", + "eslint/no-regex-spaces": "error", + "eslint/no-self-compare": "error", + "eslint/no-var": "error", + + "import/consistent-type-specifier-style": ["error", "prefer-top-level"], + "import/no-commonjs": "error", + "import/no-cycle": "error", + "import/no-duplicates": "error", + "import/no-dynamic-require": "error", + "import/no-unassigned-import": "error", + + "typescript/consistent-type-assertions": [ + "error", + { + assertionStyle: "never", + }, + ], + "typescript/consistent-type-imports": [ + "error", + { + prefer: "type-imports", + fixStyle: "separate-type-imports", + }, + ], + "typescript/explicit-module-boundary-types": [ + "error", + { + allowHigherOrderFunctions: false, + }, + ], + "typescript/no-confusing-void-expression": "error", + "typescript/no-deprecated": "error", + "typescript/no-explicit-any": "error", + "typescript/no-import-type-side-effects": "error", + "typescript/no-misused-promises": "error", + "typescript/no-namespace": "error", + "typescript/no-non-null-assertion": "error", + "typescript/no-require-imports": "error", + "typescript/no-unnecessary-condition": "error", + "typescript/no-unsafe-argument": "error", + "typescript/no-unsafe-assignment": "error", + "typescript/no-unsafe-call": "error", + "typescript/no-unsafe-member-access": "error", + "typescript/no-unsafe-return": "error", + "typescript/only-throw-error": "error", + "typescript/prefer-nullish-coalescing": "error", + "typescript/prefer-promise-reject-errors": "error", + "typescript/require-await": "error", + "typescript/restrict-plus-operands": "error", + "typescript/return-await": "error", + "typescript/strict-void-return": "error", + "typescript/switch-exhaustiveness-check": [ + "error", + { + considerDefaultExhaustiveForUnions: true, + }, + ], + "typescript/use-unknown-in-catch-callback-variable": "error", + + "unicorn/no-abusive-eslint-disable": "error", + "unicorn/no-instanceof-array": "error", + "unicorn/no-useless-promise-resolve-reject": "error", + "unicorn/no-useless-undefined": "error", + "unicorn/prefer-array-some": "error", + "unicorn/prefer-modern-math-apis": "error", + "unicorn/prefer-node-protocol": "error", + "unicorn/prefer-string-slice": "error", + "unicorn/throw-new-error": "error", + + "vitest/expect-expect": [ + "error", + { + additionalTestBlockFunctions: ["it.effect"], + assertFunctionNames: ["assert", "assertType", "expect", "expectTypeOf"], + }, + ], + "vitest/no-standalone-expect": [ + "error", + { + additionalTestBlockFunctions: ["it.effect"], + }, + ], + }, +}); diff --git a/package.json b/package.json index 1244027..a67d52e 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,13 @@ "type": "module", "module": "alchemy.run.ts", "scripts": { - "prepare": "lefthook install || true && effect-tsgo patch", + "prepare": "effect-tsgo patch --no-typescript --oxlint && (lefthook install || true)", "test:unit": "vitest run test/unit", "test:e2e": "vitest run test/e2e", "typecheck": "tsc --noEmit", "knip": "knip", - "lint": "oxlint", - "lint:fix": "oxlint --fix", + "lint": "oxlint .", + "lint:fix": "oxlint . --fix", "format": "oxfmt", "format:fix": "oxfmt --write .", "deploy:dev": "alchemy deploy", @@ -22,20 +22,20 @@ "dependencies": { "@effect/platform-browser": "4.0.0-beta.102", "@effect/platform-node": "4.0.0-beta.102", - "@opencode-ai/sdk": "^1.18.11", + "@opencode-ai/sdk": "^1.18.15", "alchemy": "2.0.0-beta.67", "effect": "4.0.0-beta.102" }, "devDependencies": { - "@effect/tsgo": "^0.24.3", + "@effect/tsgo": "0.36.1", "@effect/vitest": "4.0.0-beta.102", - "@types/node": "^26.1.2", - "knip": "^6.31.0", + "@types/node": "^26.2.0", + "knip": "^6.32.0", "lefthook": "^2.1.10", - "oxfmt": "^0.61.0", - "oxlint": "^1.76.0", - "oxlint-tsgolint": "^7.0.2001", - "typescript": "^7.0.2", + "oxfmt": "^0.62.0", + "oxlint": "1.77.0", + "oxlint-tsgolint": "7.0.2001", + "typescript": "7.0.2", "vitest": "^4.1.10" }, "packageManager": "pnpm@11.18.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23f203e..fca11d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,45 +18,45 @@ importers: specifier: 4.0.0-beta.102 version: 4.0.0-beta.102(effect@4.0.0-beta.102)(ioredis@5.11.1) '@opencode-ai/sdk': - specifier: ^1.18.11 - version: 1.18.11 + specifier: ^1.18.15 + version: 1.18.15 alchemy: specifier: 2.0.0-beta.67 - version: 2.0.0-beta.67(patch_hash=d5ede49c8eda79ec4b835ebb1a2cfe6443e095f5c26a656095e225c4817cb436)(@effect/platform-node@4.0.0-beta.102(effect@4.0.0-beta.102)(ioredis@5.11.1))(@types/node@26.1.2)(effect@4.0.0-beta.102)(typescript@7.0.2)(vite@8.1.3(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.2)(vite@8.1.3(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.0) + version: 2.0.0-beta.67(patch_hash=d5ede49c8eda79ec4b835ebb1a2cfe6443e095f5c26a656095e225c4817cb436)(@effect/platform-node@4.0.0-beta.102(effect@4.0.0-beta.102)(ioredis@5.11.1))(@types/node@26.2.0)(effect@4.0.0-beta.102)(typescript@7.0.2)(vite@8.1.3(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.2.0)(vite@8.1.3(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.0) effect: specifier: 4.0.0-beta.102 version: 4.0.0-beta.102 devDependencies: '@effect/tsgo': - specifier: ^0.24.3 - version: 0.24.3 + specifier: 0.36.1 + version: 0.36.1 '@effect/vitest': specifier: 4.0.0-beta.102 - version: 4.0.0-beta.102(effect@4.0.0-beta.102)(vitest@4.1.10(@types/node@26.1.2)(vite@8.1.3(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))) + version: 4.0.0-beta.102(effect@4.0.0-beta.102)(vitest@4.1.10(@types/node@26.2.0)(vite@8.1.3(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))) '@types/node': - specifier: ^26.1.2 - version: 26.1.2 + specifier: ^26.2.0 + version: 26.2.0 knip: - specifier: ^6.31.0 - version: 6.31.0 + specifier: ^6.32.0 + version: 6.32.0 lefthook: specifier: ^2.1.10 version: 2.1.10 oxfmt: - specifier: ^0.61.0 - version: 0.61.0 + specifier: ^0.62.0 + version: 0.62.0 oxlint: - specifier: ^1.76.0 - version: 1.76.0(oxlint-tsgolint@7.0.2001) + specifier: 1.77.0 + version: 1.77.0(oxlint-tsgolint@7.0.2001) oxlint-tsgolint: - specifier: ^7.0.2001 + specifier: 7.0.2001 version: 7.0.2001 typescript: - specifier: ^7.0.2 + specifier: 7.0.2 version: 7.0.2 vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.2)(vite@8.1.3(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.10(@types/node@26.2.0)(vite@8.1.3(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0)) packages: @@ -300,43 +300,43 @@ packages: peerDependencies: effect: ^4.0.0-beta.102 - '@effect/tsgo-darwin-arm64@0.24.3': - resolution: {integrity: sha512-eO83D7ZmpAsocA5WJQ8SSAPnAOZ+dEduFIozg9c2SmH36PCHG1Eb++I0UiQml+2LAr4o/QE2wKdWj7LExebjZA==} + '@effect/tsgo-darwin-arm64@0.36.1': + resolution: {integrity: sha512-jhoWW+d06Dvl9+LanikNBu9oyqFsN3xuuTMo+h6/yCbR/DvUICbs8KRJrWaq9IcHLiXE/2OySxljysDhT1yvsw==} cpu: [arm64] os: [darwin] - '@effect/tsgo-darwin-x64@0.24.3': - resolution: {integrity: sha512-ksX9BdCM8289RRuN0lWhtzuvstH1frxwdxhoW08rrGW3ryER3udEFqEtgtveP0cYvK9gTlV5lXbQPNGktQ2sTw==} + '@effect/tsgo-darwin-x64@0.36.1': + resolution: {integrity: sha512-owLy90/P4xDgXC9L7gv7cvzPxO3mnjgH0WU0vV57S58U+aGzmJSP4bWgeMPc26n/t248BzkIjROc4jGgXtCtkA==} cpu: [x64] os: [darwin] - '@effect/tsgo-linux-arm64@0.24.3': - resolution: {integrity: sha512-CMiThuURi14rT5Ikag5pVnUC3tx4IcmPpRR4WBQkHxAbOf5a1Lo1NUF82zY0azhLW2WszBamTSZP5Tcv4uks2A==} + '@effect/tsgo-linux-arm64@0.36.1': + resolution: {integrity: sha512-7aelNsnRISKOUGFfNP+1vx3VO09EiAG5XBfdGuOsDUoSFGNjlV9vIsXzA1+bWK1h4QKia3xp4VESgyQFlD/80g==} cpu: [arm64] os: [linux] - '@effect/tsgo-linux-arm@0.24.3': - resolution: {integrity: sha512-0TzebGLMNZkHOGSK0w1B4zuvHGlA+Od4LdElWeHHP6iMPEiEWPq17If787+oc+Y67yITPJ9DZcibu69eI9mZhg==} + '@effect/tsgo-linux-arm@0.36.1': + resolution: {integrity: sha512-K8ccWMpt3Hj6XnrEVIg5HU7u13XnYrQqCs9MSFF/yA19YQqrJmtXB1UL9vfzb3v6vSeFblNTf6uxAROnieSAtA==} cpu: [arm] os: [linux] - '@effect/tsgo-linux-x64@0.24.3': - resolution: {integrity: sha512-R4jl1JWoYEldUU1rtCG1fXWW2ywj/rgY1xaCzK8HSb8fQhQ71WRLLAyAe8ewROuseWQ9Ip+IbbfAeXQOpmpdDA==} + '@effect/tsgo-linux-x64@0.36.1': + resolution: {integrity: sha512-qlrOByxdpnh1Usn9jzNRB08F6zuEVSKjCr4FVI2wfssKMriVEsWjdEggLiUA+Lkk7/HSK0cWiC4ewMqXDVg6VA==} cpu: [x64] os: [linux] - '@effect/tsgo-win32-arm64@0.24.3': - resolution: {integrity: sha512-DOJ4oa8pk8qxEQwKHpJOqtjOfB9iOMuc6Cnb2ZbTG32PBfhqeRYv6YFGhFtgAojXyxfuRP2gCJVpFfjzHJh7nQ==} + '@effect/tsgo-win32-arm64@0.36.1': + resolution: {integrity: sha512-SGlynbRz97xpdoCiP3g1jnDWG6z9GP+jeptz0jFTB/x7TbGIGopTaueckoNplKRbaRPnPIjLjaQz7Ng7WcifWg==} cpu: [arm64] os: [win32] - '@effect/tsgo-win32-x64@0.24.3': - resolution: {integrity: sha512-6GpENpPn3mXldmJoM+Z7qi+8fe39xJYB2bLD5jqCFWl14vFp1AeJvYwHYQak8HRm1FiPz72Ninslv8DjHYtZgA==} + '@effect/tsgo-win32-x64@0.36.1': + resolution: {integrity: sha512-+rDjvR1Z48hNeoolBD2c6JDIfKeHIZZLCErtxBBff6ja06vXmBP8F62rCFnN0CC7gKvgussNHy4MM+WWNjLEqg==} cpu: [x64] os: [win32] - '@effect/tsgo@0.24.3': - resolution: {integrity: sha512-WQxKU3MFzWzI38GbE9cf0POkVX4y0xahD8QqDjLfixW1AEValuHNfOQvyKM2MDWOLdGff4hP8VnIOeduwQt66A==} + '@effect/tsgo@0.36.1': + resolution: {integrity: sha512-w52AXlRYFEJHU39FhL68dcFCGDKW7qTkrCMUWWlCMYkFoOVFvwp+4qVM4OcrqPeNFizmhqEGHGrvUabaNf086g==} hasBin: true '@effect/vitest@4.0.0-beta.102': @@ -576,8 +576,8 @@ packages: resolution: {integrity: sha512-da6KbdNCV5sr1/txD896V+6W0iamFWrvVl8cHkBSPT+YlvmT3DwXa4jxZnQc+gnuTEqSWbBeoSZYTayXH9wXcw==} engines: {node: '>= 20'} - '@opencode-ai/sdk@1.18.11': - resolution: {integrity: sha512-yDImmNv4PhxdMgtiHVNWQWEVwQlAm7Dr0y4XU7CT4dOIbzgO+VP+9I02lAP7Zva1FhGeyI7oKMI2tzB9RUsWaQ==} + '@opencode-ai/sdk@1.18.15': + resolution: {integrity: sha512-8sfo9nGiVwesAZW9Wqkvynyn7w4wYaHx1O9qOHpYL65+Bs2XUpHP3kBbZe58gjQydFgk6I74kUF7sqCiPu2arQ==} '@oxc-parser/binding-android-arm-eabi@0.142.0': resolution: {integrity: sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ==} @@ -815,124 +815,124 @@ packages: cpu: [x64] os: [win32] - '@oxfmt/binding-android-arm-eabi@0.61.0': - resolution: {integrity: sha512-BaS+1OVvg9sr+Xav0+KdWedQRcAzrdoEcwMZeqoc2F6ieC1s/t5eM35YQoRPQ7vAqkZ+p3tbQb1r9I9mrV5oGA==} + '@oxfmt/binding-android-arm-eabi@0.62.0': + resolution: {integrity: sha512-pdsv0C4gPjJ8H1+sd8u0BDx+yLACTL+rgeMIOL1ln4ihSnhw8CWXtYWgvcSkyTfgGBIzFKab+d8rx9Xl4en/Kw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.61.0': - resolution: {integrity: sha512-of8atAV0M1egGcVOMbgZCvc10sFOP3ayQBNQV5h5G3fNq8gACdEswfFk9bzGrdbM23rtg0Coxi7np7oPLcueNw==} + '@oxfmt/binding-android-arm64@0.62.0': + resolution: {integrity: sha512-WC3YQ7uS/KtDrjmqwBviwFKe9qeoi+eXx8aX1z/ffG23Md75myjrJaQqTuJvdOLPoa4EYTjDWH0dHXfwulCVog==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.61.0': - resolution: {integrity: sha512-7l8+5ov4BGwtAcmpzvEik/TG3bciwyw/S3e6j5GKH7pcQqcgCVxD3AuJeP6upto+SOTBKQ4wrrdbMt0gq8fHSQ==} + '@oxfmt/binding-darwin-arm64@0.62.0': + resolution: {integrity: sha512-GM8Yf3LjjaR1I8PD0SfeoIlwhsh9GvSF+cQ8sf624Yxnjsyumn95aFzYfKJVefblfDIiOAnZ7QVm2sa21Er/0Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.61.0': - resolution: {integrity: sha512-Fnz4dDDXBb7udk+DmwelNjxbD6yptyxwCqwCH2ebo4RVLxVsRfFsn/AHJC49KIltPrVokamGv4SSOsiV50DTxQ==} + '@oxfmt/binding-darwin-x64@0.62.0': + resolution: {integrity: sha512-d5THp7F8bCxLqNogEXDORRsQD6dosf3EyFtnXfBer6v+8tGdcWIjoDX9WaXrrF/26zOmL8qHpPTKCEvpBDmZkQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.61.0': - resolution: {integrity: sha512-mddOebKNCP+AucmzfNsk3jgbr681qAUvgMqi865GW5gWLJ/AnzXbvjQRrny0e++NAN8aphav/aRSrfFxNsNjpA==} + '@oxfmt/binding-freebsd-x64@0.62.0': + resolution: {integrity: sha512-1DnrtXGZooOZ0fHgAXZUaDQzBVh1CM2MNW4oBXyQ2aWKvCHjyljvT9fgBkOM0fEOb96X5eqtcfJ0YUVt9jj66g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.61.0': - resolution: {integrity: sha512-svx59iYL+DbaZGZUIoice4W0CjRXGExnbz7Re+awIb60rVxBS2KrU7Hnlx+nZYanLGLpjneUEgo/VFEKkSZAyQ==} + '@oxfmt/binding-linux-arm-gnueabihf@0.62.0': + resolution: {integrity: sha512-4pQDHOYRH+Huqe0StIaWyvk2CVl/aTaqSrbZpA3/pLS2xH24ME7lBgYprhQF2fRkHBzhGGGKliwxFsDdHwx59g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.61.0': - resolution: {integrity: sha512-BYK9MPJPCf6d+fLKMTruThmEyCtHzQ1zLcsrTlUVkmnoXIaHAbfpeLYQwX1tkjs7W11dyzoi6HFvKcdnvX1zNg==} + '@oxfmt/binding-linux-arm-musleabihf@0.62.0': + resolution: {integrity: sha512-X0jAaZJFMCVKhB6YyWVTQ/wN2DLsBcZKSMqTS76bF6riT+XZdtg2FPEdjDvdVbunO9cG+tWiVaEs4Zs38lxYog==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.61.0': - resolution: {integrity: sha512-QUaCNLq2/EC6G5ljOuFanl9Lgw6ZWp4co7rs4+KOMUzbGfA4Lq58FHRjjF9sVIG+93XSbo343MxFATrOU1qctA==} + '@oxfmt/binding-linux-arm64-gnu@0.62.0': + resolution: {integrity: sha512-682Z8T5s8T5ATArYtsejKvbIfd8LEAXyyDkKkoZVq8HND7Vx8TYLlrDjDSeYfodMeVwHOgkj13lJYR8cj6vUSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-arm64-musl@0.61.0': - resolution: {integrity: sha512-S6uvJ6MXnRXl+zTs0CARNDvkE+cymj0EVWEKKsyKnlLlqTyQJBjw5s4D2pSIOZc+S46cy4STefzcr/sm0VzVPA==} + '@oxfmt/binding-linux-arm64-musl@0.62.0': + resolution: {integrity: sha512-lk25fAl7KWaLWVJcW0CHEXB7QlQZtx5eDkjpaGMK0hzXTjUe0Wmlu8IKuFHoviSOcEJedRTs4VE/506VqGxGew==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-ppc64-gnu@0.61.0': - resolution: {integrity: sha512-6VDlRcytvZG6UlSIdAFKDLbppo9tvPxrWzle6vHldYFMeuDPQEfMKrkwezp7FaBq1wik9ra554ZZeRPsyIkFpg==} + '@oxfmt/binding-linux-ppc64-gnu@0.62.0': + resolution: {integrity: sha512-SFyNqHQLwySceWNLhiSldx7wPXRAzP0L0WcW9GegP3uWrpZGJiZlQO85NbHAFPEfxR9PhZ9qSnZryEh7+v+4Gw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-gnu@0.61.0': - resolution: {integrity: sha512-KkBTYbzExpbmn15XjKPLu2fRV2PVlq+KWt+brad5rwIa03vdYoaDRWiS7raHII/dCTR6Ro4UpYUCH4t6lif4WQ==} + '@oxfmt/binding-linux-riscv64-gnu@0.62.0': + resolution: {integrity: sha512-KYj55C1ywJfHo6+aKDuEmUtVEdJALsC5GwayDGsI6FGz2GxFqNr/mA8nxVsNbJzm7sE5MRqTQ9ziImSzhYXysA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-musl@0.61.0': - resolution: {integrity: sha512-69tzIq7sJLVB9dxYYtvMzcSSsnZHSO+U2U19O2RqDqgj6+Q4O7HjSXdaszbcgqzhsUwzSH7z5kWvk8nmf6BHTg==} + '@oxfmt/binding-linux-riscv64-musl@0.62.0': + resolution: {integrity: sha512-BhZDNo5GOU5nC378RhD0/XpvaEBHsH3HLgJp8YZX3A0InC7oivzA63HsRmiXFLtLSHAstEVrDf6fbC7Rs8Jh/A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-s390x-gnu@0.61.0': - resolution: {integrity: sha512-Oqi/N0OvtOVXsPKAOOhKgGH3msRYF8BLJaNBbWiupRiKoKVyc8JRKPCfarkQJC+RgP9U8raUKLe+bNwd0HUMiA==} + '@oxfmt/binding-linux-s390x-gnu@0.62.0': + resolution: {integrity: sha512-UyAFmyHkgSgUJ/wOM4p3U8AC2yAFvRH5PNBs7TnK0fObTT/XSWcdr/lAzPSWaekHaZFaMeFZyk9n93Joq3J93A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-gnu@0.61.0': - resolution: {integrity: sha512-3TKwv/ed4uwJSemAA8P9XcoqETpjQI4waquF9UilhA9Mn/dhr1PdUEXWlL74mtc6ZNfmKPA9+NEJm01nRF8CVA==} + '@oxfmt/binding-linux-x64-gnu@0.62.0': + resolution: {integrity: sha512-1iYMP0leytWazFubD/WnINJuIrzRPuoL1aWEJdlGezEzDbTxcd29R4r8IUzP2oWeKst5V02uMJgR2NILlPlG6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-musl@0.61.0': - resolution: {integrity: sha512-uFso4u4nLkVSlMCpgjyvWV60Gt7GvDQHnk1mmRxHIkZTMB0ljpUKwCD9FYGgN9H97x2wYl0UwEjgRZaPIuhEhw==} + '@oxfmt/binding-linux-x64-musl@0.62.0': + resolution: {integrity: sha512-4rA/URtJSTVNVAQz6Q8wf7SaRvOXVy+TizriT9hs/Y1XhLR/R+92uWKRQG8yFWRAIEBbFHJ6WevQcl/G9SXEfw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxfmt/binding-openharmony-arm64@0.61.0': - resolution: {integrity: sha512-keGLkzeOvkMpNmPp4hffXWpfoSsY6e1K8++KXD4mSSfxdvM8q9QUDsYY689TB1k6Co832DZn1MnaaVx6cIBMWQ==} + '@oxfmt/binding-openharmony-arm64@0.62.0': + resolution: {integrity: sha512-mSZuFHU2ar1KLUjXpI2QBQcJ1VsOB3mOCgQXuXCpKs19dgh4u+OaovNfrWDfiJb+ihJ2+f7YFcaO9bS2dlTCXA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.61.0': - resolution: {integrity: sha512-VzsAISkFxmNhJ5LBDEL9VuH6tJsVJMtqYit2LyIUf/HLnsCe4Pg9SMOjjVQzGWt0bnpyfJ94CrqTqcpNZzK+ug==} + '@oxfmt/binding-win32-arm64-msvc@0.62.0': + resolution: {integrity: sha512-OfwuhkcjDlqC4EgDojtiV9mzpLqeB9KqTOWPOjLEYBVdDCVSxqW3qzp/xcIxsbtI0UgGCnKvAqYKyY25kf5JZw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.61.0': - resolution: {integrity: sha512-xv4t7yzwJoYaLB6Zv28B3W+j7brEjsyv50rLTAQgmxJzddce9fAMCxed8dSAkbWES0zz2J29nYK5FaTuD2YBHg==} + '@oxfmt/binding-win32-ia32-msvc@0.62.0': + resolution: {integrity: sha512-P9uDDNFRzghO3X8QAzhkjKhK7JvtABsVn8UYtFX7uor12IAnwNt8nNIctvfWj1JkQU/kE+fmLRPiw7XlrIHsZw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.61.0': - resolution: {integrity: sha512-6EZXFkqOwxdDYjIn3TSNnPk3ST5E5GiYd4FiM6UF/mCL/LZSfr6D6UygTfW3R1PCQP2quCKpCEGRlij8E3VYbg==} + '@oxfmt/binding-win32-x64-msvc@0.62.0': + resolution: {integrity: sha512-dlI5SY7XYQCiCBafntWagCR6HcAJB/NpsLtdlPx8x08+Osz8Ok1HHz1GZuusegCe/VoJ6pAnF5a4pd5OZAq7qQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -967,124 +967,124 @@ packages: cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.76.0': - resolution: {integrity: sha512-ZHIE5Zt9AsPDcY4nOlofXt0YfneEeo+QrKMPcPzLf2Z6Q8VtV2W73d7SFJ920WUwyik783u/doKCs3KXdwG+7w==} + '@oxlint/binding-android-arm-eabi@1.77.0': + resolution: {integrity: sha512-E06sKWS6PiI6HRxS1wyQg22HvApt01hI7fV+T3wUk3OSbaaP4a3hYGY/MIQDmASqCiRjBdpRQYkgMkqH82cWmQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.76.0': - resolution: {integrity: sha512-shm/ngQilHK6bs+ElJWa4oHfNj5vL1Gl/iVEJldTQjpr0/67oSgr0KUpbmcnLig5Fo0v/l6j2567A7TOL89ONA==} + '@oxlint/binding-android-arm64@1.77.0': + resolution: {integrity: sha512-NvsKz0KZxTp9cYWPLf+FXaSZwB3oO3peAjtukpOMBgse2vhQSoIIVqeO1yR0lEo/UcdZIDL18uq+kL0LzQ0ytA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.76.0': - resolution: {integrity: sha512-rvJmrAPKSQ9aWJ6wIS6CK2tJjwzfW0ApQH9qokq6sfDvmHwoyIHxHFMq7z7i7GiV6fdE6s8qvBqWKPTu8RmT6Q==} + '@oxlint/binding-darwin-arm64@1.77.0': + resolution: {integrity: sha512-bgjTn6nW4bQCFBvSvuHCpDD+sONvmpo4lGI4PxzMt1quBA+xYxhczk6RiCn3GZ9gY8uhaBbwhj9MdKGfu6T9DA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.76.0': - resolution: {integrity: sha512-U/zYdb7VYKGY6pA9Vd2rYl9O/HlCylcOlb5PGPvVLtg+oLGsk6H3XGKEMHKyqD3nmmtmlmwb/8SwU2vfSAtvMw==} + '@oxlint/binding-darwin-x64@1.77.0': + resolution: {integrity: sha512-aotaIttH1R6j1Rwhx0M0htgeZyGtVQqYNTVEYMN/UcgHPquGA6kmk9OyuDc3a2GKUQBC+3C3GVQCcrRPMYqAFA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.76.0': - resolution: {integrity: sha512-WvKG9CAriuo0XNiFzpXjDngUZcRGFNpaK2kLyMUsnJlShxkT96u+BpJQ3KqdQwGOrvI14L6V8bAwXwAYNNY6Jg==} + '@oxlint/binding-freebsd-x64@1.77.0': + resolution: {integrity: sha512-nNx/wta7ksRAdYvq+l4AWjXkLxEXHALhENxjj2cYbQAIR4ybaA5L+hCbE63HOmft5czQ6ks+hb8vmEAnn7YGPg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.76.0': - resolution: {integrity: sha512-qJ5+RH99TqFRq3UCDxkW0zJJu9c+OAHFY72vGlxZLEpuO+MpKo3POgqb8sYipL9KYm8XY6ofb0HsOuvY6hQNqQ==} + '@oxlint/binding-linux-arm-gnueabihf@1.77.0': + resolution: {integrity: sha512-tMLLjM7xXtzXisVCzkOTXNCy9bZVId2wteNwjohlFDR/jY6WagpEDA1c1wu4xRc20Hojaxj+V6DSR7gbKxijWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.76.0': - resolution: {integrity: sha512-PvPCVptkgVARsucgIqFQQcSmJ6xc6GtnVB5bRBekRahTc9eObMtjHfMjy5M+C2tHt5UCMttWM9RuSk/H9NqYeg==} + '@oxlint/binding-linux-arm-musleabihf@1.77.0': + resolution: {integrity: sha512-MiAFDFaqR0tmHTAyo0YDcZ5hyLREdYw/RQhc2R3cbT+8O3tB+zqPM2th9TTQ+Uo3jn/embS+DO+HyX9ztCPkOQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.76.0': - resolution: {integrity: sha512-3KeFDx8Bu4HPAXbuHZOr/oHvN+QT+JQhMw/NYPz7Z071xLSsG27Jfh9PIQVEY7hk1I+jr43ExqRIeJ6VKk2yLw==} + '@oxlint/binding-linux-arm64-gnu@1.77.0': + resolution: {integrity: sha512-/xqQ3B16i1T4cyt/9Mn+4CpzhUXoBXp7kVpIwzOXNFLj5JmK1bIjsbSnX296Gg8A/o7oDtKWikFgBx0SLwztkw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.76.0': - resolution: {integrity: sha512-oPFkkKTgl0K/EIg9fQ8oA3IGcI05/Mq1en04iFa41mmNPT+6KEiByVazTOZZJiHMBBrbsns1YJ2e1Scqwzesjw==} + '@oxlint/binding-linux-arm64-musl@1.77.0': + resolution: {integrity: sha512-LSbwuRKiNCenPDcbARqAZ5RfBy7gmj7vOvfJRLeCDU3gFtSxWbhv/+VTlaUqzUhNj1gFLHB8h7ALnxa/Az6z6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.76.0': - resolution: {integrity: sha512-gN7yZ0eqflA5Fhf1wvHxGUltIV3FsvmB1zhNMDEK9vSHhc7E6qg9CuPeBgPZab66Tjzq6w6kHAtNEvnTHf4cyw==} + '@oxlint/binding-linux-ppc64-gnu@1.77.0': + resolution: {integrity: sha512-QWdcH31mXEUe5Nq1s0CfCpceaKjIo9uZtwDjAuL681g1axf+5x8xrg/eXWaw//4NCxYZ4V4e5Hu5tvdR+pTBlg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.76.0': - resolution: {integrity: sha512-S/HqMbn22mQrjtErUxEoS/a55u8kIeXvreIxiJu5G7Le3UecEd6SQZxrDIpuhtgaFnsY/nVra3ytP+pRljDilA==} + '@oxlint/binding-linux-riscv64-gnu@1.77.0': + resolution: {integrity: sha512-GnOfYgJxbcElOiPZaDFDl406ONddwvOWk2jvAAAEjwAl4GofNoHF+/HHUIBYa6bFCArlcGPi0XjC4cU1pkgF/Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.76.0': - resolution: {integrity: sha512-ZIga3097VJZolGZk6SrIAUokIGfRkxRlhiHDUznZptGBfwrhD7pNfD1rzEzsCwvk/1DX0A1bLz+liuNh5QKIVQ==} + '@oxlint/binding-linux-riscv64-musl@1.77.0': + resolution: {integrity: sha512-AyEMTUCf0xY+hHF+IxqXFQIX0yQOIR8ykpY0lJNOw9xYqOzUX8dyZfRvlG0RfXwuQn2eonf/8NrMmDSZJjdqsA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.76.0': - resolution: {integrity: sha512-ZGiiA7pFzMJSyMWYZTVlPgbTsx+Vl8ihLGMIujPwaslUF7kIPPWAbVmAlTc+9lWDV+DCiB8Ikixu+lSHeOIIWQ==} + '@oxlint/binding-linux-s390x-gnu@1.77.0': + resolution: {integrity: sha512-sPLzEcNvxd/oyVQ5oZo92CiHkFkpBeRop13E/P3TPY+hZfXHKCOWKI70TE2RYwMKFJDc20EMjH16L7NZICtKTw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.76.0': - resolution: {integrity: sha512-JLiy5WuvEBFTT6ErIFV35SLzi0R7Iri6MKU6dZbTxfIx8pndbbPs3Mj780nMipBFcPkti+okAPOJ9POKkHFEgg==} + '@oxlint/binding-linux-x64-gnu@1.77.0': + resolution: {integrity: sha512-1Oh2ssH2L7lwyvkdSqaMUfsGfwU2Wfvew+obBUYjRVqhpBcUpwnsPSEr1IzVi9XqkuY10geiLsNKecqaZC34Dw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.76.0': - resolution: {integrity: sha512-z7lgKQtbo/I1NIe8G5NHLesxJDv0tRSUWTpXKb9Pm3E9nKFKfO4IOSDtFroKgXtOYb0jQbcdH+0wzTyMXVes+A==} + '@oxlint/binding-linux-x64-musl@1.77.0': + resolution: {integrity: sha512-0j/2wRgNGO+Qj/M1uu/p57h/hFTTWWcfie0ufkbabeus2s5+/QqkCflnMOwLLN5m2GsNeWp4xdl4cPa4n7QCOQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.76.0': - resolution: {integrity: sha512-JOjKymIpb9QcYfEhZsN6h4V9Ivd474W38cNIBRv6bg2TbIvogbMTH0Mg6YWW9TiRDqfcX+/Hyfsbo5vcSE5guQ==} + '@oxlint/binding-openharmony-arm64@1.77.0': + resolution: {integrity: sha512-BJ/j54qS0usEnyDkLYURMj2iiD9h5Cyy+ppzeMSXBGRXaGRNWnj1Mw14NqWMR5E/PzdgB30OOCCzLzbRoduafw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.76.0': - resolution: {integrity: sha512-pqDWZiwcmByWUEm1NFUBNiT6aentCcaoMWJv0HbXEmuYermJ4sg8ppVrshubYP2MZ6SHccJJcpr6x469PuDFIw==} + '@oxlint/binding-win32-arm64-msvc@1.77.0': + resolution: {integrity: sha512-Yh8w+g2Lpx7StrvtYkoz9JJvXjB9wxgFChFNb85nrXm/wj/XTwGWS1hve9+900HL7llrntYB3YP+y32E3tRqzA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.76.0': - resolution: {integrity: sha512-Ba0O659kgMv6pwO3z9PdO+K3aMxQRaw9HnG+e6AtOfgwcKFvYilciQYBoUBmxfQvOCKZe1SwjMkuB542NkuDMQ==} + '@oxlint/binding-win32-ia32-msvc@1.77.0': + resolution: {integrity: sha512-zja5b7+6a7UsRFgAQSrnax5vrzliEyNPLCjfXONu/vTWswaIVZGFajJZptaeRvPE4LghtFdAzVFlexTm7MVTGA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.76.0': - resolution: {integrity: sha512-5qcirPHO8nKfkoowEVWtpAoVTcYDy6g0UT0NGic450Qv8J2NrOqg4uQ8QppRP4MDTC7Xx47lbZnmadTH03CGGA==} + '@oxlint/binding-win32-x64-msvc@1.77.0': + resolution: {integrity: sha512-+teyvPDZ2RjUvo+SuCqS/UhaJl1QtdW5fWT5NJTV61V5MIuIS90Db9LixmtEGvXixyttiK62P96MSu3UlpviBw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1274,11 +1274,8 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/node@26.1.0': - resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==} - - '@types/node@26.1.2': - resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + '@types/node@26.2.0': + resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} '@types/webidl-conversions@7.0.3': resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} @@ -1734,8 +1731,8 @@ packages: get-port-please@3.2.0: resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} - get-tsconfig@4.14.0: - resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + get-tsconfig@4.14.1: + resolution: {integrity: sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==} glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} @@ -1846,8 +1843,8 @@ packages: jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} - knip@6.31.0: - resolution: {integrity: sha512-NbeIEmUS2VUMjAkbiSNOKPJeV9wpCsr0660sUyKyMQbk4Iom0++nTLInVp4MJ+LfR4kORnw67bDi5tvO7YLnzA==} + knip@6.32.0: + resolution: {integrity: sha512-KDX9OmmOFmlvmxTkrx6Z0GHISMut+pXMSKR8eg84bovaxJKx2NdQD4JYCXveSbvieRe107W6vCD2xCpmz0qBYA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -2144,8 +2141,8 @@ packages: oxc-resolver@11.24.2: resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} - oxfmt@0.61.0: - resolution: {integrity: sha512-DxdHBEMYpcEnHoUHjjOigUqV2TYKsvxLwUPXnVYBjgFdqrcQ/91OtwubtZ2PUodCs3sStI8R5Qw3fKNGK4e8wQ==} + oxfmt@0.62.0: + resolution: {integrity: sha512-vxgGHTmnDU9j4CX7dDBLzxgmHxfda/yPcgJkGCMUSCwRmz+euo/V08xXLNgXTeqAB9Fhf3Pe2nO1RNKLCVgphQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -2161,8 +2158,8 @@ packages: resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==} hasBin: true - oxlint@1.76.0: - resolution: {integrity: sha512-6QoFioEU4fNdiUx/2Eo6TRd6NG7H7njnRCz8rhB66cZmMHDTqcm1Rjvl8Wry+ZTQMBAmyb4Mlf62Mk5X+eHSOw==} + oxlint@1.77.0: + resolution: {integrity: sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -2953,14 +2950,14 @@ snapshots: '@distilled.cloud/core': 0.30.3(effect@4.0.0-beta.102) effect: 4.0.0-beta.102 - '@distilled.cloud/cloudflare-rolldown-plugin@0.15.0(rolldown@1.1.5)(vite@8.1.3(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))(workerd@1.20260704.1)': + '@distilled.cloud/cloudflare-rolldown-plugin@0.15.0(rolldown@1.1.5)(vite@8.1.3(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))(workerd@1.20260704.1)': dependencies: '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260704.1) magic-string: 0.30.21 unenv: 2.0.0-rc.24 optionalDependencies: rolldown: 1.1.5 - vite: 8.1.3(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.1.3(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0) transitivePeerDependencies: - workerd @@ -2973,13 +2970,13 @@ snapshots: optionalDependencies: '@effect/platform-node': 4.0.0-beta.102(effect@4.0.0-beta.102)(ioredis@5.11.1) - '@distilled.cloud/cloudflare-vite-plugin@0.15.0(@distilled.cloud/cloudflare-runtime@0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.102))(@effect/platform-node@4.0.0-beta.102(effect@4.0.0-beta.102)(ioredis@5.11.1))(effect@4.0.0-beta.102))(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.102))(@effect/platform-node@4.0.0-beta.102(effect@4.0.0-beta.102)(ioredis@5.11.1))(effect@4.0.0-beta.102)(rolldown@1.1.5)(vite@8.1.3(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))(workerd@1.20260704.1)': + '@distilled.cloud/cloudflare-vite-plugin@0.15.0(@distilled.cloud/cloudflare-runtime@0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.102))(@effect/platform-node@4.0.0-beta.102(effect@4.0.0-beta.102)(ioredis@5.11.1))(effect@4.0.0-beta.102))(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.102))(@effect/platform-node@4.0.0-beta.102(effect@4.0.0-beta.102)(ioredis@5.11.1))(effect@4.0.0-beta.102)(rolldown@1.1.5)(vite@8.1.3(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))(workerd@1.20260704.1)': dependencies: '@distilled.cloud/cloudflare': 0.30.3(effect@4.0.0-beta.102) - '@distilled.cloud/cloudflare-rolldown-plugin': 0.15.0(rolldown@1.1.5)(vite@8.1.3(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))(workerd@1.20260704.1) + '@distilled.cloud/cloudflare-rolldown-plugin': 0.15.0(rolldown@1.1.5)(vite@8.1.3(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))(workerd@1.20260704.1) '@distilled.cloud/cloudflare-runtime': 0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.102))(@effect/platform-node@4.0.0-beta.102(effect@4.0.0-beta.102)(ioredis@5.11.1))(effect@4.0.0-beta.102) effect: 4.0.0-beta.102 - vite: 8.1.3(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.1.3(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0) optionalDependencies: '@effect/platform-node': 4.0.0-beta.102(effect@4.0.0-beta.102)(ioredis@5.11.1) transitivePeerDependencies: @@ -3035,41 +3032,41 @@ snapshots: '@cloudflare/workers-types': 5.20260728.1 effect: 4.0.0-beta.102 - '@effect/tsgo-darwin-arm64@0.24.3': + '@effect/tsgo-darwin-arm64@0.36.1': optional: true - '@effect/tsgo-darwin-x64@0.24.3': + '@effect/tsgo-darwin-x64@0.36.1': optional: true - '@effect/tsgo-linux-arm64@0.24.3': + '@effect/tsgo-linux-arm64@0.36.1': optional: true - '@effect/tsgo-linux-arm@0.24.3': + '@effect/tsgo-linux-arm@0.36.1': optional: true - '@effect/tsgo-linux-x64@0.24.3': + '@effect/tsgo-linux-x64@0.36.1': optional: true - '@effect/tsgo-win32-arm64@0.24.3': + '@effect/tsgo-win32-arm64@0.36.1': optional: true - '@effect/tsgo-win32-x64@0.24.3': + '@effect/tsgo-win32-x64@0.36.1': optional: true - '@effect/tsgo@0.24.3': + '@effect/tsgo@0.36.1': optionalDependencies: - '@effect/tsgo-darwin-arm64': 0.24.3 - '@effect/tsgo-darwin-x64': 0.24.3 - '@effect/tsgo-linux-arm': 0.24.3 - '@effect/tsgo-linux-arm64': 0.24.3 - '@effect/tsgo-linux-x64': 0.24.3 - '@effect/tsgo-win32-arm64': 0.24.3 - '@effect/tsgo-win32-x64': 0.24.3 + '@effect/tsgo-darwin-arm64': 0.36.1 + '@effect/tsgo-darwin-x64': 0.36.1 + '@effect/tsgo-linux-arm': 0.36.1 + '@effect/tsgo-linux-arm64': 0.36.1 + '@effect/tsgo-linux-x64': 0.36.1 + '@effect/tsgo-win32-arm64': 0.36.1 + '@effect/tsgo-win32-x64': 0.36.1 - '@effect/vitest@4.0.0-beta.102(effect@4.0.0-beta.102)(vitest@4.1.10(@types/node@26.1.2)(vite@8.1.3(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)))': + '@effect/vitest@4.0.0-beta.102(effect@4.0.0-beta.102)(vitest@4.1.10(@types/node@26.2.0)(vite@8.1.3(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0)))': dependencies: effect: 4.0.0-beta.102 - vitest: 4.1.10(@types/node@26.1.2)(vite@8.1.3(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@26.2.0)(vite@8.1.3(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0)) '@electric-sql/pglite-socket@0.0.20(@electric-sql/pglite@0.3.15)': dependencies: @@ -3321,7 +3318,7 @@ snapshots: '@octokit/request-error': 7.1.0 '@octokit/webhooks-methods': 6.0.0 - '@opencode-ai/sdk@1.18.11': + '@opencode-ai/sdk@1.18.15': dependencies: cross-spawn: 7.0.6 @@ -3454,61 +3451,61 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.24.2': optional: true - '@oxfmt/binding-android-arm-eabi@0.61.0': + '@oxfmt/binding-android-arm-eabi@0.62.0': optional: true - '@oxfmt/binding-android-arm64@0.61.0': + '@oxfmt/binding-android-arm64@0.62.0': optional: true - '@oxfmt/binding-darwin-arm64@0.61.0': + '@oxfmt/binding-darwin-arm64@0.62.0': optional: true - '@oxfmt/binding-darwin-x64@0.61.0': + '@oxfmt/binding-darwin-x64@0.62.0': optional: true - '@oxfmt/binding-freebsd-x64@0.61.0': + '@oxfmt/binding-freebsd-x64@0.62.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.61.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.62.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.61.0': + '@oxfmt/binding-linux-arm-musleabihf@0.62.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.61.0': + '@oxfmt/binding-linux-arm64-gnu@0.62.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.61.0': + '@oxfmt/binding-linux-arm64-musl@0.62.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.61.0': + '@oxfmt/binding-linux-ppc64-gnu@0.62.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.61.0': + '@oxfmt/binding-linux-riscv64-gnu@0.62.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.61.0': + '@oxfmt/binding-linux-riscv64-musl@0.62.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.61.0': + '@oxfmt/binding-linux-s390x-gnu@0.62.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.61.0': + '@oxfmt/binding-linux-x64-gnu@0.62.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.61.0': + '@oxfmt/binding-linux-x64-musl@0.62.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.61.0': + '@oxfmt/binding-openharmony-arm64@0.62.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.61.0': + '@oxfmt/binding-win32-arm64-msvc@0.62.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.61.0': + '@oxfmt/binding-win32-ia32-msvc@0.62.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.61.0': + '@oxfmt/binding-win32-x64-msvc@0.62.0': optional: true '@oxlint-tsgolint/darwin-arm64@7.0.2001': @@ -3529,61 +3526,61 @@ snapshots: '@oxlint-tsgolint/win32-x64@7.0.2001': optional: true - '@oxlint/binding-android-arm-eabi@1.76.0': + '@oxlint/binding-android-arm-eabi@1.77.0': optional: true - '@oxlint/binding-android-arm64@1.76.0': + '@oxlint/binding-android-arm64@1.77.0': optional: true - '@oxlint/binding-darwin-arm64@1.76.0': + '@oxlint/binding-darwin-arm64@1.77.0': optional: true - '@oxlint/binding-darwin-x64@1.76.0': + '@oxlint/binding-darwin-x64@1.77.0': optional: true - '@oxlint/binding-freebsd-x64@1.76.0': + '@oxlint/binding-freebsd-x64@1.77.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.76.0': + '@oxlint/binding-linux-arm-gnueabihf@1.77.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.76.0': + '@oxlint/binding-linux-arm-musleabihf@1.77.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.76.0': + '@oxlint/binding-linux-arm64-gnu@1.77.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.76.0': + '@oxlint/binding-linux-arm64-musl@1.77.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.76.0': + '@oxlint/binding-linux-ppc64-gnu@1.77.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.76.0': + '@oxlint/binding-linux-riscv64-gnu@1.77.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.76.0': + '@oxlint/binding-linux-riscv64-musl@1.77.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.76.0': + '@oxlint/binding-linux-s390x-gnu@1.77.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.76.0': + '@oxlint/binding-linux-x64-gnu@1.77.0': optional: true - '@oxlint/binding-linux-x64-musl@1.76.0': + '@oxlint/binding-linux-x64-musl@1.77.0': optional: true - '@oxlint/binding-openharmony-arm64@1.76.0': + '@oxlint/binding-openharmony-arm64@1.77.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.76.0': + '@oxlint/binding-win32-arm64-msvc@1.77.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.76.0': + '@oxlint/binding-win32-ia32-msvc@1.77.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.76.0': + '@oxlint/binding-win32-x64-msvc@1.77.0': optional: true '@prisma/debug@7.2.0': {} @@ -3753,11 +3750,7 @@ snapshots: '@types/estree@1.0.9': {} - '@types/node@26.1.0': - dependencies: - undici-types: 8.3.0 - - '@types/node@26.1.2': + '@types/node@26.2.0': dependencies: undici-types: 8.3.0 @@ -3769,7 +3762,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 26.1.0 + '@types/node': 26.2.0 '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -3859,13 +3852,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.1.3(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(vite@8.1.3(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.3(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.1.3(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -3901,7 +3894,7 @@ snapshots: agent-base@7.1.4: {} - alchemy@2.0.0-beta.67(patch_hash=d5ede49c8eda79ec4b835ebb1a2cfe6443e095f5c26a656095e225c4817cb436)(@effect/platform-node@4.0.0-beta.102(effect@4.0.0-beta.102)(ioredis@5.11.1))(@types/node@26.1.2)(effect@4.0.0-beta.102)(typescript@7.0.2)(vite@8.1.3(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.2)(vite@8.1.3(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.0): + alchemy@2.0.0-beta.67(patch_hash=d5ede49c8eda79ec4b835ebb1a2cfe6443e095f5c26a656095e225c4817cb436)(@effect/platform-node@4.0.0-beta.102(effect@4.0.0-beta.102)(ioredis@5.11.1))(@types/node@26.2.0)(effect@4.0.0-beta.102)(typescript@7.0.2)(vite@8.1.3(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.2.0)(vite@8.1.3(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.0): dependencies: '@alchemy.run/node-utils': 0.0.5 '@aws-sdk/credential-providers': 3.1079.0 @@ -3909,14 +3902,14 @@ snapshots: '@distilled.cloud/aws': 0.30.3(effect@4.0.0-beta.102) '@distilled.cloud/axiom': 0.30.3(effect@4.0.0-beta.102) '@distilled.cloud/cloudflare': 0.30.3(effect@4.0.0-beta.102) - '@distilled.cloud/cloudflare-rolldown-plugin': 0.15.0(rolldown@1.1.5)(vite@8.1.3(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))(workerd@1.20260704.1) + '@distilled.cloud/cloudflare-rolldown-plugin': 0.15.0(rolldown@1.1.5)(vite@8.1.3(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))(workerd@1.20260704.1) '@distilled.cloud/cloudflare-runtime': 0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.102))(@effect/platform-node@4.0.0-beta.102(effect@4.0.0-beta.102)(ioredis@5.11.1))(effect@4.0.0-beta.102) - '@distilled.cloud/cloudflare-vite-plugin': 0.15.0(@distilled.cloud/cloudflare-runtime@0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.102))(@effect/platform-node@4.0.0-beta.102(effect@4.0.0-beta.102)(ioredis@5.11.1))(effect@4.0.0-beta.102))(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.102))(@effect/platform-node@4.0.0-beta.102(effect@4.0.0-beta.102)(ioredis@5.11.1))(effect@4.0.0-beta.102)(rolldown@1.1.5)(vite@8.1.3(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))(workerd@1.20260704.1) + '@distilled.cloud/cloudflare-vite-plugin': 0.15.0(@distilled.cloud/cloudflare-runtime@0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.102))(@effect/platform-node@4.0.0-beta.102(effect@4.0.0-beta.102)(ioredis@5.11.1))(effect@4.0.0-beta.102))(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.102))(@effect/platform-node@4.0.0-beta.102(effect@4.0.0-beta.102)(ioredis@5.11.1))(effect@4.0.0-beta.102)(rolldown@1.1.5)(vite@8.1.3(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))(workerd@1.20260704.1) '@distilled.cloud/core': 0.30.3(effect@4.0.0-beta.102) '@distilled.cloud/neon': 0.30.3(effect@4.0.0-beta.102) '@distilled.cloud/planetscale': 0.30.3(effect@4.0.0-beta.102) '@effect/sql-d1': 4.0.0-beta.102(effect@4.0.0-beta.102) - '@effect/vitest': 4.0.0-beta.102(effect@4.0.0-beta.102)(vitest@4.1.10(@types/node@26.1.2)(vite@8.1.3(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))) + '@effect/vitest': 4.0.0-beta.102(effect@4.0.0-beta.102)(vitest@4.1.10(@types/node@26.2.0)(vite@8.1.3(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))) '@libsql/client': 0.17.4 '@octokit/rest': 22.0.1 '@octokit/webhooks': 14.2.0 @@ -3935,7 +3928,7 @@ snapshots: jszip: 3.10.1 libsodium-wrappers: 0.8.4 mongodb: 6.21.0(@aws-sdk/credential-providers@3.1079.0) - mysql2: 3.22.5(@types/node@26.1.2) + mysql2: 3.22.5(@types/node@26.2.0) pathe: 2.0.3 pg: 8.22.0 picomatch: 4.0.5 @@ -3945,7 +3938,7 @@ snapshots: yaml: 2.9.0 optionalDependencies: '@effect/platform-node': 4.0.0-beta.102(effect@4.0.0-beta.102)(ioredis@5.11.1) - vite: 8.1.3(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.1.3(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0) ws: 8.21.0 transitivePeerDependencies: - '@mongodb-js/zstd' @@ -4174,7 +4167,7 @@ snapshots: get-port-please@3.2.0: {} - get-tsconfig@4.14.0: + get-tsconfig@4.14.1: dependencies: resolve-pkg-maps: 1.0.0 @@ -4296,11 +4289,11 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 - knip@6.31.0: + knip@6.32.0: dependencies: fdir: 6.5.0(picomatch@4.0.5) formatly: 0.3.0 - get-tsconfig: 4.14.0 + get-tsconfig: 4.14.1 jiti: 2.7.0 oxc-parser: 0.142.0 oxc-resolver: 11.24.2 @@ -4501,9 +4494,9 @@ snapshots: multipasta@0.2.8: {} - mysql2@3.22.5(@types/node@26.1.2): + mysql2@3.22.5(@types/node@26.2.0): dependencies: - '@types/node': 26.1.2 + '@types/node': 26.2.0 aws-ssl-profiles: 1.1.2 denque: 2.1.0 generate-function: 2.3.1 @@ -4587,29 +4580,29 @@ snapshots: '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 - oxfmt@0.61.0: + oxfmt@0.62.0: dependencies: tinypool: 2.1.0 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.61.0 - '@oxfmt/binding-android-arm64': 0.61.0 - '@oxfmt/binding-darwin-arm64': 0.61.0 - '@oxfmt/binding-darwin-x64': 0.61.0 - '@oxfmt/binding-freebsd-x64': 0.61.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.61.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.61.0 - '@oxfmt/binding-linux-arm64-gnu': 0.61.0 - '@oxfmt/binding-linux-arm64-musl': 0.61.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.61.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.61.0 - '@oxfmt/binding-linux-riscv64-musl': 0.61.0 - '@oxfmt/binding-linux-s390x-gnu': 0.61.0 - '@oxfmt/binding-linux-x64-gnu': 0.61.0 - '@oxfmt/binding-linux-x64-musl': 0.61.0 - '@oxfmt/binding-openharmony-arm64': 0.61.0 - '@oxfmt/binding-win32-arm64-msvc': 0.61.0 - '@oxfmt/binding-win32-ia32-msvc': 0.61.0 - '@oxfmt/binding-win32-x64-msvc': 0.61.0 + '@oxfmt/binding-android-arm-eabi': 0.62.0 + '@oxfmt/binding-android-arm64': 0.62.0 + '@oxfmt/binding-darwin-arm64': 0.62.0 + '@oxfmt/binding-darwin-x64': 0.62.0 + '@oxfmt/binding-freebsd-x64': 0.62.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.62.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.62.0 + '@oxfmt/binding-linux-arm64-gnu': 0.62.0 + '@oxfmt/binding-linux-arm64-musl': 0.62.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.62.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.62.0 + '@oxfmt/binding-linux-riscv64-musl': 0.62.0 + '@oxfmt/binding-linux-s390x-gnu': 0.62.0 + '@oxfmt/binding-linux-x64-gnu': 0.62.0 + '@oxfmt/binding-linux-x64-musl': 0.62.0 + '@oxfmt/binding-openharmony-arm64': 0.62.0 + '@oxfmt/binding-win32-arm64-msvc': 0.62.0 + '@oxfmt/binding-win32-ia32-msvc': 0.62.0 + '@oxfmt/binding-win32-x64-msvc': 0.62.0 oxlint-tsgolint@7.0.2001: optionalDependencies: @@ -4620,27 +4613,27 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 7.0.2001 '@oxlint-tsgolint/win32-x64': 7.0.2001 - oxlint@1.76.0(oxlint-tsgolint@7.0.2001): + oxlint@1.77.0(oxlint-tsgolint@7.0.2001): optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.76.0 - '@oxlint/binding-android-arm64': 1.76.0 - '@oxlint/binding-darwin-arm64': 1.76.0 - '@oxlint/binding-darwin-x64': 1.76.0 - '@oxlint/binding-freebsd-x64': 1.76.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.76.0 - '@oxlint/binding-linux-arm-musleabihf': 1.76.0 - '@oxlint/binding-linux-arm64-gnu': 1.76.0 - '@oxlint/binding-linux-arm64-musl': 1.76.0 - '@oxlint/binding-linux-ppc64-gnu': 1.76.0 - '@oxlint/binding-linux-riscv64-gnu': 1.76.0 - '@oxlint/binding-linux-riscv64-musl': 1.76.0 - '@oxlint/binding-linux-s390x-gnu': 1.76.0 - '@oxlint/binding-linux-x64-gnu': 1.76.0 - '@oxlint/binding-linux-x64-musl': 1.76.0 - '@oxlint/binding-openharmony-arm64': 1.76.0 - '@oxlint/binding-win32-arm64-msvc': 1.76.0 - '@oxlint/binding-win32-ia32-msvc': 1.76.0 - '@oxlint/binding-win32-x64-msvc': 1.76.0 + '@oxlint/binding-android-arm-eabi': 1.77.0 + '@oxlint/binding-android-arm64': 1.77.0 + '@oxlint/binding-darwin-arm64': 1.77.0 + '@oxlint/binding-darwin-x64': 1.77.0 + '@oxlint/binding-freebsd-x64': 1.77.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.77.0 + '@oxlint/binding-linux-arm-musleabihf': 1.77.0 + '@oxlint/binding-linux-arm64-gnu': 1.77.0 + '@oxlint/binding-linux-arm64-musl': 1.77.0 + '@oxlint/binding-linux-ppc64-gnu': 1.77.0 + '@oxlint/binding-linux-riscv64-gnu': 1.77.0 + '@oxlint/binding-linux-riscv64-musl': 1.77.0 + '@oxlint/binding-linux-s390x-gnu': 1.77.0 + '@oxlint/binding-linux-x64-gnu': 1.77.0 + '@oxlint/binding-linux-x64-musl': 1.77.0 + '@oxlint/binding-openharmony-arm64': 1.77.0 + '@oxlint/binding-win32-arm64-msvc': 1.77.0 + '@oxlint/binding-win32-ia32-msvc': 1.77.0 + '@oxlint/binding-win32-x64-msvc': 1.77.0 oxlint-tsgolint: 7.0.2001 pako@1.0.11: {} @@ -4962,7 +4955,7 @@ snapshots: optionalDependencies: typescript: 7.0.2 - vite@8.1.3(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0): + vite@8.1.3(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 @@ -4970,15 +4963,15 @@ snapshots: rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.1.2 + '@types/node': 26.2.0 fsevents: 2.3.3 jiti: 2.7.0 yaml: 2.9.0 - vitest@4.1.10(@types/node@26.1.2)(vite@8.1.3(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)): + vitest@4.1.10(@types/node@26.2.0)(vite@8.1.3(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.3(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(vite@8.1.3(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -4995,10 +4988,10 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.3(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.1.3(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 26.1.2 + '@types/node': 26.2.0 transitivePeerDependencies: - msw diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 72d44f2..3daaf99 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,14 @@ allowBuilds: workerd: true minimumReleaseAgeExclude: - alchemy@2.0.0-beta.67 - - "@opencode-ai/sdk@1.18.11" + - "@opencode-ai/sdk@1.18.15" + - "@effect/tsgo-darwin-arm64@0.36.1" + - "@effect/tsgo-darwin-x64@0.36.1" + - "@effect/tsgo-linux-arm64@0.36.1" + - "@effect/tsgo-linux-arm@0.36.1" + - "@effect/tsgo-linux-x64@0.36.1" + - "@effect/tsgo-win32-arm64@0.36.1" + - "@effect/tsgo-win32-x64@0.36.1" + - "@effect/tsgo@0.36.1" patchedDependencies: alchemy@2.0.0-beta.67: patches/alchemy@2.0.0-beta.67.patch diff --git a/src/linear/client/client.ts b/src/linear/client/client.ts index 3d620d8..5d24786 100644 --- a/src/linear/client/client.ts +++ b/src/linear/client/client.ts @@ -40,7 +40,7 @@ const LinearAgentActivityContent = Schema.Union([ }), ]); -export type LinearAgentActivityContent = Schema.Schema.Type; +export type LinearAgentActivityContent = typeof LinearAgentActivityContent.Type; export type LinearAgentActivitySignalMetadata = { readonly options: ReadonlyArray<{ @@ -90,7 +90,7 @@ const LinearGraphqlErrorResponse = Schema.Struct({ extensions: Schema.optionalKey(Schema.Unknown), }); -type LinearGraphqlError = Schema.Schema.Type; +type LinearGraphqlError = typeof LinearGraphqlError.Type; const graphqlErrorMessage = (errors: ReadonlyArray) => errors.map((error) => error.extensions?.userPresentableMessage ?? error.message).join("\n"); @@ -245,7 +245,7 @@ export type LinearHttpClientService = { }; export class LinearHttpClient extends Context.Service()( - "app/LinearHttpClient", + "opencode-event-bridge/linear/client/client/LinearHttpClient", ) { static readonly layer = Layer.effect( LinearHttpClient, @@ -261,15 +261,17 @@ export class LinearHttpClient extends Context.Service new LinearHttpClientError({ cause, reason: "InvalidGraphqlRequest" }), + Effect.mapError((cause) => + LinearHttpClientError.make({ cause, reason: "InvalidGraphqlRequest" }), ), ); return yield* rawClient .execute(request) .pipe( - Effect.mapError((cause) => new LinearHttpClientError({ cause, reason: "HttpError" })), + Effect.mapError((cause) => + LinearHttpClientError.make({ cause, reason: "HttpError" }), + ), ); }); @@ -277,24 +279,23 @@ export class LinearHttpClient extends Context.Service(schema: S) => (response: HttpClientResponse.HttpClientResponse) => HttpClientResponse.schemaBodyJson(Schema.Unknown)(response).pipe( - Effect.mapError( - (cause) => new LinearHttpClientError({ cause, reason: "InvalidGraphqlResponse" }), + Effect.mapError((cause) => + LinearHttpClientError.make({ cause, reason: "InvalidGraphqlResponse" }), ), Effect.flatMap((body) => Schema.decodeUnknownEffect(LinearGraphqlErrorResponse)(body).pipe( Effect.matchEffect({ onFailure: () => Schema.decodeUnknownEffect(schema)(body).pipe( - Effect.mapError( - (cause) => - new LinearHttpClientError({ - cause, - reason: "InvalidGraphqlResponse", - }), + Effect.mapError((cause) => + LinearHttpClientError.make({ + cause, + reason: "InvalidGraphqlResponse", + }), ), ), onSuccess: (failure) => - new LinearHttpClientError({ + LinearHttpClientError.make({ cause: graphqlErrorMessage(failure.errors), reason: graphqlFailureReason(failure.errors), }), @@ -326,7 +327,7 @@ export class LinearHttpClient extends Context.Service new LinearHttpClientError({ cause, reason: "HttpError" })), + Effect.mapError((cause) => LinearHttpClientError.make({ cause, reason: "HttpError" })), ); }); @@ -371,7 +372,7 @@ export class LinearHttpClient extends Context.Service new LinearHttpClientError({ cause, reason: "HttpError" })), + Effect.mapError((cause) => LinearHttpClientError.make({ cause, reason: "HttpError" })), ); }); @@ -435,7 +436,7 @@ export class LinearHttpClient extends Context.Service = + const result: typeof LinearIssueAgentSessionsGraphqlResponse.Type = yield* decodeGraphql(LinearIssueAgentSessionsGraphqlResponse)(response); for (const session of result.data.agentSessions.nodes) { @@ -513,7 +514,7 @@ export class LinearHttpClient extends Context.Service new LinearSessionProcessorError({ cause, reason: "OpenCodeFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "OpenCodeFailure" }), ), ); yield* store @@ -173,8 +173,8 @@ const processPermissionReply = (input: { organizationId: input.organizationId, }) .pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "StoreFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "StoreFailure" }), ), ); yield* observeRun({ locator: input.locator, organizationId: input.organizationId }); @@ -199,8 +199,8 @@ const processQuestionReply = (input: { yield* store .putPendingInput({ organizationId: input.organizationId, pending: next }) .pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "StoreFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "StoreFailure" }), ), ); const question = next.questions[index]; @@ -224,8 +224,8 @@ const processQuestionReply = (input: { workspaceDirectory: input.locator.workspaceDirectory, }) .pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "OpenCodeFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "OpenCodeFailure" }), ), ); yield* store @@ -234,8 +234,8 @@ const processQuestionReply = (input: { organizationId: input.organizationId, }) .pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "StoreFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "StoreFailure" }), ), ); yield* observeRun({ locator: input.locator, organizationId: input.organizationId }); @@ -284,8 +284,8 @@ const processProjectSelectionReply = (input: { const opencode = yield* OpenCodeClient; const store = yield* LinearSessionStore; const projects = yield* opencode.listProjects.pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "OpenCodeFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "OpenCodeFailure" }), ), ); const project = matchProject({ candidates: [input.body], projects }); @@ -306,8 +306,8 @@ const processProjectSelectionReply = (input: { organizationId: input.organizationId, }) .pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "StoreFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "StoreFailure" }), ), ); @@ -328,8 +328,8 @@ const recoverOpenCodePendingInput = (input: { workspaceDirectory: input.locator.workspaceDirectory, }) .pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "OpenCodeFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "OpenCodeFailure" }), ), ); const permissions = yield* opencode @@ -339,8 +339,8 @@ const recoverOpenCodePendingInput = (input: { workspaceDirectory: input.locator.workspaceDirectory, }) .pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "OpenCodeFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "OpenCodeFailure" }), ), ); const permission = permissions[0]; @@ -363,8 +363,8 @@ const recoverOpenCodePendingInput = (input: { yield* store .putPendingInput({ organizationId: input.organizationId, pending }) .pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "StoreFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "StoreFailure" }), ), ); return Option.some(handled("prompted")); @@ -377,8 +377,8 @@ const recoverOpenCodePendingInput = (input: { workspaceDirectory: input.locator.workspaceDirectory, }) .pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "OpenCodeFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "OpenCodeFailure" }), ), ); const question = questions[0]; @@ -408,8 +408,8 @@ const recoverOpenCodePendingInput = (input: { yield* store .putPendingInput({ organizationId: input.organizationId, pending }) .pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "StoreFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "StoreFailure" }), ), ); @@ -428,7 +428,7 @@ export const processPromptedInteraction = (input: { if (input.payload.agentActivity.signal === "stop") { if (input.locator === null) { - return yield* new LinearSessionProcessorError({ reason: "MissingAgentSession" }); + return yield* LinearSessionProcessorError.make({ reason: "MissingAgentSession" }); } const observer = yield* OpenCodeRunObserverService; @@ -438,8 +438,8 @@ export const processPromptedInteraction = (input: { workspaceDirectory: input.locator.workspaceDirectory, }) .pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "OpenCodeFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "OpenCodeFailure" }), ), ); yield* observer.cancel({ linearAgentSessionId: input.payload.agentSession.id }); @@ -453,8 +453,8 @@ export const processPromptedInteraction = (input: { organizationId: input.payload.organizationId, }) .pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "StoreFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "StoreFailure" }), ), ); @@ -470,8 +470,8 @@ export const processPromptedInteraction = (input: { if (input.locator === null) { const projects = yield* opencode.listProjects.pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "OpenCodeFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "OpenCodeFailure" }), ), ); const project = matchProject({ diff --git a/src/linear/issue/process.ts b/src/linear/issue/process.ts index 470685f..e885a91 100644 --- a/src/linear/issue/process.ts +++ b/src/linear/issue/process.ts @@ -44,8 +44,8 @@ export const processIssueWebhook = (input: { issueId: payload.data.id, }) .pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "LinearFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "LinearFailure" }), ), ); const locators = yield* Effect.all( @@ -56,8 +56,8 @@ export const processIssueWebhook = (input: { organizationId: payload.organizationId, }) .pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "StoreFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "StoreFailure" }), ), ), ), @@ -76,7 +76,7 @@ export const processIssueWebhook = (input: { const abort = aborts.find(Result.isFailure); if (abort !== undefined) { - return yield* new LinearSessionProcessorError({ + return yield* LinearSessionProcessorError.make({ cause: abort.failure, reason: "OpenCodeFailure", }); @@ -101,7 +101,7 @@ export const processIssueWebhook = (input: { const removal = removals.find(Result.isFailure); if (removal !== undefined) { - return yield* new LinearSessionProcessorError({ + return yield* LinearSessionProcessorError.make({ cause: removal.failure, reason: "OpenCodeFailure", }); @@ -123,7 +123,7 @@ export const processIssueWebhook = (input: { const deletion = deletes.find(Result.isFailure); if (deletion !== undefined) { - return yield* new LinearSessionProcessorError({ + return yield* LinearSessionProcessorError.make({ cause: deletion.failure, reason: "StoreFailure", }); diff --git a/src/linear/oauth/auth.ts b/src/linear/oauth/auth.ts index d6e6fac..d69e460 100644 --- a/src/linear/oauth/auth.ts +++ b/src/linear/oauth/auth.ts @@ -1,17 +1,17 @@ -import { Context, Effect, Schema } from "effect"; +import { Clock, Context, Effect, Schema } from "effect"; import { LinearOrgAuthRecord } from "./schema.ts"; -type LinearOrgAuthRecord = Schema.Schema.Type; +type LinearOrgAuthRecord = typeof LinearOrgAuthRecord.Type; -export type LinearOrgAuthStoreKv = { - readonly get: (key: string) => Effect.Effect; - readonly put: (key: string, value: string) => Effect.Effect; +export type LinearOrgAuthStoreKv = { + readonly get: (key: string) => Effect.Effect; + readonly put: (key: string, value: string) => Effect.Effect; }; -export type LinearOrgAuthRefresh = ( +export type LinearOrgAuthRefresh = ( record: LinearOrgAuthRecord, -) => Effect.Effect; +) => Effect.Effect; export type LinearOrgAuthStoreService = { readonly getOrgAuth: (input: { @@ -22,7 +22,7 @@ export type LinearOrgAuthStoreService = { export class LinearOrgAuthStore extends Context.Service< LinearOrgAuthStore, LinearOrgAuthStoreService ->()("app/LinearOrgAuthStore") {} +>()("opencode-event-bridge/linear/oauth/auth/LinearOrgAuthStore") {} export class LinearOrgAuthStoreError extends Schema.TaggedErrorClass()( "LinearOrgAuthStoreError", @@ -40,83 +40,86 @@ export class LinearOrgAuthStoreError extends Schema.TaggedErrorClass(input: { + readonly kv: LinearOrgAuthStoreKv; readonly organizationId: string; }) => Effect.gen(function* () { const value = yield* input.kv.get(input.organizationId).pipe( - Effect.mapError( - (cause) => - new LinearOrgAuthStoreError({ - cause, - key: input.organizationId, - reason: "StoreFailure", - }), + Effect.mapError((cause) => + LinearOrgAuthStoreError.make({ + cause, + key: input.organizationId, + reason: "StoreFailure", + }), ), ); if (value === null) { - return yield* new LinearOrgAuthStoreError({ reason: "MissingOrganizationAuth" }); + return yield* LinearOrgAuthStoreError.make({ reason: "MissingOrganizationAuth" }); } - return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(LinearOrgAuthRecord))( - value, - ).pipe( - Effect.mapError( - (cause) => - new LinearOrgAuthStoreError({ - cause, - key: input.organizationId, - reason: "InvalidRecord", - }), + return yield* Schema.decodeEffect(Schema.fromJsonString(LinearOrgAuthRecord))(value).pipe( + Effect.mapError((cause) => + LinearOrgAuthStoreError.make({ + cause, + key: input.organizationId, + reason: "InvalidRecord", + }), ), ); }); -const putOrgAuth = (input: { - readonly kv: LinearOrgAuthStoreKv; +const putOrgAuth = (input: { + readonly kv: LinearOrgAuthStoreKv; readonly record: LinearOrgAuthRecord; }) => Schema.encodeEffect(Schema.fromJsonString(LinearOrgAuthRecord))(input.record).pipe( - Effect.mapError( - (cause) => - new LinearOrgAuthStoreError({ - cause, - key: input.record.organizationId, - reason: "InvalidRecord", - }), + Effect.mapError((cause) => + LinearOrgAuthStoreError.make({ + cause, + key: input.record.organizationId, + reason: "InvalidRecord", + }), ), Effect.flatMap((value) => input.kv.put(input.record.organizationId, value).pipe( - Effect.mapError( - (cause) => - new LinearOrgAuthStoreError({ - cause, - key: input.record.organizationId, - reason: "StoreFailure", - }), + Effect.mapError((cause) => + LinearOrgAuthStoreError.make({ + cause, + key: input.record.organizationId, + reason: "StoreFailure", + }), ), ), ), ); -export const makeLinearOrgAuthStore = (kv: LinearOrgAuthStoreKv, refresh?: LinearOrgAuthRefresh) => +export const makeLinearOrgAuthStore = (input: { + readonly kv: LinearOrgAuthStoreKv; + readonly refresh?: LinearOrgAuthRefresh; +}) => LinearOrgAuthStore.of({ - getOrgAuth: (input) => + getOrgAuth: (getInput) => Effect.gen(function* () { - const record = yield* readOrgAuth({ kv, organizationId: input.organizationId }); + const record = yield* readOrgAuth({ + kv: input.kv, + organizationId: getInput.organizationId, + }); + const now = yield* Clock.currentTimeMillis; - if (record.accessTokenExpiresAt > Date.now() + expirySkewMillis || refresh === undefined) { + if (record.accessTokenExpiresAt > now + expirySkewMillis || input.refresh === undefined) { return record; } - const refreshed = yield* refresh(record).pipe( - Effect.mapError( - (cause) => new LinearOrgAuthStoreError({ cause, reason: "RefreshFailure" }), - ), - ); - yield* putOrgAuth({ kv, record: refreshed }); + const refreshed = yield* input + .refresh(record) + .pipe( + Effect.mapError((cause) => + LinearOrgAuthStoreError.make({ cause, reason: "RefreshFailure" }), + ), + ); + yield* putOrgAuth({ kv: input.kv, record: refreshed }); return refreshed; }), diff --git a/src/linear/oauth/install.ts b/src/linear/oauth/install.ts index 4e61c2e..a185d1e 100644 --- a/src/linear/oauth/install.ts +++ b/src/linear/oauth/install.ts @@ -1,4 +1,4 @@ -import { Crypto, Schema } from "effect"; +import { Clock, Crypto, Schema } from "effect"; import * as Effect from "effect/Effect"; import type { LinearHttpClientError } from "../client/client.ts"; @@ -18,13 +18,13 @@ const makeCallbackUrl = (request: { readonly headers: Record }) Effect.gen(function* () { const host = request.headers["host"]; if (host === undefined) { - return yield* new LinearOAuthUpstreamError({ reason: "InvalidCallbackUrl" }); + return yield* LinearOAuthUpstreamError.make({ reason: "InvalidCallbackUrl" }); } const protocol = request.headers["x-forwarded-proto"] ?? "https"; return new URL("/api/oauth/callback", `${protocol}://${host}`).toString(); }); -export const makeLinearOAuthAuthorizeUrl = (input: { +export const makeLinearOAuthAuthorizeUrl = (input: { readonly clientId: string; readonly request: { readonly headers: Record }; readonly state: { @@ -32,7 +32,7 @@ export const makeLinearOAuthAuthorizeUrl = (input: { key: string, value: string, options: { readonly expirationTtl: number }, - ): Effect.Effect; + ): Effect.Effect; }; }) => Effect.gen(function* () { @@ -51,7 +51,7 @@ export const makeLinearOAuthAuthorizeUrl = (input: { return url.toString(); }); -export const completeLinearOAuthInstall = (input: { +export const completeLinearOAuthInstall = (input: { readonly allowedOrganizationId: string; readonly clientId: string; readonly clientSecret: string; @@ -67,11 +67,11 @@ export const completeLinearOAuthInstall = (input: { accessToken: string, ): Effect.Effect; }; - readonly orgAuth: { put(key: string, value: string): Effect.Effect }; + readonly orgAuth: { put(key: string, value: string): Effect.Effect }; readonly request: { readonly headers: Record }; readonly state: { - delete(key: string): Effect.Effect; - get(key: string): Effect.Effect; + delete(key: string): Effect.Effect; + get(key: string): Effect.Effect; }; readonly stateValue: string; }) => @@ -79,7 +79,7 @@ export const completeLinearOAuthInstall = (input: { const storedState = yield* input.state.get(input.stateValue).pipe(Effect.orDie); yield* input.state.delete(input.stateValue).pipe(Effect.orDie); if (storedState === null) { - return yield* new LinearOAuthBadRequest({ reason: "InvalidState" }); + return yield* LinearOAuthBadRequest.make({ reason: "InvalidState" }); } const token = yield* input.linear @@ -90,22 +90,22 @@ export const completeLinearOAuthInstall = (input: { redirectUri: yield* makeCallbackUrl(input.request), }) .pipe( - Effect.mapError( - (cause) => new LinearOAuthUpstreamError({ cause, reason: "InvalidTokenResponse" }), + Effect.mapError((cause) => + LinearOAuthUpstreamError.make({ cause, reason: "InvalidTokenResponse" }), ), ); const viewer = yield* input.linear .fetchViewer(token.access_token) .pipe( - Effect.mapError( - (cause) => new LinearOAuthUpstreamError({ cause, reason: "InvalidViewerResponse" }), + Effect.mapError((cause) => + LinearOAuthUpstreamError.make({ cause, reason: "InvalidViewerResponse" }), ), ); if (viewer.data.viewer.organization.id !== input.allowedOrganizationId) { - return yield* new LinearOAuthForbidden({ reason: "OrganizationNotAllowed" }); + return yield* LinearOAuthForbidden.make({ reason: "OrganizationNotAllowed" }); } - const now = Date.now(); + const now = yield* Clock.currentTimeMillis; const record = { accessToken: token.access_token, accessTokenExpiresAt: now + token.expires_in * 1_000, @@ -114,16 +114,16 @@ export const completeLinearOAuthInstall = (input: { refreshToken: token.refresh_token, organizationName: viewer.data.viewer.organization.name, }; - const encoded = yield* Schema.decodeUnknownEffect(LinearOrgAuthRecord)(record).pipe( - Effect.mapError( - (cause) => new LinearOAuthUpstreamError({ cause, reason: "InvalidTokenResponse" }), + const encoded = yield* Schema.decodeEffect(LinearOrgAuthRecord)(record).pipe( + Effect.mapError((cause) => + LinearOAuthUpstreamError.make({ cause, reason: "InvalidTokenResponse" }), ), ); const serialized = yield* Schema.encodeEffect(Schema.fromJsonString(LinearOrgAuthRecord))( encoded, ).pipe( - Effect.mapError( - (cause) => new LinearOAuthUpstreamError({ cause, reason: "InvalidTokenResponse" }), + Effect.mapError((cause) => + LinearOAuthUpstreamError.make({ cause, reason: "InvalidTokenResponse" }), ), ); yield* input.orgAuth.put(encoded.organizationId, serialized).pipe(Effect.orDie); diff --git a/src/linear/session/process.ts b/src/linear/session/process.ts index 6d8a8a1..cd2f109 100644 --- a/src/linear/session/process.ts +++ b/src/linear/session/process.ts @@ -53,8 +53,8 @@ const ensureWorktree = (input: { const worktrees = yield* opencode .listWorktrees({ projectDirectory: input.projectDirectory }) .pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "OpenCodeFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "OpenCodeFailure" }), ), ); const matched = worktrees.find((worktree) => @@ -69,8 +69,8 @@ const ensureWorktree = (input: { .createWorktree({ branch, projectDirectory: input.projectDirectory }) .pipe( Effect.map((worktree) => worktree.workspaceDirectory), - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "OpenCodeFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "OpenCodeFailure" }), ), ); }); @@ -96,7 +96,7 @@ export const processLinearWebhook = (message: { Effect.matchEffect({ onFailure: (cause) => { if (cause.reason !== "MissingOrganizationAuth") { - return new LinearSessionProcessorError({ cause, reason: "AuthFailure" }); + return LinearSessionProcessorError.make({ cause, reason: "AuthFailure" }); } return Effect.logError("linear session processing failed", { @@ -139,8 +139,8 @@ export const processLinearWebhook = (message: { }) .pipe( Effect.asVoid, - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "LinearFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "LinearFailure" }), ), ); @@ -152,8 +152,8 @@ export const processLinearWebhook = (message: { externalUrls: [{ label: "OpenCode", url: opencodeSessionUrl(input) }], }) .pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "LinearFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "LinearFailure" }), ), ); @@ -189,7 +189,7 @@ export const processLinearWebhook = (message: { }); } - return new LinearSessionProcessorError({ cause: error, reason: "LinearFailure" }); + return LinearSessionProcessorError.make({ cause: error, reason: "LinearFailure" }); }), ); @@ -208,8 +208,8 @@ export const processLinearWebhook = (message: { const session = yield* opencode .createSession({ title: payload.agentSession.issue.title, workspaceDirectory }) .pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "OpenCodeFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "OpenCodeFailure" }), ), ); @@ -225,8 +225,8 @@ export const processLinearWebhook = (message: { organizationId: payload.organizationId, }) .pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "StoreFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "StoreFailure" }), ), ); @@ -239,8 +239,8 @@ export const processLinearWebhook = (message: { .promptAsync({ body: promptBody, sessionId: session.id, workspaceDirectory }) .pipe( Effect.tapError(() => cancelRun()), - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "OpenCodeFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "OpenCodeFailure" }), ), ); @@ -254,8 +254,8 @@ export const processLinearWebhook = (message: { organizationId: payload.organizationId, }) .pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "StoreFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "StoreFailure" }), ), ); @@ -265,8 +265,8 @@ export const processLinearWebhook = (message: { } const projects = yield* opencode.listProjects.pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "OpenCodeFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "OpenCodeFailure" }), ), ); const labels = yield* linear @@ -275,8 +275,8 @@ export const processLinearWebhook = (message: { issueId: payload.agentSession.issue.id, }) .pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "LinearFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "LinearFailure" }), ), ); const project = matchProject({ candidates: projectCandidatesFromLabels(labels), projects }); @@ -292,8 +292,8 @@ export const processLinearWebhook = (message: { }, }) .pipe( - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "StoreFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "StoreFailure" }), ), ); yield* postActivity({ @@ -337,8 +337,8 @@ export const processLinearWebhook = (message: { }) .pipe( Effect.tapError(() => cancelRun()), - Effect.mapError( - (cause) => new LinearSessionProcessorError({ cause, reason: "OpenCodeFailure" }), + Effect.mapError((cause) => + LinearSessionProcessorError.make({ cause, reason: "OpenCodeFailure" }), ), ); return "prompted"; diff --git a/src/linear/session/store.ts b/src/linear/session/store.ts index 0bc9312..562f12f 100644 --- a/src/linear/session/store.ts +++ b/src/linear/session/store.ts @@ -16,8 +16,8 @@ export const LinearPendingInputKey = Schema.TemplateLiteral([ Schema.String.check(Schema.isMinLength(1)), ]); -export type LinearAgentSessionKey = Schema.Schema.Type; -export type LinearPendingInputKey = Schema.Schema.Type; +export type LinearAgentSessionKey = typeof LinearAgentSessionKey.Type; +export type LinearPendingInputKey = typeof LinearPendingInputKey.Type; const QuestionOption = Schema.Struct({ description: Schema.String, @@ -52,7 +52,7 @@ export const PendingOpenCodeInput = Schema.Union([ type: Schema.Literal("permission"), }), Schema.Struct({ - answers: Schema.Array(Schema.NullOr(Schema.Array(Schema.String))), + answers: Schema.String.pipe(Schema.Array, Schema.NullOr, Schema.Array), linearAgentSessionId: Schema.String, opencodeRequestId: Schema.String, opencodeSessionId: Schema.String, @@ -61,21 +61,21 @@ export const PendingOpenCodeInput = Schema.Union([ }), ]); -export type Question = Schema.Schema.Type; -export type SessionLocator = Schema.Schema.Type; -export type PendingOpenCodeInput = Schema.Schema.Type; +export type Question = typeof Question.Type; +export type SessionLocator = typeof SessionLocator.Type; +export type PendingOpenCodeInput = typeof PendingOpenCodeInput.Type; -export type LinearSessionStoreKv = { +export type LinearSessionStoreKv = { readonly delete: ( key: LinearAgentSessionKey | LinearPendingInputKey, - ) => Effect.Effect; + ) => Effect.Effect; readonly get: ( key: LinearAgentSessionKey | LinearPendingInputKey, - ) => Effect.Effect; + ) => Effect.Effect; readonly put: ( key: LinearAgentSessionKey | LinearPendingInputKey, value: string, - ) => Effect.Effect; + ) => Effect.Effect; }; export type LinearSessionStoreService = { @@ -108,7 +108,7 @@ export type LinearSessionStoreService = { export class LinearSessionStore extends Context.Service< LinearSessionStore, LinearSessionStoreService ->()("app/LinearSessionStore") {} +>()("opencode-event-bridge/linear/session/store/LinearSessionStore") {} export class LinearSessionStoreError extends Schema.TaggedErrorClass()( "LinearSessionStoreError", @@ -130,8 +130,8 @@ export const pendingInputKey = (input: { readonly linearAgentSessionId: string; }): LinearPendingInputKey => `linear:${input.organizationId}:pending:${input.linearAgentSessionId}`; -const getRecord = (input: { - readonly kv: LinearSessionStoreKv; +const getRecord = (input: { + readonly kv: LinearSessionStoreKv; readonly key: LinearAgentSessionKey | LinearPendingInputKey; readonly schema: S; }) => @@ -139,8 +139,8 @@ const getRecord = (input: { const value = yield* input.kv .get(input.key) .pipe( - Effect.mapError( - (cause) => new LinearSessionStoreError({ cause, key: input.key, reason: "StoreFailure" }), + Effect.mapError((cause) => + LinearSessionStoreError.make({ cause, key: input.key, reason: "StoreFailure" }), ), ); @@ -148,48 +148,47 @@ const getRecord = (input: { return null; } - return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(input.schema))(value).pipe( - Effect.mapError( - (cause) => new LinearSessionStoreError({ cause, key: input.key, reason: "InvalidRecord" }), + return yield* Schema.decodeEffect(Schema.fromJsonString(input.schema))(value).pipe( + Effect.mapError((cause) => + LinearSessionStoreError.make({ cause, key: input.key, reason: "InvalidRecord" }), ), ); }); -const putRecord = (input: { - readonly kv: LinearSessionStoreKv; +const putRecord = (input: { + readonly kv: LinearSessionStoreKv; readonly key: LinearAgentSessionKey | LinearPendingInputKey; readonly record: Schema.Schema.Type; readonly schema: S; }) => Schema.encodeEffect(Schema.fromJsonString(input.schema))(input.record).pipe( - Effect.mapError( - (cause) => new LinearSessionStoreError({ cause, key: input.key, reason: "InvalidRecord" }), + Effect.mapError((cause) => + LinearSessionStoreError.make({ cause, key: input.key, reason: "InvalidRecord" }), ), Effect.flatMap((value) => input.kv .put(input.key, value) .pipe( - Effect.mapError( - (cause) => - new LinearSessionStoreError({ cause, key: input.key, reason: "StoreFailure" }), + Effect.mapError((cause) => + LinearSessionStoreError.make({ cause, key: input.key, reason: "StoreFailure" }), ), ), ), ); -const deleteRecord = (input: { - readonly kv: LinearSessionStoreKv; +const deleteRecord = (input: { + readonly kv: LinearSessionStoreKv; readonly key: LinearAgentSessionKey | LinearPendingInputKey; }) => input.kv .delete(input.key) .pipe( - Effect.mapError( - (cause) => new LinearSessionStoreError({ cause, key: input.key, reason: "StoreFailure" }), + Effect.mapError((cause) => + LinearSessionStoreError.make({ cause, key: input.key, reason: "StoreFailure" }), ), ); -export const makeLinearSessionStore = (kv: LinearSessionStoreKv) => +export const makeLinearSessionStore = (kv: LinearSessionStoreKv) => LinearSessionStore.of({ deletePendingInput: (input) => deleteRecord({ key: pendingInputKey(input), kv }), deleteSessionLocator: (input) => deleteRecord({ key: agentSessionKey(input), kv }), diff --git a/src/linear/webhook/payload.ts b/src/linear/webhook/payload.ts index 015a789..fb844e7 100644 --- a/src/linear/webhook/payload.ts +++ b/src/linear/webhook/payload.ts @@ -233,15 +233,11 @@ export const LinearWebhookPayload = Schema.Union([ PromptedAgentSessionEventWebhookPayload, ]); -export type LinearWebhookPayload = Schema.Schema.Type; -export type IssueCleanupWebhookPayload = Schema.Schema.Type; -export type IssueWebhookPayload = Schema.Schema.Type; -export type CreatedAgentSessionEventWebhookPayload = Schema.Schema.Type< - typeof CreatedAgentSessionEventWebhookPayload ->; -export type PromptedAgentSessionEventWebhookPayload = Schema.Schema.Type< - typeof PromptedAgentSessionEventWebhookPayload ->; -export type StopAgentSessionEventWebhookPayload = Schema.Schema.Type< - typeof StopAgentSessionEventWebhookPayload ->; +export type LinearWebhookPayload = typeof LinearWebhookPayload.Type; +export type IssueCleanupWebhookPayload = typeof IssueCleanupWebhookPayload.Type; +export type IssueWebhookPayload = typeof IssueWebhookPayload.Type; +export type CreatedAgentSessionEventWebhookPayload = + typeof CreatedAgentSessionEventWebhookPayload.Type; +export type PromptedAgentSessionEventWebhookPayload = + typeof PromptedAgentSessionEventWebhookPayload.Type; +export type StopAgentSessionEventWebhookPayload = typeof StopAgentSessionEventWebhookPayload.Type; diff --git a/src/linear/webhook/receive.ts b/src/linear/webhook/receive.ts index 7183ca9..cc9838a 100644 --- a/src/linear/webhook/receive.ts +++ b/src/linear/webhook/receive.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import { Clock, Schema } from "effect"; import * as Effect from "effect/Effect"; import { LinearWebhookBadRequest, LinearWebhookUnauthorized } from "./api.ts"; @@ -9,32 +9,37 @@ const supportedEvents = new Set(["AgentSessionEvent", "Issue"]); const decodeAndValidateLinearWebhookPayload = (rawBody: ArrayBuffer, linearEventHeader: string) => Effect.gen(function* () { - const linearWebhookPayload = yield* Schema.decodeUnknownEffect( + const linearWebhookPayload = yield* Schema.decodeEffect( Schema.fromJsonString(LinearWebhookPayload), )(new TextDecoder().decode(rawBody)).pipe( - Effect.mapError((cause) => new LinearWebhookBadRequest({ cause, reason: "InvalidPayload" })), + Effect.mapError((cause) => LinearWebhookBadRequest.make({ cause, reason: "InvalidPayload" })), ); if (linearWebhookPayload.type !== linearEventHeader) { - return yield* new LinearWebhookBadRequest({ reason: "EventMismatch" }); + return yield* LinearWebhookBadRequest.make({ reason: "EventMismatch" }); } - if (Math.abs(Date.now() - linearWebhookPayload.webhookTimestamp) > 60_000) { - return yield* new LinearWebhookUnauthorized({ reason: "StaleTimestamp" }); + const now = yield* Clock.currentTimeMillis; + if (Math.abs(now - linearWebhookPayload.webhookTimestamp) > 60_000) { + return yield* LinearWebhookUnauthorized.make({ reason: "StaleTimestamp" }); } return linearWebhookPayload; }); -export const receiveLinearWebhook = (input: { +export const receiveLinearWebhook = (input: { readonly allowedOrganizationId: string; readonly event: string; - readonly queue: { send(payload: LinearWebhookPayload): Effect.Effect }; + readonly queue: { send(payload: LinearWebhookPayload): Effect.Effect }; readonly rawBody: ArrayBuffer; readonly signature: string; readonly webhookSecret: string; }) => Effect.gen(function* () { - yield* verifyLinearWebhookSignature(input.webhookSecret, input.signature, input.rawBody); + yield* verifyLinearWebhookSignature({ + headerSignature: input.signature, + rawBody: input.rawBody, + webhookSecret: input.webhookSecret, + }); if (!supportedEvents.has(input.event)) { return { queued: false }; @@ -42,7 +47,7 @@ export const receiveLinearWebhook = (input: { const payload = yield* decodeAndValidateLinearWebhookPayload(input.rawBody, input.event); if (payload.organizationId !== input.allowedOrganizationId) { - return yield* new LinearWebhookUnauthorized({ reason: "OrganizationNotAllowed" }); + return yield* LinearWebhookUnauthorized.make({ reason: "OrganizationNotAllowed" }); } yield* Effect.log("linear webhook payload decoded", { diff --git a/src/linear/webhook/verify.ts b/src/linear/webhook/verify.ts index 182bb3f..bf049f3 100644 --- a/src/linear/webhook/verify.ts +++ b/src/linear/webhook/verify.ts @@ -3,36 +3,36 @@ import * as Effect from "effect/Effect"; import { LinearWebhookUnauthorized } from "./api.ts"; -export const verifyLinearWebhookSignature = ( - linearWebhookSecret: string, - headerSignatureString: string, - rawBody: ArrayBuffer, -) => +export const verifyLinearWebhookSignature = (input: { + readonly headerSignature: string; + readonly rawBody: ArrayBuffer; + readonly webhookSecret: string; +}) => Effect.gen(function* () { - const signatureBytes = yield* Schema.decodeUnknownEffect(Schema.Uint8ArrayFromHex)( - headerSignatureString, + const signatureBytes = yield* Schema.decodeEffect(Schema.Uint8ArrayFromHex)( + input.headerSignature, ).pipe( - Effect.mapError( - (cause) => new LinearWebhookUnauthorized({ cause, reason: "InvalidSignature" }), + Effect.mapError((cause) => + LinearWebhookUnauthorized.make({ cause, reason: "InvalidSignature" }), ), ); const verificationSignature = new Uint8Array(signatureBytes); - const key = yield* Effect.promise(async () => + const key = yield* Effect.promise(() => crypto.subtle.importKey( "raw", - new TextEncoder().encode(linearWebhookSecret), + new TextEncoder().encode(input.webhookSecret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"], ), ); - const valid = yield* Effect.promise(async () => - crypto.subtle.verify("HMAC", key, verificationSignature, rawBody), + const valid = yield* Effect.promise(() => + crypto.subtle.verify("HMAC", key, verificationSignature, input.rawBody), ); if (!valid) { - return yield* new LinearWebhookUnauthorized({ reason: "InvalidSignature" }); + return yield* LinearWebhookUnauthorized.make({ reason: "InvalidSignature" }); } return undefined; diff --git a/src/opencode/client.ts b/src/opencode/client.ts index 6ead7f6..3fda765 100644 --- a/src/opencode/client.ts +++ b/src/opencode/client.ts @@ -1,7 +1,7 @@ import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2/client"; import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; -import { Schema } from "effect"; +import { Clock, Schema } from "effect"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -70,12 +70,12 @@ export type OpenCodeClientService = { readonly sessionId: string; readonly workspaceDirectory: string; }) => Effect.Effect<{ readonly body: string }, OpenCodeClientError>; - readonly waitUntilIdle: (input: { + readonly waitUntilIdle: (input: { readonly sessionId: string; readonly onEvent?: ( event: Event, opencodeSessionIds: ReadonlyArray, - ) => Effect.Effect; + ) => Effect.Effect; readonly requireStart?: boolean; readonly workspaceDirectory: string; }) => Effect.Effect; @@ -102,7 +102,7 @@ export type OpenCodeClientService = { }; export class OpenCodeClient extends Context.Service()( - "app/OpenCodeClient", + "opencode-event-bridge/opencode/client/OpenCodeClient", ) {} export type OpenCodeFetch = typeof fetch; @@ -115,19 +115,19 @@ type OpenCodeSdkResult = Promise< const resultData = (run: (signal: AbortSignal) => OpenCodeSdkResult) => Effect.tryPromise({ catch: (cause) => - new OpenCodeClientError({ detail: detailFrom(cause), reason: "RequestFailed" }), + OpenCodeClientError.make({ detail: detailFrom(cause), reason: "RequestFailed" }), try: run, }).pipe( Effect.flatMap((result) => { if (result.error !== undefined) { - return new OpenCodeClientError({ + return OpenCodeClientError.make({ detail: detailFrom(result.error), reason: "RequestFailed", }); } if (result.data === undefined) { - return new OpenCodeClientError({ reason: "InvalidResponse" }); + return OpenCodeClientError.make({ reason: "InvalidResponse" }); } return Effect.succeed(result.data); @@ -144,12 +144,12 @@ const resultVoid = ( ) => Effect.tryPromise({ catch: (cause) => - new OpenCodeClientError({ detail: detailFrom(cause), reason: "RequestFailed" }), + OpenCodeClientError.make({ detail: detailFrom(cause), reason: "RequestFailed" }), try: run, }).pipe( Effect.flatMap((result) => { if (result.error !== undefined) { - return new OpenCodeClientError({ + return OpenCodeClientError.make({ detail: detailFrom(result.error), reason: "RequestFailed", }); @@ -162,7 +162,7 @@ const resultVoid = ( const fetchVoid = (run: (signal: AbortSignal) => Promise) => Effect.tryPromise({ catch: (cause) => - new OpenCodeClientError({ detail: detailFrom(cause), reason: "RequestFailed" }), + OpenCodeClientError.make({ detail: detailFrom(cause), reason: "RequestFailed" }), try: run, }).pipe( Effect.flatMap((response) => { @@ -172,10 +172,10 @@ const fetchVoid = (run: (signal: AbortSignal) => Promise) => return Effect.tryPromise({ catch: (cause) => - new OpenCodeClientError({ detail: detailFrom(cause), reason: "RequestFailed" }), - try: async () => response.text(), + OpenCodeClientError.make({ detail: detailFrom(cause), reason: "RequestFailed" }), + try: () => response.text(), }).pipe( - Effect.flatMap((detail) => new OpenCodeClientError({ detail, reason: "RequestFailed" })), + Effect.flatMap((detail) => OpenCodeClientError.make({ detail, reason: "RequestFailed" })), ); }), ); @@ -282,17 +282,18 @@ const basename = (path: string) => { export const pathBasename = basename; -export const makeOpenCodeClient = ( - fetch: OpenCodeFetch, - baseUrl: string = openCodePrivateBaseUrl, -) => { - const client = createOpencodeClient({ baseUrl, fetch }); +export const makeOpenCodeClient = (options: { + readonly baseUrl?: string; + readonly fetch: OpenCodeFetch; +}) => { + const baseUrl = options.baseUrl ?? openCodePrivateBaseUrl; + const client = createOpencodeClient({ baseUrl, fetch: options.fetch }); const sessionMessages = (input: { readonly sessionId: string; readonly workspaceDirectory: string; }) => - resultData(async (signal) => + resultData((signal) => client.session.messages( { directory: input.workspaceDirectory, @@ -304,20 +305,28 @@ export const makeOpenCodeClient = ( const waitForWorkspace = ( directory: string, - started: number = Date.now(), + started?: number, ): Effect.Effect => - resultData(async (signal) => client.session.status({ directory }, { signal })).pipe( - Effect.asVoid, - Effect.catch((error) => { - if (Date.now() - started > maxWorktreeReadyMillis) { - return new OpenCodeClientError({ detail: error.detail, reason: "Timeout" }); - } + Effect.gen(function* () { + const startedAt = started ?? (yield* Clock.currentTimeMillis); + return yield* resultData((signal) => client.session.status({ directory }, { signal })).pipe( + Effect.asVoid, + Effect.catch((error) => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + if (now - startedAt > maxWorktreeReadyMillis) { + return yield* OpenCodeClientError.make({ + detail: error.detail, + reason: "Timeout", + }); + } - return Effect.sleep(waitMillis).pipe( - Effect.flatMap(() => waitForWorkspace(directory, started)), - ); - }), - ); + yield* Effect.sleep(waitMillis); + return yield* waitForWorkspace(directory, startedAt); + }), + ), + ); + }); const listSessionTree = (input: { readonly sessionId: string; @@ -332,7 +341,7 @@ export const makeOpenCodeClient = ( } seen.add(sessionId); - const children = yield* resultData(async (signal) => + const children = yield* resultData((signal) => client.session.children( { directory: input.workspaceDirectory, @@ -356,8 +365,8 @@ export const makeOpenCodeClient = ( const url = new URL(`/session/${encodeURIComponent(input.sessionId)}/abort`, baseUrl); url.searchParams.set("directory", input.workspaceDirectory); - return fetchVoid(async (signal) => - fetch(url, { + return fetchVoid((signal) => + options.fetch(url, { body: "{}", headers: { "content-type": "application/json" }, method: "POST", @@ -374,7 +383,7 @@ export const makeOpenCodeClient = ( return Effect.succeed({ body }); } - return new OpenCodeClientError({ reason: "InvalidResponse" }); + return OpenCodeClientError.make({ reason: "InvalidResponse" }); }), ), waitUntilIdle: (input) => @@ -397,12 +406,11 @@ export const makeOpenCodeClient = ( } return input.onEvent(event, sessionIds()).pipe( - Effect.mapError( - (cause) => - new OpenCodeClientError({ - detail: detailFrom(cause), - reason: "RequestFailed", - }), + Effect.mapError((cause) => + OpenCodeClientError.make({ + detail: detailFrom(cause), + reason: "RequestFailed", + }), ), ); }; @@ -427,19 +435,19 @@ export const makeOpenCodeClient = ( const detail = eventSessionError(event, tracked); if (detail !== undefined) { - return yield* new OpenCodeClientError({ detail, reason: "SessionError" }); + return yield* OpenCodeClientError.make({ detail, reason: "SessionError" }); } yield* emitEvent(event); if (eventWaitsForInput(event, tracked)) { - return yield* new OpenCodeClientError({ reason: "InputPending" }); + return yield* OpenCodeClientError.make({ reason: "InputPending" }); } return false; }); const emitPending = Effect.gen(function* () { - const permissions = yield* resultData(async (signal) => + const permissions = yield* resultData((signal) => client.permission.list({ directory: input.workspaceDirectory }, { signal }), ); const permissionEvents = permissions @@ -458,7 +466,7 @@ export const makeOpenCodeClient = ( seenPending.add(permission.key); } - const questions = yield* resultData(async (signal) => + const questions = yield* resultData((signal) => client.question.list({ directory: input.workspaceDirectory }, { signal }), ); const questionEvents = questions @@ -483,17 +491,18 @@ export const makeOpenCodeClient = ( }); const poll = (startedAt: number): Effect.Effect => Effect.gen(function* () { - if (Date.now() - startedAt > maxWaitMillis) { - return yield* new OpenCodeClientError({ reason: "Timeout" }); + const now = yield* Clock.currentTimeMillis; + if (now - startedAt > maxWaitMillis) { + return yield* OpenCodeClientError.make({ reason: "Timeout" }); } yield* refreshSessionTree; const hasPending = yield* emitPending; if (hasPending) { - return yield* new OpenCodeClientError({ reason: "InputPending" }); + return yield* OpenCodeClientError.make({ reason: "InputPending" }); } - const status = yield* resultData(async (signal) => + const status = yield* resultData((signal) => client.session.status({ directory: input.workspaceDirectory }, { signal }), ); if (sessionIds().some((id) => sessionIsActive(status, id))) { @@ -510,8 +519,8 @@ export const makeOpenCodeClient = ( const eventLoop: Effect.Effect = Effect.gen(function* () { const events = yield* Effect.tryPromise({ catch: (cause) => - new OpenCodeClientError({ detail: detailFrom(cause), reason: "RequestFailed" }), - try: async (signal) => + OpenCodeClientError.make({ detail: detailFrom(cause), reason: "RequestFailed" }), + try: (signal) => client.event.subscribe({ directory: input.workspaceDirectory }, { signal }), }); const iterator = events.stream[Symbol.asyncIterator](); @@ -519,8 +528,8 @@ export const makeOpenCodeClient = ( const loop: Effect.Effect = Effect.gen(function* () { const next = yield* Effect.tryPromise({ catch: (cause) => - new OpenCodeClientError({ detail: detailFrom(cause), reason: "RequestFailed" }), - try: async () => iterator.next(), + OpenCodeClientError.make({ detail: detailFrom(cause), reason: "RequestFailed" }), + try: () => iterator.next(), }); if (next.done === true) { @@ -535,21 +544,25 @@ export const makeOpenCodeClient = ( }); yield* refreshSessionTree; - const initialStatus = yield* resultData(async (signal) => + const initialStatus = yield* resultData((signal) => client.session.status({ directory: input.workspaceDirectory }, { signal }), ); if (sessionIds().some((id) => sessionIsActive(initialStatus, id))) { runStarted = true; } - if (!input.requireStart && sessionIds().every((id) => sessionIsIdle(initialStatus, id))) { + if ( + input.requireStart !== true && + sessionIds().every((id) => sessionIsIdle(initialStatus, id)) + ) { return; } - yield* Effect.raceFirst(eventLoop, poll(Date.now())); + const startedAt = yield* Clock.currentTimeMillis; + yield* Effect.raceFirst(eventLoop, poll(startedAt)); }), createSession: (input) => - resultData(async (signal) => + resultData((signal) => client.session.create( { directory: input.workspaceDirectory, @@ -559,7 +572,7 @@ export const makeOpenCodeClient = ( ), ).pipe(Effect.map((session) => ({ id: session.id }))), createWorktree: (input) => - resultData(async (signal) => + resultData((signal) => client.worktree.create( { directory: input.projectDirectory, @@ -574,7 +587,7 @@ export const makeOpenCodeClient = ( ), ), ), - listProjects: resultData(async (signal) => client.project.list(undefined, { signal })).pipe( + listProjects: resultData((signal) => client.project.list(undefined, { signal })).pipe( Effect.map((projects) => projects.map((project) => ({ directory: project.worktree, @@ -584,14 +597,14 @@ export const makeOpenCodeClient = ( ), ), listWorktrees: (input) => - resultData(async (signal) => + resultData((signal) => client.worktree.list({ directory: input.projectDirectory }, { signal }), ).pipe( Effect.map((worktrees) => worktrees.map((workspaceDirectory) => ({ workspaceDirectory }))), ), listSessionTree, removeWorktree: (input) => - resultData(async (signal) => + resultData((signal) => client.worktree.remove( { directory: input.projectDirectory, @@ -601,7 +614,7 @@ export const makeOpenCodeClient = ( ), ).pipe(Effect.asVoid), promptAsync: (input) => - resultVoid(async (signal) => + resultVoid((signal) => client.session.promptAsync( { agent: "build", @@ -613,7 +626,7 @@ export const makeOpenCodeClient = ( ), ), listPendingPermissions: (input) => - resultData(async (signal) => + resultData((signal) => client.permission.list({ directory: input.workspaceDirectory }, { signal }), ).pipe( Effect.map((permissions) => { @@ -622,7 +635,7 @@ export const makeOpenCodeClient = ( }), ), listPendingQuestions: (input) => - resultData(async (signal) => + resultData((signal) => client.question.list({ directory: input.workspaceDirectory }, { signal }), ).pipe( Effect.map((questions) => { @@ -631,7 +644,7 @@ export const makeOpenCodeClient = ( }), ), replyPermission: (input) => - resultData(async (signal) => + resultData((signal) => client.permission.reply( { directory: input.workspaceDirectory, @@ -642,7 +655,7 @@ export const makeOpenCodeClient = ( ), ).pipe(Effect.asVoid), replyQuestion: (input) => - resultData(async (signal) => + resultData((signal) => client.question.reply( { answers: input.answers.map((answer) => [...answer]), diff --git a/src/opencode/run-observer.ts b/src/opencode/run-observer.ts index 38a10a5..c1535b9 100644 --- a/src/opencode/run-observer.ts +++ b/src/opencode/run-observer.ts @@ -2,6 +2,7 @@ import * as Alchemy from "alchemy"; import * as Cloudflare from "alchemy/Cloudflare"; import { Schema } from "effect"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -33,7 +34,7 @@ export const OpenCodeRunRecord = Schema.Struct({ workspaceDirectory: Schema.String, }); -export type OpenCodeRunRecord = Schema.Schema.Type; +export type OpenCodeRunRecord = typeof OpenCodeRunRecord.Type; export type OpenCodeRunObserverServiceShape = { readonly cancel: (input: { readonly linearAgentSessionId: string }) => Effect.Effect; @@ -43,7 +44,7 @@ export type OpenCodeRunObserverServiceShape = { export class OpenCodeRunObserverService extends Context.Service< OpenCodeRunObserverService, OpenCodeRunObserverServiceShape ->()("app/OpenCodeRunObserver") {} +>()("opencode-event-bridge/opencode/run-observer/OpenCodeRunObserverService") {} const runKey = "run"; const alarmDelayMillis = 1_000; @@ -155,7 +156,7 @@ export const makeOpenCodeRunObserverHandler = (input: { }), ), Effect.catch((error) => { - if (error instanceof OpenCodeClientError && error.reason === "InputPending") { + if (Schema.is(OpenCodeClientError)(error) && error.reason === "InputPending") { return input.state.schedule().pipe( Effect.tap(() => Effect.log("opencode run observer awaiting input", { @@ -257,7 +258,7 @@ export default class OpenCodeRunObserver extends Cloudflare.DurableObject { + const fetchOpenCode = (input: string | URL | Request, init?: RequestInit) => { const request = input instanceof Request ? input : new Request(input, init); const url = new URL(request.url); url.protocol = "http:"; @@ -265,7 +266,7 @@ export default class OpenCodeRunObserver extends Cloudflare.DurableObject sessionStore @@ -306,8 +305,8 @@ export default class OpenCodeRunObserver extends Cloudflare.DurableObject orgAuthStore .get(key) @@ -323,14 +322,14 @@ export default class OpenCodeRunObserver extends Cloudflare.DurableObject + refresh: (record) => Effect.gen(function* () { const token = yield* linear.refreshOAuthToken({ clientId: Redacted.value(oauthClientId), clientSecret: Redacted.value(oauthClientSecret), refreshToken: record.refreshToken, }); - const now = Date.now(); + const now = yield* Clock.currentTimeMillis; return { ...record, @@ -339,7 +338,7 @@ export default class OpenCodeRunObserver extends Cloudflare.DurableObject state.storage.put(runKey, run).pipe(Effect.provideService(Alchemy.RuntimeContext, ctx)), schedule: () => - state.storage - .setAlarm(Date.now() + alarmDelayMillis) - .pipe(Effect.provideService(Alchemy.RuntimeContext, ctx)), + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + yield* state.storage.setAlarm(now + alarmDelayMillis); + }).pipe(Effect.provideService(Alchemy.RuntimeContext, ctx)), }, store, }); diff --git a/src/workers/linear-ingress.ts b/src/workers/linear-ingress.ts index e69c31e..d96451c 100644 --- a/src/workers/linear-ingress.ts +++ b/src/workers/linear-ingress.ts @@ -34,7 +34,7 @@ export default class LinearWebhookReceiver extends Cloudflare.Worker ({ main: import.meta.filename, - name: makeStageWorkerName(stack.stage, "linear-webhook"), + name: makeStageWorkerName({ stage: stack.stage, suffix: "linear-webhook" }), })), Effect.gen(function* () { const queueResource = yield* LinearWebhookQueue; @@ -109,11 +109,10 @@ export default class LinearWebhookReceiver extends Cloudflare.Worker handler.pipe( - Effect.provide(BrowserCrypto.layer), Effect.catchCause((cause) => Effect.logError("linear webhook request failed", { cause: Cause.pretty(cause), diff --git a/src/workers/linear-webhook-processor.ts b/src/workers/linear-webhook-processor.ts index 2e90d1b..5d1d3db 100644 --- a/src/workers/linear-webhook-processor.ts +++ b/src/workers/linear-webhook-processor.ts @@ -3,6 +3,7 @@ import * as Cloudflare from "alchemy/Cloudflare"; import { Stack } from "alchemy/Stack"; import { Schema } from "effect"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -46,7 +47,7 @@ export default class LinearWebhookProcessor extends Cloudflare.Worker ({ main: import.meta.filename, - name: makeStageWorkerName(stack.stage, "event-processor"), + name: makeStageWorkerName({ stage: stack.stage, suffix: "event-processor" }), url: false, })), Effect.gen(function* () { @@ -93,8 +94,8 @@ export default class LinearWebhookProcessor extends Cloudflare.Worker orgAuthStore .get(key) @@ -110,14 +111,14 @@ export default class LinearWebhookProcessor extends Cloudflare.Worker + refresh: (record) => Effect.gen(function* () { const token = yield* linear.refreshOAuthToken({ clientId: Redacted.value(oauthClientId), clientSecret: Redacted.value(oauthClientSecret), refreshToken: record.refreshToken, }); - const now = Date.now(); + const now = yield* Clock.currentTimeMillis; return { ...record, @@ -126,8 +127,8 @@ export default class LinearWebhookProcessor extends Cloudflare.Worker { + }); + const fetchOpenCode = (input: string | URL | Request, init?: RequestInit) => { const request = input instanceof Request ? input : new Request(input, init); const url = new URL(request.url); url.protocol = "http:"; @@ -135,7 +136,7 @@ export default class LinearWebhookProcessor extends Cloudflare.Worker { +export const makeStageWorkerName = (input: { readonly stage: string; readonly suffix: string }) => { const normalizedStage = - stage + input.stage .toLowerCase() .replaceAll(/[^a-z0-9-]/g, "-") .replaceAll(/-+/g, "-") .replace(/^-|-$/g, "") || "stage"; - const prefix = `oeb-${suffix}-`; + const prefix = `oeb-${input.suffix}-`; return `${prefix}${normalizedStage}`.slice(0, maxWorkerNameLength).replace(/-$/g, ""); }; diff --git a/stacks/github.ts b/stacks/github.ts index 32e7604..df7fcc6 100644 --- a/stacks/github.ts +++ b/stacks/github.ts @@ -1,18 +1,12 @@ import * as Alchemy from "alchemy"; import * as Cloudflare from "alchemy/Cloudflare"; import * as GitHub from "alchemy/GitHub"; +import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; -const env = (name: string) => - Effect.gen(function* () { - const value = process.env[name]; - if (value === undefined || value === "") { - return yield* Effect.die(`Missing environment variable: ${name}`); - } - return value; - }); +const env = (name: string) => Config.nonEmptyString(name).pipe(Effect.orDie); export default Alchemy.Stack( "GitHub", @@ -22,7 +16,9 @@ export default Alchemy.Stack( }, Effect.gen(function* () { const owner = yield* env("GITHUB_OWNER"); - const repository = process.env["GITHUB_REPOSITORY_NAME"] ?? "opencode-event-bridge"; + const repository = yield* Config.string("GITHUB_REPOSITORY_NAME").pipe( + Config.withDefault("opencode-event-bridge"), + ); const accountId = yield* env("CLOUDFLARE_ACCOUNT_ID"); const token = yield* Cloudflare.ApiToken.AccountApiToken("CIToken", { diff --git a/test/e2e/infra.test.ts b/test/e2e/infra.test.ts index 8c8475c..80d5419 100644 --- a/test/e2e/infra.test.ts +++ b/test/e2e/infra.test.ts @@ -1,27 +1,36 @@ -import { expect } from "@effect/vitest"; +import assert from "node:assert/strict"; + import * as Cloudflare from "alchemy/Cloudflare"; import * as Test from "alchemy/Test/Vitest"; +import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; import Stack from "../../alchemy.run.ts"; -const stage = process.env["STAGE"] ?? "test"; +const configuration = Effect.runSync( + Effect.gen(function* () { + return { + ci: yield* Config.boolean("CI").pipe(Config.withDefault(false)), + stage: yield* Config.string("STAGE").pipe(Config.withDefault("test")), + }; + }), +); const { afterAll, beforeAll, deploy, destroy, test } = Test.make({ providers: Cloudflare.providers(), state: Cloudflare.state(), - stage, + stage: configuration.stage, }); const stack = beforeAll(deploy(Stack), { timeout: 300_000 }); -afterAll.skipIf(!process.env["CI"])(destroy(Stack), { timeout: 300_000 }); +afterAll.skipIf(configuration.ci !== true)(destroy(Stack), { timeout: 300_000 }); test( "deploys the Linear ingress Worker", Effect.gen(function* () { const url = yield* stack; - expect(url).toBeTypeOf("string"); - expect(url).toMatch(/^https:\/\//); + assert.ok(url !== undefined); + assert.match(url, /^https:\/\//); }), ); diff --git a/test/unit/linear-client.test.ts b/test/unit/linear-client.test.ts index bc71803..3573566 100644 --- a/test/unit/linear-client.test.ts +++ b/test/unit/linear-client.test.ts @@ -1,43 +1,42 @@ +import { afterEach, describe, expect, layer, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { afterEach, describe, expect, it, vi } from "vitest"; import { LinearHttpClient } from "../../src/linear/client/client.ts"; -const forbiddenFetch: typeof fetch = async () => { - await Promise.resolve(); - return Response.json( - { - errors: [ - { - extensions: { - type: "forbidden", - userPresentableMessage: "No permission to update issue labels", +const forbiddenFetch: typeof fetch = () => + Promise.resolve( + Response.json( + { + errors: [ + { + extensions: { + type: "forbidden", + userPresentableMessage: "No permission to update issue labels", + }, + message: "Forbidden", }, - message: "Forbidden", - }, - ], - }, - { status: 401 }, + ], + }, + { status: 401 }, + ), ); -}; describe("LinearHttpClient", () => { afterEach(() => { vi.unstubAllGlobals(); }); - it("classifies typed Linear GraphQL auth errors as permission failures", async () => { - vi.stubGlobal("fetch", forbiddenFetch); - - const error = await Effect.runPromise( + layer(Layer.fresh(LinearHttpClient.layer))((it) => { + it.effect("classifies typed Linear GraphQL auth errors as permission failures", () => Effect.gen(function* () { + vi.stubGlobal("fetch", forbiddenFetch); const linear = yield* LinearHttpClient; - return yield* linear.fetchViewer("token").pipe(Effect.flip); - }).pipe(Effect.provide(Layer.fresh(LinearHttpClient.layer))), - ); + const error = yield* linear.fetchViewer("token").pipe(Effect.flip); - expect(error.reason).toBe("PermissionFailure"); - expect(error.cause).toBe("No permission to update issue labels"); + expect(error.reason).toBe("PermissionFailure"); + expect(error.cause).toBe("No permission to update issue labels"); + }), + ); }); }); diff --git a/test/unit/linear-oauth-auth.test.ts b/test/unit/linear-oauth-auth.test.ts index 69af456..eaf21ec 100644 --- a/test/unit/linear-oauth-auth.test.ts +++ b/test/unit/linear-oauth-auth.test.ts @@ -1,7 +1,9 @@ +import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import { describe, expect, it, vi } from "vitest"; +import * as Schema from "effect/Schema"; import { LinearOrgAuthStoreError, makeLinearOrgAuthStore } from "../../src/linear/oauth/auth.ts"; +import { LinearOrgAuthRecord } from "../../src/linear/oauth/schema.ts"; const record = { accessToken: "old-access-token", @@ -11,6 +13,7 @@ const record = { organizationName: "Example", refreshToken: "old-refresh-token", }; +const encodedRecord = Schema.encodeSync(Schema.fromJsonString(LinearOrgAuthRecord))(record); const makeKv = (value: string | null) => { const writes: Array<{ readonly key: string; readonly value: string }> = []; @@ -28,45 +31,50 @@ const makeKv = (value: string | null) => { }; describe("LinearOrgAuthStore", () => { - it("refreshes expired org auth and stores the refreshed record", async () => { - vi.setSystemTime(new Date("2026-05-14T00:00:00.000Z")); - const kv = makeKv(JSON.stringify(record)); - const store = makeLinearOrgAuthStore(kv.kv, (current) => - Effect.succeed({ - ...current, - accessToken: "new-access-token", - accessTokenExpiresAt: Date.now() + 3_600_000, - refreshToken: "new-refresh-token", - }), - ); + it.effect("refreshes expired org auth and stores the refreshed record", () => + Effect.gen(function* () { + const kv = makeKv(encodedRecord); + const store = makeLinearOrgAuthStore({ + kv: kv.kv, + refresh: (current) => + Effect.succeed({ + ...current, + accessToken: "new-access-token", + accessTokenExpiresAt: 3_600_000, + refreshToken: "new-refresh-token", + }), + }); - const result = await Effect.runPromise(store.getOrgAuth({ organizationId: "org-id" })); + const result = yield* store.getOrgAuth({ organizationId: "org-id" }); - expect(result.accessToken).toBe("new-access-token"); - expect(kv.writes).toEqual([ - { - key: "org-id", - value: JSON.stringify({ - ...record, - accessToken: "new-access-token", - accessTokenExpiresAt: Date.now() + 3_600_000, - refreshToken: "new-refresh-token", - }), - }, - ]); - vi.useRealTimers(); - }); + expect(result.accessToken).toBe("new-access-token"); + expect(kv.writes).toEqual([ + { + key: "org-id", + value: yield* Schema.encodeEffect(Schema.fromJsonString(LinearOrgAuthRecord))({ + ...record, + accessToken: "new-access-token", + accessTokenExpiresAt: 3_600_000, + refreshToken: "new-refresh-token", + }), + }, + ]); + }), + ); - it("surfaces refresh failures without overwriting stored auth", async () => { - const kv = makeKv(JSON.stringify(record)); - const store = makeLinearOrgAuthStore(kv.kv, () => Effect.fail("refresh failed")); + it.effect("surfaces refresh failures without overwriting stored auth", () => + Effect.gen(function* () { + const kv = makeKv(encodedRecord); + const store = makeLinearOrgAuthStore({ + kv: kv.kv, + refresh: () => Effect.fail("refresh failed"), + }); - const error = await Effect.runPromise( - store.getOrgAuth({ organizationId: "org-id" }).pipe(Effect.flip), - ); + const error = yield* store.getOrgAuth({ organizationId: "org-id" }).pipe(Effect.flip); - expect(error).toBeInstanceOf(LinearOrgAuthStoreError); - expect(error.reason).toBe("RefreshFailure"); - expect(kv.writes).toEqual([]); - }); + expect(error).toBeInstanceOf(LinearOrgAuthStoreError); + expect(error.reason).toBe("RefreshFailure"); + expect(kv.writes).toEqual([]); + }), + ); }); diff --git a/test/unit/linear-oauth-install.test.ts b/test/unit/linear-oauth-install.test.ts index dd2a5b1..c8c4989 100644 --- a/test/unit/linear-oauth-install.test.ts +++ b/test/unit/linear-oauth-install.test.ts @@ -1,7 +1,9 @@ +import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import { describe, expect, it } from "vitest"; +import * as Schema from "effect/Schema"; import { completeLinearOAuthInstall } from "../../src/linear/oauth/install.ts"; +import { LinearOrgAuthRecord } from "../../src/linear/oauth/schema.ts"; import { LinearOAuthForbidden } from "../../src/linear/webhook/api.ts"; const tokenResponse = { @@ -57,12 +59,12 @@ const makeLinear = (organizationId: string) => ({ const request = { headers: { host: "bridge.example.com" } }; describe("completeLinearOAuthInstall", () => { - it("stores auth for the allowed organization", async () => { - const state = makeState(); - const orgAuth = makeOrgAuth(); + it.effect("stores auth for the allowed organization", () => + Effect.gen(function* () { + const state = makeState(); + const orgAuth = makeOrgAuth(); - const result = await Effect.runPromise( - completeLinearOAuthInstall({ + const result = yield* completeLinearOAuthInstall({ allowedOrganizationId: "org-id", clientId: "client-id", clientSecret: "client-secret", @@ -72,32 +74,32 @@ describe("completeLinearOAuthInstall", () => { request, state: state.state, stateValue: "state-id", - }), - ); + }); - expect(result.organizationId).toBe("org-id"); - expect(state.deletes).toEqual(["state-id"]); - expect(orgAuth.writes).toEqual([ - { - key: "org-id", - value: JSON.stringify({ - accessToken: "linear-access-token", - accessTokenExpiresAt: result.accessTokenExpiresAt, - appUserId: "app-user-id", - organizationId: "org-id", - organizationName: "Example", - refreshToken: "linear-refresh-token", - }), - }, - ]); - }); + expect(result.organizationId).toBe("org-id"); + expect(state.deletes).toEqual(["state-id"]); + expect(orgAuth.writes).toEqual([ + { + key: "org-id", + value: yield* Schema.encodeEffect(Schema.fromJsonString(LinearOrgAuthRecord))({ + accessToken: "linear-access-token", + accessTokenExpiresAt: result.accessTokenExpiresAt, + appUserId: "app-user-id", + organizationId: "org-id", + organizationName: "Example", + refreshToken: "linear-refresh-token", + }), + }, + ]); + }), + ); - it("rejects other organizations before storing auth", async () => { - const state = makeState(); - const orgAuth = makeOrgAuth(); + it.effect("rejects other organizations before storing auth", () => + Effect.gen(function* () { + const state = makeState(); + const orgAuth = makeOrgAuth(); - const error = await Effect.runPromise( - completeLinearOAuthInstall({ + const error = yield* completeLinearOAuthInstall({ allowedOrganizationId: "org-id", clientId: "client-id", clientSecret: "client-secret", @@ -107,12 +109,12 @@ describe("completeLinearOAuthInstall", () => { request, state: state.state, stateValue: "state-id", - }).pipe(Effect.flip), - ); + }).pipe(Effect.flip); - expect(error).toBeInstanceOf(LinearOAuthForbidden); - expect(error.reason).toBe("OrganizationNotAllowed"); - expect(state.deletes).toEqual(["state-id"]); - expect(orgAuth.writes).toEqual([]); - }); + expect(error).toBeInstanceOf(LinearOAuthForbidden); + expect(error.reason).toBe("OrganizationNotAllowed"); + expect(state.deletes).toEqual(["state-id"]); + expect(orgAuth.writes).toEqual([]); + }), + ); }); diff --git a/test/unit/linear-session-processor.test.ts b/test/unit/linear-session-processor.test.ts index ff9ef0b..f38ce13 100644 --- a/test/unit/linear-session-processor.test.ts +++ b/test/unit/linear-session-processor.test.ts @@ -1,8 +1,9 @@ import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2/client"; +import { describe, expect, it } from "@effect/vitest"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { describe, expect, it } from "vitest"; +import * as Schema from "effect/Schema"; import type { LinearAgentActivityContent, @@ -28,14 +29,16 @@ import { LinearSessionStore, LinearSessionStoreError, makeLinearSessionStore, + PendingOpenCodeInput, pendingInputKey, + SessionLocator, } from "../../src/linear/session/store.ts"; import { OpenCodeClient, OpenCodeClientError } from "../../src/opencode/client.ts"; import { OpenCodeRunObserverService } from "../../src/opencode/run-observer.ts"; const orgAuth = { accessToken: "linear-access-token", - accessTokenExpiresAt: Date.now() + 1_000, + accessTokenExpiresAt: 1_000, appUserId: "app-user-id", organizationId: "org-id", organizationName: "Example", @@ -51,6 +54,9 @@ const locator = { projectDirectory: "/workspace/opencode-event-bridge", workspaceDirectory: "/tmp/opencode/oeb-1", }; +const encodeLocator = Schema.encodeSync(Schema.fromJsonString(SessionLocator)); +const encodePendingInput = Schema.encodeSync(Schema.fromJsonString(PendingOpenCodeInput)); +const encodeUnknown = Schema.encodeSync(Schema.UnknownFromJsonString); type ActivityRecord = { readonly agentSessionId: string; @@ -112,7 +118,7 @@ const makeLinear = (input: { if (input.labelWriteFailure === true) { return Effect.fail( - new LinearHttpClientError({ cause: "forbidden", reason: "PermissionFailure" }), + LinearHttpClientError.make({ cause: "forbidden", reason: "PermissionFailure" }), ); } @@ -200,7 +206,9 @@ const makeOpencode = (input?: { }).pipe(Effect.asVoid), removeWorktree: (worktree) => input?.removeWorktreeFailure === true - ? Effect.fail(new OpenCodeClientError({ detail: "remove failed", reason: "RequestFailed" })) + ? Effect.fail( + OpenCodeClientError.make({ detail: "remove failed", reason: "RequestFailed" }), + ) : Effect.sync(() => { input?.removals?.push(worktree.workspaceDirectory); }).pipe(Effect.asVoid), @@ -320,54 +328,47 @@ const provide = (input: { readonly cancels?: Array; readonly runs?: Array; }) => - [ - Layer.succeed(LinearHttpClient)(input.linear), - Layer.succeed(LinearOrgAuthStore)({ getOrgAuth: () => Effect.succeed(orgAuth) }), - Layer.succeed(LinearSessionStore)(makeLinearSessionStore(input.store.kv)), - Layer.succeed(OpenCodeClient)(input.opencode), - Layer.succeed(OpenCodeRunObserverService)(makeObserver(input.runs ?? [], input.cancels ?? [])), - ] satisfies [ - Layer.Layer, - Layer.Layer, - Layer.Layer, - Layer.Layer, - Layer.Layer, - ]; + Context.make(LinearHttpClient, input.linear).pipe( + Context.add(LinearOrgAuthStore, { getOrgAuth: () => Effect.succeed(orgAuth) }), + Context.add(LinearSessionStore, makeLinearSessionStore(input.store.kv)), + Context.add(OpenCodeClient, input.opencode), + Context.add(OpenCodeRunObserverService, makeObserver(input.runs ?? [], input.cancels ?? [])), + ); describe("LinearSessionStore", () => { - it("preserves invalid record cause and key", async () => { - const key = agentSessionKey({ - linearAgentSessionId: "linear-session-id", - organizationId: "org-id", - }); - const store = makeLinearSessionStore(makeKv(new Map([[key, JSON.stringify({})]])).kv); - - const error = await Effect.runPromise( - store + it.effect("preserves invalid record cause and key", () => + Effect.gen(function* () { + const key = agentSessionKey({ + linearAgentSessionId: "linear-session-id", + organizationId: "org-id", + }); + const store = makeLinearSessionStore(makeKv(new Map([[key, encodeUnknown({})]])).kv); + + const error = yield* store .getSessionLocator({ linearAgentSessionId: "linear-session-id", organizationId: "org-id" }) - .pipe(Effect.flip), - ); + .pipe(Effect.flip); - expect(error).toBeInstanceOf(LinearSessionStoreError); - expect(error.reason).toBe("InvalidRecord"); - expect(error.key).toBe(key); - }); + expect(error).toBeInstanceOf(LinearSessionStoreError); + expect(error.reason).toBe("InvalidRecord"); + expect(error.key).toBe(key); + }), + ); }); describe("processLinearWebhook", () => { - it("rejects unauthorized organizations before reading auth", async () => { - const store = makeKv(); - const prompts: Array<{ readonly body: string; readonly sessionId: string }> = []; - const activities: Array = []; - const base = createdPayload(); - const payload: LinearWebhookPayload = { - ...base, - agentSession: { ...base.agentSession, organizationId: "other-org-id" }, - organizationId: "other-org-id", - }; - - const result = await Effect.runPromise( - processAllowedWebhook(payload).pipe( + it.effect("rejects unauthorized organizations before reading auth", () => + Effect.gen(function* () { + const store = makeKv(); + const prompts: Array<{ readonly body: string; readonly sessionId: string }> = []; + const activities: Array = []; + const base = createdPayload(); + const payload: LinearWebhookPayload = { + ...base, + agentSession: { ...base.agentSession, organizationId: "other-org-id" }, + organizationId: "other-org-id", + }; + + const result = yield* processAllowedWebhook(payload).pipe( Effect.provide( provide({ linear: makeLinear({ activities }), @@ -375,24 +376,24 @@ describe("processLinearWebhook", () => { store, }), ), - ), - ); - - expect(result).toBe("organization-not-allowed"); - expect(prompts).toEqual([]); - expect(activities).toEqual([]); - }); - - it("starts a labelled issue and stores a session locator", async () => { - const store = makeKv(); - const prompts: Array<{ readonly body: string; readonly sessionId: string }> = []; - const activities: Array<{ - readonly agentSessionId: string; - readonly content: LinearAgentActivityContent; - }> = []; - - await Effect.runPromise( - processAllowedWebhook(createdPayload()).pipe( + ); + + expect(result).toBe("organization-not-allowed"); + expect(prompts).toEqual([]); + expect(activities).toEqual([]); + }), + ); + + it.effect("starts a labelled issue and stores a session locator", () => + Effect.gen(function* () { + const store = makeKv(); + const prompts: Array<{ readonly body: string; readonly sessionId: string }> = []; + const activities: Array<{ + readonly agentSessionId: string; + readonly content: LinearAgentActivityContent; + }> = []; + + yield* processAllowedWebhook(createdPayload()).pipe( Effect.provide( provide({ linear: makeLinear({ activities }), @@ -400,27 +401,27 @@ describe("processLinearWebhook", () => { store, }), ), - ), - ); + ); - expect(prompts).toEqual([{ body: "start", sessionId: "opencode-session-id" }]); - expect( - store.values.get( - agentSessionKey({ linearAgentSessionId: "linear-session-id", organizationId: "org-id" }), - ), - ).toBe(JSON.stringify(locator)); - }); - - it("elicits project selection when no label matches", async () => { - const store = makeKv(); - const activities: Array = []; - const pendingKey = pendingInputKey({ - linearAgentSessionId: "linear-session-id", - organizationId: "org-id", - }); + expect(prompts).toEqual([{ body: "start", sessionId: "opencode-session-id" }]); + expect( + store.values.get( + agentSessionKey({ linearAgentSessionId: "linear-session-id", organizationId: "org-id" }), + ), + ).toBe(encodeLocator(locator)); + }), + ); + + it.effect("elicits project selection when no label matches", () => + Effect.gen(function* () { + const store = makeKv(); + const activities: Array = []; + const pendingKey = pendingInputKey({ + linearAgentSessionId: "linear-session-id", + organizationId: "org-id", + }); - await Effect.runPromise( - processAllowedWebhook(createdPayload()).pipe( + yield* processAllowedWebhook(createdPayload()).pipe( Effect.provide( provide({ linear: makeLinear({ activities, labels: [] }), @@ -428,56 +429,59 @@ describe("processLinearWebhook", () => { store, }), ), - ), - ); - - expect(activities.at(-1)?.content.type).toBe("elicitation"); - expect(activities.at(-1)?.signal).toBe("select"); - expect(store.values.get(pendingKey)).toBe( - JSON.stringify({ - linearAgentSessionId: "linear-session-id", - promptContext: "start", - type: "projectSelection", - }), - ); - }); - - it("matches plain and grouped project labels", async () => { - const labels = [ - [{ id: "plain", name: "opencode-event-bridge", parent: null }], - [{ id: "grouped", name: "opencode-event-bridge", parent: { name: "repo" } }], - ]; - - await Promise.all( - labels.map(async (group) => { - const store = makeKv(); - const prompts: Array<{ readonly body: string; readonly sessionId: string }> = []; - const activities: Array = []; - - await Effect.runPromise( - processAllowedWebhook(createdPayload()).pipe( - Effect.provide( - provide({ - linear: makeLinear({ activities, labels: group }), - opencode: makeOpencode({ prompts }), - store, - }), - ), - ), - ); - - expect(prompts).toEqual([{ body: "start", sessionId: "opencode-session-id" }]); - }), - ); - }); - - it("elicits project selection when labels are ambiguous", async () => { - const store = makeKv(); - const prompts: Array<{ readonly body: string; readonly sessionId: string }> = []; - const activities: Array = []; - - await Effect.runPromise( - processAllowedWebhook(createdPayload()).pipe( + ); + + expect(activities.at(-1)?.content.type).toBe("elicitation"); + expect(activities.at(-1)?.signal).toBe("select"); + expect(store.values.get(pendingKey)).toBe( + encodePendingInput({ + linearAgentSessionId: "linear-session-id", + promptContext: "start", + type: "projectSelection", + }), + ); + }), + ); + + it.effect("matches plain and grouped project labels", () => + Effect.gen(function* () { + const labels = [ + [{ id: "plain", name: "opencode-event-bridge", parent: null }], + [{ id: "grouped", name: "opencode-event-bridge", parent: { name: "repo" } }], + ]; + + yield* Effect.forEach( + labels, + (group) => + Effect.gen(function* () { + const store = makeKv(); + const prompts: Array<{ readonly body: string; readonly sessionId: string }> = []; + const activities: Array = []; + + yield* processAllowedWebhook(createdPayload()).pipe( + Effect.provide( + provide({ + linear: makeLinear({ activities, labels: group }), + opencode: makeOpencode({ prompts }), + store, + }), + ), + ); + + expect(prompts).toEqual([{ body: "start", sessionId: "opencode-session-id" }]); + }), + { discard: true }, + ); + }), + ); + + it.effect("elicits project selection when labels are ambiguous", () => + Effect.gen(function* () { + const store = makeKv(); + const prompts: Array<{ readonly body: string; readonly sessionId: string }> = []; + const activities: Array = []; + + yield* processAllowedWebhook(createdPayload()).pipe( Effect.provide( provide({ linear: makeLinear({ activities }), @@ -499,31 +503,31 @@ describe("processLinearWebhook", () => { store, }), ), - ), - ); - - expect(prompts).toEqual([]); - expect(activities.at(-1)?.content.type).toBe("elicitation"); - expect(activities.at(-1)?.signalMetadata?.options).toEqual([ - { label: "opencode-event-bridge", value: "opencode-event-bridge" }, - { label: "other", value: "other" }, - ]); - }); - - it("continues an existing OpenCode session before reading labels", async () => { - const key = agentSessionKey({ - linearAgentSessionId: "linear-session-id", - organizationId: "org-id", - }); - const store = makeKv(new Map([[key, JSON.stringify(locator)]])); - const prompts: Array<{ readonly body: string; readonly sessionId: string }> = []; - const activities: Array<{ - readonly agentSessionId: string; - readonly content: LinearAgentActivityContent; - }> = []; - - await Effect.runPromise( - processAllowedWebhook(promptedPayload("continue")).pipe( + ); + + expect(prompts).toEqual([]); + expect(activities.at(-1)?.content.type).toBe("elicitation"); + expect(activities.at(-1)?.signalMetadata?.options).toEqual([ + { label: "opencode-event-bridge", value: "opencode-event-bridge" }, + { label: "other", value: "other" }, + ]); + }), + ); + + it.effect("continues an existing OpenCode session before reading labels", () => + Effect.gen(function* () { + const key = agentSessionKey({ + linearAgentSessionId: "linear-session-id", + organizationId: "org-id", + }); + const store = makeKv(new Map([[key, encodeLocator(locator)]])); + const prompts: Array<{ readonly body: string; readonly sessionId: string }> = []; + const activities: Array<{ + readonly agentSessionId: string; + readonly content: LinearAgentActivityContent; + }> = []; + + yield* processAllowedWebhook(promptedPayload("continue")).pipe( Effect.provide( provide({ linear: makeLinear({ activities, labels: [] }), @@ -531,35 +535,35 @@ describe("processLinearWebhook", () => { store, }), ), - ), - ); + ); - expect(prompts).toEqual([{ body: "continue", sessionId: "opencode-session-id" }]); - }); + expect(prompts).toEqual([{ body: "continue", sessionId: "opencode-session-id" }]); + }), + ); - it("starts after project selection and writes the project label", async () => { - const pendingKey = pendingInputKey({ - linearAgentSessionId: "linear-session-id", - organizationId: "org-id", - }); - const store = makeKv( - new Map([ - [ - pendingKey, - JSON.stringify({ - linearAgentSessionId: "linear-session-id", - promptContext: "original prompt context", - type: "projectSelection", - }), - ], - ]), - ); - const prompts: Array<{ readonly body: string; readonly sessionId: string }> = []; - const labelWrites: Array = []; - const activities: Array = []; - - await Effect.runPromise( - processAllowedWebhook(promptedPayload("opencode-event-bridge")).pipe( + it.effect("starts after project selection and writes the project label", () => + Effect.gen(function* () { + const pendingKey = pendingInputKey({ + linearAgentSessionId: "linear-session-id", + organizationId: "org-id", + }); + const store = makeKv( + new Map([ + [ + pendingKey, + encodePendingInput({ + linearAgentSessionId: "linear-session-id", + promptContext: "original prompt context", + type: "projectSelection", + }), + ], + ]), + ); + const prompts: Array<{ readonly body: string; readonly sessionId: string }> = []; + const labelWrites: Array = []; + const activities: Array = []; + + yield* processAllowedWebhook(promptedPayload("opencode-event-bridge")).pipe( Effect.provide( provide({ linear: makeLinear({ activities, labelWrites, labels: [] }), @@ -567,23 +571,23 @@ describe("processLinearWebhook", () => { store, }), ), - ), - ); - - expect(labelWrites).toEqual(["repo:opencode-event-bridge"]); - expect(prompts).toEqual([ - { body: "original prompt context", sessionId: "opencode-session-id" }, - ]); - expect(store.values.has(pendingKey)).toBe(false); - }); - - it("continues startup and warns when project label write is rejected", async () => { - const store = makeKv(); - const prompts: Array<{ readonly body: string; readonly sessionId: string }> = []; - const activities: Array = []; - - await Effect.runPromise( - processAllowedWebhook(promptedPayload("opencode-event-bridge")).pipe( + ); + + expect(labelWrites).toEqual(["repo:opencode-event-bridge"]); + expect(prompts).toEqual([ + { body: "original prompt context", sessionId: "opencode-session-id" }, + ]); + expect(store.values.has(pendingKey)).toBe(false); + }), + ); + + it.effect("continues startup and warns when project label write is rejected", () => + Effect.gen(function* () { + const store = makeKv(); + const prompts: Array<{ readonly body: string; readonly sessionId: string }> = []; + const activities: Array = []; + + yield* processAllowedWebhook(promptedPayload("opencode-event-bridge")).pipe( Effect.provide( provide({ linear: makeLinear({ activities, labelWriteFailure: true, labels: [] }), @@ -591,28 +595,28 @@ describe("processLinearWebhook", () => { store, }), ), - ), - ); - - expect(prompts).toEqual([{ body: "start", sessionId: "opencode-session-id" }]); - expect(activities.map((activity) => activity.content)).toContainEqual({ - body: "Warning: could not write Linear repo label.", - type: "response", - }); - }); - - it("does not start when project label write fails for a non-permission reason", async () => { - const store = makeKv(); - const prompts: Array<{ readonly body: string; readonly sessionId: string }> = []; - const activities: Array = []; - - const result = await Effect.runPromise( - processAllowedWebhook(promptedPayload("opencode-event-bridge")).pipe( + ); + + expect(prompts).toEqual([{ body: "start", sessionId: "opencode-session-id" }]); + expect(activities.map((activity) => activity.content)).toContainEqual({ + body: "Warning: could not write Linear repo label.", + type: "response", + }); + }), + ); + + it.effect("does not start when project label write fails for a non-permission reason", () => + Effect.gen(function* () { + const store = makeKv(); + const prompts: Array<{ readonly body: string; readonly sessionId: string }> = []; + const activities: Array = []; + + const result = yield* processAllowedWebhook(promptedPayload("opencode-event-bridge")).pipe( Effect.provide( provide({ linear: makeLinear({ activities, - labelWriteError: new LinearHttpClientError({ + labelWriteError: LinearHttpClientError.make({ cause: "schema changed", reason: "InvalidGraphqlResponse", }), @@ -622,54 +626,54 @@ describe("processLinearWebhook", () => { store, }), ), - ), - ); + ); - expect(result).toBe("error"); - expect(prompts).toEqual([]); - expect( - store.values.has( - agentSessionKey({ linearAgentSessionId: "linear-session-id", organizationId: "org-id" }), - ), - ).toBe(false); - expect(activities.map((activity) => activity.content)).toContainEqual({ - body: "OpenCode run failed. Check bridge logs.", - type: "error", - }); - }); - - it("replies to pending permission and clears pending input", async () => { - const sessionKey = agentSessionKey({ - linearAgentSessionId: "linear-session-id", - organizationId: "org-id", - }); - const pendingKey = pendingInputKey({ - linearAgentSessionId: "linear-session-id", - organizationId: "org-id", - }); - const store = makeKv( - new Map([ - [sessionKey, JSON.stringify(locator)], - [ - pendingKey, - JSON.stringify({ - linearAgentSessionId: "linear-session-id", - opencodeRequestId: "permission-id", - opencodeSessionId: "opencode-session-id", - type: "permission", - }), - ], - ]), - ); - const permissionReplies: Array = []; - const runs: Array = []; - const activities: Array<{ - readonly agentSessionId: string; - readonly content: LinearAgentActivityContent; - }> = []; - - await Effect.runPromise( - processAllowedWebhook(promptedPayload("Approve Always")).pipe( + expect(result).toBe("error"); + expect(prompts).toEqual([]); + expect( + store.values.has( + agentSessionKey({ linearAgentSessionId: "linear-session-id", organizationId: "org-id" }), + ), + ).toBe(false); + expect(activities.map((activity) => activity.content)).toContainEqual({ + body: "OpenCode run failed. Check bridge logs.", + type: "error", + }); + }), + ); + + it.effect("replies to pending permission and clears pending input", () => + Effect.gen(function* () { + const sessionKey = agentSessionKey({ + linearAgentSessionId: "linear-session-id", + organizationId: "org-id", + }); + const pendingKey = pendingInputKey({ + linearAgentSessionId: "linear-session-id", + organizationId: "org-id", + }); + const store = makeKv( + new Map([ + [sessionKey, encodeLocator(locator)], + [ + pendingKey, + encodePendingInput({ + linearAgentSessionId: "linear-session-id", + opencodeRequestId: "permission-id", + opencodeSessionId: "opencode-session-id", + type: "permission", + }), + ], + ]), + ); + const permissionReplies: Array = []; + const runs: Array = []; + const activities: Array<{ + readonly agentSessionId: string; + readonly content: LinearAgentActivityContent; + }> = []; + + yield* processAllowedWebhook(promptedPayload("Approve Always")).pipe( Effect.provide( provide({ linear: makeLinear({ activities }), @@ -678,37 +682,37 @@ describe("processLinearWebhook", () => { store, }), ), - ), - ); + ); + + expect(permissionReplies).toEqual(["always"]); + expect(store.values.has(pendingKey)).toBe(false); + expect(runs).toEqual([ + { + linearAgentSessionId: "linear-session-id", + opencodeSessionId: "opencode-session-id", + organizationId: "org-id", + projectDirectory: "/workspace/opencode-event-bridge", + workspaceDirectory: "/tmp/opencode/oeb-1", + }, + ]); + }), + ); - expect(permissionReplies).toEqual(["always"]); - expect(store.values.has(pendingKey)).toBe(false); - expect(runs).toEqual([ - { + it.effect("recovers pending permission from OpenCode before continuing", () => + Effect.gen(function* () { + const sessionKey = agentSessionKey({ linearAgentSessionId: "linear-session-id", - opencodeSessionId: "opencode-session-id", organizationId: "org-id", - projectDirectory: "/workspace/opencode-event-bridge", - workspaceDirectory: "/tmp/opencode/oeb-1", - }, - ]); - }); + }); + const pendingKey = pendingInputKey({ + linearAgentSessionId: "linear-session-id", + organizationId: "org-id", + }); + const store = makeKv(new Map([[sessionKey, encodeLocator(locator)]])); + const prompts: Array<{ readonly body: string; readonly sessionId: string }> = []; + const activities: Array = []; - it("recovers pending permission from OpenCode before continuing", async () => { - const sessionKey = agentSessionKey({ - linearAgentSessionId: "linear-session-id", - organizationId: "org-id", - }); - const pendingKey = pendingInputKey({ - linearAgentSessionId: "linear-session-id", - organizationId: "org-id", - }); - const store = makeKv(new Map([[sessionKey, JSON.stringify(locator)]])); - const prompts: Array<{ readonly body: string; readonly sessionId: string }> = []; - const activities: Array = []; - - await Effect.runPromise( - processAllowedWebhook(promptedPayload("continue")).pipe( + yield* processAllowedWebhook(promptedPayload("continue")).pipe( Effect.provide( provide({ linear: makeLinear({ activities }), @@ -729,67 +733,67 @@ describe("processLinearWebhook", () => { store, }), ), - ), - ); - - expect(prompts).toEqual([]); - expect(activities.at(-1)?.signal).toBe("select"); - expect(store.values.get(pendingKey)).toBe( - JSON.stringify({ + ); + + expect(prompts).toEqual([]); + expect(activities.at(-1)?.signal).toBe("select"); + expect(store.values.get(pendingKey)).toBe( + encodePendingInput({ + linearAgentSessionId: "linear-session-id", + opencodeRequestId: "permission-id", + opencodeSessionId: "child-session-id", + type: "permission", + }), + ); + }), + ); + + it.effect("collects multi-question replies before replying to OpenCode", () => + Effect.gen(function* () { + const sessionKey = agentSessionKey({ linearAgentSessionId: "linear-session-id", - opencodeRequestId: "permission-id", - opencodeSessionId: "child-session-id", - type: "permission", - }), - ); - }); - - it("collects multi-question replies before replying to OpenCode", async () => { - const sessionKey = agentSessionKey({ - linearAgentSessionId: "linear-session-id", - organizationId: "org-id", - }); - const pendingKey = pendingInputKey({ - linearAgentSessionId: "linear-session-id", - organizationId: "org-id", - }); - const questions = [ - { - header: "Color", - options: [{ description: "Red choice", label: "Red" }], - question: "Pick color", - }, - { - header: "Animal", - options: [{ description: "Dog choice", label: "Dog" }], - question: "Pick animal", - }, - ]; - const store = makeKv( - new Map([ - [sessionKey, JSON.stringify(locator)], - [ - pendingKey, - JSON.stringify({ - answers: [null, null], - linearAgentSessionId: "linear-session-id", - opencodeRequestId: "question-id", - opencodeSessionId: "opencode-session-id", - questions, - type: "question", - }), - ], - ]), - ); - const questionReplies: Array<{ - readonly answers: ReadonlyArray>; - readonly requestId: string; - }> = []; - const runs: Array = []; - const activities: Array = []; - - await Effect.runPromise( - processAllowedWebhook(promptedPayload("Red")).pipe( + organizationId: "org-id", + }); + const pendingKey = pendingInputKey({ + linearAgentSessionId: "linear-session-id", + organizationId: "org-id", + }); + const questions = [ + { + header: "Color", + options: [{ description: "Red choice", label: "Red" }], + question: "Pick color", + }, + { + header: "Animal", + options: [{ description: "Dog choice", label: "Dog" }], + question: "Pick animal", + }, + ]; + const store = makeKv( + new Map([ + [sessionKey, encodeLocator(locator)], + [ + pendingKey, + encodePendingInput({ + answers: [null, null], + linearAgentSessionId: "linear-session-id", + opencodeRequestId: "question-id", + opencodeSessionId: "opencode-session-id", + questions, + type: "question", + }), + ], + ]), + ); + const questionReplies: Array<{ + readonly answers: ReadonlyArray>; + readonly requestId: string; + }> = []; + const runs: Array = []; + const activities: Array = []; + + yield* processAllowedWebhook(promptedPayload("Red")).pipe( Effect.provide( provide({ linear: makeLinear({ activities }), @@ -798,17 +802,15 @@ describe("processLinearWebhook", () => { store, }), ), - ), - ); + ); - expect(questionReplies).toEqual([]); - expect(activities.at(-1)?.content).toEqual({ - body: "Pick animal\n\n- Dog: Dog choice", - type: "elicitation", - }); + expect(questionReplies).toEqual([]); + expect(activities.at(-1)?.content).toEqual({ + body: "Pick animal\n\n- Dog: Dog choice", + type: "elicitation", + }); - await Effect.runPromise( - processAllowedWebhook(promptedPayload("Dog")).pipe( + yield* processAllowedWebhook(promptedPayload("Dog")).pipe( Effect.provide( provide({ linear: makeLinear({ activities }), @@ -817,65 +819,65 @@ describe("processLinearWebhook", () => { store, }), ), - ), - ); + ); - expect(questionReplies).toEqual([{ answers: [["Red"], ["Dog"]], requestId: "question-id" }]); - expect(store.values.has(pendingKey)).toBe(false); - expect(runs).toEqual([ - { + expect(questionReplies).toEqual([{ answers: [["Red"], ["Dog"]], requestId: "question-id" }]); + expect(store.values.has(pendingKey)).toBe(false); + expect(runs).toEqual([ + { + linearAgentSessionId: "linear-session-id", + opencodeSessionId: "opencode-session-id", + organizationId: "org-id", + projectDirectory: "/workspace/opencode-event-bridge", + workspaceDirectory: "/tmp/opencode/oeb-1", + }, + ]); + }), + ); + + it.effect("sends multiple selected answers for one OpenCode question", () => + Effect.gen(function* () { + const sessionKey = agentSessionKey({ linearAgentSessionId: "linear-session-id", - opencodeSessionId: "opencode-session-id", organizationId: "org-id", - projectDirectory: "/workspace/opencode-event-bridge", - workspaceDirectory: "/tmp/opencode/oeb-1", - }, - ]); - }); - - it("sends multiple selected answers for one OpenCode question", async () => { - const sessionKey = agentSessionKey({ - linearAgentSessionId: "linear-session-id", - organizationId: "org-id", - }); - const pendingKey = pendingInputKey({ - linearAgentSessionId: "linear-session-id", - organizationId: "org-id", - }); - const store = makeKv( - new Map([ - [sessionKey, JSON.stringify(locator)], - [ - pendingKey, - JSON.stringify({ - answers: [null], - linearAgentSessionId: "linear-session-id", - opencodeRequestId: "question-id", - opencodeSessionId: "opencode-session-id", - questions: [ - { - header: "Colors", - multiple: true, - options: [ - { description: "Red choice", label: "Red" }, - { description: "Blue choice", label: "Blue" }, - ], - question: "Pick colors", - }, - ], - type: "question", - }), - ], - ]), - ); - const questionReplies: Array<{ - readonly answers: ReadonlyArray>; - readonly requestId: string; - }> = []; - const activities: Array = []; - - await Effect.runPromise( - processAllowedWebhook(promptedPayload("red, blue")).pipe( + }); + const pendingKey = pendingInputKey({ + linearAgentSessionId: "linear-session-id", + organizationId: "org-id", + }); + const store = makeKv( + new Map([ + [sessionKey, encodeLocator(locator)], + [ + pendingKey, + encodePendingInput({ + answers: [null], + linearAgentSessionId: "linear-session-id", + opencodeRequestId: "question-id", + opencodeSessionId: "opencode-session-id", + questions: [ + { + header: "Colors", + multiple: true, + options: [ + { description: "Red choice", label: "Red" }, + { description: "Blue choice", label: "Blue" }, + ], + question: "Pick colors", + }, + ], + type: "question", + }), + ], + ]), + ); + const questionReplies: Array<{ + readonly answers: ReadonlyArray>; + readonly requestId: string; + }> = []; + const activities: Array = []; + + yield* processAllowedWebhook(promptedPayload("red, blue")).pipe( Effect.provide( provide({ linear: makeLinear({ activities }), @@ -883,28 +885,28 @@ describe("processLinearWebhook", () => { store, }), ), - ), - ); + ); - expect(questionReplies).toEqual([{ answers: [["Red", "Blue"]], requestId: "question-id" }]); - expect(store.values.has(pendingKey)).toBe(false); - }); + expect(questionReplies).toEqual([{ answers: [["Red", "Blue"]], requestId: "question-id" }]); + expect(store.values.has(pendingKey)).toBe(false); + }), + ); - it("aborts on stop signal and posts stopped", async () => { - const key = agentSessionKey({ - linearAgentSessionId: "linear-session-id", - organizationId: "org-id", - }); - const store = makeKv(new Map([[key, JSON.stringify(locator)]])); - const aborts: Array = []; - const cancels: Array = []; - const activities: Array<{ - readonly agentSessionId: string; - readonly content: LinearAgentActivityContent; - }> = []; - - await Effect.runPromise( - processAllowedWebhook(stopPayload()).pipe( + it.effect("aborts on stop signal and posts stopped", () => + Effect.gen(function* () { + const key = agentSessionKey({ + linearAgentSessionId: "linear-session-id", + organizationId: "org-id", + }); + const store = makeKv(new Map([[key, encodeLocator(locator)]])); + const aborts: Array = []; + const cancels: Array = []; + const activities: Array<{ + readonly agentSessionId: string; + readonly content: LinearAgentActivityContent; + }> = []; + + yield* processAllowedWebhook(stopPayload()).pipe( Effect.provide( provide({ cancels, @@ -913,30 +915,30 @@ describe("processLinearWebhook", () => { store, }), ), - ), - ); + ); - expect(aborts).toEqual(["opencode-session-id"]); - expect(cancels).toEqual(["linear-session-id"]); - expect(activities.at(-1)?.content).toEqual({ body: "Stopped.", type: "response" }); - }); + expect(aborts).toEqual(["opencode-session-id"]); + expect(cancels).toEqual(["linear-session-id"]); + expect(activities.at(-1)?.content).toEqual({ body: "Stopped.", type: "response" }); + }), + ); - it("cleans up completed issues after aborting sessions", async () => { - const key = agentSessionKey({ - linearAgentSessionId: "linear-session-id", - organizationId: "org-id", - }); - const store = makeKv(new Map([[key, JSON.stringify(locator)]])); - const aborts: Array = []; - const cancels: Array = []; - const removals: Array = []; - const activities: Array<{ - readonly agentSessionId: string; - readonly content: LinearAgentActivityContent; - }> = []; - - await Effect.runPromise( - processAllowedWebhook(issuePayload()).pipe( + it.effect("cleans up completed issues after aborting sessions", () => + Effect.gen(function* () { + const key = agentSessionKey({ + linearAgentSessionId: "linear-session-id", + organizationId: "org-id", + }); + const store = makeKv(new Map([[key, encodeLocator(locator)]])); + const aborts: Array = []; + const cancels: Array = []; + const removals: Array = []; + const activities: Array<{ + readonly agentSessionId: string; + readonly content: LinearAgentActivityContent; + }> = []; + + yield* processAllowedWebhook(issuePayload()).pipe( Effect.provide( provide({ cancels, @@ -945,28 +947,28 @@ describe("processLinearWebhook", () => { store, }), ), - ), - ); - - expect(aborts).toEqual(["opencode-session-id"]); - expect(cancels).toEqual(["linear-session-id"]); - expect(removals).toEqual(["/tmp/opencode/oeb-1"]); - expect(store.values.has(key)).toBe(false); - }); - - it("keeps cleanup state when worktree removal fails", async () => { - const key = agentSessionKey({ - linearAgentSessionId: "linear-session-id", - organizationId: "org-id", - }); - const store = makeKv(new Map([[key, JSON.stringify(locator)]])); - const aborts: Array = []; - const cancels: Array = []; - const removals: Array = []; - const activities: Array = []; - - const error = await Effect.runPromise( - processAllowedWebhook(issuePayload()).pipe( + ); + + expect(aborts).toEqual(["opencode-session-id"]); + expect(cancels).toEqual(["linear-session-id"]); + expect(removals).toEqual(["/tmp/opencode/oeb-1"]); + expect(store.values.has(key)).toBe(false); + }), + ); + + it.effect("keeps cleanup state when worktree removal fails", () => + Effect.gen(function* () { + const key = agentSessionKey({ + linearAgentSessionId: "linear-session-id", + organizationId: "org-id", + }); + const store = makeKv(new Map([[key, encodeLocator(locator)]])); + const aborts: Array = []; + const cancels: Array = []; + const removals: Array = []; + const activities: Array = []; + + const error = yield* processAllowedWebhook(issuePayload()).pipe( Effect.provide( provide({ cancels, @@ -976,43 +978,43 @@ describe("processLinearWebhook", () => { }), ), Effect.flip, - ), - ); - - expect(error).toMatchObject({ reason: "OpenCodeFailure" }); - expect(aborts).toEqual(["opencode-session-id"]); - expect(cancels).toEqual(["linear-session-id"]); - expect(removals).toEqual([]); - expect(store.values.has(key)).toBe(true); - }); - - it("dedupes worktree removal across issue agent sessions", async () => { - const first = agentSessionKey({ - linearAgentSessionId: "linear-session-id", - organizationId: "org-id", - }); - const second = agentSessionKey({ - linearAgentSessionId: "linear-session-2", - organizationId: "org-id", - }); - const secondLocator = { - ...locator, - linearAgentSessionId: "linear-session-2", - opencodeSessionId: "opencode-session-2", - }; - const store = makeKv( - new Map([ - [first, JSON.stringify(locator)], - [second, JSON.stringify(secondLocator)], - ]), - ); - const aborts: Array = []; - const cancels: Array = []; - const removals: Array = []; - const activities: Array = []; - - await Effect.runPromise( - processAllowedWebhook(issuePayload()).pipe( + ); + + expect(error).toMatchObject({ reason: "OpenCodeFailure" }); + expect(aborts).toEqual(["opencode-session-id"]); + expect(cancels).toEqual(["linear-session-id"]); + expect(removals).toEqual([]); + expect(store.values.has(key)).toBe(true); + }), + ); + + it.effect("dedupes worktree removal across issue agent sessions", () => + Effect.gen(function* () { + const first = agentSessionKey({ + linearAgentSessionId: "linear-session-id", + organizationId: "org-id", + }); + const second = agentSessionKey({ + linearAgentSessionId: "linear-session-2", + organizationId: "org-id", + }); + const secondLocator = { + ...locator, + linearAgentSessionId: "linear-session-2", + opencodeSessionId: "opencode-session-2", + }; + const store = makeKv( + new Map([ + [first, encodeLocator(locator)], + [second, encodeLocator(secondLocator)], + ]), + ); + const aborts: Array = []; + const cancels: Array = []; + const removals: Array = []; + const activities: Array = []; + + yield* processAllowedWebhook(issuePayload()).pipe( Effect.provide( provide({ cancels, @@ -1024,13 +1026,13 @@ describe("processLinearWebhook", () => { store, }), ), - ), - ); - - expect(aborts).toEqual(["opencode-session-id", "opencode-session-2"]); - expect(cancels).toEqual(["linear-session-id", "linear-session-2"]); - expect(removals).toEqual(["/tmp/opencode/oeb-1"]); - expect(store.values.has(first)).toBe(false); - expect(store.values.has(second)).toBe(false); - }); + ); + + expect(aborts).toEqual(["opencode-session-id", "opencode-session-2"]); + expect(cancels).toEqual(["linear-session-id", "linear-session-2"]); + expect(removals).toEqual(["/tmp/opencode/oeb-1"]); + expect(store.values.has(first)).toBe(false); + expect(store.values.has(second)).toBe(false); + }), + ); }); diff --git a/test/unit/linear-webhook-flow.test.ts b/test/unit/linear-webhook-flow.test.ts index 797c543..dbc3549 100644 --- a/test/unit/linear-webhook-flow.test.ts +++ b/test/unit/linear-webhook-flow.test.ts @@ -1,7 +1,6 @@ +import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; -import { describe, expect, it, vi } from "vitest"; import type { LinearAgentActivityContent, @@ -54,7 +53,7 @@ const makeOrgAuthStore = () => ({ getOrgAuth: () => Effect.succeed({ accessToken: "linear-access-token", - accessTokenExpiresAt: Date.now() + 1_000, + accessTokenExpiresAt: 1_000, appUserId: "app-user-id", organizationId: "org-id", organizationName: "Example", @@ -149,7 +148,7 @@ const createdPayload = (): LinearWebhookPayload => ({ promptContext: "start", type: "AgentSessionEvent", webhookId: "webhook-id", - webhookTimestamp: Date.now(), + webhookTimestamp: 0, }); const rawBodyOf = (payload: LinearWebhookPayload) => { @@ -158,26 +157,28 @@ const rawBodyOf = (payload: LinearWebhookPayload) => { }; const sign = (rawBody: ArrayBuffer) => - Effect.promise(async () => { - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { hash: "SHA-256", name: "HMAC" }, - false, - ["sign"], + Effect.gen(function* () { + const key = yield* Effect.promise(() => + crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { hash: "SHA-256", name: "HMAC" }, + false, + ["sign"], + ), ); - const bytes = new Uint8Array(await crypto.subtle.sign("HMAC", key, rawBody)); + const signed = yield* Effect.promise(() => crypto.subtle.sign("HMAC", key, rawBody)); + const bytes = new Uint8Array(signed); return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); }); -const jsonBodyOf = (request: Request) => - Effect.promise(async () => { - if (request.body === null) { - return null; - } +const jsonBodyOf = (request: Request) => { + if (request.body === null) { + return Effect.succeed(null); + } - return request.json(); - }); + return Effect.promise(() => request.json()); +}; const responseFor = (request: OpenCodeRequest, messageRequests: number, statusRequests: number) => { if (request.url === "http://127.0.0.1:4096/project") { @@ -263,7 +264,7 @@ const makeRecordingFetch = (requests: Ref.Ref>): let messageRequests = 0; let statusRequests = 0; - return async (input, init) => { + return (input, init) => { const request = input instanceof Request ? input : new Request(input, init); const effect = Effect.gen(function* () { const observed = { @@ -293,10 +294,8 @@ const makeRecordingFetch = (requests: Ref.Ref>): }; describe("Linear webhook to OpenCode", () => { - it("receives a signed created agent session and sends OpenCode requests", async () => { - vi.setSystemTime(new Date("2026-05-14T00:00:00.000Z")); - - await Effect.gen(function* () { + it.effect("receives a signed created agent session and sends OpenCode requests", () => + Effect.gen(function* () { const queued = yield* Ref.make>([]); const requests = yield* Ref.make>([]); const activities = yield* Ref.make< @@ -332,20 +331,20 @@ describe("Linear webhook to OpenCode", () => { yield* Effect.forEach(messages, (message) => processAllowedWebhook(message), { discard: true, }).pipe( - Effect.provide([ - Layer.succeed(LinearHttpClient)(makeLinear(activities, links)), - Layer.succeed(LinearOrgAuthStore)(makeOrgAuthStore()), - Layer.succeed(LinearSessionStore)(makeLinearSessionStore(store.kv)), - Layer.succeed(OpenCodeClient)( - makeOpenCodeClient(makeRecordingFetch(requests), opencodeBaseUrl), - ), - Layer.succeed(OpenCodeRunObserverService)( - OpenCodeRunObserverService.of({ - cancel: () => Effect.void, - start: (run) => Ref.update(runs, (items) => [...items, run]), - }), - ), - ]), + Effect.provideService(LinearHttpClient, makeLinear(activities, links)), + Effect.provideService(LinearOrgAuthStore, makeOrgAuthStore()), + Effect.provideService(LinearSessionStore, makeLinearSessionStore(store.kv)), + Effect.provideService( + OpenCodeClient, + makeOpenCodeClient({ baseUrl: opencodeBaseUrl, fetch: makeRecordingFetch(requests) }), + ), + Effect.provideService( + OpenCodeRunObserverService, + OpenCodeRunObserverService.of({ + cancel: () => Effect.void, + start: (run) => Ref.update(runs, (items) => [...items, run]), + }), + ), ); const observed = yield* Ref.get(requests); @@ -416,6 +415,6 @@ describe("Linear webhook to OpenCode", () => { ], }, ]); - }).pipe(Effect.runPromise); - }); + }), + ); }); diff --git a/test/unit/linear-webhook.test.ts b/test/unit/linear-webhook.test.ts index ea87d48..63f7b15 100644 --- a/test/unit/linear-webhook.test.ts +++ b/test/unit/linear-webhook.test.ts @@ -1,7 +1,9 @@ +import { beforeEach, describeWrapped, expect, layer } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import * as Schema from "effect/Schema"; +import { TestClock } from "effect/testing"; import type { LinearWebhookPayload } from "../../src/linear/webhook/payload.ts"; @@ -32,7 +34,7 @@ const issuePayload = (): LinearWebhookPayload => ({ updatedFrom: null, url: "https://linear.app/example/issue/OEB-1", webhookId: "webhook-id", - webhookTimestamp: Date.now(), + webhookTimestamp: 0, }); const bodyOf = (payload: LinearWebhookPayload) => { @@ -41,32 +43,30 @@ const bodyOf = (payload: LinearWebhookPayload) => { }; const sign = (raw: ArrayBuffer) => - Effect.promise(async () => { - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { hash: "SHA-256", name: "HMAC" }, - false, - ["sign"], + Effect.gen(function* () { + const key = yield* Effect.promise(() => + crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { hash: "SHA-256", name: "HMAC" }, + false, + ["sign"], + ), ); - const bytes = new Uint8Array(await crypto.subtle.sign("HMAC", key, raw)); + const signed = yield* Effect.promise(() => crypto.subtle.sign("HMAC", key, raw)); + const bytes = new Uint8Array(signed); return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); }); -describe("receiveLinearWebhook", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it("verifies and queues supported webhook events", async () => { - vi.setSystemTime(new Date("2026-05-14T00:00:00.000Z")); - const payload = issuePayload(); - const rawBody = bodyOf(payload); - const signature = await Effect.runPromise(sign(rawBody)); - const sent: Array = []; +describeWrapped("receiveLinearWebhook", (it) => { + it.effect("verifies and queues supported webhook events", () => + Effect.gen(function* () { + const payload = issuePayload(); + const rawBody = bodyOf(payload); + const signature = yield* sign(rawBody); + const sent: Array = []; - const result = await Effect.runPromise( - receiveLinearWebhook({ + const result = yield* receiveLinearWebhook({ allowedOrganizationId: "org-id", event: "Issue", queue: { @@ -75,20 +75,20 @@ describe("receiveLinearWebhook", () => { rawBody, signature, webhookSecret: secret, - }), - ); + }); - expect(result).toEqual({ queued: true }); - expect(sent).toEqual([payload]); - }); + expect(result).toEqual({ queued: true }); + expect(sent).toEqual([payload]); + }), + ); - it("ignores unsupported signed events without decoding the payload", async () => { - const rawBody = bodyOf(issuePayload()); - const signature = await Effect.runPromise(sign(rawBody)); - const sent: Array = []; + it.effect("ignores unsupported signed events without decoding the payload", () => + Effect.gen(function* () { + const rawBody = bodyOf(issuePayload()); + const signature = yield* sign(rawBody); + const sent: Array = []; - const result = await Effect.runPromise( - receiveLinearWebhook({ + const result = yield* receiveLinearWebhook({ allowedOrganizationId: "org-id", event: "Reaction", queue: { @@ -97,40 +97,39 @@ describe("receiveLinearWebhook", () => { rawBody, signature, webhookSecret: secret, - }), - ); + }); - expect(result).toEqual({ queued: false }); - expect(sent).toEqual([]); - }); + expect(result).toEqual({ queued: false }); + expect(sent).toEqual([]); + }), + ); - it("rejects invalid signatures", async () => { - const rawBody = bodyOf(issuePayload()); + it.effect("rejects invalid signatures", () => + Effect.gen(function* () { + const rawBody = bodyOf(issuePayload()); - const error = await Effect.runPromise( - receiveLinearWebhook({ + const error = yield* receiveLinearWebhook({ allowedOrganizationId: "org-id", event: "Issue", queue: { send: () => Effect.void }, rawBody, signature: "00", webhookSecret: secret, - }).pipe(Effect.flip), - ); + }).pipe(Effect.flip); - expect(error).toBeInstanceOf(LinearWebhookUnauthorized); - expect(error.reason).toBe("InvalidSignature"); - }); + expect(error).toBeInstanceOf(LinearWebhookUnauthorized); + expect(error.reason).toBe("InvalidSignature"); + }), + ); - it("rejects signed webhook payloads from other organizations", async () => { - vi.setSystemTime(new Date("2026-05-14T00:00:00.000Z")); - const payload: LinearWebhookPayload = { ...issuePayload(), organizationId: "other-org-id" }; - const rawBody = bodyOf(payload); - const signature = await Effect.runPromise(sign(rawBody)); - const sent: Array = []; + it.effect("rejects signed webhook payloads from other organizations", () => + Effect.gen(function* () { + const payload: LinearWebhookPayload = { ...issuePayload(), organizationId: "other-org-id" }; + const rawBody = bodyOf(payload); + const signature = yield* sign(rawBody); + const sent: Array = []; - const error = await Effect.runPromise( - receiveLinearWebhook({ + const error = yield* receiveLinearWebhook({ allowedOrganizationId: "org-id", event: "Issue", queue: { @@ -139,96 +138,102 @@ describe("receiveLinearWebhook", () => { rawBody, signature, webhookSecret: secret, - }).pipe(Effect.flip), - ); - - expect(error).toBeInstanceOf(LinearWebhookUnauthorized); - expect(error.reason).toBe("OrganizationNotAllowed"); - expect(sent).toEqual([]); - }); - - it("preserves invalid payload decode cause", async () => { - const bytes = new TextEncoder().encode(JSON.stringify({ type: "Issue" })); - const rawBody = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); - const signature = await Effect.runPromise(sign(rawBody)); - - const error = await Effect.runPromise( - receiveLinearWebhook({ + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(LinearWebhookUnauthorized); + expect(error.reason).toBe("OrganizationNotAllowed"); + expect(sent).toEqual([]); + }), + ); + + it.effect("preserves invalid payload decode cause", () => + Effect.gen(function* () { + const serialized = yield* Schema.encodeEffect(Schema.UnknownFromJsonString)({ + type: "Issue", + }); + const bytes = new TextEncoder().encode(serialized); + const rawBody = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + const signature = yield* sign(rawBody); + + const error = yield* receiveLinearWebhook({ allowedOrganizationId: "org-id", event: "Issue", queue: { send: () => Effect.void }, rawBody, signature, webhookSecret: secret, - }).pipe(Effect.flip), - ); + }).pipe(Effect.flip); - expect(error).toBeInstanceOf(LinearWebhookBadRequest); - expect(error.reason).toBe("InvalidPayload"); - expect(String(error.cause)).toContain("createdAt"); - }); + expect(error).toBeInstanceOf(LinearWebhookBadRequest); + expect(error.reason).toBe("InvalidPayload"); + expect(String(error.cause)).toContain("createdAt"); + }), + ); - it("rejects mismatched event headers", async () => { - const rawBody = bodyOf(issuePayload()); - const signature = await Effect.runPromise(sign(rawBody)); + it.effect("rejects mismatched event headers", () => + Effect.gen(function* () { + const rawBody = bodyOf(issuePayload()); + const signature = yield* sign(rawBody); - const error = await Effect.runPromise( - receiveLinearWebhook({ + const error = yield* receiveLinearWebhook({ allowedOrganizationId: "org-id", event: "AgentSessionEvent", queue: { send: () => Effect.void }, rawBody, signature, webhookSecret: secret, - }).pipe(Effect.flip), - ); + }).pipe(Effect.flip); - expect(error).toBeInstanceOf(LinearWebhookBadRequest); - expect(error.reason).toBe("EventMismatch"); - }); + expect(error).toBeInstanceOf(LinearWebhookBadRequest); + expect(error.reason).toBe("EventMismatch"); + }), + ); - it("rejects stale webhook timestamps", async () => { - vi.setSystemTime(new Date("2026-05-14T00:00:00.000Z")); - const payload = issuePayload(); - vi.setSystemTime(new Date("2026-05-14T00:02:01.000Z")); - const rawBody = bodyOf(payload); - const signature = await Effect.runPromise(sign(rawBody)); + it.effect("rejects stale webhook timestamps", () => + Effect.gen(function* () { + const payload = issuePayload(); + yield* TestClock.adjust(121_000); + const rawBody = bodyOf(payload); + const signature = yield* sign(rawBody); - const error = await Effect.runPromise( - receiveLinearWebhook({ + const error = yield* receiveLinearWebhook({ allowedOrganizationId: "org-id", event: "Issue", queue: { send: () => Effect.void }, rawBody, signature, webhookSecret: secret, - }).pipe(Effect.flip), - ); + }).pipe(Effect.flip); - expect(error).toBeInstanceOf(LinearWebhookUnauthorized); - expect(error.reason).toBe("StaleTimestamp"); - }); + expect(error).toBeInstanceOf(LinearWebhookUnauthorized); + expect(error.reason).toBe("StaleTimestamp"); + }), + ); +}); + +const logs: Array = []; +const logger = Logger.make((options) => { + logs.push(options.message); }); -describe("decodeLinearWebhookQueueMessage", () => { - it("logs invalid queue payload with decode cause and message id", async () => { - const logs: Array = []; - const logger = Logger.make((options) => { - logs.push(options.message); - }); +beforeEach(() => { + logs.length = 0; +}); - const result = await Effect.runPromise( - decodeLinearWebhookQueueMessage({ +layer(Logger.layer([logger]))("decodeLinearWebhookQueueMessage", (it) => { + it.effect("logs invalid queue payload with decode cause and message id", () => + Effect.gen(function* () { + const result = yield* decodeLinearWebhookQueueMessage({ body: { type: "AgentSessionEvent" }, messageId: "msg-id", - }).pipe(Effect.provide(Logger.layer([logger]))), - ); + }); - expect(Option.isNone(result)).toBe(true); - const rendered = logs.map((log) => JSON.stringify(log)).join("\n"); + expect(Option.isNone(result)).toBe(true); + const rendered = logs.map((log) => JSON.stringify(log)).join("\n"); - expect(rendered).toContain("invalid linear webhook queue message"); - expect(rendered).toContain("msg-id"); - expect(rendered).toContain("webhookId"); - }); + expect(rendered).toContain("invalid linear webhook queue message"); + expect(rendered).toContain("msg-id"); + expect(rendered).toContain("webhookId"); + }), + ); }); diff --git a/test/unit/opencode-client.test.ts b/test/unit/opencode-client.test.ts index 4fb6f2d..bd02cb9 100644 --- a/test/unit/opencode-client.test.ts +++ b/test/unit/opencode-client.test.ts @@ -1,5 +1,7 @@ +import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import { describe, expect, it } from "vitest"; +import * as Fiber from "effect/Fiber"; +import { TestClock } from "effect/testing"; import { makeOpenCodeClient } from "../../src/opencode/client.ts"; @@ -7,158 +9,165 @@ const baseUrl = "http://127.0.0.1:4096"; const makeFetch = (handler: (request: Request) => Response | Promise): typeof fetch => - async (input, init) => { - await Promise.resolve(); - return handler(input instanceof Request ? input : new Request(input, init)); - }; + (input, init) => + Promise.resolve(handler(input instanceof Request ? input : new Request(input, init))); describe("OpenCodeClient", () => { - it("aborts sessions with workspace routing", async () => { - const requests: Array<{ - readonly body: string; - readonly contentType: string | null; - readonly method: string; - readonly url: string; - }> = []; - const client = makeOpenCodeClient( - makeFetch(async (request) => { - requests.push({ - body: await request.text(), - contentType: request.headers.get("content-type"), - method: request.method, - url: request.url, - }); - - if (request.url === `${baseUrl}/session/session-id/abort?directory=%2Ftmp%2Fwork`) { - return Response.json(true); - } - - return new Response("unexpected request", { status: 500 }); - }), - baseUrl, - ); - - await Effect.runPromise( - client.abortSession({ sessionId: "session-id", workspaceDirectory: "/tmp/work" }), - ); - - expect(requests).toEqual([ - { - body: "{}", - contentType: "application/json", - method: "POST", - url: `${baseUrl}/session/session-id/abort?directory=%2Ftmp%2Fwork`, - }, - ]); - }); - - it("does not subscribe when session status is already idle", async () => { - const requests: Array<{ readonly method: string; readonly url: string }> = []; - const client = makeOpenCodeClient( - makeFetch((request) => { - requests.push({ method: request.method, url: request.url }); - - if (request.url === `${baseUrl}/session/session-id/children?directory=%2Ftmp%2Fwork`) { - return Response.json([]); - } - - if (request.url === `${baseUrl}/session/status?directory=%2Ftmp%2Fwork`) { - return Response.json({ "session-id": { type: "idle" } }); - } - - return new Response("unexpected request", { status: 500 }); - }), - baseUrl, - ); - - await Effect.runPromise( - client.waitUntilIdle({ sessionId: "session-id", workspaceDirectory: "/tmp/work" }), - ); - - expect(requests).toEqual([ - { method: "GET", url: `${baseUrl}/session/session-id/children?directory=%2Ftmp%2Fwork` }, - { method: "GET", url: `${baseUrl}/session/status?directory=%2Ftmp%2Fwork` }, - ]); - }); - - it("waits for a required run start before accepting idle", async () => { - const requests: Array<{ readonly method: string; readonly url: string }> = []; - const statuses: Array = []; - const client = makeOpenCodeClient( - makeFetch((request) => { - requests.push({ method: request.method, url: request.url }); - - if (request.url === `${baseUrl}/session/session-id/children?directory=%2Ftmp%2Fwork`) { - return Response.json([]); - } - - if (request.url === `${baseUrl}/permission?directory=%2Ftmp%2Fwork`) { - return Response.json([]); - } - - if (request.url === `${baseUrl}/question?directory=%2Ftmp%2Fwork`) { - return Response.json([]); - } - - if (request.url === `${baseUrl}/event?directory=%2Ftmp%2Fwork`) { - return new Response("", { headers: { "content-type": "text/event-stream" } }); - } - - if (request.url === `${baseUrl}/session/status?directory=%2Ftmp%2Fwork`) { - const status = statuses.length === 0 ? "idle" : statuses.length === 1 ? "busy" : "idle"; - statuses.push(status); - return Response.json({ "session-id": { type: status } }); - } - - return new Response("unexpected request", { status: 500 }); - }), - baseUrl, - ); - - await Effect.runPromise( - client.waitUntilIdle({ - requireStart: true, + it.effect("aborts sessions with workspace routing", () => + Effect.gen(function* () { + const requests: Array<{ + readonly body: string; + readonly contentType: string | null; + readonly method: string; + readonly url: string; + }> = []; + const client = makeOpenCodeClient({ + baseUrl, + fetch: makeFetch((request) => + request.text().then((body) => { + requests.push({ + body, + contentType: request.headers.get("content-type"), + method: request.method, + url: request.url, + }); + + if (request.url === `${baseUrl}/session/session-id/abort?directory=%2Ftmp%2Fwork`) { + return Response.json(true); + } + + return new Response("unexpected request", { status: 500 }); + }), + ), + }); + + yield* client.abortSession({ sessionId: "session-id", workspaceDirectory: "/tmp/work" }); + + expect(requests).toEqual([ + { + body: "{}", + contentType: "application/json", + method: "POST", + url: `${baseUrl}/session/session-id/abort?directory=%2Ftmp%2Fwork`, + }, + ]); + }), + ); + + it.effect("does not subscribe when session status is already idle", () => + Effect.gen(function* () { + const requests: Array<{ readonly method: string; readonly url: string }> = []; + const client = makeOpenCodeClient({ + baseUrl, + fetch: makeFetch((request) => { + requests.push({ method: request.method, url: request.url }); + + if (request.url === `${baseUrl}/session/session-id/children?directory=%2Ftmp%2Fwork`) { + return Response.json([]); + } + + if (request.url === `${baseUrl}/session/status?directory=%2Ftmp%2Fwork`) { + return Response.json({ "session-id": { type: "idle" } }); + } + + return new Response("unexpected request", { status: 500 }); + }), + }); + + yield* client.waitUntilIdle({ sessionId: "session-id", workspaceDirectory: "/tmp/work" }); + + expect(requests).toEqual([ + { method: "GET", url: `${baseUrl}/session/session-id/children?directory=%2Ftmp%2Fwork` }, + { method: "GET", url: `${baseUrl}/session/status?directory=%2Ftmp%2Fwork` }, + ]); + }), + ); + + it.effect("waits for a required run start before accepting idle", () => + Effect.gen(function* () { + const requests: Array<{ readonly method: string; readonly url: string }> = []; + const statuses: Array = []; + const client = makeOpenCodeClient({ + baseUrl, + fetch: makeFetch((request) => { + requests.push({ method: request.method, url: request.url }); + + if (request.url === `${baseUrl}/session/session-id/children?directory=%2Ftmp%2Fwork`) { + return Response.json([]); + } + + if (request.url === `${baseUrl}/permission?directory=%2Ftmp%2Fwork`) { + return Response.json([]); + } + + if (request.url === `${baseUrl}/question?directory=%2Ftmp%2Fwork`) { + return Response.json([]); + } + + if (request.url === `${baseUrl}/event?directory=%2Ftmp%2Fwork`) { + return new Response("", { headers: { "content-type": "text/event-stream" } }); + } + + if (request.url === `${baseUrl}/session/status?directory=%2Ftmp%2Fwork`) { + const status = statuses.length === 0 ? "idle" : statuses.length === 1 ? "busy" : "idle"; + statuses.push(status); + return Response.json({ "session-id": { type: status } }); + } + + return new Response("unexpected request", { status: 500 }); + }), + }); + + const fiber = yield* client + .waitUntilIdle({ + requireStart: true, + sessionId: "session-id", + workspaceDirectory: "/tmp/work", + }) + .pipe(Effect.forkChild); + yield* TestClock.adjust(1_000); + yield* Fiber.join(fiber); + + expect(statuses).toEqual(["idle", "busy", "idle"]); + expect(requests).toContainEqual({ + method: "GET", + url: `${baseUrl}/event?directory=%2Ftmp%2Fwork`, + }); + }), + ); + + it.effect("builds the final response from completed assistant text parts", () => + Effect.gen(function* () { + const client = makeOpenCodeClient({ + baseUrl, + fetch: makeFetch((request) => { + if (request.url === `${baseUrl}/session/session-id/message?directory=%2Ftmp%2Fwork`) { + return Response.json([ + { + info: { id: "user-message-id", role: "user", time: { completed: 1 } }, + parts: [{ text: "ignore", type: "text" }], + }, + { + info: { id: "assistant-message-id", role: "assistant", time: { completed: 2 } }, + parts: [ + { text: "hello ", type: "text" }, + { text: "ignored", type: "tool" }, + { text: "world", type: "text" }, + ], + }, + ]); + } + + return new Response("unexpected request", { status: 500 }); + }), + }); + + const response = yield* client.completedResponse({ sessionId: "session-id", workspaceDirectory: "/tmp/work", - }), - ); - - expect(statuses).toEqual(["idle", "busy", "idle"]); - expect(requests).toContainEqual({ - method: "GET", - url: `${baseUrl}/event?directory=%2Ftmp%2Fwork`, - }); - }); - - it("builds the final response from completed assistant text parts", async () => { - const client = makeOpenCodeClient( - makeFetch((request) => { - if (request.url === `${baseUrl}/session/session-id/message?directory=%2Ftmp%2Fwork`) { - return Response.json([ - { - info: { id: "user-message-id", role: "user", time: { completed: 1 } }, - parts: [{ text: "ignore", type: "text" }], - }, - { - info: { id: "assistant-message-id", role: "assistant", time: { completed: 2 } }, - parts: [ - { text: "hello ", type: "text" }, - { text: "ignored", type: "tool" }, - { text: "world", type: "text" }, - ], - }, - ]); - } - - return new Response("unexpected request", { status: 500 }); - }), - baseUrl, - ); - - const response = await Effect.runPromise( - client.completedResponse({ sessionId: "session-id", workspaceDirectory: "/tmp/work" }), - ); - - expect(response).toEqual({ body: "hello world" }); - }); + }); + + expect(response).toEqual({ body: "hello world" }); + }), + ); }); diff --git a/test/unit/opencode-events-process.test.ts b/test/unit/opencode-events-process.test.ts index aa920ab..8500014 100644 --- a/test/unit/opencode-events-process.test.ts +++ b/test/unit/opencode-events-process.test.ts @@ -1,8 +1,8 @@ import type { Event } from "@opencode-ai/sdk/v2/client"; +import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { describe, expect, it } from "vitest"; +import * as Schema from "effect/Schema"; import type { LinearAgentActivityContent, @@ -18,6 +18,7 @@ import { LinearHttpClient } from "../../src/linear/client/client.ts"; import { LinearSessionStore, makeLinearSessionStore, + PendingOpenCodeInput, pendingInputKey, } from "../../src/linear/session/store.ts"; import { processOpenCodeEvent } from "../../src/opencode/events/process.ts"; @@ -84,317 +85,306 @@ const run = { opencodeSessionIds: ["opencode-session-id"], organizationId: "org-id", }; +const encodePendingInput = Schema.encodeEffect(Schema.fromJsonString(PendingOpenCodeInput)); describe("processOpenCodeEvent", () => { - it("maps todo updates to Linear plan states", async () => { - const store = makeKv(); - const activities: Array = []; - const plans: Array< - ReadonlyArray<{ - readonly content: string; - readonly status: "pending" | "inProgress" | "completed" | "canceled"; - }> - > = []; - const event: Event = { - id: "event-id", - properties: { - sessionID: "opencode-session-id", - todos: [ - { content: "Build", priority: "high", status: "in_progress" }, - { content: "Ship", priority: "medium", status: "cancelled" }, - ], - }, - type: "todo.updated", - }; + it.effect("maps todo updates to Linear plan states", () => + Effect.gen(function* () { + const store = makeKv(); + const activities: Array = []; + const plans: Array< + ReadonlyArray<{ + readonly content: string; + readonly status: "pending" | "inProgress" | "completed" | "canceled"; + }> + > = []; + const event: Event = { + id: "event-id", + properties: { + sessionID: "opencode-session-id", + todos: [ + { content: "Build", priority: "high", status: "in_progress" }, + { content: "Ship", priority: "medium", status: "cancelled" }, + ], + }, + type: "todo.updated", + }; - await Effect.runPromise( - processOpenCodeEvent({ event, run }).pipe( - Effect.provide([ - Layer.succeed(LinearHttpClient)(makeLinear({ activities, plans })), - Layer.succeed(LinearSessionStore)(makeLinearSessionStore(store.kv)), - ]), - ), - ); + yield* processOpenCodeEvent({ event, run }).pipe( + Effect.provideService(LinearHttpClient, makeLinear({ activities, plans })), + Effect.provideService(LinearSessionStore, makeLinearSessionStore(store.kv)), + ); - expect(plans).toEqual([ - [ - { content: "Build", status: "inProgress" }, - { content: "Ship", status: "canceled" }, - ], - ]); - }); + expect(plans).toEqual([ + [ + { content: "Build", status: "inProgress" }, + { content: "Ship", status: "canceled" }, + ], + ]); + }), + ); - it("stores permission requests and posts elicitation", async () => { - const store = makeKv(); - const activities: Array = []; - const plans: Array< - ReadonlyArray<{ - readonly content: string; - readonly status: "pending" | "inProgress" | "completed" | "canceled"; - }> - > = []; - const event: Event = { - id: "event-id", - properties: { - always: [], - id: "permission-id", - metadata: {}, - patterns: ["*"], - permission: "Run command?", - sessionID: "opencode-session-id", - }, - type: "permission.asked", - }; + it.effect("stores permission requests and posts elicitation", () => + Effect.gen(function* () { + const store = makeKv(); + const activities: Array = []; + const plans: Array< + ReadonlyArray<{ + readonly content: string; + readonly status: "pending" | "inProgress" | "completed" | "canceled"; + }> + > = []; + const event: Event = { + id: "event-id", + properties: { + always: [], + id: "permission-id", + metadata: {}, + patterns: ["*"], + permission: "Run command?", + sessionID: "opencode-session-id", + }, + type: "permission.asked", + }; - await Effect.runPromise( - processOpenCodeEvent({ event, run }).pipe( - Effect.provide([ - Layer.succeed(LinearHttpClient)(makeLinear({ activities, plans })), - Layer.succeed(LinearSessionStore)(makeLinearSessionStore(store.kv)), - ]), - ), - ); + yield* processOpenCodeEvent({ event, run }).pipe( + Effect.provideService(LinearHttpClient, makeLinear({ activities, plans })), + Effect.provideService(LinearSessionStore, makeLinearSessionStore(store.kv)), + ); - expect(activities).toEqual([ - { - agentSessionId: "linear-session-id", - content: { body: "Run command?\n\nApprove\nApprove Always\nReject", type: "elicitation" }, - signal: "select", - signalMetadata: { - options: [ - { label: "Approve", value: "Approve" }, - { label: "Approve Always", value: "Approve Always" }, - { label: "Reject", value: "Reject" }, - ], + expect(activities).toEqual([ + { + agentSessionId: "linear-session-id", + content: { body: "Run command?\n\nApprove\nApprove Always\nReject", type: "elicitation" }, + signal: "select", + signalMetadata: { + options: [ + { label: "Approve", value: "Approve" }, + { label: "Approve Always", value: "Approve Always" }, + { label: "Reject", value: "Reject" }, + ], + }, }, - }, - ]); - expect( - store.values.get( - pendingInputKey({ linearAgentSessionId: "linear-session-id", organizationId: "org-id" }), - ), - ).toBe( - JSON.stringify({ - linearAgentSessionId: "linear-session-id", - opencodeRequestId: "permission-id", - opencodeSessionId: "opencode-session-id", - type: "permission", - }), - ); - }); + ]); + expect( + store.values.get( + pendingInputKey({ linearAgentSessionId: "linear-session-id", organizationId: "org-id" }), + ), + ).toBe( + yield* encodePendingInput({ + linearAgentSessionId: "linear-session-id", + opencodeRequestId: "permission-id", + opencodeSessionId: "opencode-session-id", + type: "permission", + }), + ); + }), + ); - it("stores child session permission requests for a tracked run", async () => { - const store = makeKv(); - const activities: Array = []; - const plans: Array< - ReadonlyArray<{ - readonly content: string; - readonly status: "pending" | "inProgress" | "completed" | "canceled"; - }> - > = []; - const event: Event = { - id: "event-id", - properties: { - always: [], - id: "permission-id", - metadata: {}, - patterns: ["*"], - permission: "Run command?", - sessionID: "child-session-id", - }, - type: "permission.asked", - }; + it.effect("stores child session permission requests for a tracked run", () => + Effect.gen(function* () { + const store = makeKv(); + const activities: Array = []; + const plans: Array< + ReadonlyArray<{ + readonly content: string; + readonly status: "pending" | "inProgress" | "completed" | "canceled"; + }> + > = []; + const event: Event = { + id: "event-id", + properties: { + always: [], + id: "permission-id", + metadata: {}, + patterns: ["*"], + permission: "Run command?", + sessionID: "child-session-id", + }, + type: "permission.asked", + }; - await Effect.runPromise( - processOpenCodeEvent({ + yield* processOpenCodeEvent({ event, run: { ...run, opencodeSessionIds: ["opencode-session-id", "child-session-id"] }, }).pipe( - Effect.provide([ - Layer.succeed(LinearHttpClient)(makeLinear({ activities, plans })), - Layer.succeed(LinearSessionStore)(makeLinearSessionStore(store.kv)), - ]), - ), - ); + Effect.provideService(LinearHttpClient, makeLinear({ activities, plans })), + Effect.provideService(LinearSessionStore, makeLinearSessionStore(store.kv)), + ); - expect( - store.values.get( - pendingInputKey({ linearAgentSessionId: "linear-session-id", organizationId: "org-id" }), - ), - ).toBe( - JSON.stringify({ - linearAgentSessionId: "linear-session-id", - opencodeRequestId: "permission-id", - opencodeSessionId: "child-session-id", - type: "permission", - }), - ); - }); + expect( + store.values.get( + pendingInputKey({ linearAgentSessionId: "linear-session-id", organizationId: "org-id" }), + ), + ).toBe( + yield* encodePendingInput({ + linearAgentSessionId: "linear-session-id", + opencodeRequestId: "permission-id", + opencodeSessionId: "child-session-id", + type: "permission", + }), + ); + }), + ); - it("posts session errors to Linear", async () => { - const store = makeKv(); - const activities: Array = []; - const plans: Array< - ReadonlyArray<{ - readonly content: string; - readonly status: "pending" | "inProgress" | "completed" | "canceled"; - }> - > = []; - const event: Event = { - id: "event-id", - properties: { - error: { data: { message: "boom" }, name: "UnknownError" }, - sessionID: "opencode-session-id", - }, - type: "session.error", - }; + it.effect("posts session errors to Linear", () => + Effect.gen(function* () { + const store = makeKv(); + const activities: Array = []; + const plans: Array< + ReadonlyArray<{ + readonly content: string; + readonly status: "pending" | "inProgress" | "completed" | "canceled"; + }> + > = []; + const event: Event = { + id: "event-id", + properties: { + error: { data: { message: "boom" }, name: "UnknownError" }, + sessionID: "opencode-session-id", + }, + type: "session.error", + }; - await Effect.runPromise( - processOpenCodeEvent({ event, run }).pipe( - Effect.provide([ - Layer.succeed(LinearHttpClient)(makeLinear({ activities, plans })), - Layer.succeed(LinearSessionStore)(makeLinearSessionStore(store.kv)), - ]), - ), - ); + yield* processOpenCodeEvent({ event, run }).pipe( + Effect.provideService(LinearHttpClient, makeLinear({ activities, plans })), + Effect.provideService(LinearSessionStore, makeLinearSessionStore(store.kv)), + ); - expect(activities).toEqual([ - { - agentSessionId: "linear-session-id", - content: { - body: JSON.stringify({ data: { message: "boom" }, name: "UnknownError" }), - type: "error", + expect(activities).toEqual([ + { + agentSessionId: "linear-session-id", + content: { + body: '{"data":{"message":"boom"},"name":"UnknownError"}', + type: "error", + }, + signal: undefined, + signalMetadata: undefined, }, - signal: undefined, - signalMetadata: undefined, - }, - ]); - }); + ]); + }), + ); - it("stores questions and posts the first elicitation", async () => { - const store = makeKv(); - const activities: Array = []; - const plans: Array< - ReadonlyArray<{ - readonly content: string; - readonly status: "pending" | "inProgress" | "completed" | "canceled"; - }> - > = []; - const event: Event = { - id: "event-id", - properties: { - id: "question-id", - questions: [ - { - header: "Color", - options: [{ description: "Red choice", label: "Red" }], - question: "Pick color", - }, - ], - sessionID: "opencode-session-id", - }, - type: "question.asked", - }; + it.effect("stores questions and posts the first elicitation", () => + Effect.gen(function* () { + const store = makeKv(); + const activities: Array = []; + const plans: Array< + ReadonlyArray<{ + readonly content: string; + readonly status: "pending" | "inProgress" | "completed" | "canceled"; + }> + > = []; + const event: Event = { + id: "event-id", + properties: { + id: "question-id", + questions: [ + { + header: "Color", + options: [{ description: "Red choice", label: "Red" }], + question: "Pick color", + }, + ], + sessionID: "opencode-session-id", + }, + type: "question.asked", + }; - await Effect.runPromise( - processOpenCodeEvent({ event, run }).pipe( - Effect.provide([ - Layer.succeed(LinearHttpClient)(makeLinear({ activities, plans })), - Layer.succeed(LinearSessionStore)(makeLinearSessionStore(store.kv)), - ]), - ), - ); + yield* processOpenCodeEvent({ event, run }).pipe( + Effect.provideService(LinearHttpClient, makeLinear({ activities, plans })), + Effect.provideService(LinearSessionStore, makeLinearSessionStore(store.kv)), + ); - expect(activities).toEqual([ - { - agentSessionId: "linear-session-id", - content: { body: "Pick color\n\n- Red: Red choice", type: "elicitation" }, - signal: "select", - signalMetadata: { options: [{ label: "Red", value: "Red" }] }, - }, - ]); - expect( - store.values.get( - pendingInputKey({ linearAgentSessionId: "linear-session-id", organizationId: "org-id" }), - ), - ).toBe( - JSON.stringify({ - answers: [null], - linearAgentSessionId: "linear-session-id", - opencodeRequestId: "question-id", - opencodeSessionId: "opencode-session-id", - questions: [ - { - header: "Color", - options: [{ description: "Red choice", label: "Red" }], - question: "Pick color", - }, - ], - type: "question", - }), - ); - }); + expect(activities).toEqual([ + { + agentSessionId: "linear-session-id", + content: { body: "Pick color\n\n- Red: Red choice", type: "elicitation" }, + signal: "select", + signalMetadata: { options: [{ label: "Red", value: "Red" }] }, + }, + ]); + expect( + store.values.get( + pendingInputKey({ linearAgentSessionId: "linear-session-id", organizationId: "org-id" }), + ), + ).toBe( + yield* encodePendingInput({ + answers: [null], + linearAgentSessionId: "linear-session-id", + opencodeRequestId: "question-id", + opencodeSessionId: "opencode-session-id", + questions: [ + { + header: "Color", + options: [{ description: "Red choice", label: "Red" }], + question: "Pick color", + }, + ], + type: "question", + }), + ); + }), + ); - it("keeps the active pending input instead of queuing another one", async () => { - const store = makeKv(); - const activities: Array = []; - const plans: Array< - ReadonlyArray<{ - readonly content: string; - readonly status: "pending" | "inProgress" | "completed" | "canceled"; - }> - > = []; - const first: Event = { - id: "event-1", - properties: { - always: [], - id: "permission-1", - metadata: {}, - patterns: ["*"], - permission: "Run first?", - sessionID: "opencode-session-id", - }, - type: "permission.asked", - }; - const second: Event = { - id: "event-2", - properties: { - always: [], - id: "permission-2", - metadata: {}, - patterns: ["*"], - permission: "Run second?", - sessionID: "opencode-session-id", - }, - type: "permission.asked", - }; + it.effect("keeps the active pending input instead of queuing another one", () => + Effect.gen(function* () { + const store = makeKv(); + const activities: Array = []; + const plans: Array< + ReadonlyArray<{ + readonly content: string; + readonly status: "pending" | "inProgress" | "completed" | "canceled"; + }> + > = []; + const first: Event = { + id: "event-1", + properties: { + always: [], + id: "permission-1", + metadata: {}, + patterns: ["*"], + permission: "Run first?", + sessionID: "opencode-session-id", + }, + type: "permission.asked", + }; + const second: Event = { + id: "event-2", + properties: { + always: [], + id: "permission-2", + metadata: {}, + patterns: ["*"], + permission: "Run second?", + sessionID: "opencode-session-id", + }, + type: "permission.asked", + }; - await Effect.runPromise( - Effect.gen(function* () { + yield* Effect.gen(function* () { yield* processOpenCodeEvent({ event: first, run }); yield* processOpenCodeEvent({ event: second, run }); }).pipe( - Effect.provide([ - Layer.succeed(LinearHttpClient)(makeLinear({ activities, plans })), - Layer.succeed(LinearSessionStore)(makeLinearSessionStore(store.kv)), - ]), - ), - ); + Effect.provideService(LinearHttpClient, makeLinear({ activities, plans })), + Effect.provideService(LinearSessionStore, makeLinearSessionStore(store.kv)), + ); - expect( - store.values.get( - pendingInputKey({ linearAgentSessionId: "linear-session-id", organizationId: "org-id" }), - ), - ).toBe( - JSON.stringify({ - linearAgentSessionId: "linear-session-id", - opencodeRequestId: "permission-1", - opencodeSessionId: "opencode-session-id", - type: "permission", - }), - ); - expect(activities.map((activity) => activity.content)).toEqual([ - { body: "Run first?\n\nApprove\nApprove Always\nReject", type: "elicitation" }, - ]); - }); + expect( + store.values.get( + pendingInputKey({ linearAgentSessionId: "linear-session-id", organizationId: "org-id" }), + ), + ).toBe( + yield* encodePendingInput({ + linearAgentSessionId: "linear-session-id", + opencodeRequestId: "permission-1", + opencodeSessionId: "opencode-session-id", + type: "permission", + }), + ); + expect(activities.map((activity) => activity.content)).toEqual([ + { body: "Run first?\n\nApprove\nApprove Always\nReject", type: "elicitation" }, + ]); + }), + ); }); diff --git a/test/unit/opencode-run-observer.test.ts b/test/unit/opencode-run-observer.test.ts index 6fefa5f..babbcb9 100644 --- a/test/unit/opencode-run-observer.test.ts +++ b/test/unit/opencode-run-observer.test.ts @@ -1,5 +1,5 @@ +import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import { describe, expect, it } from "vitest"; import type { LinearAgentActivityContent, @@ -97,112 +97,120 @@ const store = { } satisfies LinearSessionStoreService; describe("OpenCode run observer", () => { - it("reschedules auth failures without clearing the active run", async () => { - const state = makeState(run); - const handler = makeOpenCodeRunObserverHandler({ - authStore: { - getOrgAuth: () => - Effect.fail(new LinearOrgAuthStoreError({ reason: "MissingOrganizationAuth" })), - }, - linear: makeLinear([]), - opencode, - state: state.state, - store, - }); - - await Effect.runPromise(handler.alarm()); - - expect(state.alarmCount()).toBe(1); - expect(state.clearCount()).toBe(0); - expect(state.stored()).toEqual(run); - }); - - it("posts the completed response and clears the active run", async () => { - const state = makeState(run); - const activities: Array<{ - readonly content: LinearAgentActivityContent; - readonly sessionId: string; - }> = []; - const handler = makeOpenCodeRunObserverHandler({ - authStore: { - getOrgAuth: () => - Effect.succeed({ - accessToken: "linear-access-token", - accessTokenExpiresAt: Date.now() + 1_000, - appUserId: "app-user-id", - organizationId: "org-id", - organizationName: "Example", - refreshToken: "refresh-token", - }), - }, - linear: makeLinear(activities), - opencode, - state: state.state, - store, - }); - - await Effect.runPromise(handler.alarm()); - - expect(activities).toEqual([ - { - content: { body: "done", type: "response" }, - sessionId: "linear-session-id", - }, - ]); - expect(state.clearCount()).toBe(1); - expect(state.stored()).toBeUndefined(); - }); - - it("reschedules pending input without clearing the active run", async () => { - const state = makeState(run); - const activities: Array<{ - readonly content: LinearAgentActivityContent; - readonly sessionId: string; - }> = []; - const pendingOpencode = { - ...opencode, - waitUntilIdle: () => Effect.fail(new OpenCodeClientError({ reason: "InputPending" })), - } satisfies OpenCodeClientService; - const handler = makeOpenCodeRunObserverHandler({ - authStore: { - getOrgAuth: () => - Effect.succeed({ - accessToken: "linear-access-token", - accessTokenExpiresAt: Date.now() + 1_000, - appUserId: "app-user-id", - organizationId: "org-id", - organizationName: "Example", - refreshToken: "refresh-token", - }), - }, - linear: makeLinear(activities), - opencode: pendingOpencode, - state: state.state, - store, - }); - - await Effect.runPromise(handler.alarm()); - - expect(activities).toEqual([]); - expect(state.alarmCount()).toBe(1); - expect(state.clearCount()).toBe(0); - expect(state.stored()).toEqual(run); - }); - - it("clears invalid stored run records", async () => { - const state = makeState({}); - const handler = makeOpenCodeRunObserverHandler({ - authStore: { - getOrgAuth: () => Effect.die("unused"), - }, - linear: makeLinear([]), - opencode, - state: state.state, - store, - }); - - await Effect.runPromise(handler.alarm()); - - expect(state.clearCount()).toBe(1); - }); + it.effect("reschedules auth failures without clearing the active run", () => + Effect.gen(function* () { + const state = makeState(run); + const handler = makeOpenCodeRunObserverHandler({ + authStore: { + getOrgAuth: () => + Effect.fail(LinearOrgAuthStoreError.make({ reason: "MissingOrganizationAuth" })), + }, + linear: makeLinear([]), + opencode, + state: state.state, + store, + }); + + yield* handler.alarm(); + + expect(state.alarmCount()).toBe(1); + expect(state.clearCount()).toBe(0); + expect(state.stored()).toEqual(run); + }), + ); + + it.effect("posts the completed response and clears the active run", () => + Effect.gen(function* () { + const state = makeState(run); + const activities: Array<{ + readonly content: LinearAgentActivityContent; + readonly sessionId: string; + }> = []; + const handler = makeOpenCodeRunObserverHandler({ + authStore: { + getOrgAuth: () => + Effect.succeed({ + accessToken: "linear-access-token", + accessTokenExpiresAt: 1_000, + appUserId: "app-user-id", + organizationId: "org-id", + organizationName: "Example", + refreshToken: "refresh-token", + }), + }, + linear: makeLinear(activities), + opencode, + state: state.state, + store, + }); + + yield* handler.alarm(); + + expect(activities).toEqual([ + { + content: { body: "done", type: "response" }, + sessionId: "linear-session-id", + }, + ]); + expect(state.clearCount()).toBe(1); + expect(state.stored()).toBeUndefined(); + }), + ); + + it.effect("reschedules pending input without clearing the active run", () => + Effect.gen(function* () { + const state = makeState(run); + const activities: Array<{ + readonly content: LinearAgentActivityContent; + readonly sessionId: string; + }> = []; + const pendingOpencode = { + ...opencode, + waitUntilIdle: () => Effect.fail(OpenCodeClientError.make({ reason: "InputPending" })), + } satisfies OpenCodeClientService; + const handler = makeOpenCodeRunObserverHandler({ + authStore: { + getOrgAuth: () => + Effect.succeed({ + accessToken: "linear-access-token", + accessTokenExpiresAt: 1_000, + appUserId: "app-user-id", + organizationId: "org-id", + organizationName: "Example", + refreshToken: "refresh-token", + }), + }, + linear: makeLinear(activities), + opencode: pendingOpencode, + state: state.state, + store, + }); + + yield* handler.alarm(); + + expect(activities).toEqual([]); + expect(state.alarmCount()).toBe(1); + expect(state.clearCount()).toBe(0); + expect(state.stored()).toEqual(run); + }), + ); + + it.effect("clears invalid stored run records", () => + Effect.gen(function* () { + const state = makeState({}); + const handler = makeOpenCodeRunObserverHandler({ + authStore: { + getOrgAuth: () => Effect.die("unused"), + }, + linear: makeLinear([]), + opencode, + state: state.state, + store, + }); + + yield* handler.alarm(); + + expect(state.clearCount()).toBe(1); + }), + ); }); diff --git a/tsconfig.json b/tsconfig.json index 0522aaf..91dacf0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,4 @@ { - "$schema": "./node_modules/@effect/tsgo/schema.json", "compilerOptions": { // Environment setup & latest features "lib": ["ESNext"], @@ -24,13 +23,6 @@ "noImplicitOverride": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noPropertyAccessFromIndexSignature": true, - - "plugins": [ - { - "name": "@effect/language-service", - "ignoreEffectSuggestionsInTscExitCode": false - } - ] + "noPropertyAccessFromIndexSignature": true } }