refactor(cli): define server connection boundary (#37133)

This commit is contained in:
Kit Langton 2026-07-15 11:48:38 -04:00 committed by GitHub
commit f5dd181443
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 250 additions and 121 deletions

View file

@ -3,7 +3,7 @@ import { Effect, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Service } from "@opencode-ai/client/effect"
import { Server } from "../../services/server"
import { ServerConnection } from "../../services/server-connection"
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
@ -18,7 +18,7 @@ type OpenApi = {
export default Runtime.handler(
Commands.commands.api,
Effect.fn("cli.api")(function* (input) {
const server = yield* Server.resolve({
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
mismatch: "ignore",
@ -62,11 +62,7 @@ export function rawRequest(input: readonly string[]) {
return { method: input[0].toUpperCase(), path: input[1] }
}
function resolveRequest(
endpoint: Service.Endpoint,
input: readonly string[],
params: Record<string, string>,
) {
function resolveRequest(endpoint: Service.Endpoint, input: readonly string[], params: Record<string, string>) {
const raw = rawRequest(input)
if (raw) return Effect.succeed(raw)
if (input.length !== 1) return Effect.fail(new Error("Expected an operation name or an HTTP method and path"))

View file

@ -4,8 +4,8 @@ import { run } from "@opencode-ai/tui"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Config } from "../../config"
import { Effect, Option } from "effect"
import { Server } from "../../services/server"
import { Context, Effect, FileSystem, Option } from "effect"
import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode-ai/core/npm"
@ -18,7 +18,7 @@ export default Runtime.handler(Commands, (input) =>
yield* updater.check().pipe(Effect.forkScoped)
const preflight = UpdatePreflight.make()
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
const server = yield* Server.resolve({
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
onStart: (reason, existing) => {
@ -37,11 +37,22 @@ export default Runtime.handler(Commands, (input) =>
preflight.loading()
const config = yield* Config.Service
const npm = yield* Npm.Service
const context = yield* Effect.context()
const fileSystem = yield* FileSystem.FileSystem
const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem))
const context = yield* Effect.context<FileSystem.FileSystem>()
const runFork = Effect.runForkWith(context)
const runPromise = Effect.runPromiseWith(context)
const service = server.service
yield* run({
server,
server: {
endpoint: server.endpoint,
service: service
? {
reconnect: (onStatus, signal) => runServicePromise(service.reconnect(onStatus), { signal }),
restart: () => runServicePromise(service.restart()),
}
: undefined,
},
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
config: {
path: config.path,

View file

@ -1,14 +1,14 @@
import { Effect, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Server } from "../../services/server"
import { ServerConnection } from "../../services/server-connection"
export default Runtime.handler(Commands.commands.mini, (input) =>
Effect.gen(function* () {
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
yield* Effect.promise(async () => validateMiniTerminal())
const serverURL = Option.getOrUndefined(input.server)
const server = yield* Server.resolve({ server: serverURL, standalone: input.standalone })
const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })
yield* Effect.promise(() =>
runMini({
server,

View file

@ -1,13 +1,13 @@
import { Effect, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Server } from "../../services/server"
import { ServerConnection } from "../../services/server-connection"
export default Runtime.handler(Commands.commands.run, (input) =>
Effect.gen(function* () {
const { runNonInteractive } = yield* Effect.promise(() => import("../../mini"))
const separator = process.argv.indexOf("--", 2)
const server = yield* Server.resolve({
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})

View file

@ -1,12 +1,12 @@
import { Service } from "@opencode-ai/client/effect"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { Server } from "../services/server"
import { ServerConnection } from "../services/server-connection"
import { waitForCatalogReady } from "./catalog.shared"
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
import type { RunInput, RunTuiConfig } from "./types"
export type MiniCommandInput = {
server: Server.Resolved
server: ServerConnection.Resolved
continue?: boolean
session?: string
fork?: boolean
@ -38,10 +38,7 @@ export async function runMini(input: MiniCommandInput) {
return agentTask
}
const resolveSession = async () => {
const [agent, selected] = await Promise.all([
resolveAgent(),
selectSession(sdk, directory, input),
])
const [agent, selected] = await Promise.all([resolveAgent(), selectSession(sdk, directory, input)])
const readyModel =
model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined)
if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel })

View file

@ -4,7 +4,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { Model } from "@opencode-ai/schema/model"
import { open } from "node:fs/promises"
import path from "node:path"
import { Server } from "../services/server"
import { ServerConnection } from "../services/server-connection"
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
import { runNonInteractivePrompt } from "./noninteractive"
import { toolInlineInfo } from "./tool"
@ -12,7 +12,7 @@ import type { MiniToolPart } from "./types"
import { UI } from "./ui"
export type RunCommandInput = {
server: Server.Resolved
server: ServerConnection.Resolved
message: string[]
continue?: boolean
session?: string
@ -73,8 +73,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Ser
.then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined))
: undefined
const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel)
if (variant && !model)
return reportError(input, "Cannot select a variant before selecting a model", session?.id)
if (variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id)
if (model) {
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
const available = await client.model.list({ location: { directory: cwd, workspace } })

View file

@ -1,4 +1,3 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Service } from "@opencode-ai/client/effect"
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
@ -16,11 +15,10 @@ export type Args = {
export type Resolved = {
readonly endpoint: Service.Endpoint
readonly reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<Service.Endpoint>
readonly reload?: () => Promise<void>
readonly service?: ReturnType<typeof managedService>
}
export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) {
export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args) {
if (args.server !== undefined && args.standalone)
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
if (args.server !== undefined) {
@ -45,24 +43,24 @@ export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) {
}
const options = yield* ServiceConfig.options()
const endpoint = yield* resolveManaged({ ...options, onStart: args.onStart }, args.mismatch ?? "replace")
const reconnectOptions = { ...options, version: undefined }
return {
endpoint,
reconnect: (onStatus, signal) =>
Effect.runPromise(Service.start({ ...reconnectOptions, onStatus }).pipe(Effect.provide(NodeFileSystem.layer)), {
signal,
}),
reload: () =>
Effect.runPromise(
Effect.gen(function* () {
yield* Service.stop(options, { targetVersion: options.version })
yield* Service.start(options)
}).pipe(Effect.provide(NodeFileSystem.layer)),
),
endpoint: yield* resolveManaged({ ...options, onStart: args.onStart }, args.mismatch ?? "replace"),
service: managedService(options),
} satisfies Resolved
})
function managedService(options: Service.StartOptions) {
const reconnectOptions = { ...options, version: undefined }
return {
reconnect: (onStatus: (status: Service.Status) => void) => Service.start({ ...reconnectOptions, onStatus }),
restart: () =>
Effect.gen(function* () {
yield* Service.stop(options, { targetVersion: options.version })
yield* Service.start(options)
}),
}
}
const resolveManaged = Effect.fnUntraced(function* (
options: Service.StartOptions,
mismatch: NonNullable<Args["mismatch"]>,
@ -92,4 +90,4 @@ function connectError(endpoint: Service.Endpoint, cause: unknown) {
return new Error(`Server at ${endpoint.url} did not provide a compatible V2 health response`, { cause })
}
export * as Server from "./server"
export * as ServerConnection from "./server-connection"