mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 08:46:15 -04:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fcb4534a82 | |||
| a0a6963beb | |||
| da35eac93f | |||
| c944048bcf | |||
| c2351e308f | |||
| 8de40be6ea | |||
| 2ed8fe5960 | |||
| 1ea9137c01 | |||
| 77d3289d27 |
@@ -90,11 +90,18 @@ jobs:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Build
|
||||
- name: Build legacy CLI
|
||||
if: github.ref_name != 'v2'
|
||||
run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
|
||||
- name: Build preview CLI
|
||||
id: build
|
||||
run: |
|
||||
./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
@@ -102,6 +109,7 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli
|
||||
path: |
|
||||
@@ -109,6 +117,7 @@ jobs:
|
||||
packages/opencode/dist/opencode-linux*
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist/opencode-windows*
|
||||
@@ -491,11 +500,13 @@ jobs:
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
@@ -627,13 +627,6 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/quark": {
|
||||
"name": "@opencode-ai/quark",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode-ai/schema",
|
||||
"version": "1.17.11",
|
||||
@@ -889,7 +882,6 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/quark": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -968,6 +960,20 @@
|
||||
"solid-js": "^1.9.0",
|
||||
},
|
||||
},
|
||||
"packages/updates": {
|
||||
"name": "@opencode-ai/updates",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"wrangler": "4.110.0",
|
||||
},
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.18.3",
|
||||
@@ -2099,8 +2105,6 @@
|
||||
|
||||
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
|
||||
|
||||
"@opencode-ai/quark": ["@opencode-ai/quark@workspace:packages/quark"],
|
||||
|
||||
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
|
||||
|
||||
"@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"],
|
||||
@@ -2129,6 +2133,8 @@
|
||||
|
||||
"@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"],
|
||||
|
||||
"@opencode-ai/updates": ["@opencode-ai/updates@workspace:packages/updates"],
|
||||
|
||||
"@opencode-ai/util": ["@opencode-ai/util@workspace:packages/util"],
|
||||
|
||||
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
|
||||
@@ -6597,6 +6603,8 @@
|
||||
|
||||
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
|
||||
|
||||
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
||||
|
||||
"@opencode-ai/www/@cloudflare/vite-plugin": ["@cloudflare/vite-plugin@1.44.0", "", { "dependencies": { "@cloudflare/unenv-preset": "2.16.1", "miniflare": "4.20260708.1", "unenv": "2.0.0-rc.24", "wrangler": "4.110.0", "ws": "8.21.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0 || ^8.0.0" }, "bin": { "cf-vite": "bin/cf-vite" } }, "sha512-8wGGunqRcs34o4GRq0Rurp7GZg30xtLJeRGUU81a49r9zQRjlp3xIlsWr3nFlSCso4eE3cjZfiKC/2y116M4TQ=="],
|
||||
@@ -7867,6 +7875,16 @@
|
||||
|
||||
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/miniflare": ["miniflare@4.20260708.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260708.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-c94O9zRDISdqO18EHt6l0iF/fWgWt8p18PJvRsA/L/NJZ9Cfke3s/F5Blg1XXF7WDutVRzWVWy8Vy4LaT5ifsA=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
|
||||
|
||||
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
||||
|
||||
"@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||
@@ -8715,6 +8733,72 @@
|
||||
|
||||
"@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/miniflare/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/miniflare/undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260708.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260708.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260708.1", "", { "os": "linux", "cpu": "x64" }, "sha512-3daE60YdD7YX0Jtuzc9DE/r/qMkmx8ZvHTkF8Mzmp3F5tbzlV0DAzmu5PFUPF2WuvtKbAhZKbvC2cHmWpQYxnA=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260708.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-VLdNYOx5Hj+9C6isy0ACWZsbMtSxex2DIJWEe7cZxUdlphZ58ZT8zxNXK8yunFiowd34hn3VwGMopdvdj8lvmA=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260708.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA=="],
|
||||
|
||||
"@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
|
||||
|
||||
"@opencode-ai/www/@cloudflare/vite-plugin/miniflare/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
@@ -8999,6 +9083,44 @@
|
||||
|
||||
"@opencode-ai/desktop/@actions/artifact/@actions/core/@actions/exec/@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260708.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg=="],
|
||||
|
||||
"@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260708.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA=="],
|
||||
@@ -9149,6 +9271,8 @@
|
||||
|
||||
"@mintlify/common/sucrase/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
|
||||
|
||||
"@opencode-ai/www/@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
|
||||
|
||||
"@opencode-ai/www/wrangler/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
|
||||
|
||||
+128
-25
@@ -17,6 +17,11 @@ import {
|
||||
} from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
|
||||
import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
|
||||
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
|
||||
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
@@ -1002,7 +1007,7 @@ function HomeServerRow(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectList(props: {
|
||||
type HomeProjectListProps = {
|
||||
server: ServerConnection.Any
|
||||
projects: LocalProject[]
|
||||
selected: HomeProjectSelection
|
||||
@@ -1013,29 +1018,82 @@ function HomeProjectList(props: {
|
||||
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
|
||||
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
}
|
||||
|
||||
function HomeProjectList(props: HomeProjectListProps) {
|
||||
const global = useGlobal()
|
||||
let listRef!: HTMLDivElement
|
||||
const projects = () => global.ensureServerCtx(props.server).projects
|
||||
|
||||
return (
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<For each={props.projects}>
|
||||
{(project) => (
|
||||
<HomeProjectRow
|
||||
project={project}
|
||||
server={props.server}
|
||||
selected={
|
||||
props.selected.server === ServerConnection.key(props.server) &&
|
||||
props.selected.directory === project.worktree
|
||||
}
|
||||
unseenCount={props.unseenCount(props.server, project)}
|
||||
selectProject={props.selectProject}
|
||||
openNewSession={props.openNewSession}
|
||||
editProject={props.editProject}
|
||||
closeProject={props.closeProject}
|
||||
clearNotifications={props.clearNotifications}
|
||||
language={props.language}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<DragDropProvider
|
||||
sensors={(defaults) => [
|
||||
...defaults.filter((sensor) => sensor !== PointerSensor),
|
||||
PointerSensor.configure({
|
||||
activationConstraints: (event) =>
|
||||
event.pointerType === "touch"
|
||||
? [new PointerActivationConstraints.Delay({ value: 250, tolerance: 5 })]
|
||||
: [new PointerActivationConstraints.Distance({ value: 4 })],
|
||||
preventActivation: (event) => event.target instanceof Element && !!event.target.closest("[data-action]"),
|
||||
}),
|
||||
]}
|
||||
modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]}
|
||||
plugins={(defaults) => [
|
||||
...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback),
|
||||
AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }),
|
||||
Feedback.configure({ dropAnimation: null }),
|
||||
]}
|
||||
onDragEnd={(event) => {
|
||||
const source = event.operation.source
|
||||
if (event.canceled || !isSortable(source)) return
|
||||
if (source.initialIndex !== source.index) projects().move(source.id.toString(), source.index)
|
||||
if (props.selected.server !== ServerConnection.key(props.server))
|
||||
props.selectProject(props.server, source.id.toString())
|
||||
}}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-1" ref={listRef}>
|
||||
{/* Keyed on worktree strings: the enriched project objects are
|
||||
recreated on every store or sync update, so iterating them directly
|
||||
remounts all rows — killing any in-flight drag activation (the
|
||||
row's sortable unregisters on unmount) and discarding animations.
|
||||
String keys keep row elements alive and move them on reorder. */}
|
||||
<For each={props.projects.map((project) => project.worktree)}>
|
||||
{(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
|
||||
</For>
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectSlot(
|
||||
props: HomeProjectListProps & {
|
||||
worktree: string
|
||||
index: () => number
|
||||
},
|
||||
) {
|
||||
const project = createMemo(() => props.projects.find((item) => item.worktree === props.worktree))
|
||||
|
||||
return (
|
||||
<Show when={project()}>
|
||||
{(item) => (
|
||||
<HomeProjectRow
|
||||
project={item()}
|
||||
server={props.server}
|
||||
index={props.index}
|
||||
serverSelected={props.selected.server === ServerConnection.key(props.server)}
|
||||
selected={
|
||||
props.selected.server === ServerConnection.key(props.server) && props.selected.directory === props.worktree
|
||||
}
|
||||
unseenCount={props.unseenCount(props.server, item())}
|
||||
selectProject={props.selectProject}
|
||||
openNewSession={props.openNewSession}
|
||||
editProject={props.editProject}
|
||||
closeProject={props.closeProject}
|
||||
clearNotifications={props.clearNotifications}
|
||||
language={props.language}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1115,6 +1173,8 @@ function HomeRecentlyClosedRow(props: {
|
||||
function HomeProjectRow(props: {
|
||||
project: LocalProject
|
||||
server: ServerConnection.Any
|
||||
index: () => number
|
||||
serverSelected: boolean
|
||||
selected: boolean
|
||||
unseenCount: number
|
||||
selectProject: (server: ServerConnection.Any, directory: string) => void
|
||||
@@ -1128,6 +1188,15 @@ function HomeProjectRow(props: {
|
||||
const platform = usePlatform()
|
||||
const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
|
||||
const [state, setState] = createStore({ menuOpen: false })
|
||||
const sortable = useSortable({
|
||||
get id() {
|
||||
return props.project.worktree
|
||||
},
|
||||
get index() {
|
||||
return props.index()
|
||||
},
|
||||
})
|
||||
let pointerDownSelected: boolean | undefined
|
||||
const canRevealInFileManager = () =>
|
||||
platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server)
|
||||
const fileManagerActionLabel = () =>
|
||||
@@ -1144,15 +1213,49 @@ function HomeProjectRow(props: {
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]">
|
||||
<div
|
||||
ref={sortable.ref}
|
||||
class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"
|
||||
classList={{ "z-10": sortable.isDragSource() }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-project-row"
|
||||
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
|
||||
classList={{
|
||||
"bg-v2-background-bg-layer-01 text-v2-text-text-base [box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]":
|
||||
sortable.isDragSource(),
|
||||
}}
|
||||
data-selected={props.selected ? "" : undefined}
|
||||
aria-current={props.selected ? "page" : undefined}
|
||||
disabled={serverUnreachable()}
|
||||
onClick={() => props.selectProject(props.server, props.project.worktree)}
|
||||
onPointerDown={(event) => {
|
||||
// Same-server mouse selection happens on pointerdown (like tabs),
|
||||
// but only ever selects; selectProject toggles, and deselecting here
|
||||
// would fire on every drag before the threshold is met. Cross-server
|
||||
// selection waits for click so reordering a remote server's projects
|
||||
// does not focus that server and load its session index. Touch is
|
||||
// excluded so flick-scrolling the list cannot select rows.
|
||||
pointerDownSelected = undefined
|
||||
if (event.button !== 0 || event.pointerType === "touch") return
|
||||
if (!props.serverSelected) return
|
||||
pointerDownSelected = props.selected
|
||||
if (!props.selected) props.selectProject(props.server, props.project.worktree)
|
||||
}}
|
||||
onClick={(event) => {
|
||||
// The drag sensor calls preventDefault on post-drag clicks; never
|
||||
// toggle selection as part of a reorder.
|
||||
if (event.defaultPrevented) return
|
||||
// Keyboard activation and touch taps keep the original toggle.
|
||||
if (event.detail === 0 || pointerDownSelected === undefined) {
|
||||
props.selectProject(props.server, props.project.worktree)
|
||||
return
|
||||
}
|
||||
// Mouse: pointerdown already selected unselected rows; a plain click
|
||||
// on an already-selected row toggles it off.
|
||||
if (pointerDownSelected) props.selectProject(props.server, props.project.worktree)
|
||||
pointerDownSelected = undefined
|
||||
}}
|
||||
>
|
||||
<HomeProjectAvatar project={props.project} />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { $ } from "bun"
|
||||
import pkg from "../package.json"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { fileURLToPath } from "url"
|
||||
import { UpdateArtifact } from "../../../script/update-artifact"
|
||||
|
||||
const dir = fileURLToPath(new URL("..", import.meta.url))
|
||||
process.chdir(dir)
|
||||
@@ -81,3 +82,10 @@ await publishDistribution({
|
||||
binary: "opencode2-node",
|
||||
packagePrefix: "@opencode-ai/cli-node-",
|
||||
})
|
||||
await UpdateArtifact.publish({
|
||||
channel: Script.channel,
|
||||
name: "cli",
|
||||
distribution: "npm",
|
||||
version: Script.version,
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
@@ -38,7 +38,7 @@ import type {
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { OPENCODE_VERSION } from "../version"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
|
||||
import { promptContentToParts } from "./content"
|
||||
@@ -176,7 +176,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
sessionCapabilities: { close: {}, fork: {}, list: {}, resume: {} },
|
||||
},
|
||||
authMethods: [authMethod],
|
||||
agentInfo: { name: "OpenCode", version: InstallationVersion },
|
||||
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
|
||||
}
|
||||
},
|
||||
authenticate: async (params) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ServerConnection } from "../../services/server-connection"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { UpdatePreflight } from "../../services/update-preflight"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
|
||||
|
||||
export default Runtime.handler(Commands, (input) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -44,6 +45,7 @@ export default Runtime.handler(Commands, (input) =>
|
||||
const runPromise = Effect.runPromiseWith(context)
|
||||
const service = server.service
|
||||
yield* run({
|
||||
app: { name: process.env.OPENCODE_CLIENT ?? "cli", version: OPENCODE_VERSION, channel: OPENCODE_CHANNEL },
|
||||
server: {
|
||||
endpoint: server.endpoint,
|
||||
service: service
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { NodeRuntime, NodeServices } from "@effect/platform-node"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { Commands } from "./commands/commands"
|
||||
import { Runtime } from "./framework/runtime"
|
||||
import { Observability } from "@opencode-ai/util/observability"
|
||||
import { Client } from "@opencode-ai/util/client"
|
||||
import { Updater } from "./services/updater"
|
||||
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/util/installation/version"
|
||||
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "./version"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
@@ -53,12 +52,12 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
})
|
||||
|
||||
Effect.logInfo("cli starting", {
|
||||
version: InstallationVersion,
|
||||
channel: InstallationChannel,
|
||||
local: InstallationLocal,
|
||||
version: OPENCODE_VERSION,
|
||||
channel: OPENCODE_CHANNEL,
|
||||
local: OPENCODE_LOCAL,
|
||||
args: process.argv.slice(2),
|
||||
}).pipe(
|
||||
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
|
||||
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: OPENCODE_VERSION })),
|
||||
Effect.annotateLogs({ role: "cli" }),
|
||||
Effect.provide(Config.layer),
|
||||
Effect.provide(Updater.layer),
|
||||
@@ -74,7 +73,10 @@ Effect.logInfo("cli starting", {
|
||||
Observability.layer({
|
||||
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
headers: process.env.OTEL_EXPORTER_OTLP_HEADERS,
|
||||
}).pipe(Layer.provide(Client.layer(process.env.OPENCODE_CLIENT))),
|
||||
client: process.env.OPENCODE_CLIENT ?? "cli",
|
||||
version: OPENCODE_VERSION,
|
||||
channel: OPENCODE_CHANNEL,
|
||||
}),
|
||||
),
|
||||
Effect.provide(NodeServices.layer),
|
||||
Effect.scoped,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NodeServices } from "@effect/platform-node"
|
||||
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { randomBytes, randomUUID } from "node:crypto"
|
||||
import path from "node:path"
|
||||
@@ -69,7 +69,11 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
const instanceID = randomUUID()
|
||||
const server = yield* start(
|
||||
{
|
||||
client: process.env.OPENCODE_CLIENT ?? "cli",
|
||||
app: {
|
||||
name: process.env.OPENCODE_CLIENT ?? "cli",
|
||||
version: OPENCODE_VERSION,
|
||||
channel: OPENCODE_CHANNEL,
|
||||
},
|
||||
hostname,
|
||||
port,
|
||||
password,
|
||||
@@ -77,11 +81,11 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
database: {
|
||||
path:
|
||||
process.env.OPENCODE_DB ??
|
||||
(["latest", "beta", "prod"].includes(InstallationChannel) ||
|
||||
(["latest", "beta", "prod"].includes(OPENCODE_CHANNEL) ||
|
||||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
|
||||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
|
||||
? "opencode.db"
|
||||
: `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
|
||||
: `opencode-${OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
|
||||
},
|
||||
models: {
|
||||
url: process.env.OPENCODE_MODELS_URL,
|
||||
@@ -173,7 +177,7 @@ const register = Effect.fnUntraced(function* (
|
||||
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
|
||||
const info = {
|
||||
id,
|
||||
version: InstallationVersion,
|
||||
version: OPENCODE_VERSION,
|
||||
url: HttpServer.formatAddress(address),
|
||||
pid: process.pid,
|
||||
password,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Service, type Endpoint, type EnsureOptions } from "@opencode-ai/client/effect/service"
|
||||
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { OPENCODE_VERSION } from "../version"
|
||||
import { Effect, Redacted } from "effect"
|
||||
import { Env } from "../env"
|
||||
import { ServiceConfig } from "./service-config"
|
||||
@@ -32,9 +32,9 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
|
||||
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
|
||||
catch: (cause) => connectError(endpoint, cause),
|
||||
})
|
||||
if (health.version !== InstallationVersion)
|
||||
if (health.version !== OPENCODE_VERSION)
|
||||
process.stderr.write(
|
||||
`Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`,
|
||||
`Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${OPENCODE_VERSION}. Continuing anyway.\n`,
|
||||
)
|
||||
return { endpoint } satisfies Resolved
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../version"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Effect, FileSystem, Option, Schema } from "effect"
|
||||
@@ -24,26 +24,26 @@ type Key = (typeof keys)[number]
|
||||
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
||||
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info))
|
||||
|
||||
export function filename(channel = InstallationChannel) {
|
||||
export function filename(channel = OPENCODE_CHANNEL) {
|
||||
if (channel === "latest" || channel === "next") return "service.json"
|
||||
return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
|
||||
}
|
||||
|
||||
export function defaultPort(channel = InstallationChannel) {
|
||||
export function defaultPort(channel = OPENCODE_CHANNEL) {
|
||||
if (channel === "latest" || channel === "next") return 0xc0de
|
||||
if (channel === "local") return 0xc0df
|
||||
return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000)
|
||||
}
|
||||
|
||||
export function legacyFilename(channel = InstallationChannel) {
|
||||
export function legacyFilename(channel = OPENCODE_CHANNEL) {
|
||||
if (channel === "latest" || channel === "local") return
|
||||
return `service-${Hash.fast(channel)}.json`
|
||||
}
|
||||
|
||||
export function versionBelongsToChannel(
|
||||
version: string | undefined,
|
||||
channel = InstallationChannel,
|
||||
installedVersion = InstallationVersion,
|
||||
channel = OPENCODE_CHANNEL,
|
||||
installedVersion = OPENCODE_VERSION,
|
||||
) {
|
||||
if (version === undefined) return false
|
||||
if (version === installedVersion) return true
|
||||
@@ -55,8 +55,8 @@ export function versionBelongsToChannel(
|
||||
export const migrateRegistration = Effect.fnUntraced(function* (
|
||||
legacy: string,
|
||||
file: string,
|
||||
channel = InstallationChannel,
|
||||
installedVersion = InstallationVersion,
|
||||
channel = OPENCODE_CHANNEL,
|
||||
installedVersion = OPENCODE_VERSION,
|
||||
) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const text = yield* fs.readFileString(legacy).pipe(Effect.option)
|
||||
@@ -92,7 +92,7 @@ const paths = Effect.gen(function* () {
|
||||
legacyConfigFile: legacy ? path.join(global.config, legacy) : undefined,
|
||||
legacyRegistrationFiles: [
|
||||
...(legacy ? [path.join(global.state, legacy)] : []),
|
||||
...(name !== "service.json" && InstallationChannel !== "local" ? [path.join(global.state, "service.json")] : []),
|
||||
...(name !== "service.json" && OPENCODE_CHANNEL !== "local" ? [path.join(global.state, "service.json")] : []),
|
||||
],
|
||||
configFile: path.join(global.config, name),
|
||||
}
|
||||
@@ -103,7 +103,7 @@ export const options = Effect.fnUntraced(function* () {
|
||||
yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file))
|
||||
return {
|
||||
file,
|
||||
version: InstallationVersion,
|
||||
version: OPENCODE_VERSION,
|
||||
command: [...selfCommand(), "serve", "--service"],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// version-mismatched background service before the TUI attaches.
|
||||
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core"
|
||||
import { render, useTerminalDimensions } from "@opentui/solid"
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { OPENCODE_VERSION } from "../version"
|
||||
import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
|
||||
import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
|
||||
import { go } from "@opencode-ai/tui/logo"
|
||||
@@ -356,12 +356,12 @@ function UpdateFooter(props: {
|
||||
] as const)
|
||||
: []),
|
||||
["to", colors.muted],
|
||||
[InstallationVersion, colors.accent],
|
||||
[OPENCODE_VERSION, colors.accent],
|
||||
)
|
||||
const completedHeader = phrase(
|
||||
["OpenCode", colors.muted, true],
|
||||
["updated to", colors.muted],
|
||||
[InstallationVersion, colors.accent],
|
||||
[OPENCODE_VERSION, colors.accent],
|
||||
)
|
||||
const pausedHeader = phrase(["OpenCode", colors.muted, true], ["update paused", colors.muted])
|
||||
const outcomeStatus = () =>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import {
|
||||
InstallationChannel,
|
||||
InstallationLocal,
|
||||
InstallationVersion,
|
||||
} from "@opencode-ai/util/installation/version"
|
||||
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
@@ -52,7 +48,7 @@ export const layer = Layer.effect(
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const channel = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
|
||||
const readPolicy = Effect.fnUntraced(function* () {
|
||||
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
|
||||
@@ -99,8 +95,8 @@ export const layer = Layer.effect(
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
fetch(
|
||||
`https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(InstallationChannel)}`,
|
||||
{ headers: { "User-Agent": `opencode/${InstallationVersion}` }, signal: AbortSignal.timeout(10_000) },
|
||||
`https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(OPENCODE_CHANNEL)}`,
|
||||
{ headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` }, signal: AbortSignal.timeout(10_000) },
|
||||
),
|
||||
catch: (cause) => new Error("Failed to check for updates", { cause }),
|
||||
})
|
||||
@@ -138,13 +134,13 @@ export const layer = Layer.effect(
|
||||
|
||||
const check = Effect.fn("cli.updater.check")(function* () {
|
||||
if (
|
||||
InstallationLocal ||
|
||||
OPENCODE_LOCAL ||
|
||||
["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")
|
||||
)
|
||||
return yield* Effect.logInfo("update check skipped", {
|
||||
reason: InstallationLocal ? "local-install" : "disabled",
|
||||
version: InstallationVersion,
|
||||
channel: InstallationChannel,
|
||||
reason: OPENCODE_LOCAL ? "local-install" : "disabled",
|
||||
version: OPENCODE_VERSION,
|
||||
channel: OPENCODE_CHANNEL,
|
||||
})
|
||||
const policy = yield* readPolicy()
|
||||
if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
|
||||
@@ -152,15 +148,15 @@ export const layer = Layer.effect(
|
||||
return yield* Effect.gen(function* () {
|
||||
const version = yield* latest()
|
||||
yield* Effect.logInfo("update check", {
|
||||
current: InstallationVersion,
|
||||
current: OPENCODE_VERSION,
|
||||
latest: version,
|
||||
})
|
||||
const next = action(InstallationVersion, version, policy)
|
||||
const next = action(OPENCODE_VERSION, version, policy)
|
||||
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
|
||||
const detected = yield* method()
|
||||
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
|
||||
yield* upgrade(detected, version)
|
||||
yield* Effect.logInfo("updated OpenCode", { from: InstallationVersion, to: version, method: detected })
|
||||
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
|
||||
})
|
||||
}, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })))
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
declare const OPENCODE_VERSION: string
|
||||
declare const OPENCODE_CHANNEL: string
|
||||
|
||||
const version = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
||||
const channel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
|
||||
|
||||
export { version as OPENCODE_VERSION, channel as OPENCODE_CHANNEL }
|
||||
export const OPENCODE_LOCAL = channel === "local"
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ClientError, OpenCode } from "@opencode-ai/client/promise"
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
import path from "node:path"
|
||||
import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini"
|
||||
import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run"
|
||||
@@ -31,14 +31,14 @@ describe("mini command", () => {
|
||||
const initial = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
|
||||
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||
},
|
||||
})
|
||||
const replacement = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
authorization.push(request.headers.get("authorization"))
|
||||
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
|
||||
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||
},
|
||||
})
|
||||
const controller = new AbortController()
|
||||
@@ -145,7 +145,7 @@ describe("mini command", () => {
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/health")
|
||||
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
|
||||
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||
if (url.pathname === "/api/location")
|
||||
return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
|
||||
if (url.pathname === "/api/model") {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, FileSystem, Scope } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
@@ -17,7 +17,7 @@ test("resolution groups Effect-native lifecycle operations only for the managed
|
||||
fetch() {
|
||||
return Response.json({
|
||||
healthy: true,
|
||||
version: InstallationVersion,
|
||||
version: OPENCODE_VERSION,
|
||||
pid: process.pid,
|
||||
})
|
||||
},
|
||||
@@ -33,7 +33,7 @@ test("resolution groups Effect-native lifecycle operations only for the managed
|
||||
registration,
|
||||
JSON.stringify({
|
||||
id,
|
||||
version: InstallationVersion,
|
||||
version: OPENCODE_VERSION,
|
||||
url: server.url.toString(),
|
||||
pid: process.pid,
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Service, type Info } from "@opencode-ai/client/effect/service"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
@@ -332,7 +332,7 @@ test("unresponsive managed port occupancy reports a bounded conflict", async ()
|
||||
)
|
||||
const stale = {
|
||||
id: "stale",
|
||||
version: InstallationVersion,
|
||||
version: OPENCODE_VERSION,
|
||||
url: "http://127.0.0.1:1",
|
||||
pid: process.pid,
|
||||
password: "stale",
|
||||
@@ -368,7 +368,7 @@ test("port contender recognizes an incumbent registered during the bind race", a
|
||||
fetch() {
|
||||
requests.count += 1
|
||||
if (requests.count === 2) recognizing.resolve()
|
||||
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }, { status: 503 })
|
||||
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }, { status: 503 })
|
||||
},
|
||||
})
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
@@ -380,7 +380,7 @@ test("port contender recognizes an incumbent registered during the bind race", a
|
||||
registration,
|
||||
JSON.stringify({
|
||||
id: "stale",
|
||||
version: InstallationVersion,
|
||||
version: OPENCODE_VERSION,
|
||||
url: "http://127.0.0.1:1",
|
||||
pid: 2_147_483_647,
|
||||
password: "stale",
|
||||
@@ -397,7 +397,7 @@ test("port contender recognizes an incumbent registered during the bind race", a
|
||||
await Bun.sleep(8_000)
|
||||
const info = {
|
||||
id: "incumbent",
|
||||
version: InstallationVersion,
|
||||
version: OPENCODE_VERSION,
|
||||
url: `http://127.0.0.1:${listener.port}`,
|
||||
pid: process.pid,
|
||||
password: "incumbent",
|
||||
|
||||
@@ -358,6 +358,7 @@ export const dict = {
|
||||
"zen.api.error.providerNotSupported": "المزود {{provider}} غير مدعوم",
|
||||
"zen.api.error.missingApiKey": "مفتاح API مفقود.",
|
||||
"zen.api.error.invalidApiKey": "مفتاح API غير صالح.",
|
||||
"zen.api.error.requestBlockedByUpstreamProvider": "حظر المزود الخارجي الطلب.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "تم تجاوز حصة الاشتراك. أعد المحاولة خلال {{retryIn}}.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"تم الوصول إلى حد الاستخدام لمدة 5 ساعات. تتم إعادة التعيين خلال {{retryIn}}. لمواصلة استخدام هذا النموذج الآن، فعّل الاستخدام من رصيدك المتاح: {{consoleGoUrl}}",
|
||||
|
||||
@@ -366,6 +366,7 @@ export const dict = {
|
||||
"zen.api.error.providerNotSupported": "Provedor {{provider}} não suportado",
|
||||
"zen.api.error.missingApiKey": "Chave de API ausente.",
|
||||
"zen.api.error.invalidApiKey": "Chave de API inválida.",
|
||||
"zen.api.error.requestBlockedByUpstreamProvider": "Solicitação bloqueada pelo provedor upstream.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Cota de assinatura excedida. Tente novamente em {{retryIn}}.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"Limite de uso de 5 horas atingido. Será reiniciado em {{retryIn}}. Para continuar usando este modelo agora, habilite o uso a partir do seu saldo disponível: {{consoleGoUrl}}",
|
||||
|
||||
@@ -362,6 +362,7 @@ export const dict = {
|
||||
"zen.api.error.providerNotSupported": "Udbyder {{provider}} understøttes ikke",
|
||||
"zen.api.error.missingApiKey": "Manglende API-nøgle.",
|
||||
"zen.api.error.invalidApiKey": "Ugyldig API-nøgle.",
|
||||
"zen.api.error.requestBlockedByUpstreamProvider": "Anmodningen blev blokeret af upstream-udbyderen.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igen om {{retryIn}}.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"Forbrugsgrænsen for 5 timer er nået. Nulstilles om {{retryIn}}. For at fortsætte med at bruge denne model nu, aktivér forbrug fra din tilgængelige saldo: {{consoleGoUrl}}",
|
||||
|
||||
@@ -365,6 +365,7 @@ export const dict = {
|
||||
"zen.api.error.providerNotSupported": "Anbieter {{provider}} wird nicht unterstützt",
|
||||
"zen.api.error.missingApiKey": "Fehlender API-Key.",
|
||||
"zen.api.error.invalidApiKey": "Ungültiger API-Key.",
|
||||
"zen.api.error.requestBlockedByUpstreamProvider": "Anfrage vom vorgelagerten Anbieter blockiert.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Abonnement-Quote überschritten. Erneuter Versuch in {{retryIn}}.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"5-Stunden-Nutzungslimit erreicht. Wird in {{retryIn}} zurückgesetzt. Um dieses Modell jetzt weiter zu nutzen, aktiviere die Nutzung über dein verfügbares Guthaben: {{consoleGoUrl}}",
|
||||
|
||||
@@ -360,6 +360,7 @@ export const dict = {
|
||||
"zen.api.error.providerNotSupported": "Provider {{provider}} not supported",
|
||||
"zen.api.error.missingApiKey": "Missing API key.",
|
||||
"zen.api.error.invalidApiKey": "Invalid API key.",
|
||||
"zen.api.error.requestBlockedByUpstreamProvider": "Request blocked by upstream provider.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Subscription quota exceeded. Retry in {{retryIn}}.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"5-hour usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}",
|
||||
|
||||
@@ -366,6 +366,7 @@ export const dict = {
|
||||
"zen.api.error.providerNotSupported": "Proveedor {{provider}} no soportado",
|
||||
"zen.api.error.missingApiKey": "Falta la clave API.",
|
||||
"zen.api.error.invalidApiKey": "Clave API inválida.",
|
||||
"zen.api.error.requestBlockedByUpstreamProvider": "El proveedor externo bloqueó la solicitud.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Cuota de suscripción excedida. Reintenta en {{retryIn}}.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"Límite de uso de 5 horas alcanzado. Se restablece en {{retryIn}}. Para seguir usando este modelo ahora, habilita el uso desde tu saldo disponible: {{consoleGoUrl}}",
|
||||
|
||||
@@ -366,6 +366,7 @@ export const dict = {
|
||||
"zen.api.error.providerNotSupported": "Fournisseur {{provider}} non pris en charge",
|
||||
"zen.api.error.missingApiKey": "Clé API manquante.",
|
||||
"zen.api.error.invalidApiKey": "Clé API invalide.",
|
||||
"zen.api.error.requestBlockedByUpstreamProvider": "Requête bloquée par le fournisseur en amont.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Quota d'abonnement dépassé. Réessayez dans {{retryIn}}.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"Limite d'utilisation sur 5 heures atteinte. Réinitialisation dans {{retryIn}}. Pour continuer à utiliser ce modèle dès maintenant, activez l'utilisation depuis votre solde disponible : {{consoleGoUrl}}",
|
||||
|
||||
@@ -362,6 +362,7 @@ export const dict = {
|
||||
"zen.api.error.providerNotSupported": "Provider {{provider}} non supportato",
|
||||
"zen.api.error.missingApiKey": "Chiave API mancante.",
|
||||
"zen.api.error.invalidApiKey": "Chiave API non valida.",
|
||||
"zen.api.error.requestBlockedByUpstreamProvider": "Richiesta bloccata dal provider upstream.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Quota dell'abbonamento superata. Riprova tra {{retryIn}}.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"Limite di utilizzo di 5 ore raggiunto. Si reimposta tra {{retryIn}}. Per continuare a usare questo modello ora, abilita l'utilizzo dal tuo saldo disponibile: {{consoleGoUrl}}",
|
||||
|
||||
@@ -362,6 +362,7 @@ export const dict = {
|
||||
"zen.api.error.providerNotSupported": "プロバイダー {{provider}} はサポートされていません",
|
||||
"zen.api.error.missingApiKey": "APIキーがありません。",
|
||||
"zen.api.error.invalidApiKey": "無効なAPIキーです。",
|
||||
"zen.api.error.requestBlockedByUpstreamProvider": "上流プロバイダーによりリクエストがブロックされました。",
|
||||
"zen.api.error.subscriptionQuotaExceeded":
|
||||
"サブスクリプションの制限を超えました。{{retryIn}} 後に再試行してください。",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
|
||||
@@ -357,6 +357,7 @@ export const dict = {
|
||||
"zen.api.error.providerNotSupported": "{{provider}} 제공자는 지원되지 않습니다",
|
||||
"zen.api.error.missingApiKey": "API 키가 누락되었습니다.",
|
||||
"zen.api.error.invalidApiKey": "유효하지 않은 API 키입니다.",
|
||||
"zen.api.error.requestBlockedByUpstreamProvider": "상위 제공자가 요청을 차단했습니다.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "구독 할당량을 초과했습니다. {{retryIn}} 후 다시 시도해 주세요.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"5시간 사용 한도에 도달했습니다. {{retryIn}} 후 초기화됩니다. 이 모델을 지금 계속 사용하려면 사용 가능한 잔액에서 사용을 활성화하세요: {{consoleGoUrl}}",
|
||||
|
||||
@@ -363,6 +363,7 @@ export const dict = {
|
||||
"zen.api.error.providerNotSupported": "Leverandør {{provider}} støttes ikke",
|
||||
"zen.api.error.missingApiKey": "Mangler API-nøkkel.",
|
||||
"zen.api.error.invalidApiKey": "Ugyldig API-nøkkel.",
|
||||
"zen.api.error.requestBlockedByUpstreamProvider": "Forespørselen ble blokkert av leverandøren.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igjen om {{retryIn}}.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"5-timers bruksgrense nådd. Tilbakestilles om {{retryIn}}. For å fortsette å bruke denne modellen nå, aktiver bruk fra din tilgjengelige saldo: {{consoleGoUrl}}",
|
||||
|
||||
@@ -364,6 +364,7 @@ export const dict = {
|
||||
"zen.api.error.providerNotSupported": "Dostawca {{provider}} nie jest obsługiwany",
|
||||
"zen.api.error.missingApiKey": "Brak klucza API.",
|
||||
"zen.api.error.invalidApiKey": "Nieprawidłowy klucz API.",
|
||||
"zen.api.error.requestBlockedByUpstreamProvider": "Żądanie zablokowane przez dostawcę zewnętrznego.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Przekroczono limit subskrypcji. Spróbuj ponownie za {{retryIn}}.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"Osiągnięto 5-godzinny limit użycia. Resetuje się za {{retryIn}}. Aby nadal korzystać z tego modelu, włącz użycie z dostępnego salda: {{consoleGoUrl}}",
|
||||
|
||||
@@ -368,6 +368,7 @@ export const dict = {
|
||||
"zen.api.error.providerNotSupported": "Провайдер {{provider}} не поддерживается",
|
||||
"zen.api.error.missingApiKey": "Отсутствует API ключ.",
|
||||
"zen.api.error.invalidApiKey": "Неверный API ключ.",
|
||||
"zen.api.error.requestBlockedByUpstreamProvider": "Запрос заблокирован вышестоящим провайдером.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Квота подписки превышена. Повторите попытку через {{retryIn}}.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"Достигнут лимит использования за 5 часов. Сбросится через {{retryIn}}. Чтобы продолжить использовать эту модель сейчас, включите оплату с доступного баланса: {{consoleGoUrl}}",
|
||||
|
||||
@@ -359,6 +359,7 @@ export const dict = {
|
||||
"zen.api.error.providerNotSupported": "ไม่รองรับผู้ให้บริการ {{provider}}",
|
||||
"zen.api.error.missingApiKey": "ไม่มี API key",
|
||||
"zen.api.error.invalidApiKey": "API key ไม่ถูกต้อง",
|
||||
"zen.api.error.requestBlockedByUpstreamProvider": "คำขอถูกบล็อกโดยผู้ให้บริการต้นทาง",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "โควต้าการสมัครสมาชิกเกินขีดจำกัด ลองใหม่ในอีก {{retryIn}}",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"ถึงขีดจำกัดการใช้งานในรอบ 5 ชั่วโมงแล้ว จะรีเซ็ตในอีก {{retryIn}} หากต้องการใช้โมเดลนี้ต่อทันที ให้เปิดใช้งานจากยอดเงินคงเหลือของคุณ: {{consoleGoUrl}}",
|
||||
|
||||
@@ -366,6 +366,7 @@ export const dict = {
|
||||
"zen.api.error.providerNotSupported": "{{provider}} sağlayıcısı desteklenmiyor",
|
||||
"zen.api.error.missingApiKey": "API anahtarı eksik.",
|
||||
"zen.api.error.invalidApiKey": "Geçersiz API anahtarı.",
|
||||
"zen.api.error.requestBlockedByUpstreamProvider": "İstek üst sağlayıcı tarafından engellendi.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Abonelik kotası aşıldı. {{retryIn}} içinde tekrar deneyin.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"5 saatlik kullanım limitine ulaşıldı. {{retryIn}} içinde sıfırlanır. Bu modeli şimdi kullanmaya devam etmek için kullanılabilir bakiyenizden kullanımı etkinleştirin: {{consoleGoUrl}}",
|
||||
|
||||
@@ -363,6 +363,7 @@ export const dict = {
|
||||
"zen.api.error.providerNotSupported": "Провайдер {{provider}} не підтримується",
|
||||
"zen.api.error.missingApiKey": "Відсутній ключ API.",
|
||||
"zen.api.error.invalidApiKey": "Недійсний ключ API.",
|
||||
"zen.api.error.requestBlockedByUpstreamProvider": "Запит заблоковано зовнішнім провайдером.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Перевищено квоту підписки. Повторіть через {{retryIn}}.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"Досягнуто 5-годинного ліміту використання. Скидається через {{retryIn}}. Щоб продовжити, увімкніть використання з доступного балансу: {{consoleGoUrl}}",
|
||||
|
||||
@@ -346,6 +346,7 @@ export const dict = {
|
||||
"zen.api.error.providerNotSupported": "不支持提供商 {{provider}}",
|
||||
"zen.api.error.missingApiKey": "缺少 API 密钥。",
|
||||
"zen.api.error.invalidApiKey": "无效的 API 密钥。",
|
||||
"zen.api.error.requestBlockedByUpstreamProvider": "请求被上游提供商阻止。",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "超出订阅配额。请在 {{retryIn}} 后重试。",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"已达到 5 小时使用限额。将在 {{retryIn}} 后重置。如需立即继续使用该模型,请启用从可用余额扣费:{{consoleGoUrl}}",
|
||||
|
||||
@@ -346,6 +346,7 @@ export const dict = {
|
||||
"zen.api.error.providerNotSupported": "不支援供應商 {{provider}}",
|
||||
"zen.api.error.missingApiKey": "缺少 API 金鑰。",
|
||||
"zen.api.error.invalidApiKey": "無效的 API 金鑰。",
|
||||
"zen.api.error.requestBlockedByUpstreamProvider": "請求遭上游供應商封鎖。",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "超出訂閱配額。請在 {{retryIn}} 後重試。",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"已達 5 小時使用上限,將在 {{retryIn}} 後重置。若要立即繼續使用此模型,請從可用餘額啟用使用量:{{consoleGoUrl}}",
|
||||
|
||||
@@ -697,6 +697,9 @@ export async function handler(
|
||||
workspace: {
|
||||
id: WorkspaceTable.id,
|
||||
region: WorkspaceTable.region,
|
||||
isBlocked: WorkspaceTable.is_blocked,
|
||||
isFlaggedByAnthropic: WorkspaceTable.is_flagged_by_anthropic,
|
||||
isFlaggedByOpenAI: WorkspaceTable.is_flagged_by_openai,
|
||||
},
|
||||
billing: {
|
||||
balance: BillingTable.balance,
|
||||
@@ -772,6 +775,12 @@ export async function handler(
|
||||
)
|
||||
|
||||
if (!data) throw new AuthError(t("zen.api.error.invalidApiKey"))
|
||||
if (
|
||||
data.workspace.isBlocked ||
|
||||
(data.workspace.isFlaggedByAnthropic && modelInfo.id.startsWith("claude-")) ||
|
||||
(data.workspace.isFlaggedByOpenAI && modelInfo.id.startsWith("gpt-"))
|
||||
)
|
||||
throw new AuthError(t("zen.api.error.requestBlockedByUpstreamProvider"))
|
||||
if (
|
||||
modelInfo.id.startsWith("alpha-") &&
|
||||
Resource.App.stage === "production" &&
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE `workspace` ADD `is_blocked` boolean;--> statement-breakpoint
|
||||
ALTER TABLE `workspace` ADD `is_flagged_by_anthropic` boolean;--> statement-breakpoint
|
||||
ALTER TABLE `workspace` ADD `is_flagged_by_openai` boolean;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { json, primaryKey, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
|
||||
import { boolean, json, primaryKey, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
|
||||
import { timestamps, ulid } from "../drizzle/types"
|
||||
|
||||
export const WorkspaceTable = mysqlTable(
|
||||
@@ -8,6 +8,9 @@ export const WorkspaceTable = mysqlTable(
|
||||
slug: varchar("slug", { length: 255 }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
region: json("region").$type<("us" | "eu" | "sg" | "cn")[]>(),
|
||||
is_blocked: boolean(),
|
||||
is_flagged_by_anthropic: boolean(),
|
||||
is_flagged_by_openai: boolean(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [uniqueIndex("slug").on(table.slug)],
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export * as App from "./app"
|
||||
|
||||
import { Context, Layer } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
|
||||
export interface Info {
|
||||
readonly name: string
|
||||
readonly version: string
|
||||
readonly channel: string
|
||||
}
|
||||
|
||||
export const Metadata = Context.Reference<Info>("@opencode/App", {
|
||||
defaultValue: () => make(),
|
||||
})
|
||||
|
||||
export function make(input: Partial<Info> = {}): Info {
|
||||
return {
|
||||
name: input.name ?? "opencode",
|
||||
version: input.version ?? "unknown",
|
||||
channel: input.channel ?? "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
export function useragent(app: Info) {
|
||||
return `opencode/${app.channel}/${app.version}/${app.name}`
|
||||
}
|
||||
|
||||
export const layer = (input?: Partial<Info>) => Layer.succeed(Metadata, make(input))
|
||||
|
||||
export const configured = (input?: Partial<Info>) =>
|
||||
makeGlobalNode({ service: Metadata, layer: layer(input), deps: [] })
|
||||
|
||||
export const node = configured()
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ElicitationCompleteNotificationSchema,
|
||||
ElicitRequestSchema,
|
||||
GetPromptResultSchema,
|
||||
type Implementation,
|
||||
type ElicitRequestFormParams,
|
||||
type ElicitRequestParams,
|
||||
type ElicitRequestURLParams,
|
||||
@@ -29,7 +30,6 @@ import {
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Cause, Effect, Exit, Schema } from "effect"
|
||||
import { ConfigMCP } from "../config/mcp"
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
|
||||
const DEFAULT_STARTUP_TIMEOUT = 30_000
|
||||
const DEFAULT_CATALOG_TIMEOUT = 30_000
|
||||
@@ -185,6 +185,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||
// stored token (and a no-op redirect) surfaces an UnauthorizedError, which we map to needs_auth.
|
||||
authProvider?: OAuthClientProvider,
|
||||
elicitation?: ElicitationHandler,
|
||||
clientInfo: Implementation = { name: "opencode", version: "unknown" },
|
||||
) {
|
||||
const transport: Transport = yield* Effect.gen(function* () {
|
||||
if (config.type === "local") {
|
||||
@@ -209,7 +210,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||
})
|
||||
})
|
||||
const client = new Client(
|
||||
{ name: "opencode", version: InstallationVersion },
|
||||
clientInfo,
|
||||
{
|
||||
capabilities: {
|
||||
...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}),
|
||||
|
||||
@@ -157,7 +157,17 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/MCP") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
export const Options = Schema.Struct({
|
||||
clientInfo: Schema.optional(
|
||||
Schema.Struct({
|
||||
name: Schema.String,
|
||||
version: Schema.String,
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
export const layer = (options?: Options) => Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
@@ -498,7 +508,14 @@ export const layer = Layer.effect(
|
||||
const authProvider = yield* connectProvider(entry)
|
||||
// List tools as part of connect so a failure here marks the server failed rather than
|
||||
// leaving it connected with a silently empty tool list and no path to recover.
|
||||
const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider, elicitation).pipe(
|
||||
const result = yield* MCPClient.connect(
|
||||
name,
|
||||
entry.config,
|
||||
location.directory,
|
||||
authProvider,
|
||||
elicitation,
|
||||
options?.clientInfo,
|
||||
).pipe(
|
||||
Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))),
|
||||
Scope.provide(scope),
|
||||
Effect.exit,
|
||||
@@ -772,11 +789,15 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, Location.node, EventV2.node, Form.node, Integration.node, Credential.node],
|
||||
})
|
||||
export function configured(options?: Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [Config.node, Location.node, EventV2.node, Form.node, Integration.node, Credential.node],
|
||||
})
|
||||
}
|
||||
|
||||
export const node = configured()
|
||||
|
||||
// Schema `optional` strips undefined-valued properties on encode, so fields can assign
|
||||
// optional properties directly instead of conditionally spreading them.
|
||||
|
||||
@@ -3,12 +3,11 @@ import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effe
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { ModelsDev } from "@opencode-ai/schema/models-dev"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Client } from "@opencode-ai/util/client"
|
||||
import { App } from "./app"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Flock } from "@opencode-ai/util/flock"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { EventV2 } from "./event"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
@@ -543,7 +542,7 @@ export const layer = (options?: Options) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const events = yield* EventV2.Service
|
||||
const client = yield* Client.Name
|
||||
const app = yield* App.Metadata
|
||||
const http = HttpClient.filterStatusOk(
|
||||
(yield* HttpClient.HttpClient).pipe(
|
||||
HttpClient.retryTransient({
|
||||
@@ -556,7 +555,7 @@ export const layer = (options?: Options) =>
|
||||
|
||||
const source = options?.url || "https://models.dev"
|
||||
const fetch = options?.fetch ?? true
|
||||
const userAgent = `opencode/${InstallationChannel}/${InstallationVersion}/${client}`
|
||||
const userAgent = App.useragent(app)
|
||||
const filepath = path.join(
|
||||
Global.Path.cache,
|
||||
source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`,
|
||||
@@ -660,7 +659,7 @@ export function configured(options?: Options) {
|
||||
return makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [FSUtil.node, EventV2.node, Client.node, httpClient],
|
||||
deps: [FSUtil.node, EventV2.node, App.node, httpClient],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ export * as PluginV2 from "./plugin"
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Event, ID, type Info } from "@opencode-ai/schema/plugin"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { App } from "./app"
|
||||
import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { AISDK } from "./aisdk"
|
||||
@@ -144,6 +145,7 @@ export const node = makeLocationNode({
|
||||
layer,
|
||||
deps: [
|
||||
EventV2.node,
|
||||
App.node,
|
||||
AgentV2.node,
|
||||
AISDK.node,
|
||||
Catalog.node,
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as PluginHost from "./host"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { App } from "../app"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { AISDK } from "../aisdk"
|
||||
@@ -26,6 +27,7 @@ import { PluginHooks } from "./hooks"
|
||||
|
||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
|
||||
const app = yield* App.Metadata
|
||||
const agents = yield* AgentV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
@@ -61,6 +63,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
|
||||
|
||||
return {
|
||||
app,
|
||||
options: {},
|
||||
agent: {
|
||||
get: (id) => agents.get(AgentV2.ID.make(id)),
|
||||
|
||||
@@ -57,6 +57,7 @@ export function fromPromise(plugin: Plugin) {
|
||||
)
|
||||
|
||||
const context2: Context = {
|
||||
app: host.app,
|
||||
options: host.options,
|
||||
agent: {
|
||||
get: (id) => run(host.agent.get(id)),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import os from "os"
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { App } from "../../app"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
|
||||
@@ -23,7 +23,7 @@ export const CloudflareAIGatewayPlugin = define({
|
||||
accountId: config.accountId,
|
||||
gateway: config.gatewayId,
|
||||
apiKey: config.apiKey,
|
||||
options: gatewayOptions(evt.options, metadata),
|
||||
options: gatewayOptions(evt.options, metadata, ctx.app),
|
||||
} as any)
|
||||
const unified = createUnified({ apiKey: config.apiKey })
|
||||
evt.sdk = {
|
||||
@@ -64,7 +64,7 @@ function gatewayMetadata(options: Record<string, unknown>) {
|
||||
return raw ? Option.getOrUndefined(decodeJson(raw)) : undefined
|
||||
}
|
||||
|
||||
function gatewayOptions(options: Record<string, unknown>, metadata: unknown) {
|
||||
function gatewayOptions(options: Record<string, unknown>, metadata: unknown, app: App.Info) {
|
||||
return {
|
||||
metadata,
|
||||
cacheTtl: options.cacheTtl,
|
||||
@@ -72,7 +72,7 @@ function gatewayOptions(options: Record<string, unknown>, metadata: unknown) {
|
||||
skipCache: options.skipCache,
|
||||
collectLog: options.collectLog,
|
||||
headers: {
|
||||
"User-Agent": `opencode/${InstallationVersion} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||
"User-Agent": `${App.useragent(app)} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import os from "os"
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { App } from "../../app"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
@@ -29,10 +29,13 @@ export const CloudflareWorkersAIPlugin = define({
|
||||
if (!hasWorkersEndpoint(evt.model) && !accountId) return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
|
||||
evt.sdk = mod.createOpenAICompatible(
|
||||
sdkOptions({
|
||||
...evt.options,
|
||||
baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined),
|
||||
}) as any,
|
||||
sdkOptions(
|
||||
{
|
||||
...evt.options,
|
||||
baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined),
|
||||
},
|
||||
ctx.app,
|
||||
) as any,
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -61,13 +64,13 @@ function hasWorkersEndpoint(model: {
|
||||
return ProviderV2.isAISDK(model.package) && typeof model.settings?.baseURL === "string"
|
||||
}
|
||||
|
||||
function sdkOptions(options: Record<string, any>) {
|
||||
function sdkOptions(options: Record<string, any>, app: App.Info) {
|
||||
return {
|
||||
...options,
|
||||
baseURL: expandAccountId(options.baseURL),
|
||||
apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey,
|
||||
headers: {
|
||||
"User-Agent": `opencode/${InstallationVersion} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||
"User-Agent": `${App.useragent(app)} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||
...options.headers,
|
||||
},
|
||||
name: providerID,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Catalog } from "../../catalog"
|
||||
import { Credential } from "../../credential"
|
||||
import { EventV2 } from "../../event"
|
||||
import { CopilotModels } from "../../github-copilot/models"
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { App } from "../../app"
|
||||
import { Integration } from "../../integration"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
@@ -13,6 +13,7 @@ import type { PluginInternal } from "../internal"
|
||||
|
||||
const clientID = "Ov23li8tweQw6odWQebz"
|
||||
const apiVersion = "2026-06-01"
|
||||
const userApiVersion = "2025-04-01"
|
||||
const pollingSafetyMargin = 3000
|
||||
const methodID = Integration.MethodID.make("device")
|
||||
|
||||
@@ -27,10 +28,18 @@ const Token = Schema.Struct({
|
||||
error: Schema.optional(Schema.String),
|
||||
interval: Schema.optional(Schema.Number),
|
||||
})
|
||||
const User = Schema.Struct({
|
||||
endpoints: Schema.optional(
|
||||
Schema.Struct({
|
||||
api: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
})
|
||||
const decodeUser = Schema.decodeUnknownOption(User)
|
||||
const JsonBody = Schema.UnknownFromJsonString
|
||||
const decodeBody = Schema.decodeUnknownOption(JsonBody)
|
||||
|
||||
const oauth = {
|
||||
const oauth = (app: App.Info) => ({
|
||||
integrationID: Integration.ID.make("github-copilot"),
|
||||
method: {
|
||||
id: methodID,
|
||||
@@ -63,7 +72,7 @@ const oauth = {
|
||||
const urls = oauthURLs(domain)
|
||||
const device = yield* request(urls.device, {
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
headers: headers(app),
|
||||
body: JSON.stringify({ client_id: clientID, scope: "read:user" }),
|
||||
}).pipe(Effect.map(Schema.decodeUnknownSync(Device)))
|
||||
const interval = Math.max(device.interval, 1) * 1000
|
||||
@@ -71,7 +80,7 @@ const oauth = {
|
||||
const poll = (wait: number): Effect.Effect<Credential.OAuth, unknown> =>
|
||||
request(urls.token, {
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
headers: headers(app),
|
||||
body: JSON.stringify({
|
||||
client_id: clientID,
|
||||
device_code: device.device_code,
|
||||
@@ -81,15 +90,35 @@ const oauth = {
|
||||
Effect.map(Schema.decodeUnknownSync(Token)),
|
||||
Effect.flatMap((token) => {
|
||||
if (token.access_token) {
|
||||
return Effect.succeed(
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
refresh: token.access_token,
|
||||
access: token.access_token,
|
||||
expires: 0,
|
||||
...(enterprise ? { metadata: { enterpriseUrl: domain } } : {}),
|
||||
}),
|
||||
const access = token.access_token
|
||||
return request(
|
||||
`${domain === "github.com" ? "https://api.github.com" : `https://api.${domain}`}/copilot_internal/user`,
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${access}`,
|
||||
"User-Agent": App.useragent(app),
|
||||
"X-GitHub-Api-Version": userApiVersion,
|
||||
},
|
||||
},
|
||||
).pipe(
|
||||
Effect.map((user) => Option.getOrUndefined(decodeUser(user))?.endpoints?.api?.replace(/\/+$/, "")),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
Effect.map((apiEndpoint) =>
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
refresh: access,
|
||||
access,
|
||||
expires: 0,
|
||||
...((enterprise || apiEndpoint) && {
|
||||
metadata: {
|
||||
...(enterprise ? { enterpriseUrl: domain } : {}),
|
||||
...(apiEndpoint ? { apiEndpoint } : {}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (token.error === "authorization_pending")
|
||||
@@ -109,7 +138,7 @@ const oauth = {
|
||||
callback: poll(interval),
|
||||
}
|
||||
}),
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
}) satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
function shouldUseResponses(modelID: string) {
|
||||
// Copilot supports Responses for GPT-5 class models, except mini variants
|
||||
@@ -141,8 +170,7 @@ export const GithubCopilotPlugin = define({
|
||||
return
|
||||
}
|
||||
|
||||
const enterprise = credential.metadata?.enterpriseUrl
|
||||
loaded.baseURL = baseURL(typeof enterprise === "string" ? enterprise : undefined)
|
||||
loaded.baseURL = copilotBaseURL(credential.metadata)
|
||||
const provider = yield* catalog.provider.get(ProviderV2.ID.githubCopilot)
|
||||
const existing = (yield* catalog.model.all()).filter((model) => model.providerID === ProviderV2.ID.githubCopilot)
|
||||
loaded.models = yield* Effect.tryPromise({
|
||||
@@ -152,7 +180,7 @@ export const GithubCopilotPlugin = define({
|
||||
{
|
||||
...provider?.headers,
|
||||
Authorization: `Bearer ${credential.refresh}`,
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
"User-Agent": App.useragent(ctx.app),
|
||||
"X-GitHub-Api-Version": apiVersion,
|
||||
},
|
||||
existing,
|
||||
@@ -166,8 +194,9 @@ export const GithubCopilotPlugin = define({
|
||||
})
|
||||
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.method.update(oauth)
|
||||
draft.method.update(oauth(ctx.app))
|
||||
})
|
||||
yield* load()
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
|
||||
if (!item) return
|
||||
@@ -199,7 +228,6 @@ export const GithubCopilotPlugin = define({
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refresh().pipe(Effect.forkScoped)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
@@ -209,6 +237,7 @@ export const GithubCopilotPlugin = define({
|
||||
typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined,
|
||||
evt.options.fetch,
|
||||
evt.package === "@ai-sdk/anthropic",
|
||||
ctx.app,
|
||||
)
|
||||
if (evt.package === "@ai-sdk/anthropic") {
|
||||
evt.options.headers = {
|
||||
@@ -261,11 +290,18 @@ function baseURL(enterprise?: string) {
|
||||
return enterprise ? `https://copilot-api.${normalizeDomain(enterprise)}` : "https://api.githubcopilot.com"
|
||||
}
|
||||
|
||||
function headers() {
|
||||
export function copilotBaseURL(metadata?: Readonly<Record<string, unknown>>) {
|
||||
const endpoint = metadata?.apiEndpoint
|
||||
if (typeof endpoint === "string" && endpoint) return endpoint
|
||||
const enterprise = metadata?.enterpriseUrl
|
||||
return baseURL(typeof enterprise === "string" ? enterprise : undefined)
|
||||
}
|
||||
|
||||
function headers(app: App.Info) {
|
||||
return {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
"User-Agent": App.useragent(app),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,7 +318,12 @@ function request(url: string, init: RequestInit) {
|
||||
|
||||
type Fetch = (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response>
|
||||
|
||||
export function copilotFetch(token: string | undefined, upstream: Fetch | undefined, anthropic: boolean): Fetch {
|
||||
export function copilotFetch(
|
||||
token: string | undefined,
|
||||
upstream: Fetch | undefined,
|
||||
anthropic: boolean,
|
||||
app: App.Info,
|
||||
): Fetch {
|
||||
const send = upstream ?? fetch
|
||||
return async (input, init) => {
|
||||
const requestHeaders = new Headers(init?.headers)
|
||||
@@ -291,7 +332,7 @@ export function copilotFetch(token: string | undefined, upstream: Fetch | undefi
|
||||
requestHeaders.delete("x-api-key")
|
||||
requestHeaders.set("Authorization", `Bearer ${token}`)
|
||||
}
|
||||
requestHeaders.set("User-Agent", `opencode/${InstallationVersion}`)
|
||||
requestHeaders.set("User-Agent", App.useragent(app))
|
||||
requestHeaders.set("Openai-Intent", "conversation-edits")
|
||||
requestHeaders.set("X-GitHub-Api-Version", apiVersion)
|
||||
if (anthropic) requestHeaders.set("anthropic-beta", "interleaved-thinking-2025-05-14")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import os from "os"
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { App } from "../../app"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
@@ -20,7 +20,7 @@ export const GitLabPlugin = define({
|
||||
: (process.env.GITLAB_INSTANCE_URL ?? "https://gitlab.com"),
|
||||
apiKey: typeof evt.options.apiKey === "string" ? evt.options.apiKey : process.env.GITLAB_TOKEN,
|
||||
aiGatewayHeaders: {
|
||||
"User-Agent": `opencode/${InstallationVersion} gitlab-ai-provider/${mod.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||
"User-Agent": `${App.useragent(ctx.app)} gitlab-ai-provider/${mod.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||
"anthropic-beta": "context-1m-2025-08-07",
|
||||
...evt.options.aiGatewayHeaders,
|
||||
},
|
||||
|
||||
@@ -2,9 +2,9 @@ import { createServer } from "node:http"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import { App } from "../../app"
|
||||
import { Credential } from "../../credential"
|
||||
import { EventV2 } from "../../event"
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { Integration } from "../../integration"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { OauthCallbackPage } from "../../oauth/page"
|
||||
@@ -42,7 +42,7 @@ const Claims = Schema.fromJsonString(
|
||||
)
|
||||
const decodeClaims = Schema.decodeUnknownOption(Claims)
|
||||
|
||||
const browser = {
|
||||
const browser = (app: App.Info) => ({
|
||||
integrationID: Integration.ID.make("openai"),
|
||||
method: {
|
||||
id: browserMethodID,
|
||||
@@ -91,15 +91,15 @@ const browser = {
|
||||
url: authorizeURL(redirect, pkce, state),
|
||||
instructions: "Complete authorization in your browser. This window will close automatically.",
|
||||
callback: Deferred.await(code).pipe(
|
||||
Effect.flatMap((value) => exchange(value, redirect, pkce)),
|
||||
Effect.flatMap((value) => exchange(value, redirect, pkce, app)),
|
||||
Effect.map((tokens) => credential(browserMethodID, tokens)),
|
||||
),
|
||||
}
|
||||
}),
|
||||
refresh: (value) => refresh(browserMethodID, value),
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
refresh: (value) => refresh(browserMethodID, value, app),
|
||||
}) satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
const headless = {
|
||||
const headless = (app: App.Info) => ({
|
||||
integrationID: Integration.ID.make("openai"),
|
||||
method: {
|
||||
id: headlessMethodID,
|
||||
@@ -112,7 +112,7 @@ const headless = {
|
||||
`${issuer}/api/accounts/deviceauth/usercode`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: headers("application/json"),
|
||||
headers: headers("application/json", app),
|
||||
body: JSON.stringify({ client_id: clientID }),
|
||||
},
|
||||
)
|
||||
@@ -127,7 +127,7 @@ const headless = {
|
||||
try: (signal) =>
|
||||
fetch(`${issuer}/api/accounts/deviceauth/token`, {
|
||||
method: "POST",
|
||||
headers: headers("application/json"),
|
||||
headers: headers("application/json", app),
|
||||
body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }),
|
||||
signal,
|
||||
}),
|
||||
@@ -140,10 +140,12 @@ const headless = {
|
||||
}
|
||||
return credential(
|
||||
headlessMethodID,
|
||||
yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, {
|
||||
verifier: data.code_verifier,
|
||||
challenge: "",
|
||||
}),
|
||||
yield* exchange(
|
||||
data.authorization_code,
|
||||
`${issuer}/deviceauth/callback`,
|
||||
{ verifier: data.code_verifier, challenge: "" },
|
||||
app,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (response.status !== 403 && response.status !== 404) {
|
||||
@@ -154,8 +156,8 @@ const headless = {
|
||||
}),
|
||||
}
|
||||
}),
|
||||
refresh: (value) => refresh(headlessMethodID, value),
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
refresh: (value) => refresh(headlessMethodID, value, app),
|
||||
}) satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
export const OpenAIPlugin = define({
|
||||
id: "opencode.provider.openai",
|
||||
@@ -173,8 +175,8 @@ export const OpenAIPlugin = define({
|
||||
})
|
||||
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.method.update(browser)
|
||||
draft.method.update(headless)
|
||||
draft.method.update(browser(ctx.app))
|
||||
draft.method.update(headless(ctx.app))
|
||||
})
|
||||
yield* load()
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
@@ -232,14 +234,14 @@ export const OpenAIPlugin = define({
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
|
||||
function headers(contentType: string) {
|
||||
return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` }
|
||||
function headers(contentType: string, app: App.Info) {
|
||||
return { "Content-Type": contentType, "User-Agent": App.useragent(app) }
|
||||
}
|
||||
|
||||
function exchange(code: string, redirect: string, pkce: Pkce) {
|
||||
function exchange(code: string, redirect: string, pkce: Pkce, app: App.Info) {
|
||||
return request<TokenResponse>(`${issuer}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: headers("application/x-www-form-urlencoded"),
|
||||
headers: headers("application/x-www-form-urlencoded", app),
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
@@ -250,10 +252,10 @@ function exchange(code: string, redirect: string, pkce: Pkce) {
|
||||
})
|
||||
}
|
||||
|
||||
function refresh(methodID: Integration.MethodID, value: Pick<Credential.OAuth, "refresh" | "metadata">) {
|
||||
function refresh(methodID: Integration.MethodID, value: Pick<Credential.OAuth, "refresh" | "metadata">, app: App.Info) {
|
||||
return request<TokenResponse>(`${issuer}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: headers("application/x-www-form-urlencoded"),
|
||||
headers: headers("application/x-www-form-urlencoded", app),
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: value.refresh,
|
||||
|
||||
@@ -2,8 +2,8 @@ import { createServer } from "node:http"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Clock, Deferred, Effect, Option, Schema } from "effect"
|
||||
import { App } from "../../app"
|
||||
import { Credential } from "../../credential"
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { Integration } from "../../integration"
|
||||
import { OauthCallbackPage } from "../../oauth/page"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
@@ -48,7 +48,7 @@ const DeviceError = Schema.Struct({
|
||||
})
|
||||
const decodeDeviceError = Schema.decodeUnknownOption(Schema.fromJsonString(DeviceError))
|
||||
|
||||
const browser = {
|
||||
const browser = (app: App.Info) => ({
|
||||
integrationID: Integration.ID.make("xai"),
|
||||
method: {
|
||||
id: browserMethodID,
|
||||
@@ -108,15 +108,15 @@ const browser = {
|
||||
url: authorizeURL(pkce, state, randomString(32)),
|
||||
instructions: "Complete authorization in your browser. This window will close automatically.",
|
||||
callback: Deferred.await(code).pipe(
|
||||
Effect.flatMap((value) => exchange(value, pkce)),
|
||||
Effect.flatMap((value) => exchange(value, pkce, app)),
|
||||
Effect.flatMap((tokens) => credential(browserMethodID, tokens)),
|
||||
),
|
||||
}
|
||||
}),
|
||||
refresh: (value) => refresh(browserMethodID, Credential.OAuth.make({ ...value, methodID: browserMethodID })),
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
refresh: (value) => refresh(browserMethodID, Credential.OAuth.make({ ...value, methodID: browserMethodID }), app),
|
||||
}) satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
const device = {
|
||||
const device = (app: App.Info) => ({
|
||||
integrationID: Integration.ID.make("xai"),
|
||||
method: {
|
||||
id: deviceMethodID,
|
||||
@@ -128,7 +128,7 @@ const device = {
|
||||
`${issuer}/device/code`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
headers: headers(app),
|
||||
body: new URLSearchParams({ client_id: clientID, scope }).toString(),
|
||||
},
|
||||
Device,
|
||||
@@ -142,14 +142,14 @@ const device = {
|
||||
url: value.verification_uri_complete ?? value.verification_uri,
|
||||
instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`,
|
||||
...(lifetime ? { expiresAt: created + lifetime * 1000 } : {}),
|
||||
callback: poll(value).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))),
|
||||
callback: poll(value, app).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))),
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID })),
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID }), app),
|
||||
}) satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
export const XAIPlugin = define({
|
||||
id: "opencode.provider.xai",
|
||||
@@ -158,8 +158,8 @@ export const XAIPlugin = define({
|
||||
draft.update("xai", (integration) => {
|
||||
integration.name = "xAI"
|
||||
})
|
||||
draft.method.update(browser)
|
||||
draft.method.update(device)
|
||||
draft.method.update(browser(ctx.app))
|
||||
draft.method.update(device(ctx.app))
|
||||
draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
|
||||
})
|
||||
yield* ctx.aisdk.hook(
|
||||
@@ -180,12 +180,12 @@ export const XAIPlugin = define({
|
||||
}),
|
||||
})
|
||||
|
||||
function exchange(code: string, pkce: Pkce) {
|
||||
function exchange(code: string, pkce: Pkce, app: App.Info) {
|
||||
return request(
|
||||
`${issuer}/token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
headers: headers(app),
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
@@ -198,12 +198,12 @@ function exchange(code: string, pkce: Pkce) {
|
||||
)
|
||||
}
|
||||
|
||||
function refresh(methodID: Integration.MethodID, value: Credential.OAuth) {
|
||||
function refresh(methodID: Integration.MethodID, value: Credential.OAuth, app: App.Info) {
|
||||
return request(
|
||||
`${issuer}/token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
headers: headers(app),
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: value.refresh,
|
||||
@@ -214,7 +214,7 @@ function refresh(methodID: Integration.MethodID, value: Credential.OAuth) {
|
||||
).pipe(Effect.flatMap((tokens) => credential(methodID, tokens, value.refresh, value.metadata)))
|
||||
}
|
||||
|
||||
function poll(device: typeof Device.Type): Effect.Effect<Token, unknown> {
|
||||
function poll(device: typeof Device.Type, app: App.Info): Effect.Effect<Token, unknown> {
|
||||
return Effect.gen(function* () {
|
||||
const started = yield* Clock.currentTimeMillis
|
||||
const expires = started + positiveSeconds(device.expires_in, 300) * 1000
|
||||
@@ -225,7 +225,7 @@ function poll(device: typeof Device.Type): Effect.Effect<Token, unknown> {
|
||||
}
|
||||
const response = yield* send(`${issuer}/token`, {
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
headers: headers(app),
|
||||
body: new URLSearchParams({
|
||||
grant_type: deviceGrant,
|
||||
client_id: clientID,
|
||||
@@ -314,11 +314,11 @@ function tokenExpiration(tokens: Token) {
|
||||
return expiration ? expiration * 1000 : Date.now() + 3600 * 1000
|
||||
}
|
||||
|
||||
function headers() {
|
||||
function headers(app: App.Info) {
|
||||
return {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
"User-Agent": App.useragent(app),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
export * as SkillPlugin from "./skill"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { define, type Context } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect } from "effect"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { Config } from "../config"
|
||||
import { Location } from "../location"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -27,7 +26,7 @@ const REPORT_DESCRIPTION =
|
||||
export const Plugin = define({
|
||||
id: "opencode.skill",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const reportContent = yield* reportContentWithDiagnostics()
|
||||
const reportContent = yield* reportContentWithDiagnostics(ctx.app)
|
||||
yield* ctx.skill.transform((draft) => {
|
||||
draft.source(
|
||||
SkillV2.EmbeddedSource.make({
|
||||
@@ -58,7 +57,9 @@ export const Plugin = define({
|
||||
}),
|
||||
})
|
||||
|
||||
const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDiagnostics")(function* () {
|
||||
const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDiagnostics")(function* (
|
||||
app: Context["app"],
|
||||
) {
|
||||
const plugins = yield* configuredPlugins().pipe(Effect.orElseSucceed(() => ["Unavailable: failed to inspect config"]))
|
||||
return [
|
||||
ReportContent,
|
||||
@@ -67,8 +68,8 @@ const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDia
|
||||
"",
|
||||
"These values were captured when the built-in report skill was registered. Verify them before publishing.",
|
||||
"",
|
||||
`- opencode version: ${InstallationVersion}`,
|
||||
`- install/channel: ${InstallationChannel}`,
|
||||
`- opencode version: ${app.version}`,
|
||||
`- install/channel: ${app.channel}`,
|
||||
`- OS: ${os.type()} ${os.release()} (${os.platform()} ${os.arch()})`,
|
||||
`- Terminal: ${terminal()}`,
|
||||
`- Shell: ${shell()}`,
|
||||
|
||||
@@ -20,7 +20,7 @@ import { AbsolutePath, PositiveInt, RelativePath } from "./schema"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { SessionV1 } from "./v1/session"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { App } from "./app"
|
||||
import { Slug } from "./util/slug"
|
||||
import { ProjectTable } from "./project/sql"
|
||||
import path from "path"
|
||||
@@ -305,6 +305,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const app = yield* App.Metadata
|
||||
const database = yield* Database.Service
|
||||
const db = database.db
|
||||
const events = yield* EventV2.Service
|
||||
@@ -352,7 +353,7 @@ const layer = Layer.effect(
|
||||
const info = SessionV1.SessionInfo.make({
|
||||
id: sessionID,
|
||||
slug: Slug.create(),
|
||||
version: InstallationVersion,
|
||||
version: app.version,
|
||||
projectID: project.id,
|
||||
parentID: input.parentID,
|
||||
directory: location.directory,
|
||||
@@ -1040,5 +1041,6 @@ export const node = makeGlobalNode({
|
||||
SessionProjector.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
App.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import { llmClient } from "../effect/app-node-platform"
|
||||
import { SessionEvent } from "./event"
|
||||
import type { SessionMessage } from "./message"
|
||||
import { SessionModelHeaders } from "./model-headers"
|
||||
import { Client } from "@opencode-ai/util/client"
|
||||
import { App } from "../app"
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { toSessionError } from "./to-session-error"
|
||||
@@ -61,7 +61,7 @@ type Settings = {
|
||||
}
|
||||
|
||||
type Dependencies = {
|
||||
readonly client: string
|
||||
readonly app: App.Info
|
||||
readonly events: EventV2.Interface
|
||||
readonly llm: {
|
||||
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
|
||||
@@ -260,7 +260,7 @@ const make = (dependencies: Dependencies) => {
|
||||
.stream(
|
||||
LLM.request({
|
||||
model: plan.model,
|
||||
http: { headers: SessionModelHeaders.make(plan.session, dependencies.client) },
|
||||
http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) },
|
||||
messages: [Message.user(plan.prompt)],
|
||||
tools: [],
|
||||
}),
|
||||
@@ -399,13 +399,13 @@ export const layer = Layer.effect(
|
||||
const llm = yield* LLMClient.Service
|
||||
const config = yield* Config.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const client = yield* Client.Name
|
||||
return make({ events, llm, models, config: settings(yield* config.entries()), client })
|
||||
const app = yield* App.Metadata
|
||||
return make({ events, llm, models, config: settings(yield* config.entries()), app })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node, Client.node],
|
||||
deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node, App.node],
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { LLM, LLMClient, Message, SystemPart } from "@opencode-ai/ai"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Client } from "@opencode-ai/util/client"
|
||||
import { App } from "../app"
|
||||
import { llmClient } from "../effect/app-node-platform"
|
||||
import { PluginHooks } from "../plugin/hooks"
|
||||
import { SessionContext } from "./context"
|
||||
@@ -23,7 +23,7 @@ export const layer = Layer.effect(
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const client = yield* Client.Name
|
||||
const app = yield* App.Metadata
|
||||
|
||||
return SessionGenerate.Service.of({
|
||||
generate: Effect.fn("SessionGenerate.generate")(function* (input) {
|
||||
@@ -51,7 +51,7 @@ export const layer = Layer.effect(
|
||||
return (yield* llm.generate(
|
||||
LLM.request({
|
||||
model: model.model,
|
||||
http: { headers: SessionModelHeaders.make(selection.session, client) },
|
||||
http: { headers: SessionModelHeaders.make(selection.session, app) },
|
||||
providerOptions: { openai: { promptCacheKey } },
|
||||
system: contextEvent.system,
|
||||
messages: contextEvent.messages,
|
||||
@@ -67,5 +67,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: SessionGenerate.Service,
|
||||
layer,
|
||||
deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, Client.node, llmClient],
|
||||
deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, App.node, llmClient],
|
||||
})
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
export * as SessionModelHeaders from "./model-headers"
|
||||
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { App } from "../app"
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
export const make = (session: Pick<SessionSchema.Info, "id" | "parentID" | "projectID">, client: string) => ({
|
||||
export const make = (session: Pick<SessionSchema.Info, "id" | "parentID" | "projectID">, app: App.Info) => ({
|
||||
"x-session-affinity": session.id,
|
||||
"X-Session-Id": session.id,
|
||||
...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
"User-Agent": App.useragent(app),
|
||||
"x-opencode-project": session.projectID,
|
||||
"x-opencode-session": session.id,
|
||||
"x-opencode-client": client,
|
||||
"x-opencode-client": app.name,
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { LLM, Message, SystemPart, type LLMRequest, type ToolContent } from "@op
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Client } from "@opencode-ai/util/client"
|
||||
import { App } from "../app"
|
||||
import { ModelV2 } from "../model"
|
||||
import { PluginHooks } from "../plugin/hooks"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
@@ -85,7 +85,7 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const client = yield* Client.Name
|
||||
const app = yield* App.Metadata
|
||||
|
||||
const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) {
|
||||
const session = input.context.session
|
||||
@@ -123,7 +123,7 @@ export const layer = Layer.effect(
|
||||
const request = LLM.request({
|
||||
model,
|
||||
http: {
|
||||
headers: SessionModelHeaders.make(session, client),
|
||||
headers: SessionModelHeaders.make(session, app),
|
||||
},
|
||||
providerOptions: { openai: { promptCacheKey } },
|
||||
system: contextEvent.system,
|
||||
@@ -157,5 +157,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [PluginHooks.node, ToolRegistry.node, Client.node],
|
||||
deps: [PluginHooks.node, ToolRegistry.node, App.node],
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { AgentV2 } from "../agent"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Client } from "@opencode-ai/util/client"
|
||||
import { App } from "../app"
|
||||
import { llmClient } from "../effect/app-node-platform"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionHistory } from "./history"
|
||||
@@ -18,7 +18,7 @@ import { SessionUsage } from "./usage"
|
||||
const MAX_LENGTH = 100
|
||||
|
||||
type Dependencies = {
|
||||
readonly client: string
|
||||
readonly app: App.Info
|
||||
readonly events: EventV2.Interface
|
||||
readonly llm: {
|
||||
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
|
||||
@@ -68,7 +68,7 @@ const make = (dependencies: Dependencies) => {
|
||||
.stream(
|
||||
LLM.request({
|
||||
model: resolved.model,
|
||||
http: { headers: SessionModelHeaders.make(session, dependencies.client) },
|
||||
http: { headers: SessionModelHeaders.make(session, dependencies.app) },
|
||||
system: agent.system,
|
||||
messages: [Message.user(firstUser.text)],
|
||||
tools: [],
|
||||
@@ -112,8 +112,8 @@ export const layer = Layer.effect(
|
||||
const agents = yield* AgentV2.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const database = yield* Database.Service
|
||||
const client = yield* Client.Name
|
||||
const title = make({ events, llm, agents, models, client })
|
||||
const app = yield* App.Metadata
|
||||
const title = make({ events, llm, agents, models, app })
|
||||
return Service.of({
|
||||
generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session),
|
||||
})
|
||||
@@ -123,5 +123,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node, Client.node],
|
||||
deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node, App.node],
|
||||
})
|
||||
|
||||
@@ -185,7 +185,11 @@ export const Plugin = {
|
||||
|
||||
const run = Effect.gen(function* () {
|
||||
// The child session owns its agent/model (set at create); prompt only admits input.
|
||||
yield* runtime.session.prompt({ sessionID: child.id, text: input.prompt, resume: false })
|
||||
yield* runtime.session.prompt({
|
||||
sessionID: child.id,
|
||||
text: ["You are a subagent spawned by another session.", input.prompt].join("\n"),
|
||||
resume: false,
|
||||
})
|
||||
yield* runtime.session.resume(child.id)
|
||||
return yield* latestAssistantText(child.id)
|
||||
}).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id)))
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Context, Duration, Effect, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { App } from "../app"
|
||||
import { PositiveInt } from "../schema"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
@@ -241,7 +241,7 @@ export const Plugin = {
|
||||
// V2 invocation context does not safely expose the model yet.
|
||||
},
|
||||
{
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
"User-Agent": App.useragent(ctx.app),
|
||||
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { App } from "@opencode-ai/core/app"
|
||||
|
||||
test("formats app metadata as a user agent", () => {
|
||||
expect(App.useragent(App.make({ name: "sdk", version: "1.2.3", channel: "beta" }))).toBe(
|
||||
"opencode/beta/1.2.3/sdk",
|
||||
)
|
||||
})
|
||||
@@ -39,12 +39,13 @@ describe("resource", () => {
|
||||
process.env.OTEL_RESOURCE_ATTRIBUTES =
|
||||
"opencode.client=web,service.instance.id=override,service.namespace=anomalyco"
|
||||
|
||||
expect(resource("cli").attributes).toMatchObject({
|
||||
const app = { client: "cli", version: "1.2.3", channel: "beta" }
|
||||
expect(resource(app).attributes).toMatchObject({
|
||||
"opencode.client": "cli",
|
||||
"service.namespace": "anomalyco",
|
||||
})
|
||||
expect(resource("cli").attributes["service.instance.id"]).not.toBe("override")
|
||||
expect(resource("cli").attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/)
|
||||
expect(resource(app).attributes["service.instance.id"]).not.toBe("override")
|
||||
expect(resource(app).attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -136,6 +136,7 @@ function resourceServer(
|
||||
return {
|
||||
state,
|
||||
url: http.url.toString(),
|
||||
clientVersion: () => protocol.getClientVersion(),
|
||||
sendResourceListChanged: () => protocol.sendResourceListChanged(),
|
||||
completeElicitation: () => protocol.createElicitationCompletionNotifier("elicitation-test")(),
|
||||
close: async () => {
|
||||
@@ -151,10 +152,11 @@ function resourceServer(
|
||||
function resourceMcpLayer(
|
||||
server: string | typeof ConfigMCP.Server.Type,
|
||||
onFormCreated?: (form: Form.Info) => Effect.Effect<void>,
|
||||
options?: MCP.Options,
|
||||
) {
|
||||
const directory = AbsolutePath.make(import.meta.dir)
|
||||
const unusedIntegration = () => Effect.die("unused integration service")
|
||||
return MCP.layer.pipe(
|
||||
return MCP.layer(options).pipe(
|
||||
Layer.provideMerge(Form.layer),
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
@@ -643,7 +645,12 @@ test("loads and reads MCP resources", async () => {
|
||||
{ type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
|
||||
],
|
||||
})
|
||||
}).pipe(Effect.provide(resourceMcpLayer(server.url)))
|
||||
expect(server.clientVersion()).toMatchObject({ name: "sdk", version: "1.2.3" })
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
resourceMcpLayer(server.url, undefined, { clientInfo: { name: "sdk", version: "1.2.3" } }),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -302,7 +302,7 @@ describe("ModelsDev Service", () => {
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls.length).toBe(1)
|
||||
expect(final.calls[0].url).toContain("/api.json")
|
||||
expect(final.calls[0].userAgent).toContain("/cli")
|
||||
expect(final.calls[0].userAgent).toContain("/opencode")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ type Overrides = Partial<Omit<PluginContext, "options" | "session">> & {
|
||||
|
||||
export function host(overrides: Overrides = {}): PluginContext {
|
||||
return {
|
||||
app: overrides.app ?? { name: "test", version: "test", channel: "test" },
|
||||
options: {},
|
||||
agent: overrides.agent ?? {
|
||||
get: () => Effect.die("unused agent.get"),
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { App } from "@opencode-ai/core/app"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Fiber } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { copilotFetch, GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
|
||||
import { copilotBaseURL, copilotFetch, GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
@@ -14,11 +16,20 @@ import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const addPlugin = Effect.fn(function* (credential?: Credential.OAuth) {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* GithubCopilotPlugin.effect(host)
|
||||
yield* GithubCopilotPlugin.effect({
|
||||
...host,
|
||||
integration: {
|
||||
...host.integration,
|
||||
connection: {
|
||||
active: () => Effect.succeed(credential ? ({ type: "env", name: "TEST" } as const) : undefined),
|
||||
resolve: () => Effect.succeed(credential),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
@@ -40,6 +51,15 @@ function fakeSelectorSdk(calls: string[]) {
|
||||
}
|
||||
|
||||
describe("GithubCopilotPlugin", () => {
|
||||
test("prefers the account-specific Copilot API endpoint", () => {
|
||||
expect(
|
||||
copilotBaseURL({
|
||||
enterpriseUrl: "company.ghe.com",
|
||||
apiEndpoint: "https://api.business.githubcopilot.com",
|
||||
}),
|
||||
).toBe("https://api.business.githubcopilot.com")
|
||||
})
|
||||
|
||||
it.effect("registers GitHub Copilot device OAuth", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
@@ -52,6 +72,69 @@ describe("GithubCopilotPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("waits for initial Copilot models before exposing the connected catalog", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: async () => {
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
return Response.json({
|
||||
data: [
|
||||
{
|
||||
model_picker_enabled: true,
|
||||
id: "gpt-5",
|
||||
name: "GPT-5",
|
||||
version: "gpt-5-2026-06-01",
|
||||
supported_endpoints: ["/responses"],
|
||||
capabilities: {
|
||||
family: "gpt",
|
||||
limits: { max_output_tokens: 16_384, max_prompt_tokens: 180_000 },
|
||||
supports: { tool_calls: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
return { release, requested, server }
|
||||
}),
|
||||
({ release, requested, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(ProviderV2.ID.githubCopilot, () => {})
|
||||
draft.model.update(ProviderV2.ID.githubCopilot, ModelV2.ID.make("stale"), () => {})
|
||||
})
|
||||
const credential = Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: 0,
|
||||
metadata: { apiEndpoint: server.url.origin },
|
||||
})
|
||||
|
||||
const plugin = yield* addPlugin(credential).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.promise(() => requested.promise)
|
||||
const pending = plugin.pollUnsafe() === undefined
|
||||
release.resolve()
|
||||
yield* Fiber.join(plugin)
|
||||
|
||||
expect(pending).toBe(true)
|
||||
expect(yield* catalog.model.get(ProviderV2.ID.githubCopilot, ModelV2.ID.make("stale"))).toBeUndefined()
|
||||
expect(yield* catalog.model.get(ProviderV2.ID.githubCopilot, ModelV2.ID.make("gpt-5"))).toBeDefined()
|
||||
}),
|
||||
({ release, server }) => {
|
||||
release.resolve()
|
||||
return Effect.promise(() => server.stop(true))
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
it.live("adds Copilot authentication and request metadata headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const requests: Headers[] = []
|
||||
@@ -62,6 +145,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
return Response.json({ ok: true })
|
||||
},
|
||||
false,
|
||||
App.make({ name: "test", version: "1.2.3", channel: "beta" }),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
send("https://api.githubcopilot.com/chat/completions", {
|
||||
@@ -77,6 +161,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
expect(requests[0]?.get("x-initiator")).toBe("user")
|
||||
expect(requests[0]?.get("copilot-vision-request")).toBe("true")
|
||||
expect(requests[0]?.get("x-github-api-version")).toBe("2026-06-01")
|
||||
expect(requests[0]?.get("user-agent")).toBe("opencode/beta/1.2.3/test")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Effect } from "effect"
|
||||
import { SkillPlugin } from "@opencode-ai/core/plugin/skill"
|
||||
@@ -21,6 +20,7 @@ describe("SkillPlugin.Plugin", () => {
|
||||
const skill = yield* SkillV2.Service
|
||||
yield* SkillPlugin.Plugin.effect(
|
||||
host({
|
||||
app: { name: "test", version: "1.2.3", channel: "beta" },
|
||||
skill: {
|
||||
list: () => Effect.die("unused skill.list"),
|
||||
transform: skill.transform,
|
||||
@@ -54,7 +54,8 @@ describe("SkillPlugin.Plugin", () => {
|
||||
}),
|
||||
)
|
||||
expect(report?.slash).toBe(true)
|
||||
expect(report?.content).toContain(`- opencode version: ${InstallationVersion}`)
|
||||
expect(report?.content).toContain("- opencode version: 1.2.3")
|
||||
expect(report?.content).toContain("- install/channel: beta")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { App } from "@opencode-ai/core/app"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
|
||||
@@ -192,10 +192,10 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
"x-session-affinity": sessionID,
|
||||
"X-Session-Id": sessionID,
|
||||
"x-parent-session-id": parentID,
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
"User-Agent": App.useragent(App.make()),
|
||||
"x-opencode-project": Project.ID.global,
|
||||
"x-opencode-session": sessionID,
|
||||
"x-opencode-client": "cli",
|
||||
"x-opencode-client": "opencode",
|
||||
})
|
||||
expect(requests[0]?.generation).toBeUndefined()
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
|
||||
|
||||
@@ -22,7 +22,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { App } from "@opencode-ai/core/app"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
@@ -3180,10 +3180,10 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(requests[0]?.http?.headers).toEqual({
|
||||
"x-session-affinity": sessionID,
|
||||
"X-Session-Id": sessionID,
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
"User-Agent": App.useragent(App.make()),
|
||||
"x-opencode-project": Project.ID.global,
|
||||
"x-opencode-session": sessionID,
|
||||
"x-opencode-client": "cli",
|
||||
"x-opencode-client": "opencode",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ import { SessionTitle } from "@opencode-ai/core/session/title"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { App } from "@opencode-ai/core/app"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
@@ -155,10 +155,10 @@ it.effect("generates a title from the sole user message and renames the session"
|
||||
expect(requests[0]?.http?.headers).toEqual({
|
||||
"x-session-affinity": sessionID,
|
||||
"X-Session-Id": sessionID,
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
"User-Agent": App.useragent(App.make()),
|
||||
"x-opencode-project": Project.ID.global,
|
||||
"x-opencode-session": sessionID,
|
||||
"x-opencode-client": "cli",
|
||||
"x-opencode-client": "opencode",
|
||||
})
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build")
|
||||
const renamed = yield* store.get(sessionID)
|
||||
|
||||
@@ -273,6 +273,9 @@ describe("SubagentTool", () => {
|
||||
agent: "reviewer",
|
||||
model: childModel,
|
||||
})
|
||||
expect((yield* sessions.pending(child.id)).find((message) => message.type === "user")?.data.text).toBe(
|
||||
"You are a subagent spawned by another session.\nreview this",
|
||||
)
|
||||
|
||||
const fallback = yield* settleTool(registry, {
|
||||
sessionID: parent.id,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface App {
|
||||
readonly name: string
|
||||
readonly version: string
|
||||
readonly channel: string
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PluginApi } from "@opencode-ai/client/effect/api"
|
||||
import type { Effect, Scope } from "effect"
|
||||
import type { PluginOptions } from "../options.js"
|
||||
import type { App } from "../app.js"
|
||||
import type { AgentDomain } from "./agent.js"
|
||||
import type { AISDKDomain } from "./aisdk.js"
|
||||
import type { CatalogDomain } from "./catalog.js"
|
||||
@@ -13,6 +14,7 @@ import type { SkillDomain } from "./skill.js"
|
||||
import type { ToolDomain } from "./tool.js"
|
||||
|
||||
export interface Context {
|
||||
readonly app: App
|
||||
readonly options: PluginOptions
|
||||
readonly agent: AgentDomain
|
||||
readonly aisdk: AISDKDomain
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PluginApi } from "@opencode-ai/client/promise/api"
|
||||
import type { PluginOptions } from "../options.js"
|
||||
import type { App } from "../app.js"
|
||||
import type { AgentDomain } from "./agent.js"
|
||||
import type { AISDKDomain } from "./aisdk.js"
|
||||
import type { CatalogDomain } from "./catalog.js"
|
||||
@@ -12,6 +13,7 @@ import type { SkillDomain } from "./skill.js"
|
||||
import type { ToolDomain } from "./tool.js"
|
||||
|
||||
export interface Context {
|
||||
readonly app: App
|
||||
readonly options: PluginOptions
|
||||
readonly agent: AgentDomain
|
||||
readonly aisdk: AISDKDomain
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# Third-Party Notices
|
||||
|
||||
## alien-signals
|
||||
|
||||
The internal reactive kernel in `src/reactivity.ts` adapts the dependency
|
||||
graph algorithm of [alien-signals](https://github.com/stackblitz/alien-signals)
|
||||
3.2.1: the intrusive doubly-linked dependency and subscriber links with
|
||||
versioned in-place reuse, and the iterative `propagate` and `checkDirty`
|
||||
graph walks. alien-signals is not a runtime dependency of Quark.
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-present Johnson Chu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
@@ -1,195 +0,0 @@
|
||||
import { Layout } from "../src/layout"
|
||||
import { createHarness, type Workload } from "./harness"
|
||||
|
||||
type Job = {
|
||||
readonly id: number
|
||||
readonly labels: readonly number[]
|
||||
readonly status: "running" | "retrying"
|
||||
readonly value: number
|
||||
}
|
||||
|
||||
const size = 1_000
|
||||
const iterations = 200_000
|
||||
const initial = Array.from({ length: size }, (_, id): Job => ({
|
||||
id,
|
||||
labels: [id],
|
||||
status: id === 750 ? "retrying" : "running",
|
||||
value: 0,
|
||||
}))
|
||||
const JobLayout = Layout.struct({
|
||||
id: Layout.key(Layout.number),
|
||||
labels: Layout.array(Layout.number),
|
||||
status: Layout.string,
|
||||
value: Layout.number,
|
||||
})
|
||||
const Jobs = Layout.collection(JobLayout, ({ members, first }) => ({
|
||||
labels: members(["labels"], (job) => job.labels),
|
||||
retry: first(["status"], (job) => job.status === "retrying"),
|
||||
}))
|
||||
const LabeledJobs = Layout.collection(JobLayout, ({ members }) => ({
|
||||
labels: members(["labels"], (job) => job.labels),
|
||||
}))
|
||||
const JobPlan = Layout.compile(JobLayout)
|
||||
const bench = createHarness({ warmup: 10_000, width: 38 })
|
||||
|
||||
function manualValueUpdate(): Workload {
|
||||
const jobs = JobPlan.make(initial)
|
||||
const labels = new Set(initial.flatMap((job) => job.labels))
|
||||
const retry = jobs.get(750)!
|
||||
return {
|
||||
run(index) {
|
||||
const id = index % size
|
||||
jobs.modify(id, (job) => ({ ...job, value: index + 1 }))
|
||||
},
|
||||
consume: () => Number(labels.has(500)) + retry().id + jobs.get(iterations % size)!().value,
|
||||
}
|
||||
}
|
||||
|
||||
function indexedValueUpdate(): Workload {
|
||||
const jobs = Jobs.make(initial)
|
||||
return {
|
||||
run(index) {
|
||||
const id = index % size
|
||||
jobs.modify(id, (job) => ({ ...job, value: index + 1 }))
|
||||
},
|
||||
consume: () =>
|
||||
Number(jobs.hasMember("labels", 500)) + jobs.first("retry")()!.id + jobs.get(iterations % size)!().value,
|
||||
}
|
||||
}
|
||||
|
||||
function manualMemberAppend(): Workload {
|
||||
const jobs = JobPlan.make(initial)
|
||||
const labels = new Map(initial.flatMap((job) => job.labels.map((label) => [label, 1])))
|
||||
const members = new Map(initial.map((job) => [job.id, new Set(job.labels)]))
|
||||
return {
|
||||
run(index) {
|
||||
const id = index % size
|
||||
const label = size + index
|
||||
jobs.modify(id, (job) => {
|
||||
const next = [...job.labels.slice(-7), label]
|
||||
const previous = members.get(id)!
|
||||
const current = new Set(next)
|
||||
previous.forEach((member) => {
|
||||
if (current.has(member)) return
|
||||
const count = labels.get(member)!
|
||||
if (count === 1) labels.delete(member)
|
||||
if (count > 1) labels.set(member, count - 1)
|
||||
})
|
||||
current.forEach((member) => {
|
||||
if (!previous.has(member)) labels.set(member, (labels.get(member) ?? 0) + 1)
|
||||
})
|
||||
members.set(id, current)
|
||||
return { ...job, labels: next }
|
||||
})
|
||||
},
|
||||
consume: () => Number(labels.has(size + iterations - 1)) + jobs.get(iterations % size)!().labels.length,
|
||||
}
|
||||
}
|
||||
|
||||
function indexedMemberAppend(): Workload {
|
||||
const jobs = LabeledJobs.make(initial)
|
||||
return {
|
||||
run(index) {
|
||||
const id = index % size
|
||||
const label = size + index
|
||||
jobs.modify(id, (job) => ({
|
||||
...job,
|
||||
labels: [...job.labels.slice(-7), label],
|
||||
}))
|
||||
},
|
||||
consume: () =>
|
||||
Number(jobs.hasMember("labels", size + iterations - 1)) + jobs.get(iterations % size)!().labels.length,
|
||||
}
|
||||
}
|
||||
|
||||
function manualGrowingAppend(): Workload {
|
||||
const jobs = JobPlan.make([{ id: 0, labels: [], status: "running", value: 0 }])
|
||||
const labels = new Set<number>()
|
||||
let label = 0
|
||||
return {
|
||||
run() {
|
||||
const next = label++
|
||||
jobs.modify(0, (job) => ({ ...job, labels: [...job.labels, next] }))
|
||||
labels.add(next)
|
||||
},
|
||||
consume: () => Number(labels.has(label - 1)) + jobs.get(0)!().labels.length,
|
||||
}
|
||||
}
|
||||
|
||||
function indexedGrowingAppend(): Workload {
|
||||
const jobs = LabeledJobs.make([{ id: 0, labels: [], status: "running", value: 0 }])
|
||||
let label = 0
|
||||
return {
|
||||
run() {
|
||||
const next = label++
|
||||
jobs.modify(0, (job) => ({ ...job, labels: [...job.labels, next] }), {
|
||||
members: { labels: { add: [next] } },
|
||||
})
|
||||
},
|
||||
consume: () => Number(jobs.hasMember("labels", label - 1)) + jobs.get(0)!().labels.length,
|
||||
}
|
||||
}
|
||||
|
||||
function automaticGrowingAppend(): Workload {
|
||||
const jobs = LabeledJobs.make([{ id: 0, labels: [], status: "running", value: 0 }])
|
||||
let label = 0
|
||||
return {
|
||||
run() {
|
||||
const next = label++
|
||||
jobs.modify(0, (job) => ({ ...job, labels: [...job.labels, next] }))
|
||||
},
|
||||
consume: () => Number(jobs.hasMember("labels", label - 1)) + jobs.get(0)!().labels.length,
|
||||
}
|
||||
}
|
||||
|
||||
function collectionSet(indexed: boolean, changed: boolean): Workload {
|
||||
const jobs = indexed ? Jobs.make(initial) : JobPlan.make(initial)
|
||||
return {
|
||||
run(index) {
|
||||
const id = index % size
|
||||
const current = jobs.values()
|
||||
const job = current[id]
|
||||
jobs.set(current.with(id, { ...job, value: changed ? job.value + 1 : job.value }))
|
||||
},
|
||||
consume: () => jobs.values()[0].value + jobs.values().length,
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Compiled collection benchmark (${size} items, ${bench.samples} samples)\n`)
|
||||
|
||||
const value = bench.compare(iterations, [
|
||||
{ name: "Handwritten value update", make: manualValueUpdate },
|
||||
{ name: "Compiled indexed value update", make: indexedValueUpdate },
|
||||
])
|
||||
const member = bench.compare(iterations, [
|
||||
{ name: "Handwritten member append", make: manualMemberAppend },
|
||||
{ name: "Compiled indexed member append", make: indexedMemberAppend },
|
||||
])
|
||||
const growing = bench.compare(2_000, [
|
||||
{ name: "Handwritten growing append", make: manualGrowingAppend },
|
||||
{ name: "Automatic indexed growing append", make: automaticGrowingAppend },
|
||||
{ name: "Indexed delta growing append", make: indexedGrowingAppend },
|
||||
])
|
||||
const equivalentSet = bench.compare(2_000, [
|
||||
{ name: "Bare keyed equivalent set", make: () => collectionSet(false, false) },
|
||||
{ name: "Compiled indexed equivalent set", make: () => collectionSet(true, false) },
|
||||
])
|
||||
const changedSet = bench.compare(2_000, [
|
||||
{ name: "Bare keyed changed set", make: () => collectionSet(false, true) },
|
||||
{ name: "Compiled indexed changed set", make: () => collectionSet(true, true) },
|
||||
])
|
||||
|
||||
console.log("\nRatios to handwritten (lower is faster)")
|
||||
console.log(`Value update: ${value.ratio(1, 0).toFixed(3)}x`)
|
||||
console.log(`Member append: ${member.ratio(1, 0).toFixed(3)}x`)
|
||||
console.log(`Automatic growing append: ${growing.ratio(1, 0).toFixed(3)}x`)
|
||||
console.log(`Delta growing append: ${growing.ratio(2, 0).toFixed(3)}x`)
|
||||
console.log(`Equivalent collection set: ${equivalentSet.ratio(1, 0).toFixed(3)}x`)
|
||||
console.log(`Changed collection set: ${changedSet.ratio(1, 0).toFixed(3)}x`)
|
||||
console.log(`METRIC collection_value_ratio=${value.ratio(1, 0).toFixed(6)}`)
|
||||
console.log(`METRIC collection_member_ratio=${member.ratio(1, 0).toFixed(6)}`)
|
||||
console.log(`METRIC collection_automatic_growing_ratio=${growing.ratio(1, 0).toFixed(6)}`)
|
||||
console.log(`METRIC collection_delta_growing_ratio=${growing.ratio(2, 0).toFixed(6)}`)
|
||||
console.log(`METRIC collection_equivalent_set_ratio=${equivalentSet.ratio(1, 0).toFixed(6)}`)
|
||||
console.log(`METRIC collection_changed_set_ratio=${changedSet.ratio(1, 0).toFixed(6)}`)
|
||||
bench.finish()
|
||||
@@ -1,60 +0,0 @@
|
||||
export type Workload = {
|
||||
run(index: number): void
|
||||
consume(): number
|
||||
dispose?(): void
|
||||
}
|
||||
|
||||
export type Variant = {
|
||||
readonly name: string
|
||||
readonly make: () => Workload
|
||||
}
|
||||
|
||||
export function createHarness(options: { readonly samples?: number; readonly warmup?: number } = {}) {
|
||||
const samples = options.samples ?? 9
|
||||
const warmup = options.warmup ?? 500
|
||||
let checksum = 0
|
||||
|
||||
return {
|
||||
samples,
|
||||
compare(iterations: number, variants: readonly Variant[]) {
|
||||
const timings = variants.map(() => [] as number[])
|
||||
for (let sample = -1; sample < samples; sample++) {
|
||||
const offset = sample < 0 ? 0 : sample % variants.length
|
||||
variants
|
||||
.map((_variant, index) => (index + offset) % variants.length)
|
||||
.forEach((variantIndex) => {
|
||||
const workload = variants[variantIndex].make()
|
||||
for (let index = 0; index < Math.min(iterations, warmup); index++) workload.run(index)
|
||||
const start = Bun.nanoseconds()
|
||||
for (let index = 0; index < iterations; index++) workload.run(index)
|
||||
const elapsed = Bun.nanoseconds() - start
|
||||
checksum += workload.consume()
|
||||
workload.dispose?.()
|
||||
if (sample >= 0) timings[variantIndex].push(elapsed / iterations)
|
||||
})
|
||||
}
|
||||
|
||||
const medians = variants.map((variant, index) => {
|
||||
const median = middle(timings[index])
|
||||
const mad = middle(timings[index].map((value) => Math.abs(value - median)))
|
||||
const metric = variant.name.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_")
|
||||
console.log(`${variant.name.padEnd(42)} ${median.toFixed(1).padStart(10)} ns/op +/- ${mad.toFixed(1)} MAD`)
|
||||
console.log(`METRIC ${metric}_ns_per_op=${median.toFixed(3)}`)
|
||||
return median
|
||||
})
|
||||
return {
|
||||
medians,
|
||||
ratio(left: number, right: number) {
|
||||
return middle(timings[left].map((value, index) => value / timings[right][index]))
|
||||
},
|
||||
}
|
||||
},
|
||||
finish() {
|
||||
console.log(`CHECKSUM ${checksum}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function middle(values: number[]) {
|
||||
return values.toSorted((a, b) => a - b)[Math.floor(values.length / 2)]
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import { createComputed, createRoot } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { Keyed } from "../src"
|
||||
import { createHarness, type Workload } from "./harness"
|
||||
|
||||
type Item = {
|
||||
readonly id: number
|
||||
readonly value: number
|
||||
}
|
||||
|
||||
const bench = createHarness()
|
||||
const results: Array<{ readonly name: string; readonly ratio: number }> = []
|
||||
|
||||
function initial(size: number) {
|
||||
return Array.from({ length: size }, (_, id): Item => ({ id, value: 0 }))
|
||||
}
|
||||
|
||||
function project(values: readonly Item[]) {
|
||||
return values.reduce((total, value) => total + value.id + value.value, 0)
|
||||
}
|
||||
|
||||
function quarkDirect(size: number, aggregate: boolean): Workload {
|
||||
const values = initial(size)
|
||||
const keyed = Keyed.make<Item, number>({
|
||||
key: (item) => item.id,
|
||||
equivalent: (left, right) => left.value === right.value,
|
||||
})
|
||||
keyed.set(values)
|
||||
const target = keyed.slots()[Math.floor(size / 2)]
|
||||
let sink = aggregate ? project(keyed.values()) : target().value
|
||||
const dispose = aggregate
|
||||
? keyed.values.subscribe((next) => (sink = project(next)))
|
||||
: target.subscribe((value) => (sink = value.value))
|
||||
return {
|
||||
run: (index) => keyed.update({ id: Math.floor(size / 2), value: index + 1 }),
|
||||
consume: () => sink,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
function solidDirect(size: number, aggregate: boolean): Workload {
|
||||
let run = (_index: number) => {}
|
||||
let consume = () => 0
|
||||
let dispose = () => {}
|
||||
createRoot((rootDispose) => {
|
||||
dispose = rootDispose
|
||||
const [values, setValues] = createStore(initial(size))
|
||||
const target = Math.floor(size / 2)
|
||||
let sink = aggregate ? project(values) : values[target].value
|
||||
if (aggregate) createComputed(() => (sink = project(values)))
|
||||
else createComputed(() => (sink = values[target].value))
|
||||
run = (index) => setValues(target, reconcile({ id: target, value: index + 1 }))
|
||||
consume = () => sink
|
||||
})
|
||||
return { run, consume, dispose }
|
||||
}
|
||||
|
||||
function quarkNoSubscriber(size: number): Workload {
|
||||
const keyed = Keyed.make<Item, number>({
|
||||
key: (item) => item.id,
|
||||
equivalent: (left, right) => left.value === right.value,
|
||||
})
|
||||
keyed.set(initial(size))
|
||||
const target = Math.floor(size / 2)
|
||||
return {
|
||||
run: (index) => keyed.update({ id: target, value: index + 1 }),
|
||||
consume: () => keyed.slots()[target]().value,
|
||||
}
|
||||
}
|
||||
|
||||
function solidNoSubscriber(size: number): Workload {
|
||||
const [values, setValues] = createStore(initial(size))
|
||||
const target = Math.floor(size / 2)
|
||||
return {
|
||||
run: (index) => setValues(target, reconcile({ id: target, value: index + 1 })),
|
||||
consume: () => values[target].value,
|
||||
}
|
||||
}
|
||||
|
||||
function solidPathWriteNoSubscriber(size: number): Workload {
|
||||
const [values, setValues] = createStore(initial(size))
|
||||
const target = Math.floor(size / 2)
|
||||
return {
|
||||
run: (index) => setValues(target, "value", index + 1),
|
||||
consume: () => values[target].value,
|
||||
}
|
||||
}
|
||||
|
||||
function quarkDense(size: number): Workload {
|
||||
const keyed = Keyed.make<Item, number>({
|
||||
key: (item) => item.id,
|
||||
equivalent: (left, right) => left.value === right.value,
|
||||
})
|
||||
keyed.set(initial(size))
|
||||
let sink = project(keyed.values())
|
||||
const dispose = keyed.values.subscribe((values) => (sink = project(values)))
|
||||
return {
|
||||
run: (index) => keyed.set(initial(size).map((item) => ({ ...item, value: index + 1 }))),
|
||||
consume: () => sink,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
function solidDense(size: number): Workload {
|
||||
let run = (_index: number) => {}
|
||||
let consume = () => 0
|
||||
let dispose = () => {}
|
||||
createRoot((rootDispose) => {
|
||||
dispose = rootDispose
|
||||
const [values, setValues] = createStore(initial(size))
|
||||
let sink = project(values)
|
||||
createComputed(() => (sink = project(values)))
|
||||
run = (index) => setValues(reconcile(initial(size).map((item) => ({ ...item, value: index + 1 }))))
|
||||
consume = () => sink
|
||||
})
|
||||
return { run, consume, dispose }
|
||||
}
|
||||
|
||||
function quarkUnstable(size: number): Workload {
|
||||
const keyed = Keyed.make<Item, number>({ key: (item) => item.id })
|
||||
keyed.set(initial(size))
|
||||
let sink = project(keyed.values())
|
||||
const dispose = keyed.values.subscribe((values) => (sink = project(values)))
|
||||
return {
|
||||
run(index) {
|
||||
const offset = (index + 1) * size
|
||||
keyed.set(initial(size).map((item) => ({ id: item.id + offset, value: index })))
|
||||
},
|
||||
consume: () => sink,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
function solidUnstable(size: number): Workload {
|
||||
let run = (_index: number) => {}
|
||||
let consume = () => 0
|
||||
let dispose = () => {}
|
||||
createRoot((rootDispose) => {
|
||||
dispose = rootDispose
|
||||
const [values, setValues] = createStore(initial(size))
|
||||
let sink = project(values)
|
||||
createComputed(() => (sink = project(values)))
|
||||
run = (index) => {
|
||||
const offset = (index + 1) * size
|
||||
setValues(reconcile(initial(size).map((item) => ({ id: item.id + offset, value: index }))))
|
||||
}
|
||||
consume = () => sink
|
||||
})
|
||||
return { run, consume, dispose }
|
||||
}
|
||||
|
||||
function compare(name: string, iterations: number, quark: () => Workload, solid: () => Workload) {
|
||||
console.log(`\n${name}`)
|
||||
const result = bench.compare(iterations, [
|
||||
{ name: `Quark ${name}`, make: quark },
|
||||
{ name: `Solid ${name}`, make: solid },
|
||||
])
|
||||
results.push({ name, ratio: result.ratio(0, 1) })
|
||||
}
|
||||
|
||||
console.log(`Keyed integration benchmark (${bench.samples} samples)`)
|
||||
|
||||
compare(
|
||||
"direct no subscribers 1000",
|
||||
200_000,
|
||||
() => quarkNoSubscriber(1_000),
|
||||
() => solidNoSubscriber(1_000),
|
||||
)
|
||||
compare(
|
||||
"adversarial direct path write 1000",
|
||||
200_000,
|
||||
() => quarkNoSubscriber(1_000),
|
||||
() => solidPathWriteNoSubscriber(1_000),
|
||||
)
|
||||
compare(
|
||||
"subscribed values 10",
|
||||
100_000,
|
||||
() => quarkDirect(10, true),
|
||||
() => solidDirect(10, true),
|
||||
)
|
||||
compare(
|
||||
"subscribed values 100",
|
||||
25_000,
|
||||
() => quarkDirect(100, true),
|
||||
() => solidDirect(100, true),
|
||||
)
|
||||
compare(
|
||||
"subscribed values 1000",
|
||||
2_500,
|
||||
() => quarkDirect(1_000, true),
|
||||
() => solidDirect(1_000, true),
|
||||
)
|
||||
compare(
|
||||
"subscribed values 10000",
|
||||
250,
|
||||
() => quarkDirect(10_000, true),
|
||||
() => solidDirect(10_000, true),
|
||||
)
|
||||
compare(
|
||||
"dense update 1000",
|
||||
250,
|
||||
() => quarkDense(1_000),
|
||||
() => solidDense(1_000),
|
||||
)
|
||||
compare(
|
||||
"unstable keys 100",
|
||||
1_000,
|
||||
() => quarkUnstable(100),
|
||||
() => solidUnstable(100),
|
||||
)
|
||||
|
||||
console.log("\nRatios to Solid (lower is faster)")
|
||||
results.forEach((result) => {
|
||||
console.log(`${result.name.padEnd(34)} ${result.ratio.toFixed(3)}x`)
|
||||
console.log(`METRIC ${result.name.replaceAll(/[^a-z0-9]+/g, "_")}_ratio=${result.ratio.toFixed(6)}`)
|
||||
})
|
||||
bench.finish()
|
||||
@@ -1,63 +0,0 @@
|
||||
import { createHarness, type Workload } from "./harness"
|
||||
|
||||
type Row =
|
||||
| { readonly type: "message"; readonly messageID: string }
|
||||
| { readonly type: "part"; readonly messageID: string; readonly partID: string }
|
||||
| {
|
||||
readonly type: "group"
|
||||
readonly kind: "reasoning" | "exploration"
|
||||
readonly messageID: string
|
||||
readonly partID: string
|
||||
}
|
||||
|
||||
const size = 10_000
|
||||
const iterations = 1_000_000
|
||||
const rows = Array.from({ length: size }, (_, index): Row => {
|
||||
if (index % 3 === 0) return { type: "message", messageID: `message-${index}` }
|
||||
if (index % 3 === 1) return { type: "part", messageID: `message-${index >> 2}`, partID: `text:${index}` }
|
||||
return {
|
||||
type: "group",
|
||||
kind: index % 2 === 0 ? "reasoning" : "exploration",
|
||||
messageID: `message-${index >> 2}`,
|
||||
partID: `call-${index}`,
|
||||
}
|
||||
})
|
||||
const precomputed = rows.map((row) => ({ row, id: concatenate(row) }))
|
||||
const bench = createHarness()
|
||||
|
||||
function workload(read: (index: number) => string): Workload {
|
||||
let sink = 0
|
||||
return {
|
||||
run(index) {
|
||||
sink += read(index % size).length
|
||||
},
|
||||
consume: () => sink,
|
||||
}
|
||||
}
|
||||
|
||||
function json(row: Row) {
|
||||
if (row.type === "message") return JSON.stringify([row.type, row.messageID])
|
||||
if (row.type === "part") return JSON.stringify([row.type, row.messageID, row.partID])
|
||||
return JSON.stringify([row.type, row.kind, row.messageID, row.partID])
|
||||
}
|
||||
|
||||
function concatenate(row: Row) {
|
||||
if (row.type === "message") return `m${row.messageID.length}:${row.messageID}`
|
||||
if (row.type === "part") return `p${row.messageID.length}:${row.messageID}${row.partID.length}:${row.partID}`
|
||||
return `g${row.kind === "reasoning" ? "r" : "e"}${row.messageID.length}:${row.messageID}${row.partID.length}:${row.partID}`
|
||||
}
|
||||
|
||||
console.log(`Session row key benchmark (${size.toLocaleString()} rows, ${bench.samples} samples)\n`)
|
||||
|
||||
const result = bench.compare(iterations, [
|
||||
{ name: "JSON tuple key", make: () => workload((index) => json(rows[index])) },
|
||||
{ name: "Concatenated key", make: () => workload((index) => concatenate(rows[index])) },
|
||||
{ name: "Precomputed key", make: () => workload((index) => precomputed[index].id) },
|
||||
])
|
||||
|
||||
console.log("\nRatios to JSON tuple (lower is faster)")
|
||||
console.log(`Concatenated: ${result.ratio(1, 0).toFixed(3)}x`)
|
||||
console.log(`Precomputed: ${result.ratio(2, 0).toFixed(3)}x`)
|
||||
console.log(`METRIC concatenated_key_ratio=${result.ratio(1, 0).toFixed(6)}`)
|
||||
console.log(`METRIC precomputed_key_ratio=${result.ratio(2, 0).toFixed(6)}`)
|
||||
bench.finish()
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/quark",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./solid": "./src/solid.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"bench:keyed": "bun --conditions=browser bench/keyed.ts",
|
||||
"bench:row-key": "bun --conditions=browser bench/row-key.ts",
|
||||
"test": "bun --conditions=browser test"
|
||||
},
|
||||
"dependencies": {
|
||||
"solid-js": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { Keyed } from "./keyed"
|
||||
export { Layout } from "./layout"
|
||||
export { Computed, State, Transaction, type Readable, type Writable } from "./reactivity"
|
||||
@@ -1,177 +0,0 @@
|
||||
import { Computed, State, Transaction, type Readable, type Writable } from "./reactivity"
|
||||
|
||||
export namespace Keyed {
|
||||
export type Position<Key> = "end" | { readonly before: Key } | { readonly after: Key }
|
||||
|
||||
export interface Metrics {
|
||||
slotPublications: number
|
||||
structuralPublications: number
|
||||
equivalenceSuppressions: number
|
||||
}
|
||||
|
||||
/** Read surface of a keyed collection; hand this to consumers that must not mutate. */
|
||||
export interface ReadOnly<A, Key> {
|
||||
readonly slots: Readable<readonly Readable<A>[]>
|
||||
readonly values: Readable<readonly A[]>
|
||||
has(key: Key): boolean
|
||||
get(key: Key): Readable<A> | undefined
|
||||
before(key: Key): Readable<A> | undefined
|
||||
after(key: Key): Readable<A> | undefined
|
||||
}
|
||||
|
||||
export interface Keyed<A, Key> extends ReadOnly<A, Key> {
|
||||
set(values: readonly A[]): boolean
|
||||
update(value: A): boolean
|
||||
modify(key: Key, f: (value: A) => A): boolean
|
||||
insert(value: A, position?: Position<Key>): Readable<A>
|
||||
remove(key: Key): boolean
|
||||
move(key: Key, position?: Position<Key>): boolean
|
||||
}
|
||||
|
||||
export function make<A, Key>(options: {
|
||||
readonly key: (value: A) => Key
|
||||
readonly equivalent?: (left: A, right: A) => boolean
|
||||
readonly metrics?: Metrics
|
||||
}): Keyed<A, Key> {
|
||||
const slots = State.make<readonly Writable<A>[]>([])
|
||||
const byKey = new Map<Key, Writable<A>>()
|
||||
const equivalent = options.equivalent ?? Object.is
|
||||
const values = Computed.make<readonly A[]>((previous) => {
|
||||
const next = slots().map((slot) => slot())
|
||||
return same(previous, next) ? previous! : next
|
||||
})
|
||||
|
||||
return {
|
||||
slots,
|
||||
values,
|
||||
has: (key) => byKey.has(key),
|
||||
get: (key) => byKey.get(key),
|
||||
set(next) {
|
||||
const keys = next.map(options.key)
|
||||
const retained = new Set(keys)
|
||||
if (retained.size !== keys.length) throw new Error("Keyed values must have unique keys")
|
||||
|
||||
return Transaction.run(() => {
|
||||
let changed = false
|
||||
const previous = slots()
|
||||
const reconciled = next.map((value, index) => {
|
||||
const key = keys[index]
|
||||
const slot = byKey.get(key)
|
||||
if (!slot) {
|
||||
const created = State.make(value)
|
||||
byKey.set(key, created)
|
||||
return created
|
||||
}
|
||||
if (publish(slot, value)) changed = true
|
||||
return slot
|
||||
})
|
||||
// After reconciliation byKey is a superset of retained; equal sizes
|
||||
// mean no stale keys and the sweep can be skipped.
|
||||
if (byKey.size !== retained.size)
|
||||
byKey.forEach((_slot, key) => {
|
||||
if (!retained.has(key)) byKey.delete(key)
|
||||
})
|
||||
if (!same(previous, reconciled)) {
|
||||
slots.set(reconciled)
|
||||
if (options.metrics) options.metrics.structuralPublications++
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
})
|
||||
},
|
||||
update(value) {
|
||||
const slot = byKey.get(options.key(value))
|
||||
if (!slot) return false
|
||||
return publish(slot, value)
|
||||
},
|
||||
modify(key, f) {
|
||||
const slot = byKey.get(key)
|
||||
if (!slot) return false
|
||||
const value = f(slot())
|
||||
if (byKey.get(options.key(value)) !== slot) throw new Error("Keyed modify must preserve the value key")
|
||||
return publish(slot, value)
|
||||
},
|
||||
insert(value, position) {
|
||||
const key = options.key(value)
|
||||
if (byKey.has(key)) throw new Error(`Keyed value already exists: ${String(key)}`)
|
||||
const current = slots()
|
||||
const index = positionIndex(current, position)
|
||||
const slot = State.make(value)
|
||||
// byKey is not reactive and slots.set is a single publication, so no
|
||||
// transaction is required here; callers batch when they need to.
|
||||
byKey.set(key, slot)
|
||||
slots.set(current.toSpliced(index, 0, slot))
|
||||
if (options.metrics) options.metrics.structuralPublications++
|
||||
return slot
|
||||
},
|
||||
remove(key) {
|
||||
const slot = byKey.get(key)
|
||||
if (!slot) return false
|
||||
byKey.delete(key)
|
||||
slots.set(slots().filter((candidate) => candidate !== slot))
|
||||
if (options.metrics) options.metrics.structuralPublications++
|
||||
return true
|
||||
},
|
||||
move(key, position) {
|
||||
const slot = byKey.get(key)
|
||||
if (!slot) return false
|
||||
const current = slots()
|
||||
const from = current.indexOf(slot)
|
||||
const target = positionIndex(current, position)
|
||||
const to = from < target ? target - 1 : target
|
||||
if (from === to) return false
|
||||
const next = current.slice()
|
||||
next.splice(from, 1)
|
||||
next.splice(to, 0, slot)
|
||||
slots.set(next)
|
||||
if (options.metrics) options.metrics.structuralPublications++
|
||||
return true
|
||||
},
|
||||
before(key) {
|
||||
return neighbor(key, -1)
|
||||
},
|
||||
after(key) {
|
||||
return neighbor(key, 1)
|
||||
},
|
||||
}
|
||||
|
||||
function publish(slot: Writable<A>, value: A) {
|
||||
const current = slot()
|
||||
// The reactive kernel uses strict identity for primitive writes.
|
||||
if (current === value || equivalent(current, value)) {
|
||||
if (options.metrics) options.metrics.equivalenceSuppressions++
|
||||
return false
|
||||
}
|
||||
slot.set(value)
|
||||
if (options.metrics) options.metrics.slotPublications++
|
||||
return true
|
||||
}
|
||||
|
||||
function positionIndex(current: readonly Writable<A>[], position?: Position<Key>) {
|
||||
if (position === undefined || position === "end") return current.length
|
||||
if ("before" in position) return indexOf(current, position.before)
|
||||
return indexOf(current, position.after) + 1
|
||||
}
|
||||
|
||||
function indexOf(current: readonly Writable<A>[], key: Key) {
|
||||
const target = byKey.get(key)
|
||||
if (!target) throw new Error(`Keyed value does not exist: ${String(key)}`)
|
||||
return current.indexOf(target)
|
||||
}
|
||||
|
||||
function neighbor(key: Key, offset: -1 | 1) {
|
||||
const slot = byKey.get(key)
|
||||
if (!slot) return undefined
|
||||
const current = slots()
|
||||
return current[current.indexOf(slot) + offset]
|
||||
}
|
||||
}
|
||||
|
||||
export function metrics(): Metrics {
|
||||
return { slotPublications: 0, structuralPublications: 0, equivalenceSuppressions: 0 }
|
||||
}
|
||||
|
||||
function same<A>(left: readonly A[] | undefined, right: readonly A[]) {
|
||||
return left?.length === right.length && left.every((value, index) => Object.is(value, right[index]))
|
||||
}
|
||||
}
|
||||
@@ -1,966 +0,0 @@
|
||||
import { Keyed } from "./keyed"
|
||||
import { Computed, State, Transaction, type Readable } from "./reactivity"
|
||||
|
||||
export namespace Layout {
|
||||
export interface Field<A> {
|
||||
readonly isKey?: true
|
||||
readonly primitive?: true
|
||||
readonly immutable?: true
|
||||
equivalent(left: A, right: A): boolean
|
||||
}
|
||||
|
||||
export interface KeyField<A> extends Field<A> {
|
||||
readonly isKey: true
|
||||
}
|
||||
|
||||
export interface NamedKey<Name extends PropertyKey, A> {
|
||||
readonly name: Name
|
||||
readonly field: Field<A>
|
||||
}
|
||||
|
||||
export type Type<Field> = Field extends Layout.Field<infer A> ? A : never
|
||||
|
||||
type Fields = Readonly<Record<PropertyKey, Field<unknown>>>
|
||||
type Value<StructFields> = {
|
||||
readonly [Key in keyof StructFields]: Type<StructFields[Key]>
|
||||
}
|
||||
type KeyName<StructFields> = {
|
||||
readonly [Key in keyof StructFields]: StructFields[Key] extends KeyField<unknown> ? Key : never
|
||||
}[keyof StructFields]
|
||||
type Variant<Tag extends PropertyKey, Variants> = {
|
||||
readonly [Name in keyof Variants & string]: {
|
||||
readonly [Key in Tag]: Name
|
||||
} & Type<Variants[Name]>
|
||||
}[keyof Variants & string]
|
||||
type KeyedVariant<Name extends PropertyKey, A, Tag extends PropertyKey, Variants> = {
|
||||
readonly [Key in Name]: A
|
||||
} & Variant<Tag, Variants>
|
||||
|
||||
export interface Struct<StructFields extends Fields> extends Field<Value<StructFields>> {
|
||||
readonly type: "struct"
|
||||
readonly fields: StructFields
|
||||
}
|
||||
|
||||
export interface Union<Tag extends PropertyKey, Variants extends Readonly<Record<string, Field<unknown>>>>
|
||||
extends Field<Variant<Tag, Variants>> {
|
||||
readonly type: "union"
|
||||
readonly tag: Tag
|
||||
readonly variants: Variants
|
||||
}
|
||||
|
||||
export interface KeyedUnion<
|
||||
Name extends PropertyKey,
|
||||
A,
|
||||
Tag extends PropertyKey,
|
||||
Variants extends Readonly<Record<string, Field<unknown>>>,
|
||||
> extends Field<KeyedVariant<Name, A, Tag, Variants>> {
|
||||
readonly type: "keyed-union"
|
||||
readonly key: NamedKey<Name, A>
|
||||
readonly tag: Tag
|
||||
readonly variants: Variants
|
||||
}
|
||||
|
||||
export interface Plan<A, Key> {
|
||||
readonly key: PropertyKey
|
||||
readonly fields?: Fields
|
||||
readonly equivalent: (left: A, right: A) => boolean
|
||||
/** Bitmask of changed top-level fields; 0 means equivalent. Consistent with `equivalent`. */
|
||||
readonly diff: (left: A, right: A) => number
|
||||
/** Every known top-level property name to its change bit; immutable and key names map to 0. */
|
||||
readonly bits: ReadonlyMap<PropertyKey, number>
|
||||
readonly keyOf: (value: A) => Key
|
||||
make(initial?: readonly A[], options?: { readonly metrics?: Keyed.Metrics }): Keyed.Keyed<A, Key>
|
||||
}
|
||||
|
||||
type IndexField<A> = A extends unknown ? keyof A : never
|
||||
|
||||
export interface MembersIndex<A, Member> {
|
||||
readonly type: "members"
|
||||
readonly fields?: readonly IndexField<A>[]
|
||||
readonly extract: (value: A) => Iterable<Member>
|
||||
}
|
||||
|
||||
export interface FirstIndex<A> {
|
||||
readonly type: "first"
|
||||
readonly fields?: readonly IndexField<A>[]
|
||||
readonly matches: (value: A) => boolean
|
||||
}
|
||||
|
||||
export type Index<A> = MembersIndex<A, unknown> | FirstIndex<A>
|
||||
type Indexes<A> = Readonly<Record<PropertyKey, Index<A>>>
|
||||
type MembersNames<Definitions> = {
|
||||
readonly [Name in keyof Definitions]: Definitions[Name] extends {
|
||||
readonly type: "members"
|
||||
}
|
||||
? Name
|
||||
: never
|
||||
}[keyof Definitions]
|
||||
type FirstNames<Definitions> = {
|
||||
readonly [Name in keyof Definitions]: Definitions[Name] extends {
|
||||
readonly type: "first"
|
||||
}
|
||||
? Name
|
||||
: never
|
||||
}[keyof Definitions]
|
||||
type Member<Definition> = Definition extends {
|
||||
readonly extract: (value: never) => Iterable<infer A>
|
||||
}
|
||||
? A
|
||||
: never
|
||||
type MemberChanges<Definitions> = {
|
||||
readonly [Name in MembersNames<Definitions>]?: {
|
||||
readonly add?: readonly Member<Definitions[Name]>[]
|
||||
readonly remove?: readonly Member<Definitions[Name]>[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparator backend. "generated" (the default) compiles one specialized
|
||||
* Function per plan operation; "closure" is the compatibility fallback for
|
||||
* environments that forbid runtime code generation (CSP).
|
||||
*/
|
||||
export interface CompileOptions {
|
||||
readonly backend?: "closure" | "generated"
|
||||
}
|
||||
|
||||
export interface IndexBuilder<A> {
|
||||
members<Member>(extract: (value: A) => Iterable<Member>): MembersIndex<A, Member>
|
||||
members<Member>(fields: readonly IndexField<A>[], extract: (value: A) => Iterable<Member>): MembersIndex<A, Member>
|
||||
first(matches: (value: A) => boolean): FirstIndex<A>
|
||||
first(fields: readonly IndexField<A>[], matches: (value: A) => boolean): FirstIndex<A>
|
||||
}
|
||||
|
||||
export interface Collection<A, Key, Definitions extends Indexes<A>> extends Keyed.Keyed<A, Key> {
|
||||
modify(key: Key, f: (value: A) => A, changes?: { readonly members?: MemberChanges<Definitions> }): boolean
|
||||
hasMember<Name extends MembersNames<Definitions>>(name: Name, member: Member<Definitions[Name]>): boolean
|
||||
/** Reactive first match of the named index; publishes when the first match changes identity or value. */
|
||||
first<Name extends FirstNames<Definitions>>(name: Name): Readable<A | undefined>
|
||||
}
|
||||
|
||||
export interface CollectionPlan<A, Key, Definitions extends Indexes<A>> extends Plan<A, Key> {
|
||||
make(initial?: readonly A[], options?: { readonly metrics?: Keyed.Metrics }): Collection<A, Key, Definitions>
|
||||
}
|
||||
|
||||
export const string: Field<string> = primitive()
|
||||
export const number: Field<number> = primitive()
|
||||
export const boolean: Field<boolean> = primitive()
|
||||
|
||||
interface ArrayField<A> extends Field<readonly A[]> {
|
||||
readonly type: "array"
|
||||
readonly item: Field<A>
|
||||
}
|
||||
|
||||
export function array<A>(item: Field<A>): Field<readonly A[]> {
|
||||
const field: ArrayField<A> = {
|
||||
type: "array",
|
||||
item,
|
||||
equivalent(left, right) {
|
||||
if (left === right) return true
|
||||
if (left.length !== right.length) return false
|
||||
for (let index = 0; index < left.length; index++) {
|
||||
if (!item.equivalent(left[index], right[index])) return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
export function immutable<A>(field: Field<A>): Field<A> {
|
||||
return { ...field, immutable: true, equivalent: () => true }
|
||||
}
|
||||
|
||||
export function key<A>(field: Field<A>): KeyField<A>
|
||||
export function key<const Name extends PropertyKey, A>(name: Name, field: Field<A>): NamedKey<Name, A>
|
||||
export function key<A>(name: Field<A> | PropertyKey, field?: Field<A>): KeyField<A> | NamedKey<PropertyKey, A> {
|
||||
if (field) return { name: name as PropertyKey, field }
|
||||
return { ...(name as Field<A>), isKey: true }
|
||||
}
|
||||
|
||||
export function struct<const StructFields extends Fields>(fields: StructFields): Struct<StructFields> {
|
||||
return {
|
||||
type: "struct",
|
||||
fields,
|
||||
equivalent: compileFields<Value<StructFields>>(fields),
|
||||
}
|
||||
}
|
||||
|
||||
export function union<
|
||||
const Tag extends PropertyKey,
|
||||
const Variants extends Readonly<Record<string, Field<unknown>>>,
|
||||
>(options: { readonly tag: Tag; readonly variants: Variants }): Union<Tag, Variants> {
|
||||
const equivalent = compileUnion<Tag, Variants>(options.tag, options.variants)
|
||||
return { type: "union", ...options, equivalent }
|
||||
}
|
||||
|
||||
export function keyedUnion<
|
||||
const Name extends PropertyKey,
|
||||
A,
|
||||
const Tag extends PropertyKey,
|
||||
const Variants extends Readonly<Record<string, Field<unknown>>>,
|
||||
>(options: {
|
||||
readonly key: NamedKey<Name, A>
|
||||
readonly tag: Tag
|
||||
readonly variants: Variants
|
||||
}): KeyedUnion<Name, A, Tag, Variants> {
|
||||
const equivalentVariant = compileUnion<Tag, Variants>(options.tag, options.variants)
|
||||
const key = (value: KeyedVariant<Name, A, Tag, Variants>) => value[options.key.name]
|
||||
const equivalent = (left: KeyedVariant<Name, A, Tag, Variants>, right: KeyedVariant<Name, A, Tag, Variants>) =>
|
||||
options.key.field.equivalent(key(left), key(right)) && equivalentVariant(left, right)
|
||||
return { type: "keyed-union", ...options, equivalent }
|
||||
}
|
||||
|
||||
export function compile<const StructFields extends Fields>(
|
||||
layout: Struct<StructFields>,
|
||||
options?: CompileOptions,
|
||||
): Plan<Value<StructFields>, Value<StructFields>[KeyName<StructFields>]>
|
||||
export function compile<
|
||||
const Name extends PropertyKey,
|
||||
A,
|
||||
const Tag extends PropertyKey,
|
||||
const Variants extends Readonly<Record<string, Field<unknown>>>,
|
||||
>(layout: KeyedUnion<Name, A, Tag, Variants>, options?: CompileOptions): Plan<KeyedVariant<Name, A, Tag, Variants>, A>
|
||||
export function compile(input: unknown, options: CompileOptions = {}): unknown {
|
||||
const layout = input as
|
||||
| Struct<Fields>
|
||||
| KeyedUnion<PropertyKey, unknown, PropertyKey, Readonly<Record<string, Field<unknown>>>>
|
||||
if (layout.type === "keyed-union") {
|
||||
const model = unionDiffModel(layout.key.name, layout.tag, layout.variants)
|
||||
return makePlan(
|
||||
layout.key.name,
|
||||
(value: unknown) => (value as Record<PropertyKey, unknown>)[layout.key.name],
|
||||
(options.backend !== "closure"
|
||||
? generateUnion(layout.tag, layout.variants)
|
||||
: compileUnion(layout.tag, layout.variants)) as (left: unknown, right: unknown) => boolean,
|
||||
options.backend !== "closure"
|
||||
? generateUnionDiff(layout.tag, layout.variants, model)
|
||||
: compileUnionDiff(layout.tag, layout.variants, model),
|
||||
model.bits,
|
||||
)
|
||||
}
|
||||
|
||||
const keys = Reflect.ownKeys(layout.fields).filter((name) => layout.fields[name].isKey)
|
||||
if (keys.length !== 1) throw new Error("Keyed layout must declare exactly one key field")
|
||||
const key = keys[0]
|
||||
const fields = Reflect.ownKeys(layout.fields)
|
||||
.filter((name) => name !== key && !layout.fields[name].immutable)
|
||||
.map((name, index) => ({ name, field: layout.fields[name], bit: 1 << Math.min(index, 30) }))
|
||||
const bits = new Map<PropertyKey, number>()
|
||||
Reflect.ownKeys(layout.fields).forEach((name) => bits.set(name, 0))
|
||||
fields.forEach((field) => bits.set(field.name, field.bit))
|
||||
const equivalent =
|
||||
options.backend !== "closure" ? generateEquivalent<unknown>(fields) : compileEquivalent<unknown>(fields)
|
||||
const diff = options.backend !== "closure" ? generateDiff<unknown>(fields) : compileDiff<unknown>(fields)
|
||||
return {
|
||||
fields: layout.fields,
|
||||
...makePlan(key, (value: unknown) => (value as Record<PropertyKey, unknown>)[key], equivalent, diff, bits),
|
||||
}
|
||||
}
|
||||
|
||||
export function collection<const StructFields extends Fields, const Definitions extends Indexes<Value<StructFields>>>(
|
||||
layout: Struct<StructFields>,
|
||||
define: (index: IndexBuilder<Value<StructFields>>) => Definitions,
|
||||
options?: CompileOptions,
|
||||
): CollectionPlan<Value<StructFields>, Value<StructFields>[KeyName<StructFields>], Definitions>
|
||||
export function collection<
|
||||
const Name extends PropertyKey,
|
||||
A,
|
||||
const Tag extends PropertyKey,
|
||||
const Variants extends Readonly<Record<string, Field<unknown>>>,
|
||||
const Definitions extends Indexes<KeyedVariant<Name, A, Tag, Variants>>,
|
||||
>(
|
||||
layout: KeyedUnion<Name, A, Tag, Variants>,
|
||||
define: (index: IndexBuilder<KeyedVariant<Name, A, Tag, Variants>>) => Definitions,
|
||||
options?: CompileOptions,
|
||||
): CollectionPlan<KeyedVariant<Name, A, Tag, Variants>, A, Definitions>
|
||||
export function collection(input: unknown, define: unknown, options?: CompileOptions): unknown {
|
||||
const plan = compile(input as never, options) as Plan<unknown, unknown>
|
||||
const definitions = (define as (index: IndexBuilder<unknown>) => Indexes<unknown>)({
|
||||
members: ((
|
||||
fieldsOrExtract: readonly PropertyKey[] | ((value: unknown) => Iterable<unknown>),
|
||||
extract?: (value: unknown) => Iterable<unknown>,
|
||||
) =>
|
||||
typeof fieldsOrExtract === "function"
|
||||
? { type: "members", extract: fieldsOrExtract }
|
||||
: { type: "members", fields: fieldsOrExtract, extract: extract! }) as IndexBuilder<unknown>["members"],
|
||||
first: ((
|
||||
fieldsOrMatches: readonly PropertyKey[] | ((value: unknown) => boolean),
|
||||
matches?: (value: unknown) => boolean,
|
||||
) =>
|
||||
typeof fieldsOrMatches === "function"
|
||||
? { type: "first", matches: fieldsOrMatches }
|
||||
: { type: "first", fields: fieldsOrMatches, matches: matches! }) as IndexBuilder<unknown>["first"],
|
||||
})
|
||||
return {
|
||||
...plan,
|
||||
make(initial: readonly unknown[] = [], makeOptions?: { readonly metrics?: Keyed.Metrics }) {
|
||||
return makeCollection(plan, definitions, initial, makeOptions)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit-target collection compiler for keyed-union layouts whose derived
|
||||
* value type intentionally under-describes the real one (e.g. reference
|
||||
* fields typed `unknown`, optional fields omitted). The layout's key name,
|
||||
* key type, and tag values are checked against `Target`; field-level shapes
|
||||
* are trusted at this single boundary.
|
||||
*/
|
||||
export function collectionOf<Target>() {
|
||||
return function <
|
||||
const Name extends keyof Target & PropertyKey,
|
||||
const Tag extends keyof Target & PropertyKey,
|
||||
const Variants extends Readonly<Record<Extract<Target[Tag], string>, Field<unknown>>>,
|
||||
const Definitions extends Indexes<Target>,
|
||||
>(
|
||||
layout: KeyedUnion<Name, Target[Name], Tag, Variants>,
|
||||
define: (index: IndexBuilder<Target>) => Definitions,
|
||||
options?: CompileOptions,
|
||||
): CollectionPlan<Target, Target[Name], Definitions> {
|
||||
return collection(layout as never, define as never, options) as CollectionPlan<Target, Target[Name], Definitions>
|
||||
}
|
||||
}
|
||||
|
||||
function makePlan<A, Key>(
|
||||
key: PropertyKey,
|
||||
getKey: (value: A) => Key,
|
||||
equivalent: (left: A, right: A) => boolean,
|
||||
diff: (left: A, right: A) => number,
|
||||
bits: ReadonlyMap<PropertyKey, number>,
|
||||
) {
|
||||
return {
|
||||
key,
|
||||
equivalent,
|
||||
diff,
|
||||
bits,
|
||||
keyOf: getKey,
|
||||
make(initial: readonly A[] = [], options?: { readonly metrics?: Keyed.Metrics }) {
|
||||
const values = Keyed.make({
|
||||
key: getKey,
|
||||
equivalent,
|
||||
metrics: options?.metrics,
|
||||
})
|
||||
values.set(initial)
|
||||
return values
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function makeCollection<A, Key, Definitions extends Indexes<A>>(
|
||||
plan: Plan<A, Key>,
|
||||
definitions: Definitions,
|
||||
initial: readonly A[],
|
||||
options?: { readonly metrics?: Keyed.Metrics },
|
||||
): Collection<A, Key, Definitions> {
|
||||
// `pending` hands a diff the collection already computed for a staged
|
||||
// mutation to the comparator, so equivalence costs one comparison, not two.
|
||||
let pending: { readonly previous: A; readonly next: A; readonly mask: number } | undefined
|
||||
const values = Keyed.make<A, Key>({
|
||||
key: plan.keyOf,
|
||||
equivalent(left, right) {
|
||||
if (pending && pending.previous === left && pending.next === right) {
|
||||
const mask = pending.mask
|
||||
pending = undefined
|
||||
return mask === 0
|
||||
}
|
||||
return plan.diff(left, right) === 0
|
||||
},
|
||||
metrics: options?.metrics,
|
||||
})
|
||||
|
||||
function dependencyMask(fields: readonly PropertyKey[] | undefined) {
|
||||
return fields?.reduce<number>((mask, field) => mask | (plan.bits.get(normalizeName(field)) ?? -1), 0) ?? -1
|
||||
}
|
||||
|
||||
// Each index entry stages user-code projections before any state mutation
|
||||
// (a throwing callback must not desynchronize values and indexes) and
|
||||
// returns a commit applied after publication succeeds.
|
||||
type Commit = (slot: Readable<A>) => void
|
||||
type MemberChange = { readonly add?: readonly unknown[]; readonly remove?: readonly unknown[] }
|
||||
interface Entry {
|
||||
readonly name: PropertyKey
|
||||
readonly reads: number
|
||||
stage(key: Key, value: A, mode: "add" | "replace"): Commit
|
||||
applyDelta?(key: Key, change: MemberChange): void
|
||||
remove(key: Key, slot?: Readable<A>): void
|
||||
afterPlacement?(slot: Readable<A>): void
|
||||
afterRebuild?(): void
|
||||
member?(candidate: unknown): boolean
|
||||
readonly first?: Readable<A | undefined>
|
||||
}
|
||||
|
||||
function membersEntry(
|
||||
name: PropertyKey,
|
||||
fields: readonly PropertyKey[] | undefined,
|
||||
extract: (value: A) => Iterable<unknown>,
|
||||
): Entry {
|
||||
const counts = new Map<unknown, number>()
|
||||
const byKey = new Map<Key, { readonly source: Iterable<unknown>; readonly members: Set<unknown> }>()
|
||||
|
||||
function adjust(member: unknown, amount: 1 | -1) {
|
||||
const count = (counts.get(member) ?? 0) + amount
|
||||
if (count === 0) {
|
||||
counts.delete(member)
|
||||
return
|
||||
}
|
||||
counts.set(member, count)
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
reads: dependencyMask(fields),
|
||||
stage(key, value) {
|
||||
const previous = byKey.get(key)
|
||||
const source = extract(value)
|
||||
const members = source === previous?.source ? previous.members : new Set(source)
|
||||
return () => {
|
||||
if (!previous) members.forEach((member) => adjust(member, 1))
|
||||
if (previous && previous.members !== members) {
|
||||
members.forEach((member) => !previous.members.has(member) && adjust(member, 1))
|
||||
previous.members.forEach((member) => !members.has(member) && adjust(member, -1))
|
||||
}
|
||||
byKey.set(key, { source, members })
|
||||
}
|
||||
},
|
||||
applyDelta(key, change) {
|
||||
const members = byKey.get(key)!.members
|
||||
change.remove?.forEach((member) => {
|
||||
if (members.delete(member)) adjust(member, -1)
|
||||
})
|
||||
change.add?.forEach((member) => {
|
||||
if (members.has(member)) return
|
||||
members.add(member)
|
||||
adjust(member, 1)
|
||||
})
|
||||
// The set was mutated in place, so poison the source-identity check.
|
||||
byKey.set(key, { source: members, members })
|
||||
},
|
||||
remove(key) {
|
||||
byKey.get(key)?.members.forEach((member) => adjust(member, -1))
|
||||
byKey.delete(key)
|
||||
},
|
||||
member: (candidate) => counts.has(candidate),
|
||||
}
|
||||
}
|
||||
|
||||
function firstEntry(
|
||||
name: PropertyKey,
|
||||
fields: readonly PropertyKey[] | undefined,
|
||||
matches: (value: A) => boolean,
|
||||
): Entry {
|
||||
const matching = new Set<Key>()
|
||||
// The current first-matching slot is reactive state so `first` readers
|
||||
// observe identity changes; the flattening computed below also tracks
|
||||
// the slot itself, so value changes of the first match publish too.
|
||||
const slot = State.make<Readable<A> | undefined>(undefined)
|
||||
const first = Computed.make<A | undefined>(() => slot()?.())
|
||||
|
||||
function findFirst() {
|
||||
slot.set(values.slots().find((candidate) => matching.has(plan.keyOf(candidate()))))
|
||||
}
|
||||
|
||||
function afterPlacement(candidate: Readable<A>) {
|
||||
if (!matching.has(plan.keyOf(candidate()))) return
|
||||
const current = slot()
|
||||
if (!current) {
|
||||
slot.set(candidate)
|
||||
return
|
||||
}
|
||||
if (current === candidate) {
|
||||
findFirst()
|
||||
return
|
||||
}
|
||||
// Single pass: whichever of the two slots appears first wins.
|
||||
for (const item of values.slots()) {
|
||||
if (item === candidate) {
|
||||
slot.set(candidate)
|
||||
return
|
||||
}
|
||||
if (item === current) return
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
reads: dependencyMask(fields),
|
||||
stage(key, value, mode) {
|
||||
const matched = matches(value)
|
||||
return (valueSlot) => {
|
||||
const previous = matching.has(key)
|
||||
if (matched) matching.add(key)
|
||||
if (!matched) matching.delete(key)
|
||||
if (mode === "add") return
|
||||
if (slot() === valueSlot && !matched) {
|
||||
findFirst()
|
||||
return
|
||||
}
|
||||
if (slot() !== valueSlot && !previous && matched) afterPlacement(valueSlot)
|
||||
}
|
||||
},
|
||||
remove(key, removedSlot) {
|
||||
matching.delete(key)
|
||||
if (removedSlot && slot() === removedSlot) findFirst()
|
||||
},
|
||||
afterPlacement,
|
||||
afterRebuild: findFirst,
|
||||
first,
|
||||
}
|
||||
}
|
||||
|
||||
const entries: Entry[] = Reflect.ownKeys(definitions).map((name) => {
|
||||
const definition = definitions[name]
|
||||
if (definition.type === "members") return membersEntry(name, definition.fields, definition.extract)
|
||||
return firstEntry(name, definition.fields, definition.matches)
|
||||
})
|
||||
const byName = new Map(entries.map((entry) => [entry.name, entry]))
|
||||
|
||||
// Stage refresh work for one changed value: explicit member deltas win,
|
||||
// then projections whose declared fields are disjoint from the change are
|
||||
// skipped entirely. Returns undefined when no index work is required.
|
||||
function stageRefresh(key: Key, value: A, mask: number, changes?: unknown) {
|
||||
const record = changes as Readonly<Record<PropertyKey, MemberChange | undefined>> | undefined
|
||||
let staged: Commit[] | undefined
|
||||
for (const entry of entries) {
|
||||
const change = record?.[entry.name]
|
||||
if (change && entry.applyDelta) {
|
||||
const delta = entry.applyDelta
|
||||
;(staged ??= []).push(() => delta(key, change))
|
||||
continue
|
||||
}
|
||||
if ((mask & entry.reads) === 0) continue
|
||||
;(staged ??= []).push(entry.stage(key, value, "replace"))
|
||||
}
|
||||
return staged
|
||||
}
|
||||
|
||||
function publishStaged(
|
||||
previous: A,
|
||||
value: A,
|
||||
mask: number,
|
||||
staged: readonly Commit[] | undefined,
|
||||
slot: Readable<A>,
|
||||
) {
|
||||
// Indexes are plain data, not reactive state; the transaction exists so
|
||||
// subscribers cannot observe published values with stale indexes. With
|
||||
// no staged commits there is nothing to observe out of order.
|
||||
if (!staged) return publishValue()
|
||||
return Transaction.run(() => {
|
||||
const changed = publishValue()
|
||||
if (changed) staged.forEach((commit) => commit(slot))
|
||||
return changed
|
||||
})
|
||||
|
||||
function publishValue() {
|
||||
pending = { previous, next: value, mask }
|
||||
try {
|
||||
return values.update(value)
|
||||
} finally {
|
||||
pending = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const collection: Collection<A, Key, Definitions> = {
|
||||
...values,
|
||||
set(next) {
|
||||
const keys = next.map(plan.keyOf)
|
||||
const retained = new Set(keys)
|
||||
if (retained.size !== keys.length) throw new Error("Keyed values must have unique keys")
|
||||
const existing = new Map<Key, A>()
|
||||
values.slots().forEach((slot) => {
|
||||
const value = slot()
|
||||
existing.set(plan.keyOf(value), value)
|
||||
})
|
||||
// Stage user-code projections for new and changed values only;
|
||||
// retained equivalent values keep their index state and reads.
|
||||
const staged: Array<{ readonly key: Key; readonly commits: readonly Commit[] }> = []
|
||||
next.forEach((value, index) => {
|
||||
const key = keys[index]
|
||||
if (!existing.has(key)) {
|
||||
staged.push({ key, commits: entries.map((entry) => entry.stage(key, value, "add")) })
|
||||
return
|
||||
}
|
||||
const previous = existing.get(key)!
|
||||
if (previous === value) return
|
||||
const mask = plan.diff(previous, value)
|
||||
if (mask === 0) return
|
||||
const commits = stageRefresh(key, value, mask)
|
||||
if (commits) staged.push({ key, commits })
|
||||
})
|
||||
const structure = values.slots()
|
||||
return Transaction.run(() => {
|
||||
const changed = values.set(next)
|
||||
if (!changed) return false
|
||||
const structural = structure !== values.slots()
|
||||
if (structural)
|
||||
existing.forEach((_value, key) => {
|
||||
if (!retained.has(key)) entries.forEach((entry) => entry.remove(key))
|
||||
})
|
||||
staged.forEach((item) => {
|
||||
const slot = values.get(item.key)!
|
||||
item.commits.forEach((commit) => commit(slot))
|
||||
})
|
||||
if (structural) entries.forEach((entry) => entry.afterRebuild?.())
|
||||
return true
|
||||
})
|
||||
},
|
||||
update(value) {
|
||||
const key = plan.keyOf(value)
|
||||
const slot = values.get(key)
|
||||
if (!slot) return false
|
||||
const previous = slot()
|
||||
if (previous === value) return false
|
||||
const mask = plan.diff(previous, value)
|
||||
const staged = mask === 0 ? undefined : stageRefresh(key, value, mask)
|
||||
return publishStaged(previous, value, mask, staged, slot)
|
||||
},
|
||||
modify(key, f, changes) {
|
||||
const slot = values.get(key)
|
||||
if (!slot) return false
|
||||
const previous = slot()
|
||||
const value = f(previous)
|
||||
// Publication below targets the slot for the value's own key, so the
|
||||
// modify key-preservation invariant must be enforced here.
|
||||
if (values.get(plan.keyOf(value)) !== slot) throw new Error("Keyed modify must preserve the value key")
|
||||
if (previous === value) return false
|
||||
const mask = plan.diff(previous, value)
|
||||
const staged = mask === 0 ? undefined : stageRefresh(key, value, mask, changes?.members)
|
||||
return publishStaged(previous, value, mask, staged, slot)
|
||||
},
|
||||
insert(value, position) {
|
||||
const key = plan.keyOf(value)
|
||||
if (values.has(key)) throw new Error(`Keyed value already exists: ${String(key)}`)
|
||||
requirePosition(position)
|
||||
const staged = entries.map((entry) => entry.stage(key, value, "add"))
|
||||
return Transaction.run(() => {
|
||||
const slot = values.insert(value, position)
|
||||
staged.forEach((commit) => commit(slot))
|
||||
entries.forEach((entry) => entry.afterPlacement?.(slot))
|
||||
return slot
|
||||
})
|
||||
},
|
||||
remove(key) {
|
||||
const slot = values.get(key)
|
||||
if (!slot) return false
|
||||
return Transaction.run(() => {
|
||||
const removed = values.remove(key)
|
||||
entries.forEach((entry) => entry.remove(key, slot))
|
||||
return removed
|
||||
})
|
||||
},
|
||||
move(key, position) {
|
||||
const slot = values.get(key)
|
||||
if (!slot) return false
|
||||
return Transaction.run(() => {
|
||||
const moved = values.move(key, position)
|
||||
if (moved) entries.forEach((entry) => entry.afterPlacement?.(slot))
|
||||
return moved
|
||||
})
|
||||
},
|
||||
hasMember(name, member) {
|
||||
return byName.get(normalizeName(name))?.member?.(member) ?? false
|
||||
},
|
||||
first(name) {
|
||||
const entry = byName.get(normalizeName(name))
|
||||
if (!entry?.first) throw new Error(`Unknown first index: ${String(name)}`)
|
||||
return entry.first
|
||||
},
|
||||
}
|
||||
collection.set(initial)
|
||||
return collection
|
||||
|
||||
function requirePosition(position?: Keyed.Position<Key>) {
|
||||
if (!position || position === "end") return
|
||||
const key = "before" in position ? position.before : position.after
|
||||
if (!values.has(key)) throw new Error(`Keyed value does not exist: ${String(key)}`)
|
||||
}
|
||||
|
||||
function normalizeName(name: PropertyKey) {
|
||||
return typeof name === "number" ? String(name) : name
|
||||
}
|
||||
}
|
||||
|
||||
function primitive<A>(): Field<A> {
|
||||
return { primitive: true, equivalent: Object.is }
|
||||
}
|
||||
|
||||
function compileUnion<Tag extends PropertyKey, Variants extends Readonly<Record<string, Field<unknown>>>>(
|
||||
tag: Tag,
|
||||
variants: Variants,
|
||||
) {
|
||||
type A = Variant<Tag, Variants>
|
||||
return (left: A, right: A) => {
|
||||
const name = left[tag]
|
||||
if (name !== right[tag] || typeof name !== "string") return false
|
||||
const variant = variants[name]
|
||||
return variant ? variant.equivalent(left, right) : false
|
||||
}
|
||||
}
|
||||
|
||||
function compileFields<A>(fields: Fields) {
|
||||
return compileEquivalent<A>(
|
||||
Reflect.ownKeys(fields)
|
||||
.filter((name) => !fields[name].immutable)
|
||||
.map((name) => ({ name, field: fields[name] })),
|
||||
)
|
||||
}
|
||||
|
||||
// Field diff: a bitmask of changed top-level fields, 0 meaning equivalent.
|
||||
// Bits are assigned per mutable field name; names beyond 31 share the top
|
||||
// bit conservatively. The diff is consistent with `equivalent` by
|
||||
// construction: both compare the same fields with the same field comparators.
|
||||
type DiffField = { readonly name: PropertyKey; readonly field: Field<unknown>; readonly bit: number }
|
||||
|
||||
function compileDiff<A>(fields: ReadonlyArray<DiffField>) {
|
||||
return (left: A, right: A) => {
|
||||
const a = left as Record<PropertyKey, unknown>
|
||||
const b = right as Record<PropertyKey, unknown>
|
||||
let changed = 0
|
||||
for (const field of fields) if (!field.field.equivalent(a[field.name], b[field.name])) changed |= field.bit
|
||||
return changed
|
||||
}
|
||||
}
|
||||
|
||||
function generateDiff<A>(fields: ReadonlyArray<DiffField>) {
|
||||
if (fields.some((field) => typeof field.name === "symbol")) return compileDiff<A>(fields)
|
||||
const emitter: Emitter = { custom: [], declarations: [], names: new Map() }
|
||||
const statements = fields.map((field) => {
|
||||
const property = JSON.stringify(String(field.name))
|
||||
return `if (!(${expression(emitter, field.field, `left[${property}]`, `right[${property}]`)})) changed |= ${field.bit}`
|
||||
})
|
||||
const factory = Function(
|
||||
"custom",
|
||||
`${emitter.declarations.join("\n")}\nreturn (left, right) => { let changed = 0\n${statements.join("\n")}\nreturn changed }`,
|
||||
) as (custom: ReadonlyArray<Field<unknown>>) => (left: A, right: A) => number
|
||||
return factory(emitter.custom)
|
||||
}
|
||||
|
||||
type UnionDiffModel = {
|
||||
readonly bits: ReadonlyMap<PropertyKey, number>
|
||||
readonly perVariant: ReadonlyMap<string, ReadonlyArray<DiffField> | undefined>
|
||||
}
|
||||
|
||||
function unionDiffModel(
|
||||
keyName: PropertyKey,
|
||||
tag: PropertyKey,
|
||||
variants: Readonly<Record<string, Field<unknown>>>,
|
||||
): UnionDiffModel {
|
||||
const bits = new Map<PropertyKey, number>()
|
||||
bits.set(tag, 1)
|
||||
bits.set(keyName, 0)
|
||||
const assigned = new Map<PropertyKey, number>()
|
||||
const perVariant = new Map<string, ReadonlyArray<DiffField> | undefined>()
|
||||
let count = 0
|
||||
for (const variantName of Object.keys(variants)) {
|
||||
const variant = variants[variantName] as Field<unknown> & { readonly type?: string; readonly fields?: Fields }
|
||||
if (variant.type !== "struct") {
|
||||
perVariant.set(variantName, undefined)
|
||||
continue
|
||||
}
|
||||
const fields: DiffField[] = []
|
||||
for (const name of Reflect.ownKeys(variant.fields!)) {
|
||||
const field = variant.fields![name]
|
||||
if (field.immutable) {
|
||||
if (!assigned.has(name) && !bits.has(name)) bits.set(name, 0)
|
||||
continue
|
||||
}
|
||||
const bit = assigned.get(name) ?? 1 << Math.min(count++ + 1, 30)
|
||||
assigned.set(name, bit)
|
||||
bits.set(name, bit)
|
||||
fields.push({ name, field, bit })
|
||||
}
|
||||
perVariant.set(variantName, fields)
|
||||
}
|
||||
return { bits, perVariant }
|
||||
}
|
||||
|
||||
function compileUnionDiff(
|
||||
tag: PropertyKey,
|
||||
variants: Readonly<Record<string, Field<unknown>>>,
|
||||
model: UnionDiffModel,
|
||||
) {
|
||||
return (left: unknown, right: unknown) => {
|
||||
const a = left as Record<PropertyKey, unknown>
|
||||
const b = right as Record<PropertyKey, unknown>
|
||||
const name = a[tag]
|
||||
if (name !== b[tag] || typeof name !== "string") return -1
|
||||
const fields = model.perVariant.get(name)
|
||||
if (!fields) {
|
||||
const variant = variants[name]
|
||||
return variant && variant.equivalent(left, right) ? 0 : -1
|
||||
}
|
||||
let changed = 0
|
||||
for (const field of fields) if (!field.field.equivalent(a[field.name], b[field.name])) changed |= field.bit
|
||||
return changed
|
||||
}
|
||||
}
|
||||
|
||||
function generateUnionDiff(
|
||||
tag: PropertyKey,
|
||||
variants: Readonly<Record<string, Field<unknown>>>,
|
||||
model: UnionDiffModel,
|
||||
) {
|
||||
const symbols = [...model.perVariant.values()].some((fields) =>
|
||||
fields?.some((field) => typeof field.name === "symbol"),
|
||||
)
|
||||
if (typeof tag === "symbol" || symbols) return compileUnionDiff(tag, variants, model)
|
||||
const emitter: Emitter = { custom: [], declarations: [], names: new Map() }
|
||||
const property = JSON.stringify(String(tag))
|
||||
const cases = Object.keys(variants).map((name) => {
|
||||
const label = JSON.stringify(name)
|
||||
const fields = model.perVariant.get(name)
|
||||
if (!fields) {
|
||||
const variant = variants[name]
|
||||
const index = emitter.custom.push(variant) - 1
|
||||
return `case ${label}: return custom[${index}].equivalent(left, right) ? 0 : -1`
|
||||
}
|
||||
const statements = fields.map((field) => {
|
||||
const fieldProperty = JSON.stringify(String(field.name))
|
||||
return `if (!(${expression(emitter, field.field, `left[${fieldProperty}]`, `right[${fieldProperty}]`)})) changed |= ${field.bit}`
|
||||
})
|
||||
return `case ${label}: { let changed = 0\n${statements.join("\n")}\nreturn changed }`
|
||||
})
|
||||
const factory = Function(
|
||||
"custom",
|
||||
`${emitter.declarations.join("\n")}\nreturn (left, right) => { if (left[${property}] !== right[${property}]) return -1; switch (left[${property}]) { ${cases.join("\n")}\ndefault: return -1 } }`,
|
||||
) as (custom: ReadonlyArray<Field<unknown>>) => (left: unknown, right: unknown) => number
|
||||
return factory(emitter.custom)
|
||||
}
|
||||
|
||||
// Whole-tree generation: one Function() per plan operation whose source
|
||||
// inlines struct comparisons, emits real loops for arrays, and hoists unions
|
||||
// into named inner functions the engine can inline. User-supplied equivalence
|
||||
// functions remain indirect calls through the `custom` array; everything
|
||||
// structural compiles to direct code with no interior closure boundaries.
|
||||
type Emitter = {
|
||||
readonly custom: Field<unknown>[]
|
||||
readonly declarations: string[]
|
||||
readonly names: Map<unknown, string>
|
||||
}
|
||||
|
||||
function generateEquivalent<A>(
|
||||
fields: ReadonlyArray<{
|
||||
readonly name: PropertyKey
|
||||
readonly field: Field<unknown>
|
||||
}>,
|
||||
) {
|
||||
// Symbol names cannot appear in generated source; degrade to the closure backend.
|
||||
if (fields.some((field) => typeof field.name === "symbol")) return compileEquivalent<A>(fields)
|
||||
const emitter: Emitter = { custom: [], declarations: [], names: new Map() }
|
||||
const comparisons = fields
|
||||
.map((field) => {
|
||||
const property = JSON.stringify(String(field.name))
|
||||
return expression(emitter, field.field, `left[${property}]`, `right[${property}]`)
|
||||
})
|
||||
.filter((comparison) => comparison !== "true")
|
||||
return assemble<A>(emitter, comparisons.join(" && ") || "true")
|
||||
}
|
||||
|
||||
function generateUnion<Tag extends PropertyKey, Variants extends Readonly<Record<string, Field<unknown>>>>(
|
||||
tag: Tag,
|
||||
variants: Variants,
|
||||
) {
|
||||
if (typeof tag === "symbol") return compileUnion(tag, variants)
|
||||
const emitter: Emitter = { custom: [], declarations: [], names: new Map() }
|
||||
const root = declareUnion(emitter, { tag, variants }, tag, variants)
|
||||
return assemble<Variant<Tag, Variants>>(emitter, `${root}(left, right)`)
|
||||
}
|
||||
|
||||
function assemble<A>(emitter: Emitter, body: string) {
|
||||
const factory = Function("custom", `${emitter.declarations.join("\n")}\nreturn (left, right) => ${body}`) as (
|
||||
custom: ReadonlyArray<Field<unknown>>,
|
||||
) => (left: A, right: A) => boolean
|
||||
return factory(emitter.custom)
|
||||
}
|
||||
|
||||
function expression(emitter: Emitter, field: Field<unknown>, left: string, right: string): string {
|
||||
if (field.immutable) return "true"
|
||||
if (field.primitive && field.equivalent === Object.is) return `Object.is(${left}, ${right})`
|
||||
const layout = field as Field<unknown> & {
|
||||
readonly type?: "struct" | "union" | "keyed-union" | "array"
|
||||
readonly fields?: Fields
|
||||
readonly item?: Field<unknown>
|
||||
readonly tag?: PropertyKey
|
||||
readonly variants?: Readonly<Record<string, Field<unknown>>>
|
||||
readonly key?: NamedKey<PropertyKey, unknown>
|
||||
}
|
||||
if (layout.type === "struct" && Reflect.ownKeys(layout.fields!).every((name) => typeof name !== "symbol")) {
|
||||
const comparisons = Reflect.ownKeys(layout.fields!)
|
||||
.filter((name) => !layout.fields![name].immutable)
|
||||
.map((name) => {
|
||||
const property = JSON.stringify(String(name))
|
||||
return expression(emitter, layout.fields![name], `${left}[${property}]`, `${right}[${property}]`)
|
||||
})
|
||||
.filter((comparison) => comparison !== "true")
|
||||
if (comparisons.length === 0) return "true"
|
||||
// Shared-reference fast path: immutable updates reuse untouched sub-objects.
|
||||
return `(${left} === ${right} || (${comparisons.join(" && ")}))`
|
||||
}
|
||||
if (layout.type === "array") {
|
||||
const name = declare(emitter, layout, (fn) => {
|
||||
const item = expression(emitter, layout.item!, "l[i]", "r[i]")
|
||||
return `function ${fn}(l, r) { if (l === r) return true; if (l.length !== r.length) return false; for (let i = 0; i < l.length; i++) if (!(${item})) return false; return true }`
|
||||
})
|
||||
return `${name}(${left}, ${right})`
|
||||
}
|
||||
if (layout.type === "union" && typeof layout.tag !== "symbol") {
|
||||
return `${declareUnion(emitter, layout, layout.tag!, layout.variants!)}(${left}, ${right})`
|
||||
}
|
||||
if (layout.type === "keyed-union" && typeof layout.tag !== "symbol" && typeof layout.key!.name !== "symbol") {
|
||||
const name = declare(emitter, layout, (fn) => {
|
||||
const property = JSON.stringify(String(layout.key!.name))
|
||||
const key = expression(emitter, layout.key!.field, `l[${property}]`, `r[${property}]`)
|
||||
const union = declareUnion(emitter, {}, layout.tag!, layout.variants!)
|
||||
return `function ${fn}(l, r) { return ${key === "true" ? "" : `${key} && `}${union}(l, r) }`
|
||||
})
|
||||
return `${name}(${left}, ${right})`
|
||||
}
|
||||
const index = emitter.custom.push(field) - 1
|
||||
return `custom[${index}].equivalent(${left}, ${right})`
|
||||
}
|
||||
|
||||
function declareUnion(
|
||||
emitter: Emitter,
|
||||
identity: unknown,
|
||||
tag: PropertyKey,
|
||||
variants: Readonly<Record<string, Field<unknown>>>,
|
||||
) {
|
||||
return declare(emitter, identity, (fn) => {
|
||||
const property = JSON.stringify(String(tag))
|
||||
const cases = Object.keys(variants)
|
||||
.map((name) => `case ${JSON.stringify(name)}: return ${expression(emitter, variants[name], "l", "r")}`)
|
||||
.join("; ")
|
||||
return `function ${fn}(l, r) { if (l[${property}] !== r[${property}]) return false; switch (l[${property}]) { ${cases}; default: return false } }`
|
||||
})
|
||||
}
|
||||
|
||||
function declare(emitter: Emitter, identity: unknown, build: (name: string) => string) {
|
||||
const existing = emitter.names.get(identity)
|
||||
if (existing) return existing
|
||||
const name = `q${emitter.names.size}`
|
||||
emitter.names.set(identity, name)
|
||||
// Reserve declaration order before build runs: build may recurse into
|
||||
// declare for nested layouts, and the reserved slot keeps this function
|
||||
// textually before its dependents without infinite recursion.
|
||||
const index = emitter.declarations.push("") - 1
|
||||
emitter.declarations[index] = build(name)
|
||||
return name
|
||||
}
|
||||
|
||||
// Compatibility comparator for the closure backend: a simple monomorphic
|
||||
// loop. The generated backend is the performance path.
|
||||
function compileEquivalent<A>(
|
||||
fields: ReadonlyArray<{
|
||||
readonly name: PropertyKey
|
||||
readonly field: Field<unknown>
|
||||
}>,
|
||||
) {
|
||||
if (fields.length === 0) return (_left: A, _right: A) => true
|
||||
return (left: A, right: A) => {
|
||||
const a = left as Record<PropertyKey, unknown>
|
||||
const b = right as Record<PropertyKey, unknown>
|
||||
for (const field of fields) if (!field.field.equivalent(a[field.name], b[field.name])) return false
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,498 +0,0 @@
|
||||
/**
|
||||
* A callable reactive value.
|
||||
*
|
||||
* `subscribe`, `set`, and `update` are shared this-based methods, not
|
||||
* per-instance closures: invoke them as methods (`readable.subscribe(f)`) or
|
||||
* bind before detaching (`readable.subscribe.bind(readable)`). A detached
|
||||
* bare reference loses its node and throws.
|
||||
*/
|
||||
export interface Readable<A> {
|
||||
(): A
|
||||
subscribe(listener: (value: A) => void): () => void
|
||||
}
|
||||
|
||||
export interface Writable<A> extends Readable<A> {
|
||||
set(value: A): void
|
||||
update(f: (value: A) => A): void
|
||||
}
|
||||
|
||||
export namespace State {
|
||||
export function make<A>(initial: A): Writable<A> {
|
||||
const node: StateNode<A> = {
|
||||
flags: Flags.Mutable,
|
||||
value: initial,
|
||||
pending: initial,
|
||||
deps: undefined,
|
||||
depsTail: undefined,
|
||||
subs: undefined,
|
||||
subsTail: undefined,
|
||||
}
|
||||
// Methods are shared this-based functions rather than per-instance
|
||||
// closures: one callable and one node per state, and every call site
|
||||
// stays monomorphic on the shared method identity.
|
||||
const read = (() => readState(node)) as Writable<A> & Handle<StateNode<A>>
|
||||
read.node = node
|
||||
read.set = stateSet
|
||||
read.update = stateUpdate
|
||||
read.subscribe = sharedSubscribe
|
||||
return read
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Computed {
|
||||
export function make<A>(evaluate: (previous: A | undefined) => A): Readable<A> {
|
||||
const node: ComputedNode<A> = {
|
||||
flags: Flags.None,
|
||||
value: undefined,
|
||||
evaluate,
|
||||
deps: undefined,
|
||||
depsTail: undefined,
|
||||
subs: undefined,
|
||||
subsTail: undefined,
|
||||
}
|
||||
const read = (() => readComputed(node)) as Readable<A> & Handle<ComputedNode<A>>
|
||||
read.node = node
|
||||
read.subscribe = sharedSubscribe
|
||||
return read
|
||||
}
|
||||
}
|
||||
|
||||
interface Handle<Node> {
|
||||
node: Node
|
||||
}
|
||||
|
||||
function stateSet<A>(this: Handle<StateNode<A>>, value: A): void {
|
||||
writeState(this.node, value)
|
||||
}
|
||||
|
||||
function stateUpdate<A>(this: Handle<StateNode<A>>, f: (value: A) => A): void {
|
||||
// Read untracked: calling update inside a tracked evaluation must not
|
||||
// make the caller depend on (and re-trigger from) this state.
|
||||
const previous = swapActiveSub(undefined)
|
||||
try {
|
||||
writeState(this.node, f(readState(this.node)))
|
||||
} finally {
|
||||
activeSub = previous
|
||||
}
|
||||
}
|
||||
|
||||
function sharedSubscribe<A>(this: (() => A) & Handle<ReactiveNode>, listener: (value: A) => void): () => void {
|
||||
return subscribeNode(this.node, this, listener)
|
||||
}
|
||||
|
||||
export namespace Transaction {
|
||||
export function run<A>(f: () => A): A {
|
||||
batchDepth++
|
||||
try {
|
||||
return f()
|
||||
} finally {
|
||||
if (!--batchDepth) flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Internal reactive kernel.
|
||||
//
|
||||
// The dependency graph representation (intrusive doubly-linked dependency and
|
||||
// subscriber links with versioned in-place reuse) and the iterative
|
||||
// `propagate`/`checkDirty` walks are adapted from alien-signals 3.2.1
|
||||
// (https://github.com/stackblitz/alien-signals, MIT). See
|
||||
// THIRD_PARTY_NOTICES.md. Quark departs from the reference in three ways:
|
||||
// subscribers are fixed single-dependency watchers instead of general
|
||||
// effects, orphaned computeds are detached with an iterative work queue
|
||||
// instead of recursive unwatch callbacks, and reading a computed that is
|
||||
// currently evaluating throws a stable cycle error instead of looping
|
||||
// (stackblitz/alien-signals#118, #123).
|
||||
|
||||
const enum Flags {
|
||||
None = 0,
|
||||
/** The node can produce a new value: states always, computeds once evaluated. */
|
||||
Mutable = 1,
|
||||
/** The node is a subscription watcher delivering values to a listener. */
|
||||
Watching = 2,
|
||||
/** The computed is currently evaluating; reading it again is a cycle. */
|
||||
Computing = 4,
|
||||
/** The watcher is queued for the next flush. */
|
||||
Queued = 8,
|
||||
/** The node's value is known stale. */
|
||||
Dirty = 16,
|
||||
/** The node's value is possibly stale pending dependency revalidation. */
|
||||
Pending = 32,
|
||||
}
|
||||
|
||||
interface ReactiveNode {
|
||||
flags: Flags
|
||||
deps?: Link
|
||||
depsTail?: Link
|
||||
subs?: Link
|
||||
subsTail?: Link
|
||||
}
|
||||
|
||||
interface StateNode<A = unknown> extends ReactiveNode {
|
||||
value: A
|
||||
pending: A
|
||||
}
|
||||
|
||||
interface ComputedNode<A = unknown> extends ReactiveNode {
|
||||
value: A | undefined
|
||||
evaluate: (previous: A | undefined) => A
|
||||
}
|
||||
|
||||
interface WatcherNode extends ReactiveNode {
|
||||
read: () => unknown
|
||||
listener: (value: unknown) => void
|
||||
}
|
||||
|
||||
interface Link {
|
||||
version: number
|
||||
dep: ReactiveNode
|
||||
sub: ReactiveNode
|
||||
prevSub: Link | undefined
|
||||
nextSub: Link | undefined
|
||||
prevDep: Link | undefined
|
||||
nextDep: Link | undefined
|
||||
}
|
||||
|
||||
interface Stack<A> {
|
||||
value: A
|
||||
prev: Stack<A> | undefined
|
||||
}
|
||||
|
||||
let activeSub: ReactiveNode | undefined
|
||||
let batchDepth = 0
|
||||
let version = 0
|
||||
let flushIndex = 0
|
||||
let queueLength = 0
|
||||
const queue: Array<WatcherNode | undefined> = []
|
||||
const orphans: Array<ReactiveNode> = []
|
||||
|
||||
function swapActiveSub(sub: ReactiveNode | undefined): ReactiveNode | undefined {
|
||||
const previous = activeSub
|
||||
activeSub = sub
|
||||
return previous
|
||||
}
|
||||
|
||||
function readState<A>(node: StateNode<A>): A {
|
||||
if (node.flags & Flags.Dirty && updateState(node) && node.subs !== undefined) {
|
||||
shallowPropagate(node.subs)
|
||||
}
|
||||
if (activeSub !== undefined) link(node, activeSub, version)
|
||||
return node.value
|
||||
}
|
||||
|
||||
function writeState<A>(node: StateNode<A>, value: A): void {
|
||||
if (node.pending === (node.pending = value)) return
|
||||
node.flags = Flags.Mutable | Flags.Dirty
|
||||
if (node.subs !== undefined) {
|
||||
propagate(node.subs)
|
||||
if (!batchDepth) flush()
|
||||
}
|
||||
}
|
||||
|
||||
function readComputed<A>(node: ComputedNode<A>): A {
|
||||
const flags = node.flags
|
||||
if (flags & Flags.Computing) {
|
||||
throw new Error("Reactive cycle detected: a computed depends on its own value")
|
||||
}
|
||||
if (
|
||||
flags & Flags.Dirty ||
|
||||
(flags & Flags.Pending && (checkDirty(node.deps!, node) || ((node.flags = flags & ~Flags.Pending), false))) ||
|
||||
!flags
|
||||
) {
|
||||
if (updateComputed(node) && node.subs !== undefined) {
|
||||
shallowPropagate(node.subs)
|
||||
}
|
||||
}
|
||||
if (activeSub !== undefined) link(node, activeSub, version)
|
||||
return node.value!
|
||||
}
|
||||
|
||||
function updateState(node: StateNode): boolean {
|
||||
node.flags = Flags.Mutable
|
||||
return node.value !== (node.value = node.pending)
|
||||
}
|
||||
|
||||
function updateComputed<A>(node: ComputedNode<A>): boolean {
|
||||
node.depsTail = undefined
|
||||
node.flags = Flags.Mutable | Flags.Computing
|
||||
const previous = swapActiveSub(node)
|
||||
version++
|
||||
let completed = false
|
||||
try {
|
||||
const oldValue = node.value
|
||||
const changed = oldValue !== (node.value = node.evaluate(oldValue))
|
||||
completed = true
|
||||
return changed
|
||||
} finally {
|
||||
activeSub = previous
|
||||
node.flags &= ~Flags.Computing
|
||||
// A throwing evaluation stays dirty so the next read retries instead of
|
||||
// serving a stale value with no dependency links.
|
||||
if (!completed) node.flags |= Flags.Dirty
|
||||
purgeDeps(node)
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeNode<A>(node: ReactiveNode, read: () => A, listener: (value: A) => void): () => void {
|
||||
// Evaluate untracked before linking so a throwing computed leaves no
|
||||
// partially initialized watcher behind.
|
||||
const previous = swapActiveSub(undefined)
|
||||
try {
|
||||
read()
|
||||
} finally {
|
||||
activeSub = previous
|
||||
}
|
||||
const watcher: WatcherNode = {
|
||||
flags: Flags.Watching,
|
||||
read,
|
||||
listener: listener as (value: unknown) => void,
|
||||
deps: undefined,
|
||||
depsTail: undefined,
|
||||
}
|
||||
link(node, watcher, ++version)
|
||||
return () => {
|
||||
if (!(watcher.flags & Flags.Watching)) return
|
||||
watcher.flags &= ~(Flags.Watching | Flags.Dirty | Flags.Pending)
|
||||
unlink(watcher.deps!, watcher)
|
||||
drainOrphans()
|
||||
}
|
||||
}
|
||||
|
||||
function runWatcher(watcher: WatcherNode): void {
|
||||
const flags = watcher.flags
|
||||
watcher.flags = flags & ~(Flags.Queued | Flags.Dirty | Flags.Pending)
|
||||
if (!(flags & Flags.Watching)) return
|
||||
const dirty = !!(flags & Flags.Dirty) || (!!(flags & Flags.Pending) && checkDirty(watcher.deps!, watcher))
|
||||
// Revalidation runs user code in computed evaluations, which may dispose
|
||||
// this watcher; a disposed watcher must not deliver.
|
||||
if (!dirty || !(watcher.flags & Flags.Watching)) return
|
||||
// Listener reads stay untracked and listeners may dispose subscriptions,
|
||||
// including their own.
|
||||
const previous = swapActiveSub(undefined)
|
||||
try {
|
||||
watcher.listener(watcher.read())
|
||||
} finally {
|
||||
activeSub = previous
|
||||
}
|
||||
}
|
||||
|
||||
function flush(): void {
|
||||
try {
|
||||
while (flushIndex < queueLength) {
|
||||
const watcher = queue[flushIndex]!
|
||||
queue[flushIndex++] = undefined
|
||||
runWatcher(watcher)
|
||||
}
|
||||
} finally {
|
||||
// A throwing listener aborts this flush; the remaining watchers keep
|
||||
// their Dirty/Pending flags and requeue on the next propagation.
|
||||
while (flushIndex < queueLength) {
|
||||
const watcher = queue[flushIndex]!
|
||||
queue[flushIndex++] = undefined
|
||||
watcher.flags &= ~Flags.Queued
|
||||
}
|
||||
flushIndex = 0
|
||||
queueLength = 0
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(watcher: WatcherNode): void {
|
||||
watcher.flags |= Flags.Queued
|
||||
queue[queueLength++] = watcher
|
||||
}
|
||||
|
||||
function link(dep: ReactiveNode, sub: ReactiveNode, linkVersion: number): void {
|
||||
const prevDep = sub.depsTail
|
||||
if (prevDep !== undefined && prevDep.dep === dep) return
|
||||
const nextDep = prevDep !== undefined ? prevDep.nextDep : sub.deps
|
||||
if (nextDep !== undefined && nextDep.dep === dep) {
|
||||
nextDep.version = linkVersion
|
||||
sub.depsTail = nextDep
|
||||
return
|
||||
}
|
||||
const prevSub = dep.subsTail
|
||||
if (prevSub !== undefined && prevSub.version === linkVersion && prevSub.sub === sub) return
|
||||
const newLink: Link = {
|
||||
version: linkVersion,
|
||||
dep,
|
||||
sub,
|
||||
prevDep,
|
||||
nextDep,
|
||||
prevSub,
|
||||
nextSub: undefined,
|
||||
}
|
||||
sub.depsTail = newLink
|
||||
dep.subsTail = newLink
|
||||
if (nextDep !== undefined) nextDep.prevDep = newLink
|
||||
if (prevDep !== undefined) prevDep.nextDep = newLink
|
||||
else sub.deps = newLink
|
||||
if (prevSub !== undefined) prevSub.nextSub = newLink
|
||||
else dep.subs = newLink
|
||||
}
|
||||
|
||||
function unlink(current: Link, sub: ReactiveNode): Link | undefined {
|
||||
const dep = current.dep
|
||||
const prevDep = current.prevDep
|
||||
const nextDep = current.nextDep
|
||||
const nextSub = current.nextSub
|
||||
const prevSub = current.prevSub
|
||||
if (nextDep !== undefined) nextDep.prevDep = prevDep
|
||||
else sub.depsTail = prevDep
|
||||
if (prevDep !== undefined) prevDep.nextDep = nextDep
|
||||
else sub.deps = nextDep
|
||||
if (nextSub !== undefined) nextSub.prevSub = prevSub
|
||||
else dep.subsTail = prevSub
|
||||
if (prevSub !== undefined) prevSub.nextSub = nextSub
|
||||
else if ((dep.subs = nextSub) === undefined && dep.deps !== undefined) orphans.push(dep)
|
||||
return nextDep
|
||||
}
|
||||
|
||||
// Detaches computeds that lost their last subscriber. Iterative on purpose:
|
||||
// a recursive unwatch cascade overflows the stack on deep chains. Only nodes
|
||||
// with dependencies enter the queue (states and dep-less computeds have
|
||||
// nothing to detach). As in the reference, a computed that is read but never
|
||||
// subscribed stays linked to its sources until they are collected; create
|
||||
// long-lived computeds rather than per-operation ones.
|
||||
function drainOrphans(): void {
|
||||
while (orphans.length > 0) {
|
||||
const node = orphans.pop()!
|
||||
if (!("evaluate" in node) || node.depsTail === undefined) continue
|
||||
node.flags = Flags.Mutable | Flags.Dirty
|
||||
let current = node.depsTail as Link | undefined
|
||||
while (current !== undefined) {
|
||||
const prev = current.prevDep
|
||||
unlink(current, node)
|
||||
current = prev
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function purgeDeps(sub: ReactiveNode): void {
|
||||
const depsTail = sub.depsTail
|
||||
let dep = depsTail !== undefined ? depsTail.nextDep : sub.deps
|
||||
while (dep !== undefined) {
|
||||
dep = unlink(dep, sub)
|
||||
}
|
||||
drainOrphans()
|
||||
}
|
||||
|
||||
function propagate(current: Link): void {
|
||||
let next = current.nextSub
|
||||
let stack: Stack<Link | undefined> | undefined
|
||||
|
||||
top: do {
|
||||
const sub = current.sub
|
||||
const flags = sub.flags
|
||||
const unmarked = !(flags & (Flags.Dirty | Flags.Pending))
|
||||
if (unmarked) sub.flags = flags | Flags.Pending
|
||||
if (flags & Flags.Watching && !(flags & Flags.Queued)) enqueue(sub as WatcherNode)
|
||||
|
||||
if (unmarked && flags & Flags.Mutable) {
|
||||
const subSubs = sub.subs
|
||||
if (subSubs !== undefined) {
|
||||
current = subSubs
|
||||
const nextSub = subSubs.nextSub
|
||||
if (nextSub !== undefined) {
|
||||
stack = { value: next, prev: stack }
|
||||
next = nextSub
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (next !== undefined) {
|
||||
current = next
|
||||
next = current.nextSub
|
||||
continue
|
||||
}
|
||||
|
||||
while (stack !== undefined) {
|
||||
const continuation = stack.value
|
||||
stack = stack.prev
|
||||
if (continuation !== undefined) {
|
||||
current = continuation
|
||||
next = current.nextSub
|
||||
continue top
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
} while (true)
|
||||
}
|
||||
|
||||
function shallowPropagate(current: Link): void {
|
||||
let iterator: Link | undefined = current
|
||||
do {
|
||||
const sub = iterator.sub
|
||||
const flags = sub.flags
|
||||
if ((flags & (Flags.Pending | Flags.Dirty)) === Flags.Pending) {
|
||||
sub.flags = flags | Flags.Dirty
|
||||
if (flags & Flags.Watching && !(flags & Flags.Queued)) enqueue(sub as WatcherNode)
|
||||
}
|
||||
iterator = iterator.nextSub
|
||||
} while (iterator !== undefined)
|
||||
}
|
||||
|
||||
function updateNode(node: ReactiveNode): boolean {
|
||||
if ("evaluate" in node) return updateComputed(node as ComputedNode)
|
||||
return updateState(node as StateNode)
|
||||
}
|
||||
|
||||
function checkDirty(current: Link, sub: ReactiveNode): boolean {
|
||||
let stack: Stack<Link> | undefined
|
||||
let checkDepth = 0
|
||||
let dirty = false
|
||||
|
||||
top: do {
|
||||
const dep = current.dep
|
||||
const flags = dep.flags
|
||||
|
||||
if (sub.flags & Flags.Dirty) {
|
||||
dirty = true
|
||||
} else if ((flags & (Flags.Mutable | Flags.Dirty)) === (Flags.Mutable | Flags.Dirty)) {
|
||||
const subs = dep.subs!
|
||||
if (updateNode(dep)) {
|
||||
if (subs.nextSub !== undefined) shallowPropagate(subs)
|
||||
dirty = true
|
||||
}
|
||||
} else if ((flags & (Flags.Mutable | Flags.Pending)) === (Flags.Mutable | Flags.Pending)) {
|
||||
stack = { value: current, prev: stack }
|
||||
current = dep.deps!
|
||||
sub = dep
|
||||
checkDepth++
|
||||
continue
|
||||
}
|
||||
|
||||
if (!dirty) {
|
||||
const nextDep = current.nextDep
|
||||
if (nextDep !== undefined) {
|
||||
current = nextDep
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
while (checkDepth--) {
|
||||
current = stack!.value
|
||||
stack = stack!.prev
|
||||
if (dirty) {
|
||||
const subs = sub.subs!
|
||||
if (updateNode(sub)) {
|
||||
if (subs.nextSub !== undefined) shallowPropagate(subs)
|
||||
sub = current.sub
|
||||
continue
|
||||
}
|
||||
dirty = false
|
||||
} else {
|
||||
sub.flags &= ~Flags.Pending
|
||||
}
|
||||
sub = current.sub
|
||||
const nextDep = current.nextDep
|
||||
if (nextDep !== undefined) {
|
||||
current = nextDep
|
||||
continue top
|
||||
}
|
||||
}
|
||||
|
||||
return dirty
|
||||
} while (true)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { createMemo, For, from, type Accessor, type JSX } from "solid-js"
|
||||
import type { Keyed } from "./keyed"
|
||||
import type { Readable } from "./reactivity"
|
||||
|
||||
export function useValue<A>(readable: Readable<A>): Accessor<A> {
|
||||
return from(readable, readable())
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive accessor for one keyed slot. Structural changes re-resolve the
|
||||
* slot, while memo equality prevents an unchanged slot from propagating to the
|
||||
* consumer. Value changes flow through the slot itself.
|
||||
*
|
||||
* The key is usually constant and may be passed plainly; pass an accessor when
|
||||
* the key itself is reactive. (A key that is itself a function value is
|
||||
* indistinguishable from an accessor and must be wrapped.)
|
||||
*/
|
||||
export function useSlot<A, Key>(keyed: Keyed.ReadOnly<A, Key>, key: Key | (() => Key)): Accessor<A | undefined> {
|
||||
const resolve = typeof key === "function" ? (key as () => Key) : () => key
|
||||
const structure = useValue(keyed.slots)
|
||||
const slot = createMemo(() => {
|
||||
structure()
|
||||
return keyed.get(resolve())
|
||||
})
|
||||
const value = createMemo(() => {
|
||||
const current = slot()
|
||||
return current ? useValue(current) : undefined
|
||||
})
|
||||
return () => value()?.()
|
||||
}
|
||||
|
||||
export function KeyedFor<A>(props: {
|
||||
readonly each: Accessor<readonly Readable<A>[]>
|
||||
readonly fallback?: JSX.Element
|
||||
readonly children: (value: Accessor<A>, index: Accessor<number>) => JSX.Element
|
||||
}) {
|
||||
return For({
|
||||
get each() {
|
||||
return props.each()
|
||||
},
|
||||
get fallback() {
|
||||
return props.fallback
|
||||
},
|
||||
children: (slot, index) => props.children(useValue(slot), index),
|
||||
})
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { Keyed } from "../src/keyed"
|
||||
import { Computed } from "../src/reactivity"
|
||||
|
||||
interface Item {
|
||||
readonly id: number
|
||||
readonly label: string
|
||||
}
|
||||
|
||||
const item = (id: number, label: string): Item => ({ id, label })
|
||||
|
||||
describe("Keyed", () => {
|
||||
it("keeps slot identity across value updates and reorders", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(1, "one"), item(2, "two"), item(3, "three")])
|
||||
const [one, two, three] = keyed.slots()
|
||||
|
||||
keyed.set([item(3, "THREE"), item(1, "ONE"), item(2, "TWO")])
|
||||
|
||||
expect(keyed.slots()).toEqual([three, one, two])
|
||||
expect(keyed.slots()[0]).toBe(three)
|
||||
expect(keyed.slots()[1]).toBe(one)
|
||||
expect(keyed.slots()[2]).toBe(two)
|
||||
expect(one()).toEqual(item(1, "ONE"))
|
||||
expect(two()).toEqual(item(2, "TWO"))
|
||||
expect(three()).toEqual(item(3, "THREE"))
|
||||
})
|
||||
|
||||
it("does not publish structural changes for value-only updates or identical sets", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(1, "one"), item(2, "two")])
|
||||
const initial = keyed.slots()
|
||||
const structures: Array<readonly number[]> = []
|
||||
const dispose = keyed.slots.subscribe((slots) => structures.push(slots.map((slot) => slot().id)))
|
||||
|
||||
keyed.set([item(1, "ONE"), item(2, "TWO")])
|
||||
expect(keyed.slots()).toBe(initial)
|
||||
|
||||
keyed.set([item(1, "ONE"), item(2, "TWO")])
|
||||
expect(keyed.slots()).toBe(initial)
|
||||
|
||||
keyed.set([item(2, "TWO"), item(1, "ONE")])
|
||||
|
||||
expect(structures).toEqual([[2, 1]])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("reacts to aggregate value updates while preserving unchanged aggregate snapshots", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
const one = item(1, "one")
|
||||
const two = item(2, "two")
|
||||
keyed.set([one, two])
|
||||
const initial = keyed.values()
|
||||
const snapshots: Array<readonly Item[]> = []
|
||||
const dispose = keyed.values.subscribe((values) => snapshots.push(values))
|
||||
|
||||
keyed.set([one, item(2, "TWO")])
|
||||
const updated = keyed.values()
|
||||
expect(updated).not.toBe(initial)
|
||||
expect(updated).toEqual([one, item(2, "TWO")])
|
||||
|
||||
keyed.set([one, updated[1]])
|
||||
|
||||
expect(keyed.values()).toBe(updated)
|
||||
expect(snapshots).toEqual([[one, item(2, "TWO")]])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("uses custom equivalence to cut off slot and aggregate updates", () => {
|
||||
const keyed = Keyed.make<Item, number>({
|
||||
key: (value) => value.id,
|
||||
equivalent: (left, right) => left.id === right.id && left.label.toLowerCase() === right.label.toLowerCase(),
|
||||
})
|
||||
const original = item(1, "one")
|
||||
keyed.set([original])
|
||||
const slot = keyed.slots()[0]
|
||||
const aggregate = keyed.values()
|
||||
const slotValues: Item[] = []
|
||||
const aggregateValues: Array<readonly Item[]> = []
|
||||
const disposeSlot = slot.subscribe((value) => slotValues.push(value))
|
||||
const disposeAggregate = keyed.values.subscribe((values) => aggregateValues.push(values))
|
||||
|
||||
keyed.set([item(1, "ONE")])
|
||||
|
||||
expect(slot()).toBe(original)
|
||||
expect(keyed.values()).toBe(aggregate)
|
||||
expect(slotValues).toEqual([])
|
||||
expect(aggregateValues).toEqual([])
|
||||
|
||||
keyed.set([item(1, "changed")])
|
||||
|
||||
expect(slot()).toEqual(item(1, "changed"))
|
||||
expect(slotValues).toEqual([item(1, "changed")])
|
||||
expect(aggregateValues).toEqual([[item(1, "changed")]])
|
||||
disposeSlot()
|
||||
disposeAggregate()
|
||||
})
|
||||
|
||||
it("uses SameValueZero semantics for primitive slot values", () => {
|
||||
const keyed = Keyed.make<number, string>({ key: () => "value" })
|
||||
keyed.set([-0])
|
||||
|
||||
expect(keyed.update(0)).toBe(false)
|
||||
expect(Object.is(keyed.get("value")?.(), -0)).toBe(true)
|
||||
})
|
||||
|
||||
it("creates slots for inserts and fresh slots after remove and reinsert", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(1, "one")])
|
||||
const removed = keyed.slots()[0]
|
||||
const removedValues: Item[] = []
|
||||
const dispose = removed.subscribe((value) => removedValues.push(value))
|
||||
|
||||
keyed.set([item(2, "two")])
|
||||
const inserted = keyed.slots()[0]
|
||||
keyed.set([item(1, "new one"), item(2, "new two")])
|
||||
const [reinserted, retained] = keyed.slots()
|
||||
|
||||
expect(reinserted).not.toBe(removed)
|
||||
expect(reinserted()).toEqual(item(1, "new one"))
|
||||
expect(retained).toBe(inserted)
|
||||
expect(retained()).toEqual(item(2, "new two"))
|
||||
expect(removed()).toEqual(item(1, "one"))
|
||||
expect(removedValues).toEqual([])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("rejects duplicate keys before making any partial update", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(1, "one"), item(2, "two")])
|
||||
const slots = keyed.slots()
|
||||
const values = keyed.values()
|
||||
const notifications: string[] = []
|
||||
const disposeOne = slots[0].subscribe(() => notifications.push("one"))
|
||||
const disposeTwo = slots[1].subscribe(() => notifications.push("two"))
|
||||
const disposeSlots = keyed.slots.subscribe(() => notifications.push("slots"))
|
||||
const disposeValues = keyed.values.subscribe(() => notifications.push("values"))
|
||||
|
||||
expect(() => keyed.set([item(1, "changed"), item(1, "duplicate")])).toThrow("Keyed values must have unique keys")
|
||||
|
||||
expect(keyed.slots()).toBe(slots)
|
||||
expect(keyed.values()).toBe(values)
|
||||
expect(keyed.values()).toEqual([item(1, "one"), item(2, "two")])
|
||||
expect(notifications).toEqual([])
|
||||
disposeOne()
|
||||
disposeTwo()
|
||||
disposeSlots()
|
||||
disposeValues()
|
||||
})
|
||||
|
||||
it("uses SameValueZero equality for zero keys", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(-0, "negative zero")])
|
||||
const zero = keyed.slots()[0]
|
||||
|
||||
keyed.set([item(0, "zero")])
|
||||
|
||||
expect(keyed.slots()[0]).toBe(zero)
|
||||
expect(zero()).toEqual(item(0, "zero"))
|
||||
expect(() => keyed.set([item(0, "zero"), item(-0, "negative zero")])).toThrow("Keyed values must have unique keys")
|
||||
expect(keyed.slots()).toEqual([zero])
|
||||
expect(keyed.values()).toEqual([item(0, "zero")])
|
||||
})
|
||||
|
||||
it("uses SameValueZero equality for NaN keys", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(Number.NaN, "first")])
|
||||
const nan = keyed.slots()[0]
|
||||
|
||||
keyed.set([item(Number.NaN, "updated")])
|
||||
|
||||
expect(keyed.slots()[0]).toBe(nan)
|
||||
expect(nan()).toEqual(item(Number.NaN, "updated"))
|
||||
expect(() => keyed.set([item(Number.NaN, "one"), item(Number.NaN, "two")])).toThrow(
|
||||
"Keyed values must have unique keys",
|
||||
)
|
||||
expect(keyed.slots()).toEqual([nan])
|
||||
expect(keyed.values()).toEqual([item(Number.NaN, "updated")])
|
||||
})
|
||||
|
||||
it("publishes one glitch-free aggregate when slots and structure change together", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(1, "one"), item(2, "two"), item(3, "three")])
|
||||
const observations: string[] = []
|
||||
const summary = Computed.make(() => {
|
||||
const slotKeys = keyed
|
||||
.slots()
|
||||
.map((slot) => slot().id)
|
||||
.join(",")
|
||||
const values = keyed
|
||||
.values()
|
||||
.map((value) => `${value.id}:${value.label}`)
|
||||
.join(",")
|
||||
return `${slotKeys}|${values}`
|
||||
})
|
||||
const dispose = summary.subscribe((value) => observations.push(value))
|
||||
|
||||
keyed.set([item(3, "THREE"), item(2, "TWO"), item(4, "four")])
|
||||
|
||||
expect(observations).toEqual(["3,2,4|3:THREE,2:TWO,4:four"])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("stops slot, structural, and aggregate notifications after disposal", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(1, "one")])
|
||||
const notifications: string[] = []
|
||||
const disposeSlot = keyed.slots()[0].subscribe(() => notifications.push("slot"))
|
||||
const disposeSlots = keyed.slots.subscribe(() => notifications.push("slots"))
|
||||
const disposeValues = keyed.values.subscribe(() => notifications.push("values"))
|
||||
|
||||
disposeSlot()
|
||||
disposeSlots()
|
||||
disposeValues()
|
||||
keyed.set([item(2, "two")])
|
||||
keyed.set([item(2, "TWO")])
|
||||
|
||||
expect(notifications).toEqual([])
|
||||
})
|
||||
|
||||
it("supports empty sets and repeated clears without redundant publication", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
expect(keyed.slots()).toEqual([])
|
||||
expect(keyed.values()).toEqual([])
|
||||
const structures: Array<readonly unknown[]> = []
|
||||
const aggregates: Array<readonly Item[]> = []
|
||||
const disposeSlots = keyed.slots.subscribe((slots) => structures.push(slots))
|
||||
const disposeValues = keyed.values.subscribe((values) => aggregates.push(values))
|
||||
|
||||
keyed.set([])
|
||||
keyed.set([item(1, "one")])
|
||||
keyed.set([])
|
||||
const clearedSlots = keyed.slots()
|
||||
const clearedValues = keyed.values()
|
||||
keyed.set([])
|
||||
|
||||
expect(keyed.slots()).toBe(clearedSlots)
|
||||
expect(keyed.values()).toBe(clearedValues)
|
||||
expect(structures.map((slots) => slots.length)).toEqual([1, 0])
|
||||
expect(aggregates).toEqual([[item(1, "one")], []])
|
||||
disposeSlots()
|
||||
disposeValues()
|
||||
})
|
||||
|
||||
it("updates one existing slot without publishing structure", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(1, "one"), item(2, "two")])
|
||||
const structure = keyed.slots()
|
||||
const two = keyed.slots()[1]
|
||||
const structures: Array<readonly unknown[]> = []
|
||||
const dispose = keyed.slots.subscribe((slots) => structures.push(slots))
|
||||
|
||||
const updated = item(2, "TWO")
|
||||
expect(keyed.update(updated)).toBe(true)
|
||||
expect(keyed.update(updated)).toBe(false)
|
||||
|
||||
expect(keyed.slots()).toBe(structure)
|
||||
expect(keyed.slots()[1]).toBe(two)
|
||||
expect(two()).toEqual(item(2, "TWO"))
|
||||
expect(structures).toEqual([])
|
||||
expect(keyed.update(item(3, "three"))).toBe(false)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("modifies one existing slot while preserving its key", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(1, "one")])
|
||||
const slot = keyed.slots()[0]
|
||||
|
||||
expect(keyed.modify(1, (value) => ({ ...value, label: "ONE" }))).toBe(true)
|
||||
expect(keyed.modify(1, (value) => value)).toBe(false)
|
||||
|
||||
expect(keyed.slots()[0]).toBe(slot)
|
||||
expect(slot()).toEqual(item(1, "ONE"))
|
||||
expect(() => keyed.modify(1, (value) => ({ ...value, id: 2 }))).toThrow("Keyed modify must preserve the value key")
|
||||
expect(keyed.modify(2, (value) => value)).toBe(false)
|
||||
})
|
||||
|
||||
it("checks key membership without reading the aggregate", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(1, "one")])
|
||||
|
||||
expect(keyed.has(1)).toBe(true)
|
||||
expect(keyed.has(2)).toBe(false)
|
||||
expect(keyed.get(1)).toBe(keyed.slots()[0])
|
||||
expect(keyed.get(2)).toBeUndefined()
|
||||
expect(keyed.before(1)).toBeUndefined()
|
||||
expect(keyed.after(1)).toBeUndefined()
|
||||
keyed.remove(1)
|
||||
expect(keyed.has(1)).toBe(false)
|
||||
expect(keyed.get(1)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("inserts, removes, and moves stable slots", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(1, "one"), item(3, "three")])
|
||||
const one = keyed.slots()[0]
|
||||
const three = keyed.slots()[1]
|
||||
|
||||
const two = keyed.insert(item(2, "two"), { before: 3 })
|
||||
expect(keyed.slots()).toEqual([one, two, three])
|
||||
const four = keyed.insert(item(4, "four"), { after: 3 })
|
||||
expect(keyed.slots()).toEqual([one, two, three, four])
|
||||
expect(keyed.before(3)).toBe(two)
|
||||
expect(keyed.after(3)).toBe(four)
|
||||
expect(keyed.move(3, { before: 1 })).toBe(true)
|
||||
expect(keyed.slots()).toEqual([three, one, two, four])
|
||||
expect(keyed.move(3, { before: 1 })).toBe(false)
|
||||
expect(keyed.move(3, { after: 2 })).toBe(true)
|
||||
expect(keyed.slots()).toEqual([one, two, three, four])
|
||||
expect(keyed.move(3, "end")).toBe(true)
|
||||
expect(keyed.slots()).toEqual([one, two, four, three])
|
||||
expect(keyed.remove(2)).toBe(true)
|
||||
expect(keyed.remove(2)).toBe(false)
|
||||
expect(keyed.slots()).toEqual([one, four, three])
|
||||
expect(() => keyed.insert(item(1, "duplicate"))).toThrow("Keyed value already exists: 1")
|
||||
expect(() => keyed.insert(item(2, "two"), { before: 5 })).toThrow("Keyed value does not exist: 5")
|
||||
expect(keyed.move(5)).toBe(false)
|
||||
})
|
||||
|
||||
it("counts publications and equivalence suppressions when instrumented", () => {
|
||||
const metrics = Keyed.metrics()
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id, metrics })
|
||||
|
||||
keyed.set([item(1, "one")])
|
||||
keyed.update(item(1, "ONE"))
|
||||
const current = keyed.slots()[0]()
|
||||
keyed.update(current)
|
||||
keyed.insert(item(2, "two"))
|
||||
keyed.move(2, { before: 1 })
|
||||
keyed.remove(2)
|
||||
|
||||
expect(metrics).toEqual({
|
||||
slotPublications: 1,
|
||||
structuralPublications: 4,
|
||||
equivalenceSuppressions: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,561 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { Layout } from "../src"
|
||||
|
||||
const Item = Layout.struct({
|
||||
id: Layout.key(Layout.number),
|
||||
label: Layout.string,
|
||||
})
|
||||
|
||||
const Job = Layout.struct({
|
||||
id: Layout.key(Layout.string),
|
||||
labels: Layout.array(Layout.string),
|
||||
status: Layout.string,
|
||||
})
|
||||
|
||||
const Jobs = Layout.collection(Job, ({ members, first }) => ({
|
||||
labels: members(["labels"], (job) => job.labels),
|
||||
nextRetry: first(["status"], (job) => job.status === "retrying"),
|
||||
}))
|
||||
|
||||
describe("Layout", () => {
|
||||
it("compiles a keyed collection from trusted structural metadata", () => {
|
||||
const plan = Layout.compile(Item)
|
||||
const original = { id: 1, label: "one" }
|
||||
const values = plan.make([original])
|
||||
const slot = values.slots()[0]
|
||||
|
||||
expect(plan.key).toBe("id")
|
||||
expect(plan.fields).toBe(Item.fields)
|
||||
expect(values.update({ id: 1, label: "one" })).toBe(false)
|
||||
expect(slot()).toBe(original)
|
||||
expect(values.update({ id: 1, label: "ONE" })).toBe(true)
|
||||
expect(slot()).toEqual({ id: 1, label: "ONE" })
|
||||
})
|
||||
|
||||
it("requires exactly one key field", () => {
|
||||
expect(() => Layout.compile(Layout.struct({ value: Layout.number }))).toThrow(
|
||||
"Keyed layout must declare exactly one key field",
|
||||
)
|
||||
expect(() =>
|
||||
Layout.compile(
|
||||
Layout.struct({
|
||||
left: Layout.key(Layout.number),
|
||||
right: Layout.key(Layout.number),
|
||||
}),
|
||||
),
|
||||
).toThrow("Keyed layout must declare exactly one key field")
|
||||
})
|
||||
|
||||
it("generates the same trusted equivalence as the closure backend", () => {
|
||||
const closure = Layout.compile(Item, { backend: "closure" })
|
||||
const generated = Layout.compile(Item, { backend: "generated" })
|
||||
const values = [
|
||||
{ id: 1, label: "one" },
|
||||
{ id: 1, label: "ONE" },
|
||||
{ id: 2, label: "one" },
|
||||
]
|
||||
|
||||
values.forEach((left) => {
|
||||
values.forEach((right) => {
|
||||
expect(generated.equivalent(left, right)).toBe(closure.equivalent(left, right))
|
||||
expect(generated.diff(left, right) === 0).toBe(generated.equivalent(left, right))
|
||||
expect(closure.diff(left, right) === 0).toBe(closure.equivalent(left, right))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("honors custom primitive comparators in generated plans", () => {
|
||||
const CaseInsensitive = {
|
||||
...Layout.string,
|
||||
foldCase: true,
|
||||
equivalent(left: string, right: string) {
|
||||
return this.foldCase ? left.toLowerCase() === right.toLowerCase() : left === right
|
||||
},
|
||||
}
|
||||
const Value = Layout.struct({ id: Layout.key(Layout.number), value: CaseInsensitive })
|
||||
const closure = Layout.compile(Value, { backend: "closure" })
|
||||
const generated = Layout.compile(Value, { backend: "generated" })
|
||||
const left = { id: 1, value: "same" }
|
||||
const right = { id: 1, value: "SAME" }
|
||||
|
||||
expect(closure.equivalent(left, right)).toBe(true)
|
||||
expect(generated.equivalent(left, right)).toBe(true)
|
||||
expect(generated.diff(left, right)).toBe(0)
|
||||
})
|
||||
|
||||
it("keeps generated nested keyed unions independent when they share variants", () => {
|
||||
const variants = {
|
||||
yes: Layout.struct({ value: Layout.string }),
|
||||
no: Layout.struct({ value: Layout.string }),
|
||||
}
|
||||
const Left = Layout.keyedUnion({
|
||||
key: Layout.key("leftID", Layout.number),
|
||||
tag: "leftType",
|
||||
variants,
|
||||
})
|
||||
const Right = Layout.keyedUnion({
|
||||
key: Layout.key("rightID", Layout.number),
|
||||
tag: "rightType",
|
||||
variants,
|
||||
})
|
||||
const Root = Layout.struct({
|
||||
id: Layout.key(Layout.number),
|
||||
left: Left,
|
||||
right: Right,
|
||||
})
|
||||
const value: Layout.Type<typeof Root> = {
|
||||
id: 1,
|
||||
left: { leftID: 1, leftType: "yes", value: "same" },
|
||||
right: { rightID: 1, rightType: "yes", value: "same" },
|
||||
}
|
||||
const copy = structuredClone(value)
|
||||
const closure = Layout.compile(Root, { backend: "closure" })
|
||||
const generated = Layout.compile(Root, { backend: "generated" })
|
||||
|
||||
expect(closure.equivalent(value, copy)).toBe(true)
|
||||
expect(generated.equivalent(value, copy)).toBe(true)
|
||||
expect(generated.diff(value, copy)).toBe(0)
|
||||
})
|
||||
|
||||
it("compiles nested discriminated unions and skips immutable fields", () => {
|
||||
const Ref = Layout.struct({
|
||||
messageID: Layout.string,
|
||||
partID: Layout.string,
|
||||
})
|
||||
const Row = Layout.keyedUnion({
|
||||
key: Layout.key("id", Layout.string),
|
||||
tag: "type",
|
||||
variants: {
|
||||
message: Layout.struct({ messageID: Layout.string }),
|
||||
group: Layout.union({
|
||||
tag: "kind",
|
||||
variants: {
|
||||
reasoning: Layout.struct({
|
||||
origin: Layout.immutable(Ref),
|
||||
refs: Layout.array(Ref),
|
||||
completed: Layout.boolean,
|
||||
}),
|
||||
exploration: Layout.struct({
|
||||
origin: Layout.immutable(Ref),
|
||||
refs: Layout.array(Ref),
|
||||
pending: Layout.array(Ref),
|
||||
completed: Layout.boolean,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
const plan = Layout.compile(Row, { backend: "closure" })
|
||||
const generated = Layout.compile(Row, { backend: "generated" })
|
||||
const group = {
|
||||
id: "group-1",
|
||||
type: "group" as const,
|
||||
kind: "exploration" as const,
|
||||
origin: { messageID: "assistant-1", partID: "read-1" },
|
||||
refs: [{ messageID: "assistant-1", partID: "read-1" }],
|
||||
pending: [] as Array<{ messageID: string; partID: string }>,
|
||||
completed: false,
|
||||
}
|
||||
|
||||
expect(
|
||||
plan.equivalent(group, {
|
||||
...group,
|
||||
origin: { messageID: "ignored", partID: "ignored" },
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(plan.equivalent(group, { ...group, completed: true })).toBe(false)
|
||||
expect(
|
||||
plan.equivalent(group, {
|
||||
...group,
|
||||
pending: [{ messageID: "assistant-1", partID: "read-1" }],
|
||||
}),
|
||||
).toBe(false)
|
||||
expect(
|
||||
plan.equivalent(group, {
|
||||
id: "group-1",
|
||||
type: "group",
|
||||
kind: "reasoning",
|
||||
origin: group.origin,
|
||||
refs: group.refs,
|
||||
completed: false,
|
||||
}),
|
||||
).toBe(false)
|
||||
const candidates = [
|
||||
group,
|
||||
{ ...group, origin: { messageID: "ignored", partID: "ignored" } },
|
||||
{ ...group, completed: true },
|
||||
{ ...group, pending: [{ messageID: "assistant-1", partID: "read-1" }] },
|
||||
{
|
||||
id: "group-1",
|
||||
type: "group" as const,
|
||||
kind: "reasoning" as const,
|
||||
origin: group.origin,
|
||||
refs: group.refs,
|
||||
completed: false,
|
||||
},
|
||||
]
|
||||
candidates.forEach((left) => {
|
||||
candidates.forEach((right) => expect(generated.equivalent(left, right)).toBe(plan.equivalent(left, right)))
|
||||
})
|
||||
})
|
||||
|
||||
it("includes nested keys in structural equivalence", () => {
|
||||
const Child = Layout.struct({
|
||||
id: Layout.key(Layout.number),
|
||||
value: Layout.string,
|
||||
})
|
||||
const Parent = Layout.struct({
|
||||
id: Layout.key(Layout.number),
|
||||
child: Child,
|
||||
})
|
||||
const parent = Layout.compile(Parent)
|
||||
|
||||
expect(
|
||||
parent.equivalent({ id: 1, child: { id: 1, value: "same" } }, { id: 1, child: { id: 2, value: "same" } }),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("composes indexed collections without domain-specific behavior", () => {
|
||||
const jobs = Jobs.make([
|
||||
{ id: "one", labels: ["billing", "urgent"], status: "running" },
|
||||
{ id: "two", labels: ["billing"], status: "retrying" },
|
||||
{ id: "three", labels: [], status: "retrying" },
|
||||
])
|
||||
|
||||
expect(jobs.hasMember("labels", "billing")).toBe(true)
|
||||
expect(jobs.hasMember("labels", "missing")).toBe(false)
|
||||
expect(jobs.first("nextRetry")()?.id).toBe("two")
|
||||
expect(jobs.before("two")?.().id).toBe("one")
|
||||
expect(jobs.after("two")?.().id).toBe("three")
|
||||
|
||||
jobs.modify("one", (job) => ({ ...job, labels: [], status: "retrying" }))
|
||||
expect(jobs.hasMember("labels", "urgent")).toBe(false)
|
||||
expect(jobs.hasMember("labels", "billing")).toBe(true)
|
||||
expect(jobs.first("nextRetry")()?.id).toBe("one")
|
||||
|
||||
jobs.remove("two")
|
||||
expect(jobs.hasMember("labels", "billing")).toBe(false)
|
||||
jobs.move("three", { before: "one" })
|
||||
expect(jobs.first("nextRetry")()?.id).toBe("three")
|
||||
})
|
||||
|
||||
it("publishes first-index changes to subscribers", () => {
|
||||
const jobs = Jobs.make([
|
||||
{ id: "one", labels: [], status: "running" },
|
||||
{ id: "two", labels: [], status: "retrying" },
|
||||
])
|
||||
const seen: (string | undefined)[] = []
|
||||
const dispose = jobs.first("nextRetry").subscribe((job) => seen.push(job?.id))
|
||||
|
||||
// Identity change: a match earlier in slot order becomes the first.
|
||||
jobs.modify("one", (job) => ({ ...job, status: "retrying" }))
|
||||
// Value change of the current first match publishes through the slot.
|
||||
jobs.modify("one", (job) => ({ ...job, labels: ["late"] }))
|
||||
// The first match stops matching; the next one takes over.
|
||||
jobs.modify("one", (job) => ({ ...job, status: "done" }))
|
||||
// No match left.
|
||||
jobs.remove("two")
|
||||
|
||||
expect(seen).toEqual(["one", "one", "two", undefined])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("keeps indexes synchronized across inserts, updates, and replacement", () => {
|
||||
const jobs = Jobs.make([{ id: "one", labels: ["one"], status: "running" }])
|
||||
|
||||
jobs.insert({ id: "three", labels: ["shared"], status: "retrying" })
|
||||
jobs.insert({ id: "two", labels: ["shared"], status: "retrying" }, { before: "three" })
|
||||
expect(jobs.first("nextRetry")()?.id).toBe("two")
|
||||
|
||||
jobs.update({ id: "two", labels: [], status: "done" })
|
||||
expect(jobs.first("nextRetry")()?.id).toBe("three")
|
||||
expect(jobs.hasMember("labels", "shared")).toBe(true)
|
||||
|
||||
jobs.remove("three")
|
||||
expect(jobs.first("nextRetry")()).toBeUndefined()
|
||||
expect(jobs.hasMember("labels", "shared")).toBe(false)
|
||||
|
||||
jobs.set([
|
||||
{ id: "four", labels: ["replacement"], status: "retrying" },
|
||||
{ id: "five", labels: [], status: "running" },
|
||||
])
|
||||
expect(jobs.hasMember("labels", "replacement")).toBe(true)
|
||||
expect(jobs.hasMember("labels", "one")).toBe(false)
|
||||
expect(jobs.first("nextRetry")()?.id).toBe("four")
|
||||
})
|
||||
|
||||
it("skips index projection when the change is disjoint from its declared fields", () => {
|
||||
let extractions = 0
|
||||
let matchChecks = 0
|
||||
const IndexedJobs = Layout.collection(Job, ({ members, first }) => ({
|
||||
labels: members(["labels"], (job) => {
|
||||
extractions++
|
||||
return job.labels
|
||||
}),
|
||||
nextRetry: first(["status"], (job) => {
|
||||
matchChecks++
|
||||
return job.status === "retrying"
|
||||
}),
|
||||
}))
|
||||
const jobs = IndexedJobs.make([{ id: "one", labels: ["billing"], status: "running" }])
|
||||
const labels = jobs.get("one")!().labels
|
||||
const baseline = { extractions, matchChecks }
|
||||
|
||||
// Status-only change: labels extraction reads only `labels`, so it skips.
|
||||
jobs.update({ id: "one", labels, status: "retrying" })
|
||||
expect(extractions).toBe(baseline.extractions)
|
||||
expect(matchChecks).toBe(baseline.matchChecks + 1)
|
||||
expect(jobs.first("nextRetry")()?.id).toBe("one")
|
||||
|
||||
// Labels-only change: the first-match check reads only `status`, so it skips.
|
||||
jobs.update({ id: "one", labels: ["urgent"], status: "retrying" })
|
||||
expect(extractions).toBe(baseline.extractions + 1)
|
||||
expect(matchChecks).toBe(baseline.matchChecks + 1)
|
||||
expect(jobs.hasMember("labels", "urgent")).toBe(true)
|
||||
expect(jobs.hasMember("labels", "billing")).toBe(false)
|
||||
|
||||
// Equivalent update: nothing runs.
|
||||
jobs.update({ id: "one", labels: ["urgent"], status: "retrying" })
|
||||
expect(extractions).toBe(baseline.extractions + 1)
|
||||
expect(matchChecks).toBe(baseline.matchChecks + 1)
|
||||
})
|
||||
|
||||
it("normalizes numeric dependency names", () => {
|
||||
let extractions = 0
|
||||
const Numeric = Layout.collection(
|
||||
Layout.struct({ id: Layout.key(Layout.string), 0: Layout.string, other: Layout.string }),
|
||||
({ members }) => ({
|
||||
zero: members([0], (value) => {
|
||||
extractions++
|
||||
return [value[0]]
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const values = Numeric.make([{ id: "one", 0: "zero", other: "before" }])
|
||||
const baseline = extractions
|
||||
|
||||
values.update({ id: "one", 0: "zero", other: "after" })
|
||||
|
||||
expect(extractions).toBe(baseline)
|
||||
expect(values.hasMember("zero", "zero")).toBe(true)
|
||||
})
|
||||
|
||||
it("reuses precomputed field diffs during direct collection mutations", () => {
|
||||
let comparisons = 0
|
||||
const counted: Layout.Field<string> = {
|
||||
equivalent(left, right) {
|
||||
comparisons++
|
||||
return left === right
|
||||
},
|
||||
}
|
||||
const CountedJob = Layout.struct({
|
||||
id: Layout.key(Layout.string),
|
||||
value: counted,
|
||||
status: Layout.string,
|
||||
})
|
||||
const CountedJobs = Layout.collection(CountedJob, ({ first }) => ({
|
||||
changed: first(["value"], (job) => job.value === "after"),
|
||||
}))
|
||||
const one = { id: "one", value: "before", status: "idle" }
|
||||
const two = { id: "two", value: "before", status: "idle" }
|
||||
const jobs = CountedJobs.make([one, two])
|
||||
comparisons = 0
|
||||
|
||||
jobs.set([{ ...one }, { ...two, value: "after" }])
|
||||
|
||||
expect(comparisons).toBe(4)
|
||||
expect(jobs.get("one")?.()).toBe(one)
|
||||
expect(jobs.first("changed")()?.id).toBe("two")
|
||||
|
||||
comparisons = 0
|
||||
jobs.update({ ...jobs.get("one")!(), status: "busy" })
|
||||
expect(comparisons).toBe(1)
|
||||
|
||||
comparisons = 0
|
||||
jobs.modify("two", (job) => ({ ...job, value: "done" }))
|
||||
expect(comparisons).toBe(1)
|
||||
expect(jobs.first("changed")()).toBeUndefined()
|
||||
})
|
||||
|
||||
it("refreshes indexes that read a changed union discriminant", () => {
|
||||
const Row = Layout.keyedUnion({
|
||||
key: Layout.key("id", Layout.number),
|
||||
tag: "type",
|
||||
variants: {
|
||||
waiting: Layout.struct({ value: Layout.string }),
|
||||
ready: Layout.struct({ value: Layout.string }),
|
||||
},
|
||||
})
|
||||
const Rows = Layout.collection(Row, ({ members, first }) => ({
|
||||
types: members(["type"], (row) => [row.type]),
|
||||
firstReady: first(["type"], (row) => row.type === "ready"),
|
||||
}))
|
||||
const rows = Rows.make([{ id: 1, type: "waiting", value: "same" }])
|
||||
|
||||
rows.update({ id: 1, type: "ready", value: "same" })
|
||||
|
||||
expect(rows.get(1)?.().type).toBe("ready")
|
||||
expect(rows.hasMember("types", "waiting")).toBe(false)
|
||||
expect(rows.hasMember("types", "ready")).toBe(true)
|
||||
expect(rows.first("firstReady")()?.type).toBe("ready")
|
||||
})
|
||||
|
||||
it("tracks lazy member iterables while they are consumed", () => {
|
||||
const LazyJobs = Layout.collection(Job, ({ members }) => ({
|
||||
statuses: members(function* (job) {
|
||||
yield job.status
|
||||
}),
|
||||
}))
|
||||
const jobs = LazyJobs.make([{ id: "one", labels: [], status: "running" }])
|
||||
|
||||
expect(jobs.hasMember("statuses", "running")).toBe(true)
|
||||
jobs.update({ id: "one", labels: [], status: "retrying" })
|
||||
expect(jobs.hasMember("statuses", "running")).toBe(false)
|
||||
expect(jobs.hasMember("statuses", "retrying")).toBe(true)
|
||||
})
|
||||
|
||||
it("passes actual frozen values to identity and reflection projections", () => {
|
||||
const actual = Object.freeze({ id: "one", labels: [] as readonly string[], status: "running" })
|
||||
const accepted = new WeakSet<object>([actual])
|
||||
const EnumeratedJobs = Layout.collection(Job, ({ members, first }) => ({
|
||||
properties: members((job) => Object.keys(job)),
|
||||
selves: members((job) => [job]),
|
||||
frozen: members((job) => [Object.isFrozen(job)]),
|
||||
accepted: first((job) => accepted.has(job)),
|
||||
}))
|
||||
const jobs = EnumeratedJobs.make([actual])
|
||||
|
||||
expect(jobs.hasMember("properties", "id")).toBe(true)
|
||||
expect(jobs.hasMember("properties", "labels")).toBe(true)
|
||||
expect(jobs.hasMember("properties", "status")).toBe(true)
|
||||
expect(jobs.hasMember("selves", actual)).toBe(true)
|
||||
expect(jobs.hasMember("frozen", true)).toBe(true)
|
||||
expect(jobs.first("accepted")?.()).toBe(actual)
|
||||
})
|
||||
|
||||
it("applies explicit member deltas without re-extracting unchanged membership", () => {
|
||||
let extractions = 0
|
||||
const IndexedJobs = Layout.collection(Job, ({ members }) => ({
|
||||
labels: members((job) => {
|
||||
extractions++
|
||||
return job.labels
|
||||
}),
|
||||
}))
|
||||
const jobs = IndexedJobs.make([
|
||||
{ id: "one", labels: ["billing"], status: "running" },
|
||||
{ id: "two", labels: ["billing"], status: "running" },
|
||||
])
|
||||
const before = extractions
|
||||
|
||||
jobs.modify("one", (job) => ({ ...job, labels: ["urgent"] }), {
|
||||
members: { labels: { add: ["urgent"], remove: ["billing"] } },
|
||||
})
|
||||
|
||||
expect(extractions).toBe(before)
|
||||
expect(jobs.hasMember("labels", "billing")).toBe(true)
|
||||
expect(jobs.hasMember("labels", "urgent")).toBe(true)
|
||||
|
||||
jobs.modify("two", (job) => ({ ...job, labels: [] }), {
|
||||
members: { labels: { remove: ["billing"] } },
|
||||
})
|
||||
expect(jobs.hasMember("labels", "billing")).toBe(false)
|
||||
|
||||
if (false) {
|
||||
jobs.modify("one", (job) => job, {
|
||||
members: {
|
||||
labels: {
|
||||
// @ts-expect-error Member deltas require arrays so strings are not split into characters.
|
||||
add: "urgent",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it("does not commit mutations when an index callback throws", () => {
|
||||
const ThrowingJobs = Layout.collection(Job, ({ members, first }) => ({
|
||||
labels: members((job) => {
|
||||
if (job.labels.includes("boom")) throw new Error("boom")
|
||||
return job.labels
|
||||
}),
|
||||
nextRetry: first((job) => job.status === "retrying"),
|
||||
}))
|
||||
const jobs = ThrowingJobs.make([{ id: "one", labels: ["safe"], status: "running" }])
|
||||
|
||||
expect(() => jobs.update({ id: "one", labels: ["boom"], status: "retrying" })).toThrow("boom")
|
||||
expect(() => jobs.set([{ id: "two", labels: ["boom"], status: "retrying" }])).toThrow("boom")
|
||||
|
||||
expect(jobs.values()).toEqual([{ id: "one", labels: ["safe"], status: "running" }])
|
||||
expect(jobs.hasMember("labels", "safe")).toBe(true)
|
||||
expect(jobs.hasMember("labels", "boom")).toBe(false)
|
||||
expect(jobs.first("nextRetry")()).toBeUndefined()
|
||||
|
||||
expect(() => jobs.insert({ id: "two", labels: ["boom"], status: "retrying" }, { before: "missing" })).toThrow(
|
||||
"Keyed value does not exist: missing",
|
||||
)
|
||||
})
|
||||
|
||||
it("restores pending comparison state when publication throws", () => {
|
||||
const LabeledJob = Layout.struct({
|
||||
id: Layout.key(Layout.string),
|
||||
label: Layout.string,
|
||||
status: Layout.string,
|
||||
})
|
||||
const IndexedJobs = Layout.collection(LabeledJob, ({ first }) => ({
|
||||
retry: first(["status"], (job) => job.status === "retrying"),
|
||||
}))
|
||||
const jobs = IndexedJobs.make([{ id: "one", label: "before", status: "running" }])
|
||||
const slot = jobs.get("one")!
|
||||
const dispose = slot.subscribe(() => {
|
||||
throw new Error("listener failed")
|
||||
})
|
||||
|
||||
expect(() => jobs.update({ id: "one", label: "after", status: "running" })).toThrow("listener failed")
|
||||
dispose()
|
||||
const equivalent = { ...slot() }
|
||||
|
||||
expect(jobs.set([equivalent])).toBe(false)
|
||||
expect(slot()).not.toBe(equivalent)
|
||||
})
|
||||
|
||||
it("does not leak a pending comparison into reentrant publication", () => {
|
||||
const LabeledJob = Layout.struct({
|
||||
id: Layout.key(Layout.string),
|
||||
label: Layout.string,
|
||||
status: Layout.string,
|
||||
})
|
||||
const IndexedJobs = Layout.collection(LabeledJob, ({ first }) => ({
|
||||
retry: first(["status"], (job) => job.status === "retrying"),
|
||||
}))
|
||||
const jobs = IndexedJobs.make([{ id: "one", label: "before", status: "running" }])
|
||||
const slot = jobs.get("one")!
|
||||
let reentered = false
|
||||
let changed: boolean | undefined
|
||||
const dispose = slot.subscribe(() => {
|
||||
if (reentered) return
|
||||
reentered = true
|
||||
changed = jobs.set([{ ...slot() }])
|
||||
})
|
||||
|
||||
jobs.update({ id: "one", label: "after", status: "running" })
|
||||
|
||||
expect(changed).toBe(false)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("tracks first matches whose key is undefined", () => {
|
||||
const undefinedField: Layout.Field<undefined> = { equivalent: Object.is }
|
||||
const OptionalJobs = Layout.collection(
|
||||
Layout.struct({ id: Layout.key(undefinedField), status: Layout.string }),
|
||||
({ first }) => ({ retry: first(["status"], (job) => job.status === "retrying") }),
|
||||
)
|
||||
const jobs = OptionalJobs.make([{ id: undefined, status: "running" }])
|
||||
|
||||
jobs.update({ id: undefined, status: "retrying" })
|
||||
|
||||
expect(jobs.first("retry")?.()).toEqual({
|
||||
id: undefined,
|
||||
status: "retrying",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,427 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { Computed, State, Transaction, type Readable } from "../src/reactivity"
|
||||
|
||||
describe("reactivity", () => {
|
||||
it("keeps computed values lazy", () => {
|
||||
const source = State.make(1)
|
||||
let evaluations = 0
|
||||
const doubled = Computed.make(() => {
|
||||
evaluations++
|
||||
return source() * 2
|
||||
})
|
||||
|
||||
expect(evaluations).toBe(0)
|
||||
expect(doubled()).toBe(2)
|
||||
expect(doubled()).toBe(2)
|
||||
expect(evaluations).toBe(1)
|
||||
|
||||
source.set(2)
|
||||
expect(evaluations).toBe(1)
|
||||
expect(doubled()).toBe(4)
|
||||
expect(evaluations).toBe(2)
|
||||
})
|
||||
|
||||
it("propagates a diamond without glitches", () => {
|
||||
const source = State.make(1)
|
||||
const left = Computed.make(() => source() * 2)
|
||||
const right = Computed.make(() => source() * 3)
|
||||
const total = Computed.make(() => left() + right())
|
||||
const values: Array<number> = []
|
||||
const dispose = total.subscribe((value) => values.push(value))
|
||||
|
||||
source.set(2)
|
||||
|
||||
expect(values).toEqual([10])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("tracks dynamic dependencies", () => {
|
||||
const enabled = State.make(true)
|
||||
const left = State.make(1)
|
||||
const right = State.make(10)
|
||||
const selected = Computed.make(() => (enabled() ? left() : right()))
|
||||
const values: Array<number> = []
|
||||
const dispose = selected.subscribe((value) => values.push(value))
|
||||
|
||||
left.set(2)
|
||||
enabled.set(false)
|
||||
left.set(3)
|
||||
right.set(11)
|
||||
|
||||
expect(values).toEqual([2, 10, 11])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("batches transactions", () => {
|
||||
const left = State.make(1)
|
||||
const right = State.make(2)
|
||||
const total = Computed.make(() => left() + right())
|
||||
const values: Array<number> = []
|
||||
const dispose = total.subscribe((value) => values.push(value))
|
||||
|
||||
Transaction.run(() => {
|
||||
left.set(2)
|
||||
right.set(3)
|
||||
})
|
||||
|
||||
expect(values).toEqual([5])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("cuts off unchanged derived values", () => {
|
||||
const source = State.make(1)
|
||||
let parityEvaluations = 0
|
||||
let labelEvaluations = 0
|
||||
const parity = Computed.make(() => {
|
||||
parityEvaluations++
|
||||
return source() % 2
|
||||
})
|
||||
const label = Computed.make(() => {
|
||||
labelEvaluations++
|
||||
return parity() === 0 ? "even" : "odd"
|
||||
})
|
||||
const dispose = label.subscribe(() => {})
|
||||
|
||||
source.set(3)
|
||||
|
||||
expect(parityEvaluations).toBe(2)
|
||||
expect(labelEvaluations).toBe(1)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("disposes subscriptions", () => {
|
||||
const source = State.make(1)
|
||||
const values: Array<number> = []
|
||||
const dispose = source.subscribe((value) => values.push(value))
|
||||
|
||||
source.set(2)
|
||||
dispose()
|
||||
source.set(3)
|
||||
|
||||
expect(values).toEqual([2])
|
||||
})
|
||||
|
||||
it("does not track state read by subscription listeners", () => {
|
||||
const source = State.make(1)
|
||||
const unrelated = State.make(1)
|
||||
const values: Array<number> = []
|
||||
const dispose = source.subscribe((value) => {
|
||||
unrelated()
|
||||
values.push(value)
|
||||
})
|
||||
|
||||
source.set(2)
|
||||
unrelated.set(2)
|
||||
|
||||
expect(values).toEqual([2])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("does not track the state read by update", () => {
|
||||
const trigger = State.make(0)
|
||||
const updated = State.make(0)
|
||||
let runs = 0
|
||||
const dispose = trigger.subscribe(() => {
|
||||
runs++
|
||||
updated.update((value) => value + 1)
|
||||
})
|
||||
|
||||
trigger.set(1)
|
||||
updated.set(10)
|
||||
|
||||
expect(runs).toBe(1)
|
||||
expect(updated()).toBe(10)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("delivers the previous value to computed evaluation", () => {
|
||||
const source = State.make(1)
|
||||
const previousValues: Array<number | undefined> = []
|
||||
const running = Computed.make<number>((previous) => {
|
||||
previousValues.push(previous)
|
||||
return (previous ?? 0) + source()
|
||||
})
|
||||
|
||||
expect(running()).toBe(1)
|
||||
source.set(2)
|
||||
expect(running()).toBe(3)
|
||||
source.set(3)
|
||||
expect(running()).toBe(6)
|
||||
expect(previousValues).toEqual([undefined, 1, 3])
|
||||
})
|
||||
|
||||
it("detaches and reattaches dynamic dependencies", () => {
|
||||
const enabled = State.make(true)
|
||||
const left = State.make(1)
|
||||
const right = State.make(10)
|
||||
let evaluations = 0
|
||||
const selected = Computed.make(() => {
|
||||
evaluations++
|
||||
return enabled() ? left() : right()
|
||||
})
|
||||
const dispose = selected.subscribe(() => {})
|
||||
|
||||
expect(evaluations).toBe(1)
|
||||
enabled.set(false)
|
||||
expect(evaluations).toBe(2)
|
||||
|
||||
// Detached: left writes must not re-evaluate the computed.
|
||||
left.set(2)
|
||||
left.set(3)
|
||||
expect(evaluations).toBe(2)
|
||||
|
||||
// Reattached: left writes re-evaluate, right writes no longer do.
|
||||
enabled.set(true)
|
||||
expect(evaluations).toBe(3)
|
||||
expect(selected()).toBe(3)
|
||||
right.set(11)
|
||||
expect(evaluations).toBe(3)
|
||||
left.set(4)
|
||||
expect(evaluations).toBe(4)
|
||||
expect(selected()).toBe(4)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("notifies a diamond subscriber exactly once per write", () => {
|
||||
const source = State.make(1)
|
||||
const left = Computed.make(() => source() * 2)
|
||||
const right = Computed.make(() => source() * 3)
|
||||
const total = Computed.make(() => left() + right())
|
||||
let notifications = 0
|
||||
const dispose = total.subscribe(() => notifications++)
|
||||
|
||||
source.set(2)
|
||||
source.set(3)
|
||||
|
||||
expect(notifications).toBe(2)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("batches nested transactions until the outermost ends", () => {
|
||||
const left = State.make(1)
|
||||
const right = State.make(2)
|
||||
const total = Computed.make(() => left() + right())
|
||||
const values: Array<number> = []
|
||||
const dispose = total.subscribe((value) => values.push(value))
|
||||
|
||||
Transaction.run(() => {
|
||||
left.set(2)
|
||||
Transaction.run(() => {
|
||||
right.set(3)
|
||||
})
|
||||
expect(values).toEqual([])
|
||||
left.set(3)
|
||||
})
|
||||
|
||||
expect(values).toEqual([6])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("restores batch state when a transaction throws", () => {
|
||||
const source = State.make(1)
|
||||
const values: Array<number> = []
|
||||
const dispose = source.subscribe((value) => values.push(value))
|
||||
|
||||
expect(() =>
|
||||
Transaction.run(() => {
|
||||
source.set(2)
|
||||
throw new Error("boom")
|
||||
}),
|
||||
).toThrow("boom")
|
||||
|
||||
// The write that happened before the throw still flushes once the
|
||||
// transaction unwinds, and later writes are not batched.
|
||||
expect(values).toEqual([2])
|
||||
source.set(3)
|
||||
expect(values).toEqual([2, 3])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("supports disposing another subscription during notification", () => {
|
||||
const source = State.make(1)
|
||||
const first: Array<number> = []
|
||||
const second: Array<number> = []
|
||||
let disposeSecond = () => {}
|
||||
const disposeFirst = source.subscribe((value) => {
|
||||
first.push(value)
|
||||
disposeSecond()
|
||||
})
|
||||
disposeSecond = source.subscribe((value) => second.push(value))
|
||||
|
||||
source.set(2)
|
||||
source.set(3)
|
||||
|
||||
expect(first).toEqual([2, 3])
|
||||
expect(second).toEqual([])
|
||||
disposeFirst()
|
||||
})
|
||||
|
||||
it("does not deliver to a watcher disposed during revalidation", () => {
|
||||
const source = State.make(0)
|
||||
let dispose = () => {}
|
||||
const gate = Computed.make(() => {
|
||||
const value = source()
|
||||
if (value > 0) dispose()
|
||||
return value
|
||||
})
|
||||
const values: Array<number> = []
|
||||
dispose = gate.subscribe((value) => values.push(value))
|
||||
|
||||
// Revalidating the watcher re-evaluates gate, whose evaluation disposes
|
||||
// the subscription before the value could be delivered.
|
||||
source.set(1)
|
||||
source.set(2)
|
||||
|
||||
expect(values).toEqual([])
|
||||
})
|
||||
|
||||
it("supports a subscription disposing itself during notification", () => {
|
||||
const source = State.make(1)
|
||||
const values: Array<number> = []
|
||||
let dispose = () => {}
|
||||
dispose = source.subscribe((value) => {
|
||||
values.push(value)
|
||||
dispose()
|
||||
})
|
||||
|
||||
source.set(2)
|
||||
source.set(3)
|
||||
|
||||
expect(values).toEqual([2])
|
||||
})
|
||||
|
||||
it("does not track State.update reading its own value", () => {
|
||||
const counter = State.make(0)
|
||||
const source = State.make(1)
|
||||
let evaluations = 0
|
||||
const tracked = Computed.make(() => {
|
||||
evaluations++
|
||||
counter.update((value) => value)
|
||||
return source()
|
||||
})
|
||||
const dispose = tracked.subscribe(() => {})
|
||||
|
||||
expect(evaluations).toBe(1)
|
||||
counter.set(5)
|
||||
expect(evaluations).toBe(1)
|
||||
source.set(2)
|
||||
expect(evaluations).toBe(2)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("recovers tracking after a computed evaluation throws", () => {
|
||||
const shouldThrow = State.make(true)
|
||||
const source = State.make(1)
|
||||
const throwing = Computed.make(() => {
|
||||
if (shouldThrow()) throw new Error("computed boom")
|
||||
return source()
|
||||
})
|
||||
|
||||
expect(() => throwing()).toThrow("computed boom")
|
||||
|
||||
// The failed evaluation must restore the active observer so unrelated
|
||||
// graphs keep tracking correctly afterwards.
|
||||
const other = State.make(1)
|
||||
const doubled = Computed.make(() => other() * 2)
|
||||
const values: Array<number> = []
|
||||
const dispose = doubled.subscribe((value) => values.push(value))
|
||||
other.set(2)
|
||||
expect(values).toEqual([4])
|
||||
dispose()
|
||||
|
||||
shouldThrow.set(false)
|
||||
expect(throwing()).toBe(1)
|
||||
source.set(2)
|
||||
expect(throwing()).toBe(2)
|
||||
})
|
||||
|
||||
it("keeps notifying other subscribers after a listener throws", () => {
|
||||
const source = State.make(1)
|
||||
const values: Array<number> = []
|
||||
const disposeThrowing = source.subscribe(() => {
|
||||
throw new Error("listener boom")
|
||||
})
|
||||
const dispose = source.subscribe((value) => values.push(value))
|
||||
|
||||
expect(() => source.set(2)).toThrow("listener boom")
|
||||
disposeThrowing()
|
||||
source.set(3)
|
||||
|
||||
expect(values).toContain(3)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("leaves no dependency links behind when subscription initialization fails", () => {
|
||||
const source = State.make(1)
|
||||
const throwing = Computed.make(() => {
|
||||
if (source() === 1) throw new Error("init boom")
|
||||
return source()
|
||||
})
|
||||
const values: Array<number> = []
|
||||
|
||||
expect(() => throwing.subscribe((value) => values.push(value))).toThrow("init boom")
|
||||
|
||||
// The failed subscription must not stay linked to the graph.
|
||||
source.set(2)
|
||||
source.set(3)
|
||||
expect(values).toEqual([])
|
||||
|
||||
// The computed itself remains usable.
|
||||
expect(throwing()).toBe(3)
|
||||
})
|
||||
|
||||
it("propagates deep chains without recursive stack growth", () => {
|
||||
// A cold pull of an unevaluated chain necessarily nests user getter
|
||||
// frames, so each layer is evaluated as it is built. The kernel-owned
|
||||
// paths under test are dirty propagation and revalidation, which must
|
||||
// walk the full depth iteratively.
|
||||
const depth = 100_000
|
||||
const source = State.make(0)
|
||||
const chain = Array.from({ length: depth }).reduce<Readable<number>>((current) => {
|
||||
const next = Computed.make(() => current() + 1)
|
||||
next()
|
||||
return next
|
||||
}, source)
|
||||
|
||||
expect(chain()).toBe(depth)
|
||||
const values: Array<number> = []
|
||||
const dispose = chain.subscribe((value) => values.push(value))
|
||||
source.set(1)
|
||||
expect(values).toEqual([depth + 1])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("propagates wide fan-out without recursive stack growth", () => {
|
||||
const width = 50_000
|
||||
const source = State.make(0)
|
||||
const nodes = Array.from({ length: width }, () => Computed.make(() => source() + 1))
|
||||
const total = Computed.make(() => nodes.reduce((sum, node) => sum + node(), 0))
|
||||
let sink = 0
|
||||
const dispose = total.subscribe((value) => {
|
||||
sink = value
|
||||
})
|
||||
|
||||
source.set(1)
|
||||
expect(sink).toBe(width * 2)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("fails deterministically on cyclic computed dependencies", () => {
|
||||
// Regression for stackblitz/alien-signals#123: mutually dependent
|
||||
// computed graphs must throw a stable error instead of looping or
|
||||
// exhausting memory.
|
||||
const fieldA = State.make(false)
|
||||
const fieldB = State.make(false)
|
||||
const a: Readable<boolean | null> = Computed.make(() => (b() !== true ? fieldA() : null))
|
||||
const b: Readable<boolean | null> = Computed.make(() => (a() !== true ? fieldB() : null))
|
||||
|
||||
// Every read that reaches the cycle fails the same way; a failed
|
||||
// evaluation stays dirty and retries instead of serving a stale value.
|
||||
expect(() => a()).toThrow(/cycle/i)
|
||||
expect(() => b()).toThrow(/cycle/i)
|
||||
|
||||
fieldA.set(true)
|
||||
|
||||
expect(() => a()).toThrow(/cycle/i)
|
||||
})
|
||||
})
|
||||
@@ -1,100 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { createComputed, createRoot, createSignal } from "solid-js"
|
||||
import { Computed, Keyed, State, Transaction, type Readable } from "../src"
|
||||
import { useSlot, useValue } from "../src/solid"
|
||||
|
||||
describe("Solid adapter", () => {
|
||||
it("bridges Quark values into Solid ownership", () => {
|
||||
const left = State.make(1)
|
||||
const right = State.make(2)
|
||||
const total = Computed.make(() => left() + right())
|
||||
const values: number[] = []
|
||||
|
||||
createRoot((dispose) => {
|
||||
const value = useValue(total)
|
||||
createComputed(() => values.push(value()))
|
||||
|
||||
Transaction.run(() => {
|
||||
left.set(2)
|
||||
right.set(3)
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
left.set(4)
|
||||
expect(values).toEqual([3, 5])
|
||||
})
|
||||
|
||||
it("preserves function values", () => {
|
||||
const first = () => 1
|
||||
const second = () => 2
|
||||
const state = State.make(first)
|
||||
let value = first
|
||||
|
||||
createRoot((dispose) => {
|
||||
const current = useValue(state)
|
||||
createComputed(() => (value = current()))
|
||||
state.set(second)
|
||||
dispose()
|
||||
})
|
||||
|
||||
expect(value).toBe(second)
|
||||
})
|
||||
|
||||
it("accepts a plain constant key", () => {
|
||||
const keyed = Keyed.make<{ readonly id: number; readonly value: string }, number>({ key: (value) => value.id })
|
||||
keyed.set([{ id: 1, value: "one" }])
|
||||
const values: Array<string | undefined> = []
|
||||
|
||||
createRoot((dispose) => {
|
||||
const value = useSlot(keyed, 1)
|
||||
createComputed(() => values.push(value()?.value))
|
||||
keyed.modify(1, (item) => ({ ...item, value: "ONE" }))
|
||||
dispose()
|
||||
})
|
||||
|
||||
expect(values).toEqual(["one", "ONE"])
|
||||
})
|
||||
|
||||
it("switches keyed slots and releases obsolete subscriptions", () => {
|
||||
const keyed = Keyed.make<{ readonly id: number; readonly value: string }, number>({ key: (value) => value.id })
|
||||
keyed.set([
|
||||
{ id: 1, value: "one" },
|
||||
{ id: 2, value: "two" },
|
||||
])
|
||||
const active = [0, 0]
|
||||
track(keyed.get(1)!, 0)
|
||||
track(keyed.get(2)!, 1)
|
||||
const values: Array<string | undefined> = []
|
||||
|
||||
createRoot((dispose) => {
|
||||
const [key, setKey] = createSignal(1)
|
||||
const value = useSlot(keyed, key)
|
||||
createComputed(() => values.push(value()?.value))
|
||||
|
||||
keyed.modify(1, (item) => ({ ...item, value: "ONE" }))
|
||||
keyed.insert({ id: 3, value: "three" })
|
||||
setKey(2)
|
||||
keyed.modify(1, (item) => ({ ...item, value: "ignored" }))
|
||||
keyed.modify(2, (item) => ({ ...item, value: "TWO" }))
|
||||
|
||||
expect(active).toEqual([0, 1])
|
||||
dispose()
|
||||
})
|
||||
|
||||
expect(active).toEqual([0, 0])
|
||||
expect(values).toEqual(["one", "ONE", "two", "TWO"])
|
||||
|
||||
function track(slot: Readable<{ readonly id: number; readonly value: string }>, index: number) {
|
||||
const subscribe = slot.subscribe.bind(slot)
|
||||
slot.subscribe = (listener) => {
|
||||
active[index]++
|
||||
const dispose = subscribe(listener)
|
||||
return () => {
|
||||
active[index]--
|
||||
dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,7 @@ export const create = Effect.fn("OpenCode.create")(function* (options: ServerOpt
|
||||
ManagedRuntime.make(
|
||||
createEmbeddedRoutes({
|
||||
...options,
|
||||
client: options.client ?? "sdk",
|
||||
app: { ...options.app, name: options.app?.name ?? "sdk" },
|
||||
database: { path: ":memory:", ...options.database },
|
||||
}).pipe(Layer.provide(HttpServer.layerServices)),
|
||||
),
|
||||
|
||||
@@ -25,6 +25,27 @@ const sessionID = (fixture: Fixture) => fixture.sdk.Session.ID.create()
|
||||
const location = (fixture: Fixture) =>
|
||||
fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) })
|
||||
|
||||
it.live("exposes app metadata to plugins", () =>
|
||||
withEmbedded("opencode-embedded-app-", (fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const opencode = yield* fixture.sdk.OpenCode.create({
|
||||
app: { name: "test", version: "1.2.3", channel: "beta" },
|
||||
})
|
||||
const app = yield* Deferred.make<{ readonly name: string; readonly version: string; readonly channel: string }>()
|
||||
yield* opencode.plugin({
|
||||
id: `app-${crypto.randomUUID()}`,
|
||||
effect: (ctx) => Deferred.succeed(app, ctx.app).pipe(Effect.asVoid),
|
||||
})
|
||||
yield* opencode.plugin.list({ location: location(fixture) })
|
||||
expect(yield* Deferred.await(app).pipe(Effect.timeout("4 seconds"))).toEqual({
|
||||
name: "test",
|
||||
version: "1.2.3",
|
||||
channel: "beta",
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"reloads every booted Location after SDK plugin registration",
|
||||
() =>
|
||||
|
||||
@@ -12,6 +12,12 @@ import { createClient } from "@hey-api/openapi-ts"
|
||||
const opencode = path.resolve(dir, "../../opencode")
|
||||
const client = path.resolve(dir, "../../client")
|
||||
|
||||
if (!(await Bun.file(path.join(opencode, "package.json")).exists())) {
|
||||
await $`rm -rf dist`
|
||||
await $`bun tsc`
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode)
|
||||
await $`bun -e ${`
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { ServerInfo } from "../server-info"
|
||||
|
||||
export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) =>
|
||||
handlers
|
||||
.handle("health.get", () =>
|
||||
Effect.succeed({
|
||||
healthy: true as const,
|
||||
version: InstallationVersion,
|
||||
pid: process.pid,
|
||||
Effect.gen(function* () {
|
||||
const info = yield* ServerInfo.Service
|
||||
return {
|
||||
healthy: true as const,
|
||||
version: info.app.version ?? "unknown",
|
||||
pid: process.pid,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle("health.stop", () => Effect.succeed({ accepted: false })),
|
||||
|
||||
@@ -4,7 +4,13 @@ import { Observability } from "@opencode-ai/util/observability"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const ServerOptions = Schema.Struct({
|
||||
client: Schema.optional(Schema.String),
|
||||
app: Schema.optional(
|
||||
Schema.Struct({
|
||||
name: Schema.optional(Schema.String),
|
||||
version: Schema.optional(Schema.String),
|
||||
channel: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
hostname: Schema.optional(Schema.String),
|
||||
port: Schema.optional(
|
||||
Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(65_535)),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as ServerProcess from "./process"
|
||||
|
||||
import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
||||
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
|
||||
@@ -50,7 +49,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
|
||||
// Request fibers may continue inbound trace context, but must not inherit the server startup parent.
|
||||
yield* bound.http
|
||||
.serve(
|
||||
dispatch(password, status, application, shutdown).pipe(
|
||||
dispatch(password, status, application, shutdown, options.app?.version ?? "unknown").pipe(
|
||||
HttpMiddleware.cors({ allowedOrigins: isAllowedCorsOrigin, maxAge: 86_400 }),
|
||||
),
|
||||
HttpMiddleware.logger,
|
||||
@@ -153,6 +152,7 @@ function dispatch(
|
||||
status: Status.Interface,
|
||||
application: Ref.Ref<Option.Option<App>>,
|
||||
shutdown: Deferred.Deferred<void>,
|
||||
version: string,
|
||||
): App {
|
||||
const auth = ServerAuth.Config.of({ password: Option.some(password), username: "opencode" })
|
||||
return Effect.gen(function* () {
|
||||
@@ -166,7 +166,7 @@ function dispatch(
|
||||
: undefined
|
||||
if (lifecycle !== undefined) {
|
||||
if (!(yield* authorizedRequest(request, auth))) return unauthorized()
|
||||
return yield* control(request, lifecycle, status, () => Deferred.doneUnsafe(shutdown, Effect.void))
|
||||
return yield* control(request, lifecycle, status, () => Deferred.doneUnsafe(shutdown, Effect.void), version)
|
||||
}
|
||||
const state = yield* status.current
|
||||
const app = yield* Ref.get(application)
|
||||
@@ -189,8 +189,9 @@ const control = Effect.fnUntraced(function* (
|
||||
route: "health" | "stop",
|
||||
status: Status.Interface,
|
||||
stop: () => void,
|
||||
version: string,
|
||||
) {
|
||||
if (route === "health") return yield* healthResponse(status)
|
||||
if (route === "health") return yield* healthResponse(status, version)
|
||||
const body = yield* request.json.pipe(Effect.option)
|
||||
const input = Option.isSome(body) ? Schema.decodeUnknownOption(ServiceStatus.StopRequest)(body.value) : Option.none()
|
||||
if (Option.isNone(input)) return HttpServerResponse.jsonUnsafe({ code: "invalid_request" }, { status: 400 })
|
||||
@@ -210,10 +211,10 @@ const control = Effect.fnUntraced(function* (
|
||||
return HttpServerResponse.jsonUnsafe({ accepted })
|
||||
})
|
||||
|
||||
const healthResponse = Effect.fnUntraced(function* (status: Status.Interface) {
|
||||
const healthResponse = Effect.fnUntraced(function* (status: Status.Interface, version: string) {
|
||||
const state = yield* status.current
|
||||
return HttpServerResponse.jsonUnsafe(
|
||||
{ healthy: true, version: InstallationVersion, pid: process.pid },
|
||||
{ healthy: true, version, pid: process.pid },
|
||||
{
|
||||
status: state.type === "ready" ? 200 : state.type === "failed" ? 500 : 503,
|
||||
headers: state.type === "starting" || state.type === "stopping" ? { "retry-after": "1" } : undefined,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { App } from "@opencode-ai/core/app"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -6,7 +7,6 @@ import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventLogger } from "@opencode-ai/core/event-logger"
|
||||
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
|
||||
import { Observability } from "@opencode-ai/util/observability"
|
||||
import { Client } from "@opencode-ai/util/client"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { CommandV2 } from "@opencode-ai/core/command"
|
||||
@@ -15,12 +15,9 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
||||
import { Pty } from "@opencode-ai/core/pty"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
||||
import { SessionGenerateNode } from "@opencode-ai/core/session/generate-node"
|
||||
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
|
||||
import { SessionTitle } from "@opencode-ai/core/session/title"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
@@ -88,7 +85,7 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
const pluginRuntimeCell = PluginRuntime.makeCell()
|
||||
const replacements: LayerNode.Replacements = [
|
||||
[Database.node, Database.configured(options.database)],
|
||||
[Client.node, Client.configured(options.client)],
|
||||
[App.node, App.configured(options.app)],
|
||||
[ModelsDev.node, ModelsDev.configured(options.models)],
|
||||
[Watcher.node, Watcher.configured({ enabled: options.fs?.filewatcher })],
|
||||
[FileSystemSearch.node, FileSystemSearch.configured({ fff: options.fs?.fff })],
|
||||
@@ -105,6 +102,15 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
[CommandV2.node, CommandV2.configured({ gitbash: options.windows?.gitbash })],
|
||||
[Pty.node, Pty.configured({ gitbash: options.windows?.gitbash })],
|
||||
[Shell.node, Shell.configured({ gitbash: options.windows?.gitbash })],
|
||||
[
|
||||
MCP.node,
|
||||
MCP.configured({
|
||||
clientInfo: {
|
||||
name: options.app?.name ?? "opencode",
|
||||
version: options.app?.version ?? "unknown",
|
||||
},
|
||||
}),
|
||||
],
|
||||
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
|
||||
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
|
||||
]
|
||||
@@ -112,7 +118,7 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
? Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend"))
|
||||
const simulation = yield* simulationReplacements()
|
||||
const simulation = yield* simulationReplacements({ version: App.make(options.app).version })
|
||||
return AppNodeBuilder.build(applicationServices, [...replacements, ...simulation])
|
||||
}),
|
||||
)
|
||||
@@ -123,7 +129,7 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
const services = Layer.succeedContext(context)
|
||||
const requestServices = Layer.merge(
|
||||
Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service, WellKnown.Service)(context)),
|
||||
ServerInfo.layer(serviceURLs),
|
||||
ServerInfo.layer(serviceURLs, options.app),
|
||||
)
|
||||
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
|
||||
Layer.provide(handlers.pipe(Layer.provide(services))),
|
||||
@@ -133,7 +139,14 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
Layer.provide(authorizationLayer),
|
||||
Layer.provide(schemaErrorLayer),
|
||||
Layer.provide(auth),
|
||||
Layer.provide(Observability.layer(options.observability).pipe(Layer.provide(Client.layer(options.client)))),
|
||||
Layer.provide(
|
||||
Observability.layer({
|
||||
...options.observability,
|
||||
client: options.app?.name,
|
||||
version: options.app?.version,
|
||||
channel: options.app?.channel,
|
||||
}),
|
||||
),
|
||||
HttpRouter.provideRequest(requestServices),
|
||||
Layer.provideMerge(services),
|
||||
Layer.provideMerge(HttpRouter.layer),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Context, Layer } from "effect"
|
||||
import { networkInterfaces } from "node:os"
|
||||
import type { ServerOptions } from "./options"
|
||||
|
||||
export class Service extends Context.Service<Service, { readonly urls: () => ReadonlyArray<string> }>()(
|
||||
"@opencode-ai/server/ServerInfo",
|
||||
) {}
|
||||
export class Service extends Context.Service<
|
||||
Service,
|
||||
{ readonly urls: () => ReadonlyArray<string>; readonly app: NonNullable<ServerOptions["app"]> }
|
||||
>()("@opencode-ai/server/ServerInfo") {}
|
||||
|
||||
export function layer(urls: () => ReadonlyArray<string>) {
|
||||
return Layer.succeed(Service, Service.of({ urls }))
|
||||
export function layer(urls: () => ReadonlyArray<string>, app: ServerOptions["app"] = {}) {
|
||||
return Layer.succeed(Service, Service.of({ urls, app }))
|
||||
}
|
||||
|
||||
export function connectionURLs(value: string, requestedHostname?: string) {
|
||||
|
||||
@@ -12,3 +12,9 @@ test("rejects ports outside the valid range", () => {
|
||||
expect(Option.isNone(decode({ port: -1 }))).toBe(true)
|
||||
expect(Option.isNone(decode({ port: 65_536 }))).toBe(true)
|
||||
})
|
||||
|
||||
test("accepts optional app metadata", () => {
|
||||
expect(
|
||||
Option.getOrThrow(decode({ app: { name: "sdk", version: "1.2.3", channel: "beta" } })).app,
|
||||
).toEqual({ name: "sdk", version: "1.2.3", channel: "beta" })
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ it.live("allows browser preflight requests without credentials", () =>
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
})
|
||||
const response = yield* Effect.promise(() =>
|
||||
@@ -38,5 +39,6 @@ it.live("allows browser preflight requests without credentials", () =>
|
||||
|
||||
expect(health.status).toBe(200)
|
||||
expect(health.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
|
||||
expect(yield* Effect.promise(() => health.json())).toMatchObject({ version: "test-version" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -26,6 +26,19 @@ describe("prompt input v2 interaction machine", () => {
|
||||
expect(closed.state.popover).toEqual({ type: "closed" })
|
||||
})
|
||||
|
||||
test("completes nested slash command names", () => {
|
||||
const open = transitionPromptInputV2(
|
||||
createPromptInputV2InteractionState(),
|
||||
{ type: "input.changed", value: "/review/" },
|
||||
persisted(),
|
||||
)
|
||||
const item = { ...command, label: "/review/nested" }
|
||||
const selected = transitionPromptInputV2(open.state, { type: "popover.select", item }, persisted("/review/"))
|
||||
|
||||
expect(open.state.popover).toEqual({ type: "command-inline", query: "review/" })
|
||||
expect(selected.commands).toContainEqual({ type: "draft.setText", value: "/review/nested " })
|
||||
})
|
||||
|
||||
test("opens context completion at the cursor", () => {
|
||||
const value = "alpha @sr omega"
|
||||
const input = persisted(value)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user