refactor(server): canonicalize service API (#31049)
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
import { cmd } from "@/cli/cmd/cmd"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { type rpc } from "../tui/worker"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { UI } from "@/cli/ui"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { errorMessage } from "@opencode-ai/tui/util/error"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import type { EventSource } from "@opencode-ai/tui/context/sdk"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "../tui/win32"
|
||||
import { writeHeapSnapshot } from "v8"
|
||||
import {
|
||||
OPENCODE_PROCESS_ROLE,
|
||||
OPENCODE_RUN_ID,
|
||||
ensureRunID,
|
||||
sanitizedProcessEnv,
|
||||
} from "@opencode-ai/core/util/opencode-process"
|
||||
import { validateSession } from "../tui/validate-session"
|
||||
import { resolveTuiRuntime } from "../tui/runtime"
|
||||
|
||||
declare global {
|
||||
const OPENCODE_WORKER_PATH: string
|
||||
}
|
||||
|
||||
type RpcClient = ReturnType<typeof Rpc.client<typeof rpc>>
|
||||
|
||||
function createWorkerFetch(client: RpcClient): typeof fetch {
|
||||
const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const request = new Request(input, init)
|
||||
const body = request.body ? await request.text() : undefined
|
||||
const result = await client.call("fetch", {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
body,
|
||||
})
|
||||
return new Response(result.body, {
|
||||
status: result.status,
|
||||
headers: result.headers,
|
||||
})
|
||||
}
|
||||
return fn as typeof fetch
|
||||
}
|
||||
|
||||
function createEventSource(client: RpcClient): EventSource {
|
||||
return {
|
||||
subscribe: async (handler) => {
|
||||
return client.on<GlobalEvent>("global.event", (e) => {
|
||||
handler(e)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function target() {
|
||||
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
|
||||
const dist = new URL("./cli/tui/worker.js", import.meta.url)
|
||||
if (await Filesystem.exists(fileURLToPath(dist))) return dist
|
||||
return new URL("../tui/worker.ts", import.meta.url)
|
||||
}
|
||||
|
||||
async function input(value?: string) {
|
||||
const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text()
|
||||
if (!value) return piped
|
||||
if (!piped) return value
|
||||
return piped + "\n" + value
|
||||
}
|
||||
|
||||
export function resolveThreadDirectory(project?: string, envPWD = process.env.PWD, cwd = process.cwd()) {
|
||||
const root = Filesystem.resolve(envPWD ?? cwd)
|
||||
if (project) return Filesystem.resolve(path.isAbsolute(project) ? project : path.join(root, project))
|
||||
return Filesystem.resolve(cwd)
|
||||
}
|
||||
|
||||
export const TuiThreadCommand = cmd({
|
||||
command: "$0 [project]",
|
||||
describe: "start opencode tui",
|
||||
builder: (yargs) =>
|
||||
withNetworkOptions(yargs)
|
||||
.positional("project", {
|
||||
type: "string",
|
||||
describe: "path to start opencode in",
|
||||
})
|
||||
.option("model", {
|
||||
type: "string",
|
||||
alias: ["m"],
|
||||
describe: "model to use in the format of provider/model",
|
||||
})
|
||||
.option("continue", {
|
||||
alias: ["c"],
|
||||
describe: "continue the last session",
|
||||
type: "boolean",
|
||||
})
|
||||
.option("session", {
|
||||
alias: ["s"],
|
||||
type: "string",
|
||||
describe: "session id to continue",
|
||||
})
|
||||
.option("fork", {
|
||||
type: "boolean",
|
||||
describe: "fork the session when continuing (use with --continue or --session)",
|
||||
})
|
||||
.option("prompt", {
|
||||
type: "string",
|
||||
describe: "prompt to use",
|
||||
})
|
||||
.option("agent", {
|
||||
type: "string",
|
||||
describe: "agent to use",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const { TuiConfig } = await import("@/config/tui")
|
||||
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
|
||||
// (Important when running under `bun run` wrappers on Windows.)
|
||||
const unguard = win32InstallCtrlCGuard()
|
||||
try {
|
||||
// Must be the very first thing — disables CTRL_C_EVENT before any Worker
|
||||
// spawn or async work so the OS cannot kill the process group.
|
||||
win32DisableProcessedInput()
|
||||
|
||||
if (args.fork && !args.continue && !args.session) {
|
||||
UI.error("--fork requires --continue or --session")
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve relative --project paths from PWD, then use the real cwd after
|
||||
// chdir so the thread and worker share the same directory key.
|
||||
const next = resolveThreadDirectory(args.project)
|
||||
const file = await target()
|
||||
try {
|
||||
process.chdir(next)
|
||||
} catch {
|
||||
UI.error("Failed to change directory to " + next)
|
||||
return
|
||||
}
|
||||
const cwd = Filesystem.resolve(process.cwd())
|
||||
const env = sanitizedProcessEnv({
|
||||
[OPENCODE_PROCESS_ROLE]: "worker",
|
||||
[OPENCODE_RUN_ID]: ensureRunID(),
|
||||
})
|
||||
|
||||
const worker = new Worker(file, {
|
||||
env,
|
||||
})
|
||||
worker.onerror = (e) => {
|
||||
Log.Default.error("thread error", {
|
||||
message: e.message,
|
||||
filename: e.filename,
|
||||
lineno: e.lineno,
|
||||
colno: e.colno,
|
||||
error: e.error,
|
||||
})
|
||||
}
|
||||
|
||||
const client = Rpc.client<typeof rpc>(worker)
|
||||
const error = (e: unknown) => {
|
||||
Log.Default.error("process error", { error: errorMessage(e) })
|
||||
}
|
||||
const reload = () => {
|
||||
client.call("reload", undefined).catch((err) => {
|
||||
Log.Default.warn("worker reload failed", {
|
||||
error: errorMessage(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
process.on("uncaughtException", error)
|
||||
process.on("unhandledRejection", error)
|
||||
process.on("SIGUSR2", reload)
|
||||
|
||||
let stopped = false
|
||||
const stop = async () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
process.off("uncaughtException", error)
|
||||
process.off("unhandledRejection", error)
|
||||
process.off("SIGUSR2", reload)
|
||||
await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => {
|
||||
Log.Default.warn("worker shutdown failed", {
|
||||
error: errorMessage(error),
|
||||
})
|
||||
})
|
||||
worker.terminate()
|
||||
}
|
||||
|
||||
const prompt = await input(args.prompt)
|
||||
const config = await TuiConfig.get()
|
||||
const runtime = resolveTuiRuntime(config)
|
||||
|
||||
const network = resolveNetworkOptionsNoConfig(args)
|
||||
const external =
|
||||
process.argv.includes("--port") ||
|
||||
process.argv.includes("--hostname") ||
|
||||
process.argv.includes("--mdns") ||
|
||||
network.mdns ||
|
||||
network.port !== 0 ||
|
||||
network.hostname !== "127.0.0.1"
|
||||
|
||||
const transport = external
|
||||
? {
|
||||
url: (await client.call("server", network)).url,
|
||||
fetch: undefined,
|
||||
events: undefined,
|
||||
}
|
||||
: {
|
||||
url: "http://opencode.internal",
|
||||
fetch: createWorkerFetch(client),
|
||||
events: createEventSource(client),
|
||||
}
|
||||
|
||||
try {
|
||||
await validateSession({
|
||||
url: transport.url,
|
||||
sessionID: args.session,
|
||||
directory: cwd,
|
||||
fetch: transport.fetch,
|
||||
})
|
||||
} catch (error) {
|
||||
UI.error(errorMessage(error))
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
|
||||
}, 1000).unref?.()
|
||||
|
||||
try {
|
||||
const { createTuiRenderer, tui } = await import("@opencode-ai/tui")
|
||||
const { createLegacyTuiHost } = await import("../tui/host")
|
||||
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
|
||||
const renderer = await createTuiRenderer(config, runtime)
|
||||
const handle = tui({
|
||||
...runtime,
|
||||
url: transport.url,
|
||||
renderer,
|
||||
async onSnapshot() {
|
||||
const tui = writeHeapSnapshot("tui.heapsnapshot")
|
||||
const server = await client.call("snapshot", undefined)
|
||||
return [tui, server]
|
||||
},
|
||||
config,
|
||||
host: createLegacyTuiHost(renderer),
|
||||
pluginHost: createLegacyTuiPluginHost(),
|
||||
directory: cwd,
|
||||
fetch: transport.fetch,
|
||||
events: transport.events,
|
||||
args: {
|
||||
continue: args.continue,
|
||||
sessionID: args.session,
|
||||
agent: args.agent,
|
||||
model: args.model,
|
||||
prompt,
|
||||
fork: args.fork,
|
||||
},
|
||||
})
|
||||
await handle.done
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
} finally {
|
||||
unguard?.()
|
||||
}
|
||||
process.exit(0)
|
||||
},
|
||||
})
|
||||
// scratch
|
||||
Reference in New Issue
Block a user