mirror of
https://github.com/anomalyco/opencode.git
synced 2026-06-01 19:05:38 +00:00
Use PTY service directly in HTTP routes (#25138)
This commit is contained in:
@@ -5,7 +5,6 @@ import { InstanceState } from "@/effect/instance-state"
|
|||||||
import { EffectBridge } from "@/effect/bridge"
|
import { EffectBridge } from "@/effect/bridge"
|
||||||
import { lazy } from "@opencode-ai/core/util/lazy"
|
import { lazy } from "@opencode-ai/core/util/lazy"
|
||||||
import { Plugin } from "@/plugin"
|
import { Plugin } from "@/plugin"
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
import { Shell } from "@/shell/shell"
|
import { Shell } from "@/shell/shell"
|
||||||
import type { Proc } from "#pty"
|
import type { Proc } from "#pty"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
@@ -229,42 +228,38 @@ export const layer = Layer.effect(
|
|||||||
subscribers: new Map(),
|
subscribers: new Map(),
|
||||||
}
|
}
|
||||||
s.sessions.set(id, session)
|
s.sessions.set(id, session)
|
||||||
proc.onData(
|
proc.onData((chunk) => {
|
||||||
Instance.bind((chunk) => {
|
session.cursor += chunk.length
|
||||||
session.cursor += chunk.length
|
|
||||||
|
|
||||||
for (const [key, ws] of session.subscribers.entries()) {
|
for (const [key, ws] of session.subscribers.entries()) {
|
||||||
if (ws.readyState !== 1) {
|
if (ws.readyState !== 1) {
|
||||||
session.subscribers.delete(key)
|
session.subscribers.delete(key)
|
||||||
continue
|
continue
|
||||||
}
|
|
||||||
if (sock(ws) !== key) {
|
|
||||||
session.subscribers.delete(key)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
ws.send(chunk)
|
|
||||||
} catch {
|
|
||||||
session.subscribers.delete(key)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (sock(ws) !== key) {
|
||||||
|
session.subscribers.delete(key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ws.send(chunk)
|
||||||
|
} catch {
|
||||||
|
session.subscribers.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
session.buffer += chunk
|
session.buffer += chunk
|
||||||
if (session.buffer.length <= BUFFER_LIMIT) return
|
if (session.buffer.length <= BUFFER_LIMIT) return
|
||||||
const excess = session.buffer.length - BUFFER_LIMIT
|
const excess = session.buffer.length - BUFFER_LIMIT
|
||||||
session.buffer = session.buffer.slice(excess)
|
session.buffer = session.buffer.slice(excess)
|
||||||
session.bufferCursor += excess
|
session.bufferCursor += excess
|
||||||
}),
|
})
|
||||||
)
|
proc.onExit(({ exitCode }) => {
|
||||||
proc.onExit(
|
if (session.info.status === "exited") return
|
||||||
Instance.bind(({ exitCode }) => {
|
log.info("session exited", { id, exitCode })
|
||||||
if (session.info.status === "exited") return
|
session.info.status = "exited"
|
||||||
log.info("session exited", { id, exitCode })
|
bridge.fork(bus.publish(Event.Exited, { id, exitCode }))
|
||||||
session.info.status = "exited"
|
bridge.fork(remove(id))
|
||||||
bridge.fork(bus.publish(Event.Exited, { id, exitCode }))
|
})
|
||||||
bridge.fork(remove(id))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
yield* bus.publish(Event.Created, { info })
|
yield* bus.publish(Event.Created, { info })
|
||||||
return info
|
return info
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { EffectBridge } from "@/effect/bridge"
|
|
||||||
import { Pty } from "@/pty"
|
import { Pty } from "@/pty"
|
||||||
import { PtyID } from "@/pty/schema"
|
import { PtyID } from "@/pty/schema"
|
||||||
import { handlePtyInput } from "@/pty/input"
|
import { handlePtyInput } from "@/pty/input"
|
||||||
@@ -23,16 +22,11 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
|
|||||||
})
|
})
|
||||||
|
|
||||||
const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
|
const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
|
||||||
const bridge = yield* EffectBridge.make()
|
return yield* pty.create({
|
||||||
return yield* Effect.promise(() =>
|
...ctx.payload,
|
||||||
bridge.promise(
|
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
|
||||||
pty.create({
|
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
|
||||||
...ctx.payload,
|
})
|
||||||
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
|
|
||||||
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
|
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
|
||||||
@@ -68,52 +62,60 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const ptyConnectRoute = HttpRouter.add(
|
export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||||
"GET",
|
|
||||||
PtyPaths.connect,
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const pty = yield* Pty.Service
|
const pty = yield* Pty.Service
|
||||||
const params = yield* HttpRouter.schemaPathParams(Params)
|
yield* router.add(
|
||||||
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
|
"GET",
|
||||||
|
PtyPaths.connect,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const params = yield* HttpRouter.schemaPathParams(Params)
|
||||||
|
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
|
||||||
|
|
||||||
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
|
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
|
||||||
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
|
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
|
||||||
const cursor =
|
const cursor =
|
||||||
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined
|
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1
|
||||||
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
|
? parsedCursor
|
||||||
const write = yield* socket.writer
|
: undefined
|
||||||
let closed = false
|
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
|
||||||
const adapter = {
|
const write = yield* socket.writer
|
||||||
get readyState() {
|
const services = yield* Effect.context()
|
||||||
return closed ? 3 : 1
|
const writeScoped = (effect: Effect.Effect<void, unknown>) => {
|
||||||
},
|
Effect.runForkWith(services)(effect.pipe(Effect.catch(() => Effect.void)))
|
||||||
send: (data: string | Uint8Array | ArrayBuffer) => {
|
}
|
||||||
if (closed) return
|
let closed = false
|
||||||
Effect.runFork(
|
const adapter = {
|
||||||
write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void)),
|
get readyState() {
|
||||||
)
|
return closed ? 3 : 1
|
||||||
},
|
},
|
||||||
close: (code?: number, reason?: string) => {
|
send: (data: string | Uint8Array | ArrayBuffer) => {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
closed = true
|
writeScoped(write(data instanceof ArrayBuffer ? new Uint8Array(data) : data))
|
||||||
Effect.runFork(write(new Socket.CloseEvent(code, reason)).pipe(Effect.catch(() => Effect.void)))
|
},
|
||||||
},
|
close: (code?: number, reason?: string) => {
|
||||||
}
|
if (closed) return
|
||||||
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
|
|
||||||
if (!handler) return HttpServerResponse.empty()
|
|
||||||
|
|
||||||
yield* socket
|
|
||||||
.runRaw((message) => handlePtyInput(handler, message))
|
|
||||||
.pipe(
|
|
||||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
|
||||||
Effect.ensuring(
|
|
||||||
Effect.sync(() => {
|
|
||||||
closed = true
|
closed = true
|
||||||
handler.onClose()
|
writeScoped(write(new Socket.CloseEvent(code, reason)))
|
||||||
}),
|
},
|
||||||
),
|
}
|
||||||
Effect.orDie,
|
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
|
||||||
)
|
if (!handler) return HttpServerResponse.empty()
|
||||||
return HttpServerResponse.empty()
|
|
||||||
}).pipe(Effect.provide(Pty.defaultLayer)),
|
yield* socket
|
||||||
|
.runRaw((message) => handlePtyInput(handler, message))
|
||||||
|
.pipe(
|
||||||
|
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
||||||
|
Effect.ensuring(
|
||||||
|
Effect.sync(() => {
|
||||||
|
closed = true
|
||||||
|
handler.onClose()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Effect.orDie,
|
||||||
|
)
|
||||||
|
return HttpServerResponse.empty()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test"
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { PtyID } from "../../src/pty/schema"
|
import { PtyID } from "../../src/pty/schema"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
@@ -6,18 +7,60 @@ import { Server } from "../../src/server/server"
|
|||||||
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { resetDatabase } from "../fixture/db"
|
import { resetDatabase } from "../fixture/db"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir, tmpdirScoped } from "../fixture/fixture"
|
||||||
|
import { Config, Effect, Layer, Queue, Schema } from "effect"
|
||||||
|
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||||
|
import * as Socket from "effect/unstable/socket/Socket"
|
||||||
|
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||||
|
import { Pty } from "../../src/pty"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
void Log.init({ print: false })
|
void Log.init({ print: false })
|
||||||
|
|
||||||
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||||
const testPty = process.platform === "win32" ? test.skip : test
|
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()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
|
||||||
|
ExperimentalHttpApiServer.routes,
|
||||||
|
{ disableListenLog: true, disableLogger: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const effectIt = testEffect(
|
||||||
|
Layer.mergeAll(
|
||||||
|
testStateLayer,
|
||||||
|
Socket.layerWebSocketConstructorGlobal,
|
||||||
|
servedRoutes.pipe(
|
||||||
|
Layer.provide(Socket.layerWebSocketConstructorGlobal),
|
||||||
|
Layer.provideMerge(NodeHttpServer.layerTest),
|
||||||
|
Layer.provideMerge(NodeServices.layer),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
function app() {
|
function app() {
|
||||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||||
return Server.Default().app
|
return Server.Default().app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serverUrl() {
|
||||||
|
return HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir)
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
|
||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
@@ -85,4 +128,48 @@ describe("pty HttpApi bridge", () => {
|
|||||||
})
|
})
|
||||||
expect(response.status).toBe(404)
|
expect(response.status).toBe(404)
|
||||||
})
|
})
|
||||||
|
;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)(
|
||||||
|
"serves PTY websocket output and input through Effect routes",
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } })
|
||||||
|
const created = yield* HttpClientRequest.post(PtyPaths.create).pipe(
|
||||||
|
directoryHeader(dir),
|
||||||
|
HttpClientRequest.bodyJson({ command: "/bin/cat", title: "websocket" }),
|
||||||
|
Effect.flatMap(HttpClient.execute),
|
||||||
|
)
|
||||||
|
expect(created.status).toBe(200)
|
||||||
|
const info = yield* Schema.decodeUnknownEffect(Pty.Info)(yield* created.json)
|
||||||
|
|
||||||
|
const socket = yield* Socket.makeWebSocket(
|
||||||
|
`${(yield* serverUrl()).replace(/^http/, "ws")}${PtyPaths.connect.replace(":ptyID", info.id)}?cursor=-1&directory=${encodeURIComponent(dir)}`,
|
||||||
|
{ closeCodeIsError: () => false },
|
||||||
|
)
|
||||||
|
const messages = yield* Queue.unbounded<string>()
|
||||||
|
yield* socket
|
||||||
|
.runRaw((message) =>
|
||||||
|
Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)),
|
||||||
|
)
|
||||||
|
.pipe(Effect.catch(() => Effect.void))
|
||||||
|
.pipe(Effect.forkScoped)
|
||||||
|
const write = yield* socket.writer
|
||||||
|
|
||||||
|
const takeUntil = (expected: string, seen = ""): Effect.Effect<string, unknown> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds")))
|
||||||
|
if (next.includes(expected)) return next
|
||||||
|
return yield* takeUntil(expected, next)
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* write("ping-route\n")
|
||||||
|
expect(yield* takeUntil("ping-route")).toContain("ping-route")
|
||||||
|
yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void))
|
||||||
|
|
||||||
|
const removed = yield* HttpClientRequest.delete(PtyPaths.remove.replace(":ptyID", info.id)).pipe(
|
||||||
|
directoryHeader(dir),
|
||||||
|
HttpClient.execute,
|
||||||
|
)
|
||||||
|
expect(removed.status).toBe(200)
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user