fix(cli): unify server resolution

This commit is contained in:
Dax Raad
2026-07-09 13:28:01 -04:00
parent 6524dfc818
commit cc686ab8f6
6 changed files with 59 additions and 28 deletions
+1
View File
@@ -37,6 +37,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
Spec.make("api", { Spec.make("api", {
description: "Make a request to the running server", description: "Make a request to the running server",
params: { params: {
...ServerParams,
request: Argument.string("operation | method path").pipe( request: Argument.string("operation | method path").pipe(
Argument.withDescription("OpenAPI operation ID, or an HTTP method followed by a path"), Argument.withDescription("OpenAPI operation ID, or an HTTP method followed by a path"),
Argument.variadic({ min: 1, max: 2 }), Argument.variadic({ min: 1, max: 2 }),
+7 -4
View File
@@ -3,7 +3,7 @@ import { Effect, Option } from "effect"
import { Commands } from "../commands" import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect"
import { ServiceConfig } from "../../services/service-config" import { Server } from "../../services/server"
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"]) const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
@@ -18,9 +18,12 @@ type OpenApi = {
export default Runtime.handler( export default Runtime.handler(
Commands.commands.api, Commands.commands.api,
Effect.fn("cli.api")(function* (input) { Effect.fn("cli.api")(function* (input) {
const options = yield* ServiceConfig.options() const server = yield* Server.resolve({
const found = yield* Service.discover(options) server: Option.getOrUndefined(input.server),
const endpoint = found ?? (yield* Service.start(options)) standalone: input.standalone,
mismatch: "ignore",
})
const endpoint = server.endpoint
const params = Option.getOrElse(input.param, () => ({})) const params = Option.getOrElse(input.param, () => ({}))
const request = yield* resolveRequest(endpoint, input.request, params) const request = yield* resolveRequest(endpoint, input.request, params)
const headers = new Headers(Service.headers(endpoint)) const headers = new Headers(Service.headers(endpoint))
+26 -3
View File
@@ -10,11 +10,12 @@ import { Standalone } from "./standalone"
export type Args = { export type Args = {
readonly server?: string readonly server?: string
readonly standalone?: boolean readonly standalone?: boolean
readonly mismatch?: "replace" | "ignore" | "error"
} }
export type Resolved = { export type Resolved = {
readonly endpoint: Service.Endpoint readonly endpoint: Service.Endpoint
readonly discover?: () => Promise<Service.Endpoint> readonly reconnect?: (attempt: number) => Promise<Service.Endpoint>
readonly reload?: () => Promise<void> readonly reload?: () => Promise<void>
} }
@@ -45,11 +46,19 @@ export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) {
} }
const options = yield* ServiceConfig.options() const options = yield* ServiceConfig.options()
const endpoint = yield* Service.start(options) const endpoint = yield* resolveManaged(options, args.mismatch ?? "replace")
const reconnectOptions = { ...options, version: undefined } const reconnectOptions = { ...options, version: undefined }
return { return {
endpoint, endpoint,
discover: () => Effect.runPromise(Service.start(reconnectOptions).pipe(Effect.provide(NodeFileSystem.layer))), reconnect: (attempt) =>
Effect.runPromise(
Effect.gen(function* () {
if (attempt > 3) return yield* Service.start(reconnectOptions)
const endpoint = yield* Service.discover(reconnectOptions)
if (endpoint !== undefined) return endpoint
return yield* Effect.fail(new Error("Background server is unavailable"))
}).pipe(Effect.provide(NodeFileSystem.layer)),
),
reload: () => reload: () =>
Effect.runPromise( Effect.runPromise(
Effect.gen(function* () { Effect.gen(function* () {
@@ -60,6 +69,20 @@ export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) {
} satisfies Resolved } satisfies Resolved
}) })
const resolveManaged = Effect.fnUntraced(function* (
options: Service.Options,
mismatch: NonNullable<Args["mismatch"]>,
) {
if (mismatch === "replace") return yield* Service.start(options)
if (mismatch === "ignore") return yield* Service.start({ ...options, version: undefined })
const compatible = yield* Service.discover(options)
if (compatible !== undefined) return compatible
const existing = yield* Service.discover({ ...options, version: undefined })
if (existing !== undefined) return yield* Effect.fail(new Error("Background server version does not match this client"))
return yield* Service.start(options)
})
function connectError(endpoint: Service.Endpoint, cause: unknown) { function connectError(endpoint: Service.Endpoint, cause: unknown) {
if (isUnauthorizedError(cause)) { if (isUnauthorizedError(cause)) {
return new Error( return new Error(
+6 -6
View File
@@ -147,7 +147,7 @@ const appBindingCommands = [
export type TuiInput = { export type TuiInput = {
server: { server: {
endpoint: Service.Endpoint endpoint: Service.Endpoint
discover?: () => Promise<Service.Endpoint> reconnect?: (attempt: number) => Promise<Service.Endpoint>
reload?: () => Promise<void> reload?: () => Promise<void>
} }
args: Args args: Args
@@ -200,10 +200,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory)), Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory)),
), ),
) )
const discover = input.server.discover const reconnectEndpoint = input.server.reconnect
const reconnect = discover const reconnect = reconnectEndpoint
? async () => { ? async (attempt: number) => {
const endpoint = await discover() const endpoint = await reconnectEndpoint(attempt)
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) } const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
return { return {
client: createOpencodeClient({ ...next, directory }), client: createOpencodeClient({ ...next, directory }),
@@ -339,7 +339,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<SDKProvider <SDKProvider
client={createOpencodeClient({ ...options, directory })} client={createOpencodeClient({ ...options, directory })}
api={api} api={api}
discover={reconnect} reconnect={reconnect}
reload={input.server.reload} reload={input.server.reload}
> >
<PermissionProvider> <PermissionProvider>
+4 -4
View File
@@ -26,7 +26,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
init: (props: { init: (props: {
client: OpencodeClient client: OpencodeClient
api: OpenCodeClient api: OpenCodeClient
discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }> reconnect?: (attempt: number) => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
// Stops and starts the managed service; present only in service mode. // Stops and starts the managed service; present only in service mode.
reload?: () => Promise<void> reload?: () => Promise<void>
}) => { }) => {
@@ -122,8 +122,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
// Re-resolve the transport before retrying: the server may have // Re-resolve the transport before retrying: the server may have
// moved (service restarted on a new port) or need starting. Static // moved (service restarted on a new port) or need starting. Static
// transports (--server, standalone) resolve to the same address. // transports (--server, standalone) resolve to the same address.
if (props.discover) { if (props.reconnect) {
const next = await props.discover().catch(() => undefined) const next = await props.reconnect(attempt).catch(() => undefined)
if (abort.signal.aborted || controller.signal.aborted) return if (abort.signal.aborted || controller.signal.aborted) return
if (next) { if (next) {
client = next.client client = next.client
@@ -135,7 +135,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
attempt, attempt,
error: message, error: message,
}) })
await wait(250, controller.signal) await wait(1_000, controller.signal)
} }
})() })()
return ready return ready
+15 -11
View File
@@ -50,7 +50,10 @@ function update(version: string): V2Event {
} }
} }
async function mount(discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>, log?: LogSink) { async function mount(
reconnect?: (attempt: number) => Promise<{ client: OpencodeClient; api: OpenCodeClient }>,
log?: LogSink,
) {
const events = createEventStream() const events = createEventStream()
const calls = createFetch(undefined, events) const calls = createFetch(undefined, events)
const seen: V2Event[] = [] const seen: V2Event[] = []
@@ -64,7 +67,7 @@ async function mount(discover?: () => Promise<{ client: OpencodeClient; api: Ope
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts log={log}> <TestTuiContexts log={log}>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)} discover={discover}> <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)} reconnect={reconnect}>
<ProjectProvider> <ProjectProvider>
<Probe <Probe
onReady={async (ctx) => { onReady={async (ctx) => {
@@ -183,27 +186,28 @@ describe("useEvent", () => {
} }
}) })
test("rediscovers the server after the event stream drops", async () => { test("reconnects to the server after the event stream drops", async () => {
let calls = 0 const attempts: number[] = []
const replacementEvents = createEventStream() const replacementEvents = createEventStream()
const replacementCalls = createFetch(undefined, replacementEvents) const replacementCalls = createFetch(undefined, replacementEvents)
const replacement = { client: createClient(replacementCalls.fetch), api: createApi(replacementCalls.fetch) } const replacement = { client: createClient(replacementCalls.fetch), api: createApi(replacementCalls.fetch) }
const { app, events, sdk, seen } = await mount(async () => { const { app, events, sdk, seen } = await mount(async (attempt) => {
calls += 1 attempts.push(attempt)
return replacement return replacement
}) })
try { try {
await wait(() => sdk.connection.status() === "connected") await wait(() => sdk.connection.status() === "connected")
// Discovery only runs when the stream is down, never while connected. // Reconnection only runs when the stream is down, never while connected.
expect(calls).toBe(0) expect(attempts).toEqual([])
events.disconnect() events.disconnect()
await wait(() => sdk.connection.status() === "connected" && calls > 0) await wait(() => sdk.connection.status() === "connected" && attempts.length > 0)
replacementEvents.emit(event(vcs("rediscovered"), { directory: "/tmp/rediscovered" })) replacementEvents.emit(event(vcs("rediscovered"), { directory: "/tmp/rediscovered" }))
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "rediscovered")) await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "rediscovered"))
expect(sdk.client).toBe(replacement.client) expect(sdk.client).toBe(replacement.client)
expect(sdk.api).toBe(replacement.api) expect(sdk.api).toBe(replacement.api)
expect(attempts).toEqual([1])
const history = sdk.connection.internal.history() const history = sdk.connection.internal.history()
expect(history.map((event) => [event.data.status, event.data.attempt])).toEqual([ expect(history.map((event) => [event.data.status, event.data.attempt])).toEqual([
["connecting", 0], ["connecting", 0],
@@ -218,7 +222,7 @@ describe("useEvent", () => {
} }
}) })
test("keeps the current client when discovery fails", async () => { test("keeps the current client when reconnection fails", async () => {
let calls = 0 let calls = 0
const { app, events, sdk, seen } = await mount(async () => { const { app, events, sdk, seen } = await mount(async () => {
calls += 1 calls += 1
@@ -229,7 +233,7 @@ describe("useEvent", () => {
await wait(() => sdk.connection.status() === "connected") await wait(() => sdk.connection.status() === "connected")
const original = sdk.client const original = sdk.client
events.disconnect() events.disconnect()
// Discovery rejects; the loop retries against the last known transport, // Reconnection rejects; the loop retries against the last known transport,
// which succeeds once the fixture accepts the reconnect. // which succeeds once the fixture accepts the reconnect.
await wait(() => calls > 0 && sdk.connection.status() === "connected") await wait(() => calls > 0 && sdk.connection.status() === "connected")
events.emit(event(vcs("recovered"), { directory: "/tmp/recovered" })) events.emit(event(vcs("recovered"), { directory: "/tmp/recovered" }))