Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01635b20ee | ||
|
|
e5cf3ad3c2 | ||
|
|
82da3320ba | ||
|
|
aa553dea94 | ||
|
|
0c9cad86ec | ||
|
|
69910f361c | ||
|
|
7bafbb309a | ||
|
|
ec26d78450 | ||
|
|
49e8d324dd | ||
|
|
60b9ba4db7 | ||
|
|
a1a401dc9b | ||
|
|
2f1547ebaf | ||
|
|
62da1e7682 | ||
|
|
28a06e52fc | ||
|
|
5a5d981c4e | ||
|
|
62a7781e04 | ||
|
|
2d03cf4a9a | ||
|
|
4025d30b24 | ||
|
|
dd3c443d8c | ||
|
|
4aaa9f9e30 | ||
|
|
31c805bc97 | ||
|
|
94f2ed1b84 | ||
|
|
86dc66eae9 | ||
|
|
9814dc6526 | ||
|
|
f09c859974 | ||
|
|
1fcdb0246a | ||
|
|
340ad9e9ed | ||
|
|
76d814e747 | ||
|
|
9941e70d29 | ||
|
|
2afb812620 | ||
|
|
ad2fc1ce4b | ||
|
|
5acc368ef4 | ||
|
|
e2dc89c6f3 | ||
|
|
e1581183ff | ||
|
|
f195c952fc | ||
|
|
34b1045a01 | ||
|
|
aa9956d46d | ||
|
|
cec5a752bb | ||
|
|
0f0a4be253 |
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -85,7 +85,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -121,7 +121,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -148,7 +148,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -170,7 +170,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -194,7 +194,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "0.0.0",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
@@ -214,7 +214,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -275,7 +275,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"drizzle-orm": "catalog:",
|
||||
@@ -330,7 +330,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -344,7 +344,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -374,7 +374,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -390,7 +390,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -403,7 +403,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -421,7 +421,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -478,6 +478,7 @@
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/scheduled": "1.5.2",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@types/ws": "8.18.1",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"ai": "catalog:",
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
@@ -519,6 +520,7 @@
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"ws": "8.21.0",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
@@ -559,7 +561,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -575,9 +577,9 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.2.15",
|
||||
"@opentui/keymap": ">=0.2.15",
|
||||
"@opentui/solid": ">=0.2.15",
|
||||
"@opentui/core": ">=0.2.16",
|
||||
"@opentui/keymap": ">=0.2.16",
|
||||
"@opentui/solid": ">=0.2.16",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
@@ -597,7 +599,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -612,7 +614,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -625,7 +627,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.14.50",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/stats-core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -648,7 +650,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.14.50",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -667,7 +669,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.14.50",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -707,7 +709,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -756,7 +758,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -824,9 +826,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.2.15",
|
||||
"@opentui/keymap": "0.2.15",
|
||||
"@opentui/solid": "0.2.15",
|
||||
"@opentui/core": "0.2.16",
|
||||
"@opentui/keymap": "0.2.16",
|
||||
"@opentui/solid": "0.2.16",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -1716,23 +1718,23 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.2.15", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.15", "@opentui/core-darwin-x64": "0.2.15", "@opentui/core-linux-arm64": "0.2.15", "@opentui/core-linux-x64": "0.2.15", "@opentui/core-win32-arm64": "0.2.15", "@opentui/core-win32-x64": "0.2.15" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-YGHttdZWScMcSvtYgZkLR6VhUO1OoUiQzwYjZgIusf5eCkPLD8PapH+PTMVqAiX16CHO6JxfMlkHv5qDiHAccQ=="],
|
||||
"@opentui/core": ["@opentui/core@0.2.16", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.16", "@opentui/core-darwin-x64": "0.2.16", "@opentui/core-linux-arm64": "0.2.16", "@opentui/core-linux-x64": "0.2.16", "@opentui/core-win32-arm64": "0.2.16", "@opentui/core-win32-x64": "0.2.16" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-4vWN15Zc3nsXJlOiHhhpqkBXD+wrNFKxCPtiTiillZYDRre+XsZogVTOOGUDwaBIC23OSxq7imezLmmtShVBEA=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-s25f9GmZd6wxNM5ExRmwwnLT+NLCKxnTWuO9aObOlqsXfLMGHQZrb6YwgAn/PSTua98KmH7GJCVWdPgZ/P+0RQ=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aFb2Yp+oqDu3h6VCWi7xpQ9yjpKSQcROzGGfHgqC6Nd3U+uiLfPJBkmiI87iK0opCggCFj5TkKI004050DmGjg=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-GyaipN+nOcEr8rcTO2mqKTGmOBk0C300I69fLtubD3BadHcMI1DVNlQrcf/J1mkQEuMYbmBTi/1hT1ybWGr2Mw=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KimiHE0j7EsTB5P8doW0lr1eH5iZKLPKWQO+tmy1VcdYr/TzqhdHSvGuJXrZvfTFi9/rV57Eq0d7964Ri9O0vQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-h+uyufselGT4afKMP8Lg4yUl5Kp+DJBlhu3XpWXhphE5Pnq5+f0uGBr4P+34CNcWxMsDnvagSQLFRCS4rGrOWA=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-4fCwRCfTtUgS/5QcSEkSuBjgQymSOUWXgrXG2ycrf3Swi0QhKDA/pVjwLrUJ6eF+/8mQyQSEV72T8MxMO3M2qg=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-jx+NImPq4wSp3Apfe7tlixiEJNnRyECTRJRWhGF6ZJz4PwFfgK2UHZKYR0DZHbV8nYawoDNQPJDXEWcoZShnMg=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-KgQBGjiucw4e7gM+R8qOzHWBFhjCY1IfCrGjW3Wzxv2hKUlL+mPhelaeJwnEqtNxMUdVTYjlwlu3IHxslXMJWQ=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2SQQLvf3sgmToxrNika9AdcccKrjPJEn5jW6sSv0oEixNBzUzW41vSZZG4LM/V3lL8eg0LoYDnRZeKLB4gwSqQ=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-C6WqEI3VkXatXraMgSFXZjEXq0pzURGjRpFAJZYmuVDmpqE57o7E80Np2UkdZ6m5kpJDt4mRyu3krc/P825iNQ=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-SVMVgnC7LVEm+yVZKdmmhRBj/xAT94PanT+UCcHxaCWK+OLmv/AX+ohHq2m0odup6iXcEqj+7mAltO9fgJLFIg=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-kCX3CMTns6DMCFDNTDV4sjmBKyA/iEvzaVhl/jYi4JRIVT2zcy1lo+lhXT5mPgYHmJZu8Uye6j3Zi3c7Z2Me5A=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.2.15", "", { "dependencies": { "@opentui/core": "0.2.15" }, "peerDependencies": { "@opentui/react": "0.2.15", "@opentui/solid": "0.2.15", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wqQp6y7P2jZZJiOMwupxjGryuSWCs+njjglwW/xny9J17gomBmUvTIcIIWNG0Jv+EGO9ScBzCScGlwBHFhHyYw=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.2.16", "", { "dependencies": { "@opentui/core": "0.2.16" }, "peerDependencies": { "@opentui/react": "0.2.16", "@opentui/solid": "0.2.16", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-YBLQfNLbU2kx49bjEY9rrFoNlvIoi5qNJfRcOt6frvnR3C6MLl0/8hZY+vMQ2PEQWeEiNejFnl1lQw+z4Nk2FQ=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.2.15", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.15", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-CViepAjsCWXwrLndMt+qlLo7cooVX7DXwSJHNizw7mfrRJtOPzSYJZCIk1vF4IJTWffCHygoYMe3uSeKvzAcbw=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.2.16", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.16", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2Q+v1PPpXXr+sALi9Aj6I5Jvo7xDfbmstYjRLL7lW3Hghh9i7ONQKpt/gyDDRbhSsYrhxKYTNenF9OxgoXkTHg=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -5138,7 +5140,7 @@
|
||||
|
||||
"write-file-atomic": ["write-file-atomic@7.0.1", "", { "dependencies": { "signal-exit": "^4.0.1" } }, "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg=="],
|
||||
|
||||
"ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
|
||||
|
||||
@@ -5496,6 +5498,8 @@
|
||||
|
||||
"@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
|
||||
"@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
||||
|
||||
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
||||
|
||||
"@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
||||
@@ -5958,6 +5962,8 @@
|
||||
|
||||
"miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="],
|
||||
|
||||
"miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
||||
|
||||
"miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
|
||||
|
||||
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||
@@ -6074,6 +6080,8 @@
|
||||
|
||||
"storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||
|
||||
"storybook/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
||||
|
||||
"storybook-solidjs-vite/vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="],
|
||||
|
||||
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
+2
-2
@@ -209,8 +209,8 @@ const ingestConfig = new sst.Linkable("LakeIngestConfig", {
|
||||
const ingestService = new sst.aws.Service("LakeIngestService", {
|
||||
cluster: lakeCluster,
|
||||
architecture: "arm64",
|
||||
cpu: "0.5 vCPU",
|
||||
memory: "1 GB",
|
||||
cpu: "1 vCPU",
|
||||
memory: "4 GB",
|
||||
image: {
|
||||
context: ".",
|
||||
dockerfile: "packages/stats/server/Dockerfile",
|
||||
|
||||
+1
-3
@@ -5,10 +5,8 @@ export const domain = (() => {
|
||||
})()
|
||||
|
||||
export const zoneID = "430ba34c138cfb5360826c4909f99be8"
|
||||
// Dev owns the shared AWS lake/stats infra for all non-production stages.
|
||||
export const awsStage = $app.stage === "production" ? "production" : "dev"
|
||||
// Temporarily omit AWS infra so SST removes the lake/stats resources.
|
||||
export const deployAws = false
|
||||
export const deployAws = $app.stage === awsStage
|
||||
|
||||
new cloudflare.RegionalHostname("RegionalHostname", {
|
||||
hostname: domain,
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-FT8N4SBP7OmVu73OwNyPJvBoxFd2+IXzNnFubB8y6J0=",
|
||||
"aarch64-linux": "sha256-rGst01voY1/qr/fIMEgfL2zep/+W2RhjJh5pKs+/P3s=",
|
||||
"aarch64-darwin": "sha256-+oTdR3CuR88Ra3QQsDn7ixNwJf0YjOxySTSiRsPPUOg=",
|
||||
"x86_64-darwin": "sha256-3ZkB83KWtp6N7u9xhzmCouv27UuisXYvr2SBap4k++E="
|
||||
"x86_64-linux": "sha256-KFVkxs0jtN1EXvwuxFMOlWg6BDr8L6K/wJZ/N73p8cI=",
|
||||
"aarch64-linux": "sha256-7OLglRBBt5prwb9e0I0iMTXi3MrDfDI+8xcDrxtq/JI=",
|
||||
"aarch64-darwin": "sha256-Q7iE6xJyomDnvqUgsHh+FlCnJFI6vK0zJSfI9BBYitQ=",
|
||||
"x86_64-darwin": "sha256-9lVsq2Bw74Vgphg6GSo/HxCtDXUyabxcS/vE1Um/Hfw="
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -10,7 +10,7 @@
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
"dev:stats": "bun run --cwd packages/stats/app dev",
|
||||
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
@@ -38,9 +38,9 @@
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.2.15",
|
||||
"@opentui/keymap": "0.2.15",
|
||||
"@opentui/solid": "0.2.15",
|
||||
"@opentui/core": "0.2.16",
|
||||
"@opentui/keymap": "0.2.16",
|
||||
"@opentui/solid": "0.2.16",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
type Component,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
@@ -40,7 +41,7 @@ import { NotificationProvider } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
||||
import { TerminalProvider } from "@/context/terminal"
|
||||
import DirectoryLayout from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
@@ -48,11 +49,6 @@ import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import { ServersProvider } from "./context/servers"
|
||||
|
||||
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "prod") {
|
||||
document.body.classList.remove("text-12-regular")
|
||||
document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]")
|
||||
}
|
||||
|
||||
const HomeRoute = lazy(() => import("@/pages/home"))
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
|
||||
@@ -97,9 +93,26 @@ function QueryProvider(props: ParentProps) {
|
||||
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
function BodyDesignClass() {
|
||||
const settings = useSettings()
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const enabled = settings.general.newLayoutDesigns()
|
||||
document.body.classList.toggle("text-12-regular", !enabled)
|
||||
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
|
||||
document.body.classList.toggle("text-[13px]", enabled)
|
||||
document.body.classList.toggle("font-[440]", enabled)
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function AppShellProviders(props: ParentProps) {
|
||||
return (
|
||||
<SettingsProvider>
|
||||
<BodyDesignClass />
|
||||
<PermissionProvider>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider>
|
||||
|
||||
@@ -79,8 +79,6 @@ import { pathKey } from "@/utils/path-key"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { displayName } from "@/pages/layout/helpers"
|
||||
|
||||
const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
interface PromptInputProps {
|
||||
class?: string
|
||||
variant?: "dock" | "new-session"
|
||||
@@ -1456,7 +1454,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
/>
|
||||
<Switch>
|
||||
<Match when={USE_V2_INPUT}>
|
||||
<Match when={settings.general.newLayoutDesigns()}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<DockShellForm
|
||||
data-component={newSession() ? "session-new-composer" : "session-composer"}
|
||||
@@ -1528,7 +1526,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
"select-text": true,
|
||||
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]": true,
|
||||
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-base": true,
|
||||
"[&_[data-type=file]]:text-syntax-property": true,
|
||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||
"font-mono!": store.mode === "shell",
|
||||
|
||||
@@ -45,8 +45,6 @@ const OPEN_APPS = [
|
||||
"sublime-text",
|
||||
] as const
|
||||
|
||||
const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
type OpenApp = (typeof OPEN_APPS)[number]
|
||||
type OS = "macos" | "windows" | "linux" | "unknown"
|
||||
|
||||
@@ -157,11 +155,11 @@ export function SessionHeader() {
|
||||
})
|
||||
const hotkey = createMemo(() => command.keybind("file.open"))
|
||||
const os = createMemo(() => detectOS(platform))
|
||||
const isDesktopV2 = platform.platform === "desktop" && USE_V2_TITLEBAR
|
||||
const search = createMemo(() => (isDesktopV2 ? settings.general.showSearch() : true))
|
||||
const tree = createMemo(() => (isDesktopV2 ? settings.general.showFileTree() : true))
|
||||
const term = createMemo(() => (isDesktopV2 ? settings.general.showTerminal() : true))
|
||||
const status = createMemo(() => (isDesktopV2 ? settings.general.showStatus() : true))
|
||||
const isDesktopV2 = createMemo(() => platform.platform === "desktop" && settings.general.newLayoutDesigns())
|
||||
const search = createMemo(() => (isDesktopV2() ? settings.general.showSearch() : true))
|
||||
const tree = createMemo(() => (isDesktopV2() ? settings.general.showFileTree() : true))
|
||||
const term = createMemo(() => (isDesktopV2() ? settings.general.showTerminal() : true))
|
||||
const status = createMemo(() => (isDesktopV2() ? settings.general.showStatus() : true))
|
||||
|
||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||
finder: true,
|
||||
|
||||
@@ -399,6 +399,18 @@ export const SettingsGeneral: Component = () => {
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.newLayoutDesigns.title")}
|
||||
description={language.t("settings.general.row.newLayoutDesigns.description")}
|
||||
>
|
||||
<div data-action="settings-new-layout-designs">
|
||||
<Switch
|
||||
checked={settings.general.newLayoutDesigns()}
|
||||
onChange={(checked) => settings.general.setNewLayoutDesigns(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
@@ -802,7 +814,7 @@ export const SettingsGeneral: Component = () => {
|
||||
|
||||
<DisplaySection />
|
||||
|
||||
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<Show when={desktop()}>
|
||||
<AdvancedSection />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,6 @@ const legacyTitlebarHeight = 40
|
||||
const v2TitlebarHeight = 44
|
||||
const minTitlebarZoom = 0.25
|
||||
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
|
||||
const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
|
||||
|
||||
@@ -75,6 +74,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const params = useParams()
|
||||
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
|
||||
|
||||
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
||||
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
||||
@@ -85,7 +85,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
|
||||
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
|
||||
const minHeight = () => {
|
||||
const height = USE_V2_TITLEBAR ? v2TitlebarHeight : legacyTitlebarHeight
|
||||
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
|
||||
if (mac()) return `${height / zoom()}px`
|
||||
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
|
||||
return undefined
|
||||
@@ -119,7 +119,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const canBack = createMemo(() => history.index > 0)
|
||||
const canForward = createMemo(() => history.index < history.stack.length - 1)
|
||||
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
||||
const nav = createMemo(() => (USE_V2_TITLEBAR ? settings.general.showNavigation() : true))
|
||||
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
|
||||
const updateState = createMemo<TitlebarUpdatePillState>(() => {
|
||||
const version = props.update?.version()
|
||||
return {
|
||||
@@ -222,8 +222,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
<header
|
||||
classList={{
|
||||
"shrink-0 relative overflow-hidden flex flex-row": true,
|
||||
"h-11 bg-v2-background-bg-deep": USE_V2_TITLEBAR,
|
||||
"h-10 bg-background-base": !USE_V2_TITLEBAR,
|
||||
"h-11 bg-v2-background-bg-deep": useV2Titlebar(),
|
||||
"h-10 bg-background-base": !useV2Titlebar(),
|
||||
}}
|
||||
style={{
|
||||
"min-height": minHeight(),
|
||||
@@ -239,7 +239,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
onDblClick={maximize}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={USE_V2_TITLEBAR}>
|
||||
<Match when={useV2Titlebar()}>
|
||||
{(_) => {
|
||||
const serverSync = useServerSync()
|
||||
const navigate = useNavigate()
|
||||
@@ -490,7 +490,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
project={projectForSession(tab.info, projects(), projectByID())}
|
||||
directory={tab.dir}
|
||||
onClose={() => tabsStoreActions.removeTab(tab.href)}
|
||||
hideClose={tabsEnriched().length < 2}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -737,14 +736,13 @@ function TabNavItem(props: {
|
||||
title: string
|
||||
project?: LocalProject
|
||||
directory: string
|
||||
hideClose?: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const match = useMatch(() => props.href)
|
||||
const isActive = () => !!match()
|
||||
return (
|
||||
<div
|
||||
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] pl-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
data-active={isActive()}
|
||||
>
|
||||
<a
|
||||
@@ -755,7 +753,7 @@ function TabNavItem(props: {
|
||||
<span class="text-clip leading-5">{props.title}</span>
|
||||
</a>
|
||||
|
||||
<div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
|
||||
<div class="absolute not-group-hover:not-group-data-[active=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
|
||||
<div
|
||||
class="absolute inset-0 bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
|
||||
style={{
|
||||
@@ -766,7 +764,7 @@ function TabNavItem(props: {
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100"
|
||||
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100 z-10"
|
||||
onClick={props.onClose}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface Settings {
|
||||
editToolPartsExpanded: boolean
|
||||
showSessionProgressBar: boolean
|
||||
showCustomAgents: boolean
|
||||
newLayoutDesigns?: boolean
|
||||
}
|
||||
updates: {
|
||||
startup: boolean
|
||||
@@ -54,6 +55,7 @@ export interface Settings {
|
||||
export const monoDefault = "System Mono"
|
||||
export const sansDefault = "System Sans"
|
||||
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
||||
export const newLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
const monoFallback =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
@@ -242,6 +244,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setShowCustomAgents(value: boolean) {
|
||||
setStore("general", "showCustomAgents", value)
|
||||
},
|
||||
newLayoutDesigns: withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault),
|
||||
setNewLayoutDesigns(value: boolean) {
|
||||
setStore("general", "newLayoutDesigns", value)
|
||||
},
|
||||
},
|
||||
updates: {
|
||||
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
|
||||
|
||||
@@ -791,6 +791,8 @@ export const dict = {
|
||||
"settings.general.row.showSessionProgressBar.title": "Show session progress bar",
|
||||
"settings.general.row.showSessionProgressBar.description":
|
||||
"Display the animated progress bar at the top of the session when the agent is working",
|
||||
"settings.general.row.newLayoutDesigns.title": "New layout and designs",
|
||||
"settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI",
|
||||
"settings.general.row.pinchZoom.title": "Pinch to zoom",
|
||||
"settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
|
||||
|
||||
|
||||
+109
-104
@@ -31,8 +31,8 @@ import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { useServers } from "@/context/servers"
|
||||
import { useSettings } from "@/context/settings"
|
||||
|
||||
const USE_HOME_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
const HOME_SESSION_LIMIT = 15
|
||||
const HOME_ROW =
|
||||
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] border-0 bg-transparent text-left text-v2-text-text-muted transition-colors duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
@@ -52,8 +52,12 @@ type HomeSessionGroup = {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
if (USE_HOME_DESIGN) return <HomeDesign />
|
||||
return <LegacyHome />
|
||||
const settings = useSettings()
|
||||
return (
|
||||
<Show when={settings.general.newLayoutDesigns()} fallback={<LegacyHome />}>
|
||||
<HomeDesign />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeDesign() {
|
||||
@@ -68,13 +72,11 @@ function HomeDesign() {
|
||||
const [state, setState] = createStore({ search: "", project: undefined as string | undefined })
|
||||
|
||||
const projects = createMemo(() => layout.projects.list())
|
||||
const selectedProject = createMemo(
|
||||
() => projects().find((project) => project.worktree === state.project) ?? projects()[0],
|
||||
)
|
||||
const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.project))
|
||||
const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])]
|
||||
const projectDirectories = createMemo(() => {
|
||||
const project = selectedProject()
|
||||
if (!project) return []
|
||||
if (!project) return [...projects().flatMap((project) => directories(project))]
|
||||
return directories(project)
|
||||
})
|
||||
const search = createMemo(() => state.search.trim())
|
||||
@@ -89,8 +91,8 @@ function HomeDesign() {
|
||||
const projectByID = createMemo(
|
||||
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
||||
)
|
||||
const records = createMemo(() =>
|
||||
[
|
||||
const records = createMemo(() => {
|
||||
return [
|
||||
...new Map(
|
||||
projectDirectories()
|
||||
.flatMap((directory) => sortedRootSessions(sync.child(directory, { bootstrap: false })[0], Date.now()))
|
||||
@@ -112,8 +114,8 @@ function HomeDesign() {
|
||||
if (!value) return true
|
||||
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(value)
|
||||
})
|
||||
.slice(0, HOME_SESSION_LIMIT),
|
||||
)
|
||||
.slice(0, HOME_SESSION_LIMIT)
|
||||
})
|
||||
const groups = createMemo(() => groupSessions(records(), language))
|
||||
|
||||
function selectProject(directory: string) {
|
||||
@@ -220,7 +222,7 @@ function HomeDesign() {
|
||||
aria-label={language.t("sidebar.project.recentSessions")}
|
||||
>
|
||||
<Show
|
||||
when={selectedProject()}
|
||||
when={projectDirectories().length > 0}
|
||||
fallback={
|
||||
<HomeEmptyState
|
||||
icon="folder-add-left"
|
||||
@@ -298,8 +300,8 @@ function HomeProjectColumn(props: {
|
||||
const layout = useLayout()
|
||||
const projects = createMemo(() => layout.projects.list())
|
||||
return (
|
||||
<aside class="flex min-w-0 flex-col lg:pt-[52px]" aria-label={props.language.t("home.projects")}>
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-3">
|
||||
<aside class="flex min-w-0 flex-col lg:pt-[52px] gap-4" aria-label={props.language.t("home.projects")}>
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
|
||||
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
@@ -311,8 +313,8 @@ function HomeProjectColumn(props: {
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
/>
|
||||
</div>
|
||||
<For
|
||||
each={servers.list()}
|
||||
<Show
|
||||
when={servers.list().length > 1}
|
||||
fallback={
|
||||
<ProjectList
|
||||
projects={projects()}
|
||||
@@ -328,61 +330,63 @@ function HomeProjectColumn(props: {
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(server) => {
|
||||
const key = ServerConnection.key(server)
|
||||
const healthy = () => !!servers.health[key]?.healthy
|
||||
const [open, setOpen] = createSignal(true)
|
||||
<For each={servers.list()}>
|
||||
{(server) => {
|
||||
const key = ServerConnection.key(server)
|
||||
const healthy = () => !!servers.health[key]?.healthy
|
||||
const [open, setOpen] = createSignal(true)
|
||||
|
||||
return (
|
||||
<div class="mt-4 max-h-[min(572px,calc(100vh_-_300px))] min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div class="relative h-7 group">
|
||||
<button
|
||||
class="w-full h-full px-1.5 gap-2 flex flex-row items-center hover:not-disabled:bg-v2-overlay-simple-overlay-hover rounded-[4px]"
|
||||
disabled={!healthy()}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<div class="size-4 flex items-center justify-center">
|
||||
<ServerHealthIndicator health={servers.health[key]} />
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<span>{server.displayName ?? new URL(server.http.url).host}</span>
|
||||
<Show when={healthy()}>
|
||||
<IconV2
|
||||
name="outline-chevron-down"
|
||||
class="text-v2-icon-icon-muted data-[open=false]:-rotate-90"
|
||||
data-open={open()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<IconButtonV2
|
||||
class="absolute right-1 inset-y-1 opacity-0 group-hover:opacity-100"
|
||||
name="out"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="outline-dots" class="text-v2-icon-icon-muted" />}
|
||||
/>
|
||||
return (
|
||||
<div class="max-h-[min(572px,calc(100vh_-_300px))] min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div class="relative h-7 group">
|
||||
<button
|
||||
class="w-full h-full px-1.5 gap-2 flex flex-row items-center hover:not-disabled:bg-v2-overlay-simple-overlay-hover rounded-[4px]"
|
||||
disabled={!healthy()}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<div class="size-4 flex items-center justify-center">
|
||||
<ServerHealthIndicator health={servers.health[key]} />
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<span>{server.displayName ?? new URL(server.http.url).host}</span>
|
||||
<Show when={healthy()}>
|
||||
<IconV2
|
||||
name="outline-chevron-down"
|
||||
class="text-v2-icon-icon-muted data-[open=false]:-rotate-90"
|
||||
data-open={open()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<IconButtonV2
|
||||
class="absolute right-1 inset-y-1 opacity-0 group-hover:opacity-100"
|
||||
name="out"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="outline-dots" class="text-v2-icon-icon-muted" />}
|
||||
/>
|
||||
</div>
|
||||
<Show when={healthy() && open()}>
|
||||
<div class="h-px bg-v2-border-border-base mx-3 my-1" />
|
||||
<ProjectList
|
||||
projects={projects()}
|
||||
selectedProject={props.selectedProject}
|
||||
onSelectedProjectChange={props.selectProject}
|
||||
onChooseProject={props.chooseProject}
|
||||
openNewSession={props.openNewSession}
|
||||
editProject={props.editProject}
|
||||
closeProject={props.closeProject}
|
||||
clearNotifications={props.clearNotifications}
|
||||
unseenCount={props.unseenCount}
|
||||
language={props.language}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={healthy() && open()}>
|
||||
<div class="h-px bg-v2-border-border-base mx-3 my-1" />
|
||||
<ProjectList
|
||||
projects={projects()}
|
||||
selectedProject={props.selectedProject}
|
||||
onSelectedProjectChange={props.selectProject}
|
||||
onChooseProject={props.chooseProject}
|
||||
openNewSession={props.openNewSession}
|
||||
editProject={props.editProject}
|
||||
closeProject={props.closeProject}
|
||||
clearNotifications={props.clearNotifications}
|
||||
unseenCount={props.unseenCount}
|
||||
language={props.language}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<div class="mt-4 flex min-w-0 flex-col gap-1">
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
||||
@@ -416,12 +420,14 @@ function HomeProjectRow(props: {
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const name = createMemo(() => displayName(props.project))
|
||||
const [menuOpen, setMenuOpen] = createSignal(false)
|
||||
|
||||
return (
|
||||
<div class="group/project relative flex h-8 min-w-0 items-center rounded-[6px] hover:bg-v2-overlay-simple-overlay-hover focus-within:bg-v2-overlay-simple-overlay-hover">
|
||||
<div class="group/project relative flex h-8 min-w-0 items-center rounded-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-project-row"
|
||||
class={`${HOME_PROJECT_NAV_ROW} pr-16`}
|
||||
class={`${HOME_PROJECT_NAV_ROW} pr-16 peer`}
|
||||
classList={{ "bg-v2-overlay-simple-overlay-hover": props.selected }}
|
||||
data-selected={props.selected ? "" : undefined}
|
||||
aria-current={props.selected ? "page" : undefined}
|
||||
@@ -430,7 +436,37 @@ function HomeProjectRow(props: {
|
||||
<HomeProjectAvatar project={props.project} />
|
||||
<span>{name()}</span>
|
||||
</button>
|
||||
<div class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 group-focus-within/project:opacity-100">
|
||||
<div
|
||||
class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 peer-focus-visible:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
|
||||
data-menu={menuOpen()}
|
||||
>
|
||||
<MenuV2 gutter={4} modal={false} placement="bottom-end" open={menuOpen()} onOpenChange={setMenuOpen}>
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
data-action="home-project-menu"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="outline-dots" />}
|
||||
aria-label={props.language.t("common.moreOptions")}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.Item onSelect={() => props.openNewSession(props.project.worktree)}>
|
||||
{props.language.t("command.session.new")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => props.editProject(props.project)}>
|
||||
{props.language.t("common.edit")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item disabled={props.unseenCount === 0} onSelect={() => props.clearNotifications(props.project)}>
|
||||
{props.language.t("sidebar.project.clearNotifications")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={() => props.closeProject(props.project.worktree)}>
|
||||
{props.language.t("common.close")}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<IconButtonV2
|
||||
data-action="home-project-new-session"
|
||||
variant="ghost-muted"
|
||||
@@ -442,37 +478,6 @@ function HomeProjectRow(props: {
|
||||
props.openNewSession(props.project.worktree)
|
||||
}}
|
||||
/>
|
||||
<MenuV2 gutter={4} modal={false} placement="bottom-end">
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
data-action="home-project-menu"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="menu" />}
|
||||
aria-label={props.language.t("common.moreOptions")}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.Item onSelect={() => props.openNewSession(props.project.worktree)}>
|
||||
<Icon name="new-session" size="small" />
|
||||
{props.language.t("command.session.new")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => props.editProject(props.project)}>
|
||||
<Icon name="edit" size="small" />
|
||||
{props.language.t("common.edit")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item disabled={props.unseenCount === 0} onSelect={() => props.clearNotifications(props.project)}>
|
||||
<Icon name="circle-check" size="small" />
|
||||
{props.language.t("sidebar.project.clearNotifications")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={() => props.closeProject(props.project.worktree)}>
|
||||
<Icon name="close" size="small" />
|
||||
{props.language.t("common.close")}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -561,7 +566,7 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
icon="edit"
|
||||
class="h-7 px-2 text-v2-text-text-muted [font-weight:530]"
|
||||
class="h-7 px-2 text-v2-text-text-muted"
|
||||
onClick={onNewSession()}
|
||||
>
|
||||
{language.t("command.session.new")}
|
||||
|
||||
+160
-160
@@ -89,8 +89,6 @@ import {
|
||||
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
|
||||
import { SidebarContent } from "./layout/sidebar-shell"
|
||||
|
||||
const USE_NEW_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const [store, setStore, , ready] = persisted(
|
||||
Persist.global("layout.page", ["layout.page.v1"]),
|
||||
@@ -129,6 +127,7 @@ export default function Layout(props: ParentProps) {
|
||||
const command = useCommand()
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
const newDesign = createMemo(() => settings.general.newLayoutDesigns())
|
||||
const initialDirectory = decode64(params.dir)
|
||||
const location = useLocation()
|
||||
const route = createMemo(() => {
|
||||
@@ -154,7 +153,7 @@ export default function Layout(props: ParentProps) {
|
||||
const currentDir = createMemo(() => route().dir)
|
||||
|
||||
const [state, setState] = createStore({
|
||||
autoselect: !initialDirectory && !USE_NEW_DESIGN,
|
||||
autoselect: !initialDirectory && !newDesign(),
|
||||
busyWorkspaces: {} as Record<string, boolean>,
|
||||
hoverProject: undefined as string | undefined,
|
||||
scrollSessionKey: undefined as string | undefined,
|
||||
@@ -1142,7 +1141,7 @@ export default function Layout(props: ParentProps) {
|
||||
},
|
||||
]
|
||||
|
||||
if (!USE_NEW_DESIGN)
|
||||
if (!newDesign())
|
||||
Array.from({ length: 9 }, (_, i) => {
|
||||
const index = i
|
||||
const number = index + 1
|
||||
@@ -1821,7 +1820,7 @@ export default function Layout(props: ParentProps) {
|
||||
createEffect(() => {
|
||||
document.documentElement.style.setProperty(
|
||||
"--dialog-left-margin",
|
||||
USE_NEW_DESIGN ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
|
||||
newDesign() ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2363,179 +2362,180 @@ export default function Layout(props: ParentProps) {
|
||||
/>
|
||||
)
|
||||
|
||||
if (USE_NEW_DESIGN) {
|
||||
return (
|
||||
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
return (
|
||||
<Show
|
||||
when={!newDesign()}
|
||||
fallback={
|
||||
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
{autoselecting() ?? ""}
|
||||
<Titlebar update={titlebarUpdate} />
|
||||
<main
|
||||
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"m-2 mt-0 rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden": !!params.id || !params.dir,
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
<Toast.Region />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
{autoselecting() ?? ""}
|
||||
<Titlebar update={titlebarUpdate} />
|
||||
<main
|
||||
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"m-2 mt-0 rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden": !!params.id || !params.dir,
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
<Toast.Region />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
{autoselecting() ?? ""}
|
||||
<Titlebar update={titlebarUpdate} />
|
||||
<Show when={updateVersion() !== undefined}>
|
||||
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 min-w-0 flex">
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<div class="size-full relative overflow-x-hidden">
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-desktop"
|
||||
classList={{
|
||||
"hidden xl:block": true,
|
||||
"absolute inset-y-0 left-0": true,
|
||||
"z-10": true,
|
||||
}}
|
||||
style={{ width: `${side()}px` }}
|
||||
ref={(el) => {
|
||||
setState("nav", el)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
aim.reset()
|
||||
if (!sidebarHovering()) return
|
||||
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
|
||||
</nav>
|
||||
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div
|
||||
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
|
||||
style={{ left: `${side()}px` }}
|
||||
onPointerDown={() => setState("sizing", true)}
|
||||
>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
onResize={(w) => {
|
||||
setState("sizing", true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setState("sizing", false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||
style={{ left: "calc(4rem + 12px)" }}
|
||||
/>
|
||||
|
||||
<div class="xl:hidden">
|
||||
<div
|
||||
classList={{
|
||||
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
|
||||
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
|
||||
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
|
||||
}}
|
||||
/>
|
||||
<Show when={updateVersion() !== undefined}>
|
||||
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 min-w-0 flex">
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<div class="size-full relative overflow-x-hidden">
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-mobile"
|
||||
data-component="sidebar-nav-desktop"
|
||||
classList={{
|
||||
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
||||
"translate-x-0": layout.mobileSidebar.opened(),
|
||||
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||
"hidden xl:block": true,
|
||||
"absolute inset-y-0 left-0": true,
|
||||
"z-10": true,
|
||||
}}
|
||||
style={{ width: `${side()}px` }}
|
||||
ref={(el) => {
|
||||
setState("nav", el)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
aim.reset()
|
||||
if (!sidebarHovering()) return
|
||||
|
||||
arm()
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sidebarContent(true)}
|
||||
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"absolute inset-0": true,
|
||||
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
|
||||
"z-20": true,
|
||||
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
|
||||
!state.sizing,
|
||||
}}
|
||||
style={{
|
||||
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
|
||||
}}
|
||||
>
|
||||
<main
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div
|
||||
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
|
||||
style={{ left: `${side()}px` }}
|
||||
onPointerDown={() => setState("sizing", true)}
|
||||
>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
onResize={(w) => {
|
||||
setState("sizing", true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setState("sizing", false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||
style={{ left: "calc(4rem + 12px)" }}
|
||||
/>
|
||||
|
||||
<div class="xl:hidden">
|
||||
<div
|
||||
classList={{
|
||||
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
|
||||
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
|
||||
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
|
||||
}}
|
||||
/>
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-mobile"
|
||||
classList={{
|
||||
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
||||
"translate-x-0": layout.mobileSidebar.opened(),
|
||||
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sidebarContent(true)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||
"absolute inset-0": true,
|
||||
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
|
||||
"z-20": true,
|
||||
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
|
||||
!state.sizing,
|
||||
}}
|
||||
style={{
|
||||
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
<main
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
|
||||
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
onMouseMove={disarm}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
aim.reset()
|
||||
}}
|
||||
onPointerDown={disarm}
|
||||
onMouseLeave={() => {
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<Show when={peekProject()}>
|
||||
<SidebarPanel project={peekProject} merged={false} />
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
|
||||
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
onMouseMove={disarm}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
aim.reset()
|
||||
}}
|
||||
onPointerDown={disarm}
|
||||
onMouseLeave={() => {
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<Show when={peekProject()}>
|
||||
<SidebarPanel project={peekProject} merged={false} />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
|
||||
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
style={{ left: `calc(4rem + ${panel()}px)` }}
|
||||
>
|
||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
|
||||
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
style={{ left: `calc(4rem + ${panel()}px)` }}
|
||||
>
|
||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
</div>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
<Toast.Region />
|
||||
</div>
|
||||
<Toast.Region />
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ const emptyFollowups: FollowupItem[] = []
|
||||
|
||||
type ChangeMode = "git" | "branch" | "turn"
|
||||
type VcsMode = "git" | "branch"
|
||||
const USE_NEW_SESSION_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
type SessionHistoryWindowInput = {
|
||||
sessionID: () => string | undefined
|
||||
@@ -198,6 +197,7 @@ export default function Page() {
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||
const location = useLocation()
|
||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
|
||||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
@@ -265,7 +265,7 @@ export default function Page() {
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const size = createSizing()
|
||||
const isV2NewSessionPage = () =>
|
||||
shouldUseV2NewSessionPage({ channel: import.meta.env.VITE_OPENCODE_CHANNEL, sessionID: params.id })
|
||||
shouldUseV2NewSessionPage({ newLayoutDesigns: newSessionDesign(), sessionID: params.id })
|
||||
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened() && !isV2NewSessionPage())
|
||||
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened() && !isV2NewSessionPage())
|
||||
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
|
||||
@@ -1798,14 +1798,14 @@ export default function Page() {
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Show when={USE_NEW_SESSION_DESIGN} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
|
||||
<Show when={newSessionDesign()} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
|
||||
<NewSessionDesignView>{composerRegion("inline")}</NewSessionDesignView>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<Show when={params.id || !USE_NEW_SESSION_DESIGN}>{composerRegion("dock")}</Show>
|
||||
<Show when={params.id || !newSessionDesign()}>{composerRegion("dock")}</Show>
|
||||
|
||||
<Show when={desktopReviewOpen()}>
|
||||
<div onPointerDown={() => size.start()}>
|
||||
|
||||
@@ -2,13 +2,13 @@ import { describe, expect, test } from "bun:test"
|
||||
import { shouldUseV2NewSessionPage } from "./new-session-layout"
|
||||
|
||||
describe("shouldUseV2NewSessionPage", () => {
|
||||
test("keeps prod session pages on the legacy layout", () => {
|
||||
expect(shouldUseV2NewSessionPage({ channel: "prod", sessionID: "ses_123" })).toBe(false)
|
||||
expect(shouldUseV2NewSessionPage({ channel: "prod" })).toBe(false)
|
||||
test("keeps disabled pages on the legacy layout", () => {
|
||||
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false, sessionID: "ses_123" })).toBe(false)
|
||||
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false })).toBe(false)
|
||||
})
|
||||
|
||||
test("uses the v2 layout only for non-prod new-session pages", () => {
|
||||
expect(shouldUseV2NewSessionPage({ channel: "dev" })).toBe(true)
|
||||
expect(shouldUseV2NewSessionPage({ channel: "dev", sessionID: "ses_123" })).toBe(false)
|
||||
test("uses the v2 layout only for enabled new-session pages", () => {
|
||||
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: true })).toBe(true)
|
||||
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: true, sessionID: "ses_123" })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export function shouldUseV2NewSessionPage(input: { channel?: "dev" | "beta" | "prod"; sessionID?: string }) {
|
||||
return input.channel !== "prod" && !input.sessionID
|
||||
export function shouldUseV2NewSessionPage(input: { newLayoutDesigns: boolean; sessionID?: string }) {
|
||||
return input.newLayoutDesigns && !input.sessionID
|
||||
}
|
||||
|
||||
@@ -28,8 +28,6 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S
|
||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
const USE_DESKTOP_V2 = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
|
||||
|
||||
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
|
||||
@@ -60,7 +58,7 @@ export function SessionSidePanel(props: {
|
||||
const { sessionKey, tabs, view, params } = useSessionLayout()
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const desktopV2 = () => platform.platform === "desktop" && USE_DESKTOP_V2
|
||||
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
|
||||
const shown = createMemo(() => (desktopV2() ? settings.general.showFileTree() : true))
|
||||
|
||||
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||
@@ -205,7 +203,7 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={isDesktop() && !(import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && !params.id)}>
|
||||
<Show when={isDesktop() && !(settings.general.newLayoutDesigns() && !params.id)}>
|
||||
<aside
|
||||
id="review-panel"
|
||||
aria-label={language.t("session.panel.reviewAndFiles")}
|
||||
|
||||
@@ -20,8 +20,6 @@ import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
const USE_DESKTOP_V2 = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
export type SessionCommandContext = {
|
||||
navigateMessageByOffset: (offset: number) => void
|
||||
setActiveMessage: (message: UserMessage | undefined) => void
|
||||
@@ -72,7 +70,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
})
|
||||
const activeFileTab = tabState.activeFileTab
|
||||
const closableTab = tabState.closableTab
|
||||
const desktopV2 = () => platform.platform === "desktop" && USE_DESKTOP_V2
|
||||
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
|
||||
const shown = () => (desktopV2() ? settings.general.showFileTree() : true)
|
||||
|
||||
const messages = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -64,11 +64,12 @@ function LimitsGraph(props: { href: string }) {
|
||||
{ id: "glm-5.1", name: "GLM-5.1", req: 880, d: "100ms" },
|
||||
{ id: "qwen3.7-max", name: "Qwen3.7 Max", req: 950, d: "280ms" },
|
||||
{ id: "kimi-k2.6", name: "Kimi K2.6", req: 1150, d: "150ms" },
|
||||
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 1290, d: "150ms" },
|
||||
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "150ms" },
|
||||
{ id: "qwen3.6-plus", name: "Qwen3.6 Plus", req: 3300, d: "280ms" },
|
||||
{ id: "minimax-m2.7", name: "MiniMax M2.7", req: 3400, d: "300ms" },
|
||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "200ms" },
|
||||
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus", req: 10200, d: "360ms" },
|
||||
{ id: "mimo-v2.5", name: "MiMo-V2.5", req: 30100, d: "340ms" },
|
||||
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", req: 31650, d: "340ms" },
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { IconCopy, IconCheck } from "~/component/icon"
|
||||
import { Key } from "@opencode-ai/console-core/key.js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { formatDateUTC, formatDateForTable } from "../../common"
|
||||
import styles from "./key-section.module.css"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
@@ -124,7 +123,6 @@ export function KeySection() {
|
||||
<th>{i18n.t("workspace.keys.table.name")}</th>
|
||||
<th>{i18n.t("workspace.keys.table.key")}</th>
|
||||
<th>{i18n.t("workspace.keys.table.createdBy")}</th>
|
||||
<th>{i18n.t("workspace.keys.table.lastUsed")}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -156,9 +154,6 @@ export function KeySection() {
|
||||
</Show>
|
||||
</td>
|
||||
<td data-slot="key-user-email">{key.email}</td>
|
||||
<td data-slot="key-last-used" title={key.timeUsed ? formatDateUTC(key.timeUsed) : undefined}>
|
||||
{key.timeUsed ? formatDateForTable(key.timeUsed) : "-"}
|
||||
</td>
|
||||
<td data-slot="key-actions">
|
||||
<form action={removeKey} method="post">
|
||||
<input type="hidden" name="id" value={key.id} />
|
||||
|
||||
@@ -506,7 +506,6 @@ export async function handler(
|
||||
if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
|
||||
let topPriority = Infinity
|
||||
const providers = allProviders
|
||||
.filter((provider) => !provider.disabled)
|
||||
.filter((provider) => provider.weight !== 0)
|
||||
.filter((provider) => !retry.excludeProviders.includes(provider.id))
|
||||
.filter((provider) => {
|
||||
@@ -990,10 +989,6 @@ export async function handler(
|
||||
return undefined
|
||||
})(),
|
||||
}),
|
||||
db
|
||||
.update(KeyTable)
|
||||
.set({ timeUsed: sql`now()` })
|
||||
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
|
||||
...(() => {
|
||||
if (billingSource === "subscription") {
|
||||
const plan = authInfo.billing.subscription!.plan
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||
import { FreeUsageLimitError } from "./error"
|
||||
import { logger } from "./logger"
|
||||
import { buildRateLimitKey, getRedis } from "./redis"
|
||||
@@ -22,7 +20,6 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
|
||||
|
||||
const ip = !rawIp.length ? "unknown" : rawIp
|
||||
const now = Date.now()
|
||||
const lifetimeInterval = ""
|
||||
const dailyInterval = rateLimit ? `${buildYYYYMMDD(now)}${modelId.substring(0, 2)}` : buildYYYYMMDD(now)
|
||||
const retryAfter = getRetryAfterDay(now)
|
||||
const redis = getRedis()
|
||||
@@ -32,33 +29,12 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
|
||||
|
||||
return {
|
||||
check: async () => {
|
||||
const [counts, rows] = await Promise.all([
|
||||
redis.mget<(string | number | null)[]>(isDefaultModel ? [lifetimeKey, dailyKey] : [dailyKey]).catch(() => []),
|
||||
Database.use((tx) =>
|
||||
tx
|
||||
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
|
||||
.from(IpRateLimitTable)
|
||||
.where(
|
||||
and(
|
||||
eq(IpRateLimitTable.ip, ip),
|
||||
isDefaultModel
|
||||
? inArray(IpRateLimitTable.interval, [lifetimeInterval, dailyInterval])
|
||||
: inArray(IpRateLimitTable.interval, [dailyInterval]),
|
||||
),
|
||||
),
|
||||
),
|
||||
])
|
||||
const redisLifetimeCount = isDefaultModel ? Number(counts[0] ?? 0) : 0
|
||||
const redisDailyCount = Number(counts[isDefaultModel ? 1 : 0] ?? 0)
|
||||
const databaseLifetimeCount = rows.find((r) => r.interval === lifetimeInterval)?.count ?? 0
|
||||
const databaseDailyCount = rows.find((r) => r.interval === dailyInterval)?.count ?? 0
|
||||
const lifetimeCount = Math.max(redisLifetimeCount, databaseLifetimeCount)
|
||||
const dailyCount = Math.max(redisDailyCount, databaseDailyCount)
|
||||
const counts = await redis.mget<(string | number | null)[]>(isDefaultModel ? [lifetimeKey, dailyKey] : [dailyKey])
|
||||
const lifetimeCount = isDefaultModel ? Number(counts[0] ?? 0) : 0
|
||||
const dailyCount = Number(counts[isDefaultModel ? 1 : 0] ?? 0)
|
||||
logger.debug(`rate limit lifetime: ${lifetimeCount}, daily: ${dailyCount}`)
|
||||
|
||||
isNew = isDefaultModel && lifetimeCount < dailyLimit * 7
|
||||
if (isDefaultModel && databaseLifetimeCount > redisLifetimeCount)
|
||||
await redis.set(lifetimeKey, databaseLifetimeCount).catch(() => {})
|
||||
|
||||
if ((isNew && dailyCount >= dailyLimit * 2) || (!isNew && dailyCount >= dailyLimit))
|
||||
throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], retryAfter)
|
||||
@@ -68,18 +44,7 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
|
||||
pipeline.incr(dailyKey)
|
||||
pipeline.expire(dailyKey, retryAfter)
|
||||
if (isNew) pipeline.incr(lifetimeKey)
|
||||
await Promise.all([
|
||||
pipeline.exec().catch(() => {}),
|
||||
Database.use((tx) =>
|
||||
tx
|
||||
.insert(IpRateLimitTable)
|
||||
.values([
|
||||
{ ip, interval: dailyInterval, count: 1 },
|
||||
...(isNew ? [{ ip, interval: lifetimeInterval, count: 1 }] : []),
|
||||
])
|
||||
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
|
||||
),
|
||||
])
|
||||
await pipeline.exec()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Database, eq, and, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { KeyRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||
import { RateLimitError } from "./error"
|
||||
import { buildRateLimitKey, getRedis } from "./redis"
|
||||
import { i18n } from "~/i18n"
|
||||
import { localeFromRequest } from "~/lib/language"
|
||||
|
||||
@@ -19,26 +18,20 @@ export function createRateLimiter(
|
||||
.replace(/[^0-9]/g, "")
|
||||
.substring(0, 12)
|
||||
const interval = `${modelId.substring(0, 27)}-${yyyyMMddHHmm}`
|
||||
const redis = getRedis()
|
||||
const key = buildRateLimitKey("key", zenApiKey, interval)
|
||||
|
||||
return {
|
||||
check: async () => {
|
||||
const rows = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ interval: KeyRateLimitTable.interval, count: KeyRateLimitTable.count })
|
||||
.from(KeyRateLimitTable)
|
||||
.where(and(eq(KeyRateLimitTable.key, zenApiKey), eq(KeyRateLimitTable.interval, interval))),
|
||||
).then((rows) => rows[0])
|
||||
const count = rows?.count ?? 0
|
||||
const count = Number((await redis.mget<(string | number | null)[]>([key]))[0] ?? 0)
|
||||
|
||||
if (count >= LIMIT) throw new RateLimitError(dict["zen.api.error.rateLimitExceeded"], 60)
|
||||
},
|
||||
track: async () => {
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.insert(KeyRateLimitTable)
|
||||
.values({ key: zenApiKey, interval, count: 1 })
|
||||
.onDuplicateKeyUpdate({ set: { count: sql`${KeyRateLimitTable.count} + 1` } }),
|
||||
)
|
||||
const pipeline = redis.pipeline()
|
||||
pipeline.incr(key)
|
||||
pipeline.expire(key, 60)
|
||||
await pipeline.exec()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "0.0.0",
|
||||
"version": "1.15.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -108,7 +108,7 @@ export const layer = Layer.effect(
|
||||
if (!repo) return { id: ID.global, directory: input, vcs: undefined }
|
||||
|
||||
const previous = yield* cached(repo.store)
|
||||
const id = previous ?? (yield* root(repo))
|
||||
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
|
||||
|
||||
return {
|
||||
previous,
|
||||
|
||||
@@ -5,11 +5,16 @@ import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Project.defaultLayer)
|
||||
|
||||
function remoteID(remote: string) {
|
||||
return Project.ID.make(Hash.fast(`git-remote:${remote}`))
|
||||
}
|
||||
|
||||
function abs(value: string) {
|
||||
return AbsolutePath.make(value)
|
||||
}
|
||||
@@ -86,7 +91,7 @@ describe("ProjectV2.resolve", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("uses root commit when origin exists", () =>
|
||||
it.live("prefers normalized origin over root commit", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -97,13 +102,36 @@ describe("ProjectV2.resolve", () => {
|
||||
|
||||
const result = yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
|
||||
expect(result.id).toBe(remoteID("github.com/Acme/App"))
|
||||
expect(result.id).not.toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
|
||||
expect(result.directory).toBe(yield* real(tmp.path))
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("uses root commit when local remote exists", () =>
|
||||
it.live("normalizes ssh and https remotes to the same id", () =>
|
||||
Effect.gen(function* () {
|
||||
const ssh = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const https = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
|
||||
yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://github.com/owner/repo.git" }))
|
||||
const project = yield* Project.Service
|
||||
|
||||
const a = yield* project.resolve(abs(ssh.path))
|
||||
const b = yield* project.resolve(abs(https.path))
|
||||
|
||||
expect(a.id).toBe(remoteID("github.com/owner/repo"))
|
||||
expect(b.id).toBe(a.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("ignores file remotes and falls back to root commit", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -118,7 +146,7 @@ describe("ProjectV2.resolve", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("prefers previous cached id over origin", () =>
|
||||
it.live("returns previous cached id from common dir", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -131,7 +159,7 @@ describe("ProjectV2.resolve", () => {
|
||||
const result = yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(result.previous).toBe(Project.ID.make("old-id"))
|
||||
expect(result.id).toBe(Project.ID.make("old-id"))
|
||||
expect(result.id).toBe(remoteID("github.com/owner/repo"))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -185,7 +213,7 @@ describe("ProjectV2.resolve", () => {
|
||||
|
||||
expect(result.directory).toBe(yield* real(worktree))
|
||||
expect(result.previous).toBe(Project.ID.make("old-id"))
|
||||
expect(result.id).toBe(Project.ID.make("old-id"))
|
||||
expect(result.id).toBe(remoteID("github.com/owner/repo"))
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.15.10"
|
||||
version = "1.15.11"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"name": "@opencode-ai/llm",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -122,6 +122,7 @@
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/scheduled": "1.5.2",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@types/ws": "8.18.1",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"ai": "catalog:",
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
@@ -163,6 +164,7 @@
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"ws": "8.21.0",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:"
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { Effect } from "effect"
|
||||
import { ACPNextSession } from "./session"
|
||||
import { ACPNextPermission } from "./permission"
|
||||
import {
|
||||
duplicateRunningToolUpdate,
|
||||
errorToolUpdate,
|
||||
@@ -22,7 +23,8 @@ import {
|
||||
|
||||
const log = Log.create({ service: "acp-next-event" })
|
||||
|
||||
type Connection = Pick<AgentSideConnection, "sessionUpdate">
|
||||
type Connection = Pick<AgentSideConnection, "sessionUpdate"> &
|
||||
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
|
||||
type GlobalEventEnvelope = {
|
||||
payload?: Event
|
||||
}
|
||||
@@ -40,6 +42,7 @@ export class Subscription {
|
||||
private readonly abort = new AbortController()
|
||||
private readonly shellSnapshots = new Map<string, string>()
|
||||
private readonly toolStarts = new Set<string>()
|
||||
private readonly permission: ACPNextPermission.Handler
|
||||
private started = false
|
||||
|
||||
constructor(
|
||||
@@ -48,7 +51,9 @@ export class Subscription {
|
||||
connection: Connection
|
||||
session: ACPNextSession.Interface
|
||||
},
|
||||
) {}
|
||||
) {
|
||||
this.permission = new ACPNextPermission.Handler(input)
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.started) return
|
||||
@@ -65,6 +70,9 @@ export class Subscription {
|
||||
|
||||
async handle(event: Event) {
|
||||
switch (event.type) {
|
||||
case "permission.asked":
|
||||
this.permission.handle(event)
|
||||
return
|
||||
case "message.part.updated":
|
||||
return this.handlePartUpdated(event)
|
||||
case "message.part.delta":
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { applyPatch } from "diff"
|
||||
import { exists, readText } from "@/util/filesystem"
|
||||
import type { ACPNextSession } from "./session"
|
||||
import { toLocations, toToolKind, type ToolInput } from "./tool"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const log = Log.create({ service: "acp-next-permission" })
|
||||
|
||||
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
|
||||
type Reply = "once" | "always" | "reject"
|
||||
type Connection = Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
|
||||
|
||||
const permissionOptions: PermissionOption[] = [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
||||
{ optionId: "always", kind: "allow_always", name: "Always allow" },
|
||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||
]
|
||||
|
||||
export class Handler {
|
||||
private readonly queues = new Map<string, Promise<void>>()
|
||||
|
||||
constructor(
|
||||
private readonly input: {
|
||||
sdk: OpencodeClient
|
||||
connection: Connection
|
||||
session: ACPNextSession.Interface
|
||||
},
|
||||
) {}
|
||||
|
||||
handle(event: PermissionEvent) {
|
||||
const permission = event.properties
|
||||
const previous = this.queues.get(permission.sessionID) ?? Promise.resolve()
|
||||
const next = previous
|
||||
.then(() => this.process(event))
|
||||
.catch((error: unknown) => {
|
||||
log.error("failed to handle permission", { error, permissionID: permission.id })
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.queues.get(permission.sessionID) === next) {
|
||||
this.queues.delete(permission.sessionID)
|
||||
}
|
||||
})
|
||||
this.queues.set(permission.sessionID, next)
|
||||
}
|
||||
|
||||
private async process(event: PermissionEvent) {
|
||||
const permission = event.properties
|
||||
const session = await Effect.runPromise(this.input.session.tryGet(permission.sessionID))
|
||||
if (!session) return
|
||||
|
||||
if (!this.input.connection.requestPermission) {
|
||||
log.error("ACP connection cannot request permission", {
|
||||
permissionID: permission.id,
|
||||
sessionID: permission.sessionID,
|
||||
})
|
||||
await this.reply(permission.id, "reject", session.cwd)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await this.input.connection
|
||||
.requestPermission({
|
||||
sessionId: permission.sessionID,
|
||||
toolCall: {
|
||||
toolCallId: permission.tool?.callID ?? permission.id,
|
||||
status: "pending",
|
||||
title: permission.permission,
|
||||
rawInput: permission.metadata,
|
||||
kind: toToolKind(permission.permission),
|
||||
locations: toLocations(permission.permission, permission.metadata),
|
||||
},
|
||||
options: permissionOptions,
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
log.error("failed to request permission from ACP", {
|
||||
error,
|
||||
permissionID: permission.id,
|
||||
sessionID: permission.sessionID,
|
||||
})
|
||||
await this.reply(permission.id, "reject", session.cwd)
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!result) return
|
||||
|
||||
const reply = selectedReply(result)
|
||||
if (reply !== "once" && reply !== "always") {
|
||||
await this.reply(permission.id, "reject", session.cwd)
|
||||
return
|
||||
}
|
||||
|
||||
if (permission.permission === "edit") {
|
||||
await this.writeProposedEdit(session.id, permission.metadata).catch((error: unknown) => {
|
||||
log.error("failed to write proposed edit through ACP", {
|
||||
error,
|
||||
permissionID: permission.id,
|
||||
sessionID: permission.sessionID,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
await this.reply(permission.id, reply, session.cwd)
|
||||
}
|
||||
|
||||
private async reply(requestID: string, reply: Reply, directory: string) {
|
||||
await this.input.sdk.permission.reply({
|
||||
requestID,
|
||||
reply,
|
||||
directory,
|
||||
})
|
||||
}
|
||||
|
||||
private async writeProposedEdit(sessionId: string, metadata: ToolInput) {
|
||||
const filepath = stringValue(metadata.filepath)
|
||||
const diff = stringValue(metadata.diff)
|
||||
if (!filepath || !diff || !this.input.connection.writeTextFile) return
|
||||
|
||||
const content = (await exists(filepath)) ? await readText(filepath) : ""
|
||||
const next = applyPatch(content, diff)
|
||||
if (next === false) {
|
||||
log.error("Failed to apply unified diff (context mismatch)")
|
||||
return
|
||||
}
|
||||
|
||||
void this.input.connection.writeTextFile({
|
||||
sessionId,
|
||||
path: filepath,
|
||||
content: next,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function selectedReply(result: RequestPermissionResponse): Reply {
|
||||
if (result.outcome.outcome !== "selected") return "reject"
|
||||
if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId
|
||||
return "reject"
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
export * as ACPNextPermission from "./permission"
|
||||
@@ -69,7 +69,8 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/AC
|
||||
|
||||
export function make(input: {
|
||||
sdk: OpencodeClient
|
||||
connection?: Pick<AgentSideConnection, "sessionUpdate">
|
||||
connection?: Pick<AgentSideConnection, "sessionUpdate"> &
|
||||
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
|
||||
directory?: Directory.Interface
|
||||
session?: ACPNextSession.Interface
|
||||
eventSubscription?: (subscription: ACPNextEvent.Subscription) => void
|
||||
|
||||
@@ -74,7 +74,7 @@ export function toLocations(toolName: string, input: ToolInput): ToolCallLocatio
|
||||
case "read":
|
||||
case "edit":
|
||||
case "write":
|
||||
return locationFrom(input.filePath)
|
||||
return locationFrom(input.filePath ?? input.filepath)
|
||||
|
||||
case "grep":
|
||||
case "glob":
|
||||
|
||||
@@ -460,6 +460,13 @@ export const ProvidersLoginCommand = effectCmd({
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === "google-vertex") {
|
||||
yield* Prompt.log.info(
|
||||
"Note: this API key cannot be used with Anthropic models.\n" +
|
||||
"Use `gcloud auth application-default login` or set `GOOGLE_APPLICATION_CREDENTIALS`.",
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === "opencode") {
|
||||
yield* Prompt.log.info("Create an api key at https://opencode.ai/auth")
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import { DialogThemeList } from "@tui/component/dialog-theme-list"
|
||||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { DialogAgent } from "@tui/component/dialog-agent"
|
||||
import { DialogSessionList } from "@tui/component/dialog-session-list"
|
||||
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
|
||||
import { DialogConsoleOrg } from "@tui/component/dialog-console-org"
|
||||
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
||||
import { Home } from "@tui/routes/home"
|
||||
@@ -79,8 +80,7 @@ import {
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { DialogVariant } from "./component/dialog-variant"
|
||||
|
||||
const appBindingCommands = [
|
||||
"command.palette.show",
|
||||
const appGlobalBindingCommands = [
|
||||
"session.list",
|
||||
"session.new",
|
||||
"session.quick_switch.1",
|
||||
@@ -92,6 +92,10 @@ const appBindingCommands = [
|
||||
"session.quick_switch.7",
|
||||
"session.quick_switch.8",
|
||||
"session.quick_switch.9",
|
||||
] as const
|
||||
|
||||
const appBindingCommands = [
|
||||
"command.palette.show",
|
||||
"model.list",
|
||||
"model.cycle_recent",
|
||||
"model.cycle_recent_reverse",
|
||||
@@ -111,6 +115,7 @@ const appBindingCommands = [
|
||||
"theme.mode.lock",
|
||||
"help.show",
|
||||
"docs.open",
|
||||
"workspace.list",
|
||||
"app.debug",
|
||||
"app.console",
|
||||
"app.heap_snapshot",
|
||||
@@ -603,6 +608,16 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "workspace.list",
|
||||
title: "Manage workspaces",
|
||||
category: "Workspace",
|
||||
hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||
slashName: "workspaces",
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogWorkspaceList />)
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
name: `session.quick_switch.${i + 1}`,
|
||||
title: `Switch to session in quick slot ${i + 1}`,
|
||||
@@ -929,6 +944,10 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: tuiConfig.keybinds.gather("app.global", appGlobalBindingCommands),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
enabled: () => {
|
||||
|
||||
@@ -384,6 +384,15 @@ function ApiMethod(props: ApiMethodProps) {
|
||||
</text>
|
||||
</box>
|
||||
),
|
||||
"google-vertex": (
|
||||
<box gap={1}>
|
||||
<text fg={theme.warning}>Note: this API key cannot be used with Anthropic models.</text>
|
||||
<text fg={theme.textMuted}>
|
||||
Use <span style={{ fg: theme.text }}>gcloud auth application-default login</span> or set
|
||||
GOOGLE_APPLICATION_CREDENTIALS.
|
||||
</text>
|
||||
</box>
|
||||
),
|
||||
}[props.providerID] ?? undefined
|
||||
}
|
||||
onConfirm={async (value) => {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Workspace } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { createMemo, createSignal, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
type WorkspaceOption = { workspace: Workspace }
|
||||
|
||||
export function DialogWorkspaceList() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const project = useProject()
|
||||
const { theme } = useTheme()
|
||||
const [deleting, setDeleting] = createSignal<string>()
|
||||
const [removing, setRemoving] = createSignal<string>()
|
||||
const [expanded, setExpanded] = createStore<Record<string, boolean>>({})
|
||||
|
||||
const current = createMemo(() => {
|
||||
if (route.data.type === "session") return sync.session.get(route.data.sessionID)?.workspaceID
|
||||
return project.workspace.current()
|
||||
})
|
||||
|
||||
const options = createMemo<DialogSelectOption<WorkspaceOption>[]>(() =>
|
||||
project.workspace
|
||||
.list()
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
.map((workspace) => {
|
||||
const status = project.workspace.status(workspace.id)
|
||||
return {
|
||||
title:
|
||||
removing() === workspace.id
|
||||
? "Deleting..."
|
||||
: deleting() === workspace.id
|
||||
? `Delete ${workspace.name}? Press delete again`
|
||||
: workspace.name,
|
||||
value: { workspace },
|
||||
footer: workspace.type,
|
||||
details: expanded[workspace.id] && workspace.directory ? [workspace.directory] : undefined,
|
||||
gutter: () => <text fg={status === "connected" ? theme.success : theme.error}>●</text>,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
function showDetails(workspace: Workspace) {
|
||||
setExpanded(workspace.id, (open) => !open)
|
||||
}
|
||||
|
||||
async function remove(workspace: Workspace) {
|
||||
if (removing()) return
|
||||
if (deleting() !== workspace.id) {
|
||||
setDeleting(workspace.id)
|
||||
return
|
||||
}
|
||||
|
||||
setDeleting(undefined)
|
||||
setRemoving(workspace.id)
|
||||
const result = await sdk.client.experimental.workspace.remove({ id: workspace.id }).catch((err) => ({
|
||||
error: err,
|
||||
}))
|
||||
if (result?.error) {
|
||||
setRemoving(undefined)
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Failed to delete workspace",
|
||||
message: errorMessage(result.error),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (current() === workspace.id) {
|
||||
project.workspace.set(undefined)
|
||||
route.navigate({ type: "home" })
|
||||
}
|
||||
await project.workspace.sync()
|
||||
await sync.bootstrap({ fatal: false }).catch(() => undefined)
|
||||
setRemoving(undefined)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
void sdk.client.experimental.workspace.syncList().catch(() => undefined)
|
||||
void project.workspace.sync()
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Workspaces"
|
||||
options={options()}
|
||||
onMove={(option) => {
|
||||
setDeleting(undefined)
|
||||
}}
|
||||
onSelect={(option) => showDetails(option.value.workspace)}
|
||||
actions={[
|
||||
{
|
||||
command: "session.delete",
|
||||
title: "delete",
|
||||
onTrigger: (option) => void remove(option.value.workspace),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -512,7 +512,10 @@ export function Prompt(props: PromptProps) {
|
||||
const content = await Editor.open({
|
||||
value,
|
||||
renderer,
|
||||
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
|
||||
cwd:
|
||||
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
|
||||
project.instance.directory() ||
|
||||
process.cwd(),
|
||||
})
|
||||
if (!content) return
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useEditorContext } from "@tui/context/editor"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { DialogAlert } from "../../ui/dialog-alert"
|
||||
import { TodoItem } from "../../component/todo-item"
|
||||
import { DialogMessage } from "./dialog-message"
|
||||
import type { PromptInfo } from "../../component/prompt/history"
|
||||
@@ -133,12 +134,6 @@ const sessionBindingCommands = [
|
||||
"session.toggle.actions",
|
||||
"session.toggle.scrollbar",
|
||||
"session.toggle.generic_tool_output",
|
||||
"session.page.up",
|
||||
"session.page.down",
|
||||
"session.line.up",
|
||||
"session.line.down",
|
||||
"session.half.page.up",
|
||||
"session.half.page.down",
|
||||
"session.first",
|
||||
"session.last",
|
||||
"session.messages_last_user",
|
||||
@@ -153,6 +148,17 @@ const sessionBindingCommands = [
|
||||
"session.child.previous",
|
||||
] as const
|
||||
|
||||
const sessionGlobalBindingCommands = [
|
||||
"session.page.up",
|
||||
"session.page.down",
|
||||
"session.line.up",
|
||||
"session.line.down",
|
||||
"session.half.page.up",
|
||||
"session.half.page.down",
|
||||
] as const
|
||||
|
||||
const sessionGlobalUnfocusedBindingCommands = ["session.first", "session.last"] as const
|
||||
|
||||
const context = createContext<{
|
||||
width: number
|
||||
sessionID: string
|
||||
@@ -409,15 +415,19 @@ export function Session() {
|
||||
|
||||
const local = useLocal()
|
||||
|
||||
function enterChild(sessionID: string) {
|
||||
navigate({
|
||||
type: "session",
|
||||
sessionID,
|
||||
})
|
||||
const status = sync.data.session_status[sessionID]
|
||||
if (status?.type === "retry") void DialogAlert.show(dialog, "Retry Error", status.message)
|
||||
}
|
||||
|
||||
function moveFirstChild() {
|
||||
if (children().length === 1) return
|
||||
const next = children().find((x) => !!x.parentID)
|
||||
if (next) {
|
||||
navigate({
|
||||
type: "session",
|
||||
sessionID: next.id,
|
||||
})
|
||||
}
|
||||
if (next) enterChild(next.id)
|
||||
}
|
||||
|
||||
function moveChild(direction: number) {
|
||||
@@ -428,12 +438,7 @@ export function Session() {
|
||||
|
||||
if (next >= sessions.length) next = 0
|
||||
if (next < 0) next = sessions.length - 1
|
||||
if (sessions[next]) {
|
||||
navigate({
|
||||
type: "session",
|
||||
sessionID: sessions[next].id,
|
||||
})
|
||||
}
|
||||
if (sessions[next]) enterChild(sessions[next].id)
|
||||
}
|
||||
|
||||
function childSessionHandler(func: () => void) {
|
||||
@@ -968,7 +973,10 @@ export function Session() {
|
||||
await Editor.open({
|
||||
value: transcript,
|
||||
renderer,
|
||||
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
|
||||
cwd:
|
||||
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
|
||||
project.instance.directory() ||
|
||||
process.cwd(),
|
||||
})
|
||||
} else {
|
||||
const exportDir = process.cwd()
|
||||
@@ -981,7 +989,10 @@ export function Session() {
|
||||
const result = await Editor.open({
|
||||
value: transcript,
|
||||
renderer,
|
||||
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
|
||||
cwd:
|
||||
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
|
||||
project.instance.directory() ||
|
||||
process.cwd(),
|
||||
})
|
||||
if (result !== undefined) {
|
||||
await Filesystem.write(filepath, result)
|
||||
@@ -1001,8 +1012,8 @@ export function Session() {
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
moveFirstChild()
|
||||
dialog.clear()
|
||||
moveFirstChild()
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1029,8 +1040,8 @@ export function Session() {
|
||||
hidden: true,
|
||||
enabled: !!session()?.parentID,
|
||||
run: childSessionHandler(() => {
|
||||
moveChild(1)
|
||||
dialog.clear()
|
||||
moveChild(1)
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -1040,8 +1051,8 @@ export function Session() {
|
||||
hidden: true,
|
||||
enabled: !!session()?.parentID,
|
||||
run: childSessionHandler(() => {
|
||||
moveChild(-1)
|
||||
dialog.clear()
|
||||
moveChild(-1)
|
||||
}),
|
||||
},
|
||||
])
|
||||
@@ -1061,6 +1072,15 @@ export function Session() {
|
||||
commands: sessionCommands(),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: tuiConfig.keybinds.gather("session.global", sessionGlobalBindingCommands),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
enabled: () => renderer.currentFocusedEditor === null,
|
||||
bindings: tuiConfig.keybinds.gather("session.global.unfocused", sessionGlobalUnfocusedBindingCommands),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands),
|
||||
@@ -1752,6 +1772,7 @@ function GenericTool(props: ToolProps<any>) {
|
||||
function InlineTool(props: {
|
||||
icon: string
|
||||
iconColor?: RGBA
|
||||
color?: RGBA
|
||||
complete: any
|
||||
pending: string
|
||||
spinner?: boolean
|
||||
@@ -1773,6 +1794,7 @@ function InlineTool(props: {
|
||||
})
|
||||
|
||||
const fg = createMemo(() => {
|
||||
if (props.color) return props.color
|
||||
if (permission()) return theme.warning
|
||||
if (hover() && props.onClick) return theme.text
|
||||
if (props.complete) return theme.textMuted
|
||||
@@ -2057,8 +2079,10 @@ function WebSearch(props: ToolProps<typeof WebSearchTool>) {
|
||||
}
|
||||
|
||||
function Task(props: ToolProps<typeof TaskTool>) {
|
||||
const { theme } = useTheme()
|
||||
const { navigate } = useRoute()
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
|
||||
onMount(() => {
|
||||
if (props.metadata.sessionId && !sync.data.message[props.metadata.sessionId]?.length)
|
||||
@@ -2080,6 +2104,11 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
||||
)
|
||||
|
||||
const isRunning = createMemo(() => props.part.state.status === "running")
|
||||
const retry = createMemo(() => {
|
||||
const status = sync.data.session_status[props.metadata.sessionId ?? ""]
|
||||
if (status?.type !== "retry") return
|
||||
return status
|
||||
})
|
||||
|
||||
const duration = createMemo(() => {
|
||||
const first = messages().find((x) => x.role === "user")?.time.created
|
||||
@@ -2094,7 +2123,10 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
||||
props.metadata.background === true ? `${props.input.description} (background)` : props.input.description
|
||||
let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${description}`]
|
||||
|
||||
if (isRunning() && tools().length > 0) {
|
||||
const retrying = retry()
|
||||
if (isRunning() && retrying) {
|
||||
content.push(`↳ ${Locale.truncate(retrying.message, 80)} [retrying attempt #${retrying.attempt}]`)
|
||||
} else if (isRunning() && tools().length > 0) {
|
||||
// content[0] += ` · ${tools().length} toolcalls`
|
||||
if (current()) {
|
||||
const state = current()!.state
|
||||
@@ -2117,6 +2149,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
||||
return (
|
||||
<InlineTool
|
||||
icon="│"
|
||||
color={retry() ? theme.error : undefined}
|
||||
spinner={isRunning()}
|
||||
complete={props.input.description}
|
||||
pending="Delegating..."
|
||||
@@ -2125,6 +2158,8 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
||||
if (props.metadata.sessionId) {
|
||||
navigate({ type: "session", sessionID: props.metadata.sessionId })
|
||||
}
|
||||
const status = retry()
|
||||
if (status) void DialogAlert.show(dialog, "Retry Error", status.message)
|
||||
}}
|
||||
>
|
||||
{content()}
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface DialogSelectOption<T = any> {
|
||||
title: string
|
||||
value: T
|
||||
description?: string
|
||||
details?: string[]
|
||||
footer?: JSX.Element | string
|
||||
category?: string
|
||||
categoryView?: JSX.Element
|
||||
@@ -167,7 +168,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
if (!category) return acc
|
||||
return acc + (i > 0 ? 2 : 1)
|
||||
}, 0)
|
||||
return flat().length + headers
|
||||
return flat().reduce((acc, option) => acc + 1 + (option.details?.length ?? 0), headers)
|
||||
})
|
||||
|
||||
const dimensions = useTerminalDimensions()
|
||||
@@ -426,7 +427,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
return (
|
||||
<box
|
||||
id={JSON.stringify(option.value)}
|
||||
flexDirection="row"
|
||||
flexDirection="column"
|
||||
position="relative"
|
||||
onMouseMove={() => {
|
||||
setStore("input", "mouse")
|
||||
@@ -446,24 +447,37 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
if (index === -1) return
|
||||
moveTo(index)
|
||||
}}
|
||||
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
|
||||
paddingLeft={current() || option.gutter ? 1 : 3}
|
||||
paddingRight={3}
|
||||
gap={1}
|
||||
>
|
||||
<Show when={!current() && option.margin}>
|
||||
<box position="absolute" left={1} flexShrink={0}>
|
||||
{option.margin}
|
||||
</box>
|
||||
</Show>
|
||||
<Option
|
||||
title={option.title}
|
||||
footer={flatten() ? (option.category ?? option.footer) : option.footer}
|
||||
description={option.description !== category ? option.description : undefined}
|
||||
active={active()}
|
||||
current={current()}
|
||||
gutter={option.gutter}
|
||||
/>
|
||||
<box
|
||||
flexDirection="row"
|
||||
paddingLeft={current() || option.gutter ? 1 : 3}
|
||||
paddingRight={3}
|
||||
gap={1}
|
||||
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
|
||||
>
|
||||
<Show when={!current() && option.margin}>
|
||||
<box position="absolute" left={1} flexShrink={0}>
|
||||
{option.margin}
|
||||
</box>
|
||||
</Show>
|
||||
<Option
|
||||
title={option.title}
|
||||
footer={flatten() ? (option.category ?? option.footer) : option.footer}
|
||||
description={option.description !== category ? option.description : undefined}
|
||||
active={active()}
|
||||
current={current()}
|
||||
gutter={option.gutter}
|
||||
/>
|
||||
</box>
|
||||
<For each={option.details}>
|
||||
{(detail) => (
|
||||
<box paddingLeft={3} paddingRight={3}>
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
{Locale.truncateMiddle(detail, Math.max(1, Math.min(76, dimensions().width - 12)))}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -54,6 +54,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
|
||||
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
|
||||
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
|
||||
experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
|
||||
experimentalWebSockets: bool("OPENCODE_EXPERIMENTAL_WEBSOCKETS"),
|
||||
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Bus } from "../bus"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { CodexAuthPlugin } from "./codex"
|
||||
import { CodexAuthPlugin } from "./openai/codex"
|
||||
import { Session } from "@/session/session"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { CopilotAuthPlugin } from "./github-copilot/copilot"
|
||||
@@ -29,6 +29,7 @@ import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } fro
|
||||
import { registerAdapter } from "@/control-plane/adapters"
|
||||
import type { WorkspaceAdapter } from "@/control-plane/types"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { InstallationChannel } from "@opencode-ai/core/installation/version"
|
||||
|
||||
const log = Log.create({ service: "plugin" })
|
||||
|
||||
@@ -57,18 +58,28 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
|
||||
|
||||
export function experimentalWebSocketsEnabled(input: { enabled: boolean; channel?: string }) {
|
||||
return input.enabled || ["local", "dev", "beta"].includes(input.channel ?? InstallationChannel)
|
||||
}
|
||||
|
||||
// Built-in plugins that are directly imported (not installed from npm)
|
||||
const INTERNAL_PLUGINS: PluginInstance[] = [
|
||||
CodexAuthPlugin,
|
||||
CopilotAuthPlugin,
|
||||
GitlabAuthPlugin,
|
||||
PoeAuthPlugin,
|
||||
CloudflareWorkersAuthPlugin,
|
||||
CloudflareAIGatewayAuthPlugin,
|
||||
AzureAuthPlugin,
|
||||
DigitalOceanAuthPlugin,
|
||||
XaiAuthPlugin,
|
||||
]
|
||||
function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] {
|
||||
return [
|
||||
// Temporary rollout: pre-release builds use WebSockets by default; releases require explicit opt-in.
|
||||
(input) =>
|
||||
CodexAuthPlugin(input, {
|
||||
experimentalWebSockets: experimentalWebSocketsEnabled({ enabled: flags.experimentalWebSockets }),
|
||||
}),
|
||||
CopilotAuthPlugin,
|
||||
GitlabAuthPlugin,
|
||||
PoeAuthPlugin,
|
||||
CloudflareWorkersAuthPlugin,
|
||||
CloudflareAIGatewayAuthPlugin,
|
||||
AzureAuthPlugin,
|
||||
DigitalOceanAuthPlugin,
|
||||
XaiAuthPlugin,
|
||||
]
|
||||
}
|
||||
|
||||
function isServerPlugin(value: unknown): value is PluginInstance {
|
||||
return typeof value === "function"
|
||||
@@ -151,7 +162,7 @@ export const layer = Layer.effect(
|
||||
$: typeof Bun === "undefined" ? undefined : Bun.$,
|
||||
}
|
||||
|
||||
for (const plugin of flags.disableDefaultPlugins ? [] : INTERNAL_PLUGINS) {
|
||||
for (const plugin of flags.disableDefaultPlugins ? [] : internalPlugins(flags)) {
|
||||
log.info("loading internal plugin", { name: plugin.name })
|
||||
const init = yield* Effect.tryPromise({
|
||||
try: () => plugin(input),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# OpenAI Responses WebSocket
|
||||
|
||||
Enabled by default on `local`, `dev`, and `beta`. On `latest` and `prod`, set `OPENCODE_EXPERIMENTAL_WEBSOCKETS=true`.
|
||||
|
||||
## Flow
|
||||
|
||||
1. A streamed `POST /responses` request arrives.
|
||||
2. If it has no `session-id` or `x-session-affinity` header, use HTTP.
|
||||
3. Title requests use HTTP.
|
||||
4. If that session's socket is busy or already in fallback mode, use HTTP.
|
||||
5. Otherwise, reuse its open socket or open a new one.
|
||||
6. Send `response.create` and return WebSocket events as SSE.
|
||||
|
||||
## Lifetime
|
||||
|
||||
- Connect timeout: 15 seconds.
|
||||
- Idle timeout: 5 minutes.
|
||||
- After a completed response, keep the socket for reuse.
|
||||
- Reuse a socket for up to 55 minutes, then replace it on the next request.
|
||||
|
||||
## Retries
|
||||
|
||||
- If WebSocket setup fails or it fails before its first event, replay over HTTP and keep that session on HTTP until idle-pruned.
|
||||
- If the server returns `websocket_connection_limit_reached` before output, reconnect up to 5 times, then follow the same HTTP fallback.
|
||||
- If a WebSocket fails after its first event, fail the stream. Do not replay partial output.
|
||||
- Abort or cancel closes the socket.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- `previous_response_id` continuation.
|
||||
- Optional second WebSocket for concurrent requests in one session. Currently these use HTTP.
|
||||
+31
-30
@@ -1,10 +1,11 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||
import { OAUTH_DUMMY_KEY } from "../../auth"
|
||||
import os from "os"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { createServer } from "http"
|
||||
import { OpenAIWebSocketPool } from "./ws-pool"
|
||||
|
||||
const log = Log.create({ service: "plugin.codex" })
|
||||
|
||||
@@ -28,20 +29,12 @@ interface PkceCodes {
|
||||
}
|
||||
|
||||
async function generatePKCE(): Promise<PkceCodes> {
|
||||
const verifier = generateRandomString(43)
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(verifier)
|
||||
const hash = await crypto.subtle.digest("SHA-256", data)
|
||||
const challenge = base64UrlEncode(hash)
|
||||
return { verifier, challenge }
|
||||
}
|
||||
|
||||
function generateRandomString(length: number): string {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(length))
|
||||
return Array.from(bytes)
|
||||
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)))
|
||||
.map((b) => chars[b % chars.length])
|
||||
.join("")
|
||||
const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
|
||||
return { verifier, challenge }
|
||||
}
|
||||
|
||||
function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
@@ -50,10 +43,6 @@ function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||
}
|
||||
|
||||
function generateState(): string {
|
||||
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
||||
}
|
||||
|
||||
export interface IdTokenClaims {
|
||||
chatgpt_account_id?: string
|
||||
organizations?: Array<{ id: string }>
|
||||
@@ -120,6 +109,7 @@ interface TokenResponse {
|
||||
interface CodexAuthPluginOptions {
|
||||
issuer?: string
|
||||
codexApiEndpoint?: string
|
||||
experimentalWebSockets?: boolean
|
||||
}
|
||||
|
||||
async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
|
||||
@@ -371,8 +361,14 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResp
|
||||
export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPluginOptions = {}): Promise<Hooks> {
|
||||
const issuer = options.issuer ?? ISSUER
|
||||
const codexApiEndpoint = options.codexApiEndpoint ?? CODEX_API_ENDPOINT
|
||||
let websocketFetchInstalled = false
|
||||
const websocketFetches: Array<ReturnType<typeof OpenAIWebSocketPool.createWebSocketFetch>> = []
|
||||
|
||||
return {
|
||||
async dispose() {
|
||||
for (const websocketFetch of websocketFetches) websocketFetch.close()
|
||||
websocketFetches.length = 0
|
||||
},
|
||||
provider: {
|
||||
id: "openai",
|
||||
async models(provider, ctx) {
|
||||
@@ -410,7 +406,14 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
provider: "openai",
|
||||
async loader(getAuth) {
|
||||
const auth = await getAuth()
|
||||
if (auth.type !== "oauth") return {}
|
||||
const websocketFetch = options.experimentalWebSockets
|
||||
? OpenAIWebSocketPool.createWebSocketFetch({ httpFetch: fetch })
|
||||
: undefined
|
||||
if (websocketFetch) {
|
||||
websocketFetches.push(websocketFetch)
|
||||
websocketFetchInstalled = true
|
||||
}
|
||||
if (auth.type !== "oauth") return websocketFetch ? { fetch: websocketFetch } : {}
|
||||
|
||||
let refreshPromise:
|
||||
| Promise<{
|
||||
@@ -422,7 +425,6 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
return {
|
||||
apiKey: OAUTH_DUMMY_KEY,
|
||||
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
|
||||
// Remove dummy API key authorization header
|
||||
if (init?.headers) {
|
||||
if (init.headers instanceof Headers) {
|
||||
init.headers.delete("authorization")
|
||||
@@ -436,12 +438,11 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
}
|
||||
|
||||
const currentAuth = await getAuth()
|
||||
if (currentAuth.type !== "oauth") return fetch(requestInput, init)
|
||||
if (currentAuth.type !== "oauth")
|
||||
return websocketFetch ? websocketFetch(requestInput, init) : fetch(requestInput, init)
|
||||
|
||||
// Cast to include accountId field
|
||||
const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string }
|
||||
|
||||
// Check if token needs refresh
|
||||
if (!currentAuth.access || currentAuth.expires < Date.now()) {
|
||||
if (!refreshPromise) {
|
||||
log.info("refreshing codex access token")
|
||||
@@ -473,7 +474,6 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
authWithAccount.accountId = refreshed.accountId
|
||||
}
|
||||
|
||||
// Build headers
|
||||
const headers = new Headers()
|
||||
if (init?.headers) {
|
||||
if (init.headers instanceof Headers) {
|
||||
@@ -488,16 +488,11 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set authorization header with access token
|
||||
headers.set("authorization", `Bearer ${currentAuth.access}`)
|
||||
|
||||
// Set ChatGPT-Account-Id header for organization subscriptions
|
||||
if (authWithAccount.accountId) {
|
||||
headers.set("ChatGPT-Account-Id", authWithAccount.accountId)
|
||||
}
|
||||
|
||||
// Rewrite URL to Codex endpoint
|
||||
const parsed =
|
||||
requestInput instanceof URL
|
||||
? requestInput
|
||||
@@ -507,10 +502,12 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
? new URL(codexApiEndpoint)
|
||||
: parsed
|
||||
|
||||
return fetch(url, {
|
||||
const requestInit = {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit)
|
||||
return fetch(url, OpenAIWebSocketPool.withoutInternalHeaders(requestInit))
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -521,7 +518,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
authorize: async () => {
|
||||
const { redirectUri } = await startOAuthServer()
|
||||
const pkce = await generatePKCE()
|
||||
const state = generateState()
|
||||
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
||||
const authUrl = buildAuthorizeUrl(redirectUri, pkce, state)
|
||||
|
||||
const callbackPromise = waitForOAuthCallback(pkce, state)
|
||||
@@ -639,6 +636,10 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
output.headers.originator = "opencode"
|
||||
output.headers["User-Agent"] = `opencode/${InstallationVersion} (${os.platform()} ${os.release()}; ${os.arch()})`
|
||||
output.headers["session-id"] = input.sessionID
|
||||
// Temporary fetch-layer hack: title generation currently shares the conversation
|
||||
// session ID, so the OpenAI plugin marks it for HTTP fallback until transport
|
||||
// context can be passed directly instead of smuggled through headers.
|
||||
if (websocketFetchInstalled && input.agent === "title") output.headers[OpenAIWebSocketPool.TITLE_HEADER] = "true"
|
||||
},
|
||||
"chat.params": async (input, output) => {
|
||||
if (input.model.providerID !== "openai") return
|
||||
@@ -0,0 +1,255 @@
|
||||
import WebSocket from "ws"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { OpenAIWebSocket } from "./ws"
|
||||
|
||||
export const TITLE_HEADER = "x-opencode-title"
|
||||
|
||||
const log = Log.create({ service: "plugin.openai.ws" })
|
||||
|
||||
export interface CreateWebSocketFetchOptions {
|
||||
httpFetch?: typeof globalThis.fetch
|
||||
url?: string
|
||||
connectTimeout?: number
|
||||
idleTimeout?: number
|
||||
maxConnectionAge?: number
|
||||
connectionLimitRetries?: number
|
||||
}
|
||||
|
||||
interface PoolEntry {
|
||||
socket?: WebSocket
|
||||
connectedAt?: number
|
||||
lastUsedAt: number
|
||||
busy: boolean
|
||||
fallback: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_CONNECT_TIMEOUT = 15_000
|
||||
const DEFAULT_IDLE_TIMEOUT = 5 * 60 * 1000
|
||||
const DEFAULT_MAX_CONNECTION_AGE = 55 * 60 * 1000
|
||||
const CONNECTION_LIMIT_REACHED_CODE = "websocket_connection_limit_reached"
|
||||
|
||||
export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
|
||||
const httpFetch = options?.httpFetch ?? globalThis.fetch
|
||||
const pool = new Map<string, PoolEntry>()
|
||||
const connectTimeout = options?.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT
|
||||
const idleTimeout = options?.idleTimeout ?? DEFAULT_IDLE_TIMEOUT
|
||||
const maxConnectionAge = options?.maxConnectionAge ?? DEFAULT_MAX_CONNECTION_AGE
|
||||
const connectionLimitRetries = options?.connectionLimitRetries ?? 5
|
||||
const pruneTimer = setInterval(() => prune(), Math.min(idleTimeout, 60_000))
|
||||
if (typeof pruneTimer === "object" && "unref" in pruneTimer && typeof pruneTimer.unref === "function") {
|
||||
pruneTimer.unref()
|
||||
}
|
||||
|
||||
async function websocketFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const url = input instanceof URL ? input.toString() : typeof input === "string" ? input : input.url
|
||||
const internalHeaders = OpenAIWebSocket.normalizeHeaders(init?.headers)
|
||||
const httpInit = withoutInternalHeaders(init)
|
||||
|
||||
if (init?.method !== "POST" || !new URL(url).pathname.endsWith("/responses")) {
|
||||
return httpFetch(input, httpInit)
|
||||
}
|
||||
|
||||
const body = (() => {
|
||||
try {
|
||||
if (typeof init?.body !== "string") return undefined
|
||||
const parsed = JSON.parse(init.body)
|
||||
return typeof parsed === "object" && parsed !== null ? parsed : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
if (!body?.stream) return httpFetch(input, httpInit)
|
||||
if (internalHeaders[TITLE_HEADER] === "true") {
|
||||
log.debug("http fallback", { reason: "title" })
|
||||
return httpFetch(input, httpInit)
|
||||
}
|
||||
|
||||
const sessionID = internalHeaders["x-session-affinity"] ?? internalHeaders["session-id"]
|
||||
if (!sessionID) {
|
||||
log.debug("http fallback", { reason: "missing_session" })
|
||||
return httpFetch(input, httpInit)
|
||||
}
|
||||
const key = `${sessionID}:conversation`
|
||||
|
||||
const entry = pool.get(key) ?? { lastUsedAt: Date.now(), busy: false, fallback: false }
|
||||
pool.set(key, entry)
|
||||
|
||||
if (entry.fallback) {
|
||||
log.debug("http fallback", { key, reason: "fallback_active" })
|
||||
return httpFetch(input, httpInit)
|
||||
}
|
||||
if (entry.busy) {
|
||||
log.debug("http fallback", { key, reason: "busy" })
|
||||
return httpFetch(input, httpInit)
|
||||
}
|
||||
|
||||
entry.busy = true
|
||||
entry.lastUsedAt = Date.now()
|
||||
try {
|
||||
let connectionLimitAttempts = 0
|
||||
entry.socket = await socket(
|
||||
entry,
|
||||
options?.url ?? url,
|
||||
OpenAIWebSocket.normalizeHeaders(httpInit?.headers),
|
||||
connectTimeout,
|
||||
maxConnectionAge,
|
||||
init?.signal,
|
||||
)
|
||||
let resolveFirstEvent: (started: boolean) => void = () => {}
|
||||
let rejectFirstEvent: (error: Error) => void = () => {}
|
||||
const firstEvent = new Promise<boolean>((resolve, reject) => {
|
||||
resolveFirstEvent = resolve
|
||||
rejectFirstEvent = reject
|
||||
})
|
||||
const response = OpenAIWebSocket.streamResponsesWebSocket({
|
||||
socket: entry.socket,
|
||||
body,
|
||||
idleTimeout,
|
||||
signal: init?.signal ?? undefined,
|
||||
onFirstEvent: () => resolveFirstEvent(true),
|
||||
onTerminal: (event) => {
|
||||
entry.busy = false
|
||||
entry.lastUsedAt = Date.now()
|
||||
if (event.type !== "response.completed" && event.type !== "response.done") {
|
||||
log.warn("websocket terminal failure", { key, type: event.type })
|
||||
invalidate(entry)
|
||||
}
|
||||
},
|
||||
onConnectionInvalid: (error) => {
|
||||
log.warn("websocket invalidated", { key, error: error instanceof Error ? error.message : String(error) })
|
||||
entry.busy = false
|
||||
entry.fallback = true
|
||||
invalidate(entry)
|
||||
resolveFirstEvent(false)
|
||||
},
|
||||
onAbort: (error) => {
|
||||
log.debug("websocket aborted", { key })
|
||||
entry.busy = false
|
||||
entry.lastUsedAt = Date.now()
|
||||
invalidate(entry)
|
||||
rejectFirstEvent(error)
|
||||
},
|
||||
onRetryableTerminal: async (event) => {
|
||||
const error = connectionLimitError(event)
|
||||
if (!error) return undefined
|
||||
if (connectionLimitAttempts >= connectionLimitRetries) throw error
|
||||
|
||||
connectionLimitAttempts++
|
||||
log.warn("websocket connection limit reached", { key, attempt: connectionLimitAttempts })
|
||||
invalidate(entry)
|
||||
entry.socket = await socket(
|
||||
entry,
|
||||
options?.url ?? url,
|
||||
OpenAIWebSocket.normalizeHeaders(httpInit?.headers),
|
||||
connectTimeout,
|
||||
maxConnectionAge,
|
||||
init?.signal,
|
||||
)
|
||||
entry.lastUsedAt = Date.now()
|
||||
return entry.socket
|
||||
},
|
||||
})
|
||||
if (await firstEvent) return response
|
||||
log.debug("http fallback", { key, reason: "websocket_failed_before_first_event" })
|
||||
return httpFetch(input, httpInit)
|
||||
} catch (error) {
|
||||
entry.busy = false
|
||||
entry.lastUsedAt = Date.now()
|
||||
if (OpenAIWebSocket.isAbortError(error)) {
|
||||
invalidate(entry)
|
||||
throw error
|
||||
}
|
||||
|
||||
entry.fallback = true
|
||||
log.warn("websocket setup failed", {
|
||||
key,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
fallback: "http",
|
||||
})
|
||||
invalidate(entry)
|
||||
return httpFetch(input, httpInit)
|
||||
}
|
||||
}
|
||||
|
||||
function prune() {
|
||||
const now = Date.now()
|
||||
for (const [key, entry] of pool) {
|
||||
if (entry.busy) continue
|
||||
if (now - entry.lastUsedAt < idleTimeout) continue
|
||||
log.debug("websocket idle prune", { key })
|
||||
invalidate(entry)
|
||||
pool.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
log.debug("websocket pool close", { count: pool.size })
|
||||
clearInterval(pruneTimer)
|
||||
for (const entry of pool.values()) invalidate(entry)
|
||||
pool.clear()
|
||||
}
|
||||
|
||||
return Object.assign(websocketFetch, { close })
|
||||
}
|
||||
|
||||
function connectionLimitError(event: Record<string, unknown>) {
|
||||
if (event.type !== "error" || !isRecord(event.error) || event.error.code !== CONNECTION_LIMIT_REACHED_CODE) return
|
||||
return new Error(typeof event.error.message === "string" ? event.error.message : CONNECTION_LIMIT_REACHED_CODE)
|
||||
}
|
||||
|
||||
async function socket(
|
||||
entry: PoolEntry,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
connectTimeout: number,
|
||||
maxConnectionAge: number,
|
||||
signal?: AbortSignal | null,
|
||||
) {
|
||||
if (
|
||||
entry.socket?.readyState === WebSocket.OPEN &&
|
||||
entry.connectedAt &&
|
||||
Date.now() - entry.connectedAt < maxConnectionAge
|
||||
) {
|
||||
return entry.socket
|
||||
}
|
||||
|
||||
invalidate(entry)
|
||||
const next = await OpenAIWebSocket.connectResponsesWebSocket({
|
||||
url: OpenAIWebSocket.toWebSocketUrl(url),
|
||||
headers,
|
||||
timeout: connectTimeout,
|
||||
signal: signal ?? undefined,
|
||||
})
|
||||
entry.connectedAt = Date.now()
|
||||
return next
|
||||
}
|
||||
|
||||
function invalidate(entry: PoolEntry) {
|
||||
if (entry.socket) {
|
||||
entry.socket.on("error", () => {})
|
||||
entry.socket.terminate()
|
||||
entry.socket = undefined
|
||||
}
|
||||
entry.connectedAt = undefined
|
||||
}
|
||||
|
||||
export function withoutInternalHeaders<T extends { headers?: HeadersInit }>(init: T | undefined): T | undefined {
|
||||
if (!init?.headers) return init
|
||||
if (init.headers instanceof Headers) {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.delete(TITLE_HEADER)
|
||||
return { ...init, headers }
|
||||
}
|
||||
|
||||
if (Array.isArray(init.headers)) {
|
||||
return { ...init, headers: init.headers.filter((item) => item[0].toLowerCase() !== TITLE_HEADER) }
|
||||
}
|
||||
|
||||
return {
|
||||
...init,
|
||||
headers: Object.fromEntries(Object.entries(init.headers).filter(([key]) => key.toLowerCase() !== TITLE_HEADER)),
|
||||
}
|
||||
}
|
||||
|
||||
export * as OpenAIWebSocketPool from "./ws-pool"
|
||||
@@ -0,0 +1,322 @@
|
||||
// Low-level OpenAI Responses WebSocket protocol helpers. Session pooling,
|
||||
// fallback, and continuation state intentionally live above this file.
|
||||
|
||||
import WebSocket from "ws"
|
||||
|
||||
export const PROTOCOL_HEADER = "responses_websockets=2026-02-06"
|
||||
|
||||
export interface ConnectResponsesWebSocketOptions {
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
timeout?: number
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface StreamResponsesWebSocketOptions {
|
||||
socket: WebSocket
|
||||
body: Record<string, unknown>
|
||||
idleTimeout?: number
|
||||
signal?: AbortSignal
|
||||
onFirstEvent?: () => void
|
||||
onComplete?: (event: Record<string, unknown>) => void
|
||||
onTerminal?: (event: Record<string, unknown>) => void
|
||||
onRetryableTerminal?: (event: Record<string, unknown>) => Promise<WebSocket | undefined>
|
||||
onConnectionInvalid?: (error: Error) => void
|
||||
onAbort?: (error: Error) => void
|
||||
}
|
||||
|
||||
export function toWebSocketUrl(url: string) {
|
||||
return url.replace(/^http/, "ws")
|
||||
}
|
||||
|
||||
export function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
if (!headers) return result
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
result[key.toLowerCase()] = value
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
for (const [key, value] of headers) {
|
||||
result[key.toLowerCase()] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (value != null) result[key.toLowerCase()] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function isAbortError(error: unknown): error is DOMException {
|
||||
return error instanceof DOMException && error.name === "AbortError"
|
||||
}
|
||||
|
||||
export function connectResponsesWebSocket(options: ConnectResponsesWebSocketOptions) {
|
||||
return new Promise<WebSocket>((resolve, reject) => {
|
||||
if (options.signal?.aborted) {
|
||||
reject(abortError(options.signal))
|
||||
return
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...options.headers,
|
||||
"openai-beta": options.headers["openai-beta"] ?? PROTOCOL_HEADER,
|
||||
}
|
||||
delete headers["content-length"]
|
||||
|
||||
const socket = new WebSocket(options.url, { headers })
|
||||
const timeout = options.timeout
|
||||
? setTimeout(() => {
|
||||
cleanup()
|
||||
socket.on("error", () => {})
|
||||
socket.terminate()
|
||||
reject(new Error("WebSocket connect timed out"))
|
||||
}, options.timeout)
|
||||
: undefined
|
||||
|
||||
function cleanup() {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
socket.off("open", onOpen)
|
||||
socket.off("error", onError)
|
||||
socket.off("close", onClose)
|
||||
options.signal?.removeEventListener("abort", onAbort)
|
||||
}
|
||||
|
||||
function onOpen() {
|
||||
cleanup()
|
||||
resolve(socket)
|
||||
}
|
||||
|
||||
function onError(error: Error) {
|
||||
socket.on("error", () => {})
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
|
||||
function onClose(code: number, reason: Buffer) {
|
||||
cleanup()
|
||||
reject(closeError("WebSocket closed before open", code, reason))
|
||||
}
|
||||
|
||||
function onAbort() {
|
||||
cleanup()
|
||||
socket.on("error", () => {})
|
||||
socket.terminate()
|
||||
reject(abortError(options.signal))
|
||||
}
|
||||
|
||||
socket.once("open", onOpen)
|
||||
socket.once("error", onError)
|
||||
socket.once("close", onClose)
|
||||
options.signal?.addEventListener("abort", onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
export function streamResponsesWebSocket(options: StreamResponsesWebSocketOptions) {
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
let socket = options.socket
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
let cleanupSocket = () => {}
|
||||
let completed = false
|
||||
let emitted = false
|
||||
let idleTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function cleanup() {
|
||||
if (idleTimer) clearTimeout(idleTimer)
|
||||
cleanupSocket()
|
||||
options.signal?.removeEventListener("abort", onAbort)
|
||||
}
|
||||
|
||||
function terminateSocket(target = socket) {
|
||||
target.on("error", () => {})
|
||||
target.terminate()
|
||||
}
|
||||
|
||||
function closeCompleted() {
|
||||
cleanup()
|
||||
controller?.enqueue(encoder.encode("data: [DONE]\n\n"))
|
||||
controller?.close()
|
||||
}
|
||||
|
||||
function invalidate(error: Error) {
|
||||
if (completed) return
|
||||
completed = true
|
||||
cleanup()
|
||||
options.onConnectionInvalid?.(error)
|
||||
controller?.error(error)
|
||||
}
|
||||
|
||||
function resetIdleTimeout(message: string) {
|
||||
if (completed) return
|
||||
if (!options.idleTimeout) return
|
||||
if (idleTimer) clearTimeout(idleTimer)
|
||||
idleTimer = setTimeout(() => invalidate(new Error(message)), options.idleTimeout)
|
||||
if (typeof idleTimer === "object" && "unref" in idleTimer && typeof idleTimer.unref === "function") {
|
||||
idleTimer.unref()
|
||||
}
|
||||
}
|
||||
|
||||
async function onMessage(data: WebSocket.RawData, isBinary: boolean) {
|
||||
if (completed) return
|
||||
if (isBinary) {
|
||||
invalidate(new Error("Unexpected binary WebSocket frame"))
|
||||
return
|
||||
}
|
||||
|
||||
const text = data.toString()
|
||||
const event = (() => {
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
return typeof parsed === "object" && parsed !== null ? parsed : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
|
||||
if (event?.type === "error" && !emitted && options.onRetryableTerminal) {
|
||||
cleanupSocket()
|
||||
if (idleTimer) clearTimeout(idleTimer)
|
||||
idleTimer = undefined
|
||||
try {
|
||||
const next = await options.onRetryableTerminal(event)
|
||||
if (completed) {
|
||||
if (next) terminateSocket(next)
|
||||
return
|
||||
}
|
||||
if (next) {
|
||||
attach(next)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
invalidate(error instanceof Error ? error : new Error(String(error)))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!emitted) options.onFirstEvent?.()
|
||||
controller?.enqueue(
|
||||
encoder.encode(
|
||||
`${text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => `data: ${line}`)
|
||||
.join("\n")}\n\n`,
|
||||
),
|
||||
)
|
||||
emitted = true
|
||||
resetIdleTimeout("idle timeout waiting for websocket")
|
||||
|
||||
if (!event) return
|
||||
|
||||
if (event.type === "response.completed" || event.type === "response.done") {
|
||||
completed = true
|
||||
options.onComplete?.(event)
|
||||
options.onTerminal?.(event)
|
||||
closeCompleted()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "response.failed" || event.type === "response.incomplete" || event.type === "error") {
|
||||
completed = true
|
||||
options.onTerminal?.(event)
|
||||
closeCompleted()
|
||||
}
|
||||
}
|
||||
|
||||
function onError(error: Error) {
|
||||
invalidate(error)
|
||||
}
|
||||
|
||||
function onClose(code: number, reason: Buffer) {
|
||||
if (completed) return
|
||||
invalidate(closeError("WebSocket closed before response.completed", code, reason))
|
||||
}
|
||||
|
||||
function onAbort() {
|
||||
const error = abortError(options.signal)
|
||||
if (completed) return
|
||||
completed = true
|
||||
cleanup()
|
||||
terminateSocket()
|
||||
options.onAbort?.(error)
|
||||
controller?.error(error)
|
||||
}
|
||||
|
||||
function onCancel(reason: unknown) {
|
||||
if (completed) return
|
||||
completed = true
|
||||
cleanup()
|
||||
terminateSocket()
|
||||
options.onAbort?.(cancelError(reason))
|
||||
}
|
||||
|
||||
function attach(next: WebSocket) {
|
||||
cleanupSocket()
|
||||
socket = next
|
||||
socket.on("message", onMessage)
|
||||
socket.once("error", onError)
|
||||
socket.once("close", onClose)
|
||||
cleanupSocket = () => {
|
||||
socket.off("message", onMessage)
|
||||
socket.off("error", onError)
|
||||
socket.off("close", onClose)
|
||||
}
|
||||
const { stream: _stream, background: _background, ...payload } = options.body
|
||||
resetIdleTimeout("idle timeout sending websocket request")
|
||||
socket.send(JSON.stringify({ type: "response.create", ...payload }), (error) => {
|
||||
if (completed) return
|
||||
resetIdleTimeout("idle timeout waiting for websocket")
|
||||
if (error) invalidate(error)
|
||||
})
|
||||
}
|
||||
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(next) {
|
||||
controller = next
|
||||
options.signal?.addEventListener("abort", onAbort, { once: true })
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
onAbort()
|
||||
return
|
||||
}
|
||||
|
||||
attach(socket)
|
||||
},
|
||||
cancel(reason) {
|
||||
onCancel(reason)
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function cancelError(reason: unknown) {
|
||||
if (isAbortError(reason)) return reason
|
||||
if (reason instanceof Error) return reason
|
||||
return new DOMException(typeof reason === "string" ? reason : "Aborted", "AbortError")
|
||||
}
|
||||
|
||||
function abortError(signal: AbortSignal | undefined) {
|
||||
const reason = signal?.reason
|
||||
if (isAbortError(reason)) return reason
|
||||
return new DOMException(reason instanceof Error ? reason.message : "Aborted", "AbortError")
|
||||
}
|
||||
|
||||
function closeError(message: string, code: number, reason: Buffer) {
|
||||
const details = [`code ${code}`]
|
||||
if (code === 1009) details.push("message too big")
|
||||
if (reason.length > 0) details.push(reason.toString())
|
||||
return new Error(`${message} (${details.join(": ")})`)
|
||||
}
|
||||
|
||||
export * as OpenAIWebSocket from "./ws"
|
||||
@@ -14,18 +14,20 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
|
||||
For SSE endpoints, stay in `HttpApiBuilder.group(...)` and return `HttpServerResponse.stream(...)` from the handler. Annotate the endpoint success schema with `HttpApiSchema.asText({ contentType: "text/event-stream" })` so OpenAPI documents the stream content type.
|
||||
|
||||
Use raw `HttpRouter.use(...)` only for routes that do not fit the request/response HttpApi model, such as WebSocket upgrade routes or catch-all fallback routes. Yield stable services at route-layer construction and close over them in `router.add(...)` callbacks.
|
||||
Use `HttpApiBuilder.group(...)` with `handleRaw(...)` for declared endpoints that need the raw request or response, including WebSocket upgrade routes. This keeps endpoint middleware, routing context, and OpenAPI metadata on one typed route tree.
|
||||
|
||||
```ts
|
||||
export const rawRoute = HttpRouter.use((router) =>
|
||||
export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
|
||||
yield* router.add("GET", PtyPaths.connect, (request) => connectPty(request, pty))
|
||||
return handlers.handleRaw("connect", (ctx) => connectPty(ctx.request, pty))
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Use raw `HttpRouter.use(...)` only for routes outside the declared API surface, such as a catch-all UI fallback.
|
||||
|
||||
Avoid `Effect.provide(SomeLayer)` inside request handlers or raw route callbacks. Stable layers should be provided once at the application/layer boundary, not rebuilt or scoped per request.
|
||||
|
||||
Avoid `HttpRouter.provideRequest(...)` unless the dependency is intentionally request-level. Prefer `HttpRouter.use(...)` for stable app services.
|
||||
@@ -34,4 +36,4 @@ Use `Effect.provideService(...)` in middleware only for request-derived context,
|
||||
|
||||
Public JSON errors should be explicit `Schema.ErrorClass` contracts declared on each endpoint. Use built-in `HttpApiError.*` classes only when their empty/tagged body is the intended wire shape; for SDK-visible errors with messages, define an API error schema such as `ApiNotFoundError` and fail with that exact declared error. Keep domain and storage services free of HttpApi types, and translate expected domain errors at the handler boundary.
|
||||
|
||||
When adding middleware, compose it at the layer boundary and keep the route tree explicit in `server.ts`. Shared router middleware such as auth, workspace routing, and instance context should stay visible where routes are assembled.
|
||||
When adding middleware, declare endpoint-contract middleware on the owning `HttpApiGroup` and provide its implementation layer at the assembly boundary in `server.ts`. Keep router middleware for truly raw fallback routes or global transport policy.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
|
||||
export const EventPaths = {
|
||||
event: "/event",
|
||||
@@ -20,5 +22,8 @@ export const EventApi = HttpApi.make("event").add(
|
||||
}),
|
||||
),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(WorkspaceRoutingMiddleware)
|
||||
.middleware(Authorization)
|
||||
.annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })),
|
||||
)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyTicket } from "@/pty/ticket"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { PTY_CONNECT_TICKET_QUERY } from "@/server/shared/pty-ticket"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { Authorization, PtyConnectAuthorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
@@ -138,9 +139,10 @@ export const PtyApi = HttpApi.make("pty")
|
||||
export const PtyConnectApi = HttpApi.make("pty-connect").add(
|
||||
HttpApiGroup.make("pty-connect")
|
||||
.add(
|
||||
// Decode PTY connection query fields in the raw handler after checking
|
||||
// existence, preserving the established empty-404 response ordering.
|
||||
HttpApiEndpoint.get("connect", PtyPaths.connect, {
|
||||
params: Params,
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Connected session"),
|
||||
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
@@ -149,8 +151,22 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add(
|
||||
summary: "Connect to PTY session",
|
||||
description:
|
||||
"Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
|
||||
transform: (operation) => ({
|
||||
...operation,
|
||||
parameters: [
|
||||
...(operation.parameters ?? []),
|
||||
...["directory", "workspace", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({
|
||||
in: "query",
|
||||
name,
|
||||
schema: { type: "string" },
|
||||
})),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })),
|
||||
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." }))
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(WorkspaceRoutingMiddleware)
|
||||
.middleware(PtyConnectAuthorization),
|
||||
)
|
||||
|
||||
@@ -10,13 +10,13 @@ import {
|
||||
PTY_CONNECT_TOKEN_HEADER,
|
||||
PTY_CONNECT_TOKEN_HEADER_VALUE,
|
||||
} from "@/server/shared/pty-ticket"
|
||||
import { Effect } from "effect"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import * as ApiError from "../errors"
|
||||
import { CursorQuery, Params, PtyPaths } from "../groups/pty"
|
||||
import { CursorQuery, PtyConnectApi } from "../groups/pty"
|
||||
import { WebSocketTracker } from "../websocket-tracker"
|
||||
|
||||
function validOrigin(request: HttpServerRequest.HttpServerRequest, opts: CorsOptions | undefined) {
|
||||
@@ -121,37 +121,39 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
|
||||
}),
|
||||
)
|
||||
|
||||
export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||
export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const cors = yield* CorsConfig
|
||||
yield* router.add(
|
||||
"GET",
|
||||
PtyPaths.connect,
|
||||
Effect.gen(function* () {
|
||||
const params = yield* HttpRouter.schemaPathParams(Params)
|
||||
const exists = yield* pty.get(params.ptyID).pipe(
|
||||
|
||||
return handlers.handleRaw(
|
||||
"connect",
|
||||
Effect.fn("PtyHttpApi.connect")(function* (ctx: {
|
||||
params: { ptyID: PtyID }
|
||||
request: HttpServerRequest.HttpServerRequest
|
||||
}) {
|
||||
const exists = yield* pty.get(ctx.params.ptyID).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(false)),
|
||||
)
|
||||
if (!exists) return HttpServerResponse.empty({ status: 404 })
|
||||
|
||||
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const ticket = new URL(request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY)
|
||||
const query = Schema.decodeUnknownOption(CursorQuery)(yield* HttpServerRequest.ParsedSearchParams)
|
||||
if (Option.isNone(query)) return HttpServerResponse.empty({ status: 400 })
|
||||
const ticket = new URL(ctx.request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY)
|
||||
if (ticket) {
|
||||
const valid = validOrigin(request, cors)
|
||||
? yield* tickets.consume({ ticket, ptyID: params.ptyID, ...(yield* PtyTicket.scope) })
|
||||
const valid = validOrigin(ctx.request, cors)
|
||||
? yield* tickets.consume({ ticket, ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) })
|
||||
: false
|
||||
if (!valid) return HttpServerResponse.empty({ status: 403 })
|
||||
}
|
||||
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
|
||||
const parsedCursor = query.value.cursor === undefined ? undefined : Number(query.value.cursor)
|
||||
const cursor =
|
||||
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1
|
||||
? parsedCursor
|
||||
: undefined
|
||||
const socket = yield* Effect.orDie(request.upgrade)
|
||||
const socket = yield* Effect.orDie(ctx.request.upgrade)
|
||||
const write = yield* socket.writer
|
||||
const closeAccepted = (event: Socket.CloseEvent) =>
|
||||
socket
|
||||
@@ -186,7 +188,7 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||
},
|
||||
}
|
||||
const handler = yield* pty
|
||||
.connect(params.ptyID, adapter, cursor)
|
||||
.connect(ctx.params.ptyID, adapter, cursor)
|
||||
.pipe(
|
||||
Effect.catchTag("Pty.NotFoundError", () =>
|
||||
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
|
||||
@@ -194,12 +196,8 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||
)
|
||||
if (!handler) return HttpServerResponse.empty()
|
||||
|
||||
// No `pending[]`-style early-frame buffer (the legacy handler had one).
|
||||
// `request.upgrade` returns a Socket without running the WS handshake; the
|
||||
// handshake fires inside `socket.runRaw` below, AFTER `pty.connect` resolves
|
||||
// and the message callback is registered. The client therefore can't fire
|
||||
// `open` and start sending until the listener is already wired. Don't move
|
||||
// `runRaw` ahead of `pty.connect` without re-introducing a buffer.
|
||||
// The handshake runs inside `socket.runRaw`, after the input callback is
|
||||
// registered, so the client cannot send frames before PTY input is wired.
|
||||
yield* socket
|
||||
.runRaw((message) => handlePtyInput(handler, message))
|
||||
.pipe(
|
||||
|
||||
@@ -27,6 +27,13 @@ export class V2Authorization extends HttpApiMiddleware.Service<V2Authorization>(
|
||||
},
|
||||
) {}
|
||||
|
||||
export class PtyConnectAuthorization extends HttpApiMiddleware.Service<PtyConnectAuthorization>()(
|
||||
"@opencode/ExperimentalHttpApiPtyConnectAuthorization",
|
||||
{
|
||||
error: HttpApiError.UnauthorizedNoContent,
|
||||
},
|
||||
) {}
|
||||
|
||||
function emptyCredential() {
|
||||
return {
|
||||
username: "",
|
||||
@@ -105,7 +112,6 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()(
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
if (isPublicUIPath(request.method, url.pathname)) return yield* effect
|
||||
if (hasPtyConnectTicketURL(url)) return yield* effect
|
||||
return yield* credentialFromURL(url, request).pipe(
|
||||
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
|
||||
)
|
||||
@@ -129,6 +135,24 @@ export const authorizationLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const ptyConnectAuthorizationLayer = Layer.effect(
|
||||
PtyConnectAuthorization,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* ServerAuth.Config
|
||||
if (!ServerAuth.required(config)) return PtyConnectAuthorization.of((effect) => effect)
|
||||
return PtyConnectAuthorization.of((effect) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
if (hasPtyConnectTicketURL(url)) return yield* effect
|
||||
return yield* credentialFromURL(url, request).pipe(
|
||||
Effect.flatMap((credential) => validateCredential(effect, credential, config)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export const v2AuthorizationLayer = Layer.effect(
|
||||
V2Authorization,
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import { WorkspaceRouteContext } from "./workspace-routing"
|
||||
|
||||
@@ -41,10 +41,3 @@ export const instanceContextLayer = Layer.effect(
|
||||
return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store))
|
||||
}),
|
||||
)
|
||||
|
||||
export const instanceRouterMiddleware = HttpRouter.middleware()(
|
||||
Effect.gen(function* () {
|
||||
const store = yield* InstanceStore.Service
|
||||
return (effect) => provideInstanceContext(effect, store)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4,17 +4,11 @@ import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerR
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { WebSocketTracker } from "../websocket-tracker"
|
||||
|
||||
function webSource(request: HttpServerRequest.HttpServerRequest): Request | undefined {
|
||||
return request.source instanceof Request ? request.source : undefined
|
||||
}
|
||||
|
||||
function requestBody(request: HttpServerRequest.HttpServerRequest) {
|
||||
if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty
|
||||
if (request.source instanceof Request && request.source.body === null) return HttpBody.empty
|
||||
const len = request.headers["content-length"]
|
||||
return HttpBody.raw(webSource(request)?.body ?? null, {
|
||||
contentType: request.headers["content-type"],
|
||||
contentLength: len ? Number(len) : undefined,
|
||||
})
|
||||
return HttpBody.stream(request.stream, request.headers["content-type"], len ? Number(len) : undefined)
|
||||
}
|
||||
|
||||
export function websocket(
|
||||
|
||||
+13
-24
@@ -9,7 +9,7 @@ import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL }
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Context, Data, Effect, Layer, Option, Schema } from "effect"
|
||||
import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpClient, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { InvalidRequestError } from "../errors"
|
||||
@@ -159,14 +159,14 @@ function planWorkspaceRequest(
|
||||
|
||||
function planRequest(
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
sessionWorkspaceID?: WorkspaceID,
|
||||
session?: Session.Info,
|
||||
): Effect.Effect<RequestPlan, never, Workspace.Service> {
|
||||
return Effect.gen(function* () {
|
||||
const url = requestURL(request)
|
||||
const envWorkspaceID = configuredWorkspaceID()
|
||||
const workspaceID = url.pathname.startsWith("/api/")
|
||||
? selectedV2WorkspaceID(url, sessionWorkspaceID)
|
||||
: selectedWorkspaceID(url, sessionWorkspaceID)
|
||||
? selectedV2WorkspaceID(url, session?.workspaceID)
|
||||
: selectedWorkspaceID(url, session?.workspaceID)
|
||||
if (workspaceID === InvalidWorkspaceID) return RequestPlan.InvalidWorkspace()
|
||||
const workspace = yield* resolveWorkspace(workspaceID, envWorkspaceID)
|
||||
|
||||
@@ -178,7 +178,10 @@ function planRequest(
|
||||
return yield* planWorkspaceRequest(request, url, workspace)
|
||||
}
|
||||
|
||||
return RequestPlan.Local({ directory: defaultDirectory(request, url), workspaceID: envWorkspaceID ?? workspaceID })
|
||||
return RequestPlan.Local({
|
||||
directory: session?.directory || defaultDirectory(request, url),
|
||||
workspaceID: envWorkspaceID ?? workspaceID,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -219,11 +222,14 @@ function routeHttpApiWorkspace<E>(
|
||||
const sessionID = getWorkspaceRouteSessionID(requestURL(request))
|
||||
const session = sessionID
|
||||
? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(
|
||||
Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)),
|
||||
Effect.catchIf(
|
||||
(error): error is NotFoundError => NotFoundError.isInstance(error),
|
||||
() => Effect.succeed(undefined),
|
||||
),
|
||||
Effect.catchDefect(() => Effect.succeed(undefined)),
|
||||
)
|
||||
: undefined
|
||||
const plan = yield* planRequest(request, session?.workspaceID)
|
||||
const plan = yield* planRequest(request, session)
|
||||
return yield* routeWorkspace(client, effect, plan)
|
||||
})
|
||||
}
|
||||
@@ -242,20 +248,3 @@ export const workspaceRoutingLayer = Layer.effect(
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export const workspaceRouterMiddleware = HttpRouter.middleware<{ provides: WorkspaceRouteContext }>()(
|
||||
Effect.gen(function* () {
|
||||
const makeWebSocket = yield* Socket.WebSocketConstructor
|
||||
const workspace = yield* Workspace.Service
|
||||
const client = yield* HttpClient.HttpClient
|
||||
return (effect) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const plan = yield* planRequest(request)
|
||||
return yield* routeWorkspace(client, effect, plan)
|
||||
}).pipe(
|
||||
Effect.provideService(Socket.WebSocketConstructor, makeWebSocket),
|
||||
Effect.provideService(Workspace.Service, workspace),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -59,8 +59,14 @@ import { serveUIEffect } from "@/server/shared/ui"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||
import { PublicApi } from "./public"
|
||||
import { authorizationLayer, authorizationRouterMiddleware, v2AuthorizationLayer } from "./middleware/authorization"
|
||||
import {
|
||||
authorizationLayer,
|
||||
authorizationRouterMiddleware,
|
||||
ptyConnectAuthorizationLayer,
|
||||
v2AuthorizationLayer,
|
||||
} from "./middleware/authorization"
|
||||
import { EventApi } from "./groups/event"
|
||||
import { PtyConnectApi } from "./groups/pty"
|
||||
import { eventHandlers } from "./handlers/event"
|
||||
import { configHandlers } from "./handlers/config"
|
||||
import { controlHandlers } from "./handlers/control"
|
||||
@@ -72,15 +78,15 @@ import { mcpHandlers } from "./handlers/mcp"
|
||||
import { permissionHandlers } from "./handlers/permission"
|
||||
import { projectHandlers } from "./handlers/project"
|
||||
import { providerHandlers } from "./handlers/provider"
|
||||
import { ptyConnectRoute, ptyHandlers } from "./handlers/pty"
|
||||
import { ptyConnectHandlers, ptyHandlers } from "./handlers/pty"
|
||||
import { questionHandlers } from "./handlers/question"
|
||||
import { sessionHandlers } from "./handlers/session"
|
||||
import { syncHandlers } from "./handlers/sync"
|
||||
import { tuiHandlers } from "./handlers/tui"
|
||||
import { v2Handlers } from "./handlers/v2"
|
||||
import { workspaceHandlers } from "./handlers/workspace"
|
||||
import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/instance-context"
|
||||
import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing"
|
||||
import { instanceContextLayer } from "./middleware/instance-context"
|
||||
import { workspaceRoutingLayer } from "./middleware/workspace-routing"
|
||||
import { disposeMiddleware } from "./lifecycle"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
import { compressionLayer } from "./middleware/compression"
|
||||
@@ -102,24 +108,27 @@ const cors = (corsOptions?: CorsOptions) =>
|
||||
|
||||
// Route tree:
|
||||
// - rootApiRoutes: typed /global/* and control routes; auth is declared by RootHttpApi.
|
||||
// - eventApiRoutes/rawInstanceRoutes: raw instance routes; auth and workspace routing happen as router middleware.
|
||||
// - instanceApiRoutes: schema routes; auth is declared on each group and workspace context is provided below.
|
||||
// - eventApiRoutes: typed SSE route with instance routing context and its existing API contract.
|
||||
// - ptyConnectApiRoutes: typed WebSocket upgrade route with ticket-aware auth.
|
||||
// - instanceApiRoutes: remaining typed instance routes.
|
||||
// - uiRoute: raw catch-all fallback; auth is router middleware so public static assets can bypass it.
|
||||
const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
|
||||
const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
|
||||
const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
|
||||
const v2HttpApiAuthLayer = v2AuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
|
||||
const workspaceRoutingLive = workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
|
||||
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
|
||||
Layer.provide([controlHandlers, globalHandlers]),
|
||||
Layer.provide(schemaErrorLayer),
|
||||
Layer.provide(httpApiAuthLayer),
|
||||
)
|
||||
const instanceRouterLayer = authorizationRouterMiddleware
|
||||
.combine(instanceRouterMiddleware)
|
||||
.combine(workspaceRouterMiddleware)
|
||||
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuth.Config.defaultLayer))
|
||||
const eventApiRoutes = HttpApiBuilder.layer(EventApi).pipe(
|
||||
Layer.provide(eventHandlers),
|
||||
Layer.provide(instanceRouterLayer),
|
||||
Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer]),
|
||||
)
|
||||
const ptyConnectApiRoutes = HttpApiBuilder.layer(PtyConnectApi).pipe(
|
||||
Layer.provide(ptyConnectHandlers),
|
||||
Layer.provide([ptyConnectHttpApiAuthLayer, workspaceRoutingLive, instanceContextLayer]),
|
||||
)
|
||||
const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
|
||||
Layer.provide([
|
||||
@@ -141,15 +150,8 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
|
||||
]),
|
||||
)
|
||||
|
||||
const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer))
|
||||
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
|
||||
Layer.provide([
|
||||
httpApiAuthLayer,
|
||||
v2HttpApiAuthLayer,
|
||||
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
|
||||
instanceContextLayer,
|
||||
schemaErrorLayer,
|
||||
]),
|
||||
const instanceRoutes = instanceApiRoutes.pipe(
|
||||
Layer.provide([httpApiAuthLayer, v2HttpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]),
|
||||
)
|
||||
|
||||
// `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so
|
||||
@@ -184,7 +186,7 @@ type RouteRequirements =
|
||||
export function createRoutes(
|
||||
corsOptions?: CorsOptions,
|
||||
): Layer.Layer<never, EffectConfig.ConfigError, RouteRequirements> {
|
||||
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute).pipe(
|
||||
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, ptyConnectApiRoutes, instanceRoutes, docRoute, uiRoute).pipe(
|
||||
Layer.provide([
|
||||
errorLayer,
|
||||
compressionLayer,
|
||||
|
||||
@@ -1095,8 +1095,9 @@ export function latest(msgs: WithParts[]) {
|
||||
|
||||
export function fromError(
|
||||
e: unknown,
|
||||
ctx: { providerID: ProviderID; aborted?: boolean },
|
||||
ctx: { model: Provider.Model; aborted?: boolean } | { providerID: ProviderID; aborted?: boolean },
|
||||
): NonNullable<Assistant["error"]> {
|
||||
const providerID = "model" in ctx ? ctx.model.providerID : ctx.providerID
|
||||
switch (true) {
|
||||
case e instanceof DOMException && e.name === "AbortError":
|
||||
return new AbortedError(
|
||||
@@ -1110,11 +1111,23 @@ export function fromError(
|
||||
case LoadAPIKeyError.isInstance(e):
|
||||
return new AuthError(
|
||||
{
|
||||
providerID: ctx.providerID,
|
||||
providerID,
|
||||
message: e.message,
|
||||
},
|
||||
{ cause: e },
|
||||
).toObject()
|
||||
case e instanceof Error &&
|
||||
"model" in ctx &&
|
||||
ctx.model.api.npm === "@ai-sdk/google-vertex/anthropic" &&
|
||||
e.message.includes("Could not load the default credentials"):
|
||||
return new AuthError(
|
||||
{
|
||||
providerID,
|
||||
message:
|
||||
"Anthropic models on Google Vertex require Google Cloud credentials. Use `gcloud auth application-default login` or set `GOOGLE_APPLICATION_CREDENTIALS`.",
|
||||
},
|
||||
{ cause: e },
|
||||
).toObject()
|
||||
case (e as SystemError)?.code === "ECONNRESET":
|
||||
return new APIError(
|
||||
{
|
||||
@@ -1157,7 +1170,7 @@ export function fromError(
|
||||
).toObject()
|
||||
case APICallError.isInstance(e):
|
||||
const parsed = ProviderError.parseAPICallError({
|
||||
providerID: ctx.providerID,
|
||||
providerID,
|
||||
error: e,
|
||||
})
|
||||
if (parsed.type === "context_overflow") {
|
||||
|
||||
@@ -124,7 +124,7 @@ export const layer = Layer.effect(
|
||||
|
||||
const parse = (e: unknown) =>
|
||||
MessageV2.fromError(e, {
|
||||
providerID: input.model.providerID,
|
||||
model: input.model,
|
||||
aborted,
|
||||
})
|
||||
|
||||
|
||||
@@ -351,7 +351,7 @@ export const layer: Layer.Layer<
|
||||
return yield* new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" })
|
||||
}
|
||||
|
||||
const primary = yield* canonical(ctx.worktree)
|
||||
const primary = yield* canonical(ctx.project.worktree)
|
||||
const primaryName = pathSvc.basename(primary).toLowerCase()
|
||||
return yield* Effect.forEach(parseWorktreeList(result.text), (entry) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
SessionUpdate,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { Effect, ManagedRuntime } from "effect"
|
||||
import { ACPNextEvent } from "@/acp-next/event"
|
||||
import { ACPNextSession } from "@/acp-next/session"
|
||||
|
||||
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
|
||||
type PermissionReplyParams = Parameters<OpencodeClient["permission"]["reply"]>[0]
|
||||
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
||||
|
||||
const pollUntil = async (
|
||||
check: () => boolean | Promise<boolean>,
|
||||
message: string,
|
||||
opts?: { timeoutMs?: number; intervalMs?: number },
|
||||
) => {
|
||||
const started = Date.now()
|
||||
while (true) {
|
||||
if (await check()) return
|
||||
if (Date.now() - started > (opts?.timeoutMs ?? 2000)) throw new Error(message)
|
||||
await new Promise((resolve) => setTimeout(resolve, opts?.intervalMs ?? 5))
|
||||
}
|
||||
}
|
||||
|
||||
function makeSessionService() {
|
||||
return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync(
|
||||
ACPNextSession.Service.use((service) => Effect.succeed(service)),
|
||||
)
|
||||
}
|
||||
|
||||
function createHarness(
|
||||
requestPermission: (params: RequestPermissionRequest) => Promise<RequestPermissionResponse> = () =>
|
||||
Promise.resolve({ outcome: { outcome: "selected", optionId: "once" } }),
|
||||
) {
|
||||
const replies: PermissionReplyParams[] = []
|
||||
const requests: RequestPermissionRequest[] = []
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const session = makeSessionService()
|
||||
const sdk = {
|
||||
permission: {
|
||||
reply: (params: PermissionReplyParams) => {
|
||||
replies.push(params)
|
||||
return Promise.resolve({ data: true })
|
||||
},
|
||||
},
|
||||
session: {
|
||||
message: () => Promise.resolve({ data: undefined }),
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const connection = {
|
||||
requestPermission: (params: RequestPermissionRequest) => {
|
||||
requests.push(params)
|
||||
return requestPermission(params)
|
||||
},
|
||||
sessionUpdate: (params: SessionUpdateParams) => {
|
||||
updates.push(params)
|
||||
return Promise.resolve()
|
||||
},
|
||||
} satisfies Pick<AgentSideConnection, "requestPermission" | "sessionUpdate">
|
||||
const subscription = new ACPNextEvent.Subscription({ sdk, connection, session })
|
||||
|
||||
return { connection, replies, requests, sdk, session, subscription, updates }
|
||||
}
|
||||
|
||||
async function createSession(session: ACPNextSession.Interface, sessionId: string, cwd = "/workspace") {
|
||||
await Effect.runPromise(session.create({ id: sessionId, cwd }))
|
||||
}
|
||||
|
||||
async function createKnownTextPart(
|
||||
session: ACPNextSession.Interface,
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
partId: string,
|
||||
) {
|
||||
await Effect.runPromise(
|
||||
session.recordPartMetadata({
|
||||
sessionId,
|
||||
messageId,
|
||||
partId,
|
||||
partType: "text",
|
||||
role: "assistant",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function permissionAsked(
|
||||
sessionID: string,
|
||||
id: string,
|
||||
input: {
|
||||
permission?: string
|
||||
metadata?: Record<string, unknown>
|
||||
tool?: { messageID: string; callID: string }
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
id: `evt_${id}`,
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id,
|
||||
sessionID,
|
||||
permission: input.permission ?? "bash",
|
||||
patterns: ["*"],
|
||||
metadata: input.metadata ?? { command: "printf hello" },
|
||||
always: [],
|
||||
...(input.tool ? { tool: input.tool } : {}),
|
||||
},
|
||||
} as PermissionEvent
|
||||
}
|
||||
|
||||
function textDelta(sessionID: string, messageID: string, partID: string, delta: string) {
|
||||
return {
|
||||
id: `evt_${sessionID}_${messageID}_${partID}`,
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID,
|
||||
messageID,
|
||||
partID,
|
||||
field: "text",
|
||||
delta,
|
||||
},
|
||||
} as Event
|
||||
}
|
||||
|
||||
function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) {
|
||||
return updates
|
||||
.filter((item) => item.sessionId === sessionId)
|
||||
.map((item) => item.update)
|
||||
.filter((update): update is Extract<SessionUpdate, { sessionUpdate: "agent_message_chunk" }> => {
|
||||
return update.sessionUpdate === "agent_message_chunk"
|
||||
})
|
||||
.map((update) => (update.content.type === "text" ? update.content.text : ""))
|
||||
.join("")
|
||||
}
|
||||
|
||||
describe("acp-next permissions", () => {
|
||||
it("sends requestPermission and replies with the selected outcome", async () => {
|
||||
const harness = createHarness()
|
||||
await createSession(harness.session, "ses_a")
|
||||
|
||||
harness.subscription.handle(permissionAsked("ses_a", "perm_1", { tool: { messageID: "msg_1", callID: "call_1" } }))
|
||||
|
||||
await pollUntil(() => harness.replies.length === 1, "permission was never replied")
|
||||
|
||||
expect(harness.requests[0]).toMatchObject({
|
||||
sessionId: "ses_a",
|
||||
toolCall: {
|
||||
toolCallId: "call_1",
|
||||
status: "pending",
|
||||
title: "bash",
|
||||
rawInput: { command: "printf hello" },
|
||||
kind: "execute",
|
||||
locations: [],
|
||||
},
|
||||
options: [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
||||
{ optionId: "always", kind: "allow_always", name: "Always allow" },
|
||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||
],
|
||||
})
|
||||
expect(harness.replies).toEqual([{ requestID: "perm_1", reply: "once", directory: "/workspace" }])
|
||||
})
|
||||
|
||||
it("rejects non-selected outcomes", async () => {
|
||||
const harness = createHarness(() => Promise.resolve({ outcome: { outcome: "cancelled" } }))
|
||||
await createSession(harness.session, "ses_a")
|
||||
|
||||
harness.subscription.handle(permissionAsked("ses_a", "perm_cancelled"))
|
||||
|
||||
await pollUntil(() => harness.replies.length === 1, "cancelled permission was never replied")
|
||||
|
||||
expect(harness.replies[0]).toMatchObject({ requestID: "perm_cancelled", reply: "reject" })
|
||||
})
|
||||
|
||||
it("rejects when requestPermission fails", async () => {
|
||||
const harness = createHarness(() => Promise.reject(new Error("client permission UI failed")))
|
||||
await createSession(harness.session, "ses_a")
|
||||
|
||||
harness.subscription.handle(permissionAsked("ses_a", "perm_failed"))
|
||||
|
||||
await pollUntil(() => harness.replies.length === 1, "failed permission was never rejected")
|
||||
|
||||
expect(harness.replies[0]).toMatchObject({ requestID: "perm_failed", reply: "reject" })
|
||||
})
|
||||
|
||||
it("does not let a blocked session A permission block session B message updates", async () => {
|
||||
let releasePermission: (() => void) | undefined
|
||||
const blocked = new Promise<RequestPermissionResponse>((resolve) => {
|
||||
releasePermission = () => resolve({ outcome: { outcome: "selected", optionId: "once" } })
|
||||
})
|
||||
const harness = createHarness(() => blocked)
|
||||
await createSession(harness.session, "ses_a")
|
||||
await createSession(harness.session, "ses_b")
|
||||
await createKnownTextPart(harness.session, "ses_b", "msg_b", "part_b")
|
||||
|
||||
harness.subscription.handle(permissionAsked("ses_a", "perm_blocked"))
|
||||
await pollUntil(() => harness.requests.length === 1, "blocked permission was never requested")
|
||||
|
||||
await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "session_b_message"))
|
||||
|
||||
expect(textFromUpdates(harness.updates, "ses_b")).toBe("session_b_message")
|
||||
expect(harness.replies).toHaveLength(0)
|
||||
|
||||
releasePermission?.()
|
||||
await pollUntil(() => harness.replies.length === 1, "blocked permission was never replied after release")
|
||||
})
|
||||
|
||||
it("serializes permission requests per session", async () => {
|
||||
let releaseFirst: (() => void) | undefined
|
||||
const first = new Promise<RequestPermissionResponse>((resolve) => {
|
||||
releaseFirst = () => resolve({ outcome: { outcome: "selected", optionId: "once" } })
|
||||
})
|
||||
const harness = createHarness(() =>
|
||||
harness.requests.length === 1 ? first : Promise.resolve({ outcome: { outcome: "selected", optionId: "always" } }),
|
||||
)
|
||||
await createSession(harness.session, "ses_a")
|
||||
|
||||
harness.subscription.handle(permissionAsked("ses_a", "perm_1"))
|
||||
harness.subscription.handle(permissionAsked("ses_a", "perm_2"))
|
||||
|
||||
await pollUntil(() => harness.requests.length === 1, "first permission was never requested")
|
||||
expect(harness.requests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"])
|
||||
|
||||
releaseFirst?.()
|
||||
await pollUntil(() => harness.requests.length === 2, "second permission was not requested after first resolved")
|
||||
await pollUntil(() => harness.replies.length === 2, "serialized permissions were not both replied")
|
||||
|
||||
expect(harness.replies.map((reply) => [reply.requestID, reply.reply])).toEqual([
|
||||
["perm_1", "once"],
|
||||
["perm_2", "always"],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,12 @@ import { testRender, useRenderer } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@/cli/cmd/tui/keymap"
|
||||
import {
|
||||
getOpencodeModeStack,
|
||||
OPENCODE_BASE_MODE,
|
||||
OpencodeKeymapProvider,
|
||||
registerOpencodeKeymap,
|
||||
} from "@/cli/cmd/tui/keymap"
|
||||
|
||||
test("legacy page key aliases compile as page keys", async () => {
|
||||
const sequences: Record<string, string[][]> = {}
|
||||
@@ -52,3 +57,80 @@ test("legacy page key aliases compile as page keys", async () => {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("mode-less bindings stay active when opencode mode changes", async () => {
|
||||
const counts: Record<string, Record<string, number>> = {}
|
||||
|
||||
function Harness() {
|
||||
const renderer = useRenderer()
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
const config = createTuiResolvedConfig()
|
||||
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
|
||||
const offGlobal = keymap.registerLayer({
|
||||
commands: [
|
||||
{ name: "session.list", run() {} },
|
||||
{ name: "session.new", run() {} },
|
||||
{ name: "session.page.up", run() {} },
|
||||
{ name: "session.first", run() {} },
|
||||
],
|
||||
bindings: config.keybinds.gather("test.global", [
|
||||
"session.list",
|
||||
"session.new",
|
||||
"session.page.up",
|
||||
"session.first",
|
||||
]),
|
||||
})
|
||||
const offBase = keymap.registerLayer({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
commands: [{ name: "model.list", run() {} }],
|
||||
bindings: config.keybinds.gather("test.base", ["model.list"]),
|
||||
})
|
||||
const activeCounts = () =>
|
||||
Object.fromEntries(
|
||||
Array.from(
|
||||
keymap.getCommandBindings({
|
||||
visibility: "active",
|
||||
commands: ["session.list", "session.new", "session.page.up", "session.first", "model.list"],
|
||||
}),
|
||||
([command, bindings]) => [command, bindings.length],
|
||||
),
|
||||
)
|
||||
|
||||
counts.base = activeCounts()
|
||||
const popQuestion = getOpencodeModeStack(keymap).push("question")
|
||||
counts.question = activeCounts()
|
||||
popQuestion()
|
||||
const popAutocomplete = getOpencodeModeStack(keymap).push("autocomplete")
|
||||
counts.autocomplete = activeCounts()
|
||||
popAutocomplete()
|
||||
|
||||
onCleanup(() => {
|
||||
offBase()
|
||||
offGlobal()
|
||||
offKeymap()
|
||||
})
|
||||
|
||||
return (
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<box />
|
||||
</OpencodeKeymapProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => <Harness />)
|
||||
try {
|
||||
expect(counts).toEqual({
|
||||
base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 1 },
|
||||
question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 0 },
|
||||
autocomplete: {
|
||||
"session.list": 1,
|
||||
"session.new": 1,
|
||||
"session.page.up": 2,
|
||||
"session.first": 2,
|
||||
"model.list": 0,
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -63,6 +63,7 @@ describe("RuntimeFlags", () => {
|
||||
expect(flags.experimentalWorkspaces).toBe(true)
|
||||
expect(flags.experimentalIconDiscovery).toBe(true)
|
||||
expect(flags.experimentalNativeLlm).toBe(false)
|
||||
expect(flags.experimentalWebSockets).toBe(false)
|
||||
expect(flags.client).toBe("desktop")
|
||||
}),
|
||||
)
|
||||
@@ -91,6 +92,16 @@ describe("RuntimeFlags", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("enables WebSockets via dedicated flag only", () =>
|
||||
Effect.gen(function* () {
|
||||
const explicit = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL_WEBSOCKETS: "true" })))
|
||||
const umbrella = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL: "true" })))
|
||||
|
||||
expect(explicit.experimentalWebSockets).toBe(true)
|
||||
expect(umbrella.experimentalWebSockets).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("layer accepts partial test overrides and fills defaults from Config definitions", () =>
|
||||
Effect.gen(function* () {
|
||||
const flags = yield* readFlags.pipe(
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
extractAccountIdFromClaims,
|
||||
extractAccountId,
|
||||
type IdTokenClaims,
|
||||
} from "../../src/plugin/codex"
|
||||
} from "../../src/plugin/openai/codex"
|
||||
|
||||
function createTestJwt(payload: object): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")
|
||||
@@ -122,6 +122,24 @@ describe("plugin.codex", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("installs websocket transport only when experimental websockets are enabled", async () => {
|
||||
const disabled = await CodexAuthPlugin({} as never)
|
||||
const enabled = await CodexAuthPlugin({} as never, { experimentalWebSockets: true })
|
||||
|
||||
const disabledOptions = await disabled.auth!.loader!(
|
||||
async () => ({ type: "api", key: "sk-test" }) as never,
|
||||
{} as never,
|
||||
)
|
||||
const enabledOptions = await enabled.auth!.loader!(
|
||||
async () => ({ type: "api", key: "sk-test" }) as never,
|
||||
{} as never,
|
||||
)
|
||||
|
||||
expect(disabledOptions.fetch).toBeUndefined()
|
||||
expect(enabledOptions.fetch).toBeFunction()
|
||||
await enabled.dispose?.()
|
||||
})
|
||||
|
||||
test("deduplicates concurrent Codex token refreshes", async () => {
|
||||
let auth = {
|
||||
type: "oauth" as const,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { experimentalWebSocketsEnabled } from "../../src/plugin"
|
||||
|
||||
describe("plugin.openai.websocket rollout", () => {
|
||||
test("enables websockets by default only on pre-release channels", () => {
|
||||
expect(experimentalWebSocketsEnabled({ enabled: false, channel: "local" })).toBe(true)
|
||||
expect(experimentalWebSocketsEnabled({ enabled: false, channel: "dev" })).toBe(true)
|
||||
expect(experimentalWebSocketsEnabled({ enabled: false, channel: "beta" })).toBe(true)
|
||||
expect(experimentalWebSocketsEnabled({ enabled: false, channel: "latest" })).toBe(false)
|
||||
expect(experimentalWebSocketsEnabled({ enabled: false, channel: "prod" })).toBe(false)
|
||||
})
|
||||
|
||||
test("allows releases to opt in through the experimental flag", () => {
|
||||
expect(experimentalWebSocketsEnabled({ enabled: true, channel: "latest" })).toBe(true)
|
||||
expect(experimentalWebSocketsEnabled({ enabled: true, channel: "prod" })).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,622 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { EventEmitter } from "node:events"
|
||||
import type { IncomingMessage } from "node:http"
|
||||
import net, { type AddressInfo, type Socket } from "node:net"
|
||||
import WebSocket, { WebSocketServer } from "ws"
|
||||
import { OpenAIWebSocket } from "../../src/plugin/openai/ws"
|
||||
import { OpenAIWebSocketPool, TITLE_HEADER } from "../../src/plugin/openai/ws-pool"
|
||||
|
||||
describe("plugin.openai.ws", () => {
|
||||
test("derives websocket URLs and sends auth plus protocol headers", async () => {
|
||||
let headers: IncomingMessage["headers"] | undefined
|
||||
await using server = await createWebSocketServer((_socket, request) => {
|
||||
headers = request.headers
|
||||
})
|
||||
|
||||
const socket = await OpenAIWebSocket.connectResponsesWebSocket({
|
||||
url: server.wsUrl,
|
||||
headers: { authorization: "Bearer test", "content-length": "123" },
|
||||
})
|
||||
|
||||
expect(OpenAIWebSocket.toWebSocketUrl("http://example.com/v1/responses")).toBe("ws://example.com/v1/responses")
|
||||
expect(OpenAIWebSocket.toWebSocketUrl("https://example.com/v1/responses")).toBe("wss://example.com/v1/responses")
|
||||
expect(headers?.authorization).toBe("Bearer test")
|
||||
expect(headers?.["openai-beta"]).toBe(OpenAIWebSocket.PROTOCOL_HEADER)
|
||||
expect(headers?.["content-length"]).toBeUndefined()
|
||||
socket.terminate()
|
||||
})
|
||||
|
||||
test("enforces websocket connect timeout", async () => {
|
||||
await using server = await createHangingTcpServer()
|
||||
|
||||
await expect(
|
||||
OpenAIWebSocket.connectResponsesWebSocket({
|
||||
url: server.wsUrl,
|
||||
headers: {},
|
||||
timeout: 20,
|
||||
}),
|
||||
).rejects.toThrow("WebSocket connect timed out")
|
||||
})
|
||||
|
||||
test("enforces websocket send idle timeout", async () => {
|
||||
const socket = new (class extends EventEmitter {
|
||||
send(_data: string, _callback: (error?: Error) => void) {}
|
||||
})() as unknown as WebSocket
|
||||
const invalid: string[] = []
|
||||
const response = OpenAIWebSocket.streamResponsesWebSocket({
|
||||
socket,
|
||||
body: { stream: true, input: "hi" },
|
||||
idleTimeout: 20,
|
||||
onConnectionInvalid: (error) => invalid.push(error.message),
|
||||
})
|
||||
|
||||
await expect(response.text()).rejects.toThrow("idle timeout sending websocket request")
|
||||
expect(invalid).toEqual(["idle timeout sending websocket request"])
|
||||
})
|
||||
|
||||
test("streams websocket events as SSE and handles response.done", async () => {
|
||||
let requestBody: unknown
|
||||
await using server = await createWebSocketServer((socket) => {
|
||||
socket.once("message", (data) => {
|
||||
requestBody = JSON.parse(data.toString())
|
||||
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "hello" }))
|
||||
socket.send(JSON.stringify({ type: "response.done", response: { id: "resp_123" } }))
|
||||
socket.close(1000, "done")
|
||||
})
|
||||
})
|
||||
|
||||
const socket = await OpenAIWebSocket.connectResponsesWebSocket({
|
||||
url: server.wsUrl,
|
||||
headers: { authorization: "Bearer test", "content-length": "123" },
|
||||
})
|
||||
const completed: Record<string, unknown>[] = []
|
||||
const response = OpenAIWebSocket.streamResponsesWebSocket({
|
||||
socket,
|
||||
body: { stream: true, background: true, input: "hi" },
|
||||
onComplete: (event) => completed.push(event),
|
||||
})
|
||||
|
||||
expect(await response.text()).toBe(
|
||||
'data: {"type":"response.output_text.delta","delta":"hello"}\n\ndata: {"type":"response.done","response":{"id":"resp_123"}}\n\ndata: [DONE]\n\n',
|
||||
)
|
||||
expect(requestBody).toEqual({ type: "response.create", input: "hi" })
|
||||
expect(completed).toHaveLength(1)
|
||||
expect(completed[0]?.type).toBe("response.done")
|
||||
})
|
||||
|
||||
test("errors the SSE stream when the server closes before a terminal event", async () => {
|
||||
const invalid: string[] = []
|
||||
await using server = await createWebSocketServer((socket) => {
|
||||
socket.once("message", () => {
|
||||
socket.close(1009, "payload too large")
|
||||
})
|
||||
})
|
||||
|
||||
const socket = await OpenAIWebSocket.connectResponsesWebSocket({ url: server.wsUrl, headers: {} })
|
||||
const response = OpenAIWebSocket.streamResponsesWebSocket({
|
||||
socket,
|
||||
body: { stream: true, input: "hi" },
|
||||
onConnectionInvalid: (error) => invalid.push(error.message),
|
||||
})
|
||||
|
||||
await expect(response.text()).rejects.toThrow(
|
||||
"WebSocket closed before response.completed (code 1009: message too big: payload too large)",
|
||||
)
|
||||
expect(invalid).toEqual([
|
||||
"WebSocket closed before response.completed (code 1009: message too big: payload too large)",
|
||||
])
|
||||
})
|
||||
|
||||
test("rejects unexpected binary websocket frames", async () => {
|
||||
const invalid: string[] = []
|
||||
await using server = await createWebSocketServer((socket) => {
|
||||
socket.once("message", () => {
|
||||
socket.send(Buffer.from("not json text"))
|
||||
})
|
||||
})
|
||||
|
||||
const socket = await OpenAIWebSocket.connectResponsesWebSocket({ url: server.wsUrl, headers: {} })
|
||||
const response = OpenAIWebSocket.streamResponsesWebSocket({
|
||||
socket,
|
||||
body: { stream: true, input: "hi" },
|
||||
onConnectionInvalid: (error) => invalid.push(error.message),
|
||||
})
|
||||
|
||||
await expect(response.text()).rejects.toThrow("Unexpected binary WebSocket frame")
|
||||
expect(invalid).toEqual(["Unexpected binary WebSocket frame"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("plugin.openai.ws-pool", () => {
|
||||
test("reuses one healthy websocket for sequential requests", async () => {
|
||||
let connections = 0
|
||||
let messages = 0
|
||||
await using server = await createWebSocketServer((socket) => {
|
||||
connections += 1
|
||||
socket.on("message", () => {
|
||||
messages += 1
|
||||
socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${messages}` } }))
|
||||
})
|
||||
})
|
||||
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
|
||||
url: server.url,
|
||||
httpFetch: mockFetch(async () => new Response("http")),
|
||||
})
|
||||
|
||||
const first = await fetch("https://api.openai.com/v1/responses", streamRequest())
|
||||
expect(await first.text()).toContain("data: [DONE]")
|
||||
|
||||
const second = await fetch("https://api.openai.com/v1/responses", streamRequest())
|
||||
expect(await second.text()).toContain("data: [DONE]")
|
||||
expect(connections).toBe(1)
|
||||
expect(messages).toBe(2)
|
||||
fetch.close()
|
||||
})
|
||||
|
||||
test("rotates a socket that exceeds max connection age", async () => {
|
||||
let connections = 0
|
||||
await using server = await createWebSocketServer((socket) => {
|
||||
connections += 1
|
||||
socket.on("message", () => {
|
||||
socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${connections}` } }))
|
||||
})
|
||||
})
|
||||
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
|
||||
url: server.url,
|
||||
httpFetch: mockFetch(async () => new Response("http")),
|
||||
maxConnectionAge: 0,
|
||||
})
|
||||
|
||||
const first = await fetch("https://api.openai.com/v1/responses", streamRequest())
|
||||
expect(await first.text()).toContain("data: [DONE]")
|
||||
|
||||
const second = await fetch("https://api.openai.com/v1/responses", streamRequest())
|
||||
expect(await second.text()).toContain("data: [DONE]")
|
||||
expect(connections).toBe(2)
|
||||
fetch.close()
|
||||
})
|
||||
|
||||
test("falls back to HTTP when websocket setup fails and keeps the fallback sticky", async () => {
|
||||
const attempts: string[] = []
|
||||
await using server = await createRejectingWebSocketServer(() => attempts.push("websocket"))
|
||||
const httpRequests: Headers[] = []
|
||||
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
|
||||
url: server.url,
|
||||
httpFetch: mockFetch(async (_input, init) => {
|
||||
httpRequests.push(new Headers(init?.headers))
|
||||
return new Response("http")
|
||||
}),
|
||||
connectTimeout: 100,
|
||||
})
|
||||
|
||||
const first = await fetch("https://api.openai.com/v1/responses", streamRequest({ [TITLE_HEADER]: "false" }))
|
||||
const second = await fetch("https://api.openai.com/v1/responses", streamRequest({ [TITLE_HEADER]: "false" }))
|
||||
|
||||
expect(await first.text()).toBe("http")
|
||||
expect(await second.text()).toBe("http")
|
||||
expect(attempts).toEqual(["websocket"])
|
||||
expect(httpRequests).toHaveLength(2)
|
||||
expect(httpRequests[0]?.get(TITLE_HEADER)).toBeNull()
|
||||
expect(httpRequests[1]?.get(TITLE_HEADER)).toBeNull()
|
||||
fetch.close()
|
||||
})
|
||||
|
||||
test("invalidates but does not reuse a socket after terminal failure frames", async () => {
|
||||
let connections = 0
|
||||
await using server = await createWebSocketServer((socket) => {
|
||||
connections += 1
|
||||
socket.once("message", () => {
|
||||
socket.send(JSON.stringify({ type: connections === 1 ? "response.failed" : "response.completed" }))
|
||||
})
|
||||
})
|
||||
const httpRequests: Headers[] = []
|
||||
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
|
||||
url: server.url,
|
||||
httpFetch: mockFetch(async (_input, init) => {
|
||||
httpRequests.push(new Headers(init?.headers))
|
||||
return new Response("http")
|
||||
}),
|
||||
})
|
||||
|
||||
const first = await fetch("https://api.openai.com/v1/responses", streamRequest())
|
||||
expect(await first.text()).toContain('data: {"type":"response.failed"}')
|
||||
|
||||
const second = await fetch("https://api.openai.com/v1/responses", streamRequest())
|
||||
expect(await second.text()).toContain('data: {"type":"response.completed"}')
|
||||
expect(connections).toBe(2)
|
||||
expect(httpRequests).toHaveLength(0)
|
||||
fetch.close()
|
||||
})
|
||||
|
||||
test("reconnects and replays after websocket connection limit errors", async () => {
|
||||
let connections = 0
|
||||
let messages = 0
|
||||
await using server = await createWebSocketServer((socket) => {
|
||||
connections += 1
|
||||
socket.once("message", () => {
|
||||
messages += 1
|
||||
if (connections === 1) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
status: 400,
|
||||
error: {
|
||||
type: "invalid_request_error",
|
||||
code: "websocket_connection_limit_reached",
|
||||
message: "Responses websocket connection limit reached",
|
||||
},
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_retry" } }))
|
||||
})
|
||||
})
|
||||
const httpRequests: Headers[] = []
|
||||
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
|
||||
url: server.url,
|
||||
httpFetch: mockFetch(async (_input, init) => {
|
||||
httpRequests.push(new Headers(init?.headers))
|
||||
return new Response("http")
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await fetch("https://api.openai.com/v1/responses", streamRequest())
|
||||
const text = await response.text()
|
||||
|
||||
expect(text).not.toContain("websocket_connection_limit_reached")
|
||||
expect(text).toContain('data: {"type":"response.completed","response":{"id":"resp_retry"}}')
|
||||
expect(text).toContain("data: [DONE]")
|
||||
expect(connections).toBe(2)
|
||||
expect(messages).toBe(2)
|
||||
expect(httpRequests).toHaveLength(0)
|
||||
fetch.close()
|
||||
})
|
||||
|
||||
test("falls back to HTTP after websocket connection limit retries are exhausted", async () => {
|
||||
let connections = 0
|
||||
await using server = await createWebSocketServer((socket) => {
|
||||
connections += 1
|
||||
socket.once("message", () => {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
status: 400,
|
||||
error: {
|
||||
type: "invalid_request_error",
|
||||
code: "websocket_connection_limit_reached",
|
||||
message: "Responses websocket connection limit reached",
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
let httpRequests = 0
|
||||
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
|
||||
url: server.url,
|
||||
connectionLimitRetries: 2,
|
||||
httpFetch: mockFetch(async () => {
|
||||
httpRequests += 1
|
||||
return new Response("http")
|
||||
}),
|
||||
})
|
||||
|
||||
const first = await fetch("https://api.openai.com/v1/responses", streamRequest())
|
||||
const second = await fetch("https://api.openai.com/v1/responses", streamRequest())
|
||||
|
||||
expect(await first.text()).toBe("http")
|
||||
expect(await second.text()).toBe("http")
|
||||
expect(connections).toBe(3)
|
||||
expect(httpRequests).toBe(2)
|
||||
fetch.close()
|
||||
})
|
||||
|
||||
test("replays over HTTP when websocket idles before its first event", async () => {
|
||||
let connections = 0
|
||||
await using server = await createWebSocketServer((socket) => {
|
||||
connections += 1
|
||||
socket.once("message", () => {})
|
||||
})
|
||||
const httpRequests: Headers[] = []
|
||||
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
|
||||
url: server.url,
|
||||
idleTimeout: 20,
|
||||
httpFetch: mockFetch(async (_input, init) => {
|
||||
httpRequests.push(new Headers(init?.headers))
|
||||
return new Response("http")
|
||||
}),
|
||||
})
|
||||
|
||||
const first = await fetch("https://api.openai.com/v1/responses", streamRequest())
|
||||
expect(await first.text()).toBe("http")
|
||||
const second = await fetch("https://api.openai.com/v1/responses", streamRequest())
|
||||
|
||||
expect(await second.text()).toBe("http")
|
||||
expect(connections).toBe(1)
|
||||
expect(httpRequests).toHaveLength(2)
|
||||
fetch.close()
|
||||
})
|
||||
|
||||
test("does not replay over HTTP after a websocket event was emitted", async () => {
|
||||
await using server = await createWebSocketServer((socket) => {
|
||||
socket.once("message", () => {
|
||||
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
|
||||
})
|
||||
})
|
||||
const httpRequests: Headers[] = []
|
||||
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
|
||||
url: server.url,
|
||||
idleTimeout: 20,
|
||||
httpFetch: mockFetch(async (_input, init) => {
|
||||
httpRequests.push(new Headers(init?.headers))
|
||||
return new Response("http")
|
||||
}),
|
||||
})
|
||||
|
||||
const first = await fetch("https://api.openai.com/v1/responses", streamRequest())
|
||||
await expect(first.text()).rejects.toThrow("idle timeout waiting for websocket")
|
||||
const second = await fetch("https://api.openai.com/v1/responses", streamRequest())
|
||||
|
||||
expect(await second.text()).toBe("http")
|
||||
expect(httpRequests).toHaveLength(1)
|
||||
fetch.close()
|
||||
})
|
||||
|
||||
test("falls back to HTTP for missing session and title requests", async () => {
|
||||
const httpRequests: Headers[] = []
|
||||
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
|
||||
httpFetch: mockFetch(async (_input, init) => {
|
||||
httpRequests.push(new Headers(init?.headers))
|
||||
return new Response("http")
|
||||
}),
|
||||
})
|
||||
|
||||
const missingSession = await fetch("https://api.openai.com/v1/responses", {
|
||||
method: "POST",
|
||||
headers: { [TITLE_HEADER]: "false" },
|
||||
body: JSON.stringify({ stream: true }),
|
||||
})
|
||||
const title = await fetch("https://api.openai.com/v1/responses", streamRequest({ [TITLE_HEADER]: "true" }))
|
||||
|
||||
expect(await missingSession.text()).toBe("http")
|
||||
expect(await title.text()).toBe("http")
|
||||
expect(httpRequests).toHaveLength(2)
|
||||
expect(httpRequests[0]?.get(TITLE_HEADER)).toBeNull()
|
||||
expect(httpRequests[1]?.get(TITLE_HEADER)).toBeNull()
|
||||
fetch.close()
|
||||
})
|
||||
|
||||
test("falls back to HTTP while a websocket lane is busy", async () => {
|
||||
let connections = 0
|
||||
await using server = await createWebSocketServer((socket) => {
|
||||
connections += 1
|
||||
socket.once("message", () => {
|
||||
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
|
||||
})
|
||||
})
|
||||
const abort = new AbortController()
|
||||
const httpRequests: Headers[] = []
|
||||
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
|
||||
url: server.url,
|
||||
httpFetch: mockFetch(async (_input, init) => {
|
||||
httpRequests.push(new Headers(init?.headers))
|
||||
return new Response("http")
|
||||
}),
|
||||
})
|
||||
|
||||
const first = await fetch("https://api.openai.com/v1/responses", streamRequest({}, abort.signal))
|
||||
const firstText = first.text()
|
||||
await waitFor(() => connections === 1, "websocket did not connect")
|
||||
const second = await fetch("https://api.openai.com/v1/responses", streamRequest())
|
||||
|
||||
expect(await second.text()).toBe("http")
|
||||
expect(httpRequests).toHaveLength(1)
|
||||
expect(connections).toBe(1)
|
||||
abort.abort(new Error("stop"))
|
||||
await expect(firstText).rejects.toThrow("stop")
|
||||
fetch.close()
|
||||
})
|
||||
|
||||
test("reserves a websocket lane while its socket is connecting", async () => {
|
||||
await using server = await createHangingTcpServer()
|
||||
let httpRequests = 0
|
||||
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
|
||||
url: server.url,
|
||||
connectTimeout: 20,
|
||||
httpFetch: mockFetch(async () => {
|
||||
httpRequests += 1
|
||||
return new Response("http")
|
||||
}),
|
||||
})
|
||||
|
||||
const first = fetch("https://api.openai.com/v1/responses", streamRequest())
|
||||
await waitFor(() => server.connections() === 1, "first websocket did not begin connecting")
|
||||
const second = fetch("https://api.openai.com/v1/responses", streamRequest())
|
||||
|
||||
expect(await (await second).text()).toBe("http")
|
||||
expect(await (await first).text()).toBe("http")
|
||||
expect(server.connections()).toBe(1)
|
||||
expect(httpRequests).toBe(2)
|
||||
fetch.close()
|
||||
})
|
||||
|
||||
test("replays over HTTP after an unexpected close before the first event", async () => {
|
||||
let connections = 0
|
||||
await using server = await createWebSocketServer((socket) => {
|
||||
connections += 1
|
||||
socket.once("message", () => {
|
||||
socket.close(1001, "server shutdown")
|
||||
})
|
||||
})
|
||||
const httpRequests: Headers[] = []
|
||||
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
|
||||
url: server.url,
|
||||
httpFetch: mockFetch(async (_input, init) => {
|
||||
httpRequests.push(new Headers(init?.headers))
|
||||
return new Response("http")
|
||||
}),
|
||||
})
|
||||
|
||||
const first = await fetch("https://api.openai.com/v1/responses", streamRequest())
|
||||
expect(await first.text()).toBe("http")
|
||||
const second = await fetch("https://api.openai.com/v1/responses", streamRequest())
|
||||
|
||||
expect(await second.text()).toBe("http")
|
||||
expect(connections).toBe(1)
|
||||
expect(httpRequests).toHaveLength(2)
|
||||
fetch.close()
|
||||
})
|
||||
|
||||
test("does not keep HTTP fallback active after aborting a websocket response", async () => {
|
||||
let connections = 0
|
||||
await using server = await createWebSocketServer((socket) => {
|
||||
connections += 1
|
||||
socket.once("message", () => {
|
||||
if (connections === 1) {
|
||||
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
|
||||
return
|
||||
}
|
||||
socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_456" } }))
|
||||
})
|
||||
})
|
||||
const httpRequests: Headers[] = []
|
||||
const abort = new AbortController()
|
||||
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
|
||||
url: server.url,
|
||||
httpFetch: mockFetch(async (_input, init) => {
|
||||
httpRequests.push(new Headers(init?.headers))
|
||||
return new Response("http")
|
||||
}),
|
||||
})
|
||||
|
||||
const first = await fetch("https://api.openai.com/v1/responses", streamRequest({}, abort.signal))
|
||||
const firstText = first.text()
|
||||
await waitFor(() => connections === 1, "first websocket did not connect")
|
||||
abort.abort(new Error("stop"))
|
||||
await expect(firstText).rejects.toThrow("stop")
|
||||
|
||||
const second = await fetch("https://api.openai.com/v1/responses", streamRequest())
|
||||
|
||||
expect(await second.text()).toContain("data: [DONE]")
|
||||
expect(connections).toBe(2)
|
||||
expect(httpRequests).toHaveLength(0)
|
||||
fetch.close()
|
||||
})
|
||||
|
||||
test("releases the websocket lane when the response body is cancelled", async () => {
|
||||
let connections = 0
|
||||
await using server = await createWebSocketServer((socket) => {
|
||||
connections += 1
|
||||
socket.once("message", () => {
|
||||
if (connections === 1) {
|
||||
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
|
||||
return
|
||||
}
|
||||
socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_after_cancel" } }))
|
||||
})
|
||||
})
|
||||
const httpRequests: Headers[] = []
|
||||
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
|
||||
url: server.url,
|
||||
httpFetch: mockFetch(async (_input, init) => {
|
||||
httpRequests.push(new Headers(init?.headers))
|
||||
return new Response("http")
|
||||
}),
|
||||
})
|
||||
|
||||
const first = await fetch("https://api.openai.com/v1/responses", streamRequest())
|
||||
await waitFor(() => connections === 1, "first websocket did not connect")
|
||||
await first.body!.cancel("stop")
|
||||
|
||||
const second = await fetch("https://api.openai.com/v1/responses", streamRequest())
|
||||
|
||||
expect(await second.text()).toContain("data: [DONE]")
|
||||
expect(connections).toBe(2)
|
||||
expect(httpRequests).toHaveLength(0)
|
||||
fetch.close()
|
||||
})
|
||||
})
|
||||
|
||||
function streamRequest(headers?: Record<string, string>, signal?: AbortSignal): RequestInit {
|
||||
return {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"session-id": "session-1",
|
||||
authorization: "Bearer test",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify({ stream: true, input: "hi" }),
|
||||
signal,
|
||||
}
|
||||
}
|
||||
|
||||
function mockFetch(
|
||||
fn: (
|
||||
input: Parameters<typeof globalThis.fetch>[0],
|
||||
init: Parameters<typeof globalThis.fetch>[1],
|
||||
) => ReturnType<typeof globalThis.fetch>,
|
||||
): typeof globalThis.fetch {
|
||||
return Object.assign(fn, { preconnect: globalThis.fetch.preconnect })
|
||||
}
|
||||
|
||||
async function createWebSocketServer(onConnection: (socket: WebSocket, request: IncomingMessage) => void) {
|
||||
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 })
|
||||
server.on("connection", onConnection)
|
||||
await new Promise<void>((resolve) => server.once("listening", resolve))
|
||||
return websocketServerHandle(server)
|
||||
}
|
||||
|
||||
async function createHangingTcpServer() {
|
||||
const sockets = new Set<Socket>()
|
||||
let connections = 0
|
||||
const server = net.createServer((socket) => {
|
||||
connections += 1
|
||||
sockets.add(socket)
|
||||
socket.on("close", () => sockets.delete(socket))
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
|
||||
const address = server.address() as AddressInfo
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}/v1/responses`,
|
||||
wsUrl: `ws://127.0.0.1:${address.port}/v1/responses`,
|
||||
connections: () => connections,
|
||||
async [Symbol.asyncDispose]() {
|
||||
for (const socket of sockets) socket.destroy()
|
||||
server.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function createRejectingWebSocketServer(onAttempt: () => void) {
|
||||
const server = new WebSocketServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
verifyClient(_info, callback) {
|
||||
onAttempt()
|
||||
callback(false, 401, "denied")
|
||||
},
|
||||
})
|
||||
await new Promise<void>((resolve) => server.once("listening", resolve))
|
||||
return websocketServerHandle(server)
|
||||
}
|
||||
|
||||
function websocketServerHandle(server: WebSocketServer) {
|
||||
const address = server.address() as AddressInfo
|
||||
const url = `http://127.0.0.1:${address.port}/v1/responses`
|
||||
return {
|
||||
url,
|
||||
wsUrl: url.replace(/^http/, "ws"),
|
||||
async [Symbol.asyncDispose]() {
|
||||
for (const socket of server.clients) socket.terminate()
|
||||
server.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean, message: string) {
|
||||
const started = Date.now()
|
||||
while (!predicate()) {
|
||||
if (Date.now() - started > 1_000) throw new Error(message)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1))
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,15 @@ import path from "path"
|
||||
import { tmpdirScoped } from "../fixture/fixture"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import { Database } from "@/storage/db"
|
||||
import { ProjectTable } from "@/project/project.sql"
|
||||
import { SessionTable } from "@/session/session.sql"
|
||||
import { PermissionTable } from "@/session/session.sql"
|
||||
import { WorkspaceTable } from "@/control-plane/workspace.sql"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { Cause, Effect, Exit, Layer, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
@@ -31,6 +40,10 @@ function run<A, E>(fn: (svc: Project.Interface) => Effect.Effect<A, E>) {
|
||||
})
|
||||
}
|
||||
|
||||
function remoteProjectID(remote: string) {
|
||||
return ProjectID.make(Hash.fast(`git-remote:${remote}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock ChildProcessSpawner layer that intercepts git subcommands
|
||||
* matching `failArg` and returns exit code 128, while delegating everything
|
||||
@@ -154,28 +167,91 @@ describe("Project.fromDirectory", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps root commit identity when origin exists", () =>
|
||||
it.live("prefers normalized origin remote over root commit", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
yield* Effect.promise(() => $`git remote add origin git@github.com:Test-Org/Test-Repo.git`.cwd(tmp).quiet())
|
||||
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const root = (yield* Effect.promise(() => $`git rev-list --max-parents=0 HEAD`.cwd(tmp).text())).trim()
|
||||
|
||||
expect(project.id).toBe(ProjectID.make(root))
|
||||
expect(project.id).toBe(remoteProjectID("github.com/Test-Org/Test-Repo"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps cached project identity when origin becomes available", () =>
|
||||
it.live("normalizes equivalent origin URL forms to the same project ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const ssh = yield* tmpdirScoped({ git: true })
|
||||
const https = yield* tmpdirScoped({ git: true })
|
||||
yield* Effect.promise(() => $`git remote add origin git@github.com:owner/repo.git`.cwd(ssh).quiet())
|
||||
yield* Effect.promise(() => $`git remote add origin https://github.com/owner/repo.git`.cwd(https).quiet())
|
||||
|
||||
const { project: a } = yield* run((svc) => svc.fromDirectory(ssh))
|
||||
const { project: b } = yield* run((svc) => svc.fromDirectory(https))
|
||||
|
||||
expect(a.id).toBe(remoteProjectID("github.com/owner/repo"))
|
||||
expect(b.id).toBe(a.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("migrates cached root project data when origin becomes available", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const projects = yield* Project.Service
|
||||
const { project: rootProject } = yield* projects.fromDirectory(tmp)
|
||||
const remoteID = remoteProjectID("github.com/acme/app")
|
||||
const sessionID = crypto.randomUUID() as SessionID
|
||||
const workspaceID = WorkspaceID.ascending()
|
||||
|
||||
yield* Effect.sync(() => {
|
||||
Database.use((db) => {
|
||||
db.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: rootProject.id,
|
||||
slug: sessionID,
|
||||
directory: tmp,
|
||||
title: "test",
|
||||
version: "0.0.0-test",
|
||||
time_created: Date.now(),
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.run()
|
||||
db.insert(PermissionTable)
|
||||
.values({
|
||||
project_id: rootProject.id,
|
||||
data: [{ permission: "edit", pattern: "*", action: "allow" }],
|
||||
time_created: Date.now(),
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.run()
|
||||
db.insert(WorkspaceTable)
|
||||
.values({
|
||||
id: workspaceID,
|
||||
type: "local",
|
||||
name: "test",
|
||||
project_id: rootProject.id,
|
||||
})
|
||||
.run()
|
||||
})
|
||||
})
|
||||
yield* Effect.promise(() => $`git remote add origin git@github.com:acme/app.git`.cwd(tmp).quiet())
|
||||
|
||||
const { project } = yield* projects.fromDirectory(tmp)
|
||||
|
||||
expect(project.id).toBe(rootProject.id)
|
||||
expect(project.id).toBe(remoteID)
|
||||
expect(
|
||||
Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, rootProject.id)).get()),
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get())?.project_id,
|
||||
).toBe(remoteID)
|
||||
expect(
|
||||
Database.use((db) => db.select().from(PermissionTable).where(eq(PermissionTable.project_id, remoteID)).get()),
|
||||
).toBeDefined()
|
||||
expect(
|
||||
Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get())
|
||||
?.project_id,
|
||||
).toBe(remoteID)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -214,6 +214,22 @@ describe("Worktree", () => {
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"lists the active linked worktree but not the project checkout",
|
||||
() =>
|
||||
withCreatedWorktree(undefined, ({ info }) =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const svc = yield* Worktree.Service
|
||||
const list = yield* svc.list().pipe(provideInstance(info.directory))
|
||||
|
||||
expect(list.map((item) => item.name)).toContain(info.name)
|
||||
expect(list.map((item) => item.name)).not.toContain(path.basename(test.directory).toLowerCase())
|
||||
}),
|
||||
),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"create with custom name",
|
||||
() =>
|
||||
|
||||
@@ -4,8 +4,8 @@ Use these patterns for server and HttpApi middleware tests in this directory.
|
||||
|
||||
- Prefer focused middleware tests with tiny fake routes over full API route trees when testing routing, context, proxying, or middleware policy.
|
||||
- Use `testEffect(...)` with `NodeHttpServer.layerTest` for the primary in-test server and make relative `HttpClient` requests against it.
|
||||
- Use `HttpRouter.add(...)` probe routes that expose the context under test, such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`.
|
||||
- Compose middleware in the same order as production when testing interactions, for example `instanceRouterMiddleware.combine(workspaceRouterMiddleware)`.
|
||||
- Use tiny `HttpApiBuilder` probe groups that declare the typed middleware under test and expose context such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`.
|
||||
- Declare middleware in the same order as production when testing interactions, for example `InstanceContextMiddleware` followed by `WorkspaceRoutingMiddleware`.
|
||||
- For secondary upstream servers, build Effect `NodeHttpServer.layer(...)` into the current test scope with `Layer.build(...)` so the listener stays alive until the test scope exits.
|
||||
- Avoid `Bun.serve` when testing Effect HTTP middleware. Keep the test in the Effect HTTP stack unless the production path being tested is Bun-specific.
|
||||
- For WebSocket paths, use `Socket.makeWebSocket(...)` from the test client and assert protocol forwarding or frame relay when relevant.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
||||
import { Effect, Fiber, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
@@ -12,9 +13,17 @@ import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
||||
import { InstanceLayer } from "../../src/project/instance-layer"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { Session } from "../../src/session/session"
|
||||
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
|
||||
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
|
||||
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import {
|
||||
InstanceContextMiddleware,
|
||||
instanceContextLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/instance-context"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
workspaceRoutingLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
|
||||
import { withFixedWorkspaceID } from "../fixture/flag"
|
||||
@@ -47,9 +56,10 @@ const it = testEffect(
|
||||
),
|
||||
)
|
||||
|
||||
const instanceContextTestLayer = instanceRouterMiddleware
|
||||
.combine(workspaceRouterMiddleware)
|
||||
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
|
||||
const instanceContextTestLayer = Layer.mergeAll(
|
||||
instanceContextLayer,
|
||||
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
|
||||
)
|
||||
|
||||
const localAdapter = (directory: string): WorkspaceAdapter => ({
|
||||
name: "Local Test",
|
||||
@@ -80,20 +90,57 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri
|
||||
const probeInstanceContext = Effect.gen(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
const workspaceID = yield* WorkspaceRef
|
||||
return yield* HttpServerResponse.json({
|
||||
return {
|
||||
directory: instance?.directory,
|
||||
worktree: instance?.worktree,
|
||||
projectID: instance?.project.id,
|
||||
workspaceID,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const serveProbe = (probePath: HttpRouter.PathInput = "/probe") =>
|
||||
HttpRouter.add("GET", probePath, probeInstanceContext).pipe(
|
||||
Layer.provide(instanceContextTestLayer),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
const ProbeResult = Schema.Struct({
|
||||
directory: Schema.optional(Schema.String),
|
||||
worktree: Schema.optional(Schema.String),
|
||||
projectID: Schema.optional(Schema.String),
|
||||
workspaceID: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const ProbeApi = HttpApi.make("instance-context-probe").add(
|
||||
HttpApiGroup.make("probe")
|
||||
.add(
|
||||
HttpApiEndpoint.get("get", "/probe", { query: WorkspaceRoutingQuery, success: ProbeResult }),
|
||||
HttpApiEndpoint.get("session", "/session", { query: WorkspaceRoutingQuery, success: ProbeResult }),
|
||||
HttpApiEndpoint.post("dispose", "/dispose-probe", {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: Schema.Boolean,
|
||||
}),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(WorkspaceRoutingMiddleware),
|
||||
)
|
||||
|
||||
const probeHandlers = HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
|
||||
handlers
|
||||
.handle("get", () => probeInstanceContext)
|
||||
.handle("session", () => probeInstanceContext)
|
||||
.handle(
|
||||
"dispose",
|
||||
Effect.fn("InstanceContextProbe.dispose")(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
if (!instance) return false
|
||||
yield* markInstanceForDisposal(instance)
|
||||
return true
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const probeRoutes = HttpApiBuilder.layer(ProbeApi).pipe(
|
||||
Layer.provide(probeHandlers),
|
||||
Layer.provide(instanceContextTestLayer),
|
||||
Layer.provide(Layer.mock(Session.Service)({})),
|
||||
)
|
||||
|
||||
const serveProbe = () => probeRoutes.pipe(HttpRouter.serve, Layer.build)
|
||||
|
||||
const waitDisposedEvent = waitGlobalBusEvent({
|
||||
message: "timed out waiting for instance disposal",
|
||||
@@ -101,19 +148,9 @@ const waitDisposedEvent = waitGlobalBusEvent({
|
||||
}).pipe(Effect.map((event) => ({ directory: event.directory, workspace: event.workspace })))
|
||||
|
||||
const serveDisposeProbe = () =>
|
||||
HttpRouter.serve(
|
||||
HttpRouter.add(
|
||||
"POST",
|
||||
"/dispose-probe",
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
if (!instance) return HttpServerResponse.empty({ status: 500 })
|
||||
yield* markInstanceForDisposal(instance)
|
||||
return yield* HttpServerResponse.json(true)
|
||||
}),
|
||||
).pipe(Layer.provide(instanceContextTestLayer)),
|
||||
{ middleware: disposeMiddleware, disableListenLog: true, disableLogger: true },
|
||||
).pipe(Layer.build)
|
||||
HttpRouter.serve(probeRoutes, { middleware: disposeMiddleware, disableListenLog: true, disableLogger: true }).pipe(
|
||||
Layer.build,
|
||||
)
|
||||
|
||||
describe("HttpApi instance context middleware", () => {
|
||||
it.live("provides instance context from the routed directory", () =>
|
||||
@@ -129,6 +166,7 @@ describe("HttpApi instance context middleware", () => {
|
||||
directory: dir,
|
||||
worktree: dir,
|
||||
projectID: project.project.id,
|
||||
workspaceID: null,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -156,7 +194,7 @@ describe("HttpApi instance context middleware", () => {
|
||||
type: "instance-context-workspace-ref",
|
||||
directory: workspaceDir,
|
||||
})
|
||||
yield* serveProbe("/session")
|
||||
yield* serveProbe()
|
||||
|
||||
const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe(
|
||||
HttpClientRequest.setHeader("x-opencode-directory", dir),
|
||||
@@ -269,7 +307,7 @@ describe("HttpApi instance context middleware", () => {
|
||||
// is true. Combined with the env override, the route must stay Local with
|
||||
// the configured workspace id (not divert to the requested workspace's
|
||||
// local directory).
|
||||
yield* serveProbe("/session")
|
||||
yield* serveProbe()
|
||||
|
||||
const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe(
|
||||
HttpClientRequest.setHeader("x-opencode-directory", dir),
|
||||
|
||||
+5
-4
@@ -4,6 +4,7 @@ import { HttpRouter } from "effect/unstable/http"
|
||||
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
|
||||
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { ServerAuth } from "../../src/server/auth"
|
||||
import { PtyID } from "../../src/pty/schema"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
@@ -35,7 +36,7 @@ function app(input: { password?: string; username?: string }) {
|
||||
}
|
||||
|
||||
function basic(username: string, password: string) {
|
||||
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
return ServerAuth.header({ username, password }) ?? ""
|
||||
}
|
||||
|
||||
async function cancelBody(response: Response) {
|
||||
@@ -47,8 +48,8 @@ afterEach(async () => {
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("HttpApi raw route authorization", () => {
|
||||
test("requires configured auth before opening the raw instance event stream", async () => {
|
||||
describe("HttpApi instance route authorization", () => {
|
||||
test("requires configured auth before opening the instance event stream", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const server = app({ password: "secret" })
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
@@ -64,7 +65,7 @@ describe("HttpApi raw route authorization", () => {
|
||||
expect(authed.status).toBe(200)
|
||||
})
|
||||
|
||||
test("requires configured auth before resolving the raw PTY websocket route", async () => {
|
||||
test("requires configured auth before resolving the PTY websocket route", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const server = app({ password: "secret" })
|
||||
const route = PtyPaths.connect.replace(":ptyID", PtyID.ascending())
|
||||
@@ -9,10 +9,11 @@
|
||||
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer, Scope } from "effect"
|
||||
import { Deferred, Effect, Layer, Schema, Scope } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
@@ -20,8 +21,16 @@ import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
||||
import { InstanceLayer } from "../../src/project/instance-layer"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
|
||||
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { Session } from "../../src/session/session"
|
||||
import {
|
||||
InstanceContextMiddleware,
|
||||
instanceContextLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/instance-context"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
workspaceRoutingLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
|
||||
import { workspaceLayerWithRuntimeFlags } from "../fixture/workspace"
|
||||
@@ -52,9 +61,10 @@ const it = testEffect(
|
||||
),
|
||||
)
|
||||
|
||||
const instanceContextTestLayer = instanceRouterMiddleware
|
||||
.combine(workspaceRouterMiddleware)
|
||||
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
|
||||
const instanceContextTestLayer = Layer.mergeAll(
|
||||
instanceContextLayer,
|
||||
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
|
||||
)
|
||||
|
||||
const localAdapter = (directory: string): WorkspaceAdapter => ({
|
||||
name: "Local Test",
|
||||
@@ -87,6 +97,46 @@ const captureInstance = Effect.gen(function* () {
|
||||
return { directory: instance?.directory, workspaceID } satisfies Capture
|
||||
})
|
||||
|
||||
const ProbeApi = HttpApi.make("handler-context-probe").add(
|
||||
HttpApiGroup.make("probe")
|
||||
.add(
|
||||
HttpApiEndpoint.post("fork", "/fork-probe", { query: WorkspaceRoutingQuery, success: Schema.Boolean }),
|
||||
HttpApiEndpoint.post("streamWithout", "/stream-probe-without", {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/json" })),
|
||||
}),
|
||||
HttpApiEndpoint.post("streamWith", "/stream-probe-with", {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/json" })),
|
||||
}),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(WorkspaceRoutingMiddleware),
|
||||
)
|
||||
|
||||
const serveProbes = (input: {
|
||||
fork?: Effect.Effect<boolean, never, Scope.Scope>
|
||||
streamWithout?: Effect.Effect<HttpServerResponse.HttpServerResponse>
|
||||
streamWith?: Effect.Effect<HttpServerResponse.HttpServerResponse>
|
||||
}) =>
|
||||
HttpApiBuilder.layer(ProbeApi).pipe(
|
||||
Layer.provide(
|
||||
HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
|
||||
handlers
|
||||
.handle("fork", () => input.fork ?? Effect.succeed(false))
|
||||
.handleRaw(
|
||||
"streamWithout",
|
||||
() => input.streamWithout ?? Effect.succeed(HttpServerResponse.empty({ status: 404 })),
|
||||
)
|
||||
.handleRaw("streamWith", () => input.streamWith ?? Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
|
||||
),
|
||||
),
|
||||
Layer.provide(instanceContextTestLayer),
|
||||
Layer.provide(Layer.mock(Session.Service)({})),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
|
||||
describe("HttpApi handler context inheritance", () => {
|
||||
// Mirrors handlers/session.ts:281 promptAsync. The forked fiber inherits
|
||||
// the request's Context — including InstanceRef and WorkspaceRef provided
|
||||
@@ -96,22 +146,20 @@ describe("HttpApi handler context inheritance", () => {
|
||||
const { dir, workspace } = yield* setupWorkspace("local-fork")
|
||||
const capture = yield* Deferred.make<Capture>()
|
||||
|
||||
yield* HttpRouter.add(
|
||||
"POST",
|
||||
"/fork-probe",
|
||||
Effect.gen(function* () {
|
||||
yield* serveProbes({
|
||||
fork: Effect.gen(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Deferred.succeed(capture, yield* captureInstance)
|
||||
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
|
||||
return HttpServerResponse.empty({ status: 204 })
|
||||
return true
|
||||
}),
|
||||
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
|
||||
})
|
||||
|
||||
const response = yield* HttpClient.post(
|
||||
`/fork-probe?directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`,
|
||||
)
|
||||
expect(response.status).toBe(204)
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const observed = yield* Deferred.await(capture).pipe(Effect.timeout("2 seconds"))
|
||||
expect(observed.directory).toBe(dir)
|
||||
@@ -129,10 +177,8 @@ describe("HttpApi handler context inheritance", () => {
|
||||
const withoutCapture = yield* Deferred.make<Capture>()
|
||||
const withCapture = yield* Deferred.make<Capture>()
|
||||
|
||||
yield* HttpRouter.add(
|
||||
"POST",
|
||||
"/stream-probe-without",
|
||||
Effect.gen(function* () {
|
||||
yield* serveProbes({
|
||||
streamWithout: Effect.gen(function* () {
|
||||
return HttpServerResponse.stream(
|
||||
Stream.fromEffect(
|
||||
Effect.gen(function* () {
|
||||
@@ -143,12 +189,7 @@ describe("HttpApi handler context inheritance", () => {
|
||||
{ contentType: "application/json" },
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
|
||||
|
||||
yield* HttpRouter.add(
|
||||
"POST",
|
||||
"/stream-probe-with",
|
||||
Effect.gen(function* () {
|
||||
streamWith: Effect.gen(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
const workspaceID = yield* WorkspaceRef
|
||||
return HttpServerResponse.stream(
|
||||
@@ -161,7 +202,7 @@ describe("HttpApi handler context inheritance", () => {
|
||||
{ contentType: "application/json" },
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
|
||||
})
|
||||
|
||||
const queryString = `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`
|
||||
const responseWithout = yield* HttpClient.post(`/stream-probe-without?${queryString}`)
|
||||
|
||||
@@ -147,6 +147,14 @@ describe("pty HttpApi bridge", () => {
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
test("returns 404 for missing PTY websocket before decoding cursor query", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const response = await app().request(`${PtyPaths.connect.replace(":ptyID", PtyID.ascending())}?cursor=a&cursor=b`, {
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
test("returns typed not found errors for missing PTY HTTP resources", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
|
||||
@@ -9,6 +9,7 @@ type OpenApiResponse = {
|
||||
readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
|
||||
}
|
||||
type OpenApiOperation = {
|
||||
readonly parameters?: ReadonlyArray<{ readonly name: string; readonly in: string }>
|
||||
readonly responses?: Record<string, OpenApiResponse>
|
||||
readonly security?: unknown
|
||||
}
|
||||
@@ -207,6 +208,11 @@ describe("PublicApi OpenAPI v2 errors", () => {
|
||||
expect(componentName(responseRef(spec.paths["/pty/{ptyID}/connect-token"]?.post?.responses?.["403"]) ?? "")).toBe(
|
||||
"PtyForbiddenError",
|
||||
)
|
||||
expect(
|
||||
spec.paths["/pty/{ptyID}/connect"]?.get?.parameters
|
||||
?.filter((parameter) => parameter.in === "query")
|
||||
.map((parameter) => parameter.name),
|
||||
).toEqual(["directory", "workspace", "cursor", "ticket"])
|
||||
})
|
||||
|
||||
test("documents project not-found errors", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
@@ -27,7 +28,9 @@ import * as DateTime from "effect/DateTime"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { disposeAllInstances, provideInstanceEffect, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { TestLLMServer } from "../lib/llm-server"
|
||||
import { testProviderConfig } from "../lib/test-provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
void Log.init({ print: false })
|
||||
@@ -368,6 +371,45 @@ describe("session HttpApi", () => {
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.live("uses the persisted session directory for prompt requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLMServer
|
||||
yield* llm.text("ok", { usage: { input: 1, output: 1 } })
|
||||
|
||||
const config = testProviderConfig(llm.url)
|
||||
const sessionDirectory = yield* tmpdirScoped({ git: true, config })
|
||||
const requestDirectory = yield* tmpdirScoped({ git: true, config })
|
||||
const session = yield* createSession({ title: "directory regression" }).pipe(
|
||||
provideInstanceEffect(sessionDirectory),
|
||||
)
|
||||
|
||||
const response = yield* request(
|
||||
`${pathFor(SessionPaths.prompt, { sessionID: session.id })}?directory=${encodeURIComponent(requestDirectory)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
agent: "build",
|
||||
model: { providerID: "test", modelID: "test-model" },
|
||||
parts: [{ type: "text", text: "which directory?" }],
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
yield* responseJson(response)
|
||||
|
||||
const messages = yield* Session.use
|
||||
.messages({ sessionID: session.id })
|
||||
.pipe(provideInstanceEffect(sessionDirectory), Effect.orDie)
|
||||
const assistant = messages.find((message) => message.info.role === "assistant")
|
||||
expect(assistant?.info.role === "assistant" ? assistant.info.path : undefined).toEqual({
|
||||
cwd: sessionDirectory,
|
||||
root: sessionDirectory,
|
||||
})
|
||||
}).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(CrossSpawnSpawner.defaultLayer)),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"returns v2 public request errors for cursor and workspace query failures",
|
||||
() =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Effect, Layer, Queue, Ref } from "effect"
|
||||
import { Context, Effect, Layer, Queue, Ref, Schema, Stream } from "effect"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
HttpClient,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
HttpServerResponse,
|
||||
} from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
|
||||
import Http from "node:http"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
@@ -20,10 +21,13 @@ import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { Session } from "../../src/session/session"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
WorkspaceRouteContext,
|
||||
workspaceRouterMiddleware,
|
||||
workspaceRoutingLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { HEADER as FenceHeader } from "../../src/server/shared/fence"
|
||||
import { Database } from "../../src/storage/db"
|
||||
@@ -60,13 +64,14 @@ type ProxiedRequest = {
|
||||
url: string
|
||||
method: string
|
||||
headers: Record<string, string>
|
||||
body: string
|
||||
}
|
||||
|
||||
type TestHandler<E, R> = (
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>
|
||||
|
||||
const workspaceRoutingTestLayer = workspaceRouterMiddleware.layer.pipe(
|
||||
const workspaceRoutingTestLayer = workspaceRoutingLayer.pipe(
|
||||
Layer.provide([Socket.layerWebSocketConstructorGlobal, FetchHttpClient.layer]),
|
||||
)
|
||||
|
||||
@@ -177,7 +182,12 @@ const startRemoteWorkspaceHttpServer = <E, R>(
|
||||
// everything else is the request being proxied by the middleware.
|
||||
const sync = syncResponse(request)
|
||||
if (sync) return yield* sync
|
||||
return yield* handler({ url: request.url, method: request.method, headers: request.headers })
|
||||
return yield* handler({
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: yield* request.text,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -203,16 +213,45 @@ const echoWebSocket = (request: HttpServerRequest.HttpServerRequest) =>
|
||||
return HttpServerResponse.empty()
|
||||
})
|
||||
|
||||
const serveRouteContextProbe = HttpRouter.add(
|
||||
"GET",
|
||||
"/probe",
|
||||
Effect.gen(function* () {
|
||||
// The fake route exposes the context installed by the middleware, so tests
|
||||
// can assert routing decisions without pulling in the production API tree.
|
||||
const route = yield* WorkspaceRouteContext
|
||||
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
|
||||
}),
|
||||
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
|
||||
const ProbeResult = Schema.Struct({
|
||||
directory: Schema.String,
|
||||
workspaceID: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const ProbeApi = HttpApi.make("workspace-routing-probe").add(
|
||||
HttpApiGroup.make("probe")
|
||||
.add(
|
||||
HttpApiEndpoint.get("get", "/probe", { query: WorkspaceRoutingQuery, success: ProbeResult }),
|
||||
HttpApiEndpoint.patch("patch", "/probe", { query: WorkspaceRoutingQuery, success: Schema.Boolean }),
|
||||
HttpApiEndpoint.get("session", "/session", { query: WorkspaceRoutingQuery, success: ProbeResult }),
|
||||
HttpApiEndpoint.get("workspace", WorkspacePaths.list, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: ProbeResult,
|
||||
}),
|
||||
)
|
||||
.middleware(WorkspaceRoutingMiddleware),
|
||||
)
|
||||
|
||||
const routeContextResponse = Effect.gen(function* () {
|
||||
const route = yield* WorkspaceRouteContext
|
||||
return { directory: route.directory, workspaceID: route.workspaceID }
|
||||
})
|
||||
|
||||
const probeHandlers = HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
|
||||
handlers
|
||||
.handle("get", () => routeContextResponse)
|
||||
.handle("patch", () => Effect.succeed(false))
|
||||
.handle("session", () => routeContextResponse)
|
||||
.handle("workspace", () => routeContextResponse),
|
||||
)
|
||||
|
||||
const serveProbe = HttpApiBuilder.layer(ProbeApi).pipe(
|
||||
Layer.provide(probeHandlers),
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
Layer.provide(Layer.mock(Session.Service)({})),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
|
||||
describe("HttpApi workspace routing middleware", () => {
|
||||
it.live("proxies remote workspace HTTP requests through the selected workspace target", () =>
|
||||
@@ -250,19 +289,20 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
|
||||
// The local /probe handler should not run. Selecting a remote workspace
|
||||
// should make the middleware call HttpApiProxy.http instead.
|
||||
yield* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe(
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
yield* serveProbe
|
||||
|
||||
const body = '{"title":"Remote workspace request"}'
|
||||
const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspace.id}&keep=yes`).pipe(
|
||||
HttpClientRequest.setHeaders({
|
||||
"content-type": "application/json",
|
||||
"x-opencode-directory": "/secret/path",
|
||||
"x-opencode-workspace": "internal",
|
||||
}),
|
||||
HttpClientRequest.bodyStream(
|
||||
Stream.make(new TextEncoder().encode('{"title":"Remote '), new TextEncoder().encode('workspace request"}')),
|
||||
{ contentType: "application/json" },
|
||||
),
|
||||
HttpClient.execute,
|
||||
Effect.timeout("2 seconds"),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(201)
|
||||
@@ -275,6 +315,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
expect(forwardedURL?.searchParams.get("keep")).toBe("yes")
|
||||
expect(forwardedURL?.searchParams.get("workspace")).toBeNull()
|
||||
expect(forwarded?.method).toBe("PATCH")
|
||||
expect(forwarded?.body).toBe(body)
|
||||
expect(forwarded?.headers["content-type"]).toBe("application/json")
|
||||
expect(forwarded?.headers["x-target-auth"]).toBe("secret")
|
||||
expect(forwarded?.headers["x-opencode-directory"]).toBeUndefined()
|
||||
@@ -325,9 +366,11 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
startWorkspaceSyncing: () => Effect.die("unused"),
|
||||
})
|
||||
|
||||
yield* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe(
|
||||
yield* HttpApiBuilder.layer(ProbeApi).pipe(
|
||||
Layer.provide(probeHandlers),
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
Layer.provide(Layer.succeed(Workspace.Service, workspace)),
|
||||
Layer.provide(Layer.mock(Session.Service)({})),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
@@ -351,11 +394,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
url: "http://127.0.0.1:1/base",
|
||||
})
|
||||
|
||||
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
yield* serveProbe
|
||||
|
||||
const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
|
||||
|
||||
@@ -378,11 +417,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
|
||||
// The client connects to the local test server. The middleware should
|
||||
// detect the WebSocket upgrade and proxy it to the remote /base/probe.
|
||||
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
yield* serveProbe
|
||||
|
||||
const socket = yield* Socket.makeWebSocket(
|
||||
`${(yield* serverUrl).replace(/^http/, "ws")}/probe?workspace=${workspace.id}`,
|
||||
@@ -406,11 +441,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
const workspaceID = WorkspaceID.ascending("wrk_missing")
|
||||
// If the middleware resolves the workspace first, this handler is never
|
||||
// reached and the response should be the middleware error response.
|
||||
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
yield* serveProbe
|
||||
|
||||
const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
|
||||
|
||||
@@ -433,14 +464,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
|
||||
// GET /session is a control-plane route: it lists sessions for the main
|
||||
// process and should not be redirected into the selected workspace target.
|
||||
yield* HttpRouter.add(
|
||||
"GET",
|
||||
"/session",
|
||||
Effect.gen(function* () {
|
||||
const route = yield* WorkspaceRouteContext
|
||||
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
|
||||
}),
|
||||
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
|
||||
yield* serveProbe
|
||||
|
||||
const response = yield* HttpClient.get(`/session?workspace=${workspace.id}`)
|
||||
|
||||
@@ -463,14 +487,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
// Workspace CRUD/status routes manage the control plane itself. Selecting
|
||||
// a workspace should preserve the selected id for handlers, but must not
|
||||
// swap the route context to the workspace target directory.
|
||||
yield* HttpRouter.add(
|
||||
"GET",
|
||||
WorkspacePaths.list,
|
||||
Effect.gen(function* () {
|
||||
const route = yield* WorkspaceRouteContext
|
||||
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
|
||||
}),
|
||||
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
|
||||
yield* serveProbe
|
||||
|
||||
const response = yield* HttpClient.get(`${WorkspacePaths.list}?workspace=${workspace.id}`)
|
||||
|
||||
@@ -484,7 +501,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const queryDir = path.join(dir, "query-target")
|
||||
const headerDir = path.join(dir, "header-target")
|
||||
yield* serveRouteContextProbe
|
||||
yield* serveProbe
|
||||
|
||||
// Without a selected workspace, the middleware falls back to request
|
||||
// directory hints before using the process cwd.
|
||||
@@ -495,9 +512,9 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
)
|
||||
|
||||
expect(queryResponse.status).toBe(200)
|
||||
expect(yield* queryResponse.json).toEqual({ directory: queryDir })
|
||||
expect(yield* queryResponse.json).toEqual({ directory: queryDir, workspaceID: null })
|
||||
expect(headerResponse.status).toBe(200)
|
||||
expect(yield* headerResponse.json).toEqual({ directory: headerDir })
|
||||
expect(yield* headerResponse.json).toEqual({ directory: headerDir, workspaceID: null })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -513,7 +530,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
directory: workspaceDir,
|
||||
})
|
||||
|
||||
yield* serveRouteContextProbe
|
||||
yield* serveProbe
|
||||
|
||||
// /probe is not a control-plane route, so selecting a local workspace
|
||||
// should swap the route context to the workspace target directory.
|
||||
|
||||
@@ -9,6 +9,7 @@ import { WorkspaceID } from "../../src/control-plane/schema"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
|
||||
import { Session } from "@/session/session"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Server } from "../../src/server/server"
|
||||
@@ -344,6 +345,7 @@ describe("workspace HttpApi", () => {
|
||||
proxied.push(request)
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/base/global/event") return eventStreamResponse()
|
||||
if (url.pathname === "/base/event") return eventStreamResponse()
|
||||
if (url.pathname === "/base/sync/history") return Response.json([])
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
@@ -413,6 +415,18 @@ describe("workspace HttpApi", () => {
|
||||
])
|
||||
expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-directory")
|
||||
expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-workspace")
|
||||
|
||||
const eventURL = new URL(`http://localhost${EventPaths.event}`)
|
||||
eventURL.searchParams.set("workspace", workspace.id)
|
||||
const eventResponse = yield* request(eventURL.toString(), dir)
|
||||
expect(eventResponse.status).toBe(200)
|
||||
expect(eventResponse.headers.get("content-type")).toContain("text/event-stream")
|
||||
if (!eventResponse.body) throw new Error("missing proxied event response body")
|
||||
const eventReader = eventResponse.body.getReader()
|
||||
const event = yield* Effect.promise(() => eventReader.read())
|
||||
yield* Effect.promise(() => eventReader.cancel())
|
||||
expect(new TextDecoder().decode(event.value)).toContain("server.connected")
|
||||
expect(proxied.some((item) => new URL(item.url).pathname === "/base/event")).toBe(true)
|
||||
} finally {
|
||||
void remote.stop(true)
|
||||
yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
|
||||
@@ -465,6 +479,16 @@ describe("workspace HttpApi", () => {
|
||||
method: "POST",
|
||||
}),
|
||||
])
|
||||
|
||||
const aborted = yield* request(`http://localhost/session/${session.id}/abort`, dir, { method: "POST" })
|
||||
expect(aborted.status).toBe(200)
|
||||
expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/abort`)).toEqual([
|
||||
expect.objectContaining({
|
||||
url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/abort`,
|
||||
method: "POST",
|
||||
body: "",
|
||||
}),
|
||||
])
|
||||
} finally {
|
||||
void remote.stop(true)
|
||||
yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
|
||||
|
||||
@@ -112,6 +112,22 @@ describe("HttpApi workspace proxy", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("proxies bodyless Web mutation requests as an empty body", () =>
|
||||
Effect.gen(function* () {
|
||||
const url = yield* listenServer(
|
||||
Effect.fnUntraced(function* (req: HttpServerRequest.HttpServerRequest) {
|
||||
return yield* HttpServerResponse.json({ method: req.method, body: yield* req.text })
|
||||
}),
|
||||
)
|
||||
const request = HttpServerRequest.fromWeb(new Request("http://localhost/session/abc/abort", { method: "POST" }))
|
||||
const httpClient = yield* HttpClient.HttpClient
|
||||
const response = yield* HttpApiProxy.http(httpClient, `${url}/session/abc/abort`, undefined, request)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* HttpServerResponse.toClientResponse(response).json).toEqual({ method: "POST", body: "" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("strips opencode-internal headers and merges extra headers", () =>
|
||||
Effect.gen(function* () {
|
||||
let forwarded: Record<string, string> = {}
|
||||
|
||||
@@ -1509,6 +1509,37 @@ describe("session.message-v2.fromError", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("explains ADC requirements when Vertex Anthropic credentials are missing", () => {
|
||||
const vertexAnthropicModel = {
|
||||
...model,
|
||||
providerID: ProviderID.googleVertex,
|
||||
api: { ...model.api, npm: "@ai-sdk/google-vertex/anthropic" },
|
||||
}
|
||||
const result = MessageV2.fromError(
|
||||
new Error(
|
||||
"Could not load the default credentials. Browse to https://cloud.google.com/docs/authentication/getting-started for more information.",
|
||||
),
|
||||
{ model: vertexAnthropicModel },
|
||||
)
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
name: "ProviderAuthError",
|
||||
data: {
|
||||
providerID: "google-vertex",
|
||||
message:
|
||||
"Anthropic models on Google Vertex require Google Cloud credentials. Use `gcloud auth application-default login` or set `GOOGLE_APPLICATION_CREDENTIALS`.",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("does not present Anthropic ADC guidance for Gemini Vertex credentials errors", () => {
|
||||
const result = MessageV2.fromError(new Error("Could not load the default credentials."), {
|
||||
model: { ...model, providerID: ProviderID.googleVertex, api: { ...model.api, npm: "@ai-sdk/google-vertex" } },
|
||||
})
|
||||
|
||||
expect(result.name).toBe("UnknownError")
|
||||
})
|
||||
|
||||
test("serializes tagged errors with their message", () => {
|
||||
const result = MessageV2.fromError(new Question.RejectedError(), { providerID })
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@@ -22,9 +22,9 @@
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.2.15",
|
||||
"@opentui/keymap": ">=0.2.15",
|
||||
"@opentui/solid": ">=0.2.15"
|
||||
"@opentui/core": ">=0.2.16",
|
||||
"@opentui/keymap": ">=0.2.16",
|
||||
"@opentui/solid": ">=0.2.16"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -2633,6 +2633,8 @@ export class Pty extends HeyApiClient {
|
||||
ptyID: string
|
||||
directory?: string
|
||||
workspace?: string
|
||||
cursor?: string
|
||||
ticket?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
@@ -2644,6 +2646,8 @@ export class Pty extends HeyApiClient {
|
||||
{ in: "path", key: "ptyID" },
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
{ in: "query", key: "cursor" },
|
||||
{ in: "query", key: "ticket" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -8200,6 +8200,8 @@ export type PtyConnectData = {
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
cursor?: string
|
||||
ticket?: string
|
||||
}
|
||||
url: "/pty/{ptyID}/connect"
|
||||
}
|
||||
|
||||
@@ -10403,20 +10403,32 @@
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "directory",
|
||||
"in": "query",
|
||||
"name": "directory",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"in": "query",
|
||||
"name": "workspace",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "cursor",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "ticket",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.14.50",
|
||||
"version": "1.15.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.14.50",
|
||||
"version": "1.15.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -20,6 +20,7 @@
|
||||
"db:migrate": "bun src/migrate.ts",
|
||||
"db:push": "drizzle-kit push --config=drizzle.config.ts",
|
||||
"db:studio": "drizzle-kit studio --config=drizzle.config.ts",
|
||||
"honeycomb:backfill": "bun src/honeycomb-backfill.ts",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,860 @@
|
||||
import { Client } from "@planetscale/database"
|
||||
import { drizzle } from "drizzle-orm/planetscale-serverless"
|
||||
import { geoStat, modelStat, providerStat } from "./database/schema"
|
||||
import {
|
||||
chunks,
|
||||
collapseRows,
|
||||
inserted,
|
||||
normalizeCountry,
|
||||
normalizeTier,
|
||||
rankBy,
|
||||
rankRowsWithMarketShare,
|
||||
statPeriodKey,
|
||||
synthesizeAllTierRows,
|
||||
toStatBaseRow,
|
||||
UPSERT_CHUNK_SIZE,
|
||||
type StatBaseAggregate,
|
||||
} from "./domain/stat"
|
||||
|
||||
const DAY_MS = 86_400_000
|
||||
const DEFAULT_DAYS = 60
|
||||
const FREE_MODELS = new Set(["gpt-5-nano", "grok-code", "big-pickle"])
|
||||
|
||||
type Grain = "day" | "week"
|
||||
type MetricDimension = "model" | "provider" | "geo"
|
||||
type LookupDimension = "model-provider-model" | "geo-continent"
|
||||
type ImportKey = `${MetricDimension | LookupDimension}-${Grain}`
|
||||
type RawRow = Record<string, string>
|
||||
type Period = { start: Date; end: Date }
|
||||
type Timing = { start_time: number; end_time: number; granularity?: number }
|
||||
type ImportOptions = {
|
||||
dataset: string
|
||||
databaseUrl: string | undefined
|
||||
dryRun: boolean
|
||||
periodEnd: Date | undefined
|
||||
periodStart: Date | undefined
|
||||
files: Partial<Record<ImportKey, string>>
|
||||
}
|
||||
type ModelAggregate = StatBaseAggregate & { provider: string; model: string; provider_model: string }
|
||||
type ProviderAggregate = StatBaseAggregate & { provider: string }
|
||||
type GeoAggregate = StatBaseAggregate & { country: string; continent: string }
|
||||
type ModelStatRow = typeof modelStat.$inferInsert
|
||||
type ProviderStatRow = typeof providerStat.$inferInsert
|
||||
type GeoStatRow = typeof geoStat.$inferInsert
|
||||
|
||||
const inputKeys = [
|
||||
"model-day",
|
||||
"model-week",
|
||||
"model-provider-model-day",
|
||||
"model-provider-model-week",
|
||||
"provider-day",
|
||||
"provider-week",
|
||||
"geo-day",
|
||||
"geo-week",
|
||||
"geo-continent-day",
|
||||
"geo-continent-week",
|
||||
] as const satisfies ImportKey[]
|
||||
|
||||
if (import.meta.main) await main()
|
||||
|
||||
async function main() {
|
||||
const command = process.argv[2]
|
||||
if (command === "queries") return printQueries(process.argv.slice(3))
|
||||
if (command === "import") return importFiles(process.argv.slice(3))
|
||||
usage()
|
||||
}
|
||||
|
||||
function printQueries(args: string[]) {
|
||||
const flags = parseFlags(args)
|
||||
const periodEnd = parseDateFlag(flags, "period-end") ?? defaultPeriodEnd()
|
||||
const days = parseIntegerFlag(flags, "days") ?? DEFAULT_DAYS
|
||||
const limit = parseIntegerFlag(flags, "limit") ?? 1000
|
||||
const dailyStart = new Date(
|
||||
Date.UTC(periodEnd.getUTCFullYear(), periodEnd.getUTCMonth(), periodEnd.getUTCDate() - days + 1),
|
||||
)
|
||||
const weekStart = syncWeekStart(periodEnd)
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
period_end: periodEnd.toISOString(),
|
||||
import_hint: `bun src/honeycomb-backfill.ts import --period-end ${periodEnd.toISOString()} ...`,
|
||||
daily: buildQuerySet(
|
||||
{
|
||||
start_time: Math.floor(dailyStart.getTime() / 1000),
|
||||
end_time: Math.floor(periodEnd.getTime() / 1000),
|
||||
granularity: DAY_MS / 1000,
|
||||
},
|
||||
limit,
|
||||
),
|
||||
week: buildQuerySet(
|
||||
{
|
||||
start_time: Math.floor(weekStart.getTime() / 1000),
|
||||
end_time: Math.floor(periodEnd.getTime() / 1000),
|
||||
},
|
||||
limit,
|
||||
),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async function importFiles(args: string[]) {
|
||||
const opts = parseImportOptions(args)
|
||||
const providerModelLookup = new Map([
|
||||
...(await lookupRows(opts.files["model-provider-model-day"], "day", opts, modelProviderModelLookup)),
|
||||
...(await lookupRows(opts.files["model-provider-model-week"], "week", opts, modelProviderModelLookup)),
|
||||
])
|
||||
const continentLookup = new Map([
|
||||
...(await lookupRows(opts.files["geo-continent-day"], "day", opts, geoContinentLookup)),
|
||||
...(await lookupRows(opts.files["geo-continent-week"], "week", opts, geoContinentLookup)),
|
||||
])
|
||||
const modelRows = modelRowsFromAggregates([
|
||||
...(await metricRows(opts.files["model-day"], "day", opts, (row, base) => ({
|
||||
...base,
|
||||
provider: provider(row),
|
||||
model: model(row),
|
||||
provider_model: providerModelLookup.get(lookupKey(base, provider(row), model(row))) ?? providerModel(row),
|
||||
}))),
|
||||
...(await metricRows(opts.files["model-week"], "week", opts, (row, base) => ({
|
||||
...base,
|
||||
provider: provider(row),
|
||||
model: model(row),
|
||||
provider_model: providerModelLookup.get(lookupKey(base, provider(row), model(row))) ?? providerModel(row),
|
||||
}))),
|
||||
])
|
||||
const providerRows = providerRowsFromAggregates([
|
||||
...(await metricRows(opts.files["provider-day"], "day", opts, (row, base) => ({
|
||||
...base,
|
||||
provider: provider(row),
|
||||
}))),
|
||||
...(await metricRows(opts.files["provider-week"], "week", opts, (row, base) => ({
|
||||
...base,
|
||||
provider: provider(row),
|
||||
}))),
|
||||
])
|
||||
const geoRows = geoRowsFromAggregates([
|
||||
...(await metricRows(opts.files["geo-day"], "day", opts, (row, base) => ({
|
||||
...base,
|
||||
country: country(row),
|
||||
continent: continentLookup.get(lookupKey(base, country(row))) ?? continent(row),
|
||||
}))),
|
||||
...(await metricRows(opts.files["geo-week"], "week", opts, (row, base) => ({
|
||||
...base,
|
||||
country: country(row),
|
||||
continent: continentLookup.get(lookupKey(base, country(row))) ?? continent(row),
|
||||
}))),
|
||||
])
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
modelRows: modelRows.length,
|
||||
providerRows: providerRows.length,
|
||||
geoRows: geoRows.length,
|
||||
dryRun: opts.dryRun,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
if (opts.dryRun) return
|
||||
if (!opts.databaseUrl) fail("DATABASE_URL is required unless --dry-run is set")
|
||||
|
||||
const db = drizzle({ client: new Client({ url: opts.databaseUrl }) })
|
||||
await upsertModelRows(db, modelRows)
|
||||
await upsertProviderRows(db, providerRows)
|
||||
await upsertGeoRows(db, geoRows)
|
||||
}
|
||||
|
||||
function buildQuerySet(timing: Timing, limit: number) {
|
||||
return {
|
||||
model: metricQuery(["stat_tier", "stat_provider", "model"], timing, limit),
|
||||
model_provider_model: lookupQuery(["stat_tier", "stat_provider", "model", "provider.model"], timing, limit),
|
||||
provider: metricQuery(["stat_tier", "stat_provider"], timing, limit),
|
||||
geo: metricQuery(["stat_tier", "stat_country"], timing, limit),
|
||||
geo_continent: lookupQuery(["stat_tier", "stat_country", "cf.continent"], timing, limit),
|
||||
}
|
||||
}
|
||||
|
||||
function metricQuery(breakdowns: string[], timing: Timing, limit: number) {
|
||||
return {
|
||||
...timing,
|
||||
breakdowns,
|
||||
calculated_fields: [...commonCalculatedFields(), ...metricCalculatedFields()],
|
||||
calculations: [
|
||||
{ op: "COUNT_DISTINCT", column: "session", name: "sessions" },
|
||||
{ op: "COUNT", name: "requests" },
|
||||
{ op: "SUM", column: "tokens.input", name: "input_tokens" },
|
||||
{ op: "SUM", column: "tokens.output", name: "output_tokens" },
|
||||
{ op: "SUM", column: "tokens.reasoning", name: "reasoning_tokens" },
|
||||
{ op: "SUM", column: "tokens.cache_read", name: "cache_read_tokens" },
|
||||
{ op: "SUM", column: "stat_tokens_total", name: "total_tokens" },
|
||||
{ op: "SUM", column: "stat_cost_input_microcents", name: "input_cost_microcents" },
|
||||
{ op: "SUM", column: "stat_cost_output_microcents", name: "output_cost_microcents" },
|
||||
{ op: "SUM", column: "stat_cost_total_microcents", name: "total_cost_microcents" },
|
||||
{ op: "AVG", column: "duration", name: "avg_duration_ms" },
|
||||
{ op: "P50", column: "duration", name: "p50_duration_ms" },
|
||||
{ op: "P95", column: "duration", name: "p95_duration_ms" },
|
||||
{ op: "AVG", column: "time_to_first_byte", name: "avg_ttfb_ms" },
|
||||
{ op: "P50", column: "time_to_first_byte", name: "p50_ttfb_ms" },
|
||||
{ op: "P95", column: "time_to_first_byte", name: "p95_ttfb_ms" },
|
||||
{ op: "AVG", column: "stat_output_tps", name: "avg_output_tps" },
|
||||
{ op: "SUM", column: "stat_success", name: "success_count" },
|
||||
{ op: "SUM", column: "stat_error", name: "error_count" },
|
||||
{ op: "COUNT", name: "sample_count" },
|
||||
],
|
||||
filters: commonFilters(),
|
||||
filter_combination: "AND",
|
||||
orders: [{ column: "stat_tokens_total", op: "SUM", order: "descending" }],
|
||||
limit,
|
||||
}
|
||||
}
|
||||
|
||||
function lookupQuery(breakdowns: string[], timing: Timing, limit: number) {
|
||||
return {
|
||||
...timing,
|
||||
breakdowns,
|
||||
calculated_fields: commonCalculatedFields(),
|
||||
calculations: [{ op: "COUNT", name: "requests" }],
|
||||
filters: commonFilters(),
|
||||
filter_combination: "AND",
|
||||
orders: [{ op: "COUNT", order: "descending" }],
|
||||
limit,
|
||||
}
|
||||
}
|
||||
|
||||
function commonCalculatedFields() {
|
||||
return [
|
||||
{
|
||||
name: "stat_included_client",
|
||||
expression: `IF(OR(CONTAINS(COALESCE($user_agent, ""), "ai-sdk"), CONTAINS(COALESCE($user_agent, ""), "opencode")), 1, 0)`,
|
||||
},
|
||||
{
|
||||
name: "stat_tier",
|
||||
expression: `IF(EQUALS(COALESCE($source, ""), "lite"), "Go", OR(EQUALS(COALESCE($model, ""), "gpt-5-nano"), EQUALS(COALESCE($model, ""), "grok-code"), EQUALS(COALESCE($model, ""), "big-pickle"), ENDS_WITH(COALESCE($model, ""), "-free")), "Free", "Zen")`,
|
||||
},
|
||||
{
|
||||
name: "stat_provider",
|
||||
expression:
|
||||
`IF(STARTS_WITH(COALESCE($provider, ""), "minimax-plan"), "minimax-plan", STARTS_WITH(COALESCE($provider, ""), "zai-plan"), "zai-plan", STARTS_WITH(COALESCE($provider, ""), "azure-databricks"), "azure-databricks", REG_MATCH(COALESCE($provider, ""), ` +
|
||||
"`^azure[0-9]+`" +
|
||||
`), "azure-openai", COALESCE($provider, "unknown"))`,
|
||||
},
|
||||
{ name: "stat_country", expression: `COALESCE($cf.country, "ZZ")` },
|
||||
]
|
||||
}
|
||||
|
||||
function metricCalculatedFields() {
|
||||
return [
|
||||
{
|
||||
name: "stat_tokens_total",
|
||||
expression: `SUM(COALESCE($tokens.cache_read, 0), COALESCE($tokens.cache_write_5m, 0), COALESCE($tokens.input, 0), COALESCE($tokens.output, 0))`,
|
||||
},
|
||||
{
|
||||
name: "stat_cost_input_microcents",
|
||||
expression: `COALESCE($cost.input.microcents, MUL($cost.input, 1000000), 0)`,
|
||||
},
|
||||
{
|
||||
name: "stat_cost_output_microcents",
|
||||
expression: `COALESCE($cost.output.microcents, MUL($cost.output, 1000000), 0)`,
|
||||
},
|
||||
{
|
||||
name: "stat_cost_total_microcents",
|
||||
expression: `COALESCE($cost.total.microcents, MUL($cost.total, 1000000), 0)`,
|
||||
},
|
||||
{
|
||||
name: "stat_output_tps",
|
||||
expression: `IF(LT(SUB($timestamp.last_byte, $timestamp.first_byte), 100), null, DIV(MUL($tokens.output, 1000), SUB($timestamp.last_byte, $timestamp.first_byte)))`,
|
||||
},
|
||||
{ name: "stat_success", expression: `IF(AND(GTE($status, 200), LT($status, 400)), 1, 0)` },
|
||||
{ name: "stat_error", expression: `IF(GTE($status, 400), 1, 0)` },
|
||||
]
|
||||
}
|
||||
|
||||
function commonFilters() {
|
||||
return [
|
||||
{ column: "event_type", op: "=", value: "completions" },
|
||||
{ column: "model", op: "exists" },
|
||||
{ column: "model", op: "!=", value: "" },
|
||||
{ column: "stat_included_client", op: "=", value: 1 },
|
||||
]
|
||||
}
|
||||
|
||||
function metricRows<T extends StatBaseAggregate>(
|
||||
file: string | undefined,
|
||||
grain: Grain,
|
||||
opts: ImportOptions,
|
||||
map: (row: RawRow, base: StatBaseAggregate) => T,
|
||||
) {
|
||||
if (!file) return Promise.resolve([])
|
||||
return readRows(file).then((rows) => rows.map((row) => map(row, baseAggregate(row, grain, opts))))
|
||||
}
|
||||
|
||||
function lookupRows(
|
||||
file: string | undefined,
|
||||
grain: Grain,
|
||||
opts: ImportOptions,
|
||||
map: (row: RawRow, grain: Grain, opts: ImportOptions) => readonly (readonly [string, string])[],
|
||||
) {
|
||||
if (!file) return Promise.resolve([])
|
||||
return readRows(file).then((rows) =>
|
||||
Array.from(
|
||||
rows
|
||||
.flatMap((row) => map(row, grain, opts))
|
||||
.reduce((result, [key, value]) => {
|
||||
if (value && value > (result.get(key) ?? "")) result.set(key, value)
|
||||
return result
|
||||
}, new Map<string, string>()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function modelProviderModelLookup(row: RawRow, grain: Grain, opts: ImportOptions): [string, string][] {
|
||||
const base = basePeriod(row, grain, opts)
|
||||
const value = providerModel(row)
|
||||
if (!value) return []
|
||||
return [[lookupKey({ ...base, dataset: opts.dataset, tier: tier(row), grain }, provider(row), model(row)), value]]
|
||||
}
|
||||
|
||||
function geoContinentLookup(row: RawRow, grain: Grain, opts: ImportOptions): [string, string][] {
|
||||
const base = basePeriod(row, grain, opts)
|
||||
const value = continent(row)
|
||||
if (!value) return []
|
||||
return [[lookupKey({ ...base, dataset: opts.dataset, tier: tier(row), grain }, country(row)), value]]
|
||||
}
|
||||
|
||||
function baseAggregate(row: RawRow, grain: Grain, opts: ImportOptions): StatBaseAggregate {
|
||||
return {
|
||||
...basePeriod(row, grain, opts),
|
||||
grain,
|
||||
dataset: opts.dataset,
|
||||
tier: tier(row),
|
||||
sessions: integer(row, "sessions", ["COUNT_DISTINCT(session)"]),
|
||||
requests: integer(row, "requests", ["COUNT", "COUNT()"]),
|
||||
input_tokens: integer(row, "input_tokens", ["SUM(tokens.input)", "SUM(tokens_input)"]),
|
||||
output_tokens: integer(row, "output_tokens", ["SUM(tokens.output)", "SUM(tokens_output)"]),
|
||||
reasoning_tokens: integer(row, "reasoning_tokens", ["SUM(tokens.reasoning)", "SUM(tokens_reasoning)"]),
|
||||
cache_read_tokens: integer(row, "cache_read_tokens", ["SUM(tokens.cache_read)", "SUM(tokens_cache_read)"]),
|
||||
total_tokens: integer(row, "total_tokens", ["SUM(stat_tokens_total)", "SUM(tokens)", "SUM(tokens_total)"]),
|
||||
input_cost_microcents: integer(row, "input_cost_microcents", ["SUM(stat_cost_input_microcents)"]),
|
||||
output_cost_microcents: integer(row, "output_cost_microcents", ["SUM(stat_cost_output_microcents)"]),
|
||||
total_cost_microcents: integer(row, "total_cost_microcents", ["SUM(stat_cost_total_microcents)"]),
|
||||
avg_duration_ms: nullableNumber(row, "avg_duration_ms", ["AVG(duration)", "AVG(duration_ms)"]),
|
||||
p50_duration_ms: nullableInteger(row, "p50_duration_ms", ["P50(duration)", "P50(duration_ms)"]),
|
||||
p95_duration_ms: nullableInteger(row, "p95_duration_ms", ["P95(duration)", "P95(duration_ms)"]),
|
||||
avg_ttfb_ms: nullableNumber(row, "avg_ttfb_ms", ["AVG(time_to_first_byte)", "AVG(ttfb_ms)"]),
|
||||
p50_ttfb_ms: nullableInteger(row, "p50_ttfb_ms", ["P50(time_to_first_byte)", "P50(ttfb_ms)"]),
|
||||
p95_ttfb_ms: nullableInteger(row, "p95_ttfb_ms", ["P95(time_to_first_byte)", "P95(ttfb_ms)"]),
|
||||
avg_output_tps: nullableNumber(row, "avg_output_tps", ["AVG(stat_output_tps)", "AVG(tps.output)"]),
|
||||
success_count: integer(row, "success_count", ["SUM(stat_success)"]),
|
||||
error_count: integer(row, "error_count", ["SUM(stat_error)"]),
|
||||
sample_count: integer(row, "sample_count", ["COUNT", "COUNT()"]),
|
||||
}
|
||||
}
|
||||
|
||||
function basePeriod(row: RawRow, grain: Grain, opts: ImportOptions) {
|
||||
const period = periodFor(row, grain, opts)
|
||||
return { period_start: period.start, period_end: period.end }
|
||||
}
|
||||
|
||||
function periodFor(row: RawRow, grain: Grain, opts: ImportOptions): Period {
|
||||
if (grain === "week") {
|
||||
const end = opts.periodEnd ?? parseTime(row)
|
||||
if (!end) fail("--period-end is required for week imports")
|
||||
return { start: opts.periodStart ?? syncWeekStart(end), end }
|
||||
}
|
||||
|
||||
const time = parseTime(row)
|
||||
const start = time ? startOfUtcDay(time) : opts.periodStart
|
||||
if (!start) fail("daily imports require a time column or --period-start")
|
||||
return {
|
||||
start,
|
||||
end: opts.periodEnd && sameUtcDay(start, opts.periodEnd) ? opts.periodEnd : new Date(start.getTime() + DAY_MS),
|
||||
}
|
||||
}
|
||||
|
||||
function modelRowsFromAggregates(aggregates: ModelAggregate[]) {
|
||||
return rankModelRows([
|
||||
...synthesizeAllTierRows(
|
||||
collapseRows(aggregates.filter((item) => item.grain === "week").map(toModelRow), modelDimensionKey),
|
||||
modelDimensionKey,
|
||||
),
|
||||
...synthesizeAllTierRows(
|
||||
collapseRows(aggregates.filter((item) => item.grain === "day").map(toModelRow), modelDimensionKey),
|
||||
modelDimensionKey,
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
function providerRowsFromAggregates(aggregates: ProviderAggregate[]) {
|
||||
return rankRowsWithMarketShare([
|
||||
...synthesizeAllTierRows(
|
||||
collapseRows(aggregates.filter((item) => item.grain === "week").map(toProviderRow), providerDimensionKey),
|
||||
providerDimensionKey,
|
||||
),
|
||||
...synthesizeAllTierRows(
|
||||
collapseRows(aggregates.filter((item) => item.grain === "day").map(toProviderRow), providerDimensionKey),
|
||||
providerDimensionKey,
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
function geoRowsFromAggregates(aggregates: GeoAggregate[]) {
|
||||
return rankRowsWithMarketShare([
|
||||
...synthesizeAllTierRows(
|
||||
collapseRows(aggregates.filter((item) => item.grain === "week").map(toGeoRow), geoDimensionKey),
|
||||
geoDimensionKey,
|
||||
),
|
||||
...synthesizeAllTierRows(
|
||||
collapseRows(aggregates.filter((item) => item.grain === "day").map(toGeoRow), geoDimensionKey),
|
||||
geoDimensionKey,
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
function toModelRow(data: ModelAggregate): ModelStatRow {
|
||||
return { ...toStatBaseRow(data), provider: data.provider, model: data.model, provider_model: data.provider_model }
|
||||
}
|
||||
|
||||
function toProviderRow(data: ProviderAggregate): ProviderStatRow {
|
||||
return { ...toStatBaseRow(data), provider: data.provider }
|
||||
}
|
||||
|
||||
function toGeoRow(data: GeoAggregate): GeoStatRow {
|
||||
return { ...toStatBaseRow(data), country: data.country, continent: data.continent }
|
||||
}
|
||||
|
||||
function rankModelRows(rows: ModelStatRow[]) {
|
||||
return Object.values(
|
||||
rows.reduce<Record<string, ModelStatRow[]>>((result, row) => {
|
||||
const key = statPeriodKey(row)
|
||||
result[key] = [...(result[key] ?? []), row]
|
||||
return result
|
||||
}, {}),
|
||||
).flatMap((group) => {
|
||||
const tokenRanks = rankBy(group, (row) => row.total_tokens ?? 0)
|
||||
const requestRanks = rankBy(group, (row) => row.requests ?? 0)
|
||||
const costRanks = rankBy(group, (row) => row.total_cost_microcents ?? 0)
|
||||
return group.map((row) => ({
|
||||
...row,
|
||||
rank_by_tokens: tokenRanks.get(row) ?? null,
|
||||
rank_by_requests: requestRanks.get(row) ?? null,
|
||||
rank_by_cost: costRanks.get(row) ?? null,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
function modelDimensionKey(row: ModelStatRow) {
|
||||
return [row.provider, row.model].join("\u0000")
|
||||
}
|
||||
|
||||
function providerDimensionKey(row: ProviderStatRow) {
|
||||
return row.provider
|
||||
}
|
||||
|
||||
function geoDimensionKey(row: GeoStatRow) {
|
||||
return row.country
|
||||
}
|
||||
|
||||
function lookupKey(base: { grain: string; period_start: Date; dataset: string; tier: string }, ...dimension: string[]) {
|
||||
return [base.grain, base.period_start.toISOString(), base.dataset, base.tier, ...dimension].join("\u0000")
|
||||
}
|
||||
|
||||
function tier(row: RawRow) {
|
||||
return normalizeTier(cell(row, ["stat_tier", "tier"]) || deriveTier(row))
|
||||
}
|
||||
|
||||
function deriveTier(row: RawRow) {
|
||||
const source = cell(row, ["source"])
|
||||
const value = model(row)
|
||||
if (source === "lite") return "Go"
|
||||
if (FREE_MODELS.has(value) || value.endsWith("-free")) return "Free"
|
||||
return "Zen"
|
||||
}
|
||||
|
||||
function provider(row: RawRow) {
|
||||
return normalizeProvider(cell(row, ["stat_provider", "provider"]) || "unknown")
|
||||
}
|
||||
|
||||
function normalizeProvider(value: string) {
|
||||
if (value.startsWith("minimax-plan")) return "minimax-plan"
|
||||
if (value.startsWith("zai-plan")) return "zai-plan"
|
||||
if (value.startsWith("azure-databricks")) return "azure-databricks"
|
||||
if (/^azure[0-9]+/.test(value)) return "azure-openai"
|
||||
return value || "unknown"
|
||||
}
|
||||
|
||||
function model(row: RawRow) {
|
||||
return cell(row, ["model"]) || "unknown"
|
||||
}
|
||||
|
||||
function providerModel(row: RawRow) {
|
||||
return cell(row, ["provider.model", "provider_model"]) || ""
|
||||
}
|
||||
|
||||
function country(row: RawRow) {
|
||||
return normalizeCountry(cell(row, ["stat_country", "cf.country", "cf_country", "country"]))
|
||||
}
|
||||
|
||||
function continent(row: RawRow) {
|
||||
return cell(row, ["cf.continent", "cf_continent", "continent"]) || ""
|
||||
}
|
||||
|
||||
function integer(row: RawRow, name: string, aliases: string[] = []) {
|
||||
return Math.round(number(row, name, aliases))
|
||||
}
|
||||
|
||||
function nullableInteger(row: RawRow, name: string, aliases: string[] = []) {
|
||||
if (!hasCell(row, [name, ...aliases])) return null
|
||||
return Math.round(number(row, name, aliases))
|
||||
}
|
||||
|
||||
function nullableNumber(row: RawRow, name: string, aliases: string[] = []) {
|
||||
if (!hasCell(row, [name, ...aliases])) return null
|
||||
return Number(number(row, name, aliases).toFixed(2))
|
||||
}
|
||||
|
||||
function number(row: RawRow, name: string, aliases: string[] = []) {
|
||||
const value = Number(cell(row, [name, ...aliases]).replace(/,/g, ""))
|
||||
return Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
function hasCell(row: RawRow, names: string[]) {
|
||||
return names.some((name) => row[name] !== undefined && row[name] !== "")
|
||||
}
|
||||
|
||||
function cell(row: RawRow, names: string[]) {
|
||||
const normalized = normalizedCells(row)
|
||||
return (
|
||||
names.flatMap((name) => [row[name], normalized.get(normalizeHeader(name))]).find((value) => value !== undefined) ??
|
||||
""
|
||||
)
|
||||
}
|
||||
|
||||
function normalizedCells(row: RawRow) {
|
||||
return new Map(Object.entries(row).map(([key, value]) => [normalizeHeader(key), value]))
|
||||
}
|
||||
|
||||
function normalizeHeader(value: string) {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "")
|
||||
}
|
||||
|
||||
function parseTime(row: RawRow) {
|
||||
const value = cell(row, ["time", "timestamp", "date", "datetime", "bucket"])
|
||||
if (!value) return undefined
|
||||
const numeric = Number(value)
|
||||
const date = Number.isFinite(numeric)
|
||||
? new Date(numeric > 10_000_000_000 ? numeric : numeric * 1000)
|
||||
: new Date(value)
|
||||
if (Number.isNaN(date.getTime())) fail(`Invalid time value: ${value}`)
|
||||
return date
|
||||
}
|
||||
|
||||
function startOfUtcDay(value: Date) {
|
||||
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()))
|
||||
}
|
||||
|
||||
function syncWeekStart(periodEnd: Date) {
|
||||
return new Date(Date.UTC(periodEnd.getUTCFullYear(), periodEnd.getUTCMonth(), periodEnd.getUTCDate() - 6))
|
||||
}
|
||||
|
||||
function defaultPeriodEnd() {
|
||||
return new Date(Math.floor((Date.now() - 5 * 60_000) / 60_000) * 60_000)
|
||||
}
|
||||
|
||||
function sameUtcDay(left: Date, right: Date) {
|
||||
return (
|
||||
left.getUTCFullYear() === right.getUTCFullYear() &&
|
||||
left.getUTCMonth() === right.getUTCMonth() &&
|
||||
left.getUTCDate() === right.getUTCDate()
|
||||
)
|
||||
}
|
||||
|
||||
async function readRows(file: string) {
|
||||
const text = await Bun.file(file).text()
|
||||
if (file.toLowerCase().endsWith(".json")) {
|
||||
const parsed: unknown = JSON.parse(text)
|
||||
return rowsFromJson(parsed)
|
||||
}
|
||||
return rowsFromCsv(text)
|
||||
}
|
||||
|
||||
function rowsFromJson(value: unknown): RawRow[] {
|
||||
if (Array.isArray(value)) return value.flatMap(rowFromUnknown)
|
||||
if (!isRecord(value)) fail("JSON imports must be an array of rows or an object with results/data/rows")
|
||||
|
||||
const rows = [value.results, value.data, value.rows].flatMap((candidate) =>
|
||||
Array.isArray(candidate) ? candidate.flatMap(rowFromUnknown) : [],
|
||||
)
|
||||
if (rows.length === 0) fail("JSON import did not contain rows")
|
||||
return rows
|
||||
}
|
||||
|
||||
function rowFromUnknown(value: unknown): RawRow[] {
|
||||
if (!isRecord(value)) return []
|
||||
const nested = isRecord(value.data) ? value.data : {}
|
||||
return [
|
||||
Object.fromEntries(
|
||||
Object.entries({ ...value, ...nested }).flatMap(([key, item]) => {
|
||||
if (key === "data") return []
|
||||
return [[key, cellValue(item)]]
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function rowsFromCsv(text: string): RawRow[] {
|
||||
const [headers, ...rows] = csvRecords(text).filter((row) => row.some((value) => value.trim() !== ""))
|
||||
if (!headers) return []
|
||||
return rows.map((row) =>
|
||||
Object.fromEntries(headers.map((header, index) => [header.trim(), row[index]?.trim() ?? ""])),
|
||||
)
|
||||
}
|
||||
|
||||
function csvRecords(text: string) {
|
||||
const rows: string[][] = []
|
||||
let row: string[] = []
|
||||
let field = ""
|
||||
let quoted = false
|
||||
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const char = text[index]
|
||||
const next = text[index + 1]
|
||||
if (quoted) {
|
||||
if (char === '"' && next === '"') {
|
||||
field += '"'
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
quoted = false
|
||||
continue
|
||||
}
|
||||
field += char
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
quoted = true
|
||||
continue
|
||||
}
|
||||
if (char === ",") {
|
||||
row.push(field)
|
||||
field = ""
|
||||
continue
|
||||
}
|
||||
if (char === "\n") {
|
||||
row.push(field)
|
||||
rows.push(row)
|
||||
row = []
|
||||
field = ""
|
||||
continue
|
||||
}
|
||||
if (char === "\r") continue
|
||||
field += char
|
||||
}
|
||||
|
||||
row.push(field)
|
||||
rows.push(row)
|
||||
return rows
|
||||
}
|
||||
|
||||
function cellValue(value: unknown) {
|
||||
if (value === null || value === undefined) return ""
|
||||
if (typeof value === "string") return value
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value)
|
||||
return JSON.stringify(value) ?? ""
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
async function upsertModelRows(db: ReturnType<typeof drizzle>, rows: ModelStatRow[]) {
|
||||
await Promise.all(
|
||||
chunks(rows, UPSERT_CHUNK_SIZE).map((chunk) =>
|
||||
db
|
||||
.insert(modelStat)
|
||||
.values(chunk)
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
period_end: inserted("period_end"),
|
||||
provider_model: inserted("provider_model"),
|
||||
sessions: inserted("sessions"),
|
||||
requests: inserted("requests"),
|
||||
input_tokens: inserted("input_tokens"),
|
||||
output_tokens: inserted("output_tokens"),
|
||||
reasoning_tokens: inserted("reasoning_tokens"),
|
||||
cache_read_tokens: inserted("cache_read_tokens"),
|
||||
total_tokens: inserted("total_tokens"),
|
||||
input_cost_microcents: inserted("input_cost_microcents"),
|
||||
output_cost_microcents: inserted("output_cost_microcents"),
|
||||
total_cost_microcents: inserted("total_cost_microcents"),
|
||||
avg_duration_ms: inserted("avg_duration_ms"),
|
||||
p50_duration_ms: inserted("p50_duration_ms"),
|
||||
p95_duration_ms: inserted("p95_duration_ms"),
|
||||
avg_ttfb_ms: inserted("avg_ttfb_ms"),
|
||||
p50_ttfb_ms: inserted("p50_ttfb_ms"),
|
||||
p95_ttfb_ms: inserted("p95_ttfb_ms"),
|
||||
avg_output_tps: inserted("avg_output_tps"),
|
||||
success_count: inserted("success_count"),
|
||||
error_count: inserted("error_count"),
|
||||
sample_count: inserted("sample_count"),
|
||||
rank_by_tokens: inserted("rank_by_tokens"),
|
||||
rank_by_requests: inserted("rank_by_requests"),
|
||||
rank_by_cost: inserted("rank_by_cost"),
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async function upsertProviderRows(db: ReturnType<typeof drizzle>, rows: ProviderStatRow[]) {
|
||||
await Promise.all(
|
||||
chunks(rows, UPSERT_CHUNK_SIZE).map((chunk) =>
|
||||
db
|
||||
.insert(providerStat)
|
||||
.values(chunk)
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
period_end: inserted("period_end"),
|
||||
sessions: inserted("sessions"),
|
||||
requests: inserted("requests"),
|
||||
input_tokens: inserted("input_tokens"),
|
||||
output_tokens: inserted("output_tokens"),
|
||||
reasoning_tokens: inserted("reasoning_tokens"),
|
||||
cache_read_tokens: inserted("cache_read_tokens"),
|
||||
total_tokens: inserted("total_tokens"),
|
||||
input_cost_microcents: inserted("input_cost_microcents"),
|
||||
output_cost_microcents: inserted("output_cost_microcents"),
|
||||
total_cost_microcents: inserted("total_cost_microcents"),
|
||||
avg_duration_ms: inserted("avg_duration_ms"),
|
||||
p50_duration_ms: inserted("p50_duration_ms"),
|
||||
p95_duration_ms: inserted("p95_duration_ms"),
|
||||
avg_ttfb_ms: inserted("avg_ttfb_ms"),
|
||||
p50_ttfb_ms: inserted("p50_ttfb_ms"),
|
||||
p95_ttfb_ms: inserted("p95_ttfb_ms"),
|
||||
avg_output_tps: inserted("avg_output_tps"),
|
||||
success_count: inserted("success_count"),
|
||||
error_count: inserted("error_count"),
|
||||
sample_count: inserted("sample_count"),
|
||||
market_share_tokens: inserted("market_share_tokens"),
|
||||
market_share_requests: inserted("market_share_requests"),
|
||||
market_share_sessions: inserted("market_share_sessions"),
|
||||
rank_by_tokens: inserted("rank_by_tokens"),
|
||||
rank_by_requests: inserted("rank_by_requests"),
|
||||
rank_by_sessions: inserted("rank_by_sessions"),
|
||||
rank_by_cost: inserted("rank_by_cost"),
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async function upsertGeoRows(db: ReturnType<typeof drizzle>, rows: GeoStatRow[]) {
|
||||
await Promise.all(
|
||||
chunks(rows, UPSERT_CHUNK_SIZE).map((chunk) =>
|
||||
db
|
||||
.insert(geoStat)
|
||||
.values(chunk)
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
period_end: inserted("period_end"),
|
||||
continent: inserted("continent"),
|
||||
sessions: inserted("sessions"),
|
||||
requests: inserted("requests"),
|
||||
input_tokens: inserted("input_tokens"),
|
||||
output_tokens: inserted("output_tokens"),
|
||||
reasoning_tokens: inserted("reasoning_tokens"),
|
||||
cache_read_tokens: inserted("cache_read_tokens"),
|
||||
total_tokens: inserted("total_tokens"),
|
||||
input_cost_microcents: inserted("input_cost_microcents"),
|
||||
output_cost_microcents: inserted("output_cost_microcents"),
|
||||
total_cost_microcents: inserted("total_cost_microcents"),
|
||||
avg_duration_ms: inserted("avg_duration_ms"),
|
||||
p50_duration_ms: inserted("p50_duration_ms"),
|
||||
p95_duration_ms: inserted("p95_duration_ms"),
|
||||
avg_ttfb_ms: inserted("avg_ttfb_ms"),
|
||||
p50_ttfb_ms: inserted("p50_ttfb_ms"),
|
||||
p95_ttfb_ms: inserted("p95_ttfb_ms"),
|
||||
avg_output_tps: inserted("avg_output_tps"),
|
||||
success_count: inserted("success_count"),
|
||||
error_count: inserted("error_count"),
|
||||
sample_count: inserted("sample_count"),
|
||||
market_share_tokens: inserted("market_share_tokens"),
|
||||
market_share_requests: inserted("market_share_requests"),
|
||||
market_share_sessions: inserted("market_share_sessions"),
|
||||
rank_by_tokens: inserted("rank_by_tokens"),
|
||||
rank_by_requests: inserted("rank_by_requests"),
|
||||
rank_by_sessions: inserted("rank_by_sessions"),
|
||||
rank_by_cost: inserted("rank_by_cost"),
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function parseImportOptions(args: string[]): ImportOptions {
|
||||
const flags = parseFlags(args)
|
||||
const files = inputKeys.reduce<Partial<Record<ImportKey, string>>>((result, key) => {
|
||||
const value = flags.get(key)?.[0]
|
||||
if (!value) return result
|
||||
return { ...result, [key]: value }
|
||||
}, {})
|
||||
return {
|
||||
dataset: flags.get("dataset")?.[0] ?? "zen",
|
||||
databaseUrl: flags.get("database-url")?.[0] ?? process.env.DATABASE_URL,
|
||||
dryRun: flags.has("dry-run"),
|
||||
periodEnd: parseDateFlag(flags, "period-end"),
|
||||
periodStart: parseDateFlag(flags, "period-start"),
|
||||
files,
|
||||
}
|
||||
}
|
||||
|
||||
function parseFlags(args: string[]) {
|
||||
const result = new Map<string, string[]>()
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const arg = args[index]
|
||||
if (!arg.startsWith("--")) fail(`Unexpected argument: ${arg}`)
|
||||
const name = arg.slice(2)
|
||||
if (name === "dry-run") {
|
||||
result.set(name, ["true"])
|
||||
continue
|
||||
}
|
||||
const value = args[index + 1]
|
||||
if (!value || value.startsWith("--")) fail(`Missing value for --${name}`)
|
||||
result.set(name, [...(result.get(name) ?? []), value])
|
||||
index++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function parseDateFlag(flags: Map<string, string[]>, name: string) {
|
||||
const value = flags.get(name)?.[0]
|
||||
if (!value) return undefined
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) fail(`Invalid --${name}: ${value}`)
|
||||
return date
|
||||
}
|
||||
|
||||
function parseIntegerFlag(flags: Map<string, string[]>, name: string) {
|
||||
const value = flags.get(name)?.[0]
|
||||
if (!value) return undefined
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) fail(`Invalid --${name}: ${value}`)
|
||||
return parsed
|
||||
}
|
||||
|
||||
function usage(): never {
|
||||
fail(`Usage:
|
||||
bun src/honeycomb-backfill.ts queries [--period-end ISO] [--days 60] [--limit 1000]
|
||||
bun src/honeycomb-backfill.ts import --period-end ISO [--dry-run] [--database-url URL] --model-day file.csv ...`)
|
||||
}
|
||||
|
||||
function fail(message: string): never {
|
||||
console.error(message)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -1,40 +1,29 @@
|
||||
FROM oven/bun:1.3.14-alpine
|
||||
FROM oven/bun:1.3.14-alpine AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV BUN_RUNTIME_TRANSPILER_CACHE_PATH=0
|
||||
|
||||
COPY package.json bun.lock ./
|
||||
COPY patches ./patches
|
||||
COPY packages/app/package.json ./packages/app/package.json
|
||||
COPY packages/console/app/package.json ./packages/console/app/package.json
|
||||
COPY packages/console/core/package.json ./packages/console/core/package.json
|
||||
COPY packages/console/function/package.json ./packages/console/function/package.json
|
||||
COPY packages/console/mail/package.json ./packages/console/mail/package.json
|
||||
COPY packages/console/resource/package.json ./packages/console/resource/package.json
|
||||
COPY packages/core/package.json ./packages/core/package.json
|
||||
COPY packages/desktop/package.json ./packages/desktop/package.json
|
||||
COPY packages/effect-drizzle-sqlite/package.json ./packages/effect-drizzle-sqlite/package.json
|
||||
COPY packages/enterprise/package.json ./packages/enterprise/package.json
|
||||
COPY packages/function/package.json ./packages/function/package.json
|
||||
COPY packages/http-recorder/package.json ./packages/http-recorder/package.json
|
||||
COPY packages/llm/package.json ./packages/llm/package.json
|
||||
COPY packages/opencode/package.json ./packages/opencode/package.json
|
||||
COPY packages/plugin/package.json ./packages/plugin/package.json
|
||||
COPY packages/script/package.json ./packages/script/package.json
|
||||
COPY packages/sdk/js/package.json ./packages/sdk/js/package.json
|
||||
COPY packages/slack/package.json ./packages/slack/package.json
|
||||
COPY packages/stats/app/package.json ./packages/stats/app/package.json
|
||||
COPY packages/stats/core/package.json ./packages/stats/core/package.json
|
||||
COPY packages/stats/server/package.json ./packages/stats/server/package.json
|
||||
COPY packages/storybook/package.json ./packages/storybook/package.json
|
||||
COPY packages/ui/package.json ./packages/ui/package.json
|
||||
COPY packages/web/package.json ./packages/web/package.json
|
||||
FROM base AS pruner
|
||||
|
||||
RUN bun install --frozen-lockfile --production --ignore-scripts
|
||||
COPY . .
|
||||
|
||||
COPY packages ./packages
|
||||
RUN bunx turbo@2.8.13 prune @opencode-ai/stats-server --docker --no-update-notifier --no-color
|
||||
|
||||
FROM base AS installer
|
||||
|
||||
COPY --from=pruner /app/out/json/ ./
|
||||
|
||||
# Bun 1.3.x needs the pruned workspace globs and lockfile metadata refreshed before the frozen production install.
|
||||
RUN bun -e 'const packageJson = await Bun.file("package.json").json(); packageJson.workspaces.packages = Array.from(new Bun.Glob("packages/**/package.json").scanSync(".")).map((file) => file.slice(0, -"/package.json".length)).sort(); await Bun.write("package.json", JSON.stringify(packageJson, null, 2) + "\n")'
|
||||
RUN rm -f bun.lock && bun install --filter @opencode-ai/stats-server --lockfile-only --ignore-scripts
|
||||
RUN bun install --filter @opencode-ai/stats-server --frozen-lockfile --production --ignore-scripts
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
COPY --from=installer /app ./
|
||||
COPY --from=pruner /app/out/full/ ./
|
||||
|
||||
WORKDIR /app/packages/stats/server
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.14.50",
|
||||
"version": "1.15.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user