mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-05-05 00:03:03 +08:00
Compare commits
3 Commits
beta
...
kit/delete
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ae4bfbe26 | ||
|
|
0fe2993fa4 | ||
|
|
b16397efe8 |
@@ -158,13 +158,11 @@ export async function handler(
|
||||
Object.entries(obj).flatMap(([k, v]) => {
|
||||
if (Array.isArray(v)) return [[k, v]]
|
||||
if (typeof v === "object") return [[k, replacer(v)]]
|
||||
if (typeof v === "string") {
|
||||
if (v === "$ip") return [[k, ip]]
|
||||
if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : []
|
||||
if (v.startsWith("$header.")) {
|
||||
const headerValue = input.request.headers.get(v.slice(8))
|
||||
return headerValue ? [[k, headerValue]] : []
|
||||
}
|
||||
if (v === "$ip") return [[k, ip]]
|
||||
if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : []
|
||||
if (v.startsWith("$header.")) {
|
||||
const headerValue = input.request.headers.get(v.slice(8))
|
||||
return headerValue ? [[k, headerValue]] : []
|
||||
}
|
||||
return [[k, v]]
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
@@ -86,14 +81,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"),
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -106,10 +101,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",
|
||||
@@ -149,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",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,13 @@
|
||||
import { Server } from "../../server/server"
|
||||
import type { CommandModule } from "yargs"
|
||||
|
||||
type Args = {
|
||||
httpapi: boolean
|
||||
hono: boolean
|
||||
}
|
||||
type Args = {}
|
||||
|
||||
export const GenerateCommand = {
|
||||
command: "generate",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option("httpapi", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description:
|
||||
"Generate OpenAPI from the Effect HttpApi contract (default; flag retained for backwards compatibility)",
|
||||
})
|
||||
.option("hono", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Generate OpenAPI from the legacy Hono backend (parity-diff only; will be removed)",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const specs = args.hono ? await Server.openapiHono() : await Server.openapi()
|
||||
builder: (yargs) => yargs,
|
||||
handler: async () => {
|
||||
const specs = (await Server.openapi()) as { paths: Record<string, Record<string, any>> }
|
||||
for (const item of Object.values(specs.paths)) {
|
||||
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
||||
const operation = item[method]
|
||||
|
||||
@@ -779,15 +779,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: kv.get("clear_prompt_save_history", false) ? "Don't include cleared prompts in history" : "Include cleared prompts in history",
|
||||
value: "app.toggle.clear_prompt_history",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
kv.set("clear_prompt_save_history", !kv.get("clear_prompt_save_history", false))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||
|
||||
@@ -82,7 +82,6 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
|
||||
return store.history.at(store.index)
|
||||
},
|
||||
append(item: PromptInfo) {
|
||||
if (store.history.at(-1)?.input === item.input) return
|
||||
const entry = structuredClone(unwrap(item))
|
||||
let trimmed = false
|
||||
setStore(
|
||||
|
||||
@@ -136,7 +136,6 @@ export function Prompt(props: PromptProps) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme, syntax } = useTheme()
|
||||
const kv = useKV()
|
||||
const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
|
||||
const list = createMemo(() => props.placeholders?.normal ?? [])
|
||||
const shell = createMemo(() => props.placeholders?.shell ?? [])
|
||||
@@ -175,7 +174,7 @@ export function Prompt(props: PromptProps) {
|
||||
let lastSubmittedEditorSelectionKey: string | undefined
|
||||
const [auto, setAuto] = createSignal<AutocompleteRef>()
|
||||
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
|
||||
const hasRightContent = createMemo(() => Boolean(props.right) || autoaccept() === "edit")
|
||||
const hasRightContent = createMemo(() => Boolean(props.right))
|
||||
|
||||
function promptModelWarning() {
|
||||
toast.show({
|
||||
@@ -297,16 +296,6 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
command.register(() => {
|
||||
return [
|
||||
{
|
||||
title: autoaccept() === "none" ? "Enable auto-accept edits" : "Disable auto-accept edits",
|
||||
value: "permission.auto_accept.toggle",
|
||||
search: "toggle permissions",
|
||||
category: "Agent",
|
||||
onSelect: (dialog) => {
|
||||
setAutoaccept(() => (autoaccept() === "none" ? "edit" : "none"))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Clear prompt",
|
||||
value: "prompt.clear",
|
||||
@@ -1135,12 +1124,6 @@ export function Prompt(props: PromptProps) {
|
||||
// If no image, let the default paste behavior continue
|
||||
}
|
||||
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
|
||||
if (kv.get("clear_prompt_save_history", false)) {
|
||||
history.append({
|
||||
...store.prompt,
|
||||
mode: store.mode,
|
||||
})
|
||||
}
|
||||
input.clear()
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", {
|
||||
@@ -1335,11 +1318,6 @@ export function Prompt(props: PromptProps) {
|
||||
</box>
|
||||
<Show when={hasRightContent()}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<Show when={autoaccept() === "edit"}>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning }}>autoedit</span>
|
||||
</text>
|
||||
</Show>
|
||||
{props.right}
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -110,7 +110,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const project = useProject()
|
||||
const sdk = useSDK()
|
||||
const kv = useKV()
|
||||
const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
|
||||
const fullSyncedSessions = new Set<string>()
|
||||
let syncedWorkspace = project.workspace.current()
|
||||
@@ -153,13 +152,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
|
||||
case "permission.asked": {
|
||||
const request = event.properties
|
||||
if (autoaccept() === "edit" && request.permission === "edit") {
|
||||
void sdk.client.permission.reply({
|
||||
reply: "once",
|
||||
requestID: request.id,
|
||||
})
|
||||
break
|
||||
}
|
||||
const requests = store.permission[request.sessionID]
|
||||
if (!requests) {
|
||||
setStore("permission", request.sessionID, [request])
|
||||
|
||||
@@ -8,7 +8,6 @@ import { UI } from "@/cli/ui"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { WithInstance } from "@/project/with-instance"
|
||||
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
@@ -191,11 +190,7 @@ export const TuiThreadCommand = cmd({
|
||||
const prompt = await input(args.prompt)
|
||||
const config = await TuiConfig.get()
|
||||
|
||||
const network = await WithInstance.provide({
|
||||
directory: cwd,
|
||||
fn: () => resolveNetworkOptionsNoConfig(args),
|
||||
})
|
||||
|
||||
const network = resolveNetworkOptionsNoConfig(args)
|
||||
const external =
|
||||
process.argv.includes("--port") ||
|
||||
process.argv.includes("--hostname") ||
|
||||
|
||||
@@ -37,7 +37,6 @@ export interface DialogSelectOption<T = any> {
|
||||
title: string
|
||||
value: T
|
||||
description?: string
|
||||
search?: string
|
||||
footer?: JSX.Element | string
|
||||
category?: string
|
||||
categoryView?: JSX.Element
|
||||
@@ -94,8 +93,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
// users typically search by the item name, and not its category.
|
||||
const result = fuzzysort
|
||||
.go(needle, options, {
|
||||
keys: ["title", "category", "search"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score + r[2].score,
|
||||
keys: ["title", "category"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score,
|
||||
})
|
||||
.map((x) => x.obj)
|
||||
|
||||
|
||||
@@ -48,37 +48,19 @@ import { Worktree } from "@/worktree"
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyTicket } from "@/pty/ticket"
|
||||
import { Installation } from "@/installation"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { SessionShare } from "@/share/session"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
|
||||
// Adjusts the default Config layer to ensure that plugins are always initialised before
|
||||
// any other layers read the current config
|
||||
const ConfigWithPluginPriority = Layer.effect(
|
||||
Config.Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
|
||||
return {
|
||||
...config,
|
||||
get: () => Effect.andThen(plugin.init(), config.get),
|
||||
getGlobal: () => Effect.andThen(plugin.init(), config.getGlobal),
|
||||
getConsoleState: () => Effect.andThen(plugin.init(), config.getConsoleState),
|
||||
}
|
||||
}),
|
||||
).pipe(Layer.provide(Layer.merge(Plugin.defaultLayer, Config.defaultLayer)))
|
||||
|
||||
export const AppLayer = Layer.mergeAll(
|
||||
Npm.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
Bus.defaultLayer,
|
||||
Auth.defaultLayer,
|
||||
Account.defaultLayer,
|
||||
ConfigWithPluginPriority,
|
||||
Config.defaultLayer,
|
||||
Git.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
File.defaultLayer,
|
||||
|
||||
@@ -138,14 +138,6 @@ function useLanguageModel(sdk: any) {
|
||||
return sdk.responses === undefined && sdk.chat === undefined
|
||||
}
|
||||
|
||||
function selectAzureLanguageModel(sdk: any, modelID: string, useChat: boolean) {
|
||||
if (useChat && sdk.chat) return sdk.chat(modelID)
|
||||
if (sdk.responses) return sdk.responses(modelID)
|
||||
if (sdk.messages) return sdk.messages(modelID)
|
||||
if (sdk.chat) return sdk.chat(modelID)
|
||||
return sdk.languageModel(modelID)
|
||||
}
|
||||
|
||||
function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
return {
|
||||
anthropic: () =>
|
||||
@@ -230,7 +222,12 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||
return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"]))
|
||||
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
|
||||
if (options?.["useCompletionUrls"]) {
|
||||
return sdk.chat(modelID)
|
||||
} else {
|
||||
return sdk.responses(modelID)
|
||||
}
|
||||
},
|
||||
options: {
|
||||
resourceName: resource,
|
||||
@@ -250,7 +247,12 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||
return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"]))
|
||||
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
|
||||
if (options?.["useCompletionUrls"]) {
|
||||
return sdk.chat(modelID)
|
||||
} else {
|
||||
return sdk.responses(modelID)
|
||||
}
|
||||
},
|
||||
options: {
|
||||
baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { Hono } from "hono"
|
||||
import { createBunWebSocket } from "hono/bun"
|
||||
import type { Adapter, FetchApp, Opts } from "./adapter"
|
||||
|
||||
function listen(app: FetchApp, opts: Opts, websocket?: ReturnType<typeof createBunWebSocket>["websocket"]) {
|
||||
const start = (port: number) => {
|
||||
try {
|
||||
if (websocket) {
|
||||
return Bun.serve({ fetch: app.fetch, hostname: opts.hostname, idleTimeout: 0, websocket, port })
|
||||
}
|
||||
return Bun.serve({ fetch: app.fetch, hostname: opts.hostname, idleTimeout: 0, port })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port)
|
||||
if (!server) {
|
||||
throw new Error(`Failed to start server on port ${opts.port}`)
|
||||
}
|
||||
if (!server.port) {
|
||||
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||
}
|
||||
return {
|
||||
port: server.port,
|
||||
stop(close?: boolean) {
|
||||
return Promise.resolve(server.stop(close))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const adapter: Adapter = {
|
||||
create(app: Hono) {
|
||||
const ws = createBunWebSocket()
|
||||
return {
|
||||
upgradeWebSocket: ws.upgradeWebSocket,
|
||||
listen: (opts) => Promise.resolve(listen(app, opts, ws.websocket)),
|
||||
}
|
||||
},
|
||||
createFetch(app) {
|
||||
return {
|
||||
listen: (opts) => Promise.resolve(listen(app, opts)),
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
||||
import { createNodeWebSocket } from "@hono/node-ws"
|
||||
import type { Hono } from "hono"
|
||||
import type { Adapter, FetchApp, Opts } from "./adapter"
|
||||
|
||||
async function listen(app: FetchApp, opts: Opts, inject?: (server: ServerType) => void) {
|
||||
const start = (port: number) =>
|
||||
new Promise<ServerType>((resolve, reject) => {
|
||||
const server = createAdaptorServer({ fetch: app.fetch })
|
||||
inject?.(server)
|
||||
const fail = (err: Error) => {
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
const ready = () => {
|
||||
cleanup()
|
||||
resolve(server)
|
||||
}
|
||||
const cleanup = () => {
|
||||
server.off("error", fail)
|
||||
server.off("listening", ready)
|
||||
}
|
||||
server.once("error", fail)
|
||||
server.once("listening", ready)
|
||||
server.listen(port, opts.hostname)
|
||||
})
|
||||
|
||||
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
|
||||
const addr = server.address()
|
||||
if (!addr || typeof addr === "string") {
|
||||
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||
}
|
||||
|
||||
let closing: Promise<void> | undefined
|
||||
return {
|
||||
port: addr.port,
|
||||
stop(close?: boolean) {
|
||||
closing ??= new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
if (close) {
|
||||
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
|
||||
server.closeAllConnections()
|
||||
}
|
||||
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
|
||||
server.closeIdleConnections()
|
||||
}
|
||||
}
|
||||
})
|
||||
return closing
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const adapter: Adapter = {
|
||||
create(app: Hono) {
|
||||
const ws = createNodeWebSocket({ app })
|
||||
return {
|
||||
upgradeWebSocket: ws.upgradeWebSocket,
|
||||
listen: (opts) => listen(app, opts, ws.injectWebSocket),
|
||||
}
|
||||
},
|
||||
createFetch(app) {
|
||||
return {
|
||||
listen: (opts) => listen(app, opts),
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { Hono } from "hono"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
|
||||
export type FetchApp = {
|
||||
fetch(request: Request): Response | Promise<Response>
|
||||
}
|
||||
|
||||
export type Opts = {
|
||||
port: number
|
||||
hostname: string
|
||||
}
|
||||
|
||||
export type Listener = {
|
||||
port: number
|
||||
stop: (close?: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export interface Runtime {
|
||||
upgradeWebSocket: UpgradeWebSocket
|
||||
listen(opts: Opts): Promise<Listener>
|
||||
}
|
||||
|
||||
export interface Adapter {
|
||||
create(app: Hono): Runtime
|
||||
createFetch(app: FetchApp): Omit<Runtime, "upgradeWebSocket">
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
|
||||
export type Backend = "effect-httpapi" | "hono"
|
||||
|
||||
export type Selection = {
|
||||
backend: Backend
|
||||
reason: "env" | "stable" | "explicit"
|
||||
}
|
||||
|
||||
export type Attributes = ReturnType<typeof attributes>
|
||||
|
||||
export function select(): Selection {
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) return { backend: "effect-httpapi", reason: "env" }
|
||||
return { backend: "hono", reason: "stable" }
|
||||
}
|
||||
|
||||
export function attributes(selection: Selection): Record<string, string> {
|
||||
return {
|
||||
"opencode.server.backend": selection.backend,
|
||||
"opencode.server.backend.reason": selection.reason,
|
||||
"opencode.installation.channel": InstallationChannel,
|
||||
"opencode.installation.version": InstallationVersion,
|
||||
}
|
||||
}
|
||||
|
||||
export function force(selection: Selection, backend: Backend): Selection {
|
||||
return {
|
||||
backend,
|
||||
reason: selection.backend === backend ? selection.reason : "explicit",
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
|
||||
export const ERRORS = {
|
||||
400: {
|
||||
description: "Bad request",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z
|
||||
.object({
|
||||
data: z.any(),
|
||||
errors: z.array(z.record(z.string(), z.any())),
|
||||
success: z.literal(false),
|
||||
})
|
||||
.meta({
|
||||
ref: "BadRequestError",
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "Forbidden",
|
||||
},
|
||||
404: {
|
||||
description: "Not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(NotFoundError.Schema),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export function errors(...codes: number[]) {
|
||||
return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]]))
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { HEADER, diff, load } from "./shared/fence"
|
||||
|
||||
const log = Log.create({ service: "fence-middleware" })
|
||||
|
||||
export const FenceMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") return next()
|
||||
|
||||
const prev = load()
|
||||
await next()
|
||||
const current = diff(prev, load())
|
||||
|
||||
if (Object.keys(current).length > 0) {
|
||||
log.info("header", {
|
||||
diff: current,
|
||||
})
|
||||
c.res.headers.set(HEADER, JSON.stringify(current))
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { createServer } from "node:http"
|
||||
import type { Opts } from "./adapter"
|
||||
import { Service } from "./httpapi-server"
|
||||
|
||||
export { Service }
|
||||
|
||||
export const name = "node-http-server"
|
||||
|
||||
export type Opts = { port: number; hostname: string }
|
||||
|
||||
export const layer = (opts: Opts) => {
|
||||
const server = createServer()
|
||||
const serverRef = { closeStarted: false, forceStop: false }
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import { Session } from "@/session/session"
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||
import type { ErrorHandler, MiddlewareHandler } from "hono"
|
||||
import { HTTPException } from "hono/http-exception"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { basicAuth } from "hono/basic-auth"
|
||||
import { cors } from "hono/cors"
|
||||
import { compress } from "hono/compress"
|
||||
import * as ServerBackend from "./backend"
|
||||
import { isAllowedCorsOrigin, type CorsOptions } from "./cors"
|
||||
import { isPtyConnectPath, PTY_CONNECT_TICKET_QUERY } from "./shared/pty-ticket"
|
||||
import { isPublicUIPath } from "./shared/public-ui"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
export const ErrorMiddleware: ErrorHandler = (err, c) => {
|
||||
log.error("failed", {
|
||||
error: err,
|
||||
})
|
||||
if (err instanceof NamedError) {
|
||||
let status: ContentfulStatusCode
|
||||
if (err instanceof NotFoundError) status = 404
|
||||
else if (err instanceof Provider.ModelNotFoundError) status = 400
|
||||
else if (err.name === "ProviderAuthValidationFailed") status = 400
|
||||
else if (err.name.startsWith("Worktree")) status = 400
|
||||
else status = 500
|
||||
return c.json(err.toObject(), { status })
|
||||
}
|
||||
if (err instanceof Session.BusyError) {
|
||||
return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 })
|
||||
}
|
||||
if (err instanceof HTTPException) return err.getResponse()
|
||||
const message = err instanceof Error && err.stack ? err.stack : err.toString()
|
||||
return c.json(new NamedError.Unknown({ message }).toObject(), {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
|
||||
export const AuthMiddleware: MiddlewareHandler = (c, next) => {
|
||||
// Allow CORS preflight requests to succeed without auth.
|
||||
// Browser clients sending Authorization headers will preflight with OPTIONS.
|
||||
if (c.req.method === "OPTIONS") return next()
|
||||
const password = Flag.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return next()
|
||||
if (isPublicUIPath(c.req.method, c.req.path)) return next()
|
||||
if (isPtyConnectPath(c.req.path) && c.req.query(PTY_CONNECT_TICKET_QUERY)) return next()
|
||||
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||
|
||||
if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
|
||||
|
||||
return basicAuth({ username, password })(c, next)
|
||||
}
|
||||
|
||||
export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const skip = c.req.path === "/log"
|
||||
if (skip) return next()
|
||||
const attributes = {
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
// If this logger grows full-URL fields, redact auth_token and ticket query params.
|
||||
...backendAttributes,
|
||||
}
|
||||
log.info("request", attributes)
|
||||
const timer = log.time("request", attributes)
|
||||
await next()
|
||||
timer.stop()
|
||||
}
|
||||
}
|
||||
|
||||
export function CorsMiddleware(opts?: CorsOptions): MiddlewareHandler {
|
||||
return cors({
|
||||
maxAge: 86_400,
|
||||
origin(input) {
|
||||
if (isAllowedCorsOrigin(input, opts)) return input
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const zipped = compress()
|
||||
export const CompressionMiddleware: MiddlewareHandler = (c, next) => {
|
||||
const path = c.req.path
|
||||
const method = c.req.method
|
||||
if (path === "/event" || path === "/global/event") return next()
|
||||
if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return next()
|
||||
return zipped(c, next)
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import * as Fence from "./shared/fence"
|
||||
import type { WorkspaceID } from "@/control-plane/schema"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { ProxyUtil } from "./proxy-util"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
|
||||
type Msg = string | ArrayBuffer | Uint8Array
|
||||
|
||||
function send(ws: { send(data: string | ArrayBuffer | Uint8Array): void }, data: any) {
|
||||
if (data instanceof Blob) {
|
||||
return data.arrayBuffer().then((x) => ws.send(x))
|
||||
}
|
||||
return ws.send(data)
|
||||
}
|
||||
|
||||
const app = (upgrade: UpgradeWebSocket) =>
|
||||
new Hono().get(
|
||||
"/__workspace_ws",
|
||||
upgrade((c) => {
|
||||
const url = c.req.header("x-opencode-proxy-url")
|
||||
const queue: Msg[] = []
|
||||
let remote: WebSocket | undefined
|
||||
return {
|
||||
onOpen(_, ws) {
|
||||
if (!url) {
|
||||
ws.close(1011, "missing proxy target")
|
||||
return
|
||||
}
|
||||
remote = new WebSocket(url, ProxyUtil.websocketProtocols(c.req.raw))
|
||||
remote.binaryType = "arraybuffer"
|
||||
remote.onopen = () => {
|
||||
for (const item of queue) remote?.send(item)
|
||||
queue.length = 0
|
||||
}
|
||||
remote.onmessage = (event) => {
|
||||
void send(ws, event.data)
|
||||
}
|
||||
remote.onerror = () => {
|
||||
ws.close(1011, "proxy error")
|
||||
}
|
||||
remote.onclose = (event) => {
|
||||
ws.close(event.code, event.reason)
|
||||
}
|
||||
},
|
||||
onMessage(event) {
|
||||
const data = event.data
|
||||
if (typeof data !== "string" && !(data instanceof Uint8Array) && !(data instanceof ArrayBuffer)) return
|
||||
if (remote?.readyState === WebSocket.OPEN) {
|
||||
remote.send(data)
|
||||
return
|
||||
}
|
||||
queue.push(data)
|
||||
},
|
||||
onClose(event) {
|
||||
remote?.close(event.code, event.reason)
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const log = Log.create({ service: "server-proxy" })
|
||||
|
||||
function statusText(response: unknown) {
|
||||
return (response as { source?: Response }).source?.statusText
|
||||
}
|
||||
|
||||
export function httpEffect(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) {
|
||||
return Effect.gen(function* () {
|
||||
const syncing = yield* Workspace.Service.use((workspace) => workspace.isSyncing(workspaceID))
|
||||
if (!syncing) {
|
||||
return new Response(`broken sync connection for workspace: ${workspaceID}`, {
|
||||
status: 503,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const response = yield* HttpClient.execute(
|
||||
HttpClientRequest.make(req.method as never)(url, {
|
||||
headers: ProxyUtil.headers(req, extra),
|
||||
body:
|
||||
req.method === "GET" || req.method === "HEAD"
|
||||
? HttpBody.empty
|
||||
: HttpBody.raw(req.body, {
|
||||
contentType: req.headers.get("content-type") ?? undefined,
|
||||
contentLength: req.headers.get("content-length")
|
||||
? Number(req.headers.get("content-length"))
|
||||
: undefined,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const next = new Headers(response.headers as HeadersInit)
|
||||
const sync = Fence.parse(next)
|
||||
next.delete("content-encoding")
|
||||
next.delete("content-length")
|
||||
|
||||
if (sync) yield* Fence.waitEffect(workspaceID, sync, req.signal)
|
||||
const body = yield* Stream.toReadableStreamEffect(response.stream.pipe(Stream.catchCause(() => Stream.empty)))
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
statusText: statusText(response),
|
||||
headers: next,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(FetchHttpClient.layer),
|
||||
Effect.catch(() => Effect.succeed(new Response(null, { status: 500 }))),
|
||||
)
|
||||
}
|
||||
|
||||
export function http(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) {
|
||||
return AppRuntime.runPromise(httpEffect(url, extra, req, workspaceID))
|
||||
}
|
||||
|
||||
export function websocket(
|
||||
upgrade: UpgradeWebSocket,
|
||||
target: string | URL,
|
||||
extra: HeadersInit | undefined,
|
||||
req: Request,
|
||||
env: unknown,
|
||||
) {
|
||||
const proxy = new URL(req.url)
|
||||
proxy.pathname = "/__workspace_ws"
|
||||
proxy.search = ""
|
||||
const next = new Headers(req.headers)
|
||||
next.set("x-opencode-proxy-url", ProxyUtil.websocketTargetURL(target))
|
||||
for (const [key, value] of new Headers(extra).entries()) {
|
||||
next.set(key, value)
|
||||
}
|
||||
log.info("proxy websocket", {
|
||||
request: req.url,
|
||||
target: String(target),
|
||||
})
|
||||
return app(upgrade).fetch(
|
||||
new Request(proxy, {
|
||||
method: req.method,
|
||||
headers: next,
|
||||
signal: req.signal,
|
||||
}),
|
||||
env as never,
|
||||
)
|
||||
}
|
||||
|
||||
export * as ServerProxy from "./proxy"
|
||||
@@ -1,160 +0,0 @@
|
||||
import { Auth } from "@/auth"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Effect } from "effect"
|
||||
import { ProviderID } from "@/provider/schema"
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { errors } from "../../error"
|
||||
|
||||
export function ControlPlaneRoutes(): Hono {
|
||||
const app = new Hono()
|
||||
return app
|
||||
.put(
|
||||
"/auth/:providerID",
|
||||
describeRoute({
|
||||
summary: "Set auth credentials",
|
||||
description: "Set authentication credentials",
|
||||
operationId: "auth.set",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully set authentication credentials",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", Auth.Info.zod),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const info = c.req.valid("json")
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.set(providerID, info)
|
||||
}),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/auth/:providerID",
|
||||
describeRoute({
|
||||
summary: "Remove auth credentials",
|
||||
description: "Remove authentication credentials",
|
||||
operationId: "auth.remove",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully removed authentication credentials",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.remove(providerID)
|
||||
}),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/doc",
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: "opencode",
|
||||
version: "0.0.3",
|
||||
description: "opencode api",
|
||||
},
|
||||
openapi: "3.1.1",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.use(
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
directory: z.string().optional(),
|
||||
workspace: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.post(
|
||||
"/log",
|
||||
describeRoute({
|
||||
summary: "Write log",
|
||||
description: "Write a log entry to the server logs with specified level and metadata.",
|
||||
operationId: "app.log",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Log entry written successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
service: z.string().meta({ description: "Service name for the log entry" }),
|
||||
level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
|
||||
message: z.string().meta({ description: "Log message" }),
|
||||
extra: z
|
||||
.record(z.string(), z.any())
|
||||
.optional()
|
||||
.meta({ description: "Additional metadata for the log entry" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { service, level, message, extra } = c.req.valid("json")
|
||||
const logger = Log.create({ service })
|
||||
|
||||
switch (level) {
|
||||
case "debug":
|
||||
logger.debug(message, extra)
|
||||
break
|
||||
case "info":
|
||||
logger.info(message, extra)
|
||||
break
|
||||
case "error":
|
||||
logger.error(message, extra)
|
||||
break
|
||||
case "warn":
|
||||
logger.warn(message, extra)
|
||||
break
|
||||
}
|
||||
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,210 +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 { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { errorData } from "@/util/error"
|
||||
|
||||
const log = Log.create({ service: "server.workspace" })
|
||||
|
||||
export const WorkspaceRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/adapter",
|
||||
describeRoute({
|
||||
summary: "List workspace adapters",
|
||||
description: "List all available workspace adapters for the current project.",
|
||||
operationId: "experimental.workspace.adapter.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Workspace adapters",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(zodObject(WorkspaceAdapterEntry))),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await listAdapters(Instance.project.id))
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "Create workspace",
|
||||
description: "Create a workspace for the current project.",
|
||||
operationId: "experimental.workspace.create",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Workspace created",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Workspace.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
Workspace.CreateInput.zodObject.omit({
|
||||
projectID: true,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json") as Omit<Workspace.CreateInput, "projectID">
|
||||
const workspace = await AppRuntime.runPromise(
|
||||
Workspace.Service.use((svc) =>
|
||||
svc.create({
|
||||
projectID: Instance.project.id,
|
||||
...body,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(workspace)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List workspaces",
|
||||
description: "List all workspaces.",
|
||||
operationId: "experimental.workspace.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Workspaces",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(Workspace.Info.zod)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.list(Instance.project))))
|
||||
},
|
||||
)
|
||||
.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(
|
||||
"/:id/session-restore",
|
||||
describeRoute({
|
||||
summary: "Restore session into workspace",
|
||||
description: "Replay a session's sync events into the target workspace in batches.",
|
||||
operationId: "experimental.workspace.sessionRestore",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Session replay started",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
total: z.number().int().min(0),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ id: zodObject(Workspace.Info).shape.id })),
|
||||
validator("json", Workspace.SessionRestoreInput.zodObject.omit({ workspaceID: true })),
|
||||
async (c) => {
|
||||
const { id } = c.req.valid("param")
|
||||
const body = c.req.valid("json") as Omit<Workspace.SessionRestoreInput, "workspaceID">
|
||||
log.info("session restore route requested", {
|
||||
workspaceID: id,
|
||||
sessionID: body.sessionID,
|
||||
directory: Instance.directory,
|
||||
})
|
||||
try {
|
||||
const result = await AppRuntime.runPromise(
|
||||
Workspace.Service.use((svc) =>
|
||||
svc.sessionRestore({
|
||||
workspaceID: id,
|
||||
...body,
|
||||
}),
|
||||
),
|
||||
)
|
||||
log.info("session restore route complete", {
|
||||
workspaceID: id,
|
||||
sessionID: body.sessionID,
|
||||
total: result.total,
|
||||
})
|
||||
return c.json(result)
|
||||
} catch (err) {
|
||||
log.error("session restore route failed", {
|
||||
workspaceID: id,
|
||||
sessionID: body.sessionID,
|
||||
error: errorData(err),
|
||||
})
|
||||
throw err
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -1,286 +0,0 @@
|
||||
import { Hono, type Context } from "hono"
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import { streamSSE } from "hono/streaming"
|
||||
import { Effect } from "effect"
|
||||
import z from "zod"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Bus } from "@/bus"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { AsyncQueue } from "@/util/queue"
|
||||
import { Installation } from "@/installation"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { Config } from "@/config/config"
|
||||
import { errors } from "../error"
|
||||
import { disposeAllInstancesAndEmitGlobalDisposed } from "../global-lifecycle"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
async function streamEvents(c: Context, subscribe: (q: AsyncQueue<string | null>) => () => void) {
|
||||
return streamSSE(c, async (stream) => {
|
||||
const q = new AsyncQueue<string | null>()
|
||||
let done = false
|
||||
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
payload: {
|
||||
id: Bus.createID(),
|
||||
type: "server.connected",
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// Send heartbeat every 10s to prevent stalled proxy streams.
|
||||
const heartbeat = setInterval(() => {
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
payload: {
|
||||
id: Bus.createID(),
|
||||
type: "server.heartbeat",
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}, 10_000)
|
||||
|
||||
const stop = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
clearInterval(heartbeat)
|
||||
unsub()
|
||||
q.push(null)
|
||||
log.info("global event disconnected")
|
||||
}
|
||||
|
||||
const unsub = subscribe(q)
|
||||
|
||||
stream.onAbort(stop)
|
||||
|
||||
try {
|
||||
for await (const data of q) {
|
||||
if (data === null) return
|
||||
await stream.writeSSE({ data })
|
||||
}
|
||||
} finally {
|
||||
stop()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const GlobalRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/health",
|
||||
describeRoute({
|
||||
summary: "Get health",
|
||||
description: "Get health information about the OpenCode server.",
|
||||
operationId: "global.health",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Health information",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ healthy: z.literal(true), version: z.string() })),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json({ healthy: true, version: InstallationVersion })
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/event",
|
||||
describeRoute({
|
||||
summary: "Get global events",
|
||||
description: "Subscribe to global events from the OpenCode system using server-sent events.",
|
||||
operationId: "global.event",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Event stream",
|
||||
content: {
|
||||
"text/event-stream": {
|
||||
schema: resolver(
|
||||
z
|
||||
.object({
|
||||
directory: z.string(),
|
||||
project: z.string().optional(),
|
||||
workspace: z.string().optional(),
|
||||
payload: z.union([...BusEvent.payloads(), ...SyncEvent.payloads()]),
|
||||
})
|
||||
.meta({
|
||||
ref: "GlobalEvent",
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
log.info("global event connected")
|
||||
c.header("Cache-Control", "no-cache, no-transform")
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
|
||||
return streamEvents(c, (q) => {
|
||||
async function handler(event: any) {
|
||||
q.push(JSON.stringify(event))
|
||||
}
|
||||
GlobalBus.on("event", handler)
|
||||
return () => GlobalBus.off("event", handler)
|
||||
})
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/config",
|
||||
describeRoute({
|
||||
summary: "Get global configuration",
|
||||
description: "Retrieve the current global OpenCode configuration settings and preferences.",
|
||||
operationId: "global.config.get",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Get global config info",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Config.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())))
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/config",
|
||||
describeRoute({
|
||||
summary: "Update global configuration",
|
||||
description: "Update global OpenCode configuration settings and preferences.",
|
||||
operationId: "global.config.update",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully updated global config",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Config.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Config.Info.zod),
|
||||
async (c) => {
|
||||
const config = c.req.valid("json")
|
||||
const result = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config)))
|
||||
if (result.changed) {
|
||||
void AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })).catch(
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
return c.json(result.info)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/dispose",
|
||||
describeRoute({
|
||||
summary: "Dispose instance",
|
||||
description: "Clean up and dispose all OpenCode instances, releasing all resources.",
|
||||
operationId: "global.dispose",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Global disposed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed())
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/upgrade",
|
||||
describeRoute({
|
||||
summary: "Upgrade opencode",
|
||||
description: "Upgrade opencode to the specified version or latest if not specified.",
|
||||
operationId: "global.upgrade",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Upgrade result",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.union([
|
||||
z.object({
|
||||
success: z.literal(true),
|
||||
version: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
success: z.literal(false),
|
||||
error: z.string(),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
target: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const result = await AppRuntime.runPromise(
|
||||
Installation.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const method = yield* svc.method()
|
||||
if (method === "unknown") {
|
||||
return { success: false as const, status: 400 as const, error: "Unknown installation method" }
|
||||
}
|
||||
|
||||
const target = c.req.valid("json").target || (yield* svc.latest(method))
|
||||
const result = yield* Effect.catch(
|
||||
svc.upgrade(method, target).pipe(Effect.as({ success: true as const, version: target })),
|
||||
(err) =>
|
||||
Effect.succeed({
|
||||
success: false as const,
|
||||
status: 500 as const,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
)
|
||||
if (!result.success) return result
|
||||
return { ...result, status: 200 as const }
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (!result.success) {
|
||||
return c.json({ success: false, error: result.error }, result.status)
|
||||
}
|
||||
const target = result.version
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: Installation.Event.Updated.type,
|
||||
properties: { version: target },
|
||||
},
|
||||
})
|
||||
return c.json({ success: true, version: target })
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
import { Effect } from "effect"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
const log = Log.create({ service: "server.config" })
|
||||
|
||||
export const ConfigRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "Get configuration",
|
||||
description: "Retrieve the current OpenCode configuration settings and preferences.",
|
||||
operationId: "config.get",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Get config info",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Config.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ConfigRoutes.get", c, function* () {
|
||||
const cfg = yield* Config.Service
|
||||
return yield* cfg.get()
|
||||
}),
|
||||
)
|
||||
.patch(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "Update configuration",
|
||||
description: "Update OpenCode configuration settings and preferences.",
|
||||
operationId: "config.update",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully updated config",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Config.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Config.Info.zod),
|
||||
async (c) => {
|
||||
const result = await runRequest(
|
||||
"ConfigRoutes.update",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const config = c.req.valid("json")
|
||||
const cfg = yield* Config.Service
|
||||
yield* cfg.update(config)
|
||||
return { config, ctx: yield* InstanceState.context }
|
||||
}),
|
||||
)
|
||||
const response = c.json(result.config)
|
||||
void runRequest(
|
||||
"ConfigRoutes.update.dispose",
|
||||
c,
|
||||
InstanceStore.Service.use((store) => store.dispose(result.ctx)).pipe(
|
||||
Effect.uninterruptible,
|
||||
Effect.catchCause((cause) => Effect.sync(() => log.warn("instance disposal failed", { cause }))),
|
||||
),
|
||||
)
|
||||
return response
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/providers",
|
||||
describeRoute({
|
||||
summary: "List config providers",
|
||||
description: "Get a list of all configured AI providers and their default models.",
|
||||
operationId: "config.providers",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of providers",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Provider.ConfigProvidersResult.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ConfigRoutes.providers", c, function* () {
|
||||
const svc = yield* Provider.Service
|
||||
const providers = yield* svc.list()
|
||||
return {
|
||||
providers: Object.values(providers),
|
||||
default: Provider.defaultModelIDs(providers),
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -1,90 +0,0 @@
|
||||
import z from "zod"
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, resolver } from "hono-openapi"
|
||||
import { streamSSE } from "hono/streaming"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { AsyncQueue } from "@/util/queue"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
export const EventRoutes = () =>
|
||||
new Hono().get(
|
||||
"/event",
|
||||
describeRoute({
|
||||
summary: "Subscribe to events",
|
||||
description: "Get events",
|
||||
operationId: "event.subscribe",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Event stream",
|
||||
content: {
|
||||
"text/event-stream": {
|
||||
schema: resolver(
|
||||
z.union(BusEvent.payloads()).meta({
|
||||
ref: "Event",
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
log.info("event connected")
|
||||
c.header("Cache-Control", "no-cache, no-transform")
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
return streamSSE(c, async (stream) => {
|
||||
const q = new AsyncQueue<string | null>()
|
||||
let done = false
|
||||
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
id: Bus.createID(),
|
||||
type: "server.connected",
|
||||
properties: {},
|
||||
}),
|
||||
)
|
||||
|
||||
// Send heartbeat every 10s to prevent stalled proxy streams.
|
||||
const heartbeat = setInterval(() => {
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
id: Bus.createID(),
|
||||
type: "server.heartbeat",
|
||||
properties: {},
|
||||
}),
|
||||
)
|
||||
}, 10_000)
|
||||
|
||||
const stop = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
clearInterval(heartbeat)
|
||||
unsub()
|
||||
q.push(null)
|
||||
log.info("event disconnected")
|
||||
}
|
||||
|
||||
const unsub = Bus.subscribeAll((event) => {
|
||||
q.push(JSON.stringify(event))
|
||||
if (event.type === Bus.InstanceDisposed.type) {
|
||||
stop()
|
||||
}
|
||||
})
|
||||
|
||||
stream.onAbort(stop)
|
||||
|
||||
try {
|
||||
for await (const data of q) {
|
||||
if (data === null) return
|
||||
await stream.writeSSE({ data })
|
||||
}
|
||||
} finally {
|
||||
stop()
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -1,419 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import * as EffectZod from "@/util/effect-zod"
|
||||
import { ProviderID, ModelID } from "@/provider/schema"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Project } from "@/project/project"
|
||||
import { MCP } from "@/mcp"
|
||||
import { Session } from "@/session/session"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConsoleState } from "@/config/console-state"
|
||||
import { Account } from "@/account/account"
|
||||
import { AccountID, OrgID } from "@/account/schema"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
|
||||
const ConsoleOrgOption = z.object({
|
||||
accountID: z.string(),
|
||||
accountEmail: z.string(),
|
||||
accountUrl: z.string(),
|
||||
orgID: z.string(),
|
||||
orgName: z.string(),
|
||||
active: z.boolean(),
|
||||
})
|
||||
|
||||
const ConsoleOrgList = z.object({
|
||||
orgs: z.array(ConsoleOrgOption),
|
||||
})
|
||||
|
||||
const ConsoleSwitchBody = z.object({
|
||||
accountID: z.string(),
|
||||
orgID: z.string(),
|
||||
})
|
||||
|
||||
const QueryBoolean = z.union([
|
||||
z.preprocess((value) => (value === "true" ? true : value === "false" ? false : value), z.boolean()),
|
||||
z.enum(["true", "false"]),
|
||||
])
|
||||
|
||||
function queryBoolean(value: z.infer<typeof QueryBoolean> | undefined) {
|
||||
if (value === undefined) return
|
||||
return value === true || value === "true"
|
||||
}
|
||||
|
||||
export const ExperimentalRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/console",
|
||||
describeRoute({
|
||||
summary: "Get active Console provider metadata",
|
||||
description: "Get the active Console org name and the set of provider IDs managed by that Console org.",
|
||||
operationId: "experimental.console.get",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Active Console provider metadata",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ConsoleState.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.console.get", c, function* () {
|
||||
const config = yield* Config.Service
|
||||
const account = yield* Account.Service
|
||||
const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
return {
|
||||
...state,
|
||||
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/console/orgs",
|
||||
describeRoute({
|
||||
summary: "List switchable Console orgs",
|
||||
description: "Get the available Console orgs across logged-in accounts, including the current active org.",
|
||||
operationId: "experimental.console.listOrgs",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Switchable Console orgs",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ConsoleOrgList),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.console.listOrgs", c, function* () {
|
||||
const account = yield* Account.Service
|
||||
const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
const info = Option.getOrUndefined(active)
|
||||
const orgs = groups.flatMap((group) =>
|
||||
group.orgs.map((org) => ({
|
||||
accountID: group.account.id,
|
||||
accountEmail: group.account.email,
|
||||
accountUrl: group.account.url,
|
||||
orgID: org.id,
|
||||
orgName: org.name,
|
||||
active: !!info && info.id === group.account.id && info.active_org_id === org.id,
|
||||
})),
|
||||
)
|
||||
return { orgs }
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/console/switch",
|
||||
describeRoute({
|
||||
summary: "Switch active Console org",
|
||||
description: "Persist a new active Console account/org selection for the current local OpenCode state.",
|
||||
operationId: "experimental.console.switchOrg",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Switch success",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("json", ConsoleSwitchBody),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.console.switchOrg", c, function* () {
|
||||
const body = c.req.valid("json")
|
||||
const account = yield* Account.Service
|
||||
yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID)))
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/tool/ids",
|
||||
describeRoute({
|
||||
summary: "List tool IDs",
|
||||
description:
|
||||
"Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.",
|
||||
operationId: "tool.ids",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Tool IDs",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(z.string()).meta({ ref: "ToolIDs" })),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.tool.ids", c, function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
return yield* registry.ids()
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/tool",
|
||||
describeRoute({
|
||||
summary: "List tools",
|
||||
description:
|
||||
"Get a list of available tools with their JSON schema parameters for a specific provider and model combination.",
|
||||
operationId: "tool.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Tools",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string(),
|
||||
description: z.string(),
|
||||
parameters: z.any(),
|
||||
})
|
||||
.meta({ ref: "ToolListItem" }),
|
||||
)
|
||||
.meta({ ref: "ToolList" }),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { provider, model } = c.req.valid("query")
|
||||
const tools = await runRequest(
|
||||
"ExperimentalRoutes.tool.list",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
return yield* registry.tools({
|
||||
providerID: ProviderID.make(provider),
|
||||
modelID: ModelID.make(model),
|
||||
agent: yield* agents.get(yield* agents.defaultAgent()),
|
||||
})
|
||||
}),
|
||||
)
|
||||
return c.json(
|
||||
tools.map((t) => ({
|
||||
id: t.id,
|
||||
description: t.description,
|
||||
parameters: EffectZod.toJsonSchema(t.parameters),
|
||||
})),
|
||||
)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/worktree",
|
||||
describeRoute({
|
||||
summary: "Create worktree",
|
||||
description: "Create a new git worktree for the current project and run any configured startup scripts.",
|
||||
operationId: "worktree.create",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Worktree created",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Worktree.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.CreateInput.zod.optional()),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.worktree.create", c, function* () {
|
||||
const body = c.req.valid("json")
|
||||
const svc = yield* Worktree.Service
|
||||
return yield* svc.create(body)
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/worktree",
|
||||
describeRoute({
|
||||
summary: "List worktrees",
|
||||
description: "List all sandbox worktrees for the current project.",
|
||||
operationId: "worktree.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of worktree directories",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(z.string())),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.worktree.list", c, function* () {
|
||||
const svc = yield* Project.Service
|
||||
return yield* svc.sandboxes(Instance.project.id)
|
||||
}),
|
||||
)
|
||||
.delete(
|
||||
"/worktree",
|
||||
describeRoute({
|
||||
summary: "Remove worktree",
|
||||
description: "Remove a git worktree and delete its branch.",
|
||||
operationId: "worktree.remove",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Worktree removed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.RemoveInput.zod),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.worktree.remove", c, function* () {
|
||||
const body = c.req.valid("json")
|
||||
const worktree = yield* Worktree.Service
|
||||
const project = yield* Project.Service
|
||||
yield* worktree.remove(body)
|
||||
yield* project.removeSandbox(Instance.project.id, body.directory)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/worktree/reset",
|
||||
describeRoute({
|
||||
summary: "Reset worktree",
|
||||
description: "Reset a worktree branch to the primary default branch.",
|
||||
operationId: "worktree.reset",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Worktree reset",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.ResetInput.zod),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.worktree.reset", c, function* () {
|
||||
const body = c.req.valid("json")
|
||||
const svc = yield* Worktree.Service
|
||||
yield* svc.reset(body)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/session",
|
||||
describeRoute({
|
||||
summary: "List sessions",
|
||||
description:
|
||||
"Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.",
|
||||
operationId: "experimental.session.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of sessions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.GlobalInfo.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
|
||||
roots: QueryBoolean.optional().meta({ description: "Only return root sessions (no parentID)" }),
|
||||
start: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }),
|
||||
cursor: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }),
|
||||
search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }),
|
||||
limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }),
|
||||
archived: QueryBoolean.optional().meta({ description: "Include archived sessions (default false)" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const query = c.req.valid("query")
|
||||
const limit = query.limit ?? 100
|
||||
const sessions: Session.GlobalInfo[] = []
|
||||
for await (const session of Session.listGlobal({
|
||||
directory: query.directory,
|
||||
roots: queryBoolean(query.roots),
|
||||
start: query.start,
|
||||
cursor: query.cursor,
|
||||
search: query.search,
|
||||
limit: limit + 1,
|
||||
archived: queryBoolean(query.archived),
|
||||
})) {
|
||||
sessions.push(session)
|
||||
}
|
||||
const hasMore = sessions.length > limit
|
||||
const list = hasMore ? sessions.slice(0, limit) : sessions
|
||||
if (hasMore && list.length > 0) {
|
||||
c.header("x-next-cursor", String(list[list.length - 1].time.updated))
|
||||
}
|
||||
return c.json(list)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/resource",
|
||||
describeRoute({
|
||||
summary: "Get MCP resources",
|
||||
description: "Get all available MCP resources from connected servers. Optionally filter by name.",
|
||||
operationId: "experimental.resource.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "MCP resources",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.record(z.string(), MCP.Resource.zod)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.resource.list", c, function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
return yield* mcp.resources()
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -1,190 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { File } from "@/file"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { jsonRequest } from "./trace"
|
||||
|
||||
export const FileRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/find",
|
||||
describeRoute({
|
||||
summary: "Find text",
|
||||
description: "Search for text patterns across files in the project using ripgrep.",
|
||||
operationId: "find.text",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Matches",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Ripgrep.SearchMatch.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
pattern: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) =>
|
||||
jsonRequest("FileRoutes.findText", c, function* () {
|
||||
const pattern = c.req.valid("query").pattern
|
||||
const svc = yield* Ripgrep.Service
|
||||
const result = yield* svc.search({ cwd: Instance.directory, pattern, limit: 10 })
|
||||
return result.items
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/find/file",
|
||||
describeRoute({
|
||||
summary: "Find files",
|
||||
description: "Search for files or directories by name or pattern in the project directory.",
|
||||
operationId: "find.files",
|
||||
responses: {
|
||||
200: {
|
||||
description: "File paths",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.string().array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
query: z.string(),
|
||||
dirs: z.enum(["true", "false"]).optional(),
|
||||
type: z.enum(["file", "directory"]).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
}),
|
||||
),
|
||||
async (c) =>
|
||||
jsonRequest("FileRoutes.findFile", c, function* () {
|
||||
const query = c.req.valid("query")
|
||||
const svc = yield* File.Service
|
||||
return yield* svc.search({
|
||||
query: query.query,
|
||||
limit: query.limit ?? 10,
|
||||
dirs: query.dirs !== "false",
|
||||
type: query.type,
|
||||
})
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/find/symbol",
|
||||
describeRoute({
|
||||
summary: "Find symbols",
|
||||
description: "Search for workspace symbols like functions, classes, and variables using LSP.",
|
||||
operationId: "find.symbols",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Symbols",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(LSP.Symbol.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
query: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
return c.json([])
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/file",
|
||||
describeRoute({
|
||||
summary: "List files",
|
||||
description: "List files and directories in a specified path.",
|
||||
operationId: "file.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Files and directories",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(File.Node.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) =>
|
||||
jsonRequest("FileRoutes.list", c, function* () {
|
||||
const svc = yield* File.Service
|
||||
return yield* svc.list(c.req.valid("query").path)
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/file/content",
|
||||
describeRoute({
|
||||
summary: "Read file",
|
||||
description: "Read the content of a specified file.",
|
||||
operationId: "file.read",
|
||||
responses: {
|
||||
200: {
|
||||
description: "File content",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(File.Content.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) =>
|
||||
jsonRequest("FileRoutes.read", c, function* () {
|
||||
const svc = yield* File.Service
|
||||
return yield* svc.read(c.req.valid("query").path)
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/file/status",
|
||||
describeRoute({
|
||||
summary: "Get file status",
|
||||
description: "Get the git status of all files in the project.",
|
||||
operationId: "file.status",
|
||||
responses: {
|
||||
200: {
|
||||
description: "File status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(File.Info.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("FileRoutes.status", c, function* () {
|
||||
const svc = yield* File.Service
|
||||
return yield* svc.status()
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -72,15 +72,13 @@ 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"
|
||||
|
||||
export const context = Context.makeUnsafe<unknown>(new Map())
|
||||
|
||||
const runtime = HttpRouter.middleware()(
|
||||
Effect.succeed((effect) =>
|
||||
Effect.gen(function* () {
|
||||
const selected = ServerBackend.select()
|
||||
yield* Effect.annotateCurrentSpan(ServerBackend.attributes(ServerBackend.force(selected, "effect-httpapi")))
|
||||
yield* Effect.annotateCurrentSpan({ "opencode.server.backend": "effect-httpapi" })
|
||||
return yield* effect
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1,406 +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 } 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"
|
||||
|
||||
export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): Hono => {
|
||||
const app = new Hono()
|
||||
const handler = ExperimentalHttpApiServer.webHandler(opts).handler
|
||||
const context = Context.empty() as Context.Context<unknown>
|
||||
|
||||
app.all("/api/*", (c) => handler(c.req.raw, context))
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
|
||||
app.get(EventPaths.event, (c) => handler(c.req.raw, context))
|
||||
app.get("/question", (c) => handler(c.req.raw, context))
|
||||
app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context))
|
||||
app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context))
|
||||
app.get("/permission", (c) => handler(c.req.raw, context))
|
||||
app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context))
|
||||
app.get("/config", (c) => handler(c.req.raw, context))
|
||||
app.patch("/config", (c) => handler(c.req.raw, context))
|
||||
app.get("/config/providers", (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.console, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context))
|
||||
app.post(ExperimentalPaths.consoleSwitch, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.tool, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.toolIDs, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
|
||||
app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
|
||||
app.delete(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
|
||||
app.post(ExperimentalPaths.worktreeReset, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.session, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context))
|
||||
app.get("/provider", (c) => handler(c.req.raw, context))
|
||||
app.get("/provider/auth", (c) => handler(c.req.raw, context))
|
||||
app.post("/provider/:providerID/oauth/authorize", (c) => handler(c.req.raw, context))
|
||||
app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context))
|
||||
app.get("/project", (c) => handler(c.req.raw, context))
|
||||
app.get("/project/current", (c) => handler(c.req.raw, context))
|
||||
app.post("/project/git/init", (c) => handler(c.req.raw, context))
|
||||
app.patch("/project/:projectID", (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.findText, (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.findFile, (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.findSymbol, (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.list, (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.content, (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.status, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.path, (c) => handler(c.req.raw, context))
|
||||
app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.vcsDiff, (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.sessionRestore, (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(
|
||||
"/command",
|
||||
describeRoute({
|
||||
summary: "List commands",
|
||||
description: "Get a list of all available commands in the OpenCode system.",
|
||||
operationId: "command.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of commands",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Command.Info.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("InstanceRoutes.command.list", c, function* () {
|
||||
const svc = yield* Command.Service
|
||||
return yield* svc.list()
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/agent",
|
||||
describeRoute({
|
||||
summary: "List agents",
|
||||
description: "Get a list of all available AI agents in the OpenCode system.",
|
||||
operationId: "app.agents",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of agents",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Agent.Info.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("InstanceRoutes.agent.list", c, function* () {
|
||||
const svc = yield* Agent.Service
|
||||
return yield* svc.list()
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/skill",
|
||||
describeRoute({
|
||||
summary: "List skills",
|
||||
description: "Get a list of all available skills in the OpenCode system.",
|
||||
operationId: "app.skills",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of skills",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Skill.Info.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("InstanceRoutes.skill.list", c, function* () {
|
||||
const skill = yield* Skill.Service
|
||||
return yield* skill.all()
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/lsp",
|
||||
describeRoute({
|
||||
summary: "Get LSP status",
|
||||
description: "Get LSP server status",
|
||||
operationId: "lsp.status",
|
||||
responses: {
|
||||
200: {
|
||||
description: "LSP server status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(LSP.Status.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("InstanceRoutes.lsp.status", c, function* () {
|
||||
const lsp = yield* LSP.Service
|
||||
return yield* lsp.status()
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/formatter",
|
||||
describeRoute({
|
||||
summary: "Get formatter status",
|
||||
description: "Get formatter status",
|
||||
operationId: "formatter.status",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Formatter status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Format.Status.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("InstanceRoutes.formatter.status", c, function* () {
|
||||
const svc = yield* Format.Service
|
||||
return yield* svc.status()
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { MCP } from "@/mcp"
|
||||
import { ConfigMCP } from "@/config/mcp"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Effect } from "effect"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
|
||||
const UnsupportedOAuthError = z
|
||||
.object({
|
||||
error: z.string(),
|
||||
})
|
||||
.meta({ ref: "McpUnsupportedOAuthError" })
|
||||
|
||||
const unsupportedOAuthErrorResponse = {
|
||||
description: "MCP server does not support OAuth",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(UnsupportedOAuthError),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const McpRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "Get MCP status",
|
||||
description: "Get the status of all Model Context Protocol (MCP) servers.",
|
||||
operationId: "mcp.status",
|
||||
responses: {
|
||||
200: {
|
||||
description: "MCP server status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.record(z.string(), MCP.Status.zod)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("McpRoutes.status", c, function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
return yield* mcp.status()
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "Add MCP server",
|
||||
description: "Dynamically add a new Model Context Protocol (MCP) server to the system.",
|
||||
operationId: "mcp.add",
|
||||
responses: {
|
||||
200: {
|
||||
description: "MCP server added successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.record(z.string(), MCP.Status.zod)),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
name: z.string(),
|
||||
config: ConfigMCP.Info.zod,
|
||||
}),
|
||||
),
|
||||
async (c) =>
|
||||
jsonRequest("McpRoutes.add", c, function* () {
|
||||
const { name, config } = c.req.valid("json")
|
||||
const mcp = yield* MCP.Service
|
||||
const result = yield* mcp.add(name, config)
|
||||
return result.status
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:name/auth",
|
||||
describeRoute({
|
||||
summary: "Start MCP OAuth",
|
||||
description: "Start OAuth authentication flow for a Model Context Protocol (MCP) server.",
|
||||
operationId: "mcp.auth.start",
|
||||
responses: {
|
||||
200: {
|
||||
description: "OAuth flow started",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
authorizationUrl: z.string().describe("URL to open in browser for authorization"),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
400: unsupportedOAuthErrorResponse,
|
||||
...errors(404),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.param("name")
|
||||
const result = await runRequest(
|
||||
"McpRoutes.auth.start",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const supports = yield* mcp.supportsOAuth(name)
|
||||
if (!supports) return { supports }
|
||||
return {
|
||||
supports,
|
||||
auth: yield* mcp.startAuth(name),
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (!result.supports) {
|
||||
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
|
||||
}
|
||||
return c.json(result.auth)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:name/auth/callback",
|
||||
describeRoute({
|
||||
summary: "Complete MCP OAuth",
|
||||
description:
|
||||
"Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.",
|
||||
operationId: "mcp.auth.callback",
|
||||
responses: {
|
||||
200: {
|
||||
description: "OAuth authentication completed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(MCP.Status.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
code: z.string().describe("Authorization code from OAuth callback"),
|
||||
}),
|
||||
),
|
||||
async (c) =>
|
||||
jsonRequest("McpRoutes.auth.callback", c, function* () {
|
||||
const name = c.req.param("name")
|
||||
const { code } = c.req.valid("json")
|
||||
const mcp = yield* MCP.Service
|
||||
return yield* mcp.finishAuth(name, code)
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:name/auth/authenticate",
|
||||
describeRoute({
|
||||
summary: "Authenticate MCP OAuth",
|
||||
description: "Start OAuth flow and wait for callback (opens browser)",
|
||||
operationId: "mcp.auth.authenticate",
|
||||
responses: {
|
||||
200: {
|
||||
description: "OAuth authentication completed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(MCP.Status.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
400: unsupportedOAuthErrorResponse,
|
||||
...errors(404),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.param("name")
|
||||
const result = await runRequest(
|
||||
"McpRoutes.auth.authenticate",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const supports = yield* mcp.supportsOAuth(name)
|
||||
if (!supports) return { supports }
|
||||
return {
|
||||
supports,
|
||||
status: yield* mcp.authenticate(name),
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (!result.supports) {
|
||||
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
|
||||
}
|
||||
return c.json(result.status)
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:name/auth",
|
||||
describeRoute({
|
||||
summary: "Remove MCP OAuth",
|
||||
description: "Remove OAuth credentials for an MCP server",
|
||||
operationId: "mcp.auth.remove",
|
||||
responses: {
|
||||
200: {
|
||||
description: "OAuth credentials removed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ success: z.literal(true) })),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(404),
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("McpRoutes.auth.remove", c, function* () {
|
||||
const name = c.req.param("name")
|
||||
const mcp = yield* MCP.Service
|
||||
yield* mcp.removeAuth(name)
|
||||
return { success: true as const }
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:name/connect",
|
||||
describeRoute({
|
||||
description: "Connect an MCP server",
|
||||
operationId: "mcp.connect",
|
||||
responses: {
|
||||
200: {
|
||||
description: "MCP server connected successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ name: z.string() })),
|
||||
async (c) =>
|
||||
jsonRequest("McpRoutes.connect", c, function* () {
|
||||
const { name } = c.req.valid("param")
|
||||
const mcp = yield* MCP.Service
|
||||
yield* mcp.connect(name)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:name/disconnect",
|
||||
describeRoute({
|
||||
description: "Disconnect an MCP server",
|
||||
operationId: "mcp.disconnect",
|
||||
responses: {
|
||||
200: {
|
||||
description: "MCP server disconnected successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ name: z.string() })),
|
||||
async (c) =>
|
||||
jsonRequest("McpRoutes.disconnect", c, function* () {
|
||||
const { name } = c.req.valid("param")
|
||||
const mcp = yield* MCP.Service
|
||||
yield* mcp.disconnect(name)
|
||||
return true
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
import { WithInstance } from "@/project/with-instance"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
|
||||
export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
|
||||
const directory = AppFileSystem.resolve(
|
||||
(() => {
|
||||
try {
|
||||
return decodeURIComponent(raw)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
})(),
|
||||
)
|
||||
|
||||
return WorkspaceContext.provide({
|
||||
workspaceID,
|
||||
async fn() {
|
||||
return WithInstance.provide({
|
||||
directory,
|
||||
async fn() {
|
||||
return next()
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { Permission } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { jsonRequest } from "./trace"
|
||||
|
||||
export const PermissionRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.post(
|
||||
"/:requestID/reply",
|
||||
describeRoute({
|
||||
summary: "Respond to permission request",
|
||||
description: "Approve or deny a permission request from the AI assistant.",
|
||||
operationId: "permission.reply",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Permission processed successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
requestID: PermissionID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", z.object({ reply: Permission.Reply.zod, message: z.string().optional() })),
|
||||
async (c) =>
|
||||
jsonRequest("PermissionRoutes.reply", c, function* () {
|
||||
const params = c.req.valid("param")
|
||||
const json = c.req.valid("json")
|
||||
const svc = yield* Permission.Service
|
||||
yield* svc.reply({
|
||||
requestID: params.requestID,
|
||||
reply: json.reply,
|
||||
message: json.message,
|
||||
})
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List pending permissions",
|
||||
description: "Get all pending permission requests across all sessions.",
|
||||
operationId: "permission.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of pending permissions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Permission.Request.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("PermissionRoutes.list", c, function* () {
|
||||
const svc = yield* Permission.Service
|
||||
return yield* svc.list()
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -1,116 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator } from "hono-openapi"
|
||||
import { resolver } from "hono-openapi"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { Project } from "@/project/project"
|
||||
import z from "zod"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
|
||||
export const ProjectRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List all projects",
|
||||
description: "Get a list of projects that have been opened with OpenCode.",
|
||||
operationId: "project.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of projects",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Project.Info.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const projects = Project.list()
|
||||
return c.json(projects)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/current",
|
||||
describeRoute({
|
||||
summary: "Get current project",
|
||||
description: "Retrieve the currently active project that OpenCode is working with.",
|
||||
operationId: "project.current",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Current project information",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Project.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(Instance.project)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/git/init",
|
||||
describeRoute({
|
||||
summary: "Initialize git repository",
|
||||
description: "Create a git repository for the current project and return the refreshed project info.",
|
||||
operationId: "project.initGit",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Project information after git initialization",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Project.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const dir = Instance.directory
|
||||
const prev = Instance.project
|
||||
const next = await runRequest(
|
||||
"ProjectRoutes.initGit",
|
||||
c,
|
||||
Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })),
|
||||
)
|
||||
if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next)
|
||||
await InstanceRuntime.reloadInstance({ directory: dir, worktree: dir, project: next })
|
||||
return c.json(next)
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/:projectID",
|
||||
describeRoute({
|
||||
summary: "Update project",
|
||||
description: "Update project properties such as name, icon, and commands.",
|
||||
operationId: "project.update",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Updated project information",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Project.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ projectID: ProjectID.zod })),
|
||||
validator("json", Project.UpdateInput.omit({ projectID: true })),
|
||||
async (c) =>
|
||||
jsonRequest("ProjectRoutes.update", c, function* () {
|
||||
const projectID = c.req.valid("param").projectID
|
||||
const body = c.req.valid("json")
|
||||
const svc = yield* Project.Service
|
||||
return yield* svc.update({ ...body, projectID })
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -1,158 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { Config } from "@/config/config"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ModelsDev } from "@/provider/models"
|
||||
import { ProviderAuth } from "@/provider/auth"
|
||||
import { ProviderID } from "@/provider/schema"
|
||||
import { mapValues } from "remeda"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Effect } from "effect"
|
||||
import { jsonRequest } from "./trace"
|
||||
|
||||
export const ProviderRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List providers",
|
||||
description: "Get a list of all available AI providers, including both available and connected ones.",
|
||||
operationId: "provider.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of providers",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Provider.ListResult.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ProviderRoutes.list", c, function* () {
|
||||
const svc = yield* Provider.Service
|
||||
const cfg = yield* Config.Service
|
||||
const config = yield* cfg.get()
|
||||
const all = yield* ModelsDev.Service.use((s) => s.get())
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||
const filtered: Record<string, (typeof all)[string]> = {}
|
||||
for (const [key, value] of Object.entries(all)) {
|
||||
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
|
||||
filtered[key] = value
|
||||
}
|
||||
}
|
||||
const connected = yield* svc.list()
|
||||
const providers = Object.assign(
|
||||
mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)),
|
||||
connected,
|
||||
)
|
||||
return {
|
||||
all: Object.values(providers),
|
||||
default: Provider.defaultModelIDs(providers),
|
||||
connected: Object.keys(connected),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/auth",
|
||||
describeRoute({
|
||||
summary: "Get provider auth methods",
|
||||
description: "Retrieve available authentication methods for all AI providers.",
|
||||
operationId: "provider.auth",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Provider auth methods",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ProviderAuth.Methods.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ProviderRoutes.auth", c, function* () {
|
||||
const svc = yield* ProviderAuth.Service
|
||||
return yield* svc.methods()
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:providerID/oauth/authorize",
|
||||
describeRoute({
|
||||
summary: "OAuth authorize",
|
||||
description: "Initiate OAuth authorization for a specific AI provider to get an authorization URL.",
|
||||
operationId: "provider.oauth.authorize",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Authorization URL and method",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ProviderAuth.Authorization.zod.optional()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: ProviderID.zod.meta({ description: "Provider ID" }),
|
||||
}),
|
||||
),
|
||||
validator("json", ProviderAuth.AuthorizeInput.zod),
|
||||
async (c) =>
|
||||
jsonRequest("ProviderRoutes.oauth.authorize", c, function* () {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const { method, inputs } = c.req.valid("json")
|
||||
const svc = yield* ProviderAuth.Service
|
||||
return yield* svc.authorize({
|
||||
providerID,
|
||||
method,
|
||||
inputs,
|
||||
})
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:providerID/oauth/callback",
|
||||
describeRoute({
|
||||
summary: "OAuth callback",
|
||||
description: "Handle the OAuth callback from a provider after user authorization.",
|
||||
operationId: "provider.oauth.callback",
|
||||
responses: {
|
||||
200: {
|
||||
description: "OAuth callback processed successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: ProviderID.zod.meta({ description: "Provider ID" }),
|
||||
}),
|
||||
),
|
||||
validator("json", ProviderAuth.CallbackInput.zod),
|
||||
async (c) =>
|
||||
jsonRequest("ProviderRoutes.oauth.callback", c, function* () {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const { method, code } = c.req.valid("json")
|
||||
const svc = yield* ProviderAuth.Service
|
||||
yield* svc.callback({
|
||||
providerID,
|
||||
method,
|
||||
code,
|
||||
})
|
||||
return true
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -1,340 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import type { Context } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import { Effect, Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { PtyTicket } from "@/pty/ticket"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import { errors } from "../../error"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
import { HTTPException } from "hono/http-exception"
|
||||
import { isAllowedRequestOrigin, type CorsOptions } from "@/server/cors"
|
||||
import {
|
||||
PTY_CONNECT_TICKET_QUERY,
|
||||
PTY_CONNECT_TOKEN_HEADER,
|
||||
PTY_CONNECT_TOKEN_HEADER_VALUE,
|
||||
} from "@/server/shared/pty-ticket"
|
||||
import { zod as effectZod } from "@/util/effect-zod"
|
||||
|
||||
const ShellItem = z.object({
|
||||
path: z.string(),
|
||||
name: z.string(),
|
||||
acceptable: z.boolean(),
|
||||
})
|
||||
const decodePtyID = Schema.decodeUnknownSync(PtyID)
|
||||
|
||||
function validOrigin(c: Context, opts?: CorsOptions) {
|
||||
return isAllowedRequestOrigin(c.req.header("origin"), c.req.header("host"), opts)
|
||||
}
|
||||
|
||||
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket, opts?: CorsOptions) {
|
||||
return new Hono()
|
||||
.get(
|
||||
"/shells",
|
||||
describeRoute({
|
||||
summary: "List available shells",
|
||||
description: "Get a list of available shells on the system.",
|
||||
operationId: "pty.shells",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of shells",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(ShellItem)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await Shell.list())
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List PTY sessions",
|
||||
description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.",
|
||||
operationId: "pty.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of sessions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Pty.Info.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("PtyRoutes.list", c, function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.list()
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "Create PTY session",
|
||||
description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.",
|
||||
operationId: "pty.create",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Created session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Pty.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Pty.CreateInput.zod),
|
||||
async (c) =>
|
||||
jsonRequest("PtyRoutes.create", c, function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.create(c.req.valid("json") as Pty.CreateInput)
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/:ptyID",
|
||||
describeRoute({
|
||||
summary: "Get PTY session",
|
||||
description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.",
|
||||
operationId: "pty.get",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Session info",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Pty.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(404),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||
async (c) => {
|
||||
const info = await runRequest(
|
||||
"PtyRoutes.get",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.get(c.req.valid("param").ptyID)
|
||||
}),
|
||||
)
|
||||
if (!info) {
|
||||
throw new NotFoundError({ message: "Session not found" })
|
||||
}
|
||||
return c.json(info)
|
||||
},
|
||||
)
|
||||
.put(
|
||||
"/:ptyID",
|
||||
describeRoute({
|
||||
summary: "Update PTY session",
|
||||
description: "Update properties of an existing pseudo-terminal (PTY) session.",
|
||||
operationId: "pty.update",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Updated session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Pty.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||
validator("json", Pty.UpdateInput.zod),
|
||||
async (c) =>
|
||||
jsonRequest("PtyRoutes.update", c, function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json") as Pty.UpdateInput)
|
||||
}),
|
||||
)
|
||||
.delete(
|
||||
"/:ptyID",
|
||||
describeRoute({
|
||||
summary: "Remove PTY session",
|
||||
description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
|
||||
operationId: "pty.remove",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Session removed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(404),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||
async (c) =>
|
||||
jsonRequest("PtyRoutes.remove", c, function* () {
|
||||
const pty = yield* Pty.Service
|
||||
yield* pty.remove(c.req.valid("param").ptyID)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:ptyID/connect-token",
|
||||
describeRoute({
|
||||
summary: "Create PTY WebSocket token",
|
||||
description: "Create a short-lived token for opening a PTY WebSocket connection.",
|
||||
operationId: "pty.connectToken",
|
||||
responses: {
|
||||
200: {
|
||||
description: "WebSocket connect token",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(effectZod(PtyTicket.ConnectToken)),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(403, 404),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||
async (c) => {
|
||||
if (c.req.header(PTY_CONNECT_TOKEN_HEADER) !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(c, opts))
|
||||
throw new HTTPException(403)
|
||||
const result = await runRequest(
|
||||
"PtyRoutes.connectToken",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const id = c.req.valid("param").ptyID
|
||||
if (!(yield* pty.get(id))) return
|
||||
const tickets = yield* PtyTicket.Service
|
||||
return yield* tickets.issue({ ptyID: id, ...(yield* PtyTicket.scope) })
|
||||
}),
|
||||
)
|
||||
if (!result) throw new NotFoundError({ message: "Session not found" })
|
||||
return c.json(result)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:ptyID/connect",
|
||||
describeRoute({
|
||||
summary: "Connect to PTY session",
|
||||
description: "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
|
||||
operationId: "pty.connect",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Connected session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(403, 404),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||
upgradeWebSocket(async (c) => {
|
||||
type Handler = {
|
||||
onMessage: (message: string | ArrayBuffer) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const id = decodePtyID(c.req.param("ptyID"))
|
||||
if (
|
||||
!(await runRequest(
|
||||
"PtyRoutes.connect",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.get(id)
|
||||
}),
|
||||
))
|
||||
) {
|
||||
throw new NotFoundError({ message: "Session not found" })
|
||||
}
|
||||
const ticket = c.req.query(PTY_CONNECT_TICKET_QUERY)
|
||||
if (ticket) {
|
||||
if (!validOrigin(c, opts)) throw new HTTPException(403)
|
||||
const valid = await runRequest(
|
||||
"PtyRoutes.connect.ticket",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const tickets = yield* PtyTicket.Service
|
||||
return yield* tickets.consume({ ticket, ptyID: id, ...(yield* PtyTicket.scope) })
|
||||
}),
|
||||
)
|
||||
if (!valid) throw new HTTPException(403)
|
||||
}
|
||||
const cursor = (() => {
|
||||
const value = c.req.query("cursor")
|
||||
if (!value) return
|
||||
const parsed = Number(value)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < -1) return
|
||||
return parsed
|
||||
})()
|
||||
let handler: Handler | undefined
|
||||
|
||||
type Socket = {
|
||||
readyState: number
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => void
|
||||
close: (code?: number, reason?: string) => void
|
||||
}
|
||||
|
||||
const isSocket = (value: unknown): value is Socket => {
|
||||
if (!value || typeof value !== "object") return false
|
||||
if (!("readyState" in value)) return false
|
||||
if (!("send" in value) || typeof (value as { send?: unknown }).send !== "function") return false
|
||||
if (!("close" in value) || typeof (value as { close?: unknown }).close !== "function") return false
|
||||
return typeof (value as { readyState?: unknown }).readyState === "number"
|
||||
}
|
||||
|
||||
const pending: string[] = []
|
||||
let ready = false
|
||||
|
||||
return {
|
||||
async onOpen(_event, ws) {
|
||||
const socket = ws.raw
|
||||
if (!isSocket(socket)) {
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
handler = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.connect(id, socket, cursor)
|
||||
}).pipe(Effect.withSpan("PtyRoutes.connect.open")),
|
||||
)
|
||||
ready = true
|
||||
for (const msg of pending) handler?.onMessage(msg)
|
||||
pending.length = 0
|
||||
},
|
||||
onMessage(event) {
|
||||
if (typeof event.data !== "string") return
|
||||
if (!ready) {
|
||||
pending.push(event.data)
|
||||
return
|
||||
}
|
||||
handler?.onMessage(event.data)
|
||||
},
|
||||
onClose() {
|
||||
handler?.onClose()
|
||||
},
|
||||
onError() {
|
||||
handler?.onClose()
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator } from "hono-openapi"
|
||||
import { resolver } from "hono-openapi"
|
||||
import { QuestionID } from "@/question/schema"
|
||||
import { Question } from "@/question"
|
||||
import z from "zod"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { jsonRequest } from "./trace"
|
||||
|
||||
const Reply = z.object({
|
||||
answers: Question.Answer.zod
|
||||
.array()
|
||||
.describe("User answers in order of questions (each answer is an array of selected labels)"),
|
||||
})
|
||||
|
||||
export const QuestionRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List pending questions",
|
||||
description: "Get all pending question requests across all sessions.",
|
||||
operationId: "question.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of pending questions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Question.Request.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("QuestionRoutes.list", c, function* () {
|
||||
const svc = yield* Question.Service
|
||||
return yield* svc.list()
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:requestID/reply",
|
||||
describeRoute({
|
||||
summary: "Reply to question request",
|
||||
description: "Provide answers to a question request from the AI assistant.",
|
||||
operationId: "question.reply",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Question answered successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
requestID: QuestionID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", Reply),
|
||||
async (c) =>
|
||||
jsonRequest("QuestionRoutes.reply", c, function* () {
|
||||
const params = c.req.valid("param")
|
||||
const json = c.req.valid("json")
|
||||
const svc = yield* Question.Service
|
||||
yield* svc.reply({
|
||||
requestID: params.requestID,
|
||||
answers: json.answers,
|
||||
})
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:requestID/reject",
|
||||
describeRoute({
|
||||
summary: "Reject question request",
|
||||
description: "Reject a question request from the AI assistant.",
|
||||
operationId: "question.reject",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Question rejected successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
requestID: QuestionID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) =>
|
||||
jsonRequest("QuestionRoutes.reject", c, function* () {
|
||||
const params = c.req.valid("param")
|
||||
const svc = yield* Question.Service
|
||||
yield* svc.reject(params.requestID)
|
||||
return true
|
||||
}),
|
||||
),
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,152 +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"
|
||||
|
||||
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 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(
|
||||
"/history",
|
||||
describeRoute({
|
||||
summary: "List sync events",
|
||||
description:
|
||||
"List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.",
|
||||
operationId: "sync.history.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Sync events",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
aggregate_id: z.string(),
|
||||
seq: z.number(),
|
||||
type: z.string(),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", z.record(z.string(), z.number().int().min(0))),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
const exclude = Object.entries(body)
|
||||
const where =
|
||||
exclude.length > 0
|
||||
? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!)
|
||||
: undefined
|
||||
const rows = Database.use((db) => db.select().from(EventTable).where(where).orderBy(asc(EventTable.seq)).all())
|
||||
return c.json(rows)
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { Context } from "hono"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
type AppEnv = Parameters<typeof AppRuntime.runPromise>[0] extends Effect.Effect<any, any, infer R> ? R : never
|
||||
|
||||
// Build the base span attributes for an HTTP handler: method, path, and every
|
||||
// matched route param. Names follow OTel attribute-naming guidance:
|
||||
// domain-first (`session.id`, `message.id`, …) so they match the existing
|
||||
// OTel `session.id` semantic convention and the bare `message.id` we
|
||||
// already emit from Tool.execute. Non-standard route params fall back to
|
||||
// `opencode.<name>` since those are internal implementation details
|
||||
// (per https://opentelemetry.io/blog/2025/how-to-name-your-span-attributes/).
|
||||
export interface RequestLike {
|
||||
readonly req: {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
param(): Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize a Hono route param key (e.g. `sessionID`, `messageID`, `name`)
|
||||
// to an OTel attribute key. `fooID` → `foo.id` for ID-shaped params; any
|
||||
// other param is namespaced under `opencode.` to avoid colliding with
|
||||
// standard conventions.
|
||||
export function paramToAttributeKey(key: string): string {
|
||||
const m = key.match(/^(.+)ID$/)
|
||||
if (m) return `${m[1].toLowerCase()}.id`
|
||||
return `opencode.${key}`
|
||||
}
|
||||
|
||||
export function requestAttributes(c: RequestLike): Record<string, string> {
|
||||
const attributes: Record<string, string> = {
|
||||
"http.method": c.req.method,
|
||||
"http.path": new URL(c.req.url).pathname,
|
||||
}
|
||||
for (const [key, value] of Object.entries(c.req.param())) {
|
||||
attributes[paramToAttributeKey(key)] = value
|
||||
}
|
||||
return attributes
|
||||
}
|
||||
|
||||
export function runRequest<A, E>(name: string, c: Context, effect: Effect.Effect<A, E, AppEnv>) {
|
||||
return AppRuntime.runPromise(effect.pipe(Effect.withSpan(name, { attributes: requestAttributes(c) })))
|
||||
}
|
||||
|
||||
export async function jsonRequest<C extends Context, A, E>(
|
||||
name: string,
|
||||
c: C,
|
||||
effect: (c: C) => Effect.gen.Return<A, E, AppEnv>,
|
||||
) {
|
||||
return c.json(
|
||||
await runRequest(
|
||||
name,
|
||||
c,
|
||||
Effect.gen(() => effect(c)),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
import { Hono, type Context } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { Bus } from "@/bus"
|
||||
import { Session } from "@/session/session"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import { zodObject } from "@/util/effect-zod"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { runRequest } from "./trace"
|
||||
import {
|
||||
TuiRequest,
|
||||
nextTuiRequest,
|
||||
nextTuiResponse,
|
||||
submitTuiRequest,
|
||||
submitTuiResponse,
|
||||
} from "@/server/shared/tui-control"
|
||||
|
||||
export async function callTui(ctx: Context) {
|
||||
const body = await ctx.req.json()
|
||||
submitTuiRequest({
|
||||
path: ctx.req.path,
|
||||
body,
|
||||
})
|
||||
return nextTuiResponse()
|
||||
}
|
||||
|
||||
const TuiControlRoutes = new Hono()
|
||||
.get(
|
||||
"/next",
|
||||
describeRoute({
|
||||
summary: "Get next TUI request",
|
||||
description: "Retrieve the next TUI (Terminal User Interface) request from the queue for processing.",
|
||||
operationId: "tui.control.next",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Next TUI request",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(TuiRequest),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const req = await nextTuiRequest()
|
||||
return c.json(req)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/response",
|
||||
describeRoute({
|
||||
summary: "Submit TUI response",
|
||||
description: "Submit a response to the TUI request queue to complete a pending request.",
|
||||
operationId: "tui.control.response",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Response submitted successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("json", z.any()),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
submitTuiResponse(body)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
|
||||
export const TuiRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.post(
|
||||
"/append-prompt",
|
||||
describeRoute({
|
||||
summary: "Append TUI prompt",
|
||||
description: "Append prompt to the TUI",
|
||||
operationId: "tui.appendPrompt",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Prompt processed successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", zodObject(TuiEvent.PromptAppend.properties)),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json") as { text: string })
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/open-help",
|
||||
describeRoute({
|
||||
summary: "Open help dialog",
|
||||
description: "Open the help dialog in the TUI to display user assistance information.",
|
||||
operationId: "tui.openHelp",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Help dialog opened successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.CommandExecute, {
|
||||
command: "help.show",
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/open-sessions",
|
||||
describeRoute({
|
||||
summary: "Open sessions dialog",
|
||||
description: "Open the session dialog",
|
||||
operationId: "tui.openSessions",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Session dialog opened successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.CommandExecute, {
|
||||
command: "session.list",
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/open-themes",
|
||||
describeRoute({
|
||||
summary: "Open themes dialog",
|
||||
description: "Open the theme dialog",
|
||||
operationId: "tui.openThemes",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Theme dialog opened successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.CommandExecute, {
|
||||
command: "session.list",
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/open-models",
|
||||
describeRoute({
|
||||
summary: "Open models dialog",
|
||||
description: "Open the model dialog",
|
||||
operationId: "tui.openModels",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Model dialog opened successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.CommandExecute, {
|
||||
command: "model.list",
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/submit-prompt",
|
||||
describeRoute({
|
||||
summary: "Submit TUI prompt",
|
||||
description: "Submit the prompt",
|
||||
operationId: "tui.submitPrompt",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Prompt submitted successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.CommandExecute, {
|
||||
command: "prompt.submit",
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/clear-prompt",
|
||||
describeRoute({
|
||||
summary: "Clear TUI prompt",
|
||||
description: "Clear the prompt",
|
||||
operationId: "tui.clearPrompt",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Prompt cleared successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.CommandExecute, {
|
||||
command: "prompt.clear",
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/execute-command",
|
||||
describeRoute({
|
||||
summary: "Execute TUI command",
|
||||
description: "Execute a TUI command (e.g. agent_cycle)",
|
||||
operationId: "tui.executeCommand",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Command executed successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", z.object({ command: z.string() })),
|
||||
async (c) => {
|
||||
const command = c.req.valid("json").command
|
||||
await Bus.publish(TuiEvent.CommandExecute, {
|
||||
// @ts-expect-error
|
||||
command: {
|
||||
session_new: "session.new",
|
||||
session_share: "session.share",
|
||||
session_interrupt: "session.interrupt",
|
||||
session_compact: "session.compact",
|
||||
messages_page_up: "session.page.up",
|
||||
messages_page_down: "session.page.down",
|
||||
messages_line_up: "session.line.up",
|
||||
messages_line_down: "session.line.down",
|
||||
messages_half_page_up: "session.half.page.up",
|
||||
messages_half_page_down: "session.half.page.down",
|
||||
messages_first: "session.first",
|
||||
messages_last: "session.last",
|
||||
agent_cycle: "agent.cycle",
|
||||
}[command],
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/show-toast",
|
||||
describeRoute({
|
||||
summary: "Show TUI toast",
|
||||
description: "Show a toast notification in the TUI",
|
||||
operationId: "tui.showToast",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Toast notification shown successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("json", zodObject(TuiEvent.ToastShow.properties)),
|
||||
async (c) => {
|
||||
await Bus.publish(
|
||||
TuiEvent.ToastShow,
|
||||
c.req.valid("json") as Schema.Schema.Type<typeof TuiEvent.ToastShow.properties>,
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/publish",
|
||||
describeRoute({
|
||||
summary: "Publish TUI event",
|
||||
description: "Publish a TUI event",
|
||||
operationId: "tui.publish",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Event published successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.union(
|
||||
Object.values(TuiEvent).map((def) => {
|
||||
return z
|
||||
.object({
|
||||
type: z.literal(def.type),
|
||||
properties: zodObject(def.properties),
|
||||
})
|
||||
.meta({
|
||||
ref: `Event.${def.type}`,
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
async (c) => {
|
||||
const evt = c.req.valid("json") as { type: string; properties: Record<string, unknown> }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)! as any, evt.properties as any)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/select-session",
|
||||
describeRoute({
|
||||
summary: "Select session",
|
||||
description: "Navigate the TUI to display the specified session.",
|
||||
operationId: "tui.selectSession",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Session selected successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator("json", zodObject(TuiEvent.SessionSelect.properties)),
|
||||
async (c) => {
|
||||
const { sessionID } = c.req.valid("json") as { sessionID: SessionID }
|
||||
await runRequest(
|
||||
"TuiRoutes.sessionSelect",
|
||||
c,
|
||||
Session.Service.use((svc) => svc.get(sessionID)),
|
||||
)
|
||||
await Bus.publish(TuiEvent.SessionSelect, { sessionID })
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.route("/control", TuiControlRoutes),
|
||||
)
|
||||
@@ -1,39 +0,0 @@
|
||||
import fs from "node:fs/promises"
|
||||
import { createHash } from "node:crypto"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Hono } from "hono"
|
||||
import { proxy } from "hono/proxy"
|
||||
import { ProxyUtil } from "../proxy-util"
|
||||
import { DEFAULT_CSP, UI_UPSTREAM, csp, embeddedUI, themePreloadHash, 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 })
|
||||
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
|
||||
return new Response(new Uint8Array(await fs.readFile(match)), { 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 }),
|
||||
})
|
||||
const match = response.headers.get("content-type")?.includes("text/html")
|
||||
? themePreloadHash(await response.clone().text())
|
||||
: undefined
|
||||
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
|
||||
response.headers.set("Content-Security-Policy", csp(hash))
|
||||
return response
|
||||
}
|
||||
|
||||
export const UIRoutes = (): Hono => new Hono().all("/*", (c) => serveUI(c.req.raw))
|
||||
@@ -1,30 +1,14 @@
|
||||
import { generateSpecs } from "hono-openapi"
|
||||
import { Hono } from "hono"
|
||||
import { adapter } from "#hono"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { 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,25 @@ type ListenOptions = CorsOptions & {
|
||||
mdnsDomain?: string
|
||||
}
|
||||
|
||||
const DefaultHono = lazy(() =>
|
||||
withBackend({ backend: "hono", reason: "stable" }, createHono({}, { backend: "hono", reason: "stable" })),
|
||||
)
|
||||
const DefaultHttpApi = lazy(() => createDefaultHttpApi())
|
||||
|
||||
function select() {
|
||||
return ServerBackend.select()
|
||||
}
|
||||
|
||||
export const backend = select
|
||||
|
||||
export const Default = () => {
|
||||
const selected = select()
|
||||
return selected.backend === "effect-httpapi" ? DefaultHttpApi() : DefaultHono()
|
||||
}
|
||||
|
||||
function create(opts: ListenOptions) {
|
||||
const selected = select()
|
||||
return selected.backend === "effect-httpapi"
|
||||
? withBackend(selected, createHttpApi(opts))
|
||||
: withBackend(selected, createHono(opts, selected))
|
||||
}
|
||||
|
||||
export function Legacy(opts: CorsOptions = {}) {
|
||||
return withBackend({ backend: "hono", reason: "explicit" }, createHono(opts, { backend: "hono", reason: "explicit" }))
|
||||
}
|
||||
|
||||
function createDefaultHttpApi() {
|
||||
return withBackend(select(), createHttpApi())
|
||||
}
|
||||
|
||||
function withBackend<T extends { app: ServerApp; runtime: unknown }>(selection: ServerBackend.Selection, built: T) {
|
||||
log.info("server backend selected", ServerBackend.attributes(selection))
|
||||
return built
|
||||
}
|
||||
|
||||
function createHttpApi(corsOptions?: CorsOptions) {
|
||||
const handler = ExperimentalHttpApiServer.webHandler(corsOptions).handler
|
||||
const 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(AuthMiddleware)
|
||||
.use(LoggerMiddleware(backendAttributes))
|
||||
.use(CompressionMiddleware)
|
||||
.use(CorsMiddleware(opts))
|
||||
.route("/global", GlobalRoutes())
|
||||
|
||||
const runtime = adapter.create(app)
|
||||
|
||||
if (Flag.OPENCODE_WORKSPACE_ID) {
|
||||
return {
|
||||
app: app
|
||||
.use(InstanceMiddleware(Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined))
|
||||
.use(FenceMiddleware)
|
||||
.route("/", InstanceRoutes(runtime.upgradeWebSocket, opts)),
|
||||
runtime,
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceApp = new Hono()
|
||||
const workspaceLegacyApp = new Hono()
|
||||
.use(InstanceMiddleware())
|
||||
.route("/experimental/workspace", WorkspaceRoutes())
|
||||
.use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket))
|
||||
workspaceApp.route("/", workspaceLegacyApp)
|
||||
|
||||
return {
|
||||
app: app
|
||||
.route("/", ControlPlaneRoutes())
|
||||
.route("/", workspaceApp)
|
||||
.route("/", InstanceRoutes(runtime.upgradeWebSocket, opts))
|
||||
.route("/", UIRoutes()),
|
||||
runtime,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the OpenAPI document used by the SDK build.
|
||||
*
|
||||
* Since the Effect HttpApi backend now covers every Hono route (plus the new
|
||||
* `/api/session/*` v2 routes — see `httpapi-bridge.test.ts` for the parity
|
||||
* audit), `Server.openapi()` derives the spec from `OpenApi.fromApi(PublicApi)`.
|
||||
* `PublicApi` is `OpenCodeHttpApi` annotated with the `matchLegacyOpenApi`
|
||||
* transform that injects instance query parameters, strips Effect's optional
|
||||
* null arms, normalizes component names, and patches SSE response schemas so
|
||||
* the generated SDK keeps the legacy Hono shape.
|
||||
*
|
||||
* The Hono-derived spec is still reachable via `openapiHono()` so reviewers
|
||||
* can diff the two outputs while the Hono backend lingers; once the Hono
|
||||
* backend is deleted that helper goes with it.
|
||||
*/
|
||||
export async function openapi() {
|
||||
return OpenApi.fromApi(PublicApi)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hono-derived OpenAPI spec, retained for parity diffing only. Delete once
|
||||
* the Hono backend is removed.
|
||||
*/
|
||||
export async function openapiHono() {
|
||||
// Build a fresh app with all routes registered directly so
|
||||
// hono-openapi can see describeRoute metadata (`.route()` wraps
|
||||
// handlers when the sub-app has a custom errorHandler, which
|
||||
// strips the metadata symbol).
|
||||
const { app } = createHono({})
|
||||
const result = await generateSpecs(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: "opencode",
|
||||
version: "1.0.0",
|
||||
description: "opencode api",
|
||||
},
|
||||
openapi: "3.1.1",
|
||||
},
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
export let url: URL
|
||||
|
||||
export async function listen(opts: ListenOptions): Promise<Listener> {
|
||||
const selected = select()
|
||||
const inner: Listener =
|
||||
selected.backend === "effect-httpapi" ? await listenHttpApi(opts, selected) : await listenLegacy(opts)
|
||||
|
||||
const next = new URL(inner.url)
|
||||
url = next
|
||||
|
||||
const mdns =
|
||||
opts.mdns && inner.port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1"
|
||||
if (mdns) {
|
||||
MDNS.publish(inner.port, opts.mdnsDomain)
|
||||
} else if (opts.mdns) {
|
||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||
}
|
||||
|
||||
let closing: Promise<void> | undefined
|
||||
let mdnsUnpublished = false
|
||||
const unpublish = () => {
|
||||
if (!mdns || mdnsUnpublished) return
|
||||
mdnsUnpublished = true
|
||||
MDNS.unpublish()
|
||||
}
|
||||
return {
|
||||
hostname: inner.hostname,
|
||||
port: inner.port,
|
||||
url: next,
|
||||
stop(close?: boolean) {
|
||||
unpublish()
|
||||
// Always forward stop(true), even if a graceful stop was requested
|
||||
// first, so native listeners can escalate shutdown in-place.
|
||||
const next = inner.stop(close)
|
||||
closing ??= next
|
||||
return close ? next.then(() => closing!) : closing
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function listenLegacy(opts: ListenOptions): Promise<Listener> {
|
||||
const built = create(opts)
|
||||
const server = await built.runtime.listen(opts)
|
||||
const innerUrl = new URL("http://localhost")
|
||||
innerUrl.hostname = opts.hostname
|
||||
innerUrl.port = String(server.port)
|
||||
return {
|
||||
hostname: opts.hostname,
|
||||
port: server.port,
|
||||
url: innerUrl,
|
||||
stop: (close?: boolean) => server.stop(close),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the effect-httpapi backend on a native Effect HTTP server. This
|
||||
* lets HttpApi routes that call `request.upgrade` (PTY connect, the
|
||||
* workspace-routing proxy WS bridge) work end-to-end; the legacy Hono
|
||||
* adapter path can't surface `request.upgrade` because its fetch handler has
|
||||
* no reference to the platform server instance for websocket upgrades.
|
||||
*/
|
||||
async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selection): Promise<Listener> {
|
||||
log.info("server backend selected", {
|
||||
...ServerBackend.attributes(selection),
|
||||
"opencode.server.runtime": HttpApiServer.name,
|
||||
})
|
||||
log.info("server backend", { "opencode.server.runtime": HttpApiServer.name })
|
||||
|
||||
const buildLayer = (port: number) =>
|
||||
HttpRouter.serve(ExperimentalHttpApiServer.createRoutes(opts), {
|
||||
@@ -264,10 +70,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,
|
||||
@@ -302,8 +104,24 @@ async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selec
|
||||
const innerUrl = new URL("http://localhost")
|
||||
innerUrl.hostname = opts.hostname
|
||||
innerUrl.port = String(port)
|
||||
url = innerUrl
|
||||
|
||||
const mdns =
|
||||
opts.mdns && port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1"
|
||||
if (mdns) {
|
||||
MDNS.publish(port, opts.mdnsDomain)
|
||||
} else if (opts.mdns) {
|
||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||
}
|
||||
|
||||
let forceStopPromise: Promise<void> | undefined
|
||||
let stopPromise: Promise<void> | undefined
|
||||
let mdnsUnpublished = false
|
||||
const unpublish = () => {
|
||||
if (!mdns || mdnsUnpublished) return
|
||||
mdnsUnpublished = true
|
||||
MDNS.unpublish()
|
||||
}
|
||||
const forceStop = () => {
|
||||
forceStopPromise ??= Effect.runPromiseExit(
|
||||
Effect.gen(function* () {
|
||||
@@ -319,9 +137,8 @@ async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selec
|
||||
port,
|
||||
url: innerUrl,
|
||||
stop: (close?: boolean) => {
|
||||
unpublish()
|
||||
const requested = close ? forceStop() : Promise.resolve()
|
||||
// The first call starts scope shutdown. A later stop(true) cannot undo
|
||||
// that, but it still runs forceStop() before awaiting the original close.
|
||||
stopPromise ??= requested
|
||||
.then(() => Effect.runPromiseExit(Scope.close(resolved!.scope, Exit.void)))
|
||||
.then(() => undefined)
|
||||
|
||||
@@ -1,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)
|
||||
}
|
||||
}
|
||||
@@ -339,8 +339,7 @@ export const Event = {
|
||||
sessionID: Schema.optional(SessionID),
|
||||
// Reuses MessageV2.Assistant.fields.error (already Schema.optional) so
|
||||
// the derived zod keeps the same discriminated-union shape on the bus.
|
||||
// Schema.suspend defers access to break circular init in compiled binaries.
|
||||
error: Schema.suspend(() => MessageV2.Assistant.fields.error),
|
||||
error: MessageV2.Assistant.fields.error,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -256,8 +256,6 @@ function body(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
return array(ast)
|
||||
case "Declaration":
|
||||
return decl(ast)
|
||||
case "Suspend":
|
||||
return z.lazy(() => walk(ast.thunk()))
|
||||
default:
|
||||
return fail(ast)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -1,443 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
|
||||
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
|
||||
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
|
||||
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { Server } from "../../src/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { ConfigProvider, Layer } from "effect"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const original = {
|
||||
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
|
||||
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
||||
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
|
||||
}
|
||||
|
||||
const methods = ["get", "post", "put", "delete", "patch"] as const
|
||||
let effectSpec: ReturnType<typeof OpenApi.fromApi> | undefined
|
||||
|
||||
function effectOpenApi() {
|
||||
return (effectSpec ??= OpenApi.fromApi(PublicApi))
|
||||
}
|
||||
|
||||
function app(input?: { password?: string; username?: string }) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
Flag.OPENCODE_SERVER_PASSWORD = input?.password
|
||||
Flag.OPENCODE_SERVER_USERNAME = input?.username
|
||||
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
ExperimentalHttpApiServer.routes.pipe(
|
||||
Layer.provide(
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_SERVER_PASSWORD: input?.password,
|
||||
OPENCODE_SERVER_USERNAME: input?.username,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
).handler
|
||||
return {
|
||||
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
|
||||
request(input: string | URL | Request, init?: RequestInit) {
|
||||
return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function openApiRouteKeys(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], unknown>>> }) {
|
||||
return Object.entries(spec.paths)
|
||||
.flatMap(([path, item]) =>
|
||||
methods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`),
|
||||
)
|
||||
.sort()
|
||||
}
|
||||
|
||||
function openApiParameters(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(spec.paths).flatMap(([path, item]) =>
|
||||
methods
|
||||
.filter((method) => item[method])
|
||||
.map((method) => [
|
||||
`${method.toUpperCase()} ${path}`,
|
||||
(item[method]?.parameters ?? [])
|
||||
.map(parameterKey)
|
||||
.filter((param) => param !== undefined)
|
||||
.sort(),
|
||||
]),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function openApiRequestBodies(spec: OpenApiSpec) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(spec.paths).flatMap(([path, item]) =>
|
||||
methods
|
||||
.filter((method) => item[method])
|
||||
.map((method) => [`${method.toUpperCase()} ${path}`, requestBodyKey(spec, item[method]?.requestBody)]),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
type OpenApiSpec = {
|
||||
components?: {
|
||||
schemas?: Record<string, unknown>
|
||||
}
|
||||
paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>>
|
||||
}
|
||||
|
||||
type OpenApiSchema = {
|
||||
$ref?: string
|
||||
allOf?: unknown[]
|
||||
anyOf?: unknown[]
|
||||
oneOf?: unknown[]
|
||||
properties?: Record<string, unknown>
|
||||
type?: string | string[]
|
||||
}
|
||||
|
||||
type Operation = {
|
||||
parameters?: unknown[]
|
||||
responses?: unknown
|
||||
requestBody?: unknown
|
||||
}
|
||||
|
||||
type RequestBody = {
|
||||
content?: Record<string, { schema?: OpenApiSchema }>
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
function parameterKey(param: unknown): string | undefined {
|
||||
if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return undefined
|
||||
if (typeof param.in !== "string" || typeof param.name !== "string") return undefined
|
||||
return `${param.in}:${param.name}:${"required" in param && param.required === true}:${stableSchema(
|
||||
"schema" in param ? param.schema : undefined,
|
||||
)}`
|
||||
}
|
||||
|
||||
function stableSchema(input: unknown): string {
|
||||
return JSON.stringify(sortSchema(input))
|
||||
}
|
||||
|
||||
function sortSchema(input: unknown): unknown {
|
||||
if (Array.isArray(input)) return input.map(sortSchema)
|
||||
if (!input || typeof input !== "object") return input
|
||||
return Object.fromEntries(
|
||||
Object.entries(input)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => [key, sortSchema(value)]),
|
||||
)
|
||||
}
|
||||
|
||||
function parameterSchema(input: {
|
||||
spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
|
||||
path: string
|
||||
method: (typeof methods)[number]
|
||||
name: string
|
||||
}): unknown {
|
||||
const param = input.spec.paths[input.path]?.[input.method]?.parameters?.find(
|
||||
(param) => !!param && typeof param === "object" && "name" in param && param.name === input.name,
|
||||
)
|
||||
if (!param || typeof param !== "object" || !("schema" in param)) return undefined
|
||||
return param.schema
|
||||
}
|
||||
|
||||
function requestBodyKey(spec: OpenApiSpec, body: unknown) {
|
||||
if (!body || typeof body !== "object" || !("content" in body)) return ""
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded above; test helper only needs this OpenAPI subset.
|
||||
const requestBody = body as RequestBody
|
||||
return JSON.stringify({
|
||||
required: requestBody.required === true,
|
||||
content: Object.entries(requestBody.content ?? {})
|
||||
.map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)] as const)
|
||||
.sort(([left], [right]) => left.localeCompare(right)),
|
||||
})
|
||||
}
|
||||
|
||||
function requestBodySchemaKind(spec: OpenApiSpec, schema: OpenApiSchema | undefined) {
|
||||
if (!schema) return ""
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `$ref` lookup is constrained to OpenAPI schema components in this test helper.
|
||||
const resolved = (
|
||||
schema.$ref ? spec.components?.schemas?.[schema.$ref.replace("#/components/schemas/", "")] : schema
|
||||
) as OpenApiSchema | undefined
|
||||
if (resolved?.properties) return "object"
|
||||
if (resolved?.anyOf ?? resolved?.oneOf ?? resolved?.allOf) return "object"
|
||||
return resolved?.type ?? schema.type ?? "inline"
|
||||
}
|
||||
|
||||
function responseContentTypes(input: {
|
||||
spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
|
||||
path: string
|
||||
method: (typeof methods)[number]
|
||||
status: string
|
||||
}) {
|
||||
const responses = input.spec.paths[input.path]?.[input.method]?.responses
|
||||
if (!responses || typeof responses !== "object" || !(input.status in responses)) return []
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded dynamic OpenAPI response lookup.
|
||||
const response = (responses as Record<string, unknown>)[input.status]
|
||||
if (!response || typeof response !== "object" || !("content" in response)) return []
|
||||
const content = (response as { content?: unknown }).content
|
||||
if (!content || typeof content !== "object") {
|
||||
return []
|
||||
}
|
||||
return Object.keys(content).sort()
|
||||
}
|
||||
|
||||
function authorization(username: string, password: string) {
|
||||
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
}
|
||||
|
||||
function fileUrl(input?: { directory?: string; token?: string }) {
|
||||
const url = new URL(`http://localhost${FilePaths.content}`)
|
||||
url.searchParams.set("path", "hello.txt")
|
||||
if (input?.directory) url.searchParams.set("directory", input.directory)
|
||||
if (input?.token) url.searchParams.set("auth_token", input.token)
|
||||
return url
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
|
||||
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("HttpApi server", () => {
|
||||
test("keeps Effect HttpApi behind the feature flag", () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false
|
||||
expect(Server.backend()).toEqual({ backend: "hono", reason: "stable" })
|
||||
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
expect(Server.backend()).toEqual({ backend: "effect-httpapi", reason: "env" })
|
||||
})
|
||||
|
||||
test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => {
|
||||
const honoRoutes = openApiRouteKeys(await Server.openapiHono())
|
||||
const effectRoutes = openApiRouteKeys(effectOpenApi())
|
||||
|
||||
expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([])
|
||||
expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([
|
||||
"GET /api/session",
|
||||
"GET /api/session/{sessionID}/context",
|
||||
"GET /api/session/{sessionID}/message",
|
||||
"POST /api/session/{sessionID}/compact",
|
||||
"POST /api/session/{sessionID}/prompt",
|
||||
"POST /api/session/{sessionID}/wait",
|
||||
])
|
||||
})
|
||||
|
||||
test("matches generated OpenAPI route parameters", async () => {
|
||||
const hono = openApiParameters(await Server.openapiHono())
|
||||
const effect = openApiParameters(effectOpenApi())
|
||||
|
||||
expect(
|
||||
Object.keys(hono)
|
||||
.filter((route) => JSON.stringify(hono[route]) !== JSON.stringify(effect[route]))
|
||||
.map((route) => ({ route, hono: hono[route], effect: effect[route] })),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("matches generated OpenAPI request body shape", async () => {
|
||||
const hono = openApiRequestBodies(await Server.openapiHono())
|
||||
const effect = openApiRequestBodies(effectOpenApi())
|
||||
|
||||
expect(
|
||||
Object.keys(hono)
|
||||
.filter((route) => hono[route] !== effect[route])
|
||||
.map((route) => ({ route, hono: hono[route], effect: effect[route] })),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("matches SDK-affecting query parameter schemas", async () => {
|
||||
const effect = effectOpenApi()
|
||||
|
||||
expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "roots" })).toEqual({
|
||||
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
|
||||
})
|
||||
expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "start" })).toEqual({
|
||||
type: "number",
|
||||
})
|
||||
expect(parameterSchema({ spec: effect, path: "/find/file", method: "get", name: "limit" })).toEqual({
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
maximum: 200,
|
||||
})
|
||||
expect(
|
||||
parameterSchema({ spec: effect, path: "/session/{sessionID}/message", method: "get", name: "limit" }),
|
||||
).toEqual({
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
})
|
||||
})
|
||||
|
||||
test("matches SDK-affecting request schema details", () => {
|
||||
const effect = effectOpenApi()
|
||||
const sessionUpdate = effect.paths["/session/{sessionID}"]?.patch?.requestBody
|
||||
const sessionUpdateSchema =
|
||||
typeof sessionUpdate === "object" && sessionUpdate && "content" in sessionUpdate
|
||||
? sessionUpdate.content?.["application/json"]?.schema
|
||||
: undefined
|
||||
const sessionUpdateProperties = sessionUpdateSchema?.properties as Record<string, OpenApiSchema> | undefined
|
||||
const time = sessionUpdateProperties?.time
|
||||
expect(time?.properties?.archived).toEqual({ type: "number" })
|
||||
})
|
||||
|
||||
test("documents event routes as server-sent events", () => {
|
||||
const effect = effectOpenApi()
|
||||
|
||||
expect(responseContentTypes({ spec: effect, path: "/event", method: "get", status: "200" })).toEqual([
|
||||
"text/event-stream",
|
||||
])
|
||||
expect(responseContentTypes({ spec: effect, path: "/global/event", method: "get", status: "200" })).toEqual([
|
||||
"text/event-stream",
|
||||
])
|
||||
})
|
||||
|
||||
test("allows requests when auth is disabled", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Bun.write(`${tmp.path}/hello.txt`, "hello")
|
||||
|
||||
const response = await app().request(fileUrl(), {
|
||||
headers: {
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ content: "hello" })
|
||||
})
|
||||
|
||||
test("provides instance context to bridged handlers", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const response = await app().request("/project/current", {
|
||||
headers: {
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ worktree: tmp.path })
|
||||
})
|
||||
|
||||
test("requires credentials when auth is enabled", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Bun.write(`${tmp.path}/hello.txt`, "hello")
|
||||
|
||||
const [missing, bad, good] = await Promise.all([
|
||||
app({ password: "secret" }).request(fileUrl(), {
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
}),
|
||||
app({ password: "secret" }).request(fileUrl(), {
|
||||
headers: {
|
||||
authorization: authorization("opencode", "wrong"),
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
}),
|
||||
app({ password: "secret" }).request(fileUrl(), {
|
||||
headers: {
|
||||
authorization: authorization("opencode", "secret"),
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
expect(missing.status).toBe(401)
|
||||
expect(bad.status).toBe(401)
|
||||
expect(good.status).toBe(200)
|
||||
})
|
||||
|
||||
test("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 })
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
|
||||
@@ -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,9 @@ 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
|
||||
return experimental ? Server.Default().app : Server.Default().app
|
||||
}
|
||||
|
||||
async function readFirstChunk(response: Response) {
|
||||
@@ -36,7 +33,6 @@ async function readFirstEvent(response: Response) {
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -10,18 +10,14 @@ 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 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.
|
||||
// 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
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
yield* Effect.promise(() => resetDatabase())
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
|
||||
await resetDatabase()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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<typeof app>
|
||||
|
||||
function pathFor(path: string, params: Record<string, string>) {
|
||||
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<A, E, R>(
|
||||
options: Parameters<typeof tmpdir>[0],
|
||||
fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
|
||||
) {
|
||||
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 },
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
@@ -41,7 +38,6 @@ async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpap
|
||||
}
|
||||
|
||||
async function startNoAuthListener() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false
|
||||
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()
|
||||
|
||||
@@ -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<unknown>
|
||||
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
|
||||
return experimental ? Server.Default().app : Server.Default().app
|
||||
}
|
||||
type TestApp = ReturnType<typeof app>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -1,128 +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<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
|
||||
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 NamedError
|
||||
// envelope `{ name, data: { message } }`. HttpApi returns the typed-error
|
||||
// shape `{ _tag }` instead. SDK consumers reading `error.data.message`
|
||||
// see undefined.
|
||||
//
|
||||
// FIXME: unskip when error JSON shape policy is decided + applied (separate PR).
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("Error JSON shape parity", () => {
|
||||
test.todo("HttpApi 404 body matches NamedError shape", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
|
||||
const response = await app(true).request("/session/ses_does_not_exist", {
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
const body = (await response.json()) as { name?: string; data?: { message?: string } }
|
||||
expect(body.name).toBe("NotFoundError")
|
||||
expect(typeof body.data?.message).toBe("string")
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
return experimental ? Server.Default().app : Server.Default().app
|
||||
}
|
||||
|
||||
function requestAuthorize(input: {
|
||||
@@ -101,7 +98,6 @@ function withProviderProject<A, E, R>(self: (dir: string) => Effect.Effect<A, E,
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
)
|
||||
@@ -51,7 +47,6 @@ const effectIt = testEffect(
|
||||
)
|
||||
|
||||
function app() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -20,7 +20,6 @@ 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,
|
||||
}
|
||||
@@ -33,10 +32,9 @@ 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"
|
||||
Flag.OPENCODE_SERVER_PASSWORD = input?.password
|
||||
Flag.OPENCODE_SERVER_USERNAME = input?.username
|
||||
if (backend === "legacy") return Server.Legacy().app
|
||||
if (backend === "legacy") return Server.Default().app
|
||||
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
ExperimentalHttpApiServer.routes.pipe(
|
||||
@@ -258,7 +256,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()
|
||||
|
||||
@@ -28,12 +28,10 @@ import { it } from "../lib/effect"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
|
||||
function app(experimental = true) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
|
||||
return experimental ? Server.Default().app : Server.Legacy().app
|
||||
return experimental ? Server.Default().app : Server.Default().app
|
||||
}
|
||||
|
||||
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
|
||||
@@ -139,7 +137,6 @@ function withTmp<A, E, R>(
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
|
||||
@@ -12,12 +12,10 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
|
||||
function app(httpapi = true) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpapi
|
||||
return httpapi ? Server.Default().app : Server.Legacy().app
|
||||
return httpapi ? Server.Default().app : Server.Default().app
|
||||
}
|
||||
|
||||
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
|
||||
@@ -26,7 +24,6 @@ function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
|
||||
@@ -1,116 +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<string, string>, 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 missing = await app().request(TuiPaths.selectSession, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ sessionID: SessionID.descending() }),
|
||||
})
|
||||
expect(missing.status).toBe(404)
|
||||
})
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
@@ -21,7 +21,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,
|
||||
@@ -30,7 +29,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
|
||||
@@ -116,7 +114,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
|
||||
|
||||
@@ -136,7 +133,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
|
||||
|
||||
@@ -188,7 +184,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(
|
||||
@@ -231,7 +226,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(
|
||||
@@ -261,7 +255,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")
|
||||
|
||||
@@ -269,7 +262,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("/")
|
||||
@@ -279,7 +271,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({
|
||||
@@ -293,7 +284,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("/", {
|
||||
@@ -309,7 +299,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"]) {
|
||||
@@ -323,7 +312,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",
|
||||
|
||||
@@ -22,14 +22,12 @@ import { testEffect } from "../lib/effect"
|
||||
void Log.init({ print: false })
|
||||
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, Workspace.defaultLayer),
|
||||
)
|
||||
|
||||
function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) {
|
||||
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 }))
|
||||
@@ -127,7 +125,6 @@ function eventStreamResponse() {
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
@@ -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<string, string>) {
|
||||
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 <domain>.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()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DIFFS_TAG_NAME, FileDiff } from "@pierre/diffs"
|
||||
import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs"
|
||||
import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
|
||||
import { Dynamic, isServer } from "solid-js/web"
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
notifyShadowReady,
|
||||
observeViewerScheme,
|
||||
} from "../pierre/file-runtime"
|
||||
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
|
||||
import { File, type DiffFileProps, type FileProps } from "./file"
|
||||
|
||||
type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
|
||||
@@ -25,6 +26,7 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
let container!: HTMLDivElement
|
||||
let fileDiffRef!: HTMLElement
|
||||
let fileDiffInstance: FileDiff<T> | undefined
|
||||
let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
|
||||
|
||||
const ready = createReadyWatcher()
|
||||
const workerPool = useWorkerPool(props.diffStyle)
|
||||
@@ -49,6 +51,14 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
|
||||
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
|
||||
|
||||
const getVirtualizer = () => {
|
||||
if (sharedVirtualizer) return sharedVirtualizer.virtualizer
|
||||
const result = acquireVirtualizer(container)
|
||||
if (!result) return
|
||||
sharedVirtualizer = result
|
||||
return result.virtualizer
|
||||
}
|
||||
|
||||
const setSelectedLines = (range: DiffFileProps<T>["selectedLines"], attempt = 0) => {
|
||||
const diff = fileDiffInstance
|
||||
if (!diff) return
|
||||
@@ -82,15 +92,27 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
|
||||
onCleanup(observeViewerScheme(() => fileDiffRef))
|
||||
|
||||
const virtualizer = getVirtualizer()
|
||||
const annotations = local.annotations ?? local.preloadedDiff.annotations ?? []
|
||||
fileDiffInstance = new FileDiff<T>(
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...(local.preloadedDiff.options ?? {}),
|
||||
},
|
||||
workerPool,
|
||||
)
|
||||
fileDiffInstance = virtualizer
|
||||
? new VirtualizedFileDiff<T>(
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...local.preloadedDiff.options,
|
||||
},
|
||||
virtualizer,
|
||||
virtualMetrics,
|
||||
workerPool,
|
||||
)
|
||||
: new FileDiff<T>(
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...local.preloadedDiff.options,
|
||||
},
|
||||
workerPool,
|
||||
)
|
||||
|
||||
applyViewerScheme(fileDiffRef)
|
||||
|
||||
@@ -141,6 +163,8 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
onCleanup(() => {
|
||||
clearReadyWatcher(ready)
|
||||
fileDiffInstance?.cleanUp()
|
||||
sharedVirtualizer?.release()
|
||||
sharedVirtualizer = undefined
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { sampledChecksum } from "@opencode-ai/core/util/encode"
|
||||
import {
|
||||
DEFAULT_VIRTUAL_FILE_METRICS,
|
||||
type DiffLineAnnotation,
|
||||
type FileContents,
|
||||
type FileDiffMetadata,
|
||||
@@ -9,6 +10,10 @@ import {
|
||||
type FileOptions,
|
||||
type LineAnnotation,
|
||||
type SelectedLineRange,
|
||||
type VirtualFileMetrics,
|
||||
VirtualizedFile,
|
||||
VirtualizedFileDiff,
|
||||
Virtualizer,
|
||||
} from "@pierre/diffs"
|
||||
import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
@@ -35,10 +40,19 @@ import {
|
||||
readShadowLineSelection,
|
||||
} from "../pierre/file-selection"
|
||||
import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge"
|
||||
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
|
||||
import { getWorkerPool } from "../pierre/worker"
|
||||
import { FileMedia, type FileMediaOptions } from "./file-media"
|
||||
import { FileSearchBar } from "./file-search"
|
||||
|
||||
const VIRTUALIZE_BYTES = 500_000
|
||||
|
||||
const codeMetrics = {
|
||||
...DEFAULT_VIRTUAL_FILE_METRICS,
|
||||
lineHeight: 24,
|
||||
fileGap: 0,
|
||||
} satisfies Partial<VirtualFileMetrics>
|
||||
|
||||
type SharedProps<T> = {
|
||||
annotations?: LineAnnotation<T>[] | DiffLineAnnotation<T>[]
|
||||
selectedLines?: SelectedLineRange | null
|
||||
@@ -372,6 +386,11 @@ type AnnotationTarget<A> = {
|
||||
rerender: () => void
|
||||
}
|
||||
|
||||
type VirtualStrategy = {
|
||||
get: () => Virtualizer | undefined
|
||||
cleanup: () => void
|
||||
}
|
||||
|
||||
function useModeViewer(config: ModeConfig, adapter: ModeAdapter) {
|
||||
return useFileViewer({
|
||||
enableLineSelection: config.enableLineSelection,
|
||||
@@ -513,6 +532,64 @@ function scrollParent(el: HTMLElement): HTMLElement | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
|
||||
let virtualizer: Virtualizer | undefined
|
||||
let root: Document | HTMLElement | undefined
|
||||
|
||||
const release = () => {
|
||||
virtualizer?.cleanUp()
|
||||
virtualizer = undefined
|
||||
root = undefined
|
||||
}
|
||||
|
||||
return {
|
||||
get: () => {
|
||||
if (!enabled()) {
|
||||
release()
|
||||
return
|
||||
}
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const wrapper = host()
|
||||
if (!wrapper) return
|
||||
|
||||
const next = scrollParent(wrapper) ?? document
|
||||
if (virtualizer && root === next) return virtualizer
|
||||
|
||||
release()
|
||||
virtualizer = new Virtualizer()
|
||||
root = next
|
||||
virtualizer.setup(next, next instanceof Document ? undefined : wrapper)
|
||||
return virtualizer
|
||||
},
|
||||
cleanup: release,
|
||||
}
|
||||
}
|
||||
|
||||
function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined): VirtualStrategy {
|
||||
let shared: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
|
||||
|
||||
const release = () => {
|
||||
shared?.release()
|
||||
shared = undefined
|
||||
}
|
||||
|
||||
return {
|
||||
get: () => {
|
||||
if (shared) return shared.virtualizer
|
||||
|
||||
const container = host()
|
||||
if (!container) return
|
||||
|
||||
const result = acquireVirtualizer(container)
|
||||
if (!result) return
|
||||
shared = result
|
||||
return result.virtualizer
|
||||
},
|
||||
cleanup: release,
|
||||
}
|
||||
}
|
||||
|
||||
function parseLine(node: HTMLElement) {
|
||||
if (!node.dataset.line) return
|
||||
const value = parseInt(node.dataset.line, 10)
|
||||
@@ -611,7 +688,7 @@ function ViewerShell(props: {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TextViewer<T>(props: TextFileProps<T>) {
|
||||
let instance: PierreFile<T> | undefined
|
||||
let instance: PierreFile<T> | VirtualizedFile<T> | undefined
|
||||
let viewer!: Viewer
|
||||
|
||||
const [local, others] = splitProps(props, textKeys)
|
||||
@@ -631,12 +708,36 @@ function TextViewer<T>(props: TextFileProps<T>) {
|
||||
return Math.max(1, total)
|
||||
}
|
||||
|
||||
const bytes = createMemo(() => {
|
||||
const value = local.file.contents as unknown
|
||||
if (typeof value === "string") return value.length
|
||||
if (Array.isArray(value)) {
|
||||
return value.reduce(
|
||||
// oxlint-disable-next-line no-base-to-string -- array parts coerced intentionally
|
||||
(sum, part) => sum + (typeof part === "string" ? part.length + 1 : String(part).length + 1),
|
||||
0,
|
||||
)
|
||||
}
|
||||
if (value == null) return 0
|
||||
// oxlint-disable-next-line no-base-to-string -- file contents cast to unknown, coercion is intentional
|
||||
return String(value).length
|
||||
})
|
||||
|
||||
const virtual = createMemo(() => bytes() > VIRTUALIZE_BYTES)
|
||||
|
||||
const virtuals = createLocalVirtualStrategy(() => viewer.wrapper, virtual)
|
||||
|
||||
const lineFromMouseEvent = (event: MouseEvent): MouseHit => mouseHit(event, parseLine)
|
||||
|
||||
const applySelection = (range: SelectedLineRange | null) => {
|
||||
const current = instance
|
||||
if (!current) return false
|
||||
|
||||
if (virtual()) {
|
||||
current.setSelectedLines(range)
|
||||
return true
|
||||
}
|
||||
|
||||
const root = viewer.getRoot()
|
||||
if (!root) return false
|
||||
|
||||
@@ -735,7 +836,10 @@ function TextViewer<T>(props: TextFileProps<T>) {
|
||||
const notify = () => {
|
||||
notifyRendered({
|
||||
viewer,
|
||||
isReady: (root) => root.querySelectorAll("[data-line]").length >= lineCount(),
|
||||
isReady: (root) => {
|
||||
if (virtual()) return root.querySelector("[data-line]") != null
|
||||
return root.querySelectorAll("[data-line]").length >= lineCount()
|
||||
},
|
||||
onReady: () => {
|
||||
applySelection(viewer.lastSelection)
|
||||
viewer.find.refresh({ reset: true })
|
||||
@@ -754,11 +858,17 @@ function TextViewer<T>(props: TextFileProps<T>) {
|
||||
createEffect(() => {
|
||||
const opts = options()
|
||||
const workerPool = getWorkerPool("unified")
|
||||
const isVirtual = virtual()
|
||||
|
||||
const virtualizer = virtuals.get()
|
||||
|
||||
renderViewer({
|
||||
viewer,
|
||||
current: instance,
|
||||
create: () => new PierreFile<T>(opts, workerPool),
|
||||
create: () =>
|
||||
isVirtual && virtualizer
|
||||
? new VirtualizedFile<T>(opts, virtualizer, codeMetrics, workerPool)
|
||||
: new PierreFile<T>(opts, workerPool),
|
||||
assign: (value) => {
|
||||
instance = value
|
||||
},
|
||||
@@ -785,6 +895,7 @@ function TextViewer<T>(props: TextFileProps<T>) {
|
||||
onCleanup(() => {
|
||||
instance?.cleanUp()
|
||||
instance = undefined
|
||||
virtuals.cleanup()
|
||||
})
|
||||
|
||||
return <ViewerShell mode="text" viewer={viewer} class={local.class} classList={local.classList} />
|
||||
@@ -880,6 +991,8 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
adapter,
|
||||
)
|
||||
|
||||
const virtuals = createSharedVirtualStrategy(() => viewer.container)
|
||||
|
||||
const large = createMemo(() => {
|
||||
if (local.fileDiff) {
|
||||
const before = local.fileDiff.deletionLines.join("")
|
||||
@@ -942,6 +1055,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
createEffect(() => {
|
||||
const opts = options()
|
||||
const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
|
||||
const virtualizer = virtuals.get()
|
||||
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
|
||||
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
|
||||
const done = preserve(viewer)
|
||||
@@ -956,7 +1070,10 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
renderViewer({
|
||||
viewer,
|
||||
current: instance,
|
||||
create: () => new FileDiff<T>(opts, workerPool),
|
||||
create: () =>
|
||||
virtualizer
|
||||
? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
|
||||
: new FileDiff<T>(opts, workerPool),
|
||||
assign: (value) => {
|
||||
instance = value
|
||||
},
|
||||
@@ -994,6 +1111,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
onCleanup(() => {
|
||||
instance?.cleanUp()
|
||||
instance = undefined
|
||||
virtuals.cleanup()
|
||||
dragSide = undefined
|
||||
dragEndSide = undefined
|
||||
})
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { LineCommentEditorProps } from "./line-comment"
|
||||
import { normalize, text, type ViewDiff } from "./session-diff"
|
||||
|
||||
const MAX_DIFF_CHANGED_LINES = 500
|
||||
const REVIEW_MOUNT_MARGIN = 300
|
||||
|
||||
export type SessionReviewDiffStyle = "unified" | "split"
|
||||
|
||||
@@ -158,11 +159,14 @@ type SessionReviewSelection = {
|
||||
export const SessionReview = (props: SessionReviewProps) => {
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let focusToken = 0
|
||||
let frame: number | undefined
|
||||
const i18n = useI18n()
|
||||
const fileComponent = useFileComponent()
|
||||
const anchors = new Map<string, HTMLElement>()
|
||||
const nodes = new Map<string, HTMLDivElement>()
|
||||
const [store, setStore] = createStore({
|
||||
open: [] as string[],
|
||||
visible: {} as Record<string, boolean>,
|
||||
force: {} as Record<string, boolean>,
|
||||
selection: null as SessionReviewSelection | null,
|
||||
commenting: null as SessionReviewSelection | null,
|
||||
@@ -192,7 +196,44 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
|
||||
const hasDiffs = () => files().length > 0
|
||||
|
||||
const syncVisible = () => {
|
||||
frame = undefined
|
||||
if (!scroll) return
|
||||
|
||||
const root = scroll.getBoundingClientRect()
|
||||
const top = root.top - REVIEW_MOUNT_MARGIN
|
||||
const bottom = root.bottom + REVIEW_MOUNT_MARGIN
|
||||
const openSet = new Set(open())
|
||||
const next: Record<string, boolean> = {}
|
||||
|
||||
for (const [file, el] of nodes) {
|
||||
if (!openSet.has(file)) continue
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.bottom < top || rect.top > bottom) continue
|
||||
next[file] = true
|
||||
}
|
||||
|
||||
const prev = untrack(() => store.visible)
|
||||
const prevKeys = Object.keys(prev)
|
||||
const nextKeys = Object.keys(next)
|
||||
if (prevKeys.length === nextKeys.length && nextKeys.every((file) => prev[file])) return
|
||||
setStore("visible", next)
|
||||
}
|
||||
|
||||
const queue = () => {
|
||||
if (frame !== undefined) return
|
||||
frame = requestAnimationFrame(syncVisible)
|
||||
}
|
||||
|
||||
const pinned = (file: string) =>
|
||||
props.focusedComment?.file === file ||
|
||||
props.focusedFile === file ||
|
||||
selection()?.file === file ||
|
||||
commenting()?.file === file ||
|
||||
opened()?.file === file
|
||||
|
||||
const handleScroll: JSX.EventHandler<HTMLDivElement, Event> = (event) => {
|
||||
queue()
|
||||
const next = props.onScroll
|
||||
if (!next) return
|
||||
if (Array.isArray(next)) {
|
||||
@@ -203,9 +244,21 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
;(next as JSX.EventHandler<HTMLDivElement, Event>)(event)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (frame === undefined) return
|
||||
cancelAnimationFrame(frame)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.open
|
||||
files()
|
||||
queue()
|
||||
})
|
||||
|
||||
const handleChange = (next: string[]) => {
|
||||
props.onOpenChange?.(next)
|
||||
if (props.open === undefined) setStore("open", next)
|
||||
queue()
|
||||
}
|
||||
|
||||
const handleExpandOrCollapseAll = () => {
|
||||
@@ -319,6 +372,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
viewportRef={(el) => {
|
||||
scroll = el
|
||||
props.scrollRef?.(el)
|
||||
queue()
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
classList={{
|
||||
@@ -337,6 +391,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
const diffCanRender = () => diff.additions !== 0 || diff.deletions !== 0
|
||||
|
||||
const expanded = createMemo(() => open().includes(file))
|
||||
const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file)))
|
||||
const force = () => !!store.force[file]
|
||||
|
||||
const comments = createMemo(() => grouped().get(file) ?? [])
|
||||
@@ -427,6 +482,8 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
|
||||
onCleanup(() => {
|
||||
anchors.delete(file)
|
||||
nodes.delete(file)
|
||||
queue()
|
||||
})
|
||||
|
||||
const handleLineSelected = (range: SelectedLineRange | null) => {
|
||||
@@ -512,10 +569,19 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
data-slot="session-review-diff-wrapper"
|
||||
ref={(el) => {
|
||||
anchors.set(file, el)
|
||||
nodes.set(file, el)
|
||||
queue()
|
||||
}}
|
||||
>
|
||||
<Show when={expanded()}>
|
||||
<Switch>
|
||||
<Match when={!mounted() && !tooLarge()}>
|
||||
<div
|
||||
data-slot="session-review-diff-placeholder"
|
||||
class="rounded-lg border border-border-weak-base bg-background-stronger/40"
|
||||
style={{ height: "160px" }}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={tooLarge()}>
|
||||
<div data-slot="session-review-large-diff">
|
||||
<div data-slot="session-review-large-diff-title">
|
||||
|
||||
100
packages/ui/src/pierre/virtualizer.ts
Normal file
100
packages/ui/src/pierre/virtualizer.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { type VirtualFileMetrics, Virtualizer } from "@pierre/diffs"
|
||||
|
||||
type Target = {
|
||||
key: Document | HTMLElement
|
||||
root: Document | HTMLElement
|
||||
content: HTMLElement | undefined
|
||||
}
|
||||
|
||||
type Entry = {
|
||||
virtualizer: Virtualizer
|
||||
refs: number
|
||||
}
|
||||
|
||||
const cache = new WeakMap<Document | HTMLElement, Entry>()
|
||||
|
||||
export const virtualMetrics: Partial<VirtualFileMetrics> = {
|
||||
lineHeight: 24,
|
||||
hunkSeparatorHeight: 24,
|
||||
fileGap: 0,
|
||||
}
|
||||
|
||||
function scrollable(value: string) {
|
||||
return value === "auto" || value === "scroll" || value === "overlay"
|
||||
}
|
||||
|
||||
function scrollRoot(container: HTMLElement) {
|
||||
let node = container.parentElement
|
||||
while (node) {
|
||||
const style = getComputedStyle(node)
|
||||
if (scrollable(style.overflowY)) return node
|
||||
node = node.parentElement
|
||||
}
|
||||
}
|
||||
|
||||
function target(container: HTMLElement): Target | undefined {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const review = container.closest("[data-component='session-review']")
|
||||
if (review instanceof HTMLElement) {
|
||||
const root = scrollRoot(container) ?? review
|
||||
const content = review.querySelector("[data-slot='session-review-container']")
|
||||
return {
|
||||
key: review,
|
||||
root,
|
||||
content: content instanceof HTMLElement ? content : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const root = scrollRoot(container)
|
||||
if (root) {
|
||||
const content = root.querySelector("[role='log']")
|
||||
return {
|
||||
key: root,
|
||||
root,
|
||||
content: content instanceof HTMLElement ? content : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key: document,
|
||||
root: document,
|
||||
content: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function acquireVirtualizer(container: HTMLElement) {
|
||||
const resolved = target(container)
|
||||
if (!resolved) return
|
||||
|
||||
let entry = cache.get(resolved.key)
|
||||
if (!entry) {
|
||||
const virtualizer = new Virtualizer()
|
||||
virtualizer.setup(resolved.root, resolved.content)
|
||||
entry = {
|
||||
virtualizer,
|
||||
refs: 0,
|
||||
}
|
||||
cache.set(resolved.key, entry)
|
||||
}
|
||||
|
||||
entry.refs += 1
|
||||
let done = false
|
||||
|
||||
return {
|
||||
virtualizer: entry.virtualizer,
|
||||
release() {
|
||||
if (done) return
|
||||
done = true
|
||||
|
||||
const current = cache.get(resolved.key)
|
||||
if (!current) return
|
||||
|
||||
current.refs -= 1
|
||||
if (current.refs > 0) return
|
||||
|
||||
current.virtualizer.cleanUp()
|
||||
cache.delete(resolved.key)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -648,17 +648,17 @@ OpenCode Go هي خطة اشتراك منخفضة التكلفة توفّر وص
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. توجّه إلى [FrogBot dashboard](https://app.frogbot.ai/signup)، وأنشئ حسابا، ثم أنشئ مفتاح API.
|
||||
1. توجّه إلى [Firmware dashboard](https://app.firmware.ai/signup)، وأنشئ حسابا، ثم أنشئ مفتاح API.
|
||||
|
||||
2. شغّل الأمر `/connect` وابحث عن **FrogBot**.
|
||||
2. شغّل الأمر `/connect` وابحث عن **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. أدخل مفتاح API الخاص بـ FrogBot.
|
||||
3. أدخل مفتاح API الخاص بـ Firmware.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -653,17 +653,17 @@ Također možete dodati modele kroz svoju opencode konfiguraciju.
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. Idite na [kontrolnu tablu firmvera](https://app.frogbot.ai/signup), kreirajte nalog i generišite API ključ.
|
||||
1. Idite na [kontrolnu tablu firmvera](https://app.firmware.ai/signup), kreirajte nalog i generišite API ključ.
|
||||
|
||||
2. Pokrenite naredbu `/connect` i potražite **FrogBot**.
|
||||
2. Pokrenite naredbu `/connect` i potražite **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Unesite svoj FrogBot API ključ.
|
||||
3. Unesite svoj Firmware API ključ.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -644,17 +644,17 @@ Cloudflare AI Gateway lader dig få adgang til modeller fra OpenAI, Anthropic, W
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. Gå til [FrogBot dashboard](https://app.frogbot.ai/signup), opret en konto og generer en API-nøgle.
|
||||
1. Gå til [Firmware dashboard](https://app.firmware.ai/signup), opret en konto og generer en API-nøgle.
|
||||
|
||||
2. Kør kommandoen `/connect` og søg efter **FrogBot**.
|
||||
2. Kør kommandoen `/connect` og søg efter **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Indtast frogbot API-nøglen.
|
||||
3. Indtast firmware API-nøglen.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -650,17 +650,17 @@ Mit dem Cloudflare AI Gateway können Sie über einen einheitlichen Endpunkt auf
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. Gehen Sie zu [FrogBot dashboard](https://app.frogbot.ai/signup), erstellen Sie ein Konto und generieren Sie einen API-Schlüssel.
|
||||
1. Gehen Sie zu [Firmware dashboard](https://app.firmware.ai/signup), erstellen Sie ein Konto und generieren Sie einen API-Schlüssel.
|
||||
|
||||
2. Führen Sie den Befehl `/connect` aus und suchen Sie nach **FrogBot**.
|
||||
2. Führen Sie den Befehl `/connect` aus und suchen Sie nach **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Geben Sie Ihren FrogBot API-Schlüssel ein.
|
||||
3. Geben Sie Ihren Firmware API-Schlüssel ein.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -651,17 +651,17 @@ Cloudflare AI Gateway le permite acceder a modelos de OpenAI, Anthropic, Workers
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. Dirígete al [Panel de frogbot](https://app.frogbot.ai/signup), crea una cuenta y genera una clave API.
|
||||
1. Dirígete al [Panel de firmware](https://app.firmware.ai/signup), crea una cuenta y genera una clave API.
|
||||
|
||||
2. Ejecute el comando `/connect` y busque **FrogBot**.
|
||||
2. Ejecute el comando `/connect` y busque **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Ingrese su clave de frogbot API.
|
||||
3. Ingrese su clave de firmware API.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -654,11 +654,11 @@ Vous pouvez également ajouter des modèles via votre configuration opencode.
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. Rendez-vous sur le [Tableau de bord du micrologiciel](https://app.frogbot.ai/signup), créez un compte et générez une clé API.
|
||||
1. Rendez-vous sur le [Tableau de bord du micrologiciel](https://app.firmware.ai/signup), créez un compte et générez une clé API.
|
||||
|
||||
2. Exécutez la commande `/connect` et recherchez **FrogBot**.
|
||||
2. Exécutez la commande `/connect` et recherchez **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
|
||||
@@ -628,17 +628,17 @@ Cloudflare AI Gateway ti permette di accedere a modelli di OpenAI, Anthropic, Wo
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. Vai alla [dashboard di FrogBot](https://app.frogbot.ai/signup), crea un account e genera una chiave API.
|
||||
1. Vai alla [dashboard di Firmware](https://app.firmware.ai/signup), crea un account e genera una chiave API.
|
||||
|
||||
2. Esegui il comando `/connect` e cerca **FrogBot**.
|
||||
2. Esegui il comando `/connect` e cerca **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Inserisci la tua chiave API di FrogBot.
|
||||
3. Inserisci la tua chiave API di Firmware.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -658,9 +658,9 @@ OpenCode 設定を通じてモデルを追加することもできます。
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. [ファームウェアダッシュボード](https://app.frogbot.ai/signup) に移動し、アカウントを作成し、API キーを生成します。
|
||||
1. [ファームウェアダッシュボード](https://app.firmware.ai/signup) に移動し、アカウントを作成し、API キーを生成します。
|
||||
|
||||
2. `/connect` コマンドを実行し、**ファームウェア**を検索します。
|
||||
|
||||
|
||||
@@ -654,17 +654,17 @@ Cloudflare AI Gateway는 OpenAI, Anthropic, Workers AI 등의 모델에 액세
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. [FrogBot 대시보드](https://app.frogbot.ai/signup)로 이동하여 계정을 만들고 API 키를 생성합니다.
|
||||
1. [Firmware 대시보드](https://app.firmware.ai/signup)로 이동하여 계정을 만들고 API 키를 생성합니다.
|
||||
|
||||
2. `/connect` 명령을 실행하고 **FrogBot**를 검색하십시오.
|
||||
2. `/connect` 명령을 실행하고 **Firmware**를 검색하십시오.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. FrogBot API 키를 입력하십시오.
|
||||
3. Firmware API 키를 입력하십시오.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -652,17 +652,17 @@ Cloudflare AI Gateway lar deg få tilgang til modeller fra OpenAI, Anthropic, Wo
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. Gå over til [FrogBot dashboard](https://app.frogbot.ai/signup), opprett en konto og generer en API nøkkel.
|
||||
1. Gå over til [Firmware dashboard](https://app.firmware.ai/signup), opprett en konto og generer en API nøkkel.
|
||||
|
||||
2. Kjør kommandoen `/connect` og søk etter **FrogBot**.
|
||||
2. Kjør kommandoen `/connect` og søk etter **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Skriv inn frogbot API nøkkelen.
|
||||
3. Skriv inn firmware API nøkkelen.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -650,17 +650,17 @@ Cloudflare AI Gateway umożliwia dostęp do modeli z OpenAI, Anthropic, Workers
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. Przejdź do [FrogBot dashboard](https://app.frogbot.ai/signup), utwórz konto i wygeneruj klucz API.
|
||||
1. Przejdź do [Firmware dashboard](https://app.firmware.ai/signup), utwórz konto i wygeneruj klucz API.
|
||||
|
||||
2. Uruchom polecenie `/connect` i wyszukaj **FrogBot**.
|
||||
2. Uruchom polecenie `/connect` i wyszukaj **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Wprowadź klucz API FrogBot.
|
||||
3. Wprowadź klucz API Firmware.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -721,17 +721,17 @@ Cloudflare Workers AI lets you run AI models on Cloudflare's global network dire
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. Head over to the [FrogBot dashboard](https://app.frogbot.ai/signup), create an account, and generate an API key.
|
||||
1. Head over to the [Firmware dashboard](https://app.firmware.ai/signup), create an account, and generate an API key.
|
||||
|
||||
2. Run the `/connect` command and search for **FrogBot**.
|
||||
2. Run the `/connect` command and search for **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Enter your FrogBot API key.
|
||||
3. Enter your Firmware API key.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -654,17 +654,17 @@ O Cloudflare AI Gateway permite que você acesse modelos do OpenAI, Anthropic, W
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. Acesse o [painel FrogBot](https://app.frogbot.ai/signup), crie uma conta e gere uma chave da API.
|
||||
1. Acesse o [painel Firmware](https://app.firmware.ai/signup), crie uma conta e gere uma chave da API.
|
||||
|
||||
2. Execute o comando `/connect` e procure por **FrogBot**.
|
||||
2. Execute o comando `/connect` e procure por **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Insira sua chave da API FrogBot.
|
||||
3. Insira sua chave da API Firmware.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -650,17 +650,17 @@ Cloudflare AI Gateway позволяет вам получать доступ к
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. Перейдите на [панель FrogBot](https://app.frogbot.ai/signup), создайте учетную запись и сгенерируйте ключ API.
|
||||
1. Перейдите на [панель Firmware](https://app.firmware.ai/signup), создайте учетную запись и сгенерируйте ключ API.
|
||||
|
||||
2. Запустите команду `/connect` и найдите **FrogBot**.
|
||||
2. Запустите команду `/connect` и найдите **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Введите ключ API FrogBot.
|
||||
3. Введите ключ API Firmware.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -650,17 +650,17 @@ Cloudflare AI Gateway ช่วยให้คุณเข้าถึงโม
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. ไปที่ [แดชบอร์ด FrogBot](https://app.frogbot.ai/signup) สร้างบัญชี และสร้างคีย์ API
|
||||
1. ไปที่ [แดชบอร์ด Firmware](https://app.firmware.ai/signup) สร้างบัญชี และสร้างคีย์ API
|
||||
|
||||
2. เรียกใช้คำสั่ง `/connect` และค้นหา **FrogBot**
|
||||
2. เรียกใช้คำสั่ง `/connect` และค้นหา **Firmware**
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. ป้อนคีย์ FrogBot API ของคุณ
|
||||
3. ป้อนคีย์ Firmware API ของคุณ
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -652,17 +652,17 @@ Cloudflare AI Gateway, OpenAI, Anthropic, Workers AI ve daha fazlasındaki model
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. [FrogBot dashboard](https://app.frogbot.ai/signup) adresine gidin, bir hesap oluşturun ve bir API anahtarı oluşturun.
|
||||
1. [Firmware dashboard](https://app.firmware.ai/signup) adresine gidin, bir hesap oluşturun ve bir API anahtarı oluşturun.
|
||||
|
||||
2. `/connect` komutunu çalıştırın ve **FrogBot**'i arayın.
|
||||
2. `/connect` komutunu çalıştırın ve **Firmware**'i arayın.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. FrogBot API anahtarınızı girin.
|
||||
3. Firmware API anahtarınızı girin.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -624,17 +624,17 @@ Cloudflare AI Gateway 允许你通过统一端点访问来自 OpenAI、Anthropic
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. 前往 [FrogBot 仪表盘](https://app.frogbot.ai/signup),创建账户并生成 API 密钥。
|
||||
1. 前往 [Firmware 仪表盘](https://app.firmware.ai/signup),创建账户并生成 API 密钥。
|
||||
|
||||
2. 执行 `/connect` 命令并搜索 **FrogBot**。
|
||||
2. 执行 `/connect` 命令并搜索 **Firmware**。
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. 输入你的 FrogBot API 密钥。
|
||||
3. 输入你的 Firmware API 密钥。
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -645,17 +645,17 @@ Cloudflare AI Gateway 允許您透過統一端點存取來自 OpenAI、Anthropic
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. 前往 [FrogBot 儀表板](https://app.frogbot.ai/signup),建立帳號並產生 API 金鑰。
|
||||
1. 前往 [Firmware 儀表板](https://app.firmware.ai/signup),建立帳號並產生 API 金鑰。
|
||||
|
||||
2. 執行 `/connect` 指令並搜尋 **FrogBot**。
|
||||
2. 執行 `/connect` 指令並搜尋 **Firmware**。
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. 輸入您的 FrogBot API 金鑰。
|
||||
3. 輸入您的 Firmware API 金鑰。
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
Reference in New Issue
Block a user