diff --git a/bun.lock b/bun.lock index fa3924bf39..3f22ce5b47 100644 --- a/bun.lock +++ b/bun.lock @@ -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,7 +491,6 @@ "typescript": "catalog:", "vscode-languageserver-types": "3.17.5", "why-is-node-running": "3.2.2", - "zod-to-json-schema": "3.24.5", }, }, "packages/plugin": { @@ -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=="], diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 9aa4a568da..175c723c5f 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -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"), diff --git a/packages/opencode/package.json b/packages/opencode/package.json index e4f87a7943..3126914416 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -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:" diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 99b7f1b156..3697559549 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -1,5 +1,10 @@ # HttpApi migration +> Historical migration notes. PR #25667 deletes the Hono backend and removes the +> `OPENCODE_EXPERIMENTAL_HTTPAPI` backend selection path, so sections below that +> describe Hono as the default, SDK generation from `hono-openapi`, bridge parity, +> or dual-backend rollout are pre-deletion context rather than current guidance. + Plan for replacing instance Hono route implementations with Effect `HttpApi` while preserving behavior, OpenAPI, and SDK output during the transition. ## End State diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index 3250c166ab..3533706318 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -1,6 +1,4 @@ -import z from "zod" import { Schema } from "effect" -import { zodObject } from "@/util/effect-zod" export type Definition = { type: Type @@ -18,23 +16,6 @@ export function define( 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() diff --git a/packages/opencode/src/cli/cmd/generate.ts b/packages/opencode/src/cli/cmd/generate.ts index cb15b484e3..2555c3ad7b 100644 --- a/packages/opencode/src/cli/cmd/generate.ts +++ b/packages/opencode/src/cli/cmd/generate.ts @@ -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> } for (const item of Object.values(specs.paths)) { for (const method of ["get", "post", "put", "delete", "patch"] as const) { const operation = item[method] diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index 5acb5c827e..f5b1807462 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -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,18 @@ 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> export const WorkspaceListedInfo = Schema.Struct(Struct.omit(WorkspaceInfo.fields, ["id"])) .annotate({ identifier: "WorkspaceListedInfo" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) export type WorkspaceListedInfo = DeepMutable> 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 export type Target = diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index b30536ec02..e825eaddcf 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -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 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 export class SyncHttpError extends Schema.TaggedErrorClass()("WorkspaceSyncHttpError", { diff --git a/packages/opencode/src/server/adapter.bun.ts b/packages/opencode/src/server/adapter.bun.ts deleted file mode 100644 index b1f3bae27a..0000000000 --- a/packages/opencode/src/server/adapter.bun.ts +++ /dev/null @@ -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["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)), - } - }, -} diff --git a/packages/opencode/src/server/adapter.node.ts b/packages/opencode/src/server/adapter.node.ts deleted file mode 100644 index 55ced40f77..0000000000 --- a/packages/opencode/src/server/adapter.node.ts +++ /dev/null @@ -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((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 | undefined - return { - port: addr.port, - stop(close?: boolean) { - closing ??= new Promise((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), - } - }, -} diff --git a/packages/opencode/src/server/adapter.ts b/packages/opencode/src/server/adapter.ts deleted file mode 100644 index 7f4edd2c17..0000000000 --- a/packages/opencode/src/server/adapter.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Hono } from "hono" -import type { UpgradeWebSocket } from "hono/ws" - -export type FetchApp = { - fetch(request: Request): Response | Promise -} - -export type Opts = { - port: number - hostname: string -} - -export type Listener = { - port: number - stop: (close?: boolean) => Promise -} - -export interface Runtime { - upgradeWebSocket: UpgradeWebSocket - listen(opts: Opts): Promise -} - -export interface Adapter { - create(app: Hono): Runtime - createFetch(app: FetchApp): Omit -} diff --git a/packages/opencode/src/server/backend.ts b/packages/opencode/src/server/backend.ts deleted file mode 100644 index f456dc0be5..0000000000 --- a/packages/opencode/src/server/backend.ts +++ /dev/null @@ -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 - -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 { - 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", - } -} diff --git a/packages/opencode/src/server/error.ts b/packages/opencode/src/server/error.ts deleted file mode 100644 index 506e798187..0000000000 --- a/packages/opencode/src/server/error.ts +++ /dev/null @@ -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]])) -} diff --git a/packages/opencode/src/server/fence.ts b/packages/opencode/src/server/fence.ts deleted file mode 100644 index 1b8c42c899..0000000000 --- a/packages/opencode/src/server/fence.ts +++ /dev/null @@ -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)) - } -} diff --git a/packages/opencode/src/server/httpapi-server.node.ts b/packages/opencode/src/server/httpapi-server.node.ts index 5d29fae33f..d6c6cbd2fd 100644 --- a/packages/opencode/src/server/httpapi-server.node.ts +++ b/packages/opencode/src/server/httpapi-server.node.ts @@ -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 } diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts deleted file mode 100644 index 160d258796..0000000000 --- a/packages/opencode/src/server/middleware.ts +++ /dev/null @@ -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) -} diff --git a/packages/opencode/src/server/proxy.ts b/packages/opencode/src/server/proxy.ts deleted file mode 100644 index 069f308512..0000000000 --- a/packages/opencode/src/server/proxy.ts +++ /dev/null @@ -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" diff --git a/packages/opencode/src/server/routes/control/index.ts b/packages/opencode/src/server/routes/control/index.ts deleted file mode 100644 index c5b39abde1..0000000000 --- a/packages/opencode/src/server/routes/control/index.ts +++ /dev/null @@ -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) - }, - ) -} diff --git a/packages/opencode/src/server/routes/control/workspace.ts b/packages/opencode/src/server/routes/control/workspace.ts deleted file mode 100644 index 799294b261..0000000000 --- a/packages/opencode/src/server/routes/control/workspace.ts +++ /dev/null @@ -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 - 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), - }), - ), - ) - }, - ), -) diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts deleted file mode 100644 index da3614d228..0000000000 --- a/packages/opencode/src/server/routes/global.ts +++ /dev/null @@ -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) => () => void) { - return streamSSE(c, async (stream) => { - const q = new AsyncQueue() - 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 }) - }, - ), -) diff --git a/packages/opencode/src/server/routes/instance/AGENTS.md b/packages/opencode/src/server/routes/instance/AGENTS.md deleted file mode 100644 index c94fa64af7..0000000000 --- a/packages/opencode/src/server/routes/instance/AGENTS.md +++ /dev/null @@ -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. diff --git a/packages/opencode/src/server/routes/instance/config.ts b/packages/opencode/src/server/routes/instance/config.ts deleted file mode 100644 index 949734f81a..0000000000 --- a/packages/opencode/src/server/routes/instance/config.ts +++ /dev/null @@ -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), - } - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/event.ts b/packages/opencode/src/server/routes/instance/event.ts deleted file mode 100644 index aeb1da5393..0000000000 --- a/packages/opencode/src/server/routes/instance/event.ts +++ /dev/null @@ -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() - 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() - } - }) - }, - ) diff --git a/packages/opencode/src/server/routes/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts deleted file mode 100644 index 7e09fb9ad3..0000000000 --- a/packages/opencode/src/server/routes/instance/experimental.ts +++ /dev/null @@ -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 | 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() - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/file.ts b/packages/opencode/src/server/routes/instance/file.ts deleted file mode 100644 index d0e9ee6186..0000000000 --- a/packages/opencode/src/server/routes/instance/file.ts +++ /dev/null @@ -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() - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts index 8ab43f6654..3676fc1964 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts @@ -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, diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index eac579d7dc..495497ecb4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -80,7 +80,6 @@ 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" @@ -91,8 +90,7 @@ export const context = Context.makeUnsafe(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 }), ), diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts deleted file mode 100644 index b6bf8baa74..0000000000 --- a/packages/opencode/src/server/routes/instance/index.ts +++ /dev/null @@ -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 - - 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() - }), - ) -} diff --git a/packages/opencode/src/server/routes/instance/mcp.ts b/packages/opencode/src/server/routes/instance/mcp.ts deleted file mode 100644 index d5542f042b..0000000000 --- a/packages/opencode/src/server/routes/instance/mcp.ts +++ /dev/null @@ -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 - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts deleted file mode 100644 index 23707faf79..0000000000 --- a/packages/opencode/src/server/routes/instance/middleware.ts +++ /dev/null @@ -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() - }, - }) - }, - }) - } -} diff --git a/packages/opencode/src/server/routes/instance/permission.ts b/packages/opencode/src/server/routes/instance/permission.ts deleted file mode 100644 index c18f4734b4..0000000000 --- a/packages/opencode/src/server/routes/instance/permission.ts +++ /dev/null @@ -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() - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts deleted file mode 100644 index 3d8bb605bd..0000000000 --- a/packages/opencode/src/server/routes/instance/project.ts +++ /dev/null @@ -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 }) - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/provider.ts b/packages/opencode/src/server/routes/instance/provider.ts deleted file mode 100644 index 8ff7bc3103..0000000000 --- a/packages/opencode/src/server/routes/instance/provider.ts +++ /dev/null @@ -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 = {} - 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 - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/pty.ts b/packages/opencode/src/server/routes/instance/pty.ts deleted file mode 100644 index fb8d5e356d..0000000000 --- a/packages/opencode/src/server/routes/instance/pty.ts +++ /dev/null @@ -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() - }, - } - }), - ) -} diff --git a/packages/opencode/src/server/routes/instance/question.ts b/packages/opencode/src/server/routes/instance/question.ts deleted file mode 100644 index 51ecb48ccd..0000000000 --- a/packages/opencode/src/server/routes/instance/question.ts +++ /dev/null @@ -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 - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts deleted file mode 100644 index a16a92f927..0000000000 --- a/packages/opencode/src/server/routes/instance/session.ts +++ /dev/null @@ -1,1124 +0,0 @@ -import { Hono } from "hono" -import { stream } from "hono/streaming" -import { describeRoute, validator, resolver } from "hono-openapi" -import { SessionID, MessageID, PartID } from "@/session/schema" -import z from "zod" -import { Session } from "@/session/session" -import { MessageV2 } from "@/session/message-v2" -import { SessionPrompt } from "@/session/prompt" -import { SessionRunState } from "@/session/run-state" -import { SessionCompaction } from "@/session/compaction" -import { SessionRevert } from "@/session/revert" -import { SessionShare } from "@/share/session" -import { SessionStatus } from "@/session/status" -import { SessionSummary } from "@/session/summary" -import { Todo } from "@/session/todo" -import { Effect } from "effect" -import { Agent } from "@/agent/agent" -import { Snapshot } from "@/snapshot" -import { Command } from "@/command" -import * as Log from "@opencode-ai/core/util/log" -import { Permission } from "@/permission" -import { PermissionID } from "@/permission/schema" -import { ModelID, ProviderID } from "@/provider/schema" -import { errors } from "../../error" -import { lazy } from "@/util/lazy" -import { zodObject } from "@/util/effect-zod" -import { Bus } from "@/bus" -import { NamedError } from "@opencode-ai/core/util/error" -import { jsonRequest, runRequest } from "./trace" - -const log = Log.create({ service: "server" }) - -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 | undefined) { - if (value === undefined) return - return value === true || value === "true" -} - -export const SessionRoutes = lazy(() => - new Hono() - .get( - "/", - describeRoute({ - summary: "List sessions", - description: "Get a list of all OpenCode sessions, sorted by most recently updated.", - operationId: "session.list", - responses: { - 200: { - description: "List of sessions", - content: { - "application/json": { - schema: resolver(Session.Info.zod.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - directory: z.string().optional().meta({ description: "Filter sessions by directory" }), - // TODO: in 2.0 remove `scope` and `directory` and default - // to list all sessions for a project - scope: z.enum(["project"]).optional().meta({ description: "List all sessions for the current project" }), - path: z.string().optional().meta({ description: "Filter sessions by project-relative path" }), - 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)" }), - 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" }), - }), - ), - async (c) => { - const query = c.req.valid("query") - return c.json( - await runRequest( - "SessionRoutes.list", - c, - Session.Service.use((svc) => - svc.list({ - directory: query.scope === "project" ? undefined : query.directory, - path: query.path, - roots: queryBoolean(query.roots), - start: query.start, - search: query.search, - limit: query.limit, - }), - ), - ), - ) - }, - ) - .get( - "/status", - describeRoute({ - summary: "Get session status", - description: "Retrieve the current status of all sessions, including active, idle, and completed states.", - operationId: "session.status", - responses: { - 200: { - description: "Get session status", - content: { - "application/json": { - schema: resolver(z.record(z.string(), SessionStatus.Info.zod)), - }, - }, - }, - ...errors(400), - }, - }), - async (c) => - jsonRequest("SessionRoutes.status", c, function* () { - const svc = yield* SessionStatus.Service - return Object.fromEntries(yield* svc.list()) - }), - ) - .get( - "/:sessionID", - describeRoute({ - summary: "Get session", - description: "Retrieve detailed information about a specific OpenCode session.", - tags: ["Session"], - operationId: "session.get", - responses: { - 200: { - description: "Get session", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.GetInput.zod, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - return jsonRequest("SessionRoutes.get", c, function* () { - const session = yield* Session.Service - return yield* session.get(sessionID) - }) - }, - ) - .get( - "/:sessionID/children", - describeRoute({ - summary: "Get session children", - tags: ["Session"], - description: "Retrieve all child sessions that were forked from the specified parent session.", - operationId: "session.children", - responses: { - 200: { - description: "List of children", - content: { - "application/json": { - schema: resolver(Session.Info.zod.array()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.ChildrenInput.zod, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - return jsonRequest("SessionRoutes.children", c, function* () { - const session = yield* Session.Service - return yield* session.children(sessionID) - }) - }, - ) - .get( - "/:sessionID/todo", - describeRoute({ - summary: "Get session todos", - description: "Retrieve the todo list associated with a specific session, showing tasks and action items.", - operationId: "session.todo", - responses: { - 200: { - description: "Todo list", - content: { - "application/json": { - schema: resolver(Todo.Info.zod.array()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - return jsonRequest("SessionRoutes.todo", c, function* () { - const todo = yield* Todo.Service - return yield* todo.get(sessionID) - }) - }, - ) - .post( - "/", - describeRoute({ - summary: "Create session", - description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.", - operationId: "session.create", - responses: { - ...errors(400), - 200: { - description: "Successfully created session", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - }, - }), - validator("json", Session.CreateInput.zod), - async (c) => - jsonRequest("SessionRoutes.create", c, function* () { - const body = c.req.valid("json") ?? {} - const svc = yield* SessionShare.Service - return yield* svc.create(body) - }), - ) - .delete( - "/:sessionID", - describeRoute({ - summary: "Delete session", - description: "Delete a session and permanently remove all associated data, including messages and history.", - operationId: "session.delete", - responses: { - 200: { - description: "Successfully deleted session", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.RemoveInput.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.delete", c, function* () { - const sessionID = c.req.valid("param").sessionID - const svc = yield* Session.Service - yield* svc.remove(sessionID) - return true - }), - ) - .patch( - "/:sessionID", - describeRoute({ - summary: "Update session", - description: "Update properties of an existing session, such as title or other metadata.", - operationId: "session.update", - responses: { - 200: { - description: "Successfully updated session", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator( - "json", - z.object({ - title: z.string().optional(), - permission: Permission.Ruleset.zod.optional(), - time: z - .object({ - archived: z.number().optional(), - }) - .optional(), - }), - ), - async (c) => - jsonRequest("SessionRoutes.update", c, function* () { - const sessionID = c.req.valid("param").sessionID - const updates = c.req.valid("json") - const session = yield* Session.Service - const current = yield* session.get(sessionID) - - if (updates.title !== undefined) { - yield* session.setTitle({ sessionID, title: updates.title }) - } - if (updates.permission !== undefined) { - yield* session.setPermission({ - sessionID, - permission: Permission.merge(current.permission ?? [], updates.permission), - }) - } - if (updates.time?.archived !== undefined) { - yield* session.setArchived({ sessionID, time: updates.time.archived }) - } - - return yield* session.get(sessionID) - }), - ) - // TODO(v2): remove this dedicated route and rely on the normal `/init` command flow. - .post( - "/:sessionID/init", - describeRoute({ - summary: "Initialize session", - description: - "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", - operationId: "session.init", - responses: { - 200: { - description: "200", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator( - "json", - z.object({ - modelID: ModelID.zod, - providerID: ProviderID.zod, - messageID: MessageID.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.init", c, function* () { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const svc = yield* SessionPrompt.Service - yield* svc.command({ - sessionID, - messageID: body.messageID, - model: body.providerID + "/" + body.modelID, - command: Command.Default.INIT, - arguments: "", - }) - return true - }), - ) - .post( - "/:sessionID/fork", - describeRoute({ - summary: "Fork session", - description: "Create a new session by forking an existing session at a specific message point.", - operationId: "session.fork", - responses: { - 200: { - description: "200", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", zodObject(Session.ForkInput).omit({ sessionID: true })), - async (c) => - jsonRequest("SessionRoutes.fork", c, function* () { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") as { messageID?: MessageID } - const svc = yield* Session.Service - return yield* svc.fork({ ...body, sessionID }) - }), - ) - .post( - "/:sessionID/abort", - describeRoute({ - summary: "Abort session", - description: "Abort an active session and stop any ongoing AI processing or command execution.", - operationId: "session.abort", - responses: { - 200: { - description: "Aborted session", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.abort", c, function* () { - const svc = yield* SessionPrompt.Service - yield* svc.cancel(c.req.valid("param").sessionID) - return true - }), - ) - .post( - "/:sessionID/share", - describeRoute({ - summary: "Share session", - description: "Create a shareable link for a session, allowing others to view the conversation.", - operationId: "session.share", - responses: { - 200: { - description: "Successfully shared session", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.share", c, function* () { - const sessionID = c.req.valid("param").sessionID - const share = yield* SessionShare.Service - const session = yield* Session.Service - yield* share.share(sessionID) - return yield* session.get(sessionID) - }), - ) - .get( - "/:sessionID/diff", - describeRoute({ - summary: "Get message diff", - description: "Get the file changes (diff) that resulted from a specific user message in the session.", - operationId: "session.diff", - responses: { - 200: { - description: "Successfully retrieved diff", - content: { - "application/json": { - schema: resolver(Snapshot.FileDiff.zod.array()), - }, - }, - }, - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("query", zodObject(SessionSummary.DiffInput).omit({ sessionID: true })), - async (c) => - jsonRequest("SessionRoutes.diff", c, function* () { - const query = c.req.valid("query") as Omit - const params = c.req.valid("param") - const summary = yield* SessionSummary.Service - return yield* summary.diff({ - sessionID: params.sessionID, - messageID: query.messageID, - }) - }), - ) - .delete( - "/:sessionID/share", - describeRoute({ - summary: "Unshare session", - description: "Remove the shareable link for a session, making it private again.", - operationId: "session.unshare", - responses: { - 200: { - description: "Successfully unshared session", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.unshare", c, function* () { - const sessionID = c.req.valid("param").sessionID - const share = yield* SessionShare.Service - const session = yield* Session.Service - yield* share.unshare(sessionID) - return yield* session.get(sessionID) - }), - ) - .post( - "/:sessionID/summarize", - describeRoute({ - summary: "Summarize session", - description: "Generate a concise summary of the session using AI compaction to preserve key information.", - operationId: "session.summarize", - responses: { - 200: { - description: "Summarized session", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator( - "json", - z.object({ - providerID: ProviderID.zod, - modelID: ModelID.zod, - auto: z.boolean().optional().default(false), - }), - ), - async (c) => - jsonRequest("SessionRoutes.summarize", c, function* () { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const session = yield* Session.Service - const revert = yield* SessionRevert.Service - const compact = yield* SessionCompaction.Service - const prompt = yield* SessionPrompt.Service - const agent = yield* Agent.Service - - yield* revert.cleanup(yield* session.get(sessionID)) - const msgs = yield* session.messages({ sessionID }) - const defaultAgent = yield* agent.defaultAgent() - let currentAgent = defaultAgent - for (let i = msgs.length - 1; i >= 0; i--) { - const info = msgs[i].info - if (info.role === "user") { - currentAgent = info.agent || defaultAgent - break - } - } - - yield* compact.create({ - sessionID, - agent: currentAgent, - model: { - providerID: body.providerID, - modelID: body.modelID, - }, - auto: body.auto, - }) - yield* prompt.loop({ sessionID }) - return true - }), - ) - .get( - "/:sessionID/message", - describeRoute({ - summary: "Get session messages", - description: "Retrieve all messages in a session, including user prompts and AI responses.", - operationId: "session.messages", - responses: { - 200: { - description: "List of messages", - content: { - "application/json": { - schema: resolver(MessageV2.WithParts.zod.array()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator( - "query", - z - .object({ - limit: z.coerce - .number() - .int() - .min(0) - .optional() - .meta({ description: "Maximum number of messages to return" }), - before: z - .string() - .optional() - .meta({ description: "Opaque cursor for loading older messages" }) - .refine( - (value) => { - if (!value) return true - try { - MessageV2.cursor.decode(value) - return true - } catch { - return false - } - }, - { message: "Invalid cursor" }, - ), - }) - .refine((value) => !value.before || value.limit !== undefined, { - message: "before requires limit", - path: ["before"], - }), - ), - async (c) => { - const query = c.req.valid("query") - const sessionID = c.req.valid("param").sessionID - if (query.limit === undefined || query.limit === 0) { - const messages = await runRequest( - "SessionRoutes.messages", - c, - Effect.gen(function* () { - const session = yield* Session.Service - yield* session.get(sessionID) - return yield* session.messages({ sessionID }) - }), - ) - return c.json(messages) - } - - const page = await MessageV2.page({ - sessionID, - limit: query.limit, - before: query.before, - }) - if (page.cursor) { - const url = new URL(c.req.url) - url.searchParams.set("limit", query.limit.toString()) - url.searchParams.set("before", page.cursor) - c.header("Access-Control-Expose-Headers", "Link, X-Next-Cursor") - c.header("Link", `<${url.toString()}>; rel="next"`) - c.header("X-Next-Cursor", page.cursor) - } - return c.json(page.items) - }, - ) - .get( - "/:sessionID/message/:messageID", - describeRoute({ - summary: "Get message", - description: "Retrieve a specific message from a session by its message ID.", - operationId: "session.message", - responses: { - 200: { - description: "Message", - content: { - "application/json": { - schema: resolver( - z.object({ - info: MessageV2.Info.zod, - parts: MessageV2.Part.zod.array(), - }), - ), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - }), - ), - async (c) => { - const params = c.req.valid("param") - const message = await MessageV2.get({ - sessionID: params.sessionID, - messageID: params.messageID, - }) - return c.json(message) - }, - ) - .delete( - "/:sessionID/message/:messageID", - describeRoute({ - summary: "Delete message", - description: - "Permanently delete a specific message (and all of its parts) from a session. This does not revert any file changes that may have been made while processing the message.", - operationId: "session.deleteMessage", - responses: { - 200: { - description: "Successfully deleted message", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.deleteMessage", c, function* () { - const params = c.req.valid("param") - const state = yield* SessionRunState.Service - const session = yield* Session.Service - yield* state.assertNotBusy(params.sessionID) - yield* session.removeMessage({ - sessionID: params.sessionID, - messageID: params.messageID, - }) - return true - }), - ) - .delete( - "/:sessionID/message/:messageID/part/:partID", - describeRoute({ - description: "Delete a part from a message", - operationId: "part.delete", - responses: { - 200: { - description: "Successfully deleted part", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - partID: PartID.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.deletePart", c, function* () { - const params = c.req.valid("param") - const svc = yield* Session.Service - yield* svc.removePart({ - sessionID: params.sessionID, - messageID: params.messageID, - partID: params.partID, - }) - return true - }), - ) - .patch( - "/:sessionID/message/:messageID/part/:partID", - describeRoute({ - description: "Update a part in a message", - operationId: "part.update", - responses: { - 200: { - description: "Successfully updated part", - content: { - "application/json": { - schema: resolver(MessageV2.Part.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - partID: PartID.zod, - }), - ), - validator("json", MessageV2.Part.zod), - async (c) => { - const params = c.req.valid("param") - const body = c.req.valid("json") - if (body.id !== params.partID || body.messageID !== params.messageID || body.sessionID !== params.sessionID) { - throw new Error( - `Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`, - ) - } - return jsonRequest("SessionRoutes.updatePart", c, function* () { - const svc = yield* Session.Service - return yield* svc.updatePart(body) - }) - }, - ) - .post( - "/:sessionID/message", - describeRoute({ - summary: "Send message", - description: "Create and send a new message to a session, streaming the AI response.", - operationId: "session.prompt", - responses: { - 200: { - description: "Created message", - content: { - "application/json": { - schema: resolver( - z.object({ - info: MessageV2.Assistant.zod, - parts: MessageV2.Part.zod.array(), - }), - ), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", zodObject(SessionPrompt.PromptInput).omit({ sessionID: true })), - async (c) => { - c.status(200) - c.header("Content-Type", "application/json") - return stream(c, async (stream) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const msg = await runRequest( - "SessionRoutes.prompt", - c, - SessionPrompt.Service.use((svc) => - svc.prompt({ ...body, sessionID } as unknown as SessionPrompt.PromptInput), - ), - ) - void stream.write(JSON.stringify(msg)) - }) - }, - ) - .post( - "/:sessionID/prompt_async", - describeRoute({ - summary: "Send async message", - description: - "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", - operationId: "session.prompt_async", - responses: { - 204: { - description: "Prompt accepted", - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", zodObject(SessionPrompt.PromptInput).omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - void runRequest( - "SessionRoutes.prompt_async", - c, - SessionPrompt.Service.use((svc) => - svc.prompt({ ...body, sessionID } as unknown as SessionPrompt.PromptInput), - ), - ).catch((err) => { - log.error("prompt_async failed", { sessionID, error: err }) - void Bus.publish(Session.Event.Error, { - sessionID, - error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(), - }) - }) - - return c.body(null, 204) - }, - ) - .post( - "/:sessionID/command", - describeRoute({ - summary: "Send command", - description: "Send a new command to a session for execution by the AI assistant.", - operationId: "session.command", - responses: { - 200: { - description: "Created message", - content: { - "application/json": { - schema: resolver( - z.object({ - info: MessageV2.Assistant.zod, - parts: MessageV2.Part.zod.array(), - }), - ), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", zodObject(SessionPrompt.CommandInput).omit({ sessionID: true })), - async (c) => - jsonRequest("SessionRoutes.command", c, function* () { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") as Omit - const svc = yield* SessionPrompt.Service - return yield* svc.command({ ...body, sessionID }) - }), - ) - .post( - "/:sessionID/shell", - describeRoute({ - summary: "Run shell command", - description: "Execute a shell command within the session context and return the AI's response.", - operationId: "session.shell", - responses: { - 200: { - description: "Created message", - content: { - "application/json": { - schema: resolver(MessageV2.WithParts.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", zodObject(SessionPrompt.ShellInput).omit({ sessionID: true })), - async (c) => - jsonRequest("SessionRoutes.shell", c, function* () { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") as Omit - const svc = yield* SessionPrompt.Service - return yield* svc.shell({ ...body, sessionID }) - }), - ) - .post( - "/:sessionID/revert", - describeRoute({ - summary: "Revert message", - description: "Revert a specific message in a session, undoing its effects and restoring the previous state.", - operationId: "session.revert", - responses: { - 200: { - description: "Updated session", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", zodObject(SessionRevert.RevertInput).omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") as Omit - log.info("revert", body) - return jsonRequest("SessionRoutes.revert", c, function* () { - const svc = yield* SessionRevert.Service - return yield* svc.revert({ sessionID, ...body }) - }) - }, - ) - .post( - "/:sessionID/unrevert", - describeRoute({ - summary: "Restore reverted messages", - description: "Restore all previously reverted messages in a session.", - operationId: "session.unrevert", - responses: { - 200: { - description: "Updated session", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.unrevert", c, function* () { - const sessionID = c.req.valid("param").sessionID - const svc = yield* SessionRevert.Service - return yield* svc.unrevert({ sessionID }) - }), - ) - .post( - "/:sessionID/permissions/:permissionID", - describeRoute({ - summary: "Respond to permission", - deprecated: true, - description: "Approve or deny a permission request from the AI assistant.", - operationId: "permission.respond", - responses: { - 200: { - description: "Permission processed successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - permissionID: PermissionID.zod, - }), - ), - validator("json", z.object({ response: Permission.Reply.zod })), - async (c) => - jsonRequest("SessionRoutes.permissionRespond", c, function* () { - const params = c.req.valid("param") - const svc = yield* Permission.Service - yield* svc.reply({ - requestID: params.permissionID, - reply: c.req.valid("json").response, - }) - return true - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/sync.ts b/packages/opencode/src/server/routes/instance/sync.ts deleted file mode 100644 index 9894d8c8ee..0000000000 --- a/packages/opencode/src/server/routes/instance/sync.ts +++ /dev/null @@ -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) - }, - ), -) diff --git a/packages/opencode/src/server/routes/instance/trace.ts b/packages/opencode/src/server/routes/instance/trace.ts deleted file mode 100644 index 4c7119ef3a..0000000000 --- a/packages/opencode/src/server/routes/instance/trace.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Context } from "hono" -import { Effect } from "effect" -import { AppRuntime } from "@/effect/app-runtime" - -type AppEnv = Parameters[0] extends Effect.Effect ? 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.` 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 - } -} - -// 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 { - const attributes: Record = { - "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(name: string, c: Context, effect: Effect.Effect) { - return AppRuntime.runPromise(effect.pipe(Effect.withSpan(name, { attributes: requestAttributes(c) }))) -} - -export async function jsonRequest( - name: string, - c: C, - effect: (c: C) => Effect.gen.Return, -) { - return c.json( - await runRequest( - name, - c, - Effect.gen(() => effect(c)), - ), - ) -} diff --git a/packages/opencode/src/server/routes/instance/tui.ts b/packages/opencode/src/server/routes/instance/tui.ts deleted file mode 100644 index a7a0c9cbdc..0000000000 --- a/packages/opencode/src/server/routes/instance/tui.ts +++ /dev/null @@ -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, - ) - 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 } - // 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), -) diff --git a/packages/opencode/src/server/routes/ui.ts b/packages/opencode/src/server/routes/ui.ts deleted file mode 100644 index 608525b63a..0000000000 --- a/packages/opencode/src/server/routes/ui.ts +++ /dev/null @@ -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)) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index bc09667c29..67a728b801 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -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(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 { - 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 | 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 { - 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 { - 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 | undefined let stopPromise: Promise | 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) diff --git a/packages/opencode/src/server/shared/tui-control.ts b/packages/opencode/src/server/shared/tui-control.ts index 40aaf04a96..03b62bbab0 100644 --- a/packages/opencode/src/server/shared/tui-control.ts +++ b/packages/opencode/src/server/shared/tui-control.ts @@ -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 +export type TuiRequest = Schema.Schema.Type const request = new AsyncQueue() const response = new AsyncQueue() diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts deleted file mode 100644 index 0972875305..0000000000 --- a/packages/opencode/src/server/workspace.ts +++ /dev/null @@ -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) - } -} diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2930dbaeb3..85f6124505 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -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 - -// 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 // ── Prompt input schemas ───────────────────────────────────────────────────── // diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 62b30ccf9a..05d9727891 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -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() diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 27d2e41cd6..a6599debdf 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -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 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 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 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 export const NotGitError = NamedError.create( diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 8333d9573f..3c4837e318 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -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() }) }) diff --git a/packages/opencode/test/project/instance-bootstrap-regression.test.ts b/packages/opencode/test/project/instance-bootstrap-regression.test.ts deleted file mode 100644 index c01450549b..0000000000 --- a/packages/opencode/test/project/instance-bootstrap-regression.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -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. - -afterEach(async () => { - await disposeAllInstances() -}) - -async function bootstrapFixture() { - return tmpdir({ - init: async (dir) => { - const marker = path.join(dir, "config-hook-fired") - const pluginFile = path.join(dir, "plugin.ts") - await Bun.write( - pluginFile, - [ - `const MARKER = ${JSON.stringify(marker)}`, - "export default async () => ({", - " config: async () => {", - ' await Bun.write(MARKER, "ran")', - " },", - "})", - "", - ].join("\n"), - ) - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - plugin: [pathToFileURL(pluginFile).href], - }), - ) - return marker - }, - }) -} - -test("Instance.provide runs InstanceBootstrap before fn (boundary invariant)", async () => { - await using tmp = await bootstrapFixture() - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => "ok", - }) - - expect(existsSync(tmp.extra)).toBe(true) -}) - -test("CLI bootstrap runs InstanceBootstrap before callback", async () => { - await using tmp = await bootstrapFixture() - - await cliBootstrap(tmp.path, async () => "ok") - - 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() - - await InstanceRuntime.reloadInstance({ directory: tmp.path }) - - expect(existsSync(tmp.extra)).toBe(true) -}) diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts deleted file mode 100644 index 8476ece0e7..0000000000 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ /dev/null @@ -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 | 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>> }) { - return Object.entries(spec.paths) - .flatMap(([path, item]) => - methods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`), - ) - .sort() -} - -function openApiParameters(spec: { paths: Record>> }) { - 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 - } - paths: Record>> -} - -type OpenApiSchema = { - $ref?: string - allOf?: unknown[] - anyOf?: unknown[] - oneOf?: unknown[] - properties?: Record - type?: string | string[] -} - -type Operation = { - parameters?: unknown[] - responses?: unknown - requestBody?: unknown -} - -type RequestBody = { - content?: Record - 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>> } - 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>> } - 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)[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 | 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 }) - }) -}) diff --git a/packages/opencode/test/server/httpapi-compression.test.ts b/packages/opencode/test/server/httpapi-compression.test.ts index a7e119bd87..4fcf8864fe 100644 --- a/packages/opencode/test/server/httpapi-compression.test.ts +++ b/packages/opencode/test/server/httpapi-compression.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" import { gunzipSync, inflateSync } from "node:zlib" -import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" import { Server } from "../../src/server/server" import { resetDatabase } from "../fixture/db" @@ -8,16 +7,12 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI - afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) function app() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true return Server.Default().app } diff --git a/packages/opencode/test/server/httpapi-config.test.ts b/packages/opencode/test/server/httpapi-config.test.ts index 16e8975ea1..fbbe29a9c2 100644 --- a/packages/opencode/test/server/httpapi-config.test.ts +++ b/packages/opencode/test/server/httpapi-config.test.ts @@ -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,8 @@ 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 +21,6 @@ async function waitDisposed(directory: string) { } afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-cors-vary.test.ts b/packages/opencode/test/server/httpapi-cors-vary.test.ts index edec8e9f76..74a09cb253 100644 --- a/packages/opencode/test/server/httpapi-cors-vary.test.ts +++ b/packages/opencode/test/server/httpapi-cors-vary.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, test } from "bun:test" -import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" import { Server } from "../../src/server/server" import { resetDatabase } from "../fixture/db" @@ -7,17 +6,13 @@ import { disposeAllInstances } from "../fixture/fixture" void Log.init({ print: false }) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI - afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) -function app(experimental: boolean) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app +function app() { + return Server.Default().app } const PREFLIGHT_HEADERS = { @@ -33,19 +28,8 @@ const PREFLIGHT_HEADERS = { // cached for one origin against a different origin. corsVaryFixLayer // restores the merged form. describe("CORS preflight Vary header", () => { - test("Hono backend preflight Vary contains Origin", async () => { - const response = await app(false).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 contains Origin", async () => { - const response = await app(true).request("/global/config", { + const response = await app().request("/global/config", { method: "OPTIONS", headers: PREFLIGHT_HEADERS, }) @@ -56,7 +40,7 @@ describe("CORS preflight Vary header", () => { }) test("HTTP API backend preflight Vary still preserves Access-Control-Request-Headers", async () => { - const response = await app(true).request("/global/config", { + const response = await app().request("/global/config", { method: "OPTIONS", headers: PREFLIGHT_HEADERS, }) @@ -67,7 +51,7 @@ describe("CORS preflight Vary header", () => { }) test("HTTP API backend does not duplicate Origin in Vary", async () => { - const response = await app(true).request("/global/config", { + const response = await app().request("/global/config", { method: "OPTIONS", headers: PREFLIGHT_HEADERS, }) @@ -75,8 +59,8 @@ describe("CORS preflight Vary header", () => { const vary = response.headers.get("vary") ?? "" const originCount = vary .split(",") - .map((s) => s.trim().toLowerCase()) - .filter((s) => s === "origin").length + .map((s: string) => s.trim().toLowerCase()) + .filter((s: string) => s === "origin").length expect(originCount).toBe(1) }) }) diff --git a/packages/opencode/test/server/httpapi-cors.test.ts b/packages/opencode/test/server/httpapi-cors.test.ts index 8d7e95dfbf..6c83b00d53 100644 --- a/packages/opencode/test/server/httpapi-cors.test.ts +++ b/packages/opencode/test/server/httpapi-cors.test.ts @@ -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) diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts index 940efed9c3..df716ed096 100644 --- a/packages/opencode/test/server/httpapi-event.test.ts +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -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: {} }) }) }) diff --git a/packages/opencode/test/server/httpapi-exercise/backend.ts b/packages/opencode/test/server/httpapi-exercise/backend.ts index f66d15a3ee..fac5f699c3 100644 --- a/packages/opencode/test/server/httpapi-exercise/backend.ts +++ b/packages/opencode/test/server/httpapi-exercise/backend.ts @@ -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( diff --git a/packages/opencode/test/server/httpapi-exercise/environment.ts b/packages/opencode/test/server/httpapi-exercise/environment.ts index 7962f7df94..9d3eaa0e53 100644 --- a/packages/opencode/test/server/httpapi-exercise/environment.ts +++ b/packages/opencode/test/server/httpapi-exercise/environment.ts @@ -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, } diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index bc876d9f07..4560973abe 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -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, }) diff --git a/packages/opencode/test/server/httpapi-exercise/report.ts b/packages/opencode/test/server/httpapi-exercise/report.ts index cf674f1db9..7e79e972cb 100644 --- a/packages/opencode/test/server/httpapi-exercise/report.ts +++ b/packages/opencode/test/server/httpapi-exercise/report.ts @@ -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("") } diff --git a/packages/opencode/test/server/httpapi-exercise/routing.ts b/packages/opencode/test/server/httpapi-exercise/routing.ts index b2ca3eb5af..f1f9bddb4f 100644 --- a/packages/opencode/test/server/httpapi-exercise/routing.ts +++ b/packages/opencode/test/server/httpapi-exercise/routing.ts @@ -41,7 +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") + if (mode !== "effect" && mode !== "coverage" && mode !== "auth") throw new Error(`invalid --mode ${mode}`) return { mode, diff --git a/packages/opencode/test/server/httpapi-exercise/runner.ts b/packages/opencode/test/server/httpapi-exercise/runner.ts index c735cc125c..0263ee697b 100644 --- a/packages/opencode/test/server/httpapi-exercise/runner.ts +++ b/packages/opencode/test/server/httpapi-exercise/runner.ts @@ -5,13 +5,11 @@ 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, @@ -38,16 +36,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 +44,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 +51,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( options: Options, scenario: ActiveScenario, @@ -300,19 +257,8 @@ function fakeLlmConfig(url: string): Partial { } } -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() diff --git a/packages/opencode/test/server/httpapi-exercise/runtime.ts b/packages/opencode/test/server/httpapi-exercise/runtime.ts index ef1c868208..7163cf0c5a 100644 --- a/packages/opencode/test/server/httpapi-exercise/runtime.ts +++ b/packages/opencode/test/server/httpapi-exercise/runtime.ts @@ -1,7 +1,6 @@ export type Runtime = { PublicApi: (typeof import("../../../src/server/routes/instance/httpapi/public"))["PublicApi"] ExperimentalHttpApiServer: (typeof import("../../../src/server/routes/instance/httpapi/server"))["ExperimentalHttpApiServer"] - Server: (typeof import("../../../src/server/server"))["Server"] AppLayer: (typeof import("../../../src/effect/app-runtime"))["AppLayer"] InstanceRef: (typeof import("../../../src/effect/instance-ref"))["InstanceRef"] Instance: (typeof import("../../../src/project/instance"))["Instance"] @@ -22,7 +21,6 @@ export function runtime() { return (runtimePromise ??= (async () => { const publicApi = await import("../../../src/server/routes/instance/httpapi/public") const httpApiServer = await import("../../../src/server/routes/instance/httpapi/server") - const server = await import("../../../src/server/server") const appRuntime = await import("../../../src/effect/app-runtime") const instanceRef = await import("../../../src/effect/instance-ref") const instance = await import("../../../src/project/instance") @@ -37,7 +35,6 @@ export function runtime() { return { PublicApi: publicApi.PublicApi, ExperimentalHttpApiServer: httpApiServer.ExperimentalHttpApiServer, - Server: server.Server, AppLayer: appRuntime.AppLayer, InstanceRef: instanceRef.InstanceRef, Instance: instance.Instance, diff --git a/packages/opencode/test/server/httpapi-exercise/types.ts b/packages/opencode/test/server/httpapi-exercise/types.ts index 2cdc822541..a0466d7b70 100644 --- a/packages/opencode/test/server/httpapi-exercise/types.ts +++ b/packages/opencode/test/server/httpapi-exercise/types.ts @@ -10,8 +10,8 @@ export const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const export type Method = (typeof Methods)[number] export type OpenApiMethod = (typeof OpenApiMethods)[number] -export type Mode = "effect" | "parity" | "coverage" | "auth" -export type Backend = "effect" | "legacy" +export type Mode = "effect" | "coverage" | "auth" +export type Backend = "effect" export type Comparison = "none" | "status" | "json" export type CaptureMode = "full" | "stream" export type AuthPolicy = "protected" | "public" | "public-bypass" | "ticket-bypass" diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index 8684edf134..0b8d8051bc 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" -import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" @@ -15,11 +14,9 @@ import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const testWorktreeMutations = process.platform === "win32" ? test.skip : test function app() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true return Server.Default().app } @@ -39,7 +36,6 @@ async function waitReady(directory: string) { } afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-instance.legacy.test.ts b/packages/opencode/test/server/httpapi-instance.legacy.test.ts deleted file mode 100644 index b5f0805e4c..0000000000 --- a/packages/opencode/test/server/httpapi-instance.legacy.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { Flag } from "@opencode-ai/core/flag/flag" -import { Server } from "../../src/server/server" -import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" -import * as Log from "@opencode-ai/core/util/log" -import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" -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 -} - -async function waitDisposed(directory: string) { - await waitGlobalBusEventPromise({ - message: "timed out waiting for instance disposal", - predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory, - }) -} - -afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await disposeAllInstances() - await resetDatabase() -}) - -describe("instance HttpApi", () => { - test("serves catalog read endpoints through Hono bridge", async () => { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) - - const [commands, agents, skills, lsp, formatter] = await Promise.all([ - app().request(InstancePaths.command, { headers: { "x-opencode-directory": tmp.path } }), - app().request(InstancePaths.agent, { headers: { "x-opencode-directory": tmp.path } }), - app().request(InstancePaths.skill, { headers: { "x-opencode-directory": tmp.path } }), - app().request(InstancePaths.lsp, { headers: { "x-opencode-directory": tmp.path } }), - app().request(InstancePaths.formatter, { headers: { "x-opencode-directory": tmp.path } }), - ]) - - expect(commands.status).toBe(200) - expect(await commands.json()).toContainEqual(expect.objectContaining({ name: "init", source: "command" })) - - expect(agents.status).toBe(200) - expect(await agents.json()).toContainEqual(expect.objectContaining({ name: "build", mode: "primary" })) - - expect(skills.status).toBe(200) - expect(await skills.json()).toBeArray() - - expect(lsp.status).toBe(200) - expect(await lsp.json()).toEqual([]) - - expect(formatter.status).toBe(200) - expect(await formatter.json()).toEqual([]) - }) - - test("serves project git init through Hono bridge", async () => { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) - const disposed = waitDisposed(tmp.path) - - const response = await app().request("/project/git/init", { - method: "POST", - headers: { "x-opencode-directory": tmp.path }, - }) - - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ vcs: "git", worktree: tmp.path }) - await disposed - - const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } }) - expect(current.status).toBe(200) - expect(await current.json()).toMatchObject({ vcs: "git", worktree: tmp.path }) - }) - - test("serves project update through Hono bridge", async () => { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) - - const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } }) - expect(current.status).toBe(200) - const project = (await current.json()) as { id: string } - - const response = await app().request(`/project/${project.id}`, { - method: "PATCH", - headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, - body: JSON.stringify({ name: "patched-project", commands: { start: "bun dev" } }), - }) - - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ - id: project.id, - name: "patched-project", - commands: { start: "bun dev" }, - }) - - const list = await app().request("/project", { headers: { "x-opencode-directory": tmp.path } }) - expect(list.status).toBe(200) - expect(await list.json()).toContainEqual( - expect.objectContaining({ id: project.id, name: "patched-project", commands: { start: "bun dev" } }), - ) - }) - - test("serves instance dispose through Hono bridge", async () => { - await using tmp = await tmpdir() - - const disposed = waitGlobalBusEventPromise({ - message: "timed out waiting for instance disposal", - predicate: (event) => event.payload.type === "server.instance.disposed", - }) - - const response = await app().request(InstancePaths.dispose, { - method: "POST", - headers: { "x-opencode-directory": tmp.path }, - }) - - expect(response.status).toBe(200) - expect(await response.json()).toBe(true) - expect((await disposed).directory).toBe(tmp.path) - }) -}) diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 365fa1220f..946de2835c 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -14,22 +14,18 @@ import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -// Flip the experimental HttpApi flag so backend selection telemetry on the -// production routes reports the right backend, and the experimental -// workspaces flag so SyncEvent.run actually writes to EventSequenceTable -// (the source of truth the fence middleware reads). Reset the database -// around the test so per-instance state does not leak between runs. -// resetDatabase() already calls disposeAllInstances(), so we don't repeat it. +// Flip the experimental workspaces flag so SyncEvent.run actually writes to +// EventSequenceTable (the source of truth the fence middleware reads). Reset +// the database around the test so per-instance state does not leak between +// runs. resetDatabase() already calls disposeAllInstances(), so we don't +// repeat it. const testStateLayer = Layer.effectDiscard( Effect.gen(function* () { - const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true yield* Effect.promise(() => resetDatabase()) yield* Effect.addFinalizer(() => Effect.promise(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await resetDatabase() }), diff --git a/packages/opencode/test/server/httpapi-json-parity.test.ts b/packages/opencode/test/server/httpapi-json-parity.test.ts deleted file mode 100644 index 656541be71..0000000000 --- a/packages/opencode/test/server/httpapi-json-parity.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { afterEach, describe, expect } from "bun:test" -import { Effect } from "effect" -import { Flag } from "@opencode-ai/core/flag/flag" -import { ModelID, ProviderID } from "../../src/provider/schema" -import { Instance } from "../../src/project/instance" -import { Server } from "../../src/server/server" -import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" -import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" -import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global" -import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" -import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" -import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" -import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" -import { MessageID, PartID } from "../../src/session/schema" -import { Session } from "@/session/session" -import * as Log from "@opencode-ai/core/util/log" -import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" -import { it } from "../lib/effect" - -void Log.init({ print: false }) - -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI - -function app(experimental: boolean) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app -} -type TestApp = ReturnType - -function pathFor(path: string, params: Record) { - return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path) -} - -const seedSessions = Effect.gen(function* () { - const svc = yield* Session.Service - const parent = yield* svc.create({ title: "parent" }) - yield* svc.create({ title: "child", parentID: parent.id }) - const message = yield* svc.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID: parent.id, - agent: "build", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, - time: { created: Date.now() }, - }) - yield* svc.updatePart({ - id: PartID.ascending(), - sessionID: parent.id, - messageID: message.id, - type: "text", - text: "hello", - }) - return { parent, message } -}) - -function withTmp( - options: Parameters[0], - fn: (tmp: Awaited>) => Effect.Effect, -) { - return Effect.acquireRelease( - Effect.promise(() => tmpdir(options)), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe(Effect.flatMap((tmp) => fn(tmp).pipe(provideInstance(tmp.path)))) -} - -function readJson(label: string, serverApp: TestApp, path: string, headers: HeadersInit) { - return Effect.promise(async () => { - const response = await serverApp.request(path, { headers }) - if (response.status !== 200) throw new Error(`${label} returned ${response.status}: ${await response.text()}`) - return await response.json() - }) -} - -function expectJsonParity(input: { - label: string - legacy: TestApp - httpapi: TestApp - path: string - headers: HeadersInit -}) { - return Effect.gen(function* () { - const legacy = yield* readJson(input.label, input.legacy, input.path, input.headers) - const httpapi = yield* readJson(input.label, input.httpapi, input.path, input.headers) - expect({ label: input.label, body: httpapi }).toEqual({ label: input.label, body: legacy }) - return httpapi - }) -} - -afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await disposeAllInstances() - await resetDatabase() -}) - -describe("HttpApi JSON parity", () => { - it.live( - "matches legacy JSON shape for safe GET endpoints", - withTmp( - { - git: true, - config: { - formatter: false, - lsp: false, - mcp: { - demo: { - type: "local", - command: ["echo", "demo"], - enabled: false, - }, - }, - }, - }, - (tmp) => - Effect.gen(function* () { - yield* Effect.promise(() => Bun.write(`${tmp.path}/hello.txt`, "hello\n")) - - const headers = { "x-opencode-directory": tmp.path } - const legacy = app(false) - const httpapi = app(true) - - yield* Effect.forEach( - [ - { label: "global.health", path: GlobalPaths.health, headers: {} }, - { label: "global.config", path: GlobalPaths.config, headers: {} }, - { label: "instance.path", path: InstancePaths.path, headers }, - { label: "instance.vcs", path: InstancePaths.vcs, headers }, - { label: "instance.vcsDiff", path: `${InstancePaths.vcsDiff}?mode=git`, headers }, - { label: "instance.command", path: InstancePaths.command, headers }, - { label: "instance.agent", path: InstancePaths.agent, headers }, - { label: "instance.skill", path: InstancePaths.skill, headers }, - { label: "instance.lsp", path: InstancePaths.lsp, headers }, - { label: "instance.formatter", path: InstancePaths.formatter, headers }, - { label: "config.get", path: "/config", headers }, - { label: "config.providers", path: "/config/providers", headers }, - { label: "project.list", path: "/project", headers }, - { label: "project.current", path: "/project/current", headers }, - { label: "provider.list", path: "/provider", headers }, - { label: "provider.auth", path: "/provider/auth", headers }, - { label: "permission.list", path: "/permission", headers }, - { label: "question.list", path: "/question", headers }, - { label: "mcp.status", path: McpPaths.status, headers }, - { label: "pty.shells", path: PtyPaths.shells, headers }, - { label: "pty.list", path: PtyPaths.list, headers }, - { label: "file.list", path: `${FilePaths.list}?${new URLSearchParams({ path: "." })}`, headers }, - { - label: "file.content", - path: `${FilePaths.content}?${new URLSearchParams({ path: "hello.txt" })}`, - headers, - }, - { label: "file.status", path: FilePaths.status, headers }, - { - label: "find.file", - path: `${FilePaths.findFile}?${new URLSearchParams({ query: "hello", dirs: "false" })}`, - headers, - }, - { - label: "find.text", - path: `${FilePaths.findText}?${new URLSearchParams({ pattern: "hello" })}`, - headers, - }, - { - label: "find.symbol", - path: `${FilePaths.findSymbol}?${new URLSearchParams({ query: "hello" })}`, - headers, - }, - { label: "experimental.console", path: ExperimentalPaths.console, headers }, - { label: "experimental.consoleOrgs", path: ExperimentalPaths.consoleOrgs, headers }, - { label: "experimental.toolIDs", path: ExperimentalPaths.toolIDs, headers }, - { label: "experimental.worktree", path: ExperimentalPaths.worktree, headers }, - { label: "experimental.resource", path: ExperimentalPaths.resource, headers }, - ], - (input) => expectJsonParity({ ...input, legacy, httpapi }), - { concurrency: 1 }, - ) - }), - ), - ) - - it.live( - "matches legacy JSON shape for session read endpoints", - withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => - Effect.gen(function* () { - const headers = { "x-opencode-directory": tmp.path } - const seeded = yield* seedSessions.pipe(Effect.provide(Session.defaultLayer)) - const legacy = app(false) - const httpapi = app(true) - - const rootsFalse = yield* expectJsonParity({ - label: "session.list roots false", - legacy, - httpapi, - path: `${SessionPaths.list}?roots=false`, - headers, - }) - expect((rootsFalse as Session.Info[]).map((session) => session.id)).toContain(seeded.parent.id) - expect((rootsFalse as Session.Info[]).length).toBe(2) - - const experimentalRootsFalse = yield* expectJsonParity({ - label: "experimental.session roots false", - legacy, - httpapi, - path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10", roots: "false" })}`, - headers, - }) - expect((experimentalRootsFalse as Session.GlobalInfo[]).length).toBe(2) - - const experimentalArchivedFalse = yield* expectJsonParity({ - label: "experimental.session archived false", - legacy, - httpapi, - path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10", archived: "false" })}`, - headers, - }) - expect((experimentalArchivedFalse as Session.GlobalInfo[]).length).toBe(2) - - yield* Effect.forEach( - [ - { label: "session.list roots", path: `${SessionPaths.list}?roots=true`, headers }, - { label: "session.list all", path: SessionPaths.list, headers }, - { label: "session.get", path: pathFor(SessionPaths.get, { sessionID: seeded.parent.id }), headers }, - { - label: "session.children", - path: pathFor(SessionPaths.children, { sessionID: seeded.parent.id }), - headers, - }, - { - label: "session.messages", - path: pathFor(SessionPaths.messages, { sessionID: seeded.parent.id }), - headers, - }, - { - label: "session.messages empty before", - path: `${pathFor(SessionPaths.messages, { sessionID: seeded.parent.id })}?before=`, - headers, - }, - { - label: "session.message", - path: pathFor(SessionPaths.message, { sessionID: seeded.parent.id, messageID: seeded.message.id }), - headers, - }, - { - label: "experimental.session", - path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10" })}`, - headers, - }, - ], - (input) => expectJsonParity({ ...input, legacy, httpapi }), - { concurrency: 1 }, - ) - }), - ), - ) -}) diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index b49fbe98b5..b2ff28ec67 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -10,7 +10,6 @@ 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, envPassword: process.env.OPENCODE_SERVER_PASSWORD, @@ -20,7 +19,6 @@ const auth = { username: "opencode", password: "listen-secret" } const testPty = process.platform === "win32" ? test.skip : test 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 if (original.envPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD @@ -31,8 +29,7 @@ afterEach(async () => { await resetDatabase() }) -async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi" +async function startListener() { Flag.OPENCODE_SERVER_PASSWORD = auth.password Flag.OPENCODE_SERVER_USERNAME = auth.username process.env.OPENCODE_SERVER_PASSWORD = auth.password @@ -40,8 +37,7 @@ async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpap return Server.listen({ hostname: "127.0.0.1", port: 0 }) } -async function startNoAuthListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi" +async function startNoAuthListener() { Flag.OPENCODE_SERVER_PASSWORD = undefined Flag.OPENCODE_SERVER_USERNAME = auth.username delete process.env.OPENCODE_SERVER_PASSWORD @@ -212,22 +208,6 @@ describe("HttpApi Server.listen", () => { } }) - testPty("serves PTY websocket tickets through legacy Hono Server.listen", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const listener = await startListener("hono") - try { - const info = await createCat(listener, tmp.path) - const ticket = await connectTicket(listener, info.id, tmp.path) - const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket)) - const message = waitForMessage(ws, (message) => message.includes("ping-hono-ticket")) - ws.send("ping-hono-ticket\n") - expect(await message).toContain("ping-hono-ticket") - ws.close(1000) - } finally { - await stop(listener, "timed out cleaning up hono listener").catch(() => undefined) - } - }) - testPty("rejects unsafe PTY ticket mint and connect requests", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const listener = await startListener() @@ -300,20 +280,18 @@ describe("HttpApi Server.listen", () => { } }) - for (const backend of ["effect-httpapi", "hono"] as const) { - testPty(`keeps PTY websocket tickets optional when server auth is disabled (${backend})`, async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const listener = await startNoAuthListener(backend) - try { - const info = await createCat(listener, tmp.path) - const ws = await openSocket(socketURL(listener, info.id, tmp.path)) - const message = waitForMessage(ws, (message) => message.includes(`ping-no-auth-${backend}`)) - ws.send(`ping-no-auth-${backend}\n`) - expect(await message).toContain(`ping-no-auth-${backend}`) - ws.close(1000) - } finally { - await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined) - } - }) - } + testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startNoAuthListener() + try { + const info = await createCat(listener, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path)) + const message = waitForMessage(ws, (message) => message.includes("ping-no-auth")) + ws.send("ping-no-auth\n") + expect(await message).toContain("ping-no-auth") + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined) + } + }) }) diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index f442df5770..b6c7aebcd2 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { Context, Effect, FileSystem, Layer, Path } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" -import { Flag } from "@opencode-ai/core/flag/flag" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" import { Instance } from "../../src/project/instance" @@ -15,13 +14,11 @@ import { testEffect } from "../lib/effect" void Log.init({ print: false }) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const context = Context.empty() as Context.Context const it = testEffect(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)) -function app(experimental: boolean) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app +function app() { + return Server.Default().app } type TestApp = ReturnType @@ -79,7 +76,6 @@ const readResponse = Effect.fnUntraced(function* (input: { app: TestApp; path: s }) afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) @@ -165,23 +161,19 @@ describe("mcp HttpApi", () => { }) it.live( - "matches legacy unsupported OAuth error responses", + "returns unsupported OAuth error responses", withMcpProject((dir) => Effect.gen(function* () { const headers = { "x-opencode-directory": dir } - const legacy = app(false) - const httpapi = app(true) yield* Effect.forEach(["/mcp/demo/auth", "/mcp/demo/auth/authenticate"], (path) => Effect.gen(function* () { - const legacyResponse = yield* readResponse({ app: legacy, path, headers }) - const httpapiResponse = yield* readResponse({ app: httpapi, path, headers }) + const response = yield* readResponse({ app: app(), path, headers }) - expect(legacyResponse).toEqual({ + expect(response).toEqual({ status: 400, body: JSON.stringify({ error: "MCP server demo does not support OAuth" }), }) - expect(httpapiResponse).toEqual(legacyResponse) }), ) }), diff --git a/packages/opencode/test/server/httpapi-parity.test.ts b/packages/opencode/test/server/httpapi-parity.test.ts deleted file mode 100644 index 9d7eff4964..0000000000 --- a/packages/opencode/test/server/httpapi-parity.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { Effect } from "effect" -import { Flag } from "@opencode-ai/core/flag/flag" -import * as Log from "@opencode-ai/core/util/log" -import { WithInstance } from "../../src/project/with-instance" -import { Server } from "../../src/server/server" -import { Session } from "@/session/session" -import { MessageID } from "../../src/session/schema" -import { ModelID, ProviderID } from "../../src/provider/schema" -import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" - -void Log.init({ print: false }) - -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI - -afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await disposeAllInstances() - await resetDatabase() -}) - -function app(experimental: boolean) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app -} - -function runSession(fx: Effect.Effect) { - return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer))) -} - -function createSessionWithMessages(directory: string, count: number) { - return WithInstance.provide({ - directory, - fn: async () => { - const session = await runSession(Session.Service.use((svc) => svc.create({}))) - for (let i = 0; i < count; i++) { - await runSession( - Effect.gen(function* () { - const svc = yield* Session.Service - yield* svc.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID: session.id, - agent: "build", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, - time: { created: Date.now() }, - }) - }), - ) - } - return session.id - }, - }) -} - -// ────────────────────────────────────────────────────────────────────────────── -// Reproducer 1: Link header should reflect the request's actual Host header, -// not "localhost". HttpApi uses `new URL(request.url, "http://localhost")` -// which embeds localhost because request.url is path-only. Fix: use -// `HttpServerRequest.toURL(request)` which honors the Host header. -// ────────────────────────────────────────────────────────────────────────────── -describe("Link header host", () => { - test("HttpApi pagination Link header echoes request host", async () => { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) - const sessionID = await createSessionWithMessages(tmp.path, 3) - - const response = await app(true).request(`/session/${sessionID}/message?limit=2`, { - headers: { - host: "opencode.test:4096", - "x-opencode-directory": tmp.path, - }, - }) - - expect(response.status).toBe(200) - const link = response.headers.get("link") - expect(link).not.toBeNull() - // Link should contain the request's Host, not "localhost". - expect(link).toContain("opencode.test") - expect(link).not.toContain("localhost") - }) -}) - -// ────────────────────────────────────────────────────────────────────────────── -// Reproducer 2: GET /session/{missing-id}/todo should return 404, not 500. -// The session.todo handler in HttpApi doesn't wrap with `mapNotFound`, so a -// `NotFoundError` from the service surfaces as a defect → 500. Hono's -// equivalent maps to 404 via `errors.notFound`. -// -// Affected endpoints (handlers without mapNotFound): todo, diff, summarize, -// fork, abort, init, deleteMessage, command, shell, revert, unrevert. -// -// FIXME: unskip when mapNotFound coverage is added (next PR). -// ────────────────────────────────────────────────────────────────────────────── -describe("404 mapping for missing session", () => { - test.todo("HttpApi /session/{missing}/todo returns 404 not 500", async () => { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) - - const response = await app(true).request("/session/ses_does_not_exist/todo", { - headers: { "x-opencode-directory": tmp.path }, - }) - - expect(response.status).toBe(404) - }) -}) - -// ────────────────────────────────────────────────────────────────────────────── -// Reproducer 3: 404 response body shape should match Hono's public NamedError -// envelope `{ name, data: { message } }`. SDK consumers read -// `error.data.message`, so returning an Effect built-in `{ _tag }` body is a -// compatibility break. -// ────────────────────────────────────────────────────────────────────────────── -describe("Error JSON shape parity", () => { - test("HttpApi 404 body matches Hono shape", async () => { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) - const headers = { "x-opencode-directory": tmp.path } - - const hono = await app(false).request("/session/ses_does_not_exist", { headers }) - const httpapi = await app(true).request("/session/ses_does_not_exist", { headers }) - - expect(httpapi.status).toBe(hono.status) - const body = (await httpapi.json()) as { name?: string; data?: { message?: string } } - expect(body).toEqual(await hono.json()) - expect(body.name).toBe("NotFoundError") - expect(typeof body.data?.message).toBe("string") - }) -}) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index fa32608fc8..12262e30ed 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect } from "bun:test" import { Effect, FileSystem, Layer, Path } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" -import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" @@ -13,15 +12,13 @@ import { testEffect } from "../lib/effect" void Log.init({ print: false }) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const it = testEffect(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)) const providerID = "test-oauth-parity" const oauthURL = "https://example.com/oauth" const oauthInstructions = "Finish OAuth" -function app(experimental: boolean) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app +function app() { + return Server.Default().app } function requestAuthorize(input: { @@ -101,54 +98,37 @@ function withProviderProject(self: (dir: string) => Effect.Effect { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) describe("provider HttpApi", () => { it.live( - "matches legacy OAuth authorize response shapes", + "serves OAuth authorize response shapes", withProviderProject((dir) => Effect.gen(function* () { const headers = { "x-opencode-directory": dir, "content-type": "application/json" } - const legacy = app(false) - const httpapi = app(true) + const server = app() - const apiLegacy = yield* requestAuthorize({ - app: legacy, + const api = yield* requestAuthorize({ + app: server, providerID, method: 0, headers, }) - const apiHttpApi = yield* requestAuthorize({ - app: httpapi, - providerID, - method: 0, - headers, - }) - expect(apiLegacy).toEqual({ status: 200, body: "" }) - // #26474 changed the HTTP API authorize handler to serialize an - // undefined service result as JSON `null` instead of an empty body - // so clients can `.json()` parse the response uniformly. The legacy - // Hono path still emits an empty body (`c.json(undefined)`); the new - // backend's body diverges intentionally. - expect(apiHttpApi).toEqual({ status: 200, body: "null" }) + // method 0 (api-key style) — authorize() resolves with no further + // redirect; #26474 changed the wire format to JSON `null` so clients + // can `.json()` parse uniformly instead of getting an empty body + // that throws. + expect(api).toEqual({ status: 200, body: "null" }) - const oauthLegacy = yield* requestAuthorize({ - app: legacy, + const oauth = yield* requestAuthorize({ + app: server, providerID, method: 1, headers, }) - const oauthHttpApi = yield* requestAuthorize({ - app: httpapi, - providerID, - method: 1, - headers, - }) - expect(oauthHttpApi).toEqual(oauthLegacy) - expect(JSON.parse(oauthHttpApi.body)).toEqual({ + expect(JSON.parse(oauth.body)).toEqual({ url: oauthURL, method: "code", instructions: oauthInstructions, diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index 5e63eae61c..987eba6b38 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" import { NodeHttpServer, NodeServices } from "@effect/platform-node" -import { Flag } from "@opencode-ai/core/flag/flag" import { PtyID } from "../../src/pty/schema" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" @@ -17,16 +16,13 @@ import { testEffect } from "../lib/effect" void Log.init({ print: false }) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const testPty = process.platform === "win32" ? test.skip : test const testStateLayer = Layer.effectDiscard( Effect.gen(function* () { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true yield* Effect.promise(() => resetDatabase()) yield* Effect.addFinalizer(() => Effect.promise(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await resetDatabase() }), ) @@ -50,9 +46,8 @@ const effectIt = testEffect( ), ) -function app(experimental = true) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app +function app() { + return Server.Default().app } function serverUrl() { @@ -62,7 +57,6 @@ function serverUrl() { const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir) afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) @@ -121,18 +115,6 @@ describe("pty HttpApi bridge", () => { expect(missing.status).toBe(404) }) - test("matches Hono missing PTY error body", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const headers = { "x-opencode-directory": tmp.path } - const path = PtyPaths.get.replace(":ptyID", PtyID.ascending()) - - const hono = await app(false).request(path, { headers }) - const httpapi = await app().request(path, { headers }) - - expect(httpapi.status).toBe(hono.status) - expect(await httpapi.json()).toEqual(await hono.json()) - }) - test("returns 404 for missing PTY websocket before upgrade", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const response = await app().request(PtyPaths.connect.replace(":ptyID", PtyID.ascending()), { diff --git a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts index fd82e78639..7e5757b1ea 100644 --- a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts +++ b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { ConfigProvider, Layer } from "effect" import { HttpRouter } from "effect/unstable/http" -import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { EventPaths } from "../../src/server/routes/instance/httpapi/event" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" @@ -13,10 +12,8 @@ import * as Log from "@opencode-ai/core/util/log" void Log.init({ print: false }) -const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI function app(input: { password?: string; username?: string }) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true const handler = HttpRouter.toWebHandler( ExperimentalHttpApiServer.routes.pipe( Layer.provide( @@ -48,7 +45,6 @@ async function cancelBody(response: Response) { } afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 493f890838..8a179a4dcc 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -22,23 +22,21 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" const original = { - OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, } -type Backend = "legacy" | "httpapi" +type ServerPath = "default" | "raw" type Sdk = ReturnType type SdkResult = { response: Response; data?: unknown; error?: unknown } type Captured = { status: number; data?: unknown; error?: unknown } type ProjectFixture = { sdk: Sdk; directory: string } type LlmProjectFixture = ProjectFixture & { llm: TestLLMServer["Service"] } -function app(backend: Backend, input?: { password?: string; username?: string }) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "httpapi" +function app(serverPath: ServerPath, input?: { password?: string; username?: string }) { Flag.OPENCODE_SERVER_PASSWORD = input?.password Flag.OPENCODE_SERVER_USERNAME = input?.username - if (backend === "legacy") return Server.Legacy().app + if (serverPath === "default") return Server.Default().app const handler = HttpRouter.toWebHandler( ExperimentalHttpApiServer.routes.pipe( @@ -62,7 +60,7 @@ function app(backend: Backend, input?: { password?: string; username?: string }) } function client( - backend: Backend, + serverPath: ServerPath, directory?: string, input?: { password?: string; username?: string; headers?: Record }, ) { @@ -70,12 +68,12 @@ function client( baseUrl: "http://localhost", directory, headers: input?.headers, - fetch: serverFetch(backend, input), + fetch: serverFetch(serverPath, input), }) } -function serverFetch(backend: Backend, input?: { password?: string; username?: string }) { - const serverApp = app(backend, input) +function serverFetch(serverPath: ServerPath, input?: { password?: string; username?: string }) { + const serverApp = app(serverPath, input) return Object.assign( async (request: RequestInfo | URL, init?: RequestInit) => await serverApp.fetch(request instanceof Request ? request : new Request(request, init)), @@ -194,20 +192,20 @@ function httpapi(name: string, effect: Effect.Effect) { it.live(name, effect) } -function parity(name: string, scenario: (backend: Backend) => Effect.Effect) { +function serverPathParity(name: string, scenario: (serverPath: ServerPath) => Effect.Effect) { it.live( name, Effect.gen(function* () { - const legacy = yield* scenario("legacy") + const standard = yield* scenario("default") yield* resetState() - const httpapi = yield* scenario("httpapi") - expect(httpapi).toEqual(legacy) + const raw = yield* scenario("raw") + expect(raw).toEqual(standard) }), ) } function withProject( - backend: Backend, + serverPath: ServerPath, options: { git?: boolean; config?: Partial; setup?: (dir: string) => Effect.Effect }, run: (input: ProjectFixture) => Effect.Effect, ) { @@ -216,30 +214,30 @@ function withProject( (tmp) => call(() => tmp[Symbol.asyncDispose]()).pipe(Effect.ignore), ).pipe( Effect.tap((tmp) => options.setup?.(tmp.path) ?? Effect.void), - Effect.flatMap((tmp) => run({ sdk: client(backend, tmp.path), directory: tmp.path })), + Effect.flatMap((tmp) => run({ sdk: client(serverPath, tmp.path), directory: tmp.path })), ) } -function withStandardProject(backend: Backend, run: (input: ProjectFixture) => Effect.Effect) { - return withProject(backend, { setup: writeStandardFiles }, run) +function withStandardProject(serverPath: ServerPath, run: (input: ProjectFixture) => Effect.Effect) { + return withProject(serverPath, { setup: writeStandardFiles }, run) } -function withFakeLlm(backend: Backend, run: (input: LlmProjectFixture) => Effect.Effect) { +function withFakeLlm(serverPath: ServerPath, run: (input: LlmProjectFixture) => Effect.Effect) { return Effect.gen(function* () { const llm = yield* TestLLMServer - return yield* withProject(backend, { config: providerConfig(llm.url) }, (input) => run({ ...input, llm })) + return yield* withProject(serverPath, { config: providerConfig(llm.url) }, (input) => run({ ...input, llm })) }).pipe(Effect.provide(TestLLMServer.layer)) } function withFakeLlmProject( - backend: Backend, + serverPath: ServerPath, options: { setup?: (dir: string) => Effect.Effect }, run: (input: LlmProjectFixture) => Effect.Effect, ) { return Effect.gen(function* () { const llm = yield* TestLLMServer return yield* withProject( - backend, + serverPath, { config: providerConfig(llm.url), setup: options.setup, @@ -306,7 +304,6 @@ function seedMessage(directory: string, sessionID: string) { } 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() @@ -317,7 +314,7 @@ describe("HttpApi SDK", () => { httpapi( "uses the generated SDK for global and control routes", Effect.gen(function* () { - const sdk = client("httpapi") + const sdk = client("raw") const health = yield* call(() => sdk.global.health()) const log = yield* call(() => sdk.app.log({ service: "httpapi-sdk-test", level: "info", message: "hello" })) @@ -334,7 +331,7 @@ describe("HttpApi SDK", () => { httpapi( "uses the generated SDK for safe instance routes", - withProject("httpapi", { git: false, setup: writeStandardFiles }, ({ sdk }) => + withProject("raw", { git: false, setup: writeStandardFiles }, ({ sdk }) => Effect.gen(function* () { const file = yield* call(() => sdk.file.read({ path: "hello.txt" })) const session = yield* call(() => sdk.session.create({ title: "sdk" })) @@ -357,9 +354,9 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK global and control behavior across backends", (backend) => + serverPathParity("matches generated SDK global and control behavior", (serverPath) => Effect.gen(function* () { - const sdk = client(backend) + const sdk = client(serverPath) const health = yield* capture(() => sdk.global.health()) const log = yield* capture(() => sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" })) const invalidAuth = yield* capture(() => sdk.auth.set({ providerID: "test" })) @@ -372,22 +369,22 @@ describe("HttpApi SDK", () => { }), ) - parity("matches generated SDK global event stream across backends", (backend) => - firstEvent(() => client(backend).global.event({ signal: AbortSignal.timeout(1_000) })).pipe( + serverPathParity("matches generated SDK global event stream", (serverPath) => + firstEvent(() => client(serverPath).global.event({ signal: AbortSignal.timeout(1_000) })).pipe( Effect.map((event) => ({ type: record(record(event).payload).type })), ), ) - parity("matches generated SDK instance event stream across backends", (backend) => - withStandardProject(backend, ({ sdk }) => + serverPathParity("matches generated SDK instance event stream", (serverPath) => + withStandardProject(serverPath, ({ sdk }) => firstEvent(() => sdk.event.subscribe(undefined, { signal: AbortSignal.timeout(1_000) })).pipe( Effect.map((event) => ({ type: record(record(event).payload).type })), ), ), ) - parity("matches generated SDK missing session errors across backends", (backend) => - withStandardProject(backend, ({ sdk }) => + serverPathParity("matches generated SDK missing session errors", (serverPath) => + withStandardProject(serverPath, ({ sdk }) => Effect.gen(function* () { const sessionID = "ses_missing" const expected = { @@ -408,8 +405,8 @@ describe("HttpApi SDK", () => { ), ) - parity("formats missing session validation errors for -s", (backend) => - withStandardProject(backend, ({ directory }) => + serverPathParity("formats missing session validation errors for -s", (serverPath) => + withStandardProject(serverPath, ({ directory }) => Effect.gen(function* () { const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T" const thrown = yield* captureThrown(() => @@ -417,7 +414,7 @@ describe("HttpApi SDK", () => { url: "http://localhost", directory, sessionID, - fetch: serverFetch(backend), + fetch: serverFetch(serverPath), }), ) expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`) @@ -426,20 +423,21 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK basic auth behavior across backends", (backend) => - withStandardProject(backend, ({ directory }) => + httpapi( + "uses generated SDK basic auth behavior", + withStandardProject("raw", ({ directory }) => Effect.gen(function* () { const missing = yield* capture(() => - client(backend, directory, { password: "secret" }).file.read({ path: "hello.txt" }), + client("raw", directory, { password: "secret" }).file.read({ path: "hello.txt" }), ) const bad = yield* capture(() => - client(backend, directory, { + client("raw", directory, { password: "secret", headers: { authorization: authorization("opencode", "wrong") }, }).file.read({ path: "hello.txt" }), ) const good = yield* capture(() => - client(backend, directory, { + client("raw", directory, { password: "secret", headers: { authorization: authorization("opencode", "secret") }, }).file.read({ path: "hello.txt" }), @@ -453,8 +451,8 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK instance read routes across backends", (backend) => - withStandardProject(backend, ({ sdk, directory }) => + serverPathParity("matches generated SDK instance read routes", (serverPath) => + withStandardProject(serverPath, ({ sdk, directory }) => Effect.gen(function* () { const project = yield* capture(() => sdk.project.current()) const projects = yield* capture(() => sdk.project.list()) @@ -504,8 +502,8 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK session lifecycle routes across backends", (backend) => - withStandardProject(backend, ({ sdk }) => + serverPathParity("matches generated SDK session lifecycle routes", (serverPath) => + withStandardProject(serverPath, ({ sdk }) => Effect.gen(function* () { const parent = yield* capture(() => sdk.session.create({ title: "parent" })) const parentID = String(record(parent.data).id) @@ -557,8 +555,8 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK session message and part routes across backends", (backend) => - withStandardProject(backend, ({ sdk, directory }) => + serverPathParity("matches generated SDK session message and part routes", (serverPath) => + withStandardProject(serverPath, ({ sdk, directory }) => Effect.gen(function* () { const session = yield* capture(() => sdk.session.create({ title: "messages" })) const sessionID = String(record(session.data).id) @@ -609,8 +607,8 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK prompt no-reply routes across backends", (backend) => - withStandardProject(backend, ({ sdk }) => + serverPathParity("matches generated SDK prompt no-reply routes", (serverPath) => + withStandardProject(serverPath, ({ sdk }) => Effect.gen(function* () { const session = yield* capture(() => sdk.session.create({ title: "prompt" })) const sessionID = String(record(session.data).id) @@ -646,8 +644,8 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK prompt streaming through fake LLM across backends", (backend) => - withFakeLlm(backend, ({ sdk, llm }) => + serverPathParity("matches generated SDK prompt streaming through fake LLM", (serverPath) => + withFakeLlm(serverPath, ({ sdk, llm }) => Effect.gen(function* () { yield* llm.text("fake world", { usage: { input: 11, output: 7 } }) const session = yield* capture(() => @@ -682,7 +680,7 @@ describe("HttpApi SDK", () => { httpapi( "includes project skills in REST API async prompt context", - withFakeLlmProject("httpapi", { setup: writeProjectSkill }, ({ sdk, llm }) => + withFakeLlmProject("default", { setup: writeProjectSkill }, ({ sdk, llm }) => Effect.gen(function* () { yield* llm.text("skill context ok", { usage: { input: 11, output: 7 } }) const session = yield* capture(() => @@ -710,8 +708,8 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK TUI validation and command routes across backends", (backend) => - withStandardProject(backend, ({ sdk }) => + serverPathParity("matches generated SDK TUI validation and command routes", (serverPath) => + withStandardProject(serverPath, ({ sdk }) => Effect.gen(function* () { const session = yield* capture(() => sdk.session.create({ title: "tui" })) const sessionID = String(record(session.data).id) @@ -761,8 +759,8 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK project git initialization across backends", (backend) => - withProject(backend, { git: false }, ({ sdk, directory }) => + serverPathParity("matches generated SDK project git initialization", (serverPath) => + withProject(serverPath, { git: false }, ({ sdk, directory }) => Effect.gen(function* () { const before = yield* capture(() => sdk.project.current()) const init = yield* capture(() => sdk.project.initGit()) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index c1d82446b9..6de704325b 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -30,16 +30,14 @@ import { it } from "../lib/effect" void Log.init({ print: false }) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES const workspaceLayer = Workspace.defaultLayer.pipe( Layer.provide(InstanceStore.defaultLayer), Layer.provide(InstanceBootstrap.defaultLayer), ) -function app(experimental = true) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app +function app() { + return Server.Default().app } function runSession(fx: Effect.Effect) { @@ -119,10 +117,6 @@ function request(path: string, init?: RequestInit) { return Effect.promise(async () => app().request(path, init)) } -function requestWithBackend(experimental: boolean, path: string, init?: RequestInit) { - return Effect.promise(async () => app(experimental).request(path, init)) -} - function json(response: Response) { return Effect.promise(async () => { if (response.status !== 200) throw new Error(await response.text()) @@ -149,7 +143,6 @@ function withTmp( } afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await disposeAllInstances() await resetDatabase() @@ -198,7 +191,7 @@ describe("session HttpApi", () => { ) it.live( - "serves read routes through Hono bridge", + "serves read routes", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path } @@ -305,7 +298,7 @@ describe("session HttpApi", () => { ) it.live( - "serves lifecycle mutation routes through Hono bridge", + "serves lifecycle mutation routes", withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } @@ -389,39 +382,26 @@ describe("session HttpApi", () => { ) it.live( - "matches legacy archived timestamp validation", + "validates archived timestamp values", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } - const legacy = yield* createSession(tmp.path, { title: "legacy" }) - const effect = yield* createSession(tmp.path, { title: "effect" }) + const session = yield* createSession(tmp.path, { title: "archived" }) const body = JSON.stringify({ time: { archived: -1 } }) - const legacyResponse = yield* requestWithBackend( - false, - pathFor(SessionPaths.update, { sessionID: legacy.id }), - { - method: "PATCH", - headers, - body, - }, - ) - expect(legacyResponse.status).toBe(200) - expect((yield* json(legacyResponse)).time.archived).toBe(-1) - - const effectResponse = yield* requestWithBackend(true, pathFor(SessionPaths.update, { sessionID: effect.id }), { + const response = yield* request(pathFor(SessionPaths.update, { sessionID: session.id }), { method: "PATCH", headers, body, }) - expect(effectResponse.status).toBe(legacyResponse.status) - expect((yield* json(effectResponse)).time.archived).toBe(-1) + expect(response.status).toBe(200) + expect((yield* json(response)).time.archived).toBe(-1) }), ), ) it.live( - "matches legacy project-scoped path and directory precedence", + "uses project-scoped path and directory precedence", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const currentDir = path.join(tmp.path, "packages", "opencode", "src") @@ -441,22 +421,18 @@ describe("session HttpApi", () => { directory: currentDir, }) const headers = { "x-opencode-directory": tmp.path } - const legacy = (yield* json( - yield* requestWithBackend(false, `${SessionPaths.list}?${query}`, { headers }), - )).map((item) => item.id) - const effect = (yield* json( - yield* requestWithBackend(true, `${SessionPaths.list}?${query}`, { headers }), - )).map((item) => item.id) + const sessions = (yield* json(yield* request(`${SessionPaths.list}?${query}`, { headers }))).map( + (item) => item.id, + ) - expect(legacy).toContain(pathSession.id) - expect(legacy).not.toContain(pathlessSession.id) - expect(effect).toEqual(legacy) + expect(sessions).toContain(pathSession.id) + expect(sessions).not.toContain(pathlessSession.id) }), ), ) it.live( - "matches legacy paginated message link headers", + "serves paginated message link headers", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path } @@ -465,20 +441,17 @@ describe("session HttpApi", () => { yield* createTextMessage(tmp.path, session.id, "second") const route = `${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=1` - const legacy = yield* requestWithBackend(false, route, { headers }) - const effect = yield* requestWithBackend(true, route, { headers }) + const response = yield* request(route, { headers }) - expect(effect.headers.get("x-next-cursor")).toBe(legacy.headers.get("x-next-cursor")) - expect(effect.headers.get("link")).toBe(legacy.headers.get("link")) - expect(effect.headers.get("access-control-expose-headers")).toBe( - legacy.headers.get("access-control-expose-headers"), - ) + expect(response.headers.get("x-next-cursor")).toBeTruthy() + expect(response.headers.get("link")).toContain("limit=1") + expect(response.headers.get("access-control-expose-headers")?.toLowerCase()).toContain("x-next-cursor") }), ), ) it.live( - "serves message mutation routes through Hono bridge", + "serves message mutation routes", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } @@ -522,7 +495,7 @@ describe("session HttpApi", () => { ) it.live( - "serves remaining non-LLM session mutation routes through Hono bridge", + "serves remaining non-LLM session mutation routes", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index c4d2397afd..c5ee637842 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -13,13 +13,11 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) -const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES const context = Context.empty() as Context.Context -function app(httpapi = true) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpapi - return httpapi ? Server.Default().app : Server.Legacy().app +function app() { + return Server.Default().app } function runSession(fx: Effect.Effect) { @@ -28,14 +26,13 @@ function runSession(fx: Effect.Effect) { afterEach(async () => { mock.restore() - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await disposeAllInstances() await resetDatabase() }) describe("sync HttpApi", () => { - test("serves sync routes through Hono bridge", async () => { + test("serves sync routes", async () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } @@ -87,7 +84,7 @@ describe("sync HttpApi", () => { expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true) }) - test("matches legacy seq validation", async () => { + test("validates seq values", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } const cases = [ @@ -116,18 +113,12 @@ describe("sync HttpApi", () => { ] for (const item of cases) { - const legacy = await app(false).request(item.path, { + const response = await app().request(item.path, { method: "POST", headers, body: JSON.stringify(item.body), }) - const httpapi = await app(true).request(item.path, { - method: "POST", - headers, - body: JSON.stringify(item.body), - }) - expect(httpapi.status).toBe(legacy.status) - expect(httpapi.status).toBe(400) + expect(response.status).toBe(400) } }) diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts deleted file mode 100644 index 91cad362a9..0000000000 --- a/packages/opencode/test/server/httpapi-tui.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import type { Context } from "hono" -import { Flag } from "@opencode-ai/core/flag/flag" -import { TuiEvent } from "../../src/cli/cmd/tui/event" -import { SessionID } from "../../src/session/schema" -import { Instance } from "../../src/project/instance" -import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/groups/tui" -import { callTui } from "../../src/server/routes/instance/tui" -import { Server } from "../../src/server/server" -import * as Log from "@opencode-ai/core/util/log" -import { OpenApi } from "effect/unstable/httpapi" -import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" -import { waitGlobalBusEventPromise } from "./global-bus" - -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 nextCommandExecute() { - return waitGlobalBusEventPromise({ - predicate: (event) => event.payload.type === TuiEvent.CommandExecute.type, - }).then((event) => event.payload.properties?.command) -} - -async function expectTrue(path: string, headers: Record, body?: unknown) { - const response = await app().request(path, { - method: "POST", - headers: { ...headers, "content-type": "application/json" }, - body: JSON.stringify(body ?? {}), - }) - expect(response.status).toBe(200) - expect(await response.json()).toBe(true) -} - -afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await disposeAllInstances() - await resetDatabase() -}) - -describe("tui HttpApi bridge", () => { - test("documents legacy bad request responses", async () => { - const legacy = await Server.openapiHono() - const effect = OpenApi.fromApi(TuiApi) - for (const path of [TuiPaths.appendPrompt, TuiPaths.executeCommand, TuiPaths.publish, TuiPaths.selectSession]) { - expect(legacy.paths[path].post?.responses?.[400]).toBeDefined() - expect(effect.paths[path].post?.responses?.[400]).toBeDefined() - } - }) - - test("serves TUI command and event routes through experimental Effect routes", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const headers = { "x-opencode-directory": tmp.path } - - await expectTrue(TuiPaths.appendPrompt, headers, { text: "hello" }) - await expectTrue(TuiPaths.openHelp, headers) - await expectTrue(TuiPaths.openSessions, headers) - await expectTrue(TuiPaths.openThemes, headers) - await expectTrue(TuiPaths.openModels, headers) - await expectTrue(TuiPaths.submitPrompt, headers) - await expectTrue(TuiPaths.clearPrompt, headers) - await expectTrue(TuiPaths.executeCommand, headers, { command: "agent_cycle" }) - await expectTrue(TuiPaths.showToast, headers, { message: "Saved", variant: "success" }) - await expectTrue(TuiPaths.publish, headers, { - type: "tui.prompt.append", - properties: { text: "from publish" }, - }) - - const missingSessionID = SessionID.descending() - const missing = await app().request(TuiPaths.selectSession, { - method: "POST", - headers: { ...headers, "content-type": "application/json" }, - body: JSON.stringify({ sessionID: missingSessionID }), - }) - expect(missing.status).toBe(404) - }) - - test("matches Hono missing selected session error body", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } - const body = JSON.stringify({ sessionID: SessionID.descending() }) - - const hono = await app(false).request(TuiPaths.selectSession, { method: "POST", headers, body }) - const httpapi = await app().request(TuiPaths.selectSession, { method: "POST", headers, body }) - - expect(httpapi.status).toBe(hono.status) - expect(await httpapi.json()).toEqual(await hono.json()) - }) - - test("matches legacy unknown execute command behavior", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } - const body = JSON.stringify({ command: "unknown_command" }) - - const legacyCommand = nextCommandExecute() - const legacy = await app(false).request(TuiPaths.executeCommand, { method: "POST", headers, body }) - expect(legacy.status).toBe(200) - expect(await legacy.json()).toBe(true) - - const effectCommand = nextCommandExecute() - const effect = await app().request(TuiPaths.executeCommand, { method: "POST", headers, body }) - expect(effect.status).toBe(200) - expect(await effect.json()).toBe(true) - - const legacyPublished = await legacyCommand - const effectPublished = await effectCommand - expect(effectPublished).toBe(legacyPublished) - expect(legacyPublished).toBeUndefined() - }) - - test("serves TUI control queue through experimental Effect routes", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const pending = callTui({ req: { json: async () => ({ value: 1 }), path: "/demo" } } as unknown as Context) - const headers = { "x-opencode-directory": tmp.path } - - const next = await app().request(TuiPaths.controlNext, { headers }) - expect(next.status).toBe(200) - expect(await next.json()).toEqual({ path: "/demo", body: { value: 1 } }) - - await expectTrue(TuiPaths.controlResponse, headers, { ok: true }) - expect(await pending).toEqual({ ok: true }) - }) -}) diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 440aeaecb5..3cc8d3dce8 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -22,7 +22,6 @@ import { Server } from "../../src/server/server" void Log.init({ print: false }) const original = { - OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, OPENCODE_DISABLE_EMBEDDED_WEB_UI: Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI, OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, @@ -31,7 +30,6 @@ const original = { } afterEach(() => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = original.OPENCODE_DISABLE_EMBEDDED_WEB_UI Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME @@ -117,7 +115,6 @@ function httpClient(response: Response, onRequest?: (request: HttpClientRequest. describe("HttpApi UI fallback", () => { test("serves the web UI through the experimental backend", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true let proxiedUrl: string | undefined @@ -137,7 +134,6 @@ describe("HttpApi UI fallback", () => { }) test("strips upstream transfer encoding headers from proxied assets", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true let proxiedUrl: string | undefined @@ -189,7 +185,6 @@ describe("HttpApi UI fallback", () => { // forwarded through the proxy while the proxy itself re-frames the body, // causing browsers to fail with `ERR_INVALID_CHUNKED_ENCODING`. test("strips upstream transfer-encoding header from proxied assets", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true const response = await Effect.runPromise( @@ -232,7 +227,6 @@ describe("HttpApi UI fallback", () => { }) test("serves embedded UI assets when Bun can read them but access reports missing", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true let readPath: string | undefined const response = await Effect.runPromise( @@ -262,7 +256,6 @@ describe("HttpApi UI fallback", () => { }) test("allows embedded UI terminal wasm and theme preload CSP", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true const script = 'document.documentElement.dataset.theme = "dark"' const response = await Effect.runPromise( @@ -294,7 +287,6 @@ describe("HttpApi UI fallback", () => { }) test("keeps matched API routes ahead of the UI fallback", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true const response = await Server.Default().app.request("/session/nope") @@ -302,7 +294,6 @@ describe("HttpApi UI fallback", () => { }) test("requires server password for the web UI", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true const response = await uiApp({ password: "secret", username: "opencode" }).request("/") @@ -312,7 +303,6 @@ describe("HttpApi UI fallback", () => { }) test("accepts auth token for the web UI", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true const response = await uiApp({ @@ -326,7 +316,6 @@ describe("HttpApi UI fallback", () => { }) test("accepts basic auth for the web UI", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true const response = await uiApp({ password: "secret", username: "opencode" }).request("/", { @@ -342,7 +331,6 @@ describe("HttpApi UI fallback", () => { // server returning 401 breaks PWA install. These specific public assets // should bypass auth. test("serves the PWA manifest without auth even when a server password is set", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true for (const path of ["/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png"]) { @@ -356,7 +344,6 @@ describe("HttpApi UI fallback", () => { }) test("allows web UI preflight without auth", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true const response = await app({ password: "secret", username: "opencode" }).request("/", { method: "OPTIONS", diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 2e64081b8f..a2de1362fb 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -24,16 +24,14 @@ import { testEffect } from "../lib/effect" void Log.init({ print: false }) const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES -const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const workspaceLayer = Workspace.defaultLayer.pipe( Layer.provide(InstanceStore.defaultLayer), Layer.provide(InstanceBootstrap.defaultLayer), ) const it = testEffect(Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, workspaceLayer)) -function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) { +function request(path: string, directory: string, init: RequestInit = {}) { return Effect.promise(() => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpApi const headers = new Headers(init.headers) headers.set("x-opencode-directory", directory) return Promise.resolve(Server.Default().app.request(path, { ...init, headers })) @@ -161,7 +159,6 @@ function eventStreamResponse() { afterEach(async () => { mock.restore() Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi await disposeAllInstances() await resetDatabase() }) @@ -289,32 +286,6 @@ describe("workspace HttpApi", () => { }), ) - it.live("documents legacy Hono accepting the TUI payload shape", () => - Effect.gen(function* () { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true - const dir = yield* tmpdirScoped({ git: true }) - const project = yield* Project.use.fromDirectory(dir) - registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace"))) - - const created = yield* request( - WorkspacePaths.list, - dir, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "local-test", branch: null }), - }, - false, - ) - - expect(created.status).toBe(200) - expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ - type: "local-test", - name: "local-test", - }) - }), - ) - it.live("routes local workspace requests through the workspace target directory", () => Effect.gen(function* () { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true diff --git a/packages/opencode/test/server/trace-attributes.test.ts b/packages/opencode/test/server/trace-attributes.test.ts deleted file mode 100644 index c6e8005a20..0000000000 --- a/packages/opencode/test/server/trace-attributes.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { paramToAttributeKey, requestAttributes } from "../../src/server/routes/instance/trace" - -function fakeContext(method: string, url: string, params: Record) { - return { - req: { - method, - url, - param: () => params, - }, - } -} - -describe("paramToAttributeKey", () => { - test("converts fooID to foo.id", () => { - expect(paramToAttributeKey("sessionID")).toBe("session.id") - expect(paramToAttributeKey("messageID")).toBe("message.id") - expect(paramToAttributeKey("partID")).toBe("part.id") - expect(paramToAttributeKey("projectID")).toBe("project.id") - expect(paramToAttributeKey("providerID")).toBe("provider.id") - expect(paramToAttributeKey("ptyID")).toBe("pty.id") - expect(paramToAttributeKey("permissionID")).toBe("permission.id") - expect(paramToAttributeKey("requestID")).toBe("request.id") - expect(paramToAttributeKey("workspaceID")).toBe("workspace.id") - }) - - test("namespaces non-ID params under opencode.", () => { - expect(paramToAttributeKey("name")).toBe("opencode.name") - expect(paramToAttributeKey("slug")).toBe("opencode.slug") - }) -}) - -describe("requestAttributes", () => { - test("includes http method and path", () => { - const attrs = requestAttributes(fakeContext("GET", "http://localhost/session", {})) - expect(attrs["http.method"]).toBe("GET") - expect(attrs["http.path"]).toBe("/session") - }) - - test("strips query string from path", () => { - const attrs = requestAttributes(fakeContext("GET", "http://localhost/file/search?query=foo&limit=10", {})) - expect(attrs["http.path"]).toBe("/file/search") - }) - - test("emits OTel-style .id for ID-shaped route params", () => { - const attrs = requestAttributes( - fakeContext("GET", "http://localhost/session/ses_abc/message/msg_def/part/prt_ghi", { - sessionID: "ses_abc", - messageID: "msg_def", - partID: "prt_ghi", - }), - ) - expect(attrs["session.id"]).toBe("ses_abc") - expect(attrs["message.id"]).toBe("msg_def") - expect(attrs["part.id"]).toBe("prt_ghi") - // No camelCase leftovers: - expect(attrs["opencode.sessionID"]).toBeUndefined() - expect(attrs["opencode.messageID"]).toBeUndefined() - expect(attrs["opencode.partID"]).toBeUndefined() - }) - - test("produces no param attributes when no params are matched", () => { - const attrs = requestAttributes(fakeContext("POST", "http://localhost/config", {})) - expect(Object.keys(attrs).filter((k) => k !== "http.method" && k !== "http.path")).toEqual([]) - }) - - test("namespaces non-ID params under opencode. (e.g. mcp :name)", () => { - const attrs = requestAttributes( - fakeContext("POST", "http://localhost/mcp/exa/connect", { - name: "exa", - }), - ) - expect(attrs["opencode.name"]).toBe("exa") - expect(attrs["name"]).toBeUndefined() - }) -}) diff --git a/packages/opencode/test/server/worktree-endpoint-repro.test.ts b/packages/opencode/test/server/worktree-endpoint-repro.test.ts index 768a261a00..e95d706d54 100644 --- a/packages/opencode/test/server/worktree-endpoint-repro.test.ts +++ b/packages/opencode/test/server/worktree-endpoint-repro.test.ts @@ -13,16 +13,13 @@ import { testEffect } from "../lib/effect" const stateLayer = Layer.effectDiscard( Effect.gen(function* () { const original = { - OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, OPENCODE_EXPERIMENTAL_WORKSPACES: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, } - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true yield* Effect.addFinalizer(() => Effect.promise(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original.OPENCODE_EXPERIMENTAL_WORKSPACES await resetDatabase() }), diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index 946ad1402b..b3f74a1bf6 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -9,16 +9,9 @@ import path from "path" import { createClient } from "@hey-api/openapi-ts" -const openapiSource = process.env.OPENCODE_SDK_OPENAPI === "hono" ? "hono" : "httpapi" const opencode = path.resolve(dir, "../../opencode") -// `bun dev generate` now derives the spec from the Effect HttpApi contract by -// default; pass `--hono` to fall back to the legacy Hono spec for parity diffs. -if (openapiSource === "httpapi") { - await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode) -} else { - await $`bun dev generate --hono > ${dir}/openapi.json`.cwd(opencode) -} +await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode) await createClient({ input: "./openapi.json",