From cc686ab8f6f0ec75e19b6fc24b1bc6a5a9660d08 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 9 Jul 2026 13:27:41 -0400 Subject: [PATCH] fix(cli): unify server resolution --- packages/cli/src/commands/commands.ts | 1 + packages/cli/src/commands/handlers/api.ts | 11 +++++--- packages/cli/src/services/server.ts | 29 ++++++++++++++++++-- packages/tui/src/app.tsx | 12 ++++---- packages/tui/src/context/sdk.tsx | 8 +++--- packages/tui/test/cli/tui/use-event.test.tsx | 26 ++++++++++-------- 6 files changed, 59 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 5038d6d403..a80c5ba108 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -37,6 +37,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO Spec.make("api", { description: "Make a request to the running server", params: { + ...ServerParams, request: Argument.string("operation | method path").pipe( Argument.withDescription("OpenAPI operation ID, or an HTTP method followed by a path"), Argument.variadic({ min: 1, max: 2 }), diff --git a/packages/cli/src/commands/handlers/api.ts b/packages/cli/src/commands/handlers/api.ts index 62c8c44d59..49e367d48e 100644 --- a/packages/cli/src/commands/handlers/api.ts +++ b/packages/cli/src/commands/handlers/api.ts @@ -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 { ServiceConfig } from "../../services/service-config" +import { Server } from "../../services/server" const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"]) @@ -18,9 +18,12 @@ type OpenApi = { export default Runtime.handler( Commands.commands.api, Effect.fn("cli.api")(function* (input) { - const options = yield* ServiceConfig.options() - const found = yield* Service.discover(options) - const endpoint = found ?? (yield* Service.start(options)) + const server = yield* Server.resolve({ + server: Option.getOrUndefined(input.server), + standalone: input.standalone, + mismatch: "ignore", + }) + const endpoint = server.endpoint const params = Option.getOrElse(input.param, () => ({})) const request = yield* resolveRequest(endpoint, input.request, params) const headers = new Headers(Service.headers(endpoint)) diff --git a/packages/cli/src/services/server.ts b/packages/cli/src/services/server.ts index 6423d4708a..5ea8434792 100644 --- a/packages/cli/src/services/server.ts +++ b/packages/cli/src/services/server.ts @@ -10,11 +10,12 @@ import { Standalone } from "./standalone" export type Args = { readonly server?: string readonly standalone?: boolean + readonly mismatch?: "replace" | "ignore" | "error" } export type Resolved = { readonly endpoint: Service.Endpoint - readonly discover?: () => Promise + readonly reconnect?: (attempt: number) => Promise readonly reload?: () => Promise } @@ -45,11 +46,19 @@ export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) { } const options = yield* ServiceConfig.options() - const endpoint = yield* Service.start(options) + const endpoint = yield* resolveManaged(options, args.mismatch ?? "replace") const reconnectOptions = { ...options, version: undefined } return { 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: () => Effect.runPromise( Effect.gen(function* () { @@ -60,6 +69,20 @@ export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) { } satisfies Resolved }) +const resolveManaged = Effect.fnUntraced(function* ( + options: Service.Options, + mismatch: NonNullable, +) { + 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) { if (isUnauthorizedError(cause)) { return new Error( diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 9c0ee7c118..40303f30bb 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -147,7 +147,7 @@ const appBindingCommands = [ export type TuiInput = { server: { endpoint: Service.Endpoint - discover?: () => Promise + reconnect?: (attempt: number) => Promise reload?: () => Promise } 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)), ), ) - const discover = input.server.discover - const reconnect = discover - ? async () => { - const endpoint = await discover() + const reconnectEndpoint = input.server.reconnect + const reconnect = reconnectEndpoint + ? async (attempt: number) => { + const endpoint = await reconnectEndpoint(attempt) const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) } return { client: createOpencodeClient({ ...next, directory }), @@ -339,7 +339,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index d25b9683fc..1e0afb0b61 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -26,7 +26,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ init: (props: { client: 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. reload?: () => Promise }) => { @@ -122,8 +122,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ // Re-resolve the transport before retrying: the server may have // moved (service restarted on a new port) or need starting. Static // transports (--server, standalone) resolve to the same address. - if (props.discover) { - const next = await props.discover().catch(() => undefined) + if (props.reconnect) { + const next = await props.reconnect(attempt).catch(() => undefined) if (abort.signal.aborted || controller.signal.aborted) return if (next) { client = next.client @@ -135,7 +135,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ attempt, error: message, }) - await wait(250, controller.signal) + await wait(1_000, controller.signal) } })() return ready diff --git a/packages/tui/test/cli/tui/use-event.test.tsx b/packages/tui/test/cli/tui/use-event.test.tsx index 1d03da2279..e401d6da67 100644 --- a/packages/tui/test/cli/tui/use-event.test.tsx +++ b/packages/tui/test/cli/tui/use-event.test.tsx @@ -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 calls = createFetch(undefined, events) const seen: V2Event[] = [] @@ -64,7 +67,7 @@ async function mount(discover?: () => Promise<{ client: OpencodeClient; api: Ope const app = await testRender(() => ( - + { @@ -183,27 +186,28 @@ describe("useEvent", () => { } }) - test("rediscovers the server after the event stream drops", async () => { - let calls = 0 + test("reconnects to the server after the event stream drops", async () => { + const attempts: number[] = [] const replacementEvents = createEventStream() const replacementCalls = createFetch(undefined, replacementEvents) const replacement = { client: createClient(replacementCalls.fetch), api: createApi(replacementCalls.fetch) } - const { app, events, sdk, seen } = await mount(async () => { - calls += 1 + const { app, events, sdk, seen } = await mount(async (attempt) => { + attempts.push(attempt) return replacement }) try { await wait(() => sdk.connection.status() === "connected") - // Discovery only runs when the stream is down, never while connected. - expect(calls).toBe(0) + // Reconnection only runs when the stream is down, never while connected. + expect(attempts).toEqual([]) 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" })) await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "rediscovered")) expect(sdk.client).toBe(replacement.client) expect(sdk.api).toBe(replacement.api) + expect(attempts).toEqual([1]) const history = sdk.connection.internal.history() expect(history.map((event) => [event.data.status, event.data.attempt])).toEqual([ ["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 const { app, events, sdk, seen } = await mount(async () => { calls += 1 @@ -229,7 +233,7 @@ describe("useEvent", () => { await wait(() => sdk.connection.status() === "connected") const original = sdk.client 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. await wait(() => calls > 0 && sdk.connection.status() === "connected") events.emit(event(vcs("recovered"), { directory: "/tmp/recovered" }))