Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f8e98e10f | |||
| 6fea0178eb | |||
| ebe9dcf27e | |||
| 57efec4429 | |||
| e22144806f | |||
| 27fa297a42 | |||
| b1cd25de3d | |||
| 780bbb0f3b | |||
| 817cb076a8 | |||
| 347526e9fd | |||
| 092bc674a5 | |||
| b24a4e897e | |||
| 3afa622eab | |||
| d01cb7f013 | |||
| 28b03595bf | |||
| 32684e70e6 | |||
| b2baddcd37 | |||
| dbd48d423d | |||
| e7cc8259b5 | |||
| a9ccb0804f | |||
| ebe6087e8f | |||
| dc978cb889 | |||
| 8cbc43fbb0 | |||
| 82359c4b1b | |||
| 7f3e51453b | |||
| cbdb2d9825 | |||
| 96bde05f6e | |||
| dcb8ed8eb0 | |||
| aab82cc1a7 | |||
| cd1d1e81a6 | |||
| cff441909a | |||
| 3615d5aab1 | |||
| 11d9e82eaf | |||
| eadda11ec9 | |||
| f73a56c223 | |||
| c0acf5c43f | |||
| 4d585464f3 | |||
| f0cb17a812 | |||
| 357a74714a | |||
| ffea6c7974 | |||
| 8e9550d90d | |||
| 9b7b6cb30f | |||
| cc68afb2de | |||
| 11c33d52a5 | |||
| 2a487ea990 | |||
| 9c05d4e2fd |
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -85,7 +85,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -120,7 +120,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -147,7 +147,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -171,7 +171,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -195,7 +195,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -229,7 +229,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -283,7 +283,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -313,7 +313,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -329,7 +329,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "0.0.0",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -342,7 +342,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -360,7 +360,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -393,10 +393,6 @@
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/node-server": "1.19.11",
|
||||
"@hono/node-ws": "1.3.0",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
@@ -437,8 +433,6 @@
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
"hono": "catalog:",
|
||||
"hono-openapi": "catalog:",
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
@@ -465,7 +459,6 @@
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
"zod-to-json-schema": "3.24.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.4",
|
||||
@@ -498,12 +491,11 @@
|
||||
"typescript": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"why-is-node-running": "3.2.2",
|
||||
"zod-to-json-schema": "3.24.5",
|
||||
},
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -541,7 +533,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -556,7 +548,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -591,7 +583,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -640,7 +632,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1239,8 +1231,6 @@
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
|
||||
|
||||
"@hono/node-ws": ["@hono/node-ws@1.3.0", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.2", "hono": "^4.6.0" } }, "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q=="],
|
||||
|
||||
"@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="],
|
||||
|
||||
"@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-ZqTxXSIWZmrNpdIQICbesrMpoL24blahhQzAGTzupAc=",
|
||||
"aarch64-linux": "sha256-YdZAkfJXBCnqPcYS7VUbQqT9AkYnyu/ONVG5v78Lmdw=",
|
||||
"aarch64-darwin": "sha256-3Fdu1le89F53UHuaiHanHP0CB4F72fK9p1d9PC47Wsg=",
|
||||
"x86_64-darwin": "sha256-Tc4WppnKZPzYn/ICIv1vtx+NiNqAHAzd/ogTRUCQK18="
|
||||
"x86_64-linux": "sha256-LTo0ohJN5hBOubqFLVL45unVEIwBDkACNVv64k2nkq4=",
|
||||
"aarch64-linux": "sha256-oYKY2UJRWG2fhufW4aGujX/Poou93023ZF2Fu7oyYOw=",
|
||||
"aarch64-darwin": "sha256-618c9vqKN5I+no1nzylctAiWvqw7Bsa+bzSTNwXmSQA=",
|
||||
"x86_64-darwin": "sha256-1ro3/gH0FC0TWXwWT+k675xR396GE98HpnBEeuD4t6k="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Config } from "effect"
|
||||
import { InstallationChannel } from "../installation/version"
|
||||
|
||||
function truthy(key: string) {
|
||||
const value = process.env[key]?.toLowerCase()
|
||||
@@ -11,10 +10,6 @@ function falsy(key: string) {
|
||||
return value === "false" || value === "0"
|
||||
}
|
||||
|
||||
// Channels that default to the new effect-httpapi server backend. The legacy
|
||||
// hono backend remains the default for stable (`prod`/`latest`) installs.
|
||||
const HTTPAPI_DEFAULT_ON_CHANNELS = new Set(["dev", "beta", "local"])
|
||||
|
||||
function number(key: string) {
|
||||
const value = process.env[key]
|
||||
if (!value) return undefined
|
||||
@@ -88,14 +83,6 @@ export const Flag = {
|
||||
OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"),
|
||||
|
||||
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
|
||||
// Defaults to true on dev/beta/local channels so internal users exercise the
|
||||
// new effect-httpapi server backend. Stable (`prod`/`latest`) installs stay
|
||||
// on the legacy hono backend until the rollout is complete. An explicit env
|
||||
// var ("true"/"1" or "false"/"0") always wins, providing an opt-in for
|
||||
// stable users and an escape hatch for dev/beta users.
|
||||
OPENCODE_EXPERIMENTAL_HTTPAPI:
|
||||
truthy("OPENCODE_EXPERIMENTAL_HTTPAPI") ||
|
||||
(!falsy("OPENCODE_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)),
|
||||
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||
OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.14.41"
|
||||
version = "1.14.44"
|
||||
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.14.41/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/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.14.41/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/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.14.41/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "0.0.0",
|
||||
"version": "1.14.44",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.44",
|
||||
"name": "@opencode-ai/llm",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1 +1 @@
|
||||
ALTER TABLE `workspace` ADD `time_used` integer NOT NULL;
|
||||
ALTER TABLE `workspace` ADD `time_used` integer NOT NULL DEFAULT 0;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -9,7 +9,7 @@
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "bun test --timeout 30000",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||
"test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip",
|
||||
"test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip",
|
||||
"build": "bun run script/build.ts",
|
||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||
"dev": "bun run --conditions=browser ./src/index.ts",
|
||||
@@ -33,11 +33,6 @@
|
||||
"node": "./src/pty/pty.node.ts",
|
||||
"default": "./src/pty/pty.bun.ts"
|
||||
},
|
||||
"#hono": {
|
||||
"bun": "./src/server/adapter.bun.ts",
|
||||
"node": "./src/server/adapter.node.ts",
|
||||
"default": "./src/server/adapter.bun.ts"
|
||||
},
|
||||
"#httpapi-server": {
|
||||
"bun": "./src/server/httpapi-server.node.ts",
|
||||
"node": "./src/server/httpapi-server.node.ts",
|
||||
@@ -74,8 +69,7 @@
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"why-is-node-running": "3.2.2",
|
||||
"zod-to-json-schema": "3.24.5"
|
||||
"why-is-node-running": "3.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "1.11.1",
|
||||
@@ -106,10 +100,6 @@
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/node-server": "1.19.11",
|
||||
"@hono/node-ws": "1.3.0",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
@@ -150,8 +140,6 @@
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
"hono": "catalog:",
|
||||
"hono-openapi": "catalog:",
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
@@ -177,8 +165,7 @@
|
||||
"which": "6.0.1",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
"zod-to-json-schema": "3.24.5"
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"overrides": {
|
||||
"drizzle-orm": "catalog:"
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Compare SDK types generated from Hono vs HttpApi specs.
|
||||
# Sorts types alphabetically so only meaningful body differences show.
|
||||
#
|
||||
# Usage: ./scripts/diff-sdk-types.sh # full diff
|
||||
# ./scripts/diff-sdk-types.sh --stat # summary only
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SDK="$(cd "$DIR/../sdk/js" && pwd)"
|
||||
|
||||
normalize() {
|
||||
python3 -c "
|
||||
import re, sys
|
||||
content = open(sys.argv[1]).read()
|
||||
blocks = re.split(r'(?=^export (?:type|function|const) )', content, flags=re.MULTILINE)
|
||||
header, body = blocks[0], blocks[1:]
|
||||
body.sort(key=lambda b: m.group(1) if (m := re.match(r'export \w+ (\w+)', b)) else '')
|
||||
sys.stdout.write(header + ''.join(body))
|
||||
" "$1"
|
||||
}
|
||||
|
||||
echo "Generating Hono SDK..." >&2
|
||||
(cd "$SDK" && bun run script/build.ts >/dev/null 2>&1)
|
||||
normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-hono.ts
|
||||
git -C "$SDK" checkout -- src/ 2>/dev/null
|
||||
|
||||
echo "Generating HttpApi SDK..." >&2
|
||||
(cd "$SDK" && OPENCODE_SDK_OPENAPI=httpapi bun run script/build.ts >/dev/null 2>&1)
|
||||
normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-httpapi.ts
|
||||
git -C "$SDK" checkout -- src/ 2>/dev/null
|
||||
|
||||
echo "" >&2
|
||||
if [[ "${1:-}" == "--stat" ]]; then
|
||||
diff_output=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true)
|
||||
honly=$(printf "%s\n" "$diff_output" | grep -c '^< export type' || true)
|
||||
aonly=$(printf "%s\n" "$diff_output" | grep -c '^> export type' || true)
|
||||
total=$(printf "%s\n" "$diff_output" | wc -l | tr -d ' ')
|
||||
echo "Hono-only: $honly types HttpApi-only: $aonly types Diff lines: $total"
|
||||
echo ""
|
||||
if [[ $honly -gt 0 ]]; then
|
||||
echo "=== Hono-only types ==="
|
||||
printf "%s\n" "$diff_output" | grep '^< export type' | sed 's/< export type //' | sed 's/[ =].*//' | sed 's/^/ /'
|
||||
echo ""
|
||||
fi
|
||||
if [[ $aonly -gt 0 ]]; then
|
||||
echo "=== HttpApi-only types ==="
|
||||
printf "%s\n" "$diff_output" | grep '^> export type' | sed 's/> export type //' | sed 's/[ =].*//' | sed 's/^/ /'
|
||||
fi
|
||||
else
|
||||
diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true
|
||||
fi
|
||||
@@ -1,401 +0,0 @@
|
||||
# HttpApi migration
|
||||
|
||||
Plan for replacing instance Hono route implementations with Effect `HttpApi` while preserving behavior, OpenAPI, and SDK output during the transition.
|
||||
|
||||
## End State
|
||||
|
||||
- JSON route contracts and handlers live in `src/server/routes/instance/httpapi/*`.
|
||||
- Route modules own their `HttpApiGroup`, schemas, handlers, and route-level middleware.
|
||||
- `httpapi/server.ts` only composes groups, instance lookup, observability, and the web handler bridge.
|
||||
- Hono route implementations are deleted once their `HttpApi` replacements are default, tested, and represented in the SDK/OpenAPI pipeline.
|
||||
- Streaming, SSE, and websocket routes move later through Effect HTTP primitives or another explicit replacement plan; they do not need to fit `HttpApi` if `HttpApi` is the wrong abstraction.
|
||||
|
||||
## Current State
|
||||
|
||||
- `OPENCODE_EXPERIMENTAL_HTTPAPI` selects the backend at server startup. Default is still `hono`.
|
||||
- `server/backend.ts` picks one of `effect-httpapi` or `hono`; `server.ts` builds either a pure Effect `HttpApi` web handler or the legacy Hono app accordingly. The earlier in-Hono "bridge" model has been replaced by this fork-at-startup.
|
||||
- Legacy Hono routes remain mounted for the `hono` backend and remain the source for `hono-openapi` SDK generation.
|
||||
- An Effect `HttpApi` OpenAPI surface exists (`OpenApi.fromApi(PublicApi)` in `cli/cmd/generate.ts --httpapi`, `OPENCODE_SDK_OPENAPI=httpapi` in `packages/sdk/js/script/build.ts`) but is opt-in. The default SDK generation is still Hono.
|
||||
- `httpapi/public.ts` carries the Hono-compat normalization for the Effect-generated OpenAPI surface (auth scheme strip, request-body required flag, optional `null` arms, `BadRequestError` / `NotFoundError` remap, `$ref` self-cycle fix, `auth_token` query injection). Today's Effect-generated SDK is not byte-identical to the Hono-generated SDK — see Phase 4.
|
||||
- Auth is centrally configured for the Effect backend via Effect `Config` (`refactor: use Effect config for HttpApi authorization`, `Fix HttpApi raw route authorization`) rather than re-attached in each route module.
|
||||
- Auth supports Basic auth and the legacy `auth_token` query parameter through `HttpApiSecurity.apiKey`.
|
||||
- Instance context is provided by `httpapi/server.ts` using `directory`, `workspace`, and `x-opencode-directory`.
|
||||
- `Observability.layer` is provided in the Effect route layer and deduplicated through the shared `memoMap`.
|
||||
- CORS middleware is wired into both backends (`feat(httpapi): add CORS middleware to instance routes`).
|
||||
|
||||
## Migration Rules
|
||||
|
||||
- Preserve runtime behavior first. Semantic changes, new error behavior, or route shape changes need separate PRs.
|
||||
- Migrate one route group, or one coherent subset of a route group, at a time.
|
||||
- Reuse existing services. Do not re-architect service logic during HTTP boundary migration.
|
||||
- Effect Schema owns route DTOs. Keep `.zod` only as compatibility for remaining Hono/OpenAPI surfaces.
|
||||
- Regenerate the SDK after schema or OpenAPI-affecting changes and verify the diff is expected.
|
||||
- Do not delete a Hono route until the SDK/OpenAPI pipeline no longer depends on its Hono `describeRoute` entry.
|
||||
|
||||
## Route Slice Checklist
|
||||
|
||||
Use this checklist for each small HttpApi migration PR:
|
||||
|
||||
1. Read the legacy Hono route and copy behavior exactly, including default values, headers, operation IDs, response schemas, and status codes.
|
||||
2. Put the new `HttpApiGroup`, route paths, DTO schemas, and handlers in `src/server/routes/instance/httpapi/*`.
|
||||
3. Mount the new paths in `src/server/routes/instance/index.ts` only inside the `OPENCODE_EXPERIMENTAL_HTTPAPI` block.
|
||||
4. Use `InstanceState.context` / `InstanceState.directory` inside HttpApi handlers instead of `Instance.directory`, `Instance.worktree`, or `Instance.project` ALS globals.
|
||||
5. Reuse existing services directly. If a service returns plain objects, use `Schema.Struct`; use `Schema.Class` only when handlers return actual class instances.
|
||||
6. Keep legacy Hono routes and `.zod` compatibility in place for SDK/OpenAPI generation.
|
||||
7. Add tests that hit the Hono-mounted bridge via `InstanceRoutes`, not only the raw `HttpApi` web handler, when the route depends on auth or instance context.
|
||||
8. Run `bun typecheck` from `packages/opencode`, relevant `bun run test:ci ...` tests from `packages/opencode`, and `./packages/sdk/js/script/build.ts` from the repo root.
|
||||
|
||||
## Hono Deletion Checklist
|
||||
|
||||
Use this checklist before deleting any Hono route implementation. A route being `bridged` is not enough.
|
||||
|
||||
1. `HttpApi` parity is complete for the route path, method, auth behavior, query parameters, request body, response status, response headers, and error status.
|
||||
2. The route is mounted by default, not only behind `OPENCODE_EXPERIMENTAL_HTTPAPI`.
|
||||
3. If a fallback flag exists, tests cover both the default `HttpApi` path and the fallback Hono path until the fallback is removed.
|
||||
4. OpenAPI generation uses the Effect `HttpApi` route as the source for that path.
|
||||
5. Generated SDK output is unchanged from the Hono-generated contract, or the SDK diff is intentionally reviewed and accepted.
|
||||
6. The legacy Hono `describeRoute`, validator, and handler for that path are removed.
|
||||
7. Any duplicate Zod-only DTOs are deleted or kept only as `.zod` compatibility on the canonical Effect Schema.
|
||||
8. Bridge tests exist for auth, instance selection, success response, and route-specific side effects.
|
||||
9. Mutation routes prove persisted side effects and cleanup behavior in tests. If the mutation disposes/reloads the active instance, disposal happens through an explicit post-response lifecycle hook rather than inline handler teardown.
|
||||
10. Streaming, SSE, websocket, and UI bridge routes have a specific non-Hono replacement plan. Do not force them through `HttpApi` if raw Effect HTTP is a better fit.
|
||||
|
||||
Hono can be removed from the instance server only after all mounted Hono route groups meet this checklist and `server/routes/instance/index.ts` no longer depends on Hono routing for default behavior.
|
||||
|
||||
## Experimental Read Slice Guidance
|
||||
|
||||
For the experimental route group, port read-only JSON routes before mutations:
|
||||
|
||||
- Good first batch: `GET /console`, `GET /console/orgs`, `GET /tool/ids`, `GET /resource`.
|
||||
- Consider `GET /worktree` only if the handler uses `InstanceState.context` instead of `Instance.project`.
|
||||
- Defer `POST /console/switch`, worktree create/remove/reset, and `GET /session` to separate PRs because they mutate state or have broader pagination/session behavior.
|
||||
- Preserve response headers such as pagination cursors if a route is ported.
|
||||
- If SDK generation changes, explain whether it is a semantic contract change or a generator-equivalent type normalization.
|
||||
|
||||
## Schema Notes
|
||||
|
||||
- Use `Schema.Struct(...).annotate({ identifier })` for named OpenAPI refs when handlers return plain objects.
|
||||
- Use `Schema.Class` only when the handler returns real class instances or the constructor requirement is intentional.
|
||||
- Keep nested anonymous shapes as `Schema.Struct` unless a named SDK type is useful.
|
||||
- Avoid parallel hand-written Zod and Effect definitions for the same route boundary.
|
||||
|
||||
## Phases
|
||||
|
||||
### 1. Stabilize The Bridge
|
||||
|
||||
Before porting more routes, cover the bridge behavior that every route depends on.
|
||||
|
||||
- Add tests that hit the Hono-mounted `HttpApi` bridge, not just `HttpApiBuilder.layer` directly.
|
||||
- Cover auth disabled, Basic auth success, `auth_token` success, missing credentials, and bad credentials.
|
||||
- Cover `directory` and `x-opencode-directory` instance selection.
|
||||
- Verify generated SDK output remains unchanged for non-SDK work.
|
||||
- Fix or remove any implemented-but-unmounted `HttpApi` groups.
|
||||
|
||||
### 2. Complete The Inventory
|
||||
|
||||
Create a route inventory from the actual Hono registrations and classify each route.
|
||||
|
||||
Statuses:
|
||||
|
||||
- `bridged`: served through the `HttpApi` bridge when the flag is on.
|
||||
- `implemented`: `HttpApi` group exists but is not mounted through Hono.
|
||||
- `next`: good JSON candidate for near-term porting.
|
||||
- `later`: portable, but needs schema/service cleanup first.
|
||||
- `special`: SSE, websocket, streaming, or UI bridge behavior that likely needs raw Effect HTTP rather than `HttpApi`.
|
||||
|
||||
### 3. Finish JSON Route Parity
|
||||
|
||||
Port remaining JSON routes in small batches.
|
||||
|
||||
Good near-term candidates:
|
||||
|
||||
- top-level reads: `GET /path`, `GET /vcs`, `GET /vcs/diff`, `GET /command`, `GET /agent`, `GET /skill`, `GET /lsp`, `GET /formatter`
|
||||
- simple mutations: `POST /instance/dispose`
|
||||
- experimental JSON reads: console, tool, worktree list, resource list
|
||||
- deferred JSON mutations: workspace/worktree create/remove/reset, file search, MCP auth flows
|
||||
|
||||
Keep large or stateful groups for later:
|
||||
|
||||
- `session`
|
||||
- `sync`
|
||||
- process-level experimental routes
|
||||
|
||||
### 4. Move OpenAPI And SDK Generation
|
||||
|
||||
Hono routes cannot be deleted while `hono-openapi` is the source of SDK generation.
|
||||
|
||||
Status: the Effect `HttpApi` OpenAPI surface is **implemented and opt-in** (`bun dev generate --httpapi`, `OPENCODE_SDK_OPENAPI=httpapi`). Default SDK generation still uses Hono. `httpapi/public.ts` applies the Hono-compat normalization layer to the Effect output. Diff against the Hono-generated spec still shows real gaps that must be closed before the SDK can flip:
|
||||
|
||||
- Branded-type `pattern` constraints on ID schemas are not propagated to the Effect output (~169 missing).
|
||||
- Per-property `description` annotations are not propagated through `Schema.Struct` to the Effect output (~107 missing).
|
||||
- `Event.*` and `SyncEvent.*` component names use dotted form in Hono and PascalCase in Effect (~50 differences, breaks SDK type names).
|
||||
- Effect's component deduper emits numbered duplicates (`Session9`, `SyncEvent.session.updated.11`) that need a name-collision fix.
|
||||
- Cosmetic-only diffs (`additionalProperties: false`, `const` vs `enum`, MAX_SAFE_INTEGER `maximum`, `propertyNames`) can be normalized in `public.ts` if they would otherwise change SDK output.
|
||||
|
||||
Required before route deletion:
|
||||
|
||||
- Close the diff above so Effect-generated SDK output matches the Hono-generated SDK output for every retained path.
|
||||
- Keep operation IDs, schemas, status codes, and SDK type names stable unless the change is intentional.
|
||||
- Flip `packages/sdk/js/script/build.ts` default to `httpapi` and regenerate.
|
||||
- Compare generated SDK output against `dev` for every route group deletion.
|
||||
- Remove Hono OpenAPI stubs only after Effect OpenAPI is the SDK source for those paths.
|
||||
|
||||
V2 cleanup once SDK compatibility no longer needs the legacy Hono contract:
|
||||
|
||||
- Remove `public.ts` compatibility transforms that hide honest `HttpApi` metadata, including auth `securitySchemes`, per-route `security`, and generated `401` responses.
|
||||
- Stop remapping built-in `HttpApi` error schemas back to legacy Hono `BadRequestError` / `NotFoundError` components if V2 clients can consume the actual Effect error shape.
|
||||
- Prefer the direct `HttpApi` OpenAPI output for request/response bodies and named component schemas instead of rewriting it to match Hono generator quirks.
|
||||
- Keep schema fixes that describe the actual wire format, but delete transforms that only preserve legacy SDK type names or inline-vs-ref shape.
|
||||
- Re-evaluate `auth_token` as an OpenAPI security scheme rather than a hand-injected query parameter once clients can consume the V2 spec.
|
||||
|
||||
### 5. Make HttpApi Default For JSON Routes
|
||||
|
||||
After JSON parity and SDK generation are covered:
|
||||
|
||||
- Flip the bridge default for ported JSON routes.
|
||||
- Keep a short-lived fallback flag for the old Hono implementation.
|
||||
- Run the same tests against both the default and fallback path during rollout.
|
||||
- Stop adding new Hono handlers for JSON routes once the default flips.
|
||||
|
||||
### 6. Delete Hono Route Implementations
|
||||
|
||||
Delete Hono routes group-by-group after each group meets the deletion criteria.
|
||||
|
||||
Deletion criteria:
|
||||
|
||||
- `HttpApi` route is mounted by default.
|
||||
- Behavior is covered by bridge-level tests.
|
||||
- OpenAPI/SDK generation comes from Effect for that path.
|
||||
- SDK diff is zero or explicitly accepted.
|
||||
- Legacy Hono route is no longer needed as a fallback.
|
||||
|
||||
After deleting a group:
|
||||
|
||||
- Remove its Hono route file or dead endpoints.
|
||||
- Remove its `.route(...)` registration from `instance/index.ts`.
|
||||
- Remove duplicate Zod-only route DTOs if Effect Schema now owns the type.
|
||||
- Regenerate SDK and verify output.
|
||||
|
||||
### 7. Replace Special Routes
|
||||
|
||||
Special routes need explicit designs before Hono can disappear completely.
|
||||
|
||||
- `event`: SSE
|
||||
- `pty`: websocket
|
||||
- `tui`: UI/control bridge behavior
|
||||
- streaming `session` endpoints
|
||||
|
||||
Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Hono implementations, not forcing every transport shape through `HttpApi`.
|
||||
|
||||
## Current Route Status
|
||||
|
||||
| Area | Status | Notes |
|
||||
| ------------------------- | ----------------- | -------------------------------------------------------------------------- |
|
||||
| `question` | `bridged` | `GET /question`, reply, reject |
|
||||
| `permission` | `bridged` | list and reply |
|
||||
| `provider` | `bridged` | list, auth, OAuth authorize/callback |
|
||||
| `config` | `bridged` | read, providers, update |
|
||||
| `project` | `bridged` | list, current, git init, update |
|
||||
| `file` | `bridged` partial | find text/file/symbol, list/content/status |
|
||||
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
|
||||
| `workspace` | `bridged` | adapter/list/status/create/remove/session-restore |
|
||||
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
|
||||
| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
|
||||
| `session` | `bridged` | read, lifecycle, prompt, message/part mutations, revert, permission reply |
|
||||
| `sync` | `bridged` | start/replay/history |
|
||||
| `event` | `bridged` | SSE via raw Effect HTTP |
|
||||
| `pty` | `special` | websocket |
|
||||
| `tui` | `special` | UI bridge |
|
||||
|
||||
## Full Route Checklist
|
||||
|
||||
This checklist tracks bridge parity only. Checked routes are available through the experimental `HttpApi` bridge; Hono deletion is tracked separately by the deletion checklist above.
|
||||
|
||||
### Top-Level Instance Routes
|
||||
|
||||
- [x] `POST /instance/dispose` - dispose active instance after response.
|
||||
- [x] `GET /path` - current directory and worktree paths.
|
||||
- [x] `GET /vcs` - current VCS status.
|
||||
- [x] `GET /vcs/diff` - VCS diff summary.
|
||||
- [x] `GET /command` - command catalog.
|
||||
- [x] `GET /agent` - agent catalog.
|
||||
- [x] `GET /skill` - skill catalog.
|
||||
- [x] `GET /lsp` - LSP status.
|
||||
- [x] `GET /formatter` - formatter status.
|
||||
|
||||
### Config Routes
|
||||
|
||||
- [x] `GET /config` - read config.
|
||||
- [x] `PATCH /config` - update config and dispose active instance after response.
|
||||
- [x] `GET /config/providers` - config provider summary.
|
||||
|
||||
### Project Routes
|
||||
|
||||
- [x] `GET /project` - list projects.
|
||||
- [x] `GET /project/current` - current project.
|
||||
- [x] `POST /project/git/init` - initialize git and reload active instance after response.
|
||||
- [x] `PATCH /project/:projectID` - update project metadata.
|
||||
|
||||
### Provider Routes
|
||||
|
||||
- [x] `GET /provider` - list providers.
|
||||
- [x] `GET /provider/auth` - list provider auth methods.
|
||||
- [x] `POST /provider/:providerID/oauth/authorize` - start provider OAuth.
|
||||
- [x] `POST /provider/:providerID/oauth/callback` - finish provider OAuth.
|
||||
|
||||
### Question Routes
|
||||
|
||||
- [x] `GET /question` - list questions.
|
||||
- [x] `POST /question/:requestID/reply` - reply to question.
|
||||
- [x] `POST /question/:requestID/reject` - reject question.
|
||||
|
||||
### Permission Routes
|
||||
|
||||
- [x] `GET /permission` - list permission requests.
|
||||
- [x] `POST /permission/:requestID/reply` - reply to permission request.
|
||||
|
||||
### File Routes
|
||||
|
||||
- [x] `GET /find` - text search.
|
||||
- [x] `GET /find/file` - file search.
|
||||
- [x] `GET /find/symbol` - symbol search.
|
||||
- [x] `GET /file` - list directory entries.
|
||||
- [x] `GET /file/content` - read file content.
|
||||
- [x] `GET /file/status` - file status.
|
||||
|
||||
### MCP Routes
|
||||
|
||||
- [x] `GET /mcp` - MCP status.
|
||||
- [x] `POST /mcp` - add MCP server at runtime.
|
||||
- [x] `POST /mcp/:name/auth` - start MCP OAuth.
|
||||
- [x] `POST /mcp/:name/auth/callback` - finish MCP OAuth callback.
|
||||
- [x] `POST /mcp/:name/auth/authenticate` - run MCP OAuth authenticate flow.
|
||||
- [x] `DELETE /mcp/:name/auth` - remove MCP OAuth credentials.
|
||||
- [x] `POST /mcp/:name/connect` - connect MCP server.
|
||||
- [x] `POST /mcp/:name/disconnect` - disconnect MCP server.
|
||||
|
||||
### Experimental Routes
|
||||
|
||||
- [x] `GET /experimental/console` - active Console provider metadata.
|
||||
- [x] `GET /experimental/console/orgs` - switchable Console orgs.
|
||||
- [x] `POST /experimental/console/switch` - switch active Console org.
|
||||
- [x] `GET /experimental/tool/ids` - tool IDs.
|
||||
- [x] `GET /experimental/tool` - tools for provider/model.
|
||||
- [x] `GET /experimental/worktree` - list worktrees.
|
||||
- [x] `POST /experimental/worktree` - create worktree.
|
||||
- [x] `DELETE /experimental/worktree` - remove worktree.
|
||||
- [x] `POST /experimental/worktree/reset` - reset worktree.
|
||||
- [x] `GET /experimental/session` - global session list.
|
||||
- [x] `GET /experimental/resource` - MCP resources.
|
||||
|
||||
### Workspace Routes
|
||||
|
||||
- [x] `GET /experimental/workspace/adapter` - list workspace adapters.
|
||||
- [x] `POST /experimental/workspace` - create workspace.
|
||||
- [x] `GET /experimental/workspace` - list workspaces.
|
||||
- [x] `GET /experimental/workspace/status` - workspace status.
|
||||
- [x] `DELETE /experimental/workspace/:id` - remove workspace.
|
||||
- [x] `POST /experimental/workspace/:id/session-restore` - restore session into workspace.
|
||||
|
||||
### Sync Routes
|
||||
|
||||
- [x] `POST /sync/start` - start workspace sync.
|
||||
- [x] `POST /sync/replay` - replay sync events.
|
||||
- [x] `POST /sync/history` - list sync event history.
|
||||
|
||||
### Session Routes
|
||||
|
||||
- [x] `GET /session` - list sessions.
|
||||
- [x] `GET /session/status` - session status map.
|
||||
- [x] `GET /session/:sessionID` - get session.
|
||||
- [x] `GET /session/:sessionID/children` - get child sessions.
|
||||
- [x] `GET /session/:sessionID/todo` - get session todos.
|
||||
- [x] `POST /session` - create session.
|
||||
- [x] `DELETE /session/:sessionID` - delete session.
|
||||
- [x] `PATCH /session/:sessionID` - update session metadata.
|
||||
- [x] `POST /session/:sessionID/init` - run project init command.
|
||||
- [x] `POST /session/:sessionID/fork` - fork session.
|
||||
- [x] `POST /session/:sessionID/abort` - abort session.
|
||||
- [x] `POST /session/:sessionID/share` - share session.
|
||||
- [x] `GET /session/:sessionID/diff` - session diff.
|
||||
- [x] `DELETE /session/:sessionID/share` - unshare session.
|
||||
- [x] `POST /session/:sessionID/summarize` - summarize session.
|
||||
- [x] `GET /session/:sessionID/message` - list session messages.
|
||||
- [x] `GET /session/:sessionID/message/:messageID` - get message.
|
||||
- [x] `DELETE /session/:sessionID/message/:messageID` - delete message.
|
||||
- [x] `DELETE /session/:sessionID/message/:messageID/part/:partID` - delete part.
|
||||
- [x] `PATCH /session/:sessionID/message/:messageID/part/:partID` - update part.
|
||||
- [x] `POST /session/:sessionID/message` - prompt with streaming response.
|
||||
- [x] `POST /session/:sessionID/prompt_async` - async prompt.
|
||||
- [x] `POST /session/:sessionID/command` - run command.
|
||||
- [x] `POST /session/:sessionID/shell` - run shell command.
|
||||
- [x] `POST /session/:sessionID/revert` - revert message.
|
||||
- [x] `POST /session/:sessionID/unrevert` - restore reverted messages.
|
||||
- [x] `POST /session/:sessionID/permissions/:permissionID` - deprecated permission response route.
|
||||
|
||||
### Event Routes
|
||||
|
||||
- [x] `GET /event` - SSE event stream via raw Effect HTTP.
|
||||
|
||||
### PTY Routes
|
||||
|
||||
- [x] `GET /pty` - list PTY sessions.
|
||||
- [x] `POST /pty` - create PTY session.
|
||||
- [x] `GET /pty/:ptyID` - get PTY session.
|
||||
- [x] `PUT /pty/:ptyID` - update PTY session.
|
||||
- [x] `DELETE /pty/:ptyID` - remove PTY session.
|
||||
- [x] `GET /pty/:ptyID/connect` - PTY websocket; replace with raw Effect HTTP/websocket support.
|
||||
|
||||
### TUI Routes
|
||||
|
||||
- [x] `POST /tui/append-prompt` - append prompt.
|
||||
- [x] `POST /tui/open-help` - open help.
|
||||
- [x] `POST /tui/open-sessions` - open sessions.
|
||||
- [x] `POST /tui/open-themes` - open themes.
|
||||
- [x] `POST /tui/open-models` - open models.
|
||||
- [x] `POST /tui/submit-prompt` - submit prompt.
|
||||
- [x] `POST /tui/clear-prompt` - clear prompt.
|
||||
- [x] `POST /tui/execute-command` - execute command.
|
||||
- [x] `POST /tui/show-toast` - show toast.
|
||||
- [x] `POST /tui/publish` - publish TUI event.
|
||||
- [x] `POST /tui/select-session` - select session.
|
||||
- [x] `GET /tui/control/next` - get next TUI request.
|
||||
- [x] `POST /tui/control/response` - submit TUI control response.
|
||||
|
||||
## Remaining PR Plan
|
||||
|
||||
Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays reviewable.
|
||||
|
||||
1. [x] Bridge `PATCH /project/:projectID`.
|
||||
2. [x] Bridge MCP add/connect/disconnect routes.
|
||||
3. [x] Bridge MCP OAuth routes: start, callback, authenticate, remove.
|
||||
4. [x] Bridge experimental console switch and tool list routes.
|
||||
5. [x] Bridge experimental global session list.
|
||||
6. [x] Bridge workspace create/remove/session-restore routes.
|
||||
7. [x] Bridge sync start/replay/history routes.
|
||||
8. [x] Bridge session read routes: list, status, get, children, todo, diff, messages.
|
||||
9. [x] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
|
||||
10. [x] Bridge remaining session mutation and prompt routes.
|
||||
11. [ ] Replace event SSE with non-Hono Effect HTTP. The Effect backend has a raw Effect HTTP `httpapi/event.ts`; the Hono backend still uses `hono/streaming` `streamSSE`. Either port Hono `/event` to raw Effect HTTP for the fallback window, or skip and delete it together with Hono in step 15.
|
||||
12. [x] Replace pty websocket/control routes with non-Hono Effect HTTP for the Effect backend. Hono `pty.ts` remains in the Hono backend.
|
||||
13. [x] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer for the Effect backend. Hono `tui.ts` remains in the Hono backend.
|
||||
14. [ ] Switch OpenAPI/SDK generation to Effect routes and compare SDK output. Effect path is implemented and opt-in via `--httpapi` / `OPENCODE_SDK_OPENAPI=httpapi`. Close the schema-shape gaps in `public.ts` (branded `pattern`, per-property `description`, `Event.*` / `SyncEvent.*` naming, dedup collisions), then flip `packages/sdk/js/script/build.ts` default.
|
||||
15. [ ] Flip `backend.ts` default from `hono` to `effect-httpapi`, keep `OPENCODE_EXPERIMENTAL_HTTPAPI` (or its inverse) as a short fallback flag, then delete replaced Hono route files.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Add first `HttpApi` JSON route slices.
|
||||
- [x] Bridge selected `HttpApi` routes behind `OPENCODE_EXPERIMENTAL_HTTPAPI`. (Now backend-fork-at-startup rather than in-Hono path mounting.)
|
||||
- [x] Reuse existing Effect services in handlers.
|
||||
- [x] Provide auth, instance lookup, and observability in the Effect route layer.
|
||||
- [x] Centralize auth via Effect `Config` for the Effect backend.
|
||||
- [x] Support `auth_token` as a query security scheme.
|
||||
- [x] Add bridge-level auth and instance tests.
|
||||
- [x] Complete exact Hono route inventory.
|
||||
- [x] Resolve implemented-but-unmounted route groups.
|
||||
- [x] Port remaining top-level JSON reads.
|
||||
- [x] Implement Effect `HttpApi` OpenAPI generation behind `--httpapi` / `OPENCODE_SDK_OPENAPI=httpapi`.
|
||||
- [ ] Close Effect-vs-Hono OpenAPI schema-shape gaps and flip the SDK generator default.
|
||||
- [ ] Flip the runtime backend default from `hono` to `effect-httpapi`, with a short fallback flag.
|
||||
- [ ] Delete replaced Hono route implementations.
|
||||
- [ ] Replace SSE/websocket/streaming Hono routes with non-Hono implementations (or remove with the rest of Hono).
|
||||
@@ -347,33 +347,7 @@ export class Agent implements ACPAgent {
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
const kind = toToolKind(part.tool)
|
||||
const content: ToolCallContent[] = [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.state.output,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (kind === "edit") {
|
||||
const input = part.state.input
|
||||
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
|
||||
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
|
||||
const newText =
|
||||
typeof input["newString"] === "string"
|
||||
? input["newString"]
|
||||
: typeof input["content"] === "string"
|
||||
? input["content"]
|
||||
: ""
|
||||
content.push({
|
||||
type: "diff",
|
||||
path: filePath,
|
||||
oldText,
|
||||
newText,
|
||||
})
|
||||
}
|
||||
const content = completedToolContent(part, kind)
|
||||
|
||||
if (part.tool === "todowrite") {
|
||||
const parsedTodos = decodeTodos(part.state.output)
|
||||
@@ -413,10 +387,7 @@ export class Agent implements ACPAgent {
|
||||
content,
|
||||
title: part.state.title,
|
||||
rawInput: part.state.input,
|
||||
rawOutput: {
|
||||
output: part.state.output,
|
||||
metadata: part.state.metadata,
|
||||
},
|
||||
rawOutput: completedToolRawOutput(part),
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -860,33 +831,7 @@ export class Agent implements ACPAgent {
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
const kind = toToolKind(part.tool)
|
||||
const content: ToolCallContent[] = [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.state.output,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (kind === "edit") {
|
||||
const input = part.state.input
|
||||
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
|
||||
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
|
||||
const newText =
|
||||
typeof input["newString"] === "string"
|
||||
? input["newString"]
|
||||
: typeof input["content"] === "string"
|
||||
? input["content"]
|
||||
: ""
|
||||
content.push({
|
||||
type: "diff",
|
||||
path: filePath,
|
||||
oldText,
|
||||
newText,
|
||||
})
|
||||
}
|
||||
const content = completedToolContent(part, kind)
|
||||
|
||||
if (part.tool === "todowrite") {
|
||||
const parsedTodos = decodeTodos(part.state.output)
|
||||
@@ -926,10 +871,7 @@ export class Agent implements ACPAgent {
|
||||
content,
|
||||
title: part.state.title,
|
||||
rawInput: part.state.input,
|
||||
rawOutput: {
|
||||
output: part.state.output,
|
||||
metadata: part.state.metadata,
|
||||
},
|
||||
rawOutput: completedToolRawOutput(part),
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -1655,6 +1597,70 @@ function toLocations(toolName: string, input: Record<string, any>): { path: stri
|
||||
}
|
||||
}
|
||||
|
||||
function completedToolContent(part: ToolPart, kind: ToolKind): ToolCallContent[] {
|
||||
if (part.state.status !== "completed") return []
|
||||
|
||||
const content: ToolCallContent[] = [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.state.output,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (kind === "edit") {
|
||||
const input = part.state.input
|
||||
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
|
||||
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
|
||||
const newText =
|
||||
typeof input["newString"] === "string"
|
||||
? input["newString"]
|
||||
: typeof input["content"] === "string"
|
||||
? input["content"]
|
||||
: ""
|
||||
content.push({
|
||||
type: "diff",
|
||||
path: filePath,
|
||||
oldText,
|
||||
newText,
|
||||
})
|
||||
}
|
||||
|
||||
content.push(...imageContents(part.state.attachments ?? []))
|
||||
return content
|
||||
}
|
||||
|
||||
function completedToolRawOutput(part: ToolPart) {
|
||||
if (part.state.status !== "completed") return {}
|
||||
return {
|
||||
output: part.state.output,
|
||||
metadata: part.state.metadata,
|
||||
...(part.state.attachments?.length ? { attachments: part.state.attachments } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function imageContents(attachments: Array<{ mime: string; url: string }>): ToolCallContent[] {
|
||||
return attachments.flatMap((attachment): ToolCallContent[] => {
|
||||
const match = attachment.url.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/)
|
||||
const mime = match?.[1] ?? attachment.mime
|
||||
if (!mime.startsWith("image/")) return []
|
||||
const data = match?.[2]
|
||||
if (data === undefined) return []
|
||||
return [
|
||||
{
|
||||
type: "content" as const,
|
||||
content: {
|
||||
type: "image" as const,
|
||||
mimeType: mime,
|
||||
data,
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> {
|
||||
const sdk = config.sdk
|
||||
const configured = config.defaultModel
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { zodObject } from "@/util/effect-zod"
|
||||
|
||||
export type Definition<Type extends string = string, Properties extends Schema.Top = Schema.Top> = {
|
||||
type: Type
|
||||
@@ -18,23 +16,6 @@ export function define<Type extends string, Properties extends Schema.Top>(
|
||||
return result
|
||||
}
|
||||
|
||||
export function payloads() {
|
||||
return registry
|
||||
.entries()
|
||||
.map(([type, def]) => {
|
||||
return z
|
||||
.object({
|
||||
id: z.string(),
|
||||
type: z.literal(type),
|
||||
properties: zodObject(def.properties),
|
||||
})
|
||||
.meta({
|
||||
ref: `Event.${def.type}`,
|
||||
})
|
||||
})
|
||||
.toArray()
|
||||
}
|
||||
|
||||
export function effectPayloads() {
|
||||
return registry
|
||||
.entries()
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
import { Server } from "../../server/server"
|
||||
import type { CommandModule } from "yargs"
|
||||
|
||||
type Args = {
|
||||
httpapi: boolean
|
||||
hono: boolean
|
||||
}
|
||||
type Args = {}
|
||||
|
||||
export const GenerateCommand = {
|
||||
command: "generate",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option("httpapi", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description:
|
||||
"Generate OpenAPI from the Effect HttpApi contract (default; flag retained for backwards compatibility)",
|
||||
})
|
||||
.option("hono", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Generate OpenAPI from the legacy Hono backend (parity-diff only; will be removed)",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const specs = args.hono ? await Server.openapiHono() : await Server.openapi()
|
||||
builder: (yargs) => yargs,
|
||||
handler: async () => {
|
||||
const specs = (await Server.openapi()) as { paths: Record<string, Record<string, any>> }
|
||||
for (const item of Object.values(specs.paths)) {
|
||||
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
||||
const operation = item[method]
|
||||
|
||||
@@ -119,15 +119,29 @@ export function DialogSessionList() {
|
||||
))
|
||||
}
|
||||
|
||||
function orderByRecency(sessionsList: NonNullable<ReturnType<typeof sessions>>) {
|
||||
return sessionsList
|
||||
.filter((x) => x.parentID === undefined)
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.map((x) => x.id)
|
||||
}
|
||||
|
||||
const [browseOrder] = createSignal<string[]>(orderByRecency(sync.data.session))
|
||||
|
||||
const options = createMemo(() => {
|
||||
const today = new Date().toDateString()
|
||||
return sessions()
|
||||
.filter((x) => x.parentID === undefined)
|
||||
.toSorted((a, b) => {
|
||||
const updatedDay = new Date(b.time.updated).setHours(0, 0, 0, 0) - new Date(a.time.updated).setHours(0, 0, 0, 0)
|
||||
if (updatedDay !== 0) return updatedDay
|
||||
return b.time.created - a.time.created
|
||||
})
|
||||
const sessionMap = new Map(
|
||||
sessions()
|
||||
.filter((x) => x.parentID === undefined)
|
||||
.map((x) => [x.id, x]),
|
||||
)
|
||||
|
||||
const searchResult = searchResults()
|
||||
const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder()
|
||||
|
||||
return displayOrder
|
||||
.map((id) => sessionMap.get(id))
|
||||
.filter((x) => x !== undefined)
|
||||
.map((x) => {
|
||||
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ function View(props: { api: TuiPluginApi }) {
|
||||
const [open, setOpen] = createSignal(true)
|
||||
const theme = () => props.api.theme.current
|
||||
const list = createMemo(() => props.api.state.lsp())
|
||||
const off = createMemo(() => props.api.state.config.lsp === false)
|
||||
const off = createMemo(() => !props.api.state.config.lsp)
|
||||
|
||||
return (
|
||||
<box>
|
||||
@@ -22,9 +22,7 @@ function View(props: { api: TuiPluginApi }) {
|
||||
</box>
|
||||
<Show when={list().length <= 2 || open()}>
|
||||
<Show when={list().length === 0}>
|
||||
<text fg={theme().textMuted}>
|
||||
{off() ? "LSPs have been disabled in settings" : "LSPs will activate as files are read"}
|
||||
</text>
|
||||
<text fg={theme().textMuted}>{off() ? "LSPs are disabled" : "LSPs will activate as files are read"}</text>
|
||||
</Show>
|
||||
<For each={list()}>
|
||||
{(item) => (
|
||||
|
||||
@@ -121,12 +121,12 @@ const LogLevelRef = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate
|
||||
})
|
||||
|
||||
// The Effect Schema is the canonical source of truth. The `.zod` compatibility
|
||||
// surface is derived so existing Hono validators keep working without a parallel
|
||||
// Zod definition.
|
||||
// surface is derived from it so plugin/SDK Zod consumers keep working without
|
||||
// a parallel hand-maintained Zod definition.
|
||||
//
|
||||
// The walker emits `z.object({...})` which is non-strict by default. Config
|
||||
// historically uses `.strict()` (additionalProperties: false in openapi.json),
|
||||
// so layer that on after derivation. Re-apply the Config ref afterward
|
||||
// so layer that on after derivation. Re-apply the Config ref afterward
|
||||
// since `.strict()` strips the walker's meta annotation.
|
||||
export const Info = Schema.Struct({
|
||||
$schema: Schema.optional(Schema.String).annotate({
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Schema, Struct } from "effect"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { WorkspaceID } from "./schema"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { type DeepMutable, withStatics } from "@/util/schema"
|
||||
import type { DeepMutable } from "@/util/schema"
|
||||
|
||||
export const WorkspaceInfo = Schema.Struct({
|
||||
id: WorkspaceID,
|
||||
@@ -12,21 +11,19 @@ export const WorkspaceInfo = Schema.Struct({
|
||||
directory: Schema.NullOr(Schema.String),
|
||||
extra: Schema.NullOr(Schema.Unknown),
|
||||
projectID: ProjectID,
|
||||
})
|
||||
.annotate({ identifier: "Workspace" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
}).annotate({ identifier: "Workspace" })
|
||||
export type WorkspaceInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceInfo>>
|
||||
|
||||
export const WorkspaceListedInfo = Schema.Struct(Struct.omit(WorkspaceInfo.fields, ["id"]))
|
||||
.annotate({ identifier: "WorkspaceListedInfo" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export const WorkspaceListedInfo = Schema.Struct(Struct.omit(WorkspaceInfo.fields, ["id"])).annotate({
|
||||
identifier: "WorkspaceListedInfo",
|
||||
})
|
||||
export type WorkspaceListedInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceListedInfo>>
|
||||
|
||||
export const WorkspaceAdapterEntry = Schema.Struct({
|
||||
type: Schema.String,
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
})
|
||||
export type WorkspaceAdapterEntry = Schema.Schema.Type<typeof WorkspaceAdapterEntry>
|
||||
|
||||
export type Target =
|
||||
|
||||
@@ -28,8 +28,6 @@ import { errorData } from "@/util/error"
|
||||
import { waitEvent } from "./util"
|
||||
import { WorkspaceContext } from "./workspace-context"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod as effectZod, zodObject } from "@/util/effect-zod"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
@@ -37,9 +35,7 @@ import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
export const Info = Schema.Struct({
|
||||
...WorkspaceInfoSchema.fields,
|
||||
timeUsed: Schema.Number,
|
||||
})
|
||||
.annotate({ identifier: "Workspace" })
|
||||
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
|
||||
}).annotate({ identifier: "Workspace" })
|
||||
export type Info = WorkspaceInfo & { timeUsed: number }
|
||||
|
||||
export const ConnectionStatus = Schema.Struct({
|
||||
@@ -88,14 +84,14 @@ export const CreateInput = Schema.Struct({
|
||||
branch: Info.fields.branch,
|
||||
projectID: ProjectID,
|
||||
extra: Schema.optional(Info.fields.extra),
|
||||
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
|
||||
})
|
||||
export type CreateInput = Schema.Schema.Type<typeof CreateInput>
|
||||
|
||||
export const SessionWarpInput = Schema.Struct({
|
||||
workspaceID: Schema.NullOr(WorkspaceID),
|
||||
sessionID: SessionID,
|
||||
copyChanges: Schema.optional(Schema.Boolean),
|
||||
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
|
||||
})
|
||||
export type SessionWarpInput = Schema.Schema.Type<typeof SessionWarpInput>
|
||||
|
||||
export class SyncHttpError extends Schema.TaggedErrorClass<SyncHttpError>()("WorkspaceSyncHttpError", {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Newtype } from "@/util/schema"
|
||||
|
||||
export class PermissionID extends Newtype<PermissionID>()(
|
||||
"PermissionID",
|
||||
Schema.String.annotate({ [ZodOverride]: Identifier.schema("permission") }),
|
||||
Schema.String.check(Schema.isStartsWith("per")).annotate({ [ZodOverride]: Identifier.schema("permission") }),
|
||||
) {
|
||||
static ascending(id?: string): PermissionID {
|
||||
return this.make(Identifier.ascending("permission", id))
|
||||
|
||||
@@ -935,6 +935,16 @@ export const ConfigProvidersResult = Schema.Struct({
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type ConfigProvidersResult = Types.DeepMutable<Schema.Schema.Type<typeof ConfigProvidersResult>>
|
||||
|
||||
export function toPublicInfo(provider: Info): Info {
|
||||
return JSON.parse(
|
||||
JSON.stringify(provider, (_, value) => {
|
||||
if (typeof value === "function" || typeof value === "symbol" || value === undefined) return undefined
|
||||
if (typeof value === "bigint") return value.toString()
|
||||
return value
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function defaultModelIDs<T extends { models: Record<string, { id: string }> }>(providers: Record<string, T>) {
|
||||
return mapValues(providers, (item) => sort(Object.values(item.models))[0].id)
|
||||
}
|
||||
@@ -1299,7 +1309,7 @@ const layer: Layer.Layer<
|
||||
const options = yield* Effect.promise(() =>
|
||||
plugin.auth!.loader!(
|
||||
() => bridge.promise(auth.get(providerID).pipe(Effect.orDie)) as any,
|
||||
database[plugin.auth!.provider],
|
||||
toPublicInfo(database[plugin.auth!.provider]),
|
||||
),
|
||||
)
|
||||
const opts = options ?? {}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Newtype } from "@/util/schema"
|
||||
|
||||
export class QuestionID extends Newtype<QuestionID>()(
|
||||
"QuestionID",
|
||||
Schema.String.annotate({ [ZodOverride]: Identifier.schema("question") }),
|
||||
Schema.String.check(Schema.isStartsWith("que")).annotate({ [ZodOverride]: Identifier.schema("question") }),
|
||||
) {
|
||||
static ascending(id?: string): QuestionID {
|
||||
return this.make(Identifier.ascending("question", id))
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { Hono } from "hono"
|
||||
import { createBunWebSocket } from "hono/bun"
|
||||
import type { Adapter, FetchApp, Opts } from "./adapter"
|
||||
|
||||
function listen(app: FetchApp, opts: Opts, websocket?: ReturnType<typeof createBunWebSocket>["websocket"]) {
|
||||
const start = (port: number) => {
|
||||
try {
|
||||
if (websocket) {
|
||||
return Bun.serve({ fetch: app.fetch, hostname: opts.hostname, idleTimeout: 0, websocket, port })
|
||||
}
|
||||
return Bun.serve({ fetch: app.fetch, hostname: opts.hostname, idleTimeout: 0, port })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port)
|
||||
if (!server) {
|
||||
throw new Error(`Failed to start server on port ${opts.port}`)
|
||||
}
|
||||
if (!server.port) {
|
||||
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||
}
|
||||
return {
|
||||
port: server.port,
|
||||
stop(close?: boolean) {
|
||||
return Promise.resolve(server.stop(close))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const adapter: Adapter = {
|
||||
create(app: Hono) {
|
||||
const ws = createBunWebSocket()
|
||||
return {
|
||||
upgradeWebSocket: ws.upgradeWebSocket,
|
||||
listen: (opts) => Promise.resolve(listen(app, opts, ws.websocket)),
|
||||
}
|
||||
},
|
||||
createFetch(app) {
|
||||
return {
|
||||
listen: (opts) => Promise.resolve(listen(app, opts)),
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { EventEmitter } from "node:events"
|
||||
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
||||
import { createNodeWebSocket } from "@hono/node-ws"
|
||||
import type { Hono } from "hono"
|
||||
import type { Adapter, FetchApp, Opts } from "./adapter"
|
||||
|
||||
async function listen(app: FetchApp, opts: Opts, inject?: (server: ServerType) => void) {
|
||||
const start = (port: number) =>
|
||||
new Promise<ServerType>((resolve, reject) => {
|
||||
const server = createAdaptorServer({ fetch: app.fetch })
|
||||
const events = server as EventEmitter
|
||||
inject?.(server)
|
||||
const fail = (err: Error) => {
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
const ready = () => {
|
||||
cleanup()
|
||||
resolve(server)
|
||||
}
|
||||
const cleanup = () => {
|
||||
events.off("error", fail)
|
||||
events.off("listening", ready)
|
||||
}
|
||||
events.once("error", fail)
|
||||
events.once("listening", ready)
|
||||
server.listen(port, opts.hostname)
|
||||
})
|
||||
|
||||
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
|
||||
const addr = server.address()
|
||||
if (!addr || typeof addr === "string") {
|
||||
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||
}
|
||||
|
||||
let closing: Promise<void> | undefined
|
||||
return {
|
||||
port: addr.port,
|
||||
stop(close?: boolean) {
|
||||
closing ??= new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
if (close) {
|
||||
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
|
||||
server.closeAllConnections()
|
||||
}
|
||||
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
|
||||
server.closeIdleConnections()
|
||||
}
|
||||
}
|
||||
})
|
||||
return closing
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const adapter: Adapter = {
|
||||
create(app: Hono) {
|
||||
const ws = createNodeWebSocket({ app })
|
||||
return {
|
||||
upgradeWebSocket: ws.upgradeWebSocket,
|
||||
listen: (opts) => listen(app, opts, ws.injectWebSocket),
|
||||
}
|
||||
},
|
||||
createFetch(app) {
|
||||
return {
|
||||
listen: (opts) => listen(app, opts),
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { Hono } from "hono"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
|
||||
export type FetchApp = {
|
||||
fetch(request: Request): Response | Promise<Response>
|
||||
}
|
||||
|
||||
export type Opts = {
|
||||
port: number
|
||||
hostname: string
|
||||
}
|
||||
|
||||
export type Listener = {
|
||||
port: number
|
||||
stop: (close?: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export interface Runtime {
|
||||
upgradeWebSocket: UpgradeWebSocket
|
||||
listen(opts: Opts): Promise<Listener>
|
||||
}
|
||||
|
||||
export interface Adapter {
|
||||
create(app: Hono): Runtime
|
||||
createFetch(app: FetchApp): Omit<Runtime, "upgradeWebSocket">
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
|
||||
export type Backend = "effect-httpapi" | "hono"
|
||||
|
||||
export type Selection = {
|
||||
backend: Backend
|
||||
reason: "env" | "stable" | "explicit"
|
||||
}
|
||||
|
||||
export type Attributes = ReturnType<typeof attributes>
|
||||
|
||||
export function select(): Selection {
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) return { backend: "effect-httpapi", reason: "env" }
|
||||
return { backend: "hono", reason: "stable" }
|
||||
}
|
||||
|
||||
export function attributes(selection: Selection): Record<string, string> {
|
||||
return {
|
||||
"opencode.server.backend": selection.backend,
|
||||
"opencode.server.backend.reason": selection.reason,
|
||||
"opencode.installation.channel": InstallationChannel,
|
||||
"opencode.installation.version": InstallationVersion,
|
||||
}
|
||||
}
|
||||
|
||||
export function force(selection: Selection, backend: Backend): Selection {
|
||||
return {
|
||||
backend,
|
||||
reason: selection.backend === backend ? selection.reason : "explicit",
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
|
||||
export const ERRORS = {
|
||||
400: {
|
||||
description: "Bad request",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z
|
||||
.object({
|
||||
data: z.any(),
|
||||
errors: z.array(z.record(z.string(), z.any())),
|
||||
success: z.literal(false),
|
||||
})
|
||||
.meta({
|
||||
ref: "BadRequestError",
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "Forbidden",
|
||||
},
|
||||
404: {
|
||||
description: "Not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(NotFoundError.Schema),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export function errors(...codes: number[]) {
|
||||
return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]]))
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { HEADER, diff, load } from "./shared/fence"
|
||||
|
||||
const log = Log.create({ service: "fence-middleware" })
|
||||
|
||||
export const FenceMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") return next()
|
||||
|
||||
const prev = load()
|
||||
await next()
|
||||
const current = diff(prev, load())
|
||||
|
||||
if (Object.keys(current).length > 0) {
|
||||
log.info("header", {
|
||||
diff: current,
|
||||
})
|
||||
c.res.headers.set(HEADER, JSON.stringify(current))
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { createServer } from "node:http"
|
||||
import type { Opts } from "./adapter"
|
||||
import { Service } from "./httpapi-server"
|
||||
|
||||
export { Service }
|
||||
|
||||
export const name = "node-http-server"
|
||||
|
||||
export type Opts = { port: number; hostname: string }
|
||||
|
||||
export const layer = (opts: Opts) => {
|
||||
const server = createServer()
|
||||
const serverRef = { closeStarted: false, forceStop: false }
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import { Session } from "@/session/session"
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||
import type { ErrorHandler, MiddlewareHandler } from "hono"
|
||||
import { HTTPException } from "hono/http-exception"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { basicAuth } from "hono/basic-auth"
|
||||
import { cors } from "hono/cors"
|
||||
import { compress } from "hono/compress"
|
||||
import * as ServerBackend from "./backend"
|
||||
import { isAllowedCorsOrigin, type CorsOptions } from "./cors"
|
||||
import { isPtyConnectPath, PTY_CONNECT_TICKET_QUERY } from "./shared/pty-ticket"
|
||||
import { isPublicUIPath } from "./shared/public-ui"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
export const ErrorMiddleware: ErrorHandler = (err, c) => {
|
||||
log.error("failed", {
|
||||
error: err,
|
||||
})
|
||||
if (err instanceof NamedError) {
|
||||
let status: ContentfulStatusCode
|
||||
if (err instanceof NotFoundError) status = 404
|
||||
else if (err instanceof Provider.ModelNotFoundError) status = 400
|
||||
else if (err.name === "ProviderAuthValidationFailed") status = 400
|
||||
else if (err.name.startsWith("Worktree")) status = 400
|
||||
else status = 500
|
||||
return c.json(err.toObject(), { status })
|
||||
}
|
||||
if (err instanceof Session.BusyError) {
|
||||
return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 })
|
||||
}
|
||||
if (err instanceof HTTPException) return err.getResponse()
|
||||
const message = err instanceof Error && err.stack ? err.stack : err.toString()
|
||||
return c.json(new NamedError.Unknown({ message }).toObject(), {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
|
||||
export const AuthMiddleware: MiddlewareHandler = (c, next) => {
|
||||
// Allow CORS preflight requests to succeed without auth.
|
||||
// Browser clients sending Authorization headers will preflight with OPTIONS.
|
||||
if (c.req.method === "OPTIONS") return next()
|
||||
const password = Flag.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return next()
|
||||
if (isPublicUIPath(c.req.method, c.req.path)) return next()
|
||||
if (isPtyConnectPath(c.req.path) && c.req.query(PTY_CONNECT_TICKET_QUERY)) return next()
|
||||
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||
|
||||
if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
|
||||
|
||||
return basicAuth({ username, password })(c, next)
|
||||
}
|
||||
|
||||
export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const skip = c.req.path === "/log"
|
||||
if (skip) return next()
|
||||
const attributes = {
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
// If this logger grows full-URL fields, redact auth_token and ticket query params.
|
||||
...backendAttributes,
|
||||
}
|
||||
log.info("request", attributes)
|
||||
const timer = log.time("request", attributes)
|
||||
await next()
|
||||
timer.stop()
|
||||
}
|
||||
}
|
||||
|
||||
export function CorsMiddleware(opts?: CorsOptions): MiddlewareHandler {
|
||||
return cors({
|
||||
maxAge: 86_400,
|
||||
origin(input) {
|
||||
if (isAllowedCorsOrigin(input, opts)) return input
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const zipped = compress()
|
||||
export const CompressionMiddleware: MiddlewareHandler = (c, next) => {
|
||||
const path = c.req.path
|
||||
const method = c.req.method
|
||||
if (path === "/event" || path === "/global/event") return next()
|
||||
if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return next()
|
||||
return zipped(c, next)
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import * as Fence from "./shared/fence"
|
||||
import type { WorkspaceID } from "@/control-plane/schema"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { ProxyUtil } from "./proxy-util"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
|
||||
type Msg = string | ArrayBuffer | Uint8Array
|
||||
|
||||
function send(ws: { send(data: string | ArrayBuffer | Uint8Array): void }, data: any) {
|
||||
if (data instanceof Blob) {
|
||||
return data.arrayBuffer().then((x) => ws.send(x))
|
||||
}
|
||||
return ws.send(data)
|
||||
}
|
||||
|
||||
const app = (upgrade: UpgradeWebSocket) =>
|
||||
new Hono().get(
|
||||
"/__workspace_ws",
|
||||
upgrade((c) => {
|
||||
const url = c.req.header("x-opencode-proxy-url")
|
||||
const queue: Msg[] = []
|
||||
let remote: WebSocket | undefined
|
||||
return {
|
||||
onOpen(_, ws) {
|
||||
if (!url) {
|
||||
ws.close(1011, "missing proxy target")
|
||||
return
|
||||
}
|
||||
remote = new WebSocket(url, ProxyUtil.websocketProtocols(c.req.raw))
|
||||
remote.binaryType = "arraybuffer"
|
||||
remote.onopen = () => {
|
||||
for (const item of queue) remote?.send(item)
|
||||
queue.length = 0
|
||||
}
|
||||
remote.onmessage = (event) => {
|
||||
void send(ws, event.data)
|
||||
}
|
||||
remote.onerror = () => {
|
||||
ws.close(1011, "proxy error")
|
||||
}
|
||||
remote.onclose = (event) => {
|
||||
ws.close(event.code, event.reason)
|
||||
}
|
||||
},
|
||||
onMessage(event) {
|
||||
const data = event.data
|
||||
if (typeof data !== "string" && !(data instanceof Uint8Array) && !(data instanceof ArrayBuffer)) return
|
||||
if (remote?.readyState === WebSocket.OPEN) {
|
||||
remote.send(data)
|
||||
return
|
||||
}
|
||||
queue.push(data)
|
||||
},
|
||||
onClose(event) {
|
||||
remote?.close(event.code, event.reason)
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const log = Log.create({ service: "server-proxy" })
|
||||
|
||||
function statusText(response: unknown) {
|
||||
return (response as { source?: Response }).source?.statusText
|
||||
}
|
||||
|
||||
export function httpEffect(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) {
|
||||
return Effect.gen(function* () {
|
||||
const syncing = yield* Workspace.Service.use((workspace) => workspace.isSyncing(workspaceID))
|
||||
if (!syncing) {
|
||||
return new Response(`broken sync connection for workspace: ${workspaceID}`, {
|
||||
status: 503,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const response = yield* HttpClient.execute(
|
||||
HttpClientRequest.make(req.method as never)(url, {
|
||||
headers: ProxyUtil.headers(req, extra),
|
||||
body:
|
||||
req.method === "GET" || req.method === "HEAD"
|
||||
? HttpBody.empty
|
||||
: HttpBody.raw(req.body, {
|
||||
contentType: req.headers.get("content-type") ?? undefined,
|
||||
contentLength: req.headers.get("content-length")
|
||||
? Number(req.headers.get("content-length"))
|
||||
: undefined,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const next = new Headers(response.headers as HeadersInit)
|
||||
const sync = Fence.parse(next)
|
||||
next.delete("content-encoding")
|
||||
next.delete("content-length")
|
||||
|
||||
if (sync) yield* Fence.waitEffect(workspaceID, sync, req.signal)
|
||||
const body = yield* Stream.toReadableStreamEffect(response.stream.pipe(Stream.catchCause(() => Stream.empty)))
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
statusText: statusText(response),
|
||||
headers: next,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(FetchHttpClient.layer),
|
||||
Effect.catch(() => Effect.succeed(new Response(null, { status: 500 }))),
|
||||
)
|
||||
}
|
||||
|
||||
export function http(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) {
|
||||
return AppRuntime.runPromise(httpEffect(url, extra, req, workspaceID))
|
||||
}
|
||||
|
||||
export function websocket(
|
||||
upgrade: UpgradeWebSocket,
|
||||
target: string | URL,
|
||||
extra: HeadersInit | undefined,
|
||||
req: Request,
|
||||
env: unknown,
|
||||
) {
|
||||
const proxy = new URL(req.url)
|
||||
proxy.pathname = "/__workspace_ws"
|
||||
proxy.search = ""
|
||||
const next = new Headers(req.headers)
|
||||
next.set("x-opencode-proxy-url", ProxyUtil.websocketTargetURL(target))
|
||||
for (const [key, value] of new Headers(extra).entries()) {
|
||||
next.set(key, value)
|
||||
}
|
||||
log.info("proxy websocket", {
|
||||
request: req.url,
|
||||
target: String(target),
|
||||
})
|
||||
return app(upgrade).fetch(
|
||||
new Request(proxy, {
|
||||
method: req.method,
|
||||
headers: next,
|
||||
signal: req.signal,
|
||||
}),
|
||||
env as never,
|
||||
)
|
||||
}
|
||||
|
||||
export * as ServerProxy from "./proxy"
|
||||
@@ -1,160 +0,0 @@
|
||||
import { Auth } from "@/auth"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Effect } from "effect"
|
||||
import { ProviderID } from "@/provider/schema"
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { errors } from "../../error"
|
||||
|
||||
export function ControlPlaneRoutes(): Hono {
|
||||
const app = new Hono()
|
||||
return app
|
||||
.put(
|
||||
"/auth/:providerID",
|
||||
describeRoute({
|
||||
summary: "Set auth credentials",
|
||||
description: "Set authentication credentials",
|
||||
operationId: "auth.set",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully set authentication credentials",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", Auth.Info.zod),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const info = c.req.valid("json")
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.set(providerID, info)
|
||||
}),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/auth/:providerID",
|
||||
describeRoute({
|
||||
summary: "Remove auth credentials",
|
||||
description: "Remove authentication credentials",
|
||||
operationId: "auth.remove",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully removed authentication credentials",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.remove(providerID)
|
||||
}),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/doc",
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: "opencode",
|
||||
version: "0.0.3",
|
||||
description: "opencode api",
|
||||
},
|
||||
openapi: "3.1.1",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.use(
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
directory: z.string().optional(),
|
||||
workspace: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.post(
|
||||
"/log",
|
||||
describeRoute({
|
||||
summary: "Write log",
|
||||
description: "Write a log entry to the server logs with specified level and metadata.",
|
||||
operationId: "app.log",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Log entry written successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
service: z.string().meta({ description: "Service name for the log entry" }),
|
||||
level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
|
||||
message: z.string().meta({ description: "Log message" }),
|
||||
extra: z
|
||||
.record(z.string(), z.any())
|
||||
.optional()
|
||||
.meta({ description: "Additional metadata for the log entry" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { service, level, message, extra } = c.req.valid("json")
|
||||
const logger = Log.create({ service })
|
||||
|
||||
switch (level) {
|
||||
case "debug":
|
||||
logger.debug(message, extra)
|
||||
break
|
||||
case "info":
|
||||
logger.info(message, extra)
|
||||
break
|
||||
case "error":
|
||||
logger.error(message, extra)
|
||||
break
|
||||
case "warn":
|
||||
logger.warn(message, extra)
|
||||
break
|
||||
}
|
||||
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { listAdapters } from "@/control-plane/adapters"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { WorkspaceAdapterEntry } from "@/control-plane/types"
|
||||
import { zodObject } from "@/util/effect-zod"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
|
||||
export const WorkspaceRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/adapter",
|
||||
describeRoute({
|
||||
summary: "List workspace adapters",
|
||||
description: "List all available workspace adapters for the current project.",
|
||||
operationId: "experimental.workspace.adapter.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Workspace adapters",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(zodObject(WorkspaceAdapterEntry))),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await listAdapters(Instance.project.id))
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "Create workspace",
|
||||
description: "Create a workspace for the current project.",
|
||||
operationId: "experimental.workspace.create",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Workspace created",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Workspace.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
Workspace.CreateInput.zodObject.omit({
|
||||
projectID: true,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json") as Omit<Workspace.CreateInput, "projectID">
|
||||
const workspace = await AppRuntime.runPromise(
|
||||
Workspace.Service.use((svc) =>
|
||||
svc.create({
|
||||
projectID: Instance.project.id,
|
||||
...body,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(workspace)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List workspaces",
|
||||
description: "List all workspaces.",
|
||||
operationId: "experimental.workspace.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Workspaces",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(Workspace.Info.zod)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.list(Instance.project))))
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/sync-list",
|
||||
describeRoute({
|
||||
summary: "Sync workspace list",
|
||||
description: "Register missing workspaces returned by workspace adapters.",
|
||||
operationId: "experimental.workspace.syncList",
|
||||
responses: {
|
||||
204: {
|
||||
description: "Workspace list synced",
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.syncList(Instance.project)))
|
||||
return c.body(null, 204)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/status",
|
||||
describeRoute({
|
||||
summary: "Workspace status",
|
||||
description: "Get connection status for workspaces in the current project.",
|
||||
operationId: "experimental.workspace.status",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Workspace status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(zodObject(Workspace.ConnectionStatus))),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const result = await AppRuntime.runPromise(
|
||||
Workspace.Service.use((svc) => Effect.all([svc.list(Instance.project), svc.status()])),
|
||||
)
|
||||
const ids = new Set(result[0].map((item) => item.id))
|
||||
return c.json(result[1].filter((item) => ids.has(item.workspaceID)))
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:id",
|
||||
describeRoute({
|
||||
summary: "Remove workspace",
|
||||
description: "Remove an existing workspace.",
|
||||
operationId: "experimental.workspace.remove",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Workspace removed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Workspace.Info.zod.optional()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: zodObject(Workspace.Info).shape.id,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { id } = c.req.valid("param")
|
||||
return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.remove(id))))
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/warp",
|
||||
describeRoute({
|
||||
summary: "Warp session into workspace",
|
||||
description: "Move a session's sync history into the target workspace, or detach it to the local project.",
|
||||
operationId: "experimental.workspace.warp",
|
||||
responses: {
|
||||
204: {
|
||||
description: "Session warped",
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
id: zodObject(Workspace.Info).shape.id.nullable(),
|
||||
sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID,
|
||||
copyChanges: z.boolean().optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
return AppRuntime.runPromise(
|
||||
Workspace.Service.use((workspace) =>
|
||||
workspace.sessionWarp({
|
||||
workspaceID: body.id,
|
||||
sessionID: body.sessionID,
|
||||
copyChanges: body.copyChanges,
|
||||
}),
|
||||
).pipe(
|
||||
Effect.match({
|
||||
onFailure: (error) => {
|
||||
if (error instanceof Vcs.PatchApplyError) {
|
||||
return c.json(
|
||||
{
|
||||
name: "VcsApplyError",
|
||||
data: {
|
||||
message: error.message,
|
||||
reason: error.reason,
|
||||
},
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
return c.json(
|
||||
{
|
||||
name: "WorkspaceWarpError",
|
||||
data: {
|
||||
message: error.message,
|
||||
},
|
||||
},
|
||||
400,
|
||||
)
|
||||
},
|
||||
onSuccess: () => c.body(null, 204),
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -1,286 +0,0 @@
|
||||
import { Hono, type Context } from "hono"
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import { streamSSE } from "hono/streaming"
|
||||
import { Effect } from "effect"
|
||||
import z from "zod"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Bus } from "@/bus"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { AsyncQueue } from "@/util/queue"
|
||||
import { Installation } from "@/installation"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { Config } from "@/config/config"
|
||||
import { errors } from "../error"
|
||||
import { disposeAllInstancesAndEmitGlobalDisposed } from "../global-lifecycle"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
async function streamEvents(c: Context, subscribe: (q: AsyncQueue<string | null>) => () => void) {
|
||||
return streamSSE(c, async (stream) => {
|
||||
const q = new AsyncQueue<string | null>()
|
||||
let done = false
|
||||
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
payload: {
|
||||
id: Bus.createID(),
|
||||
type: "server.connected",
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// Send heartbeat every 10s to prevent stalled proxy streams.
|
||||
const heartbeat = setInterval(() => {
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
payload: {
|
||||
id: Bus.createID(),
|
||||
type: "server.heartbeat",
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}, 10_000)
|
||||
|
||||
const stop = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
clearInterval(heartbeat)
|
||||
unsub()
|
||||
q.push(null)
|
||||
log.info("global event disconnected")
|
||||
}
|
||||
|
||||
const unsub = subscribe(q)
|
||||
|
||||
stream.onAbort(stop)
|
||||
|
||||
try {
|
||||
for await (const data of q) {
|
||||
if (data === null) return
|
||||
await stream.writeSSE({ data })
|
||||
}
|
||||
} finally {
|
||||
stop()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const GlobalRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/health",
|
||||
describeRoute({
|
||||
summary: "Get health",
|
||||
description: "Get health information about the OpenCode server.",
|
||||
operationId: "global.health",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Health information",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ healthy: z.literal(true), version: z.string() })),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json({ healthy: true, version: InstallationVersion })
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/event",
|
||||
describeRoute({
|
||||
summary: "Get global events",
|
||||
description: "Subscribe to global events from the OpenCode system using server-sent events.",
|
||||
operationId: "global.event",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Event stream",
|
||||
content: {
|
||||
"text/event-stream": {
|
||||
schema: resolver(
|
||||
z
|
||||
.object({
|
||||
directory: z.string(),
|
||||
project: z.string().optional(),
|
||||
workspace: z.string().optional(),
|
||||
payload: z.union([...BusEvent.payloads(), ...SyncEvent.payloads()]),
|
||||
})
|
||||
.meta({
|
||||
ref: "GlobalEvent",
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
log.info("global event connected")
|
||||
c.header("Cache-Control", "no-cache, no-transform")
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
|
||||
return streamEvents(c, (q) => {
|
||||
async function handler(event: any) {
|
||||
q.push(JSON.stringify(event))
|
||||
}
|
||||
GlobalBus.on("event", handler)
|
||||
return () => GlobalBus.off("event", handler)
|
||||
})
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/config",
|
||||
describeRoute({
|
||||
summary: "Get global configuration",
|
||||
description: "Retrieve the current global OpenCode configuration settings and preferences.",
|
||||
operationId: "global.config.get",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Get global config info",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Config.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())))
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/config",
|
||||
describeRoute({
|
||||
summary: "Update global configuration",
|
||||
description: "Update global OpenCode configuration settings and preferences.",
|
||||
operationId: "global.config.update",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully updated global config",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Config.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Config.Info.zod),
|
||||
async (c) => {
|
||||
const config = c.req.valid("json")
|
||||
const result = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config)))
|
||||
if (result.changed) {
|
||||
void AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })).catch(
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
return c.json(result.info)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/dispose",
|
||||
describeRoute({
|
||||
summary: "Dispose instance",
|
||||
description: "Clean up and dispose all OpenCode instances, releasing all resources.",
|
||||
operationId: "global.dispose",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Global disposed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed())
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/upgrade",
|
||||
describeRoute({
|
||||
summary: "Upgrade opencode",
|
||||
description: "Upgrade opencode to the specified version or latest if not specified.",
|
||||
operationId: "global.upgrade",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Upgrade result",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.union([
|
||||
z.object({
|
||||
success: z.literal(true),
|
||||
version: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
success: z.literal(false),
|
||||
error: z.string(),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
target: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const result = await AppRuntime.runPromise(
|
||||
Installation.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const method = yield* svc.method()
|
||||
if (method === "unknown") {
|
||||
return { success: false as const, status: 400 as const, error: "Unknown installation method" }
|
||||
}
|
||||
|
||||
const target = c.req.valid("json").target || (yield* svc.latest(method))
|
||||
const result = yield* Effect.catch(
|
||||
svc.upgrade(method, target).pipe(Effect.as({ success: true as const, version: target })),
|
||||
(err) =>
|
||||
Effect.succeed({
|
||||
success: false as const,
|
||||
status: 500 as const,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
)
|
||||
if (!result.success) return result
|
||||
return { ...result, status: 200 as const }
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (!result.success) {
|
||||
return c.json({ success: false, error: result.error }, result.status)
|
||||
}
|
||||
const target = result.version
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: Installation.Event.Updated.type,
|
||||
properties: { version: target },
|
||||
},
|
||||
})
|
||||
return c.json({ success: true, version: target })
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -1,8 +0,0 @@
|
||||
# Instance Route Parity
|
||||
|
||||
This directory contains the legacy Hono instance routes and the experimental Effect HttpApi implementation under `httpapi/`. Keep them behaviorally aligned.
|
||||
|
||||
- When adding, removing, or changing a legacy Hono route, update the matching Effect HttpApi group and handler in `httpapi/` in the same change unless the route is intentionally unsupported.
|
||||
- When changing an Effect HttpApi route, verify the legacy Hono route has the same public behavior, request shape, response shape, status codes, and instance/workspace routing semantics.
|
||||
- Keep OpenAPI/SDK-visible schemas aligned. If a difference is only an OpenAPI generation artifact, prefer fixing the source schema first; use `httpapi/public.ts` normalization only for compatibility shims that cannot be represented cleanly in the source schema.
|
||||
- Add or update parity coverage in `test/server/httpapi-bridge.test.ts` or the focused HttpApi tests when behavior or schema parity could regress.
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
import { Effect } from "effect"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
const log = Log.create({ service: "server.config" })
|
||||
|
||||
export const ConfigRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "Get configuration",
|
||||
description: "Retrieve the current OpenCode configuration settings and preferences.",
|
||||
operationId: "config.get",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Get config info",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Config.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ConfigRoutes.get", c, function* () {
|
||||
const cfg = yield* Config.Service
|
||||
return yield* cfg.get()
|
||||
}),
|
||||
)
|
||||
.patch(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "Update configuration",
|
||||
description: "Update OpenCode configuration settings and preferences.",
|
||||
operationId: "config.update",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully updated config",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Config.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Config.Info.zod),
|
||||
async (c) => {
|
||||
const result = await runRequest(
|
||||
"ConfigRoutes.update",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const config = c.req.valid("json")
|
||||
const cfg = yield* Config.Service
|
||||
yield* cfg.update(config)
|
||||
return { config, ctx: yield* InstanceState.context }
|
||||
}),
|
||||
)
|
||||
const response = c.json(result.config)
|
||||
void runRequest(
|
||||
"ConfigRoutes.update.dispose",
|
||||
c,
|
||||
InstanceStore.Service.use((store) => store.dispose(result.ctx)).pipe(
|
||||
Effect.uninterruptible,
|
||||
Effect.catchCause((cause) => Effect.sync(() => log.warn("instance disposal failed", { cause }))),
|
||||
),
|
||||
)
|
||||
return response
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/providers",
|
||||
describeRoute({
|
||||
summary: "List config providers",
|
||||
description: "Get a list of all configured AI providers and their default models.",
|
||||
operationId: "config.providers",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of providers",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Provider.ConfigProvidersResult.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ConfigRoutes.providers", c, function* () {
|
||||
const svc = yield* Provider.Service
|
||||
const providers = yield* svc.list()
|
||||
return {
|
||||
providers: Object.values(providers),
|
||||
default: Provider.defaultModelIDs(providers),
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -1,90 +0,0 @@
|
||||
import z from "zod"
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, resolver } from "hono-openapi"
|
||||
import { streamSSE } from "hono/streaming"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { AsyncQueue } from "@/util/queue"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
export const EventRoutes = () =>
|
||||
new Hono().get(
|
||||
"/event",
|
||||
describeRoute({
|
||||
summary: "Subscribe to events",
|
||||
description: "Get events",
|
||||
operationId: "event.subscribe",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Event stream",
|
||||
content: {
|
||||
"text/event-stream": {
|
||||
schema: resolver(
|
||||
z.union(BusEvent.payloads()).meta({
|
||||
ref: "Event",
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
log.info("event connected")
|
||||
c.header("Cache-Control", "no-cache, no-transform")
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
return streamSSE(c, async (stream) => {
|
||||
const q = new AsyncQueue<string | null>()
|
||||
let done = false
|
||||
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
id: Bus.createID(),
|
||||
type: "server.connected",
|
||||
properties: {},
|
||||
}),
|
||||
)
|
||||
|
||||
// Send heartbeat every 10s to prevent stalled proxy streams.
|
||||
const heartbeat = setInterval(() => {
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
id: Bus.createID(),
|
||||
type: "server.heartbeat",
|
||||
properties: {},
|
||||
}),
|
||||
)
|
||||
}, 10_000)
|
||||
|
||||
const stop = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
clearInterval(heartbeat)
|
||||
unsub()
|
||||
q.push(null)
|
||||
log.info("event disconnected")
|
||||
}
|
||||
|
||||
const unsub = Bus.subscribeAll((event) => {
|
||||
q.push(JSON.stringify(event))
|
||||
if (event.type === Bus.InstanceDisposed.type) {
|
||||
stop()
|
||||
}
|
||||
})
|
||||
|
||||
stream.onAbort(stop)
|
||||
|
||||
try {
|
||||
for await (const data of q) {
|
||||
if (data === null) return
|
||||
await stream.writeSSE({ data })
|
||||
}
|
||||
} finally {
|
||||
stop()
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -1,419 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import * as EffectZod from "@/util/effect-zod"
|
||||
import { ProviderID, ModelID } from "@/provider/schema"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Project } from "@/project/project"
|
||||
import { MCP } from "@/mcp"
|
||||
import { Session } from "@/session/session"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConsoleState } from "@/config/console-state"
|
||||
import { Account } from "@/account/account"
|
||||
import { AccountID, OrgID } from "@/account/schema"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
|
||||
const ConsoleOrgOption = z.object({
|
||||
accountID: z.string(),
|
||||
accountEmail: z.string(),
|
||||
accountUrl: z.string(),
|
||||
orgID: z.string(),
|
||||
orgName: z.string(),
|
||||
active: z.boolean(),
|
||||
})
|
||||
|
||||
const ConsoleOrgList = z.object({
|
||||
orgs: z.array(ConsoleOrgOption),
|
||||
})
|
||||
|
||||
const ConsoleSwitchBody = z.object({
|
||||
accountID: z.string(),
|
||||
orgID: z.string(),
|
||||
})
|
||||
|
||||
const QueryBoolean = z.union([
|
||||
z.preprocess((value) => (value === "true" ? true : value === "false" ? false : value), z.boolean()),
|
||||
z.enum(["true", "false"]),
|
||||
])
|
||||
|
||||
function queryBoolean(value: z.infer<typeof QueryBoolean> | undefined) {
|
||||
if (value === undefined) return
|
||||
return value === true || value === "true"
|
||||
}
|
||||
|
||||
export const ExperimentalRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/console",
|
||||
describeRoute({
|
||||
summary: "Get active Console provider metadata",
|
||||
description: "Get the active Console org name and the set of provider IDs managed by that Console org.",
|
||||
operationId: "experimental.console.get",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Active Console provider metadata",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ConsoleState.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.console.get", c, function* () {
|
||||
const config = yield* Config.Service
|
||||
const account = yield* Account.Service
|
||||
const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
return {
|
||||
...state,
|
||||
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/console/orgs",
|
||||
describeRoute({
|
||||
summary: "List switchable Console orgs",
|
||||
description: "Get the available Console orgs across logged-in accounts, including the current active org.",
|
||||
operationId: "experimental.console.listOrgs",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Switchable Console orgs",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ConsoleOrgList),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.console.listOrgs", c, function* () {
|
||||
const account = yield* Account.Service
|
||||
const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
const info = Option.getOrUndefined(active)
|
||||
const orgs = groups.flatMap((group) =>
|
||||
group.orgs.map((org) => ({
|
||||
accountID: group.account.id,
|
||||
accountEmail: group.account.email,
|
||||
accountUrl: group.account.url,
|
||||
orgID: org.id,
|
||||
orgName: org.name,
|
||||
active: !!info && info.id === group.account.id && info.active_org_id === org.id,
|
||||
})),
|
||||
)
|
||||
return { orgs }
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/console/switch",
|
||||
describeRoute({
|
||||
summary: "Switch active Console org",
|
||||
description: "Persist a new active Console account/org selection for the current local OpenCode state.",
|
||||
operationId: "experimental.console.switchOrg",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Switch success",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("json", ConsoleSwitchBody),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.console.switchOrg", c, function* () {
|
||||
const body = c.req.valid("json")
|
||||
const account = yield* Account.Service
|
||||
yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID)))
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/tool/ids",
|
||||
describeRoute({
|
||||
summary: "List tool IDs",
|
||||
description:
|
||||
"Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.",
|
||||
operationId: "tool.ids",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Tool IDs",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(z.string()).meta({ ref: "ToolIDs" })),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.tool.ids", c, function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
return yield* registry.ids()
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/tool",
|
||||
describeRoute({
|
||||
summary: "List tools",
|
||||
description:
|
||||
"Get a list of available tools with their JSON schema parameters for a specific provider and model combination.",
|
||||
operationId: "tool.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Tools",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string(),
|
||||
description: z.string(),
|
||||
parameters: z.any(),
|
||||
})
|
||||
.meta({ ref: "ToolListItem" }),
|
||||
)
|
||||
.meta({ ref: "ToolList" }),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { provider, model } = c.req.valid("query")
|
||||
const tools = await runRequest(
|
||||
"ExperimentalRoutes.tool.list",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
return yield* registry.tools({
|
||||
providerID: ProviderID.make(provider),
|
||||
modelID: ModelID.make(model),
|
||||
agent: yield* agents.get(yield* agents.defaultAgent()),
|
||||
})
|
||||
}),
|
||||
)
|
||||
return c.json(
|
||||
tools.map((t) => ({
|
||||
id: t.id,
|
||||
description: t.description,
|
||||
parameters: EffectZod.toJsonSchema(t.parameters),
|
||||
})),
|
||||
)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/worktree",
|
||||
describeRoute({
|
||||
summary: "Create worktree",
|
||||
description: "Create a new git worktree for the current project and run any configured startup scripts.",
|
||||
operationId: "worktree.create",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Worktree created",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Worktree.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.CreateInput.zod.optional()),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.worktree.create", c, function* () {
|
||||
const body = c.req.valid("json")
|
||||
const svc = yield* Worktree.Service
|
||||
return yield* svc.create(body)
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/worktree",
|
||||
describeRoute({
|
||||
summary: "List worktrees",
|
||||
description: "List all sandbox worktrees for the current project.",
|
||||
operationId: "worktree.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of worktree directories",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(z.string())),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.worktree.list", c, function* () {
|
||||
const svc = yield* Project.Service
|
||||
return yield* svc.sandboxes(Instance.project.id)
|
||||
}),
|
||||
)
|
||||
.delete(
|
||||
"/worktree",
|
||||
describeRoute({
|
||||
summary: "Remove worktree",
|
||||
description: "Remove a git worktree and delete its branch.",
|
||||
operationId: "worktree.remove",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Worktree removed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.RemoveInput.zod),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.worktree.remove", c, function* () {
|
||||
const body = c.req.valid("json")
|
||||
const worktree = yield* Worktree.Service
|
||||
const project = yield* Project.Service
|
||||
yield* worktree.remove(body)
|
||||
yield* project.removeSandbox(Instance.project.id, body.directory)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/worktree/reset",
|
||||
describeRoute({
|
||||
summary: "Reset worktree",
|
||||
description: "Reset a worktree branch to the primary default branch.",
|
||||
operationId: "worktree.reset",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Worktree reset",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.ResetInput.zod),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.worktree.reset", c, function* () {
|
||||
const body = c.req.valid("json")
|
||||
const svc = yield* Worktree.Service
|
||||
yield* svc.reset(body)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/session",
|
||||
describeRoute({
|
||||
summary: "List sessions",
|
||||
description:
|
||||
"Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.",
|
||||
operationId: "experimental.session.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of sessions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.GlobalInfo.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
|
||||
roots: QueryBoolean.optional().meta({ description: "Only return root sessions (no parentID)" }),
|
||||
start: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }),
|
||||
cursor: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }),
|
||||
search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }),
|
||||
limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }),
|
||||
archived: QueryBoolean.optional().meta({ description: "Include archived sessions (default false)" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const query = c.req.valid("query")
|
||||
const limit = query.limit ?? 100
|
||||
const sessions: Session.GlobalInfo[] = []
|
||||
for await (const session of Session.listGlobal({
|
||||
directory: query.directory,
|
||||
roots: queryBoolean(query.roots),
|
||||
start: query.start,
|
||||
cursor: query.cursor,
|
||||
search: query.search,
|
||||
limit: limit + 1,
|
||||
archived: queryBoolean(query.archived),
|
||||
})) {
|
||||
sessions.push(session)
|
||||
}
|
||||
const hasMore = sessions.length > limit
|
||||
const list = hasMore ? sessions.slice(0, limit) : sessions
|
||||
if (hasMore && list.length > 0) {
|
||||
c.header("x-next-cursor", String(list[list.length - 1].time.updated))
|
||||
}
|
||||
return c.json(list)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/resource",
|
||||
describeRoute({
|
||||
summary: "Get MCP resources",
|
||||
description: "Get all available MCP resources from connected servers. Optionally filter by name.",
|
||||
operationId: "experimental.resource.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "MCP resources",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.record(z.string(), MCP.Resource.zod)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.resource.list", c, function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
return yield* mcp.resources()
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -1,190 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { File } from "@/file"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { jsonRequest } from "./trace"
|
||||
|
||||
export const FileRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/find",
|
||||
describeRoute({
|
||||
summary: "Find text",
|
||||
description: "Search for text patterns across files in the project using ripgrep.",
|
||||
operationId: "find.text",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Matches",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Ripgrep.SearchMatch.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
pattern: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) =>
|
||||
jsonRequest("FileRoutes.findText", c, function* () {
|
||||
const pattern = c.req.valid("query").pattern
|
||||
const svc = yield* Ripgrep.Service
|
||||
const result = yield* svc.search({ cwd: Instance.directory, pattern, limit: 10 })
|
||||
return result.items
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/find/file",
|
||||
describeRoute({
|
||||
summary: "Find files",
|
||||
description: "Search for files or directories by name or pattern in the project directory.",
|
||||
operationId: "find.files",
|
||||
responses: {
|
||||
200: {
|
||||
description: "File paths",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.string().array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
query: z.string(),
|
||||
dirs: z.enum(["true", "false"]).optional(),
|
||||
type: z.enum(["file", "directory"]).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
}),
|
||||
),
|
||||
async (c) =>
|
||||
jsonRequest("FileRoutes.findFile", c, function* () {
|
||||
const query = c.req.valid("query")
|
||||
const svc = yield* File.Service
|
||||
return yield* svc.search({
|
||||
query: query.query,
|
||||
limit: query.limit ?? 10,
|
||||
dirs: query.dirs !== "false",
|
||||
type: query.type,
|
||||
})
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/find/symbol",
|
||||
describeRoute({
|
||||
summary: "Find symbols",
|
||||
description: "Search for workspace symbols like functions, classes, and variables using LSP.",
|
||||
operationId: "find.symbols",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Symbols",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(LSP.Symbol.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
query: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
return c.json([])
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/file",
|
||||
describeRoute({
|
||||
summary: "List files",
|
||||
description: "List files and directories in a specified path.",
|
||||
operationId: "file.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Files and directories",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(File.Node.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) =>
|
||||
jsonRequest("FileRoutes.list", c, function* () {
|
||||
const svc = yield* File.Service
|
||||
return yield* svc.list(c.req.valid("query").path)
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/file/content",
|
||||
describeRoute({
|
||||
summary: "Read file",
|
||||
description: "Read the content of a specified file.",
|
||||
operationId: "file.read",
|
||||
responses: {
|
||||
200: {
|
||||
description: "File content",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(File.Content.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) =>
|
||||
jsonRequest("FileRoutes.read", c, function* () {
|
||||
const svc = yield* File.Service
|
||||
return yield* svc.read(c.req.valid("query").path)
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/file/status",
|
||||
describeRoute({
|
||||
summary: "Get file status",
|
||||
description: "Get the git status of all files in the project.",
|
||||
operationId: "file.status",
|
||||
responses: {
|
||||
200: {
|
||||
description: "File status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(File.Info.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("FileRoutes.status", c, function* () {
|
||||
const svc = yield* File.Service
|
||||
return yield* svc.status()
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -22,8 +22,7 @@ import { WorkspaceApi } from "./groups/workspace"
|
||||
import { V2Api } from "./groups/v2"
|
||||
import { Authorization } from "./middleware/authorization"
|
||||
|
||||
// SSE event schemas built from the same BusEvent/SyncEvent registries that
|
||||
// the Hono spec uses, so both specs emit identical Event/SyncEvent components.
|
||||
// SSE event schemas built from the BusEvent/SyncEvent registries.
|
||||
const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" })
|
||||
const SyncEventSchemas = SyncEvent.effectPayloads()
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
.add(
|
||||
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
|
||||
success: described(ConsoleStateResponse, "Active Console provider metadata"),
|
||||
error: HttpApiError.InternalServerError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.console.get",
|
||||
@@ -91,6 +92,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
),
|
||||
HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
|
||||
success: described(ConsoleOrgList, "Switchable Console orgs"),
|
||||
error: HttpApiError.InternalServerError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.console.listOrgs",
|
||||
|
||||
@@ -261,7 +261,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
HttpApiEndpoint.post("share", SessionPaths.share, {
|
||||
params: { sessionID: SessionID },
|
||||
success: described(Session.Info, "Successfully shared session"),
|
||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||
error: [HttpApiError.InternalServerError, ApiNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.share",
|
||||
@@ -272,7 +272,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
HttpApiEndpoint.delete("unshare", SessionPaths.share, {
|
||||
params: { sessionID: SessionID },
|
||||
success: described(Session.Info, "Successfully unshared session"),
|
||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||
error: [HttpApiError.InternalServerError, ApiNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.unshare",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import { TuiRequest as TuiRequestPayload } from "@/server/shared/tui-control"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
@@ -9,10 +10,6 @@ import { described } from "./metadata"
|
||||
|
||||
const root = "/tui"
|
||||
export const CommandPayload = Schema.Struct({ command: Schema.String })
|
||||
export const TuiRequestPayload = Schema.Struct({
|
||||
path: Schema.String,
|
||||
body: Schema.Unknown,
|
||||
})
|
||||
const EventTuiPromptAppend = Schema.Struct({
|
||||
type: Schema.Literal(TuiEvent.PromptAppend.type),
|
||||
properties: TuiEvent.PromptAppend.properties,
|
||||
|
||||
@@ -24,7 +24,7 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h
|
||||
const providers = Effect.fn("ConfigHttpApi.providers")(function* () {
|
||||
const providers = yield* providerSvc.list()
|
||||
return {
|
||||
providers: Object.values(providers),
|
||||
providers: Object.values(providers).map(Provider.toPublicInfo),
|
||||
default: Provider.defaultModelIDs(providers),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -26,7 +26,10 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
|
||||
|
||||
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
|
||||
const [state, groups] = yield* Effect.all(
|
||||
[config.getConsoleState(), account.orgsByAccount().pipe(Effect.orDie)],
|
||||
[
|
||||
config.getConsoleState(),
|
||||
account.orgsByAccount().pipe(Effect.catch(() => Effect.fail(new HttpApiError.InternalServerError({})))),
|
||||
],
|
||||
{
|
||||
concurrency: "unbounded",
|
||||
},
|
||||
@@ -40,7 +43,10 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
|
||||
|
||||
const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () {
|
||||
const [groups, active] = yield* Effect.all(
|
||||
[account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)],
|
||||
[
|
||||
account.orgsByAccount().pipe(Effect.catch(() => Effect.fail(new HttpApiError.InternalServerError({})))),
|
||||
account.active().pipe(Effect.catch(() => Effect.fail(new HttpApiError.InternalServerError({})))),
|
||||
],
|
||||
{
|
||||
concurrency: "unbounded",
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
|
||||
connected,
|
||||
)
|
||||
return {
|
||||
all: Object.values(providers),
|
||||
all: Object.values(providers).map(Provider.toPublicInfo),
|
||||
default: Provider.defaultModelIDs(providers),
|
||||
connected: Object.keys(connected),
|
||||
}
|
||||
@@ -61,9 +61,11 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
|
||||
const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe(
|
||||
Effect.mapError(() => new HttpApiError.BadRequest({})),
|
||||
)
|
||||
// Match legacy Hono behavior: when authorize() resolves without a
|
||||
// result (e.g. no further redirect), serialize as JSON `null` instead
|
||||
// of an empty body so clients can `.json()` parse the response.
|
||||
const result = yield* authorize({ params: ctx.params, payload })
|
||||
if (result === undefined) return HttpServerResponse.empty({ status: 200 })
|
||||
return HttpServerResponse.jsonUnsafe(result)
|
||||
return HttpServerResponse.jsonUnsafe(result ?? null)
|
||||
})
|
||||
|
||||
const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: {
|
||||
|
||||
@@ -153,6 +153,12 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||
return HttpServerResponse.empty()
|
||||
}
|
||||
|
||||
// No `pending[]`-style early-frame buffer (the legacy Hono 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.
|
||||
yield* socket
|
||||
.runRaw((message) => handlePtyInput(handler, message))
|
||||
.pipe(
|
||||
|
||||
@@ -213,13 +213,20 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
return true
|
||||
})
|
||||
|
||||
// share/unshare errors aren't all client-induced — storage and network
|
||||
// failures from SessionShare are real possibilities. Map to a typed 500
|
||||
// (matches the legacy Hono path which routed any failure through
|
||||
// ErrorMiddleware → NamedError.Unknown 500) instead of blanket-mapping
|
||||
// every failure to a 400 BadRequest.
|
||||
const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) {
|
||||
yield* shareSvc.share(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
||||
yield* shareSvc.share(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.InternalServerError({})))
|
||||
return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID))
|
||||
})
|
||||
|
||||
const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) {
|
||||
yield* shareSvc.unshare(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
||||
yield* shareSvc
|
||||
.unshare(ctx.params.sessionID)
|
||||
.pipe(Effect.mapError(() => new HttpApiError.InternalServerError({})))
|
||||
return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID))
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Effect } from "effect"
|
||||
import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
type MarkedInstance = {
|
||||
ctx: InstanceContext
|
||||
store: InstanceStore.Interface
|
||||
@@ -47,6 +50,8 @@ export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) =>
|
||||
const marked = disposeAfterResponse.get(request.source)
|
||||
if (!marked) return response
|
||||
disposeAfterResponse.delete(request.source)
|
||||
yield* Effect.uninterruptible(marked.bridge.run(marked.store.dispose(marked.ctx)))
|
||||
yield* Effect.uninterruptible(marked.bridge.run(marked.store.dispose(marked.ctx))).pipe(
|
||||
Effect.catchCause((cause) => Effect.sync(() => log.warn("instance disposal failed", { cause }))),
|
||||
)
|
||||
return response
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { Effect, Encoding, Layer, Redacted } from "effect"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket"
|
||||
import { isPublicUIPath } from "@/server/shared/public-ui"
|
||||
@@ -33,7 +33,12 @@ function validateCredential<A, E, R>(
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
if (!ServerAuth.required(config)) return yield* effect
|
||||
if (!ServerAuth.authorized(credential, config)) return yield* new HttpApiError.Unauthorized({})
|
||||
if (!ServerAuth.authorized(credential, config)) {
|
||||
yield* HttpEffect.appendPreResponseHandler((_request, response) =>
|
||||
Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)),
|
||||
)
|
||||
return yield* new HttpApiError.Unauthorized({})
|
||||
}
|
||||
return yield* effect
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { deflateSync, gzipSync } from "node:zlib"
|
||||
import { Effect } from "effect"
|
||||
import { HttpBody, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
|
||||
// Mirror of Hono's compressible content-type set so wire behavior matches.
|
||||
const COMPRESSIBLE_CONTENT_TYPE_REGEX =
|
||||
/^\s*(?:text\/(?!event-stream(?:[;\s]|$))[^;\s]+|application\/(?:javascript|json|xml|xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i
|
||||
|
||||
const NO_TRANSFORM_REGEX = /(?:^|,)\s*?no-transform\s*?(?:,|$)/i
|
||||
|
||||
const STREAMING_PATHS = new Set(["/event", "/global/event"])
|
||||
const STREAMING_POST_REGEX = /^\/session\/[^/]+\/(?:message|prompt_async)$/
|
||||
|
||||
const THRESHOLD_BYTES = 1024
|
||||
|
||||
type Encoding = "gzip" | "deflate"
|
||||
|
||||
function pickEncoding(acceptEncoding: string | undefined): Encoding | undefined {
|
||||
if (!acceptEncoding) return undefined
|
||||
const lower = acceptEncoding.toLowerCase()
|
||||
if (lower.includes("gzip")) return "gzip"
|
||||
if (lower.includes("deflate")) return "deflate"
|
||||
return undefined
|
||||
}
|
||||
|
||||
function pathOf(url: string): string {
|
||||
const queryIndex = url.indexOf("?")
|
||||
return queryIndex === -1 ? url : url.slice(0, queryIndex)
|
||||
}
|
||||
|
||||
export const compressionLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* effect
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
|
||||
if (request.method === "HEAD") return response
|
||||
if (response.headers["content-encoding"]) return response
|
||||
if (response.headers["transfer-encoding"]) return response
|
||||
|
||||
const body = response.body
|
||||
if (body._tag !== "Uint8Array") return response
|
||||
if (body.body.byteLength < THRESHOLD_BYTES) return response
|
||||
|
||||
const cacheControl = response.headers["cache-control"]
|
||||
if (cacheControl && NO_TRANSFORM_REGEX.test(cacheControl)) return response
|
||||
|
||||
const path = pathOf(request.url)
|
||||
if (STREAMING_PATHS.has(path)) return response
|
||||
if (request.method === "POST" && STREAMING_POST_REGEX.test(path)) return response
|
||||
|
||||
const contentType = body.contentType
|
||||
if (!COMPRESSIBLE_CONTENT_TYPE_REGEX.test(contentType)) return response
|
||||
|
||||
const encoding = pickEncoding(request.headers["accept-encoding"])
|
||||
if (!encoding) return response
|
||||
|
||||
const compressed = encoding === "gzip" ? gzipSync(body.body) : deflateSync(body.body)
|
||||
return HttpServerResponse.setHeader(
|
||||
HttpServerResponse.setBody(response, HttpBody.uint8Array(compressed, contentType)),
|
||||
"content-encoding",
|
||||
encoding,
|
||||
)
|
||||
}),
|
||||
).layer
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Effect } from "effect"
|
||||
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
||||
|
||||
// effect-smol's HttpMiddleware.cors builds OPTIONS preflight responses by
|
||||
// spreading allowOrigin() and allowHeaders() into the same record. Both set
|
||||
// the `vary` key, so allowHeaders' `Vary: Access-Control-Request-Headers`
|
||||
// overwrites allowOrigin's `Vary: Origin`. With dynamic origin echoing, the
|
||||
// missing `Vary: Origin` lets shared caches reuse a preflight cached for one
|
||||
// origin against a different origin.
|
||||
//
|
||||
// TODO: upstream a fix that merges Vary values in headersFromRequestOptions
|
||||
// (packages/effect/src/unstable/http/HttpMiddleware.ts ~line 332).
|
||||
export const corsVaryFix = HttpRouter.middleware(
|
||||
(effect) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* effect
|
||||
const allowOrigin = response.headers["access-control-allow-origin"]
|
||||
if (!allowOrigin || allowOrigin === "*") return response
|
||||
|
||||
const vary = response.headers["vary"]
|
||||
if (!vary) return HttpServerResponse.setHeader(response, "vary", "Origin")
|
||||
|
||||
const tokens = vary.split(",").map((s) => s.trim().toLowerCase())
|
||||
if (tokens.includes("origin") || tokens.includes("*")) return response
|
||||
|
||||
return HttpServerResponse.setHeader(response, "vary", `${vary}, Origin`)
|
||||
}),
|
||||
{ global: true },
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Effect } from "effect"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import * as Fence from "@/server/shared/fence"
|
||||
|
||||
const ignoredMethods = new Set(["GET", "HEAD", "OPTIONS"])
|
||||
|
||||
export const fenceLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
if (!Flag.OPENCODE_WORKSPACE_ID || ignoredMethods.has(request.method)) return yield* effect
|
||||
|
||||
const previous = Fence.load()
|
||||
const response = yield* effect
|
||||
const current = Fence.diff(previous, Fence.load())
|
||||
if (Object.keys(current).length === 0) return response
|
||||
|
||||
return HttpServerResponse.setHeader(response, Fence.HEADER, JSON.stringify(current))
|
||||
}),
|
||||
).layer
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { FetchHttpClient, HttpClient, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
HttpClient,
|
||||
HttpMiddleware,
|
||||
HttpRouter,
|
||||
HttpServer,
|
||||
HttpServerResponse,
|
||||
} from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Account } from "@/account/account"
|
||||
@@ -49,6 +56,7 @@ import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors
|
||||
import { serveUIEffect } from "@/server/shared/ui"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||
import { PublicApi } from "./public"
|
||||
import { authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
|
||||
import { EventApi, eventHandlers } from "./event"
|
||||
import { configHandlers } from "./handlers/config"
|
||||
@@ -72,16 +80,17 @@ import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/ins
|
||||
import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing"
|
||||
import { disposeMiddleware } from "./lifecycle"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
import * as ServerBackend from "@/server/backend"
|
||||
import { compressionLayer } from "./middleware/compression"
|
||||
import { corsVaryFix } from "./middleware/cors-vary"
|
||||
import { errorLayer } from "./middleware/error"
|
||||
import { fenceLayer } from "./middleware/fence"
|
||||
|
||||
export const context = Context.makeUnsafe<unknown>(new Map())
|
||||
|
||||
const runtime = HttpRouter.middleware()(
|
||||
Effect.succeed((effect) =>
|
||||
Effect.gen(function* () {
|
||||
const selected = ServerBackend.select()
|
||||
yield* Effect.annotateCurrentSpan(ServerBackend.attributes(ServerBackend.force(selected, "effect-httpapi")))
|
||||
yield* Effect.annotateCurrentSpan({ "opencode.server.backend": "effect-httpapi" })
|
||||
return yield* effect
|
||||
}),
|
||||
),
|
||||
@@ -144,6 +153,17 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe
|
||||
]),
|
||||
)
|
||||
|
||||
// `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so
|
||||
// processes that never serve it (CLI, scripts) don't pay at module load.
|
||||
// `HttpServerResponse.jsonUnsafe` runs JSON.stringify eagerly, so caching
|
||||
// the response also caches the serialized body — every /doc request reuses
|
||||
// the same Uint8Array instead of re-stringifying the spec.
|
||||
const docResponse = lazy(() => HttpServerResponse.jsonUnsafe(OpenApi.fromApi(PublicApi)))
|
||||
|
||||
const docRoute = HttpRouter.use((router) => router.add("GET", "/doc", () => Effect.succeed(docResponse()))).pipe(
|
||||
Layer.provide(authOnlyRouterLayer),
|
||||
)
|
||||
|
||||
const uiRoute = HttpRouter.use((router) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
@@ -153,9 +173,12 @@ const uiRoute = HttpRouter.use((router) =>
|
||||
).pipe(Layer.provide(authOnlyRouterLayer))
|
||||
|
||||
export function createRoutes(corsOptions?: CorsOptions) {
|
||||
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe(
|
||||
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute).pipe(
|
||||
Layer.provide([
|
||||
errorLayer,
|
||||
compressionLayer,
|
||||
corsVaryFix,
|
||||
fenceLayer,
|
||||
cors(corsOptions),
|
||||
runtime,
|
||||
Account.defaultLayer,
|
||||
|
||||
@@ -1,502 +0,0 @@
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import { Hono } from "hono"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import { Context, Effect } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import z from "zod"
|
||||
import { Format } from "@/format"
|
||||
import { TuiRoutes } from "./tui"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Skill } from "@/skill"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { Command } from "@/command"
|
||||
import { QuestionRoutes } from "./question"
|
||||
import { PermissionRoutes } from "./permission"
|
||||
import { ProjectRoutes } from "./project"
|
||||
import { SessionRoutes } from "./session"
|
||||
import { PtyRoutes } from "./pty"
|
||||
import { McpRoutes } from "./mcp"
|
||||
import { FileRoutes } from "./file"
|
||||
import { ConfigRoutes } from "./config"
|
||||
import { ExperimentalRoutes } from "./experimental"
|
||||
import { ProviderRoutes } from "./provider"
|
||||
import { EventRoutes } from "./event"
|
||||
import { SyncRoutes } from "./sync"
|
||||
import { InstanceMiddleware } from "./middleware"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
import { ExperimentalHttpApiServer } from "./httpapi/server"
|
||||
import { EventPaths } from "./httpapi/event"
|
||||
import { ExperimentalPaths } from "./httpapi/groups/experimental"
|
||||
import { FilePaths } from "./httpapi/groups/file"
|
||||
import { InstancePaths } from "./httpapi/groups/instance"
|
||||
import { McpPaths } from "./httpapi/groups/mcp"
|
||||
import { PtyPaths } from "./httpapi/groups/pty"
|
||||
import { SessionPaths } from "./httpapi/groups/session"
|
||||
import { SyncPaths } from "./httpapi/groups/sync"
|
||||
import { TuiPaths } from "./httpapi/groups/tui"
|
||||
import { WorkspacePaths } from "./httpapi/groups/workspace"
|
||||
import type { CorsOptions } from "@/server/cors"
|
||||
import { errors } from "@/server/error"
|
||||
|
||||
export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): Hono => {
|
||||
const app = new Hono()
|
||||
const handler = ExperimentalHttpApiServer.webHandler(opts).handler
|
||||
const context = Context.empty() as Context.Context<unknown>
|
||||
|
||||
app.all("/api/*", (c) => handler(c.req.raw, context))
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
|
||||
app.get(EventPaths.event, (c) => handler(c.req.raw, context))
|
||||
app.get("/question", (c) => handler(c.req.raw, context))
|
||||
app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context))
|
||||
app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context))
|
||||
app.get("/permission", (c) => handler(c.req.raw, context))
|
||||
app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context))
|
||||
app.get("/config", (c) => handler(c.req.raw, context))
|
||||
app.patch("/config", (c) => handler(c.req.raw, context))
|
||||
app.get("/config/providers", (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.console, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context))
|
||||
app.post(ExperimentalPaths.consoleSwitch, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.tool, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.toolIDs, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
|
||||
app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
|
||||
app.delete(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
|
||||
app.post(ExperimentalPaths.worktreeReset, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.session, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context))
|
||||
app.get("/provider", (c) => handler(c.req.raw, context))
|
||||
app.get("/provider/auth", (c) => handler(c.req.raw, context))
|
||||
app.post("/provider/:providerID/oauth/authorize", (c) => handler(c.req.raw, context))
|
||||
app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context))
|
||||
app.get("/project", (c) => handler(c.req.raw, context))
|
||||
app.get("/project/current", (c) => handler(c.req.raw, context))
|
||||
app.post("/project/git/init", (c) => handler(c.req.raw, context))
|
||||
app.patch("/project/:projectID", (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.findText, (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.findFile, (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.findSymbol, (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.list, (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.content, (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.status, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.path, (c) => handler(c.req.raw, context))
|
||||
app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.vcsStatus, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.vcsDiffRaw, (c) => handler(c.req.raw, context))
|
||||
app.post(InstancePaths.vcsApply, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.command, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.agent, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.skill, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.lsp, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.formatter, (c) => handler(c.req.raw, context))
|
||||
app.get(McpPaths.status, (c) => handler(c.req.raw, context))
|
||||
app.post(McpPaths.status, (c) => handler(c.req.raw, context))
|
||||
app.post(McpPaths.auth, (c) => handler(c.req.raw, context))
|
||||
app.post(McpPaths.authCallback, (c) => handler(c.req.raw, context))
|
||||
app.post(McpPaths.authAuthenticate, (c) => handler(c.req.raw, context))
|
||||
app.delete(McpPaths.auth, (c) => handler(c.req.raw, context))
|
||||
app.post(McpPaths.connect, (c) => handler(c.req.raw, context))
|
||||
app.post(McpPaths.disconnect, (c) => handler(c.req.raw, context))
|
||||
app.post(SyncPaths.start, (c) => handler(c.req.raw, context))
|
||||
app.post(SyncPaths.replay, (c) => handler(c.req.raw, context))
|
||||
app.post(SyncPaths.history, (c) => handler(c.req.raw, context))
|
||||
app.get(PtyPaths.list, (c) => handler(c.req.raw, context))
|
||||
app.post(PtyPaths.create, (c) => handler(c.req.raw, context))
|
||||
app.get(PtyPaths.get, (c) => handler(c.req.raw, context))
|
||||
app.put(PtyPaths.update, (c) => handler(c.req.raw, context))
|
||||
app.delete(PtyPaths.remove, (c) => handler(c.req.raw, context))
|
||||
app.post(PtyPaths.connectToken, (c) => handler(c.req.raw, context))
|
||||
app.get(PtyPaths.connect, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.list, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.status, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.get, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.children, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.todo, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.diff, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.messages, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.message, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.create, (c) => handler(c.req.raw, context))
|
||||
app.delete(SessionPaths.remove, (c) => handler(c.req.raw, context))
|
||||
app.patch(SessionPaths.update, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.init, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.fork, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.abort, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.share, (c) => handler(c.req.raw, context))
|
||||
app.delete(SessionPaths.share, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.summarize, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.prompt, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.promptAsync, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.command, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.shell, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.revert, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.unrevert, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.permissions, (c) => handler(c.req.raw, context))
|
||||
app.delete(SessionPaths.deleteMessage, (c) => handler(c.req.raw, context))
|
||||
app.delete(SessionPaths.deletePart, (c) => handler(c.req.raw, context))
|
||||
app.patch(SessionPaths.updatePart, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.appendPrompt, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.openHelp, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.openSessions, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.openThemes, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.openModels, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.submitPrompt, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.clearPrompt, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.executeCommand, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.showToast, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.publish, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.selectSession, (c) => handler(c.req.raw, context))
|
||||
app.get(TuiPaths.controlNext, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.controlResponse, (c) => handler(c.req.raw, context))
|
||||
app.get(WorkspacePaths.adapters, (c) => handler(c.req.raw, context))
|
||||
app.post(WorkspacePaths.list, (c) => handler(c.req.raw, context))
|
||||
app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context))
|
||||
app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context))
|
||||
app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context))
|
||||
app.post(WorkspacePaths.warp, (c) => handler(c.req.raw, context))
|
||||
}
|
||||
|
||||
return app
|
||||
.route("/project", ProjectRoutes())
|
||||
.route("/pty", PtyRoutes(upgrade, opts))
|
||||
.route("/config", ConfigRoutes())
|
||||
.route("/experimental", ExperimentalRoutes())
|
||||
.route("/session", SessionRoutes())
|
||||
.route("/permission", PermissionRoutes())
|
||||
.route("/question", QuestionRoutes())
|
||||
.route("/provider", ProviderRoutes())
|
||||
.route("/sync", SyncRoutes())
|
||||
.route("/", FileRoutes())
|
||||
.route("/", EventRoutes())
|
||||
.route("/mcp", McpRoutes())
|
||||
.route("/tui", TuiRoutes())
|
||||
.post(
|
||||
"/instance/dispose",
|
||||
describeRoute({
|
||||
summary: "Dispose instance",
|
||||
description: "Clean up and dispose the current OpenCode instance, releasing all resources.",
|
||||
operationId: "instance.dispose",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Instance disposed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await InstanceRuntime.disposeInstance(Instance.current)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/path",
|
||||
describeRoute({
|
||||
summary: "Get paths",
|
||||
description: "Retrieve the current working directory and related path information for the OpenCode instance.",
|
||||
operationId: "path.get",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Path",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z
|
||||
.object({
|
||||
home: z.string(),
|
||||
state: z.string(),
|
||||
config: z.string(),
|
||||
worktree: z.string(),
|
||||
directory: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "Path",
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json({
|
||||
home: Global.Path.home,
|
||||
state: Global.Path.state,
|
||||
config: Global.Path.config,
|
||||
worktree: Instance.worktree,
|
||||
directory: Instance.directory,
|
||||
})
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/vcs",
|
||||
describeRoute({
|
||||
summary: "Get VCS info",
|
||||
description: "Retrieve version control system (VCS) information for the current project, such as git branch.",
|
||||
operationId: "vcs.get",
|
||||
responses: {
|
||||
200: {
|
||||
description: "VCS info",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Vcs.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("InstanceRoutes.vcs.get", c, function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], {
|
||||
concurrency: 2,
|
||||
})
|
||||
return { branch, default_branch }
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/vcs/diff",
|
||||
describeRoute({
|
||||
summary: "Get VCS diff",
|
||||
description: "Retrieve the current git diff for the working tree or against the default branch.",
|
||||
operationId: "vcs.diff",
|
||||
responses: {
|
||||
200: {
|
||||
description: "VCS diff",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Vcs.FileDiff.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
mode: Vcs.Mode.zod,
|
||||
}),
|
||||
),
|
||||
async (c) =>
|
||||
jsonRequest("InstanceRoutes.vcs.diff", c, function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.diff(c.req.valid("query").mode)
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/vcs/status",
|
||||
describeRoute({
|
||||
summary: "Get VCS status",
|
||||
description: "Retrieve changed files in the current working tree without patches.",
|
||||
operationId: "vcs.status",
|
||||
responses: {
|
||||
200: {
|
||||
description: "VCS status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Vcs.FileStatus.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("InstanceRoutes.vcs.status", c, function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.status()
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/vcs/diff/raw",
|
||||
describeRoute({
|
||||
summary: "Get raw VCS diff",
|
||||
description: "Retrieve a raw patch for current uncommitted changes.",
|
||||
operationId: "vcs.diff.raw",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Raw VCS diff",
|
||||
content: {
|
||||
"text/x-diff": {
|
||||
schema: resolver(z.string()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const patch = await runRequest(
|
||||
"InstanceRoutes.vcs.diffRaw",
|
||||
c,
|
||||
Vcs.Service.use((vcs) => vcs.diffRaw()),
|
||||
)
|
||||
return c.text(patch, 200, { "content-type": "text/x-diff; charset=utf-8" })
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/vcs/apply",
|
||||
describeRoute({
|
||||
summary: "Apply VCS patch",
|
||||
description: "Apply a raw patch to the current working tree.",
|
||||
operationId: "vcs.apply",
|
||||
responses: {
|
||||
200: {
|
||||
description: "VCS patch applied",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Vcs.ApplyResult.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Vcs.ApplyInput.zodObject),
|
||||
async (c) => {
|
||||
const result = await runRequest(
|
||||
"InstanceRoutes.vcs.apply",
|
||||
c,
|
||||
Vcs.Service.use((vcs) => vcs.apply(c.req.valid("json") as Vcs.ApplyInput)).pipe(
|
||||
Effect.match({
|
||||
onFailure: (error) => ({ ok: false as const, error }),
|
||||
onSuccess: (value) => ({ ok: true as const, value }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (result.ok) return c.json(result.value)
|
||||
return c.json(
|
||||
{
|
||||
name: "VcsApplyError",
|
||||
data: {
|
||||
message: result.error.message,
|
||||
reason: result.error.reason,
|
||||
},
|
||||
},
|
||||
400,
|
||||
)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/command",
|
||||
describeRoute({
|
||||
summary: "List commands",
|
||||
description: "Get a list of all available commands in the OpenCode system.",
|
||||
operationId: "command.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of commands",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Command.Info.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("InstanceRoutes.command.list", c, function* () {
|
||||
const svc = yield* Command.Service
|
||||
return yield* svc.list()
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/agent",
|
||||
describeRoute({
|
||||
summary: "List agents",
|
||||
description: "Get a list of all available AI agents in the OpenCode system.",
|
||||
operationId: "app.agents",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of agents",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Agent.Info.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("InstanceRoutes.agent.list", c, function* () {
|
||||
const svc = yield* Agent.Service
|
||||
return yield* svc.list()
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/skill",
|
||||
describeRoute({
|
||||
summary: "List skills",
|
||||
description: "Get a list of all available skills in the OpenCode system.",
|
||||
operationId: "app.skills",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of skills",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Skill.Info.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("InstanceRoutes.skill.list", c, function* () {
|
||||
const skill = yield* Skill.Service
|
||||
return yield* skill.all()
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/lsp",
|
||||
describeRoute({
|
||||
summary: "Get LSP status",
|
||||
description: "Get LSP server status",
|
||||
operationId: "lsp.status",
|
||||
responses: {
|
||||
200: {
|
||||
description: "LSP server status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(LSP.Status.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("InstanceRoutes.lsp.status", c, function* () {
|
||||
const lsp = yield* LSP.Service
|
||||
return yield* lsp.status()
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/formatter",
|
||||
describeRoute({
|
||||
summary: "Get formatter status",
|
||||
description: "Get formatter status",
|
||||
operationId: "formatter.status",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Formatter status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Format.Status.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("InstanceRoutes.formatter.status", c, function* () {
|
||||
const svc = yield* Format.Service
|
||||
return yield* svc.status()
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { MCP } from "@/mcp"
|
||||
import { ConfigMCP } from "@/config/mcp"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Effect } from "effect"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
|
||||
const UnsupportedOAuthError = z
|
||||
.object({
|
||||
error: z.string(),
|
||||
})
|
||||
.meta({ ref: "McpUnsupportedOAuthError" })
|
||||
|
||||
const unsupportedOAuthErrorResponse = {
|
||||
description: "MCP server does not support OAuth",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(UnsupportedOAuthError),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const McpRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "Get MCP status",
|
||||
description: "Get the status of all Model Context Protocol (MCP) servers.",
|
||||
operationId: "mcp.status",
|
||||
responses: {
|
||||
200: {
|
||||
description: "MCP server status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.record(z.string(), MCP.Status.zod)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("McpRoutes.status", c, function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
return yield* mcp.status()
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "Add MCP server",
|
||||
description: "Dynamically add a new Model Context Protocol (MCP) server to the system.",
|
||||
operationId: "mcp.add",
|
||||
responses: {
|
||||
200: {
|
||||
description: "MCP server added successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.record(z.string(), MCP.Status.zod)),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
name: z.string(),
|
||||
config: ConfigMCP.Info.zod,
|
||||
}),
|
||||
),
|
||||
async (c) =>
|
||||
jsonRequest("McpRoutes.add", c, function* () {
|
||||
const { name, config } = c.req.valid("json")
|
||||
const mcp = yield* MCP.Service
|
||||
const result = yield* mcp.add(name, config)
|
||||
return result.status
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:name/auth",
|
||||
describeRoute({
|
||||
summary: "Start MCP OAuth",
|
||||
description: "Start OAuth authentication flow for a Model Context Protocol (MCP) server.",
|
||||
operationId: "mcp.auth.start",
|
||||
responses: {
|
||||
200: {
|
||||
description: "OAuth flow started",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
authorizationUrl: z.string().describe("URL to open in browser for authorization"),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
400: unsupportedOAuthErrorResponse,
|
||||
...errors(404),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.param("name")
|
||||
const result = await runRequest(
|
||||
"McpRoutes.auth.start",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const supports = yield* mcp.supportsOAuth(name)
|
||||
if (!supports) return { supports }
|
||||
return {
|
||||
supports,
|
||||
auth: yield* mcp.startAuth(name),
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (!result.supports) {
|
||||
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
|
||||
}
|
||||
return c.json(result.auth)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:name/auth/callback",
|
||||
describeRoute({
|
||||
summary: "Complete MCP OAuth",
|
||||
description:
|
||||
"Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.",
|
||||
operationId: "mcp.auth.callback",
|
||||
responses: {
|
||||
200: {
|
||||
description: "OAuth authentication completed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(MCP.Status.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
code: z.string().describe("Authorization code from OAuth callback"),
|
||||
}),
|
||||
),
|
||||
async (c) =>
|
||||
jsonRequest("McpRoutes.auth.callback", c, function* () {
|
||||
const name = c.req.param("name")
|
||||
const { code } = c.req.valid("json")
|
||||
const mcp = yield* MCP.Service
|
||||
return yield* mcp.finishAuth(name, code)
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:name/auth/authenticate",
|
||||
describeRoute({
|
||||
summary: "Authenticate MCP OAuth",
|
||||
description: "Start OAuth flow and wait for callback (opens browser)",
|
||||
operationId: "mcp.auth.authenticate",
|
||||
responses: {
|
||||
200: {
|
||||
description: "OAuth authentication completed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(MCP.Status.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
400: unsupportedOAuthErrorResponse,
|
||||
...errors(404),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.param("name")
|
||||
const result = await runRequest(
|
||||
"McpRoutes.auth.authenticate",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const supports = yield* mcp.supportsOAuth(name)
|
||||
if (!supports) return { supports }
|
||||
return {
|
||||
supports,
|
||||
status: yield* mcp.authenticate(name),
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (!result.supports) {
|
||||
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
|
||||
}
|
||||
return c.json(result.status)
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:name/auth",
|
||||
describeRoute({
|
||||
summary: "Remove MCP OAuth",
|
||||
description: "Remove OAuth credentials for an MCP server",
|
||||
operationId: "mcp.auth.remove",
|
||||
responses: {
|
||||
200: {
|
||||
description: "OAuth credentials removed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ success: z.literal(true) })),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(404),
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("McpRoutes.auth.remove", c, function* () {
|
||||
const name = c.req.param("name")
|
||||
const mcp = yield* MCP.Service
|
||||
yield* mcp.removeAuth(name)
|
||||
return { success: true as const }
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:name/connect",
|
||||
describeRoute({
|
||||
description: "Connect an MCP server",
|
||||
operationId: "mcp.connect",
|
||||
responses: {
|
||||
200: {
|
||||
description: "MCP server connected successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ name: z.string() })),
|
||||
async (c) =>
|
||||
jsonRequest("McpRoutes.connect", c, function* () {
|
||||
const { name } = c.req.valid("param")
|
||||
const mcp = yield* MCP.Service
|
||||
yield* mcp.connect(name)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:name/disconnect",
|
||||
describeRoute({
|
||||
description: "Disconnect an MCP server",
|
||||
operationId: "mcp.disconnect",
|
||||
responses: {
|
||||
200: {
|
||||
description: "MCP server disconnected successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ name: z.string() })),
|
||||
async (c) =>
|
||||
jsonRequest("McpRoutes.disconnect", c, function* () {
|
||||
const { name } = c.req.valid("param")
|
||||
const mcp = yield* MCP.Service
|
||||
yield* mcp.disconnect(name)
|
||||
return true
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
import { WithInstance } from "@/project/with-instance"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
|
||||
export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
|
||||
const directory = AppFileSystem.resolve(
|
||||
(() => {
|
||||
try {
|
||||
return decodeURIComponent(raw)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
})(),
|
||||
)
|
||||
|
||||
return WorkspaceContext.provide({
|
||||
workspaceID,
|
||||
async fn() {
|
||||
return WithInstance.provide({
|
||||
directory,
|
||||
async fn() {
|
||||
return next()
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { Permission } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { jsonRequest } from "./trace"
|
||||
|
||||
export const PermissionRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.post(
|
||||
"/:requestID/reply",
|
||||
describeRoute({
|
||||
summary: "Respond to permission request",
|
||||
description: "Approve or deny a permission request from the AI assistant.",
|
||||
operationId: "permission.reply",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Permission processed successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
requestID: PermissionID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", z.object({ reply: Permission.Reply.zod, message: z.string().optional() })),
|
||||
async (c) =>
|
||||
jsonRequest("PermissionRoutes.reply", c, function* () {
|
||||
const params = c.req.valid("param")
|
||||
const json = c.req.valid("json")
|
||||
const svc = yield* Permission.Service
|
||||
yield* svc.reply({
|
||||
requestID: params.requestID,
|
||||
reply: json.reply,
|
||||
message: json.message,
|
||||
})
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List pending permissions",
|
||||
description: "Get all pending permission requests across all sessions.",
|
||||
operationId: "permission.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of pending permissions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Permission.Request.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("PermissionRoutes.list", c, function* () {
|
||||
const svc = yield* Permission.Service
|
||||
return yield* svc.list()
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -1,116 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator } from "hono-openapi"
|
||||
import { resolver } from "hono-openapi"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { Project } from "@/project/project"
|
||||
import z from "zod"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
|
||||
export const ProjectRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List all projects",
|
||||
description: "Get a list of projects that have been opened with OpenCode.",
|
||||
operationId: "project.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of projects",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Project.Info.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const projects = Project.list()
|
||||
return c.json(projects)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/current",
|
||||
describeRoute({
|
||||
summary: "Get current project",
|
||||
description: "Retrieve the currently active project that OpenCode is working with.",
|
||||
operationId: "project.current",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Current project information",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Project.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(Instance.project)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/git/init",
|
||||
describeRoute({
|
||||
summary: "Initialize git repository",
|
||||
description: "Create a git repository for the current project and return the refreshed project info.",
|
||||
operationId: "project.initGit",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Project information after git initialization",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Project.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const dir = Instance.directory
|
||||
const prev = Instance.project
|
||||
const next = await runRequest(
|
||||
"ProjectRoutes.initGit",
|
||||
c,
|
||||
Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })),
|
||||
)
|
||||
if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next)
|
||||
await InstanceRuntime.reloadInstance({ directory: dir, worktree: dir, project: next })
|
||||
return c.json(next)
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/:projectID",
|
||||
describeRoute({
|
||||
summary: "Update project",
|
||||
description: "Update project properties such as name, icon, and commands.",
|
||||
operationId: "project.update",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Updated project information",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Project.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ projectID: ProjectID.zod })),
|
||||
validator("json", Project.UpdateInput.omit({ projectID: true })),
|
||||
async (c) =>
|
||||
jsonRequest("ProjectRoutes.update", c, function* () {
|
||||
const projectID = c.req.valid("param").projectID
|
||||
const body = c.req.valid("json")
|
||||
const svc = yield* Project.Service
|
||||
return yield* svc.update({ ...body, projectID })
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -1,158 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { Config } from "@/config/config"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ModelsDev } from "@/provider/models"
|
||||
import { ProviderAuth } from "@/provider/auth"
|
||||
import { ProviderID } from "@/provider/schema"
|
||||
import { mapValues } from "remeda"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Effect } from "effect"
|
||||
import { jsonRequest } from "./trace"
|
||||
|
||||
export const ProviderRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List providers",
|
||||
description: "Get a list of all available AI providers, including both available and connected ones.",
|
||||
operationId: "provider.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of providers",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Provider.ListResult.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ProviderRoutes.list", c, function* () {
|
||||
const svc = yield* Provider.Service
|
||||
const cfg = yield* Config.Service
|
||||
const config = yield* cfg.get()
|
||||
const all = yield* ModelsDev.Service.use((s) => s.get())
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||
const filtered: Record<string, (typeof all)[string]> = {}
|
||||
for (const [key, value] of Object.entries(all)) {
|
||||
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
|
||||
filtered[key] = value
|
||||
}
|
||||
}
|
||||
const connected = yield* svc.list()
|
||||
const providers = Object.assign(
|
||||
mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)),
|
||||
connected,
|
||||
)
|
||||
return {
|
||||
all: Object.values(providers),
|
||||
default: Provider.defaultModelIDs(providers),
|
||||
connected: Object.keys(connected),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/auth",
|
||||
describeRoute({
|
||||
summary: "Get provider auth methods",
|
||||
description: "Retrieve available authentication methods for all AI providers.",
|
||||
operationId: "provider.auth",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Provider auth methods",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ProviderAuth.Methods.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ProviderRoutes.auth", c, function* () {
|
||||
const svc = yield* ProviderAuth.Service
|
||||
return yield* svc.methods()
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:providerID/oauth/authorize",
|
||||
describeRoute({
|
||||
summary: "OAuth authorize",
|
||||
description: "Initiate OAuth authorization for a specific AI provider to get an authorization URL.",
|
||||
operationId: "provider.oauth.authorize",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Authorization URL and method",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ProviderAuth.Authorization.zod.optional()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: ProviderID.zod.meta({ description: "Provider ID" }),
|
||||
}),
|
||||
),
|
||||
validator("json", ProviderAuth.AuthorizeInput.zod),
|
||||
async (c) =>
|
||||
jsonRequest("ProviderRoutes.oauth.authorize", c, function* () {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const { method, inputs } = c.req.valid("json")
|
||||
const svc = yield* ProviderAuth.Service
|
||||
return yield* svc.authorize({
|
||||
providerID,
|
||||
method,
|
||||
inputs,
|
||||
})
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:providerID/oauth/callback",
|
||||
describeRoute({
|
||||
summary: "OAuth callback",
|
||||
description: "Handle the OAuth callback from a provider after user authorization.",
|
||||
operationId: "provider.oauth.callback",
|
||||
responses: {
|
||||
200: {
|
||||
description: "OAuth callback processed successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: ProviderID.zod.meta({ description: "Provider ID" }),
|
||||
}),
|
||||
),
|
||||
validator("json", ProviderAuth.CallbackInput.zod),
|
||||
async (c) =>
|
||||
jsonRequest("ProviderRoutes.oauth.callback", c, function* () {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const { method, code } = c.req.valid("json")
|
||||
const svc = yield* ProviderAuth.Service
|
||||
yield* svc.callback({
|
||||
providerID,
|
||||
method,
|
||||
code,
|
||||
})
|
||||
return true
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -1,340 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import type { Context } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import { Effect, Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { PtyTicket } from "@/pty/ticket"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import { errors } from "../../error"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
import { HTTPException } from "hono/http-exception"
|
||||
import { isAllowedRequestOrigin, type CorsOptions } from "@/server/cors"
|
||||
import {
|
||||
PTY_CONNECT_TICKET_QUERY,
|
||||
PTY_CONNECT_TOKEN_HEADER,
|
||||
PTY_CONNECT_TOKEN_HEADER_VALUE,
|
||||
} from "@/server/shared/pty-ticket"
|
||||
import { zod as effectZod } from "@/util/effect-zod"
|
||||
|
||||
const ShellItem = z.object({
|
||||
path: z.string(),
|
||||
name: z.string(),
|
||||
acceptable: z.boolean(),
|
||||
})
|
||||
const decodePtyID = Schema.decodeUnknownSync(PtyID)
|
||||
|
||||
function validOrigin(c: Context, opts?: CorsOptions) {
|
||||
return isAllowedRequestOrigin(c.req.header("origin"), c.req.header("host"), opts)
|
||||
}
|
||||
|
||||
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket, opts?: CorsOptions) {
|
||||
return new Hono()
|
||||
.get(
|
||||
"/shells",
|
||||
describeRoute({
|
||||
summary: "List available shells",
|
||||
description: "Get a list of available shells on the system.",
|
||||
operationId: "pty.shells",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of shells",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(ShellItem)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await Shell.list())
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List PTY sessions",
|
||||
description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.",
|
||||
operationId: "pty.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of sessions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Pty.Info.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("PtyRoutes.list", c, function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.list()
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "Create PTY session",
|
||||
description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.",
|
||||
operationId: "pty.create",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Created session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Pty.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Pty.CreateInput.zod),
|
||||
async (c) =>
|
||||
jsonRequest("PtyRoutes.create", c, function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.create(c.req.valid("json") as Pty.CreateInput)
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/:ptyID",
|
||||
describeRoute({
|
||||
summary: "Get PTY session",
|
||||
description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.",
|
||||
operationId: "pty.get",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Session info",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Pty.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(404),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||
async (c) => {
|
||||
const info = await runRequest(
|
||||
"PtyRoutes.get",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.get(c.req.valid("param").ptyID)
|
||||
}),
|
||||
)
|
||||
if (!info) {
|
||||
throw new NotFoundError({ message: "Session not found" })
|
||||
}
|
||||
return c.json(info)
|
||||
},
|
||||
)
|
||||
.put(
|
||||
"/:ptyID",
|
||||
describeRoute({
|
||||
summary: "Update PTY session",
|
||||
description: "Update properties of an existing pseudo-terminal (PTY) session.",
|
||||
operationId: "pty.update",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Updated session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Pty.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||
validator("json", Pty.UpdateInput.zod),
|
||||
async (c) =>
|
||||
jsonRequest("PtyRoutes.update", c, function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json") as Pty.UpdateInput)
|
||||
}),
|
||||
)
|
||||
.delete(
|
||||
"/:ptyID",
|
||||
describeRoute({
|
||||
summary: "Remove PTY session",
|
||||
description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
|
||||
operationId: "pty.remove",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Session removed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(404),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||
async (c) =>
|
||||
jsonRequest("PtyRoutes.remove", c, function* () {
|
||||
const pty = yield* Pty.Service
|
||||
yield* pty.remove(c.req.valid("param").ptyID)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:ptyID/connect-token",
|
||||
describeRoute({
|
||||
summary: "Create PTY WebSocket token",
|
||||
description: "Create a short-lived token for opening a PTY WebSocket connection.",
|
||||
operationId: "pty.connectToken",
|
||||
responses: {
|
||||
200: {
|
||||
description: "WebSocket connect token",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(effectZod(PtyTicket.ConnectToken)),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(403, 404),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||
async (c) => {
|
||||
if (c.req.header(PTY_CONNECT_TOKEN_HEADER) !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(c, opts))
|
||||
throw new HTTPException(403)
|
||||
const result = await runRequest(
|
||||
"PtyRoutes.connectToken",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const id = c.req.valid("param").ptyID
|
||||
if (!(yield* pty.get(id))) return
|
||||
const tickets = yield* PtyTicket.Service
|
||||
return yield* tickets.issue({ ptyID: id, ...(yield* PtyTicket.scope) })
|
||||
}),
|
||||
)
|
||||
if (!result) throw new NotFoundError({ message: "Session not found" })
|
||||
return c.json(result)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:ptyID/connect",
|
||||
describeRoute({
|
||||
summary: "Connect to PTY session",
|
||||
description: "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
|
||||
operationId: "pty.connect",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Connected session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(403, 404),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||
upgradeWebSocket(async (c) => {
|
||||
type Handler = {
|
||||
onMessage: (message: string | ArrayBuffer) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const id = decodePtyID(c.req.param("ptyID"))
|
||||
if (
|
||||
!(await runRequest(
|
||||
"PtyRoutes.connect",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.get(id)
|
||||
}),
|
||||
))
|
||||
) {
|
||||
throw new NotFoundError({ message: "Session not found" })
|
||||
}
|
||||
const ticket = c.req.query(PTY_CONNECT_TICKET_QUERY)
|
||||
if (ticket) {
|
||||
if (!validOrigin(c, opts)) throw new HTTPException(403)
|
||||
const valid = await runRequest(
|
||||
"PtyRoutes.connect.ticket",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const tickets = yield* PtyTicket.Service
|
||||
return yield* tickets.consume({ ticket, ptyID: id, ...(yield* PtyTicket.scope) })
|
||||
}),
|
||||
)
|
||||
if (!valid) throw new HTTPException(403)
|
||||
}
|
||||
const cursor = (() => {
|
||||
const value = c.req.query("cursor")
|
||||
if (!value) return
|
||||
const parsed = Number(value)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < -1) return
|
||||
return parsed
|
||||
})()
|
||||
let handler: Handler | undefined
|
||||
|
||||
type Socket = {
|
||||
readyState: number
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => void
|
||||
close: (code?: number, reason?: string) => void
|
||||
}
|
||||
|
||||
const isSocket = (value: unknown): value is Socket => {
|
||||
if (!value || typeof value !== "object") return false
|
||||
if (!("readyState" in value)) return false
|
||||
if (!("send" in value) || typeof (value as { send?: unknown }).send !== "function") return false
|
||||
if (!("close" in value) || typeof (value as { close?: unknown }).close !== "function") return false
|
||||
return typeof (value as { readyState?: unknown }).readyState === "number"
|
||||
}
|
||||
|
||||
const pending: string[] = []
|
||||
let ready = false
|
||||
|
||||
return {
|
||||
async onOpen(_event, ws) {
|
||||
const socket = ws.raw
|
||||
if (!isSocket(socket)) {
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
handler = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.connect(id, socket, cursor)
|
||||
}).pipe(Effect.withSpan("PtyRoutes.connect.open")),
|
||||
)
|
||||
ready = true
|
||||
for (const msg of pending) handler?.onMessage(msg)
|
||||
pending.length = 0
|
||||
},
|
||||
onMessage(event) {
|
||||
if (typeof event.data !== "string") return
|
||||
if (!ready) {
|
||||
pending.push(event.data)
|
||||
return
|
||||
}
|
||||
handler?.onMessage(event.data)
|
||||
},
|
||||
onClose() {
|
||||
handler?.onClose()
|
||||
},
|
||||
onError() {
|
||||
handler?.onClose()
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator } from "hono-openapi"
|
||||
import { resolver } from "hono-openapi"
|
||||
import { QuestionID } from "@/question/schema"
|
||||
import { Question } from "@/question"
|
||||
import z from "zod"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { jsonRequest } from "./trace"
|
||||
|
||||
const Reply = z.object({
|
||||
answers: Question.Answer.zod
|
||||
.array()
|
||||
.describe("User answers in order of questions (each answer is an array of selected labels)"),
|
||||
})
|
||||
|
||||
export const QuestionRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List pending questions",
|
||||
description: "Get all pending question requests across all sessions.",
|
||||
operationId: "question.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of pending questions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Question.Request.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("QuestionRoutes.list", c, function* () {
|
||||
const svc = yield* Question.Service
|
||||
return yield* svc.list()
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:requestID/reply",
|
||||
describeRoute({
|
||||
summary: "Reply to question request",
|
||||
description: "Provide answers to a question request from the AI assistant.",
|
||||
operationId: "question.reply",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Question answered successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
requestID: QuestionID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", Reply),
|
||||
async (c) =>
|
||||
jsonRequest("QuestionRoutes.reply", c, function* () {
|
||||
const params = c.req.valid("param")
|
||||
const json = c.req.valid("json")
|
||||
const svc = yield* Question.Service
|
||||
yield* svc.reply({
|
||||
requestID: params.requestID,
|
||||
answers: json.answers,
|
||||
})
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:requestID/reject",
|
||||
describeRoute({
|
||||
summary: "Reject question request",
|
||||
description: "Reject a question request from the AI assistant.",
|
||||
operationId: "question.reject",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Question rejected successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
requestID: QuestionID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) =>
|
||||
jsonRequest("QuestionRoutes.reject", c, function* () {
|
||||
const params = c.req.valid("param")
|
||||
const svc = yield* Question.Service
|
||||
yield* svc.reject(params.requestID)
|
||||
return true
|
||||
}),
|
||||
),
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,199 +0,0 @@
|
||||
import z from "zod"
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { Database } from "@/storage/db"
|
||||
import { asc } from "drizzle-orm"
|
||||
import { and } from "drizzle-orm"
|
||||
import { not } from "drizzle-orm"
|
||||
import { or } from "drizzle-orm"
|
||||
import { lte } from "drizzle-orm"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { EventTable } from "@/sync/event.sql"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { errors } from "../../error"
|
||||
import { Session } from "@/session/session"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { SessionID } from "@/session/schema"
|
||||
|
||||
const ReplayEvent = z.object({
|
||||
id: z.string(),
|
||||
aggregateID: z.string(),
|
||||
seq: z.number().int().min(0),
|
||||
type: z.string(),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
const SessionPayload = z.object({
|
||||
sessionID: SessionID.zod,
|
||||
})
|
||||
|
||||
const log = Log.create({ service: "server.sync" })
|
||||
|
||||
export const SyncRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.post(
|
||||
"/start",
|
||||
describeRoute({
|
||||
summary: "Start workspace sync",
|
||||
description: "Start sync loops for workspaces in the current project that have active sessions.",
|
||||
operationId: "sync.start",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Workspace sync started",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
void AppRuntime.runPromise(
|
||||
Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(Instance.project.id)),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/replay",
|
||||
describeRoute({
|
||||
summary: "Replay sync events",
|
||||
description: "Validate and replay a complete sync event history.",
|
||||
operationId: "sync.replay",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Replayed sync events",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
directory: z.string(),
|
||||
events: z.array(ReplayEvent).min(1),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
const events = body.events
|
||||
const source = events[0].aggregateID
|
||||
|
||||
log.info("sync replay requested", {
|
||||
sessionID: source,
|
||||
events: events.length,
|
||||
first: events[0]?.seq,
|
||||
last: events.at(-1)?.seq,
|
||||
directory: body.directory,
|
||||
})
|
||||
await AppRuntime.runPromise(SyncEvent.use.replayAll(events))
|
||||
|
||||
log.info("sync replay complete", {
|
||||
sessionID: source,
|
||||
events: events.length,
|
||||
first: events[0]?.seq,
|
||||
last: events.at(-1)?.seq,
|
||||
})
|
||||
|
||||
return c.json({
|
||||
sessionID: source,
|
||||
})
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/steal",
|
||||
describeRoute({
|
||||
summary: "Steal session into workspace",
|
||||
description: "Update a session to belong to the current workspace through the sync event system.",
|
||||
operationId: "sync.steal",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Session stolen into workspace",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(SessionPayload),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", SessionPayload),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
const workspaceID = WorkspaceContext.workspaceID
|
||||
if (!workspaceID) throw new Error("Cannot steal session without workspace context")
|
||||
|
||||
SyncEvent.run(Session.Event.Updated, {
|
||||
sessionID: body.sessionID,
|
||||
info: {
|
||||
workspaceID,
|
||||
},
|
||||
})
|
||||
|
||||
log.info("sync session stolen", {
|
||||
sessionID: body.sessionID,
|
||||
workspaceID,
|
||||
})
|
||||
|
||||
return c.json({
|
||||
sessionID: body.sessionID,
|
||||
})
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/history",
|
||||
describeRoute({
|
||||
summary: "List sync events",
|
||||
description:
|
||||
"List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.",
|
||||
operationId: "sync.history.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Sync events",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
aggregate_id: z.string(),
|
||||
seq: z.number(),
|
||||
type: z.string(),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", z.record(z.string(), z.number().int().min(0))),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
const exclude = Object.entries(body)
|
||||
const where =
|
||||
exclude.length > 0
|
||||
? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!)
|
||||
: undefined
|
||||
const rows = Database.use((db) => db.select().from(EventTable).where(where).orderBy(asc(EventTable.seq)).all())
|
||||
return c.json(rows)
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { Context } from "hono"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
type AppEnv = Parameters<typeof AppRuntime.runPromise>[0] extends Effect.Effect<any, any, infer R> ? R : never
|
||||
|
||||
// Build the base span attributes for an HTTP handler: method, path, and every
|
||||
// matched route param. Names follow OTel attribute-naming guidance:
|
||||
// domain-first (`session.id`, `message.id`, …) so they match the existing
|
||||
// OTel `session.id` semantic convention and the bare `message.id` we
|
||||
// already emit from Tool.execute. Non-standard route params fall back to
|
||||
// `opencode.<name>` since those are internal implementation details
|
||||
// (per https://opentelemetry.io/blog/2025/how-to-name-your-span-attributes/).
|
||||
export interface RequestLike {
|
||||
readonly req: {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
param(): Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize a Hono route param key (e.g. `sessionID`, `messageID`, `name`)
|
||||
// to an OTel attribute key. `fooID` → `foo.id` for ID-shaped params; any
|
||||
// other param is namespaced under `opencode.` to avoid colliding with
|
||||
// standard conventions.
|
||||
export function paramToAttributeKey(key: string): string {
|
||||
const m = key.match(/^(.+)ID$/)
|
||||
if (m) return `${m[1].toLowerCase()}.id`
|
||||
return `opencode.${key}`
|
||||
}
|
||||
|
||||
export function requestAttributes(c: RequestLike): Record<string, string> {
|
||||
const attributes: Record<string, string> = {
|
||||
"http.method": c.req.method,
|
||||
"http.path": new URL(c.req.url).pathname,
|
||||
}
|
||||
for (const [key, value] of Object.entries(c.req.param())) {
|
||||
attributes[paramToAttributeKey(key)] = value
|
||||
}
|
||||
return attributes
|
||||
}
|
||||
|
||||
export function runRequest<A, E>(name: string, c: Context, effect: Effect.Effect<A, E, AppEnv>) {
|
||||
return AppRuntime.runPromise(effect.pipe(Effect.withSpan(name, { attributes: requestAttributes(c) })))
|
||||
}
|
||||
|
||||
export async function jsonRequest<C extends Context, A, E>(
|
||||
name: string,
|
||||
c: C,
|
||||
effect: (c: C) => Effect.gen.Return<A, E, AppEnv>,
|
||||
) {
|
||||
return c.json(
|
||||
await runRequest(
|
||||
name,
|
||||
c,
|
||||
Effect.gen(() => effect(c)),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
import { Hono, type Context } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { Bus } from "@/bus"
|
||||
import { Session } from "@/session/session"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import { zodObject } from "@/util/effect-zod"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { runRequest } from "./trace"
|
||||
import {
|
||||
TuiRequest,
|
||||
nextTuiRequest,
|
||||
nextTuiResponse,
|
||||
submitTuiRequest,
|
||||
submitTuiResponse,
|
||||
} from "@/server/shared/tui-control"
|
||||
|
||||
export async function callTui(ctx: Context) {
|
||||
const body = await ctx.req.json()
|
||||
submitTuiRequest({
|
||||
path: ctx.req.path,
|
||||
body,
|
||||
})
|
||||
return nextTuiResponse()
|
||||
}
|
||||
|
||||
const TuiControlRoutes = new Hono()
|
||||
.get(
|
||||
"/next",
|
||||
describeRoute({
|
||||
summary: "Get next TUI request",
|
||||
description: "Retrieve the next TUI (Terminal User Interface) request from the queue for processing.",
|
||||
operationId: "tui.control.next",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Next TUI request",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(TuiRequest),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const req = await nextTuiRequest()
|
||||
return c.json(req)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/response",
|
||||
describeRoute({
|
||||
summary: "Submit TUI response",
|
||||
description: "Submit a response to the TUI request queue to complete a pending request.",
|
||||
operationId: "tui.control.response",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Response submitted successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("json", z.any()),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
submitTuiResponse(body)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
|
||||
export const TuiRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.post(
|
||||
"/append-prompt",
|
||||
describeRoute({
|
||||
summary: "Append TUI prompt",
|
||||
description: "Append prompt to the TUI",
|
||||
operationId: "tui.appendPrompt",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Prompt processed successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", zodObject(TuiEvent.PromptAppend.properties)),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json") as { text: string })
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/open-help",
|
||||
describeRoute({
|
||||
summary: "Open help dialog",
|
||||
description: "Open the help dialog in the TUI to display user assistance information.",
|
||||
operationId: "tui.openHelp",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Help dialog opened successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.CommandExecute, {
|
||||
command: "help.show",
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/open-sessions",
|
||||
describeRoute({
|
||||
summary: "Open sessions dialog",
|
||||
description: "Open the session dialog",
|
||||
operationId: "tui.openSessions",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Session dialog opened successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.CommandExecute, {
|
||||
command: "session.list",
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/open-themes",
|
||||
describeRoute({
|
||||
summary: "Open themes dialog",
|
||||
description: "Open the theme dialog",
|
||||
operationId: "tui.openThemes",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Theme dialog opened successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.CommandExecute, {
|
||||
command: "session.list",
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/open-models",
|
||||
describeRoute({
|
||||
summary: "Open models dialog",
|
||||
description: "Open the model dialog",
|
||||
operationId: "tui.openModels",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Model dialog opened successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.CommandExecute, {
|
||||
command: "model.list",
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/submit-prompt",
|
||||
describeRoute({
|
||||
summary: "Submit TUI prompt",
|
||||
description: "Submit the prompt",
|
||||
operationId: "tui.submitPrompt",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Prompt submitted successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.CommandExecute, {
|
||||
command: "prompt.submit",
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/clear-prompt",
|
||||
describeRoute({
|
||||
summary: "Clear TUI prompt",
|
||||
description: "Clear the prompt",
|
||||
operationId: "tui.clearPrompt",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Prompt cleared successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.CommandExecute, {
|
||||
command: "prompt.clear",
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/execute-command",
|
||||
describeRoute({
|
||||
summary: "Execute TUI command",
|
||||
description: "Execute a TUI command (e.g. agent_cycle)",
|
||||
operationId: "tui.executeCommand",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Command executed successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", z.object({ command: z.string() })),
|
||||
async (c) => {
|
||||
const command = c.req.valid("json").command
|
||||
await Bus.publish(TuiEvent.CommandExecute, {
|
||||
// @ts-expect-error
|
||||
command: {
|
||||
session_new: "session.new",
|
||||
session_share: "session.share",
|
||||
session_interrupt: "session.interrupt",
|
||||
session_compact: "session.compact",
|
||||
messages_page_up: "session.page.up",
|
||||
messages_page_down: "session.page.down",
|
||||
messages_line_up: "session.line.up",
|
||||
messages_line_down: "session.line.down",
|
||||
messages_half_page_up: "session.half.page.up",
|
||||
messages_half_page_down: "session.half.page.down",
|
||||
messages_first: "session.first",
|
||||
messages_last: "session.last",
|
||||
agent_cycle: "agent.cycle",
|
||||
}[command],
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/show-toast",
|
||||
describeRoute({
|
||||
summary: "Show TUI toast",
|
||||
description: "Show a toast notification in the TUI",
|
||||
operationId: "tui.showToast",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Toast notification shown successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("json", zodObject(TuiEvent.ToastShow.properties)),
|
||||
async (c) => {
|
||||
await Bus.publish(
|
||||
TuiEvent.ToastShow,
|
||||
c.req.valid("json") as Schema.Schema.Type<typeof TuiEvent.ToastShow.properties>,
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/publish",
|
||||
describeRoute({
|
||||
summary: "Publish TUI event",
|
||||
description: "Publish a TUI event",
|
||||
operationId: "tui.publish",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Event published successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.union(
|
||||
Object.values(TuiEvent).map((def) => {
|
||||
return z
|
||||
.object({
|
||||
type: z.literal(def.type),
|
||||
properties: zodObject(def.properties),
|
||||
})
|
||||
.meta({
|
||||
ref: `Event.${def.type}`,
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
async (c) => {
|
||||
const evt = c.req.valid("json") as { type: string; properties: Record<string, unknown> }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)! as any, evt.properties as any)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/select-session",
|
||||
describeRoute({
|
||||
summary: "Select session",
|
||||
description: "Navigate the TUI to display the specified session.",
|
||||
operationId: "tui.selectSession",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Session selected successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator("json", zodObject(TuiEvent.SessionSelect.properties)),
|
||||
async (c) => {
|
||||
const { sessionID } = c.req.valid("json") as { sessionID: SessionID }
|
||||
await runRequest(
|
||||
"TuiRoutes.sessionSelect",
|
||||
c,
|
||||
Session.Service.use((svc) => svc.get(sessionID)),
|
||||
)
|
||||
await Bus.publish(TuiEvent.SessionSelect, { sessionID })
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.route("/control", TuiControlRoutes),
|
||||
)
|
||||
@@ -1,40 +0,0 @@
|
||||
import fs from "node:fs/promises"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Hono } from "hono"
|
||||
import { proxy } from "hono/proxy"
|
||||
import { ProxyUtil } from "../proxy-util"
|
||||
import { UI_UPSTREAM, csp, cspForHtml, embeddedUI, upstreamURL } from "../shared/ui"
|
||||
|
||||
export async function serveUI(request: Request) {
|
||||
const embeddedWebUI = await embeddedUI()
|
||||
const path = new URL(request.url).pathname
|
||||
|
||||
if (embeddedWebUI) {
|
||||
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
||||
if (!match) return Response.json({ error: "Not Found" }, { status: 404 })
|
||||
|
||||
if (await fs.exists(match)) {
|
||||
const mime = AppFileSystem.mimeType(match)
|
||||
const headers = new Headers({ "content-type": mime })
|
||||
const body = new Uint8Array(await fs.readFile(match))
|
||||
if (mime.startsWith("text/html")) {
|
||||
headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body)))
|
||||
}
|
||||
return new Response(body, { headers })
|
||||
}
|
||||
|
||||
return Response.json({ error: "Not Found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const response = await proxy(upstreamURL(path), {
|
||||
raw: request,
|
||||
headers: ProxyUtil.headers(request, { host: UI_UPSTREAM.host }),
|
||||
})
|
||||
response.headers.set(
|
||||
"Content-Security-Policy",
|
||||
response.headers.get("content-type")?.includes("text/html") ? cspForHtml(await response.clone().text()) : csp(),
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
export const UIRoutes = (): Hono => new Hono().all("/*", (c) => serveUI(c.req.raw))
|
||||
@@ -1,30 +1,14 @@
|
||||
import { generateSpecs } from "hono-openapi"
|
||||
import { Hono } from "hono"
|
||||
import { adapter } from "#hono"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import * as HttpApiServer from "#httpapi-server"
|
||||
import { MDNS } from "./mdns"
|
||||
import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
|
||||
import { FenceMiddleware } from "./fence"
|
||||
import { initProjectors } from "./projectors"
|
||||
import { InstanceRoutes } from "./routes/instance"
|
||||
import { ControlPlaneRoutes } from "./routes/control"
|
||||
import { UIRoutes } from "./routes/ui"
|
||||
import { GlobalRoutes } from "./routes/global"
|
||||
import { WorkspaceRouterMiddleware } from "./workspace"
|
||||
import { InstanceMiddleware } from "./routes/instance/middleware"
|
||||
import { WorkspaceRoutes } from "./routes/control/workspace"
|
||||
import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
|
||||
import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle"
|
||||
import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker"
|
||||
import { PublicApi } from "./routes/instance/httpapi/public"
|
||||
import * as ServerBackend from "./backend"
|
||||
import type { CorsOptions } from "./cors"
|
||||
|
||||
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
|
||||
@@ -53,203 +37,27 @@ type ListenOptions = CorsOptions & {
|
||||
mdnsDomain?: string
|
||||
}
|
||||
|
||||
const DefaultHono = lazy(() =>
|
||||
withBackend({ backend: "hono", reason: "stable" }, createHono({}, { backend: "hono", reason: "stable" })),
|
||||
)
|
||||
const DefaultHttpApi = lazy(() => createDefaultHttpApi())
|
||||
|
||||
function select() {
|
||||
return ServerBackend.select()
|
||||
}
|
||||
|
||||
export const backend = select
|
||||
|
||||
export const Default = () => {
|
||||
const selected = select()
|
||||
return selected.backend === "effect-httpapi" ? DefaultHttpApi() : DefaultHono()
|
||||
}
|
||||
|
||||
function create(opts: ListenOptions) {
|
||||
const selected = select()
|
||||
return selected.backend === "effect-httpapi"
|
||||
? withBackend(selected, createHttpApi(opts))
|
||||
: withBackend(selected, createHono(opts, selected))
|
||||
}
|
||||
|
||||
export function Legacy(opts: CorsOptions = {}) {
|
||||
return withBackend({ backend: "hono", reason: "explicit" }, createHono(opts, { backend: "hono", reason: "explicit" }))
|
||||
}
|
||||
|
||||
function createDefaultHttpApi() {
|
||||
return withBackend(select(), createHttpApi())
|
||||
}
|
||||
|
||||
function withBackend<T extends { app: ServerApp; runtime: unknown }>(selection: ServerBackend.Selection, built: T) {
|
||||
log.info("server backend selected", ServerBackend.attributes(selection))
|
||||
return built
|
||||
}
|
||||
|
||||
function createHttpApi(corsOptions?: CorsOptions) {
|
||||
const handler = ExperimentalHttpApiServer.webHandler(corsOptions).handler
|
||||
const defaultHttpApi = (() => {
|
||||
const handler = ExperimentalHttpApiServer.webHandler().handler
|
||||
const app: ServerApp = {
|
||||
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
|
||||
request(input, init) {
|
||||
return app.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
|
||||
},
|
||||
}
|
||||
return {
|
||||
app,
|
||||
runtime: adapter.createFetch(app),
|
||||
}
|
||||
}
|
||||
return { app }
|
||||
})()
|
||||
|
||||
function createHono(opts: CorsOptions, selection: ServerBackend.Selection = ServerBackend.force(select(), "hono")) {
|
||||
const backendAttributes = ServerBackend.attributes(selection)
|
||||
const app = new Hono()
|
||||
.onError(ErrorMiddleware)
|
||||
.use(CorsMiddleware(opts))
|
||||
.use(LoggerMiddleware(backendAttributes))
|
||||
.use(AuthMiddleware)
|
||||
.use(CompressionMiddleware)
|
||||
.route("/global", GlobalRoutes())
|
||||
export const Default = () => defaultHttpApi
|
||||
|
||||
const runtime = adapter.create(app)
|
||||
|
||||
if (Flag.OPENCODE_WORKSPACE_ID) {
|
||||
return {
|
||||
app: app
|
||||
.use(InstanceMiddleware(Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined))
|
||||
.use(FenceMiddleware)
|
||||
.route("/", InstanceRoutes(runtime.upgradeWebSocket, opts)),
|
||||
runtime,
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceApp = new Hono()
|
||||
const workspaceLegacyApp = new Hono()
|
||||
.use(InstanceMiddleware())
|
||||
.route("/experimental/workspace", WorkspaceRoutes())
|
||||
.use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket))
|
||||
workspaceApp.route("/", workspaceLegacyApp)
|
||||
|
||||
return {
|
||||
app: app
|
||||
.route("/", ControlPlaneRoutes())
|
||||
.route("/", workspaceApp)
|
||||
.route("/", InstanceRoutes(runtime.upgradeWebSocket, opts))
|
||||
.route("/", UIRoutes()),
|
||||
runtime,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the OpenAPI document used by the SDK build.
|
||||
*
|
||||
* Since the Effect HttpApi backend now covers every Hono route (plus the new
|
||||
* `/api/session/*` v2 routes — see `httpapi-bridge.test.ts` for the parity
|
||||
* audit), `Server.openapi()` derives the spec from `OpenApi.fromApi(PublicApi)`.
|
||||
* `PublicApi` is `OpenCodeHttpApi` annotated with the `matchLegacyOpenApi`
|
||||
* transform that injects instance query parameters, strips Effect's optional
|
||||
* null arms, normalizes component names, and patches SSE response schemas so
|
||||
* the generated SDK keeps the legacy Hono shape.
|
||||
*
|
||||
* The Hono-derived spec is still reachable via `openapiHono()` so reviewers
|
||||
* can diff the two outputs while the Hono backend lingers; once the Hono
|
||||
* backend is deleted that helper goes with it.
|
||||
*/
|
||||
export async function openapi() {
|
||||
return OpenApi.fromApi(PublicApi)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hono-derived OpenAPI spec, retained for parity diffing only. Delete once
|
||||
* the Hono backend is removed.
|
||||
*/
|
||||
export async function openapiHono() {
|
||||
// Build a fresh app with all routes registered directly so
|
||||
// hono-openapi can see describeRoute metadata (`.route()` wraps
|
||||
// handlers when the sub-app has a custom errorHandler, which
|
||||
// strips the metadata symbol).
|
||||
const { app } = createHono({})
|
||||
const result = await generateSpecs(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: "opencode",
|
||||
version: "1.0.0",
|
||||
description: "opencode api",
|
||||
},
|
||||
openapi: "3.1.1",
|
||||
},
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
export let url: URL
|
||||
|
||||
export async function listen(opts: ListenOptions): Promise<Listener> {
|
||||
const selected = select()
|
||||
const inner: Listener =
|
||||
selected.backend === "effect-httpapi" ? await listenHttpApi(opts, selected) : await listenLegacy(opts)
|
||||
|
||||
const next = new URL(inner.url)
|
||||
url = next
|
||||
|
||||
const mdns =
|
||||
opts.mdns && inner.port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1"
|
||||
if (mdns) {
|
||||
MDNS.publish(inner.port, opts.mdnsDomain)
|
||||
} else if (opts.mdns) {
|
||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||
}
|
||||
|
||||
let closing: Promise<void> | undefined
|
||||
let mdnsUnpublished = false
|
||||
const unpublish = () => {
|
||||
if (!mdns || mdnsUnpublished) return
|
||||
mdnsUnpublished = true
|
||||
MDNS.unpublish()
|
||||
}
|
||||
return {
|
||||
hostname: inner.hostname,
|
||||
port: inner.port,
|
||||
url: next,
|
||||
stop(close?: boolean) {
|
||||
unpublish()
|
||||
// Always forward stop(true), even if a graceful stop was requested
|
||||
// first, so native listeners can escalate shutdown in-place.
|
||||
const next = inner.stop(close)
|
||||
closing ??= next
|
||||
return close ? next.then(() => closing!) : closing
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function listenLegacy(opts: ListenOptions): Promise<Listener> {
|
||||
const built = create(opts)
|
||||
const server = await built.runtime.listen(opts)
|
||||
const innerUrl = new URL("http://localhost")
|
||||
innerUrl.hostname = opts.hostname
|
||||
innerUrl.port = String(server.port)
|
||||
return {
|
||||
hostname: opts.hostname,
|
||||
port: server.port,
|
||||
url: innerUrl,
|
||||
stop: (close?: boolean) => server.stop(close),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the effect-httpapi backend on a native Effect HTTP server. This
|
||||
* lets HttpApi routes that call `request.upgrade` (PTY connect, the
|
||||
* workspace-routing proxy WS bridge) work end-to-end; the legacy Hono
|
||||
* adapter path can't surface `request.upgrade` because its fetch handler has
|
||||
* no reference to the platform server instance for websocket upgrades.
|
||||
*/
|
||||
async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selection): Promise<Listener> {
|
||||
log.info("server backend selected", {
|
||||
...ServerBackend.attributes(selection),
|
||||
"opencode.server.runtime": HttpApiServer.name,
|
||||
})
|
||||
log.info("server backend", { "opencode.server.runtime": HttpApiServer.name })
|
||||
|
||||
const buildLayer = (port: number) =>
|
||||
HttpRouter.serve(ExperimentalHttpApiServer.createRoutes(opts), {
|
||||
@@ -270,10 +78,6 @@ async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selec
|
||||
const start = async (port: number) => {
|
||||
const scope = Scope.makeUnsafe()
|
||||
try {
|
||||
// Effect's `HttpMiddleware` interface returns `Effect<…, any, any>` by
|
||||
// design, which leaks `R = any` through `HttpRouter.serve`. The actual
|
||||
// requirements at this point are fully satisfied by `createRoutes` and the
|
||||
// platform HTTP server layer; cast away the `any` to satisfy `runPromise`.
|
||||
const layer = buildLayer(port) as Layer.Layer<
|
||||
HttpServer.HttpServer | WebSocketTracker.Service | HttpApiServer.Service,
|
||||
unknown,
|
||||
@@ -308,8 +112,24 @@ async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selec
|
||||
const innerUrl = new URL("http://localhost")
|
||||
innerUrl.hostname = opts.hostname
|
||||
innerUrl.port = String(port)
|
||||
url = innerUrl
|
||||
|
||||
const mdns =
|
||||
opts.mdns && port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1"
|
||||
if (mdns) {
|
||||
MDNS.publish(port, opts.mdnsDomain)
|
||||
} else if (opts.mdns) {
|
||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||
}
|
||||
|
||||
let forceStopPromise: Promise<void> | undefined
|
||||
let stopPromise: Promise<void> | undefined
|
||||
let mdnsUnpublished = false
|
||||
const unpublish = () => {
|
||||
if (!mdns || mdnsUnpublished) return
|
||||
mdnsUnpublished = true
|
||||
MDNS.unpublish()
|
||||
}
|
||||
const forceStop = () => {
|
||||
forceStopPromise ??= Effect.runPromiseExit(
|
||||
Effect.gen(function* () {
|
||||
@@ -325,9 +145,8 @@ async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selec
|
||||
port,
|
||||
url: innerUrl,
|
||||
stop: (close?: boolean) => {
|
||||
unpublish()
|
||||
const requested = close ? forceStop() : Promise.resolve()
|
||||
// The first call starts scope shutdown. A later stop(true) cannot undo
|
||||
// that, but it still runs forceStop() before awaiting the original close.
|
||||
stopPromise ??= requested
|
||||
.then(() => Effect.runPromiseExit(Scope.close(resolved!.scope, Exit.void)))
|
||||
.then(() => undefined)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import z from "zod"
|
||||
import { AsyncQueue } from "@/util/queue"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const TuiRequest = z.object({
|
||||
path: z.string(),
|
||||
body: z.any(),
|
||||
export const TuiRequest = Schema.Struct({
|
||||
path: Schema.String,
|
||||
body: Schema.Unknown,
|
||||
})
|
||||
|
||||
export type TuiRequest = z.infer<typeof TuiRequest>
|
||||
export type TuiRequest = Schema.Schema.Type<typeof TuiRequest>
|
||||
|
||||
const request = new AsyncQueue<TuiRequest>()
|
||||
const response = new AsyncQueue<unknown>()
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import { getAdapter } from "@/control-plane/adapters"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { WithInstance } from "@/project/with-instance"
|
||||
import { Session } from "@/session/session"
|
||||
import { Effect } from "effect"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { ServerProxy } from "./proxy"
|
||||
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "./shared/workspace-routing"
|
||||
|
||||
async function getSessionWorkspace(url: URL) {
|
||||
const id = getWorkspaceRouteSessionID(url)
|
||||
if (!id) return null
|
||||
|
||||
const session = await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) => svc.get(id)).pipe(Effect.withSpan("WorkspaceRouter.lookup")),
|
||||
).catch(() => undefined)
|
||||
return session?.workspaceID
|
||||
}
|
||||
|
||||
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
|
||||
const log = Log.create({ service: "workspace-router" })
|
||||
|
||||
return async (c, next) => {
|
||||
const url = new URL(c.req.url)
|
||||
|
||||
const sessionWorkspaceID = await getSessionWorkspace(url)
|
||||
const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace")
|
||||
|
||||
if (!workspaceID || url.pathname.startsWith("/console") || Flag.OPENCODE_WORKSPACE_ID) {
|
||||
return next()
|
||||
}
|
||||
|
||||
const workspace = await AppRuntime.runPromise(
|
||||
Workspace.Service.use((svc) => svc.get(WorkspaceID.make(workspaceID))),
|
||||
)
|
||||
|
||||
if (!workspace) {
|
||||
return new Response(`Workspace not found: ${workspaceID}`, {
|
||||
status: 500,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (isLocalWorkspaceRoute(c.req.method, url.pathname)) {
|
||||
// No instance provided because we are serving cached data; there
|
||||
// is no instance to work with
|
||||
return next()
|
||||
}
|
||||
|
||||
const adapter = getAdapter(workspace.projectID, workspace.type)
|
||||
const target = await adapter.target(workspace)
|
||||
|
||||
if (target.type === "local") {
|
||||
return WorkspaceContext.provide({
|
||||
workspaceID: WorkspaceID.make(workspaceID),
|
||||
fn: () =>
|
||||
WithInstance.provide({
|
||||
directory: target.directory,
|
||||
async fn() {
|
||||
return next()
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const proxyURL = workspaceProxyURL(target.url, url)
|
||||
|
||||
log.info("workspace proxy forwarding", {
|
||||
workspaceID,
|
||||
request: url.toString(),
|
||||
target: String(target.url),
|
||||
proxy: proxyURL.toString(),
|
||||
})
|
||||
|
||||
if (c.req.header("upgrade")?.toLowerCase() === "websocket") {
|
||||
return ServerProxy.websocket(upgrade, proxyURL, target.headers, c.req.raw, c.env)
|
||||
}
|
||||
|
||||
const headers = new Headers(c.req.raw.headers)
|
||||
headers.delete("x-opencode-workspace")
|
||||
|
||||
const req = new Request(c.req.raw, { headers })
|
||||
return ServerProxy.http(proxyURL, target.headers, req, workspace.id)
|
||||
}
|
||||
}
|
||||
@@ -446,19 +446,6 @@ export type Part =
|
||||
| RetryPart
|
||||
| CompactionPart
|
||||
|
||||
// Zod discriminated union kept for the legacy Hono OpenAPI path.
|
||||
const AssistantErrorZod = z.discriminatedUnion("name", [
|
||||
AuthError.Schema,
|
||||
NamedError.Unknown.Schema,
|
||||
OutputLengthError.Schema,
|
||||
AbortedError.Schema,
|
||||
StructuredOutputError.Schema,
|
||||
ContextOverflowError.Schema,
|
||||
APIError.Schema,
|
||||
])
|
||||
type AssistantError = z.infer<typeof AssistantErrorZod>
|
||||
|
||||
// Effect Schema for the same union — used by HttpApi OpenAPI generation.
|
||||
const AssistantErrorSchema = Schema.Union([
|
||||
AuthError.EffectSchema,
|
||||
Schema.Struct({ name: Schema.Literal("UnknownError"), data: Schema.Struct({ message: Schema.String }) }).annotate({
|
||||
@@ -470,6 +457,7 @@ const AssistantErrorSchema = Schema.Union([
|
||||
ContextOverflowError.EffectSchema,
|
||||
APIError.EffectSchema,
|
||||
]).annotate({ discriminator: "name" })
|
||||
type AssistantError = Schema.Schema.Type<typeof AssistantErrorSchema>
|
||||
|
||||
// ── Prompt input schemas ─────────────────────────────────────────────────────
|
||||
//
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import z from "zod"
|
||||
import { Database } from "@/storage/db"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
@@ -10,7 +9,6 @@ import type { WorkspaceID } from "@/control-plane/schema"
|
||||
import { EventID } from "./schema"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Context, Effect, Layer, Schema as EffectSchema } from "effect"
|
||||
import { zodObject } from "@/util/effect-zod"
|
||||
import type { DeepMutable } from "@/util/schema"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
@@ -364,26 +362,6 @@ export function claim(aggregateID: string, ownerID: string) {
|
||||
)
|
||||
}
|
||||
|
||||
export function payloads() {
|
||||
return registry
|
||||
.entries()
|
||||
.map(([type, def]) => {
|
||||
return z
|
||||
.object({
|
||||
type: z.literal("sync"),
|
||||
name: z.literal(type),
|
||||
id: z.string(),
|
||||
seq: z.number(),
|
||||
aggregateID: z.literal(def.aggregate),
|
||||
data: zodObject(def.schema),
|
||||
})
|
||||
.meta({
|
||||
ref: `SyncEvent.${def.type}`,
|
||||
})
|
||||
})
|
||||
.toArray()
|
||||
}
|
||||
|
||||
export function effectPayloads() {
|
||||
return registry
|
||||
.entries()
|
||||
|
||||
@@ -21,8 +21,6 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { zod as effectZod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
const log = Log.create({ service: "worktree" })
|
||||
|
||||
@@ -46,9 +44,7 @@ export const Info = Schema.Struct({
|
||||
name: Schema.String,
|
||||
branch: Schema.String,
|
||||
directory: Schema.String,
|
||||
})
|
||||
.annotate({ identifier: "Worktree" })
|
||||
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
|
||||
}).annotate({ identifier: "Worktree" })
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export const CreateInput = Schema.Struct({
|
||||
@@ -56,23 +52,17 @@ export const CreateInput = Schema.Struct({
|
||||
startCommand: Schema.optional(
|
||||
Schema.String.annotate({ description: "Additional startup script to run after the project's start command" }),
|
||||
),
|
||||
})
|
||||
.annotate({ identifier: "WorktreeCreateInput" })
|
||||
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
|
||||
}).annotate({ identifier: "WorktreeCreateInput" })
|
||||
export type CreateInput = Schema.Schema.Type<typeof CreateInput>
|
||||
|
||||
export const RemoveInput = Schema.Struct({
|
||||
directory: Schema.String,
|
||||
})
|
||||
.annotate({ identifier: "WorktreeRemoveInput" })
|
||||
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
|
||||
}).annotate({ identifier: "WorktreeRemoveInput" })
|
||||
export type RemoveInput = Schema.Schema.Type<typeof RemoveInput>
|
||||
|
||||
export const ResetInput = Schema.Struct({
|
||||
directory: Schema.String,
|
||||
})
|
||||
.annotate({ identifier: "WorktreeResetInput" })
|
||||
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
|
||||
}).annotate({ identifier: "WorktreeResetInput" })
|
||||
export type ResetInput = Schema.Schema.Type<typeof ResetInput>
|
||||
|
||||
export const NotGitError = NamedError.create(
|
||||
@@ -361,13 +351,15 @@ export const layer: Layer.Layer<
|
||||
}
|
||||
|
||||
const primary = yield* canonical(ctx.worktree)
|
||||
const primaryName = pathSvc.basename(primary).toLowerCase()
|
||||
return yield* Effect.forEach(parseWorktreeList(result.text), (entry) =>
|
||||
Effect.gen(function* () {
|
||||
if (!entry.path) return undefined
|
||||
const directory = yield* canonical(entry.path)
|
||||
if (directory === primary) return undefined
|
||||
const name = pathSvc.basename(directory).toLowerCase()
|
||||
return {
|
||||
name: pathSvc.basename(directory),
|
||||
name: name === primaryName ? pathSvc.basename(pathSvc.dirname(directory)) : name,
|
||||
directory,
|
||||
...(entry.branch ? { branch: entry.branch.replace(/^refs\/heads\//, "") } : {}),
|
||||
}
|
||||
|
||||
@@ -142,3 +142,18 @@ Use `provideTmpdirInstance(...)` or `tmpdirScoped()` plus `provideInstance(...)`
|
||||
- Yield services directly with `yield* MyService.Service` or `yield* MyTool`.
|
||||
- Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` wrappers when `testEffect(...)` already provides the runtime.
|
||||
- When a test needs instance-local state, prefer `it.instance(...)` over manual `Instance.provide(...)` inside Promise-style tests.
|
||||
|
||||
### Partial Service Stubs
|
||||
|
||||
When a test only needs to override one or two methods of a service, prefer `Layer.mock` over a hand-rolled `Layer.succeed(Service, Service.of({ ... }))`. `Layer.mock` lets you supply just the methods that matter — anything else throws an `UnimplementedError` defect if the test accidentally calls it, which is exactly the signal you want.
|
||||
|
||||
```typescript
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Account } from "@/account/account"
|
||||
|
||||
const failingAccountLayer = Layer.mock(Account.Service, {
|
||||
orgsByAccount: () => Effect.fail(new Account.AccountServiceError({ message: "simulated upstream failure" })),
|
||||
})
|
||||
```
|
||||
|
||||
This is much shorter than stubbing every method with `Effect.void` / `Effect.succeed(...)` placeholders, and it keeps the test focused on the behaviour under test.
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ACP } from "../../src/acp/agent"
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||
import type { Event, EventMessagePartUpdated, ToolStatePending, ToolStateRunning } from "@opencode-ai/sdk/v2"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import type {
|
||||
Event,
|
||||
EventMessagePartUpdated,
|
||||
ToolStateCompleted,
|
||||
ToolStatePending,
|
||||
ToolStateRunning,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
@@ -36,6 +41,14 @@ function isToolCallUpdate(
|
||||
return update.sessionUpdate === "tool_call_update"
|
||||
}
|
||||
|
||||
function completedToolUpdate(sessionUpdates: SessionUpdateParams[], sessionId: string, callID: string) {
|
||||
return sessionUpdates
|
||||
.filter((u) => u.sessionId === sessionId)
|
||||
.map((u) => u.update)
|
||||
.filter(isToolCallUpdate)
|
||||
.find((u) => u.toolCallId === callID && u.status === "completed")
|
||||
}
|
||||
|
||||
function toolEvent(
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
@@ -78,6 +91,46 @@ function toolEvent(
|
||||
return { directory: cwd, payload }
|
||||
}
|
||||
|
||||
function completedToolEvent(
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
opts: {
|
||||
callID: string
|
||||
tool: string
|
||||
input: Record<string, unknown>
|
||||
output: string
|
||||
attachments?: ToolStateCompleted["attachments"]
|
||||
},
|
||||
): GlobalEventEnvelope {
|
||||
const state: ToolStateCompleted = {
|
||||
status: "completed",
|
||||
input: opts.input,
|
||||
output: opts.output,
|
||||
title: opts.tool,
|
||||
metadata: {},
|
||||
time: { start: Date.now() - 1, end: Date.now() },
|
||||
...(opts.attachments && { attachments: opts.attachments }),
|
||||
}
|
||||
const payload: EventMessagePartUpdated = {
|
||||
id: `evt_${opts.callID}`,
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: sessionId,
|
||||
time: Date.now(),
|
||||
part: {
|
||||
id: `part_${opts.callID}`,
|
||||
sessionID: sessionId,
|
||||
messageID: `msg_${opts.callID}`,
|
||||
type: "tool",
|
||||
callID: opts.callID,
|
||||
tool: opts.tool,
|
||||
state,
|
||||
},
|
||||
},
|
||||
}
|
||||
return { directory: cwd, payload }
|
||||
}
|
||||
|
||||
function createEventStream() {
|
||||
const queue: GlobalEventEnvelope[] = []
|
||||
const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = []
|
||||
@@ -616,6 +669,130 @@ describe("acp.agent event subscription", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("emits image attachments as ACP tool content blocks on live completed tool updates", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
|
||||
const cwd = "/tmp/opencode-acp-test"
|
||||
const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
|
||||
const data = Buffer.from("image-data").toString("base64")
|
||||
|
||||
controller.push(
|
||||
completedToolEvent(sessionId, cwd, {
|
||||
callID: "call_image",
|
||||
tool: "read",
|
||||
input: { filePath: "/tmp/image.png" },
|
||||
output: "Image read successfully",
|
||||
attachments: [
|
||||
{
|
||||
id: "part_image",
|
||||
sessionID: sessionId,
|
||||
messageID: "msg_image",
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
filename: "image.png",
|
||||
url: `data:image/png;base64,${data}`,
|
||||
},
|
||||
{
|
||||
id: "part_text",
|
||||
sessionID: sessionId,
|
||||
messageID: "msg_image",
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: "note.txt",
|
||||
url: "data:text/plain;base64,Zm9v",
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
|
||||
const update = completedToolUpdate(sessionUpdates, sessionId, "call_image")
|
||||
expect(update?.content).toContainEqual({
|
||||
type: "content",
|
||||
content: { type: "text", text: "Image read successfully" },
|
||||
})
|
||||
expect(update?.content).toContainEqual({
|
||||
type: "content",
|
||||
content: { type: "image", mimeType: "image/png", data },
|
||||
})
|
||||
expect(update?.content?.some((item) => item.type === "content" && item.content.type === "resource")).toBe(false)
|
||||
expect((update?.rawOutput as { attachments?: unknown[] } | undefined)?.attachments?.length).toBe(2)
|
||||
|
||||
stop()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("replays completed tool image attachments as ACP tool content blocks", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, sessionUpdates, stop, sdk } = createFakeAgent()
|
||||
const cwd = "/tmp/opencode-acp-test"
|
||||
const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
|
||||
const data = Buffer.from("replay-image").toString("base64")
|
||||
|
||||
sdk.session.messages = async () => ({
|
||||
data: [
|
||||
{
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID: sessionId,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "part_replay",
|
||||
sessionID: sessionId,
|
||||
messageID: "msg_replay",
|
||||
type: "tool",
|
||||
callID: "call_replay_image",
|
||||
tool: "webfetch",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { url: "https://example.com/image.png" },
|
||||
output: "Image fetched successfully",
|
||||
title: "webfetch",
|
||||
metadata: {},
|
||||
time: { start: Date.now() - 1, end: Date.now() },
|
||||
attachments: [
|
||||
{
|
||||
id: "part_replay_image",
|
||||
sessionID: sessionId,
|
||||
messageID: "msg_replay",
|
||||
type: "file",
|
||||
mime: "image/jpeg",
|
||||
filename: "image.jpg",
|
||||
url: `data:image/jpeg;base64,${data}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any)
|
||||
|
||||
const update = completedToolUpdate(sessionUpdates, sessionId, "call_replay_image")
|
||||
expect(update?.content).toContainEqual({
|
||||
type: "content",
|
||||
content: { type: "text", text: "Image fetched successfully" },
|
||||
})
|
||||
expect(update?.content).toContainEqual({
|
||||
type: "content",
|
||||
content: { type: "image", mimeType: "image/jpeg", data },
|
||||
})
|
||||
|
||||
stop()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("does not emit duplicate synthetic pending after replayed running tool", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await WithInstance.provide({
|
||||
|
||||
@@ -5,7 +5,7 @@ import Http from "node:http"
|
||||
import path from "node:path"
|
||||
import { setTimeout as delay } from "node:timers/promises"
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { eq } from "drizzle-orm"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -376,9 +376,10 @@ describe("workspace schemas and exports", () => {
|
||||
extra: { nested: true },
|
||||
}
|
||||
|
||||
expect(Workspace.CreateInput.zod.parse(input)).toEqual(input)
|
||||
expect(() => Workspace.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow()
|
||||
expect(() => Workspace.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow()
|
||||
const decode = Schema.decodeUnknownSync(Workspace.CreateInput)
|
||||
expect(decode(input)).toEqual(input)
|
||||
expect(() => decode({ ...input, id: 1 })).toThrow()
|
||||
expect(() => decode({ ...input, branch: 1 })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { WorkspaceID } from "@/control-plane/schema"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Effect, Scope } from "effect"
|
||||
|
||||
/**
|
||||
* Scoped override for `Flag.OPENCODE_WORKSPACE_ID`. Saves the previous value
|
||||
* on entry and restores it via finalizer when the surrounding scope closes —
|
||||
* preserves the original try/finally semantics regardless of test outcome.
|
||||
*/
|
||||
export function withFixedWorkspaceID(id: WorkspaceID): Effect.Effect<void, never, Scope.Scope> {
|
||||
return Effect.gen(function* () {
|
||||
const previous = Flag.OPENCODE_WORKSPACE_ID
|
||||
Flag.OPENCODE_WORKSPACE_ID = id
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
Flag.OPENCODE_WORKSPACE_ID = previous
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
+10
-18
@@ -1,19 +1,21 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { Hono } from "hono"
|
||||
import { existsSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { InstanceRuntime } from "../../src/project/instance-runtime"
|
||||
import { InstanceMiddleware } from "../../src/server/routes/instance/middleware"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
// These regressions cover the legacy instance-loading paths fixed by PRs
|
||||
// #25389 and #25449. The plugin config hook writes a marker file, and the test
|
||||
// bodies deliberately avoid touching Plugin or config directly. The marker only
|
||||
// exists if InstanceBootstrap ran at the instance boundary.
|
||||
// InstanceBootstrap must run before any code touches the instance —
|
||||
// originally tracked by PRs #25389 and #25449, now a permanent
|
||||
// invariant. The plugin config hook writes a marker file; the test
|
||||
// bodies deliberately avoid Plugin/config directly. The marker only
|
||||
// appears if InstanceBootstrap ran at the instance boundary.
|
||||
//
|
||||
// The Hono variant of this check lived alongside these tests and is
|
||||
// going away with the Hono backend. The boundaries below are backend-
|
||||
// agnostic and stay.
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
@@ -48,7 +50,7 @@ async function bootstrapFixture() {
|
||||
})
|
||||
}
|
||||
|
||||
test("Instance.provide runs InstanceBootstrap before fn (boundary invariant)", async () => {
|
||||
test("WithInstance.provide runs InstanceBootstrap before fn", async () => {
|
||||
await using tmp = await bootstrapFixture()
|
||||
|
||||
await WithInstance.provide({
|
||||
@@ -67,16 +69,6 @@ test("CLI bootstrap runs InstanceBootstrap before callback", async () => {
|
||||
expect(existsSync(tmp.extra)).toBe(true)
|
||||
})
|
||||
|
||||
test("legacy Hono instance middleware runs InstanceBootstrap before next handler", async () => {
|
||||
await using tmp = await bootstrapFixture()
|
||||
const app = new Hono().use(InstanceMiddleware()).get("/probe", (c) => c.text("ok"))
|
||||
|
||||
const response = await app.request("/probe", { headers: { "x-opencode-directory": tmp.path } })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(existsSync(tmp.extra)).toBe(true)
|
||||
})
|
||||
|
||||
test("InstanceRuntime.reloadInstance runs InstanceBootstrap", async () => {
|
||||
await using tmp = await bootstrapFixture()
|
||||
|
||||
@@ -200,6 +200,35 @@ describe("Worktree", () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe("list", () => {
|
||||
it.live("uses parent folder name when worktree basename matches the primary worktree", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Worktree.Service
|
||||
const parent = path.join(path.dirname(dir), `${path.basename(dir)}-parent`)
|
||||
const target = path.join(parent, path.basename(dir))
|
||||
const branch = `same-basename-list-${Date.now()}`
|
||||
|
||||
yield* Effect.promise(() => fs.mkdir(parent, { recursive: true }))
|
||||
yield* Effect.promise(() => $`git worktree add -b ${branch} ${target}`.cwd(dir).quiet())
|
||||
|
||||
const list = yield* svc.list()
|
||||
const directory = yield* Effect.promise(() => fs.realpath(target).catch(() => target))
|
||||
|
||||
expect(list).toContainEqual({
|
||||
name: path.basename(parent),
|
||||
branch,
|
||||
directory: directory.toLowerCase(),
|
||||
})
|
||||
|
||||
yield* svc.remove({ directory: target })
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
describe("remove edge cases", () => {
|
||||
it.live("remove non-existent directory succeeds silently", () =>
|
||||
provideTmpdirInstance(
|
||||
|
||||
@@ -72,7 +72,9 @@ describe("HttpApi authorization middleware", () => {
|
||||
)
|
||||
|
||||
expect(missing.status).toBe(401)
|
||||
expect(missing.headers["www-authenticate"] ?? "").toContain("Basic")
|
||||
expect(badPassword.status).toBe(401)
|
||||
expect(badPassword.headers["www-authenticate"] ?? "").toContain("Basic")
|
||||
expect(good.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,501 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
|
||||
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
|
||||
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
|
||||
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { Server } from "../../src/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { ConfigProvider, Layer } from "effect"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const original = {
|
||||
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
|
||||
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
||||
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
|
||||
}
|
||||
|
||||
const methods = ["get", "post", "put", "delete", "patch"] as const
|
||||
let effectSpec: ReturnType<typeof OpenApi.fromApi> | undefined
|
||||
|
||||
function effectOpenApi() {
|
||||
return (effectSpec ??= OpenApi.fromApi(PublicApi))
|
||||
}
|
||||
|
||||
function app(input?: { password?: string; username?: string }) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
Flag.OPENCODE_SERVER_PASSWORD = input?.password
|
||||
Flag.OPENCODE_SERVER_USERNAME = input?.username
|
||||
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
ExperimentalHttpApiServer.routes.pipe(
|
||||
Layer.provide(
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_SERVER_PASSWORD: input?.password,
|
||||
OPENCODE_SERVER_USERNAME: input?.username,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
).handler
|
||||
return {
|
||||
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
|
||||
request(input: string | URL | Request, init?: RequestInit) {
|
||||
return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function openApiRouteKeys(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], unknown>>> }) {
|
||||
return Object.entries(spec.paths)
|
||||
.flatMap(([path, item]) =>
|
||||
methods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`),
|
||||
)
|
||||
.sort()
|
||||
}
|
||||
|
||||
function openApiParameters(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(spec.paths).flatMap(([path, item]) =>
|
||||
methods
|
||||
.filter((method) => item[method])
|
||||
.map((method) => [
|
||||
`${method.toUpperCase()} ${path}`,
|
||||
(item[method]?.parameters ?? [])
|
||||
.map(parameterKey)
|
||||
.filter((param) => param !== undefined)
|
||||
.sort(),
|
||||
]),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function openApiRequestBodies(spec: OpenApiSpec) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(spec.paths).flatMap(([path, item]) =>
|
||||
methods
|
||||
.filter((method) => item[method])
|
||||
.map((method) => [`${method.toUpperCase()} ${path}`, requestBodyKey(spec, item[method]?.requestBody)]),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
type OpenApiSpec = {
|
||||
components?: {
|
||||
schemas?: Record<string, unknown>
|
||||
}
|
||||
paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>>
|
||||
}
|
||||
|
||||
type OpenApiSchema = {
|
||||
$ref?: string
|
||||
allOf?: unknown[]
|
||||
anyOf?: unknown[]
|
||||
oneOf?: unknown[]
|
||||
properties?: Record<string, unknown>
|
||||
type?: string | string[]
|
||||
}
|
||||
|
||||
type Operation = {
|
||||
parameters?: unknown[]
|
||||
responses?: unknown
|
||||
requestBody?: unknown
|
||||
}
|
||||
|
||||
type RequestBody = {
|
||||
content?: Record<string, { schema?: OpenApiSchema }>
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
function parameterKey(param: unknown): string | undefined {
|
||||
if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return undefined
|
||||
if (typeof param.in !== "string" || typeof param.name !== "string") return undefined
|
||||
return `${param.in}:${param.name}:${"required" in param && param.required === true}:${stableSchema(
|
||||
"schema" in param ? param.schema : undefined,
|
||||
)}`
|
||||
}
|
||||
|
||||
function stableSchema(input: unknown): string {
|
||||
return JSON.stringify(sortSchema(input))
|
||||
}
|
||||
|
||||
function sortSchema(input: unknown): unknown {
|
||||
if (Array.isArray(input)) return input.map(sortSchema)
|
||||
if (!input || typeof input !== "object") return input
|
||||
return Object.fromEntries(
|
||||
Object.entries(input)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => [key, sortSchema(value)]),
|
||||
)
|
||||
}
|
||||
|
||||
function parameterSchema(input: {
|
||||
spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
|
||||
path: string
|
||||
method: (typeof methods)[number]
|
||||
name: string
|
||||
}): unknown {
|
||||
const param = input.spec.paths[input.path]?.[input.method]?.parameters?.find(
|
||||
(param) => !!param && typeof param === "object" && "name" in param && param.name === input.name,
|
||||
)
|
||||
if (!param || typeof param !== "object" || !("schema" in param)) return undefined
|
||||
return param.schema
|
||||
}
|
||||
|
||||
function requestBodyKey(spec: OpenApiSpec, body: unknown) {
|
||||
if (!body || typeof body !== "object" || !("content" in body)) return ""
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded above; test helper only needs this OpenAPI subset.
|
||||
const requestBody = body as RequestBody
|
||||
return JSON.stringify({
|
||||
required: requestBody.required === true,
|
||||
content: Object.entries(requestBody.content ?? {})
|
||||
.map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)] as const)
|
||||
.sort(([left], [right]) => left.localeCompare(right)),
|
||||
})
|
||||
}
|
||||
|
||||
function requestBodySchemaKind(spec: OpenApiSpec, schema: OpenApiSchema | undefined) {
|
||||
if (!schema) return ""
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `$ref` lookup is constrained to OpenAPI schema components in this test helper.
|
||||
const resolved = (
|
||||
schema.$ref ? spec.components?.schemas?.[schema.$ref.replace("#/components/schemas/", "")] : schema
|
||||
) as OpenApiSchema | undefined
|
||||
if (resolved?.properties) return "object"
|
||||
if (resolved?.anyOf ?? resolved?.oneOf ?? resolved?.allOf) return "object"
|
||||
return resolved?.type ?? schema.type ?? "inline"
|
||||
}
|
||||
|
||||
function responseContentTypes(input: {
|
||||
spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
|
||||
path: string
|
||||
method: (typeof methods)[number]
|
||||
status: string
|
||||
}) {
|
||||
const responses = input.spec.paths[input.path]?.[input.method]?.responses
|
||||
if (!responses || typeof responses !== "object" || !(input.status in responses)) return []
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded dynamic OpenAPI response lookup.
|
||||
const response = (responses as Record<string, unknown>)[input.status]
|
||||
if (!response || typeof response !== "object" || !("content" in response)) return []
|
||||
const content = (response as { content?: unknown }).content
|
||||
if (!content || typeof content !== "object") {
|
||||
return []
|
||||
}
|
||||
return Object.keys(content).sort()
|
||||
}
|
||||
|
||||
function authorization(username: string, password: string) {
|
||||
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
}
|
||||
|
||||
function fileUrl(input?: { directory?: string; token?: string }) {
|
||||
const url = new URL(`http://localhost${FilePaths.content}`)
|
||||
url.searchParams.set("path", "hello.txt")
|
||||
if (input?.directory) url.searchParams.set("directory", input.directory)
|
||||
if (input?.token) url.searchParams.set("auth_token", input.token)
|
||||
return url
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
|
||||
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("HttpApi server", () => {
|
||||
test("keeps Effect HttpApi behind the feature flag", () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false
|
||||
expect(Server.backend()).toEqual({ backend: "hono", reason: "stable" })
|
||||
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
expect(Server.backend()).toEqual({ backend: "effect-httpapi", reason: "env" })
|
||||
})
|
||||
|
||||
test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => {
|
||||
const honoRoutes = openApiRouteKeys(await Server.openapiHono())
|
||||
const effectRoutes = openApiRouteKeys(effectOpenApi())
|
||||
|
||||
expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([])
|
||||
expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([
|
||||
"GET /api/session",
|
||||
"GET /api/session/{sessionID}/context",
|
||||
"GET /api/session/{sessionID}/message",
|
||||
"POST /api/session/{sessionID}/compact",
|
||||
"POST /api/session/{sessionID}/prompt",
|
||||
"POST /api/session/{sessionID}/wait",
|
||||
])
|
||||
})
|
||||
|
||||
test("matches generated OpenAPI route parameters", async () => {
|
||||
const hono = openApiParameters(await Server.openapiHono())
|
||||
const effect = openApiParameters(effectOpenApi())
|
||||
|
||||
expect(
|
||||
Object.keys(hono)
|
||||
.filter((route) => JSON.stringify(hono[route]) !== JSON.stringify(effect[route]))
|
||||
.map((route) => ({ route, hono: hono[route], effect: effect[route] })),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("matches generated OpenAPI request body shape", async () => {
|
||||
const hono = openApiRequestBodies(await Server.openapiHono())
|
||||
const effect = openApiRequestBodies(effectOpenApi())
|
||||
|
||||
expect(
|
||||
Object.keys(hono)
|
||||
.filter((route) => hono[route] !== effect[route])
|
||||
.map((route) => ({ route, hono: hono[route], effect: effect[route] })),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("matches SDK-affecting query parameter schemas", async () => {
|
||||
const effect = effectOpenApi()
|
||||
|
||||
expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "roots" })).toEqual({
|
||||
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
|
||||
})
|
||||
expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "start" })).toEqual({
|
||||
type: "number",
|
||||
})
|
||||
expect(parameterSchema({ spec: effect, path: "/find/file", method: "get", name: "limit" })).toEqual({
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
maximum: 200,
|
||||
})
|
||||
expect(
|
||||
parameterSchema({ spec: effect, path: "/session/{sessionID}/message", method: "get", name: "limit" }),
|
||||
).toEqual({
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
})
|
||||
})
|
||||
|
||||
test("matches SDK-affecting request schema details", () => {
|
||||
const effect = effectOpenApi()
|
||||
const sessionUpdate = effect.paths["/session/{sessionID}"]?.patch?.requestBody
|
||||
const sessionUpdateSchema =
|
||||
typeof sessionUpdate === "object" && sessionUpdate && "content" in sessionUpdate
|
||||
? sessionUpdate.content?.["application/json"]?.schema
|
||||
: undefined
|
||||
const sessionUpdateProperties = sessionUpdateSchema?.properties as Record<string, OpenApiSchema> | undefined
|
||||
const time = sessionUpdateProperties?.time
|
||||
expect(time?.properties?.archived).toEqual({ type: "number" })
|
||||
})
|
||||
|
||||
test("documents event routes as server-sent events", () => {
|
||||
const effect = effectOpenApi()
|
||||
|
||||
expect(responseContentTypes({ spec: effect, path: "/event", method: "get", status: "200" })).toEqual([
|
||||
"text/event-stream",
|
||||
])
|
||||
expect(responseContentTypes({ spec: effect, path: "/global/event", method: "get", status: "200" })).toEqual([
|
||||
"text/event-stream",
|
||||
])
|
||||
})
|
||||
|
||||
test("allows requests when auth is disabled", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Bun.write(`${tmp.path}/hello.txt`, "hello")
|
||||
|
||||
const response = await app().request(fileUrl(), {
|
||||
headers: {
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ content: "hello" })
|
||||
})
|
||||
|
||||
test("provides instance context to bridged handlers", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const response = await app().request("/project/current", {
|
||||
headers: {
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ worktree: tmp.path })
|
||||
})
|
||||
|
||||
test("requires credentials when auth is enabled", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Bun.write(`${tmp.path}/hello.txt`, "hello")
|
||||
|
||||
const [missing, bad, good] = await Promise.all([
|
||||
app({ password: "secret" }).request(fileUrl(), {
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
}),
|
||||
app({ password: "secret" }).request(fileUrl(), {
|
||||
headers: {
|
||||
authorization: authorization("opencode", "wrong"),
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
}),
|
||||
app({ password: "secret" }).request(fileUrl(), {
|
||||
headers: {
|
||||
authorization: authorization("opencode", "secret"),
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
expect(missing.status).toBe(401)
|
||||
expect(bad.status).toBe(401)
|
||||
expect(good.status).toBe(200)
|
||||
})
|
||||
|
||||
test("requires credentials for root routes when auth is enabled", async () => {
|
||||
const server = app({ password: "secret" })
|
||||
const auth = { authorization: authorization("opencode", "secret") }
|
||||
const wrongAuth = { authorization: authorization("opencode", "wrong") }
|
||||
|
||||
const [missingHealth, goodHealth, missingConfig, wrongConfig, goodConfig] = await Promise.all([
|
||||
server.request(GlobalPaths.health),
|
||||
server.request(GlobalPaths.health, { headers: auth }),
|
||||
server.request(GlobalPaths.config),
|
||||
server.request(GlobalPaths.config, { headers: wrongAuth }),
|
||||
server.request(GlobalPaths.config, { headers: auth }),
|
||||
])
|
||||
|
||||
expect(missingHealth.status).toBe(401)
|
||||
expect(goodHealth.status).toBe(200)
|
||||
expect(missingConfig.status).toBe(401)
|
||||
expect(wrongConfig.status).toBe(401)
|
||||
expect(goodConfig.status).toBe(200)
|
||||
|
||||
const missingDispose = await server.request(GlobalPaths.dispose, { method: "POST" })
|
||||
expect(missingDispose.status).toBe(401)
|
||||
|
||||
const missingUpgrade = await server.request(GlobalPaths.upgrade, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "not-json",
|
||||
})
|
||||
expect(missingUpgrade.status).toBe(401)
|
||||
|
||||
const invalidUpgrade = await server.request(GlobalPaths.upgrade, {
|
||||
method: "POST",
|
||||
headers: { ...auth, "content-type": "application/json" },
|
||||
body: "not-json",
|
||||
})
|
||||
expect(invalidUpgrade.status).toBe(400)
|
||||
|
||||
const missingLog = await server.request(ControlPaths.log, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ service: "httpapi-auth-test", level: "info", message: "hello" }),
|
||||
})
|
||||
expect(missingLog.status).toBe(401)
|
||||
|
||||
const missingAuth = await server.request(ControlPaths.auth.replace(":providerID", "test"), {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "api", key: "secret" }),
|
||||
})
|
||||
expect(missingAuth.status).toBe(401)
|
||||
|
||||
const invalidAuth = await server.request(ControlPaths.auth.replace(":providerID", "test"), {
|
||||
method: "PUT",
|
||||
headers: { ...auth, "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "api" }),
|
||||
})
|
||||
expect(invalidAuth.status).toBe(400)
|
||||
})
|
||||
|
||||
test("accepts auth_token query credentials", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Bun.write(`${tmp.path}/hello.txt`, "hello")
|
||||
|
||||
const response = await app({ password: "secret" }).request(
|
||||
fileUrl({ token: Buffer.from("opencode:secret").toString("base64") }),
|
||||
{
|
||||
headers: {
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
test("selects instance from query before directory header", async () => {
|
||||
await using header = await tmpdir({ git: true })
|
||||
await using query = await tmpdir({ git: true })
|
||||
await Bun.write(`${header.path}/hello.txt`, "header")
|
||||
await Bun.write(`${query.path}/hello.txt`, "query")
|
||||
|
||||
const response = await app().request(fileUrl({ directory: query.path }), {
|
||||
headers: {
|
||||
"x-opencode-directory": header.path,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ content: "query" })
|
||||
})
|
||||
|
||||
test("serves global health from Effect HttpApi", async () => {
|
||||
const response = await app().request(`${GlobalPaths.health}?directory=/does/not/exist/opencode-test`)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ healthy: true })
|
||||
})
|
||||
|
||||
test("serves global event stream from Effect HttpApi", async () => {
|
||||
const response = await app().request(GlobalPaths.event)
|
||||
if (!response.body) throw new Error("missing event stream body")
|
||||
const reader = response.body.getReader()
|
||||
const chunk = await reader.read()
|
||||
await reader.cancel()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get("content-type")).toContain("text/event-stream")
|
||||
expect(new TextDecoder().decode(chunk.value)).toContain("server.connected")
|
||||
})
|
||||
|
||||
test("serves control log from Effect HttpApi", async () => {
|
||||
const response = await app().request(ControlPaths.log, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ service: "httpapi-test", level: "info", message: "hello" }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toBe(true)
|
||||
})
|
||||
|
||||
test("validates control auth without falling through to 404", async () => {
|
||||
const response = await app().request(ControlPaths.auth.replace(":providerID", "test"), {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "api" }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
test("validates global upgrade without invoking installers", async () => {
|
||||
const response = await app().request(GlobalPaths.upgrade, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "not-json",
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toMatchObject({ success: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { gunzipSync, inflateSync } from "node:zlib"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
// /config echoes the config back. Padding the config pushes the response body
|
||||
// well past the 1024 B threshold so we can observe compression behavior.
|
||||
function fatConfig() {
|
||||
const instructions: string[] = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
instructions.push(`padding-instruction-${i}-${"x".repeat(40)}`)
|
||||
}
|
||||
return {
|
||||
formatter: false,
|
||||
lsp: false,
|
||||
username: "compression-test-user",
|
||||
instructions,
|
||||
}
|
||||
}
|
||||
|
||||
describe("HttpApi compression", () => {
|
||||
describe("encodes responses", () => {
|
||||
test("gzips JSON when Accept-Encoding includes gzip and body exceeds threshold", async () => {
|
||||
await using tmp = await tmpdir({ config: fatConfig() })
|
||||
const response = await app().request("/config", {
|
||||
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" },
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get("content-encoding")).toBe("gzip")
|
||||
const compressed = new Uint8Array(await response.arrayBuffer())
|
||||
const decompressed = gunzipSync(compressed)
|
||||
const json = JSON.parse(new TextDecoder().decode(decompressed))
|
||||
expect(json).toMatchObject({ username: "compression-test-user" })
|
||||
expect(compressed.byteLength).toBeLessThan(decompressed.byteLength)
|
||||
})
|
||||
|
||||
test("uses deflate when only deflate is acceptable", async () => {
|
||||
await using tmp = await tmpdir({ config: fatConfig() })
|
||||
const response = await app().request("/config", {
|
||||
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "deflate" },
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get("content-encoding")).toBe("deflate")
|
||||
const compressed = new Uint8Array(await response.arrayBuffer())
|
||||
const decompressed = inflateSync(compressed)
|
||||
const json = JSON.parse(new TextDecoder().decode(decompressed))
|
||||
expect(json).toMatchObject({ username: "compression-test-user" })
|
||||
})
|
||||
|
||||
test("prefers gzip when both gzip and deflate are acceptable", async () => {
|
||||
await using tmp = await tmpdir({ config: fatConfig() })
|
||||
const response = await app().request("/config", {
|
||||
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip, deflate" },
|
||||
})
|
||||
expect(response.headers.get("content-encoding")).toBe("gzip")
|
||||
})
|
||||
|
||||
test("does not include the original Content-Length when compressed", async () => {
|
||||
await using tmp = await tmpdir({ config: fatConfig() })
|
||||
const response = await app().request("/config", {
|
||||
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" },
|
||||
})
|
||||
const compressed = new Uint8Array(await response.arrayBuffer())
|
||||
const declared = response.headers.get("content-length")
|
||||
// Either absent (transfer-encoding chunked) or matches the compressed length.
|
||||
if (declared !== null) expect(Number(declared)).toBe(compressed.byteLength)
|
||||
})
|
||||
})
|
||||
|
||||
describe("skips", () => {
|
||||
test("when no Accept-Encoding header is present", async () => {
|
||||
await using tmp = await tmpdir({ config: fatConfig() })
|
||||
const response = await app().request("/config", {
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
expect(response.headers.get("content-encoding")).toBeNull()
|
||||
})
|
||||
|
||||
test("when Accept-Encoding only allows unsupported encodings", async () => {
|
||||
await using tmp = await tmpdir({ config: fatConfig() })
|
||||
const response = await app().request("/config", {
|
||||
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "br" },
|
||||
})
|
||||
expect(response.headers.get("content-encoding")).toBeNull()
|
||||
})
|
||||
|
||||
test("when the response body is below the 1024-byte threshold", async () => {
|
||||
// A bare config produces a tiny response (~few hundred bytes).
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
const response = await app().request("/config", {
|
||||
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" },
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
const body = new Uint8Array(await response.arrayBuffer())
|
||||
expect(body.byteLength).toBeLessThan(1024)
|
||||
expect(response.headers.get("content-encoding")).toBeNull()
|
||||
})
|
||||
|
||||
test("HEAD requests", async () => {
|
||||
await using tmp = await tmpdir({ config: fatConfig() })
|
||||
const response = await app().request("/config", {
|
||||
method: "HEAD",
|
||||
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" },
|
||||
})
|
||||
expect(response.headers.get("content-encoding")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("streaming exclusions", () => {
|
||||
test("/event SSE is not compressed", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
const controller = new AbortController()
|
||||
const response = await app().request("/event", {
|
||||
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" },
|
||||
signal: controller.signal,
|
||||
})
|
||||
try {
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get("content-encoding")).toBeNull()
|
||||
} finally {
|
||||
controller.abort()
|
||||
await response.body?.cancel().catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
test("/global/event SSE is not compressed", async () => {
|
||||
const controller = new AbortController()
|
||||
const response = await app().request("/global/event", {
|
||||
headers: { "accept-encoding": "gzip" },
|
||||
signal: controller.signal,
|
||||
})
|
||||
try {
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get("content-encoding")).toBeNull()
|
||||
} finally {
|
||||
controller.abort()
|
||||
await response.body?.cancel().catch(() => {})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Server } from "../../src/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
@@ -9,10 +8,7 @@ import { waitGlobalBusEventPromise } from "./global-bus"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
|
||||
function app() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
@@ -24,7 +20,6 @@ async function waitDisposed(directory: string) {
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances } from "../fixture/fixture"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
const PREFLIGHT_HEADERS = {
|
||||
origin: "http://localhost:3000",
|
||||
"access-control-request-method": "POST",
|
||||
"access-control-request-headers": "content-type, x-opencode-directory",
|
||||
}
|
||||
|
||||
// effect-smol's HttpMiddleware.cors overwrites `Vary: Origin` with
|
||||
// `Vary: Access-Control-Request-Headers` on OPTIONS preflight responses
|
||||
// (the two share the same record key during the spread). With dynamic
|
||||
// origin echoing, missing Vary: Origin lets shared caches serve a preflight
|
||||
// cached for one origin against a different origin. corsVaryFixLayer
|
||||
// restores the merged form.
|
||||
describe("CORS preflight Vary header", () => {
|
||||
test("HTTP API backend preflight Vary contains Origin", async () => {
|
||||
const response = await app().request("/global/config", {
|
||||
method: "OPTIONS",
|
||||
headers: PREFLIGHT_HEADERS,
|
||||
})
|
||||
|
||||
expect([200, 204]).toContain(response.status)
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
|
||||
expect((response.headers.get("vary") ?? "").toLowerCase()).toContain("origin")
|
||||
})
|
||||
|
||||
test("HTTP API backend preflight Vary still preserves Access-Control-Request-Headers", async () => {
|
||||
const response = await app().request("/global/config", {
|
||||
method: "OPTIONS",
|
||||
headers: PREFLIGHT_HEADERS,
|
||||
})
|
||||
|
||||
const vary = (response.headers.get("vary") ?? "").toLowerCase()
|
||||
expect(vary).toContain("origin")
|
||||
expect(vary).toContain("access-control-request-headers")
|
||||
})
|
||||
|
||||
test("HTTP API backend does not duplicate Origin in Vary", async () => {
|
||||
const response = await app().request("/global/config", {
|
||||
method: "OPTIONS",
|
||||
headers: PREFLIGHT_HEADERS,
|
||||
})
|
||||
|
||||
const vary = response.headers.get("vary") ?? ""
|
||||
const originCount = vary
|
||||
.split(",")
|
||||
.map((s: string) => s.trim().toLowerCase())
|
||||
.filter((s: string) => s === "origin").length
|
||||
expect(originCount).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Config, Effect, Layer } from "effect"
|
||||
import { Config, ConfigProvider, Effect, Layer } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { Server } from "../../src/server/server"
|
||||
@@ -13,15 +13,12 @@ import { testEffect } from "../lib/effect"
|
||||
const testStateLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const original = {
|
||||
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
|
||||
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
||||
}
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
Flag.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
yield* Effect.promise(() => resetDatabase())
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
|
||||
await resetDatabase()
|
||||
}),
|
||||
@@ -63,12 +60,21 @@ describe("HttpApi CORS", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("adds CORS headers to legacy unauthorized responses", () =>
|
||||
it.live("adds CORS headers to unauthorized responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Effect.promise(async () =>
|
||||
Server.Legacy().app.request("/global/config", {
|
||||
headers: { origin: "https://app.opencode.ai" },
|
||||
}),
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
ExperimentalHttpApiServer.createRoutes().pipe(
|
||||
Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: "secret" }))),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
).handler
|
||||
const response = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request(new URL("/global/config", "http://localhost"), {
|
||||
headers: { origin: "https://app.opencode.ai" },
|
||||
}),
|
||||
ExperimentalHttpApiServer.context,
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { EventPaths } from "../../src/server/routes/instance/httpapi/event"
|
||||
@@ -9,11 +8,8 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
|
||||
function app(experimental = true) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
|
||||
return experimental ? Server.Default().app : Server.Legacy().app
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
async function readFirstChunk(response: Response) {
|
||||
@@ -36,13 +32,12 @@ async function readFirstEvent(response: Response) {
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("event HttpApi bridge", () => {
|
||||
test("serves event stream through experimental Effect route", async () => {
|
||||
describe("event HttpApi", () => {
|
||||
test("serves event stream", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } })
|
||||
|
||||
@@ -54,15 +49,11 @@ describe("event HttpApi bridge", () => {
|
||||
expect(await readFirstEvent(response)).toMatchObject({ type: "server.connected", properties: {} })
|
||||
})
|
||||
|
||||
test("matches legacy first event frame", async () => {
|
||||
test("serves the initial server connected event", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const legacy = await app(false).request(EventPaths.event, { headers })
|
||||
const effect = await app(true).request(EventPaths.event, { headers })
|
||||
const response = await app().request(EventPaths.event, { headers })
|
||||
|
||||
const legacyEvent = await readFirstEvent(legacy)
|
||||
const effectEvent = await readFirstEvent(effect)
|
||||
expect(effectEvent.type).toBe(legacyEvent.type)
|
||||
expect(effectEvent.properties).toEqual(legacyEvent.properties)
|
||||
expect(await readFirstEvent(response)).toMatchObject({ type: "server.connected", properties: {} })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { ConfigProvider, Effect, Layer } from "effect"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { parse } from "./assertions"
|
||||
@@ -56,16 +55,7 @@ function app(modules: Runtime, backend: Backend, options: CallOptions) {
|
||||
const username = options.auth?.username
|
||||
const password = options.auth?.password
|
||||
const cacheKey = `${backend}:${username ?? ""}:${password ?? ""}`
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect"
|
||||
Flag.OPENCODE_SERVER_PASSWORD = password
|
||||
Flag.OPENCODE_SERVER_USERNAME = username
|
||||
if (appCache[cacheKey]) return appCache[cacheKey]
|
||||
if (backend === "legacy") {
|
||||
const legacy = modules.Server.Legacy().app
|
||||
return (appCache[cacheKey] = {
|
||||
request: (input, init) => legacy.request(input, init),
|
||||
})
|
||||
}
|
||||
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
modules.ExperimentalHttpApiServer.routes.pipe(
|
||||
|
||||
@@ -22,7 +22,6 @@ process.env.OPENCODE_DB = exerciseDatabasePath
|
||||
Flag.OPENCODE_DB = exerciseDatabasePath
|
||||
|
||||
export const original = {
|
||||
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
|
||||
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
||||
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* End-to-end exerciser for the legacy Hono instance routes and the Effect HttpApi routes.
|
||||
* End-to-end exerciser for the Effect HttpApi routes.
|
||||
*
|
||||
* The goal is not to be a normal unit test file. This is a route-coverage and parity
|
||||
* harness we can run while deleting Hono: every public route should eventually have a
|
||||
* small scenario that proves the Effect route decodes requests, uses the right instance
|
||||
* context, mutates storage when expected, and returns a compatible response shape.
|
||||
* The goal is not to be a normal unit test file. This is a route-coverage harness:
|
||||
* every public route should have a small scenario that proves the route decodes
|
||||
* requests, uses the right instance context, mutates storage when expected, and
|
||||
* returns the expected response shape.
|
||||
*
|
||||
* The script intentionally isolates `OPENCODE_DB` before importing modules that touch
|
||||
* storage. Scenarios may create/delete sessions and reset the database after each run,
|
||||
@@ -15,8 +15,7 @@
|
||||
* - `.seeded(...)` creates typed per-scenario state using Effect helpers on `ctx`.
|
||||
* - `.at(...)` builds the request from that typed state.
|
||||
* - `.json(...)` / `.jsonEffect(...)` assert response shape and optional side effects.
|
||||
* - `.mutating()` tells parity mode to run Effect and Hono in separate isolated contexts
|
||||
* so destructive routes compare equivalent fresh setups instead of sharing one DB.
|
||||
* - `.mutating()` tells the runner to reset isolated state after destructive routes.
|
||||
*/
|
||||
import { Effect } from "effect"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
@@ -1263,7 +1262,6 @@ const main = Effect.gen(function* () {
|
||||
const options = parseOptions(Bun.argv.slice(2))
|
||||
const modules = yield* Effect.promise(() => runtime())
|
||||
const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi))
|
||||
const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapiHono()))
|
||||
const selected = selectedScenarios(options, scenarios)
|
||||
const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario)))
|
||||
const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario)))
|
||||
@@ -1274,7 +1272,7 @@ const main = Effect.gen(function* () {
|
||||
}
|
||||
}
|
||||
|
||||
printHeader(options, effectRoutes, honoRoutes, selected, missing, extra, {
|
||||
printHeader(options, effectRoutes, selected, missing, extra, {
|
||||
database: exerciseDatabasePath,
|
||||
global: exerciseGlobalRoot,
|
||||
})
|
||||
|
||||
@@ -14,7 +14,6 @@ export const color = {
|
||||
export function printHeader(
|
||||
options: Options,
|
||||
effectRoutes: string[],
|
||||
honoRoutes: string[],
|
||||
selected: Scenario[],
|
||||
missing: string[],
|
||||
extra: Scenario[],
|
||||
@@ -24,7 +23,7 @@ export function printHeader(
|
||||
console.log(`${color.dim}db=${paths.database}${color.reset}`)
|
||||
console.log(`${color.dim}global=${paths.global}${color.reset}`)
|
||||
console.log(
|
||||
`${color.dim}mode=${options.mode} selected=${selected.length} scenarioTimeout=${Duration.format(options.scenarioTimeout)} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length} onlyEffect=${effectRoutes.filter((route) => !honoRoutes.includes(route)).length} onlyHono=${honoRoutes.filter((route) => !effectRoutes.includes(route)).length}${color.reset}`,
|
||||
`${color.dim}mode=${options.mode} selected=${selected.length} scenarioTimeout=${Duration.format(options.scenarioTimeout)} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length}${color.reset}`,
|
||||
)
|
||||
console.log("")
|
||||
}
|
||||
|
||||
@@ -41,8 +41,7 @@ export function coverageResult(scenario: Scenario): Result {
|
||||
|
||||
export function parseOptions(args: string[]): Options {
|
||||
const mode = option(args, "--mode") ?? "effect"
|
||||
if (mode !== "effect" && mode !== "parity" && mode !== "coverage" && mode !== "auth")
|
||||
throw new Error(`invalid --mode ${mode}`)
|
||||
if (mode !== "effect" && mode !== "coverage" && mode !== "auth") throw new Error(`invalid --mode ${mode}`)
|
||||
return {
|
||||
mode,
|
||||
include: option(args, "--include"),
|
||||
|
||||
@@ -5,20 +5,10 @@ import type { Config } from "../../../src/config/config"
|
||||
import { ModelID, ProviderID } from "../../../src/provider/schema"
|
||||
import type { MessageV2 } from "../../../src/session/message-v2"
|
||||
import { MessageID, PartID } from "../../../src/session/schema"
|
||||
import { stable } from "./assertions"
|
||||
import { call, callAuthProbe } from "./backend"
|
||||
import { original } from "./environment"
|
||||
import { runtime } from "./runtime"
|
||||
import type {
|
||||
ActiveScenario,
|
||||
CallResult,
|
||||
Options,
|
||||
ProjectOptions,
|
||||
Result,
|
||||
Scenario,
|
||||
ScenarioContext,
|
||||
SeededContext,
|
||||
} from "./types"
|
||||
import type { ActiveScenario, Options, ProjectOptions, Result, Scenario, ScenarioContext, SeededContext } from "./types"
|
||||
|
||||
export function runScenario(options: Options) {
|
||||
return (scenario: Scenario) => {
|
||||
@@ -38,16 +28,6 @@ export function runScenario(options: Options) {
|
||||
function runActive(options: Options, scenario: ActiveScenario) {
|
||||
if (options.mode === "auth") return runAuth(scenario)
|
||||
|
||||
if (options.mode === "parity" && scenario.mutates && scenario.compare !== "none") {
|
||||
return Effect.gen(function* () {
|
||||
const effect = yield* runBackend(options, "effect", scenario)
|
||||
const legacy = yield* runBackend(options, "legacy", scenario)
|
||||
yield* trace(options, scenario, "compare start")
|
||||
yield* compare(scenario, effect, legacy)
|
||||
yield* trace(options, scenario, "compare done")
|
||||
})
|
||||
}
|
||||
|
||||
return withContext(options, scenario, "shared", (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* trace(options, scenario, "effect request start")
|
||||
@@ -56,17 +36,6 @@ function runActive(options: Options, scenario: ActiveScenario) {
|
||||
yield* trace(options, scenario, "effect expect start")
|
||||
yield* scenario.expect(ctx, ctx.state, effect)
|
||||
yield* trace(options, scenario, "effect expect done")
|
||||
if (options.mode === "parity" && scenario.compare !== "none") {
|
||||
yield* trace(options, scenario, "legacy request start")
|
||||
const legacy = yield* call("legacy", scenario, ctx)
|
||||
yield* trace(options, scenario, `legacy response ${legacy.status}`)
|
||||
yield* trace(options, scenario, "legacy expect start")
|
||||
yield* scenario.expect(ctx, ctx.state, legacy)
|
||||
yield* trace(options, scenario, "legacy expect done")
|
||||
yield* trace(options, scenario, "compare start")
|
||||
yield* compare(scenario, effect, legacy)
|
||||
yield* trace(options, scenario, "compare done")
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -74,38 +43,18 @@ function runActive(options: Options, scenario: ActiveScenario) {
|
||||
function runAuth(scenario: ActiveScenario) {
|
||||
return Effect.gen(function* () {
|
||||
const effect = yield* callAuthProbe("effect", scenario, "missing")
|
||||
const legacy = yield* callAuthProbe("legacy", scenario, "missing")
|
||||
if (scenario.auth === "protected") {
|
||||
if (effect.status !== 401) throw new Error(`effect auth expected 401, got ${effect.status}`)
|
||||
if (legacy.status !== 401) throw new Error(`legacy auth expected 401, got ${legacy.status}`)
|
||||
const effectAuthed = yield* callAuthProbe("effect", scenario, "valid")
|
||||
const legacyAuthed = yield* callAuthProbe("legacy", scenario, "valid")
|
||||
if (effectAuthed.status === 401) throw new Error("effect auth rejected valid credentials")
|
||||
if (legacyAuthed.status === 401) throw new Error("legacy auth rejected valid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
if (effect.status === 401) throw new Error("effect auth expected public access, got 401")
|
||||
if (legacy.status === 401) throw new Error("legacy auth expected public access, got 401")
|
||||
if (effect.timedOut) throw new Error("effect auth expected public access, probe timed out")
|
||||
if (legacy.timedOut) throw new Error("legacy auth expected public access, probe timed out")
|
||||
})
|
||||
}
|
||||
|
||||
function runBackend(options: Options, backend: "effect" | "legacy", scenario: ActiveScenario) {
|
||||
return withContext(options, scenario, backend, (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* trace(options, scenario, `${backend} request start`)
|
||||
const result = yield* call(backend, scenario, ctx)
|
||||
yield* trace(options, scenario, `${backend} response ${result.status}`)
|
||||
yield* trace(options, scenario, `${backend} expect start`)
|
||||
yield* scenario.expect(ctx, ctx.state, result)
|
||||
yield* trace(options, scenario, `${backend} expect done`)
|
||||
return result
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function withContext<A, E>(
|
||||
options: Options,
|
||||
scenario: ActiveScenario,
|
||||
@@ -300,19 +249,8 @@ function fakeLlmConfig(url: string): Partial<Config.Info> {
|
||||
}
|
||||
}
|
||||
|
||||
function compare(scenario: ActiveScenario, effect: CallResult, legacy: CallResult) {
|
||||
return Effect.sync(() => {
|
||||
if (effect.status !== legacy.status)
|
||||
throw new Error(`legacy returned ${legacy.status}, effect returned ${effect.status}`)
|
||||
if (scenario.compare === "status") return
|
||||
if (stable(effect.body) !== stable(legacy.body))
|
||||
throw new Error(`JSON parity mismatch\nlegacy: ${stable(legacy.body)}\neffect: ${stable(effect.body)}`)
|
||||
})
|
||||
}
|
||||
|
||||
const resetState = Effect.promise(async () => {
|
||||
const modules = await runtime()
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
|
||||
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
|
||||
await modules.disposeAllInstances()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user